やっぱり英語を攻略するのはフレーズ単位の口頭戦術だ

日本人は誰だって超苦手な英語や英会話。まして受験勉強で英語となるとどこから始めたらいいのかわからない、そんなあなたに朗報です。確実な王道が存在することがわかりました。それが日本語⇒英語の短文を繰り返し口頭で言い続けることです。普通の英語勉強法と違うのは、紙に何回も書くというライティングや英語から日本語というReadingを最初にしないことです。

語学においてはアウトプット練習が一番大事で、子供が母国語を覚える時に書いて覚えたりしないのと同じで、不慣れな外国語は簡単なことを口頭で言えるようにするのが一番先にくるものなんです。それでなんとなくその言語特有の言い回しが口ぐせになってきたら、そこから本格的にちょっと難しいことを覚えたり、読み書きもすればいいという順番になります。

もう一つ大事なことはその英文をネイティブはどのように発音するのかを知ることです。それはアの母音の発音が5種類あるのを発音し分けるとかのミクロな話ではなくて、イントネーションとかリズムがどうかということです。もうちょっと具体的にいうと、どこを強調して、どこをモゴモゴっと曖昧にするのかというメリハリです。それは英文の字を眺めても絶対わかりません。ネイティブが話すのを聞いて真似するのが一番です。でもそんな知り合いいないというあなた!今ならその相手はAIでしょ、という訳です。

英語のAIの発音モデルはもうほぼ完璧に自然な話し方をします。なんなら米国風にとか英国風とかアジア風にとかまでも変えられます。まぁAIが今そんなに賢いとわかったとして、どうやってしゃべらせるのかが問題です。そもそも何をしゃべらせるのかももっと問題です。覚えるに値する例文でなければ全然意味がないですから。そこの目星はもうついています。それは私が時々紹介している「話すための瞬間英作文」です。

そしてその覚え方はGASのWebアプリを使ってになります。GASってご存じでしょうか? Google Apps Scriptの略でExcelVBAのGoogle版といった感じのスクリプト言語です。GASの世界ではGUIはHTMLで、処理部分はJavaScriptを使います。「プログラミング言語も覚えなきゃじゃいつまでたっても英語の勉強にならないじゃん!」とお怒りの皆さんのために、ここは奮発してGASの開発済スクリプトを公開しちゃいます。もうそのままで今日からでもすぐ勉強に使えます。

まずはコード.gsというJavaScript部分を公開します。このコードをどこからどう入力したらいいのかは私が別途公開している説明ドキュメントを事前に見て、GASでコード.gsとかindex.htmlはどこから書いてどうやって実行して動かすのかを理解してください。

まずは次のコード.gsをスクリプトエディタから入力してください。

const SHEET_NAME = 'シート1';
// アプリメニューから起動した時の開始ポイント
function onOpen() {
  SpreadsheetApp.getUi()
      .createMenu('★暗記アプリ')
      .addItem('アプリを起動', 'showApp')
      .addToUi();
}
// アプリ版で呼ばれるHTML表示関数
function showApp() {
  const html = HtmlService.createHtmlOutputFromFile('index')
                 .setTitle('スマホ用暗記アプリ')
                 .setWidth(400)
                 .setHeight(650);
  SpreadsheetApp.getUi().showModalDialog(html, ' ');
}
// Web版アプリとしてアクセスした時の開始ポイント
function doGet() {
  // HtmlServiceを使用してHTMLファイルを読み込み、オブジェクトを作成
  const htmlOutput = HtmlService.createHtmlOutputFromFile('index')
    .setTitle('スマホ用暗記アプリ')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1'); // スマホ対応に必須
  
  // 最後に必ず return する必要があります
  return htmlOutput;
}
// 全データを取り出す関数
function getAllData(targetCourse) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) return [];
  const values = sheet.getDataRange().getValues();
  const filteredRows = values.filter(row => {
    if (targetCourse === "all") return true; // 全件の場合
    return String(row[0]) === targetCourse;  // A列(index 0)が一致する場合のみ
  });
  return filteredRows.map(row => {
    return {
      course:    String(row[0] || "未設定"),
      chap_no:   row[1] !== "" ? Number(row[1]) : null,
      q_no:      row[2] !== "" ? Number(row[2]) : null,
      question:  String(row[3] || ""),
      answer:    getCellDataInternal(row[4]),
      selection: getCellDataInternal(row[5])
    };
  });
}
// A列から重複のないコース名のリストを取得
function getCourseList() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("シート1") || ss.getSheets()[0];
  const values = sheet.getRange("A2:A" + sheet.getLastRow()).getValues();
  
  // フラットな配列に変換して重複を排除
  const courseNames = values.map(row => String(row[0])).filter(name => name !== "");
  const distinctCourses = [...new Set(courseNames)]; 
  
  return distinctCourses
}
// Answer/Selectionのデータがテキストまたは音声を両方含むことのできるオブジェクトの生成関数
function getCellDataInternal(value) {
  if (value === null || value === undefined || value === "") {
    return { type: 'text', data: '' };
  }
  if (typeof value === 'object' && value.toString() === 'CellImage') {
    try {
      const blob = value.toBuilder().build().getBlob();
      const base64 = Utilities.base64Encode(blob.getBytes());
      return {
        type: 'image',
        data: 'data:' + blob.getContentType() + ';base64,' + base64
      };
    } catch (e) {
      try {
        return { type: 'image_url', data: value.getContentUrl() };
      } catch (err) {
        return { type: 'text', data: '取得失敗:' + e.message };
      }
    }
  }
  return { type: 'text', data: String(value) };
}


次は以下のindex.htmlをスクリプトエディタから入力してください。

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      /* 全体レイアウト */
      html, body { height: 100%; margin: 0; padding: 0; } 
      body { font-family: 'Helvetica Neue', Arial, sans-serif; display: flex; justify-content: center; padding: 20px; background-color: #f0f2f5; color: #333; }
      
      /* メモカード */
      .memocard { background: white; width: 100%; max-width: 360px; border-radius: 12px; box-shadow: 0 8px 20px rgba(0,0,0,0.08); overflow: hidden; }

      /* ヘッダー・メニュー */
      .header { background: #4a90e2; color: white; display: flex; justify-content: space-between; padding: 4px 16px; align-items: center; font-weight: bold; }
      .menu-area { background: #fff8dc; color: black; display: flex; padding: 4px 16px; font-weight: bold; align-items: center; justify-content: space-between; }
      
      /* メニューCSS */
      .menu { background: #fff8dc; display:flex; padding: 0px 16px 8px 16px; align-items: center; justify-content: space-between; gap: 4px; }
      .submenu { display:flex; align-items: center; gap: 2px; }
      
      .nav-btn { cursor: pointer; padding: 8px 16px; user-select: none; border-radius: 8px; transition: background 0.3s; font-size: 1.2rem; }

      /* ボタンCSS */
      .makeq-btn { 
        cursor: pointer; 
        border-radius: 4px; 
        font-size: 10px; 
        background-color: #0bd; 
        color: white; 
        border: none; 
        padding: 7px 4px;      /* 上下を揃える */
        flex: 1; 
        height: 26px;          /* 高さを明示的に固定してinputと合わせる */
        vertical-align: middle; 
        line-height: 1;
      }

      /* 章、節指定用テキストボックスCSS */
      .position { 
        width: 30px; 
        margin-right: 4px; 
        height: 22px;
        vertical-align: middle;
        border: 1px solid #ccc;
        border-radius: 2px;
      }
      .poslabel { font-size: 11px; vertical-align: middle; }

      /* コンテンツエリアCSS */
      .content { padding: 20px; }
      .section { margin-bottom: 20px; }
      .label { font-size: 0.7rem; color: #999; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; font-weight: bold; }
      .text-display { font-size: 1rem; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
      .answer-box { background-color: #f8f9fa; padding: 12px; border-radius: 6px; border-left: 4px solid #4a90e2; }

      /* 画像表示エリアCSS */
      .img-fixed-box {
        width: 100%; height: 200px; border: 1px solid #333;
        background: #eee; display: flex; align-items: center; justify-content: center;
        overflow: hidden; margin: 0 auto; border-radius: 4px;
      }
      .img-fixed-box img { max-width: 100%; max-height: 100%; object-fit: contain; }

      /* アラート表示用CSS */
      #toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 10px 20px; border-radius: 25px; font-size: 0.9rem; opacity: 0; visibility: hidden; transition: 0.5s; z-index: 1000; }
      #toast.show { opacity: 1; visibility: visible; }
      .separator { border: 0; border-top: 1px solid #ddd; margin: 0; }
    </style>
  </head>
  <body>
    <div class="memocard">
      <div class="header">
        <span class="nav-btn" onclick="prevQuestion()">〈</span> 
        <span>暗記アプリ</span> 
        <span class="nav-btn" onclick="nextQuestion()">〉</span>
      </div>
      <div class="menu-area">
        <select id="course-filter" onchange="onCourseChange()">
          <option value="all">すべてのコース</option>
        </select>
        <label style="font-size: 12px;">
          <input type="checkbox" id="toggle-voice" onchange="toggleAnswerAudibility()"> 自動発音
          <input type="checkbox" id="toggle-switch" onchange="toggleAnswerVisibility()"> 解答隠
        </label>
      </div>
      <div class="menu">
        <div class="submenu">
          <span class="poslabel">章</span><input class="position" id="chapter" type="text"> 
          <span class="poslabel">節</span><input class="position" id="section" type="text">
        </div>
        <button class="makeq-btn" onclick="showChapterSection()">表示</button>
        <button class="makeq-btn" onclick="setRandCur()">ランダム</button>
        <button class="makeq-btn" onclick="speakText()">音声</button>
      </div>
      <hr class="separator">
      <div class="content">
        <div class="section">
          <div class="label">Question</div>
          <div id="question-text" class="text-display">読み込み中...</div>
        </div>
        <div id="answer-wrapper">
          <div class="label">Answer</div>
          <div id="answer-text-area" class="section">
            <div class="answer-box">
              <div id="answer-text" class="text-display">-</div>
            </div>
          </div>
          <div id="answer-img-area" class="section" style="display:none;">
            <div class="img-fixed-box">
              <img src="" id="answer-img" alt="解答画像">
            </div>
          </div>
        </div>
        <div class="section">
          <div class="label">Selection / Note</div>
          <div id="selection-text-area"><div id="selection-text" class="text-display">-</div></div>
          <div id="selection-img-area" class="img-fixed-box" style="display:none;">
            <img src="" id="selection-img" alt="選択肢画像">
          </div>
        </div>
      </div>
      <div id="toast"></div>
    </div>
    <script>
      let allData = [];
      let curRow = 0;
      let autoVoice = false
      // アプリ起動時の初期化処理(メニュー読み込みとトップメニューデータ読み込み・表示)
      window.onload = function() {
        google.script.run.withSuccessHandler(function(courses) {
          const select = document.getElementById('course-filter');
          select.innerHTML = '';
          courses.forEach(c => select.add(new Option(c, c)));
          onCourseChange();
        }).getCourseList();
      };
      // アプリの表示を更新する
      function updateDisplay() {
        if (allData.length === 0) return;
        const current = allData[curRow];
        document.getElementById('chapter').value = current.chap_no;
        document.getElementById('section').value = current.q_no;
        document.getElementById('question-text').textContent = current.question;

        const aTextArea = document.getElementById('answer-text-area');
        const aImgArea = document.getElementById('answer-img-area');
        if (current.answer.type === "text") {
          document.getElementById('answer-text').textContent = current.answer.data;
          aTextArea.style.display = "block";
          aImgArea.style.display = "none";
        } else {
          document.getElementById('answer-img').src = current.answer.data;
          aTextArea.style.display = "none";
          aImgArea.style.display = "block";
        }

        const sTextArea = document.getElementById('selection-text-area');
        const sImgArea = document.getElementById('selection-img-area');
        if (current.selection.type === "text") {
          document.getElementById('selection-text').textContent = current.selection.data;
          sTextArea.style.display = "block";
          sImgArea.style.display = "none";
        } else {
          document.getElementById('selection-img').src = current.selection.data;
          sTextArea.style.display = "none";
          sImgArea.style.display = "flex";
        }
        if(autoVoice){speakText()}
      }
      // 次ボタン処理
      function nextQuestion(){ if (curRow < allData.length - 1) { curRow++; updateDisplay(); } else { showAlert("最後のデータです"); } }
      function prevQuestion(){ if (curRow > 0) { curRow--; updateDisplay(); } else { showAlert("最初のデータです"); } }
      // 音声合成処理
      function speakText(){ 
        if(!allData[curRow]) return;
        let text = allData[curRow].answer.type === "text" ? allData[curRow].answer.data : allData[curRow].question;
        if(text=="〇" || text=="✖"){ // 解答が〇、✖のみだった場合に、選択肢/備考欄の内容を発話対象にする
          text = allData[curRow].selection.type === "text" ? allData[curRow].selection.data : allData[curRow].selection;
        }
        speakTextContent(text); 
      }
      // 音声合成処理本体
      /* 音声読み上げメイン関数
      * 先頭の音切れを防ぐため、複数の対策を実装しています。
      */
function speakTextContent(text) {
  if (!text || text === "-") return;

  const synth = window.speechSynthesis;
  
  // 確実に停止して状態をクリア
  synth.cancel();
  
  // 言語判定
  let detectedLang = "en-US";
  if (text.match(/[ぁ-んァ-ヶー一-龠]/)) {
    detectedLang = "ja-JP";
  } else if (text.match(/[\uAC00-\uD7AF]/)) {
    detectedLang = "ko-KR";
  }

  // 音声エンジンの完全な初期化を待つ
  const initSpeech = () => {
    return new Promise((resolve) => {
      // ダミー発話で音声エンジンをウォームアップ
      if (synth.speaking) {
        synth.cancel();
      }
      
      // 音声リストの取得を確実に
      let voices = synth.getVoices();
      if (voices.length === 0) {
        // 音声リストが未ロードの場合は待機
        synth.onvoiceschanged = () => {
          voices = synth.getVoices();
          resolve(voices);
        };
      } else {
        resolve(voices);
      }
    });
  };

  initSpeech().then((voices) => {
    // 完全に停止するまで待機
    setTimeout(() => {
      const uttr = new SpeechSynthesisUtterance();
      uttr.lang = detectedLang;
      uttr.text = text; // 元のテキストをそのまま使用
      uttr.rate = 1.0;
      uttr.pitch = 1.0;
      uttr.volume = 1.0;

      // 指定言語の音声を明示的に設定
      const preferredVoice = voices.find(voice => 
        voice.lang.startsWith(detectedLang.split('-')[0])
      );
      if (preferredVoice) {
        uttr.voice = preferredVoice;
      }

      // エラーハンドリング追加
      uttr.onerror = (e) => {
        console.error('Speech synthesis error:', e);
        // エラー時は再試行
        if (e.error === 'interrupted' || e.error === 'canceled') {
          setTimeout(() => synth.speak(uttr), 100);
        }
      };

      // 発話開始時のログ(デバッグ用、本番では削除可)
      uttr.onstart = () => {
        console.log('Speech started');
      };

      // Chrome特有の問題対策:長文で途切れる場合
      if (text.length > 200) {
        uttr.onboundary = (e) => {
          // 定期的にリフレッシュして途切れを防ぐ
          synth.pause();
          synth.resume();
        };
      }

      synth.speak(uttr);
    }, 100); // cancel後の最小限の待機時間
  });
}

      // コース変更のアクションボタン
      function onCourseChange(){
        const val = document.getElementById('course-filter').value;
        google.script.run.withSuccessHandler(data => {
          allData = data;
          curRow = 0;
          if (allData.length > 0) updateDisplay();
          else document.getElementById('question-text').textContent = "データがありません。";
        }).getAllData(val);
      }
      // 解答の表示/非表示のトグル処理
      function toggleAnswerVisibility() {
        const isChecked = document.getElementById('toggle-switch').checked;
        document.getElementById('answer-wrapper').style.display = isChecked ? 'none' : 'block';
        showAlert(isChecked ? "解答を非表示にしました" : "解答を表示しました");
      }
      // 解答の自動発話のトグル処理
      function toggleAnswerAudibility() {
        const isChecked = document.getElementById('toggle-voice').checked;
        autoVoice = isChecked;
        showAlert(isChecked ? "音声を自動にしました" : "音声を手動にしました");
      }
      // アラート表示処理
      function showAlert(m) { 
        const t = document.getElementById('toast'); t.textContent = m; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 3000); 
      }
      // ランダム表示処理
      function setRandCur(){ 
        if(allData.length > 0) { curRow = Math.floor(Math.random() * allData.length); updateDisplay(); } 
      }
      // 章、節指定でのジャンプ処理
      function showChapterSection(){
        const c = document.getElementById('chapter').value;
        const s = document.getElementById('section').value;
        const idx = allData.findIndex(i => i.chap_no == c && i.q_no == s);
        if(idx !== -1) { curRow = idx; updateDisplay(); } else { showAlert("該当データなし"); }
      }
    </script>
  </body>
</html>

これらのコードは謎すぎて眩暈がしたかもしれませんが、実はこれはAI任せで作ったコードだし、このコードがどうして動くのかなどということはほとんど理解できなくてもちゃんと動いてくれます。実はこのコードの中にAIに聞いてもなかなか正しい方法を教えてくれない画像をスプレッドシートのセルの値(CellImage)として入れて表示する処理方式が組み込まれています。それから英語をWebアプリでしゃべらせる方法も記述されています。このやり方に到達するまでに私はAIと何日も喧嘩したり罵倒したりして格闘してやっとのことでたどり着いた至宝のスクリプトです。一般的にはAIに画像をセルに入れてそれを表で検索して表示に使いたいと要求すると、それは無理だとか、一旦アップロードしてURLを登録しろとか面倒くさい方式しか解答してくれません。AIを罵倒しまくった人だけ辿り着く秘密の奥義が入ってるので堪能してください。

このWebアプリでAIに音声をしゃべらせることができると、「スマホでどこにいてもネイティブ英語で文章を確認できる」というツールを手にすることなります。そうなったらあとは筋のいい英文を入れて丸暗記すれば満点狙えますよね。定期試験のある学生さんなら「教科書や問題集まるごと入れちゃえ」ってことです。今時は本の内容をアプリに登録する時だって同じような考えてOCRをAIにやらせてコピペで入れることができます。

まぁAIって感じじゃないですけど、スキャナーで読んだ画像をWindows11のSnipping Toolでスクショすると、ポップアップで出る結果確認画面で画像をOCRしてクリップボードに文字列として取り込むことができます。そういうことができるんだったら10や20の文書を入れるのなんて物凄い速度で入力可能です。

ちなみに上記のアプリはGoogleスプレッドシートのシート1に以下のような表があることを前提としています。使い方としてはコース名が同じ行をたくさん入れることで1つのコースの学習コンテンツとなり、章・節番号に対して質問と解答や選択肢が表示できる作りになっています。結局暗記カードってそういう内容ですよね。

あんまり流行っているようには思えませんが、Google Apps Script(GAS)は無料の範囲で普通にかなりのことに使えるので、これを使いこなした人が勝ち組みになると、私は確信しています。GASを覚えるには結局HTMLとかCSSとかJavaSriptとかWeb系プログラミングの基礎を学ぶことにもなるので一挙両得です。

かなり主題から脱線してしまいましたが、英語勉強にWebアプリを自作で作ってネイティブ発音を確認しながらアウトプット練習を続ければ、確実に英語脳が育成されるので、英語は得意という状態にあっという間になると思います。結局はどれだけ繰り返し練習したかなのでアプリがあれば英語脳になるとかじゃないのでその点は重々ご理解ください。