【教師オリジナルアプリを作ろう】オリジナルの英文を聞かせる音読アプリの作り方をはじめから(JavaScript)

ここでは,教師が用意した英文を音声として再生し,オーバーラッピングの練習ができるwebアプリを作成します。

音声を聞かせるアプリを作成しようとすれば,ネイティブの声を録音する必要があります。しかし,教師が個人で録音データを用意することは現実的ではないでしょう。そこで,ブラウザの読み上げ機能(speech synthesis APIと言います)を用いて,用意された英文を機械的に読み上げてもらいます。

ブラウザで音声の読み上げを行うこと自体はそれほど難しいものではありません。今回の記事は,それをある程度webアプリとして操作可能なインターフェイスに落とし込む作業を主な目的としています。

コードの説明

実際のコードを見ていきましょう。使用する言語はJavaScriptです。ここではJavaScriptの学習者のために,なるべく細かく説明を加えていきたいと思います。

<meta name="viewport" content="width=device-width,initial-scale=1">

まず,htmlのヘッダーからです。ビューポートの設定を行います。この設定は,スマートフォンで表示したときに文字が勝手に小さくならないようにするために必要です。

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">

jqueryとjquery UIを読み込みます。jqueryは簡単に言えばJavaScriptのコードを省略して記述するためのプラグインです。また,jquery UIは画面のエフェクトを行うためのプラグインで,今回は英文をクリックしたときに背景の色が変化する部分で使用しています。

<link rel="stylesheet" href="./fasgram.css">

サイト独自のcssを読み込みます。

<div id="container">
<header><p class="size-5">fasgram 英文法トレーニングwebアプリ<span class="badge">動作サンプル</span></p></header>
<article>
<audio id="mp3-start" src="./mp3/start.mp3"></audio>
<div id="display"></div>
</article>
<footer></footer>
</div>

サイトのブロック要素です。この部分については,前回の記事【教師オリジナルアプリを作ろう】英文法アプリの見た目をアプリらしく/スマホに対応させる(JavaScript)を参考にしてください。基本的にそれと同じものです。英文や日本文などの文字は<div id="display"></div>の中に記述していきます。

JavaScriptはブロック要素の中身をあとで書き換えることができるので,いったんブロック要素(文字を入れる箱をイメージしてください)だけを用意して,あとからJavaScriptのコードで中身を入れていくという手順で進めていきます。

JavaScriptのコード

<script>

JavaScriptのコードは<script></script>で囲まれた部分に記述していきます。そのうちJavaScriptのコードは別ファイルに分けていきますが,とりあえずはhtmlファイルの中に直接記述していきます。

音声のインスタンス化

let uttr = new SpeechSynthesisUtterance();
let voices = window.speechSynthesis.getVoices();

音声のインスタンス化を行います。いきなりインスタンス化と言われても意味が分からないかもしれませんが,実際に行っていることはオブジェクトの生成です。オブジェクトとは,何かの仕事を行ってくれるロボットのようなもので,ここではuttrというロボットを用意したと考えると良いでしょう。

そして,ロボットには音声を再生する機械が内蔵されていて,それがSpeechSynthesisUtterance()であるということです。

あとでuttrにさまざまなデータを与えボタンを押すと,実際にロボットが英文を音声にして話してくれます。

また,window.speechSynthesis.getVoices()はブラウザが再生できる音声の種類を配列にして返します。

ここで注意が必要ですが,再生できる音声の種類はブラウザによって異なります。たとえ同じchromeでもwindowsとMac,android,iOSでも対応する音声の数が異なるなど,少し煩雑です。

今回のコードはwindowsのchromeでは,アメリカ人女性とイギリス人男性の2種類の声で再生されますが,androidのchromeではアメリカ人女性の声だけで再生されるようです。iPhoneからアクセスした場合にはまた状況が異なるかもしれませんが,確認はしていない点を了承ください。

getVoices()はあとからもう一度実行することになりまずが,ページを読み込んだ時点でいったん実行しておいた方が良いです。あとから実行するとうまく動作しないというバグがあるようです。

スタート画面

function start_display() {

スタート画面を表示する関数を用意します。

    $('#display').empty();

$('#display').empty();id="display"で指定された要素の中身を空にします。はじめに画面を表示するときには必要ありませんが,他の画面からスタート画面に戻るときには,いったん中身を空にした上でスタート画面を表示していくことになります。$で始まるコードがjqueryのコードになります。

jqueryは無理に使う必要はありません。しかし,実際に上と同じ処理をもともとのJavaScriptコードで書くとなると,もっと多くの処理を記述する必要があります。jqueryはそうした煩雑なコードを短い記述で済ませる上で便利なプラグインとしてよく使われています。

    const content = '<p class="size-2">ボタンを押して、トレーニングを開始しましょう。</p>'
        + '<div class="btn-wrapper">'
        + '<button type="button" id="start-button" class="btn btn-start size-1">音読練習</button>'
        + '</div>';
    $('#display').append(content);

書き込むhtmlの内容を文字列contentに格納し,$('#display').append(content);で表示します。このように,書き込むhtmlの文字列が長い場合には,+を使って複数行に分けると良いでしょう。これは,コードを見やすくするための工夫です。

    $('#start-button').on('click', function() {
        $('#mp3-start')[0].play();
        setTimeout(() => {
            read_aloud();
        },1500);
    });

on('click', function() { ~はスタートボタンがクリックされたときに行う処理を書きこむ部分です。上のコードで「音読練習」のボタンにはid="start-button"が設定されており,これが押されたときに以下の処理を実行することになります。

$('#mp3-start')[0].play();はボタンクリック時の効果音を再生します。最初のhtmlで<audio id="mp3-start" src="./mp3/start.mp3"></audio>と設定しています。play()を実行すると実際にmp3ファイルを再生します。

setTimeout(() => { read_aloud(); },1500); は1500ミリ秒後(1.5秒後)に関数read_aloud()を実行します。ボタンを押したときに効果音が流れるので,次の画面に映るまで少し待ち時間を作っています。

音読の実行

async function read_aloud() {

スタート画面のボタンをクリックすると関数read_aloud()に移動します。ここが実際に音読の画面を表示していく部分です。

asyncawaitPromise()については以前の記事【教師オリジナルアプリを作ろう】英文法問題アプリの作成方法を初めから(JavaScript)を参考にしてください。これらの機能は簡単に言えば処理を直鎖状に順番に進めていくために必要なものです。ここではボタンがクリックされるまで,勝手に次の処理に進まないようにするために用いています。

    const content = '<div style="text-align:right;"><button type="button" id="home-btn" class="size-3">最初に戻る</button></div>'
        + '<p id="instruction" class="size-3">英文をタップして音声を再生してください。音声に合わせて,声に出して読みましょう。</p>'
        + '<p id="question-number" class="size-3"></p>'
        + '<p id="en-sentence" class="size-1"></p>'
        + '<p id="ja-sentence" class="size-3"></p>'
        + '<div id="console" class="btn-wrapper">'
        + '<button type="button" id="confirm" class="btn btn-confirm size-1">次に進む</button>'
        + '</div>';
    $('#display').empty().append(content);

上と同様に文字列contentにhtmlをいったん格納して.append()で書き込んでいます。ここではまだ,実際の英文と日本文は表示せず,空のブロック要素だけを用意します。

あとで,id="question-number"に問題番号,id="en-sentence"に英文,id="ja-sentence"に日本文が格納され表示されます。

.append()の前にempty()が入っていますが,id="display"の中にはスタート画面のhtmlが残っているので,いったん消去してから書き込んでいます。このようにjqueryのコードは複数の命令をつなげて書くこともできます。

    $('#home-btn').on('click', function(){
        speechSynthesis.cancel();
        const result = window.confirm('スタート画面に戻ります。');
        if(result) {
            location.reload();
        }
    });

画面右上にある「最初に戻る」ボタンがクリックされたときの処理です。

speechSynthesis.cancel()は音声のキューを削除し,音声をストップします。window.confirm()はダイアログを表示し,次のような画面が出てきます。

OKをクリックするとresultにはtrueが格納されます。

if(result)if(result == true)と同じことです。OKボタンが押されたときにlocation.reload();を実行します。location.reload();はサイトをもう一度読み直して最初に戻ります。つまり,スタート画面に戻るということです。

英文と日本文の読み込み

英文と日本文のデータを読み込みます。この辺りは以前の記事と同じもので,jsonファイルを読み込んで配列textsに格納しています。

    const data = await fetch('./sentences.json');
    const obj = await data.json();
    const obj_json = JSON.stringify(obj); //json文字列に変換
    const texts = JSON.parse(obj_json); //配列に格納

jsonファイルの中身を見ていきましょう。json形式と言いながら単に二次元配列を書いているだけなのですが,このような形でもjsonファイルとして扱うことができます。

sentences.json

[["Due to the rain, our performance in the game was far from perfect.",
    "雨のせいで、試合の出来は決して完璧ではありませんでした。"],
["Emergency doors can be found at both ends of this hallway.",
    "非常口はこの廊下の両端にあります。"],
["My plans for studying abroad depend on whether I can get a scholarship.",
    "私の留学計画は、私が奨学金を得ることができるかどうかにかかっています。"],
["Noriko can speak Swahili and so can Marco.",
    "ノリコはスワヒリ語を話すことができ、マルコも話すことができます。"],
["To say you will go jogging every day is one thing, but to do it is another.",
    "あなたが毎日ジョギングに行くと言うことと、それをすることは別です。"],
["Our boss is a hard worker, but can be difficult to get along with.",
    "私たちの上司は働き者ですが、仲良くするのは難しいかもしれません。"],
["When Ayano came to my house, it happened that nobody was at home.",
    "アヤノが私の家に来たとき、たまたま誰もいなかった。"],
["We’ll be able to get home on time as long as the roads are clear.",
    "道順がはっきりしていれば、私たちは時間通りに家に帰ることができるでしょう。"],
["I know you said you weren’t going to the sports festival, but it is an important event, so please give it a second thought.",
    "私はあなたがスポーツフェスティバルには行かないと言っていたことは知っていますが、それは重要なイベントですので、もう一度考え直してください。"],
["I didn’t recognize any of the guests except for the two sitting in the back row.",
    ["私は後ろの列に座っている2人を除いて、招待客の誰も分かりませんでした。"],
["Some parents are opposed to letting children watch TV at dinner time.",
    "夕食時に子供にテレビを見させることに反対する親もいる。"],
["However hard it may seem to be, we have to do the job.",
    "どんなに大変そうに見えても、私たちは仕事をしなければなりません。"],
["I met Shigeo at the supermarket by chance.",
    "私はスーパーで偶然シゲオに会った。"],
["This plan needs the support of at least two thirds of the members present at this meeting.",
    "この計画には、この会議に出席しているメンバーの少なくとも3分の2以上の賛成が必要です。"],
["Peace Memorial Park lies in the center of the city.",
    "平和記念公園は市の中心部に位置しています。"],
["Does getting together on Friday suit you?",
    "金曜日に集まるということで良いですか。"],
["It was in her garden that she found the buried treasure.",
    "彼女が埋もれていた宝を見つけたのは彼女の庭だった。"],
["What did you go to Tokyo for during the Golden Week holiday?",
    "ゴールデンウィークの間,あなたは何のために東京に行きましたか。"],
["The beginning of today’s board meeting was the very moment I wanted to make use of to announce our new project.",
    "今日の役員会の始まりは、まさに私が自分たちの新しいプロジェクトの発表に活かしたいと思った瞬間だった。"],
["We tried to talk Satoru out of buying such an expensive sports car.",
    "私たちはそれほど高価なスポーツカーを買わないようにサトルを説得しようとした。"],
["Casey was getting worried because the bus going to the airport was clearly behind schedule.",
    "空港へ行くバスが明らかに予定より遅れているのでケーシーは心配になった。"],
["If you are in a hurry, you should call Double Quick Taxi because they usually come in no time.",
    "急いでいる場合は、ダブルクイックタクシーは大抵すぐに来るので、電話してください。"],
["After almost dropping the expensive glass vase, James decided not to touch any other objects in the store.",
    "高価なガラスの花瓶を落としそうになったあと、ジェームズは店内の他の物に触れないと決心した。"],
["We should make the changes to the document quickly as we are running out of time.",
    "私たちは時間が不足しているので、素早くその書類を変更すべきです。"],
["It was impossible to meet everyone’s demands about the new project.",
    "新しいプロジェクトに対するみんなの要求に応えることは不可能でした。"],
["Write a list of everything you need for the camping trip. Otherwise, you might forget to buy some things.",
"キャンプ旅行に必要なものすべての一覧表を書いて下さい。そうでなければ、あなたはいくつかのものを買うのを忘れるかもしれません。"],
["Text messaging has become a common means of communication between individuals.",
    "携帯電話のメールは個人間のコミュニケーションの一般的な手段となっている。"],
["I was shocked when I watched the completely surprising ending of the movie.",
    "私は映画の本当に驚くような結末を見てショックを受けました。"],
["There is no avoiding the increase in traffic on this highway during holidays."],
    "休暇中のこの高速道路の交通量の増加は避けられない。"],
["The police officer asked the witness to describe the situation as accurately as possible.",
    "警察官は目撃者にできるだけ正確に状況を説明するよう求めた。"],
["I’ll look up the train schedule before going to the station, just in case.",
    "念のため、駅に行く前に列車の時刻表を調べます。"],
["When I tried to play an online game, the computer would not work at all.",
    "オンラインゲームをプレイしようとしたときに、コンピューターがどうしても動かなかった。"],
["You’ll have more job opportunities in the city, but your living expenses will be higher.",
    "あなたは都市でより多くの仕事の機会を得ることができるが、生活費はより高くなるだろう。"],
["Confused about how to deal with the situation, they sat in silence waiting for someone to start speaking.",
    "その状況に対処する方法について混乱したので、彼らは誰かが話し始めるのを待ちながら座っていた。"],
["Vancouver was the largest of the four Canadian cities we visited.",
    "バンクーバーは私たちが訪れたカナダの4つの都市の中で最大だった。"],
["Their smiles disappeared after getting directions, for they still had a long way to walk.",
    "指示を得たあと彼らの笑顔は消えた。というのも彼らには歩くための長い道のりがあった。"],
["I think the new teacher is a bit too strict. What do you think of her?",
    "新しい先生は少し厳しすぎると思います。彼女をどう思いますか。"],
["His continuous support kept the international trade project from being a failure.",
    "彼の継続的な支援は、国際貿易プロジェクトが失敗するのを防いだ。"],
["It will take less time to get to the airport when the construction of the monorail is finished.",
    "モノレールの建設が完了すれば、空港に着くまでの時間が短くなるだろう。"],
["It can be difficult to tell real leather shoes from artificial leather ones by their appearance.",
    "見た目で本物の革の靴と人工皮革の靴を区別するのは難しいことがある。"]]

英文と日本文のセットを40個用意しました。これを配列に格納すると

texts[0][0] = "Due to the rain, our performance in the game was far from perfect."
texts[0][1] = "雨のせいで、試合の出来は決して完璧ではありませんでした。"
texts[1][0] = "Emergency doors can be found at both ends of this hallway."
texts[1][1] = "非常口はこの廊下の両端にあります。"
texts[2][0] = "My plans for studying abroad depend on whether I can get a scholarship."
texts[2][1] = "私の留学計画は、私が奨学金を得ることができるかどうかにかかっています。"

......

となります。

データのシャッフル

    for(let i = 0; i < 1000; i++) {
        let a = Math.floor(Math.random() * texts.length);
        let b = Math.floor(Math.random() * texts.length);
        [texts[a], texts[b]] = [texts[b], texts[a]];
    }

データをシャッフルして,英文がランダムに表示されるようにします。ランダムに表示したくない場合は,この部分は削除してください。

Math.random()は0から1未満までの乱数を発生します。これにtexts.lengthをかけます。配列textstexts[0]からtexts[39]まであるので,texts.length=40です。この値はjsonファイルで用意した英文の数を表します。かけ算の結果は0から39.999・・・までの小数になります。これをMath.floor()によって小数点以下を切り捨て,0から39までの整数とします。

[texts[a], texts[b]] = [texts[b], texts[a]]は配列の値を入れ替えます。たとえば,a=3b=7なら[texts[3], texts[7]][texts[7], texts[3]]となって,配列の値が入れ替わります。

この処理をfor文で1000回繰り返して,ランダムにシャッフルします。シャッフルの回数は配列の大きさに合わせて適当に決めます。繰り返し回数を多くするほどよりランダムにシャッフルされますが,それだけ処理時間もかかることになるので,実際の動作を確認しながら繰り返し回数を決めると良いでしょう。

画面を表示する

    for(let id = 0; id < 5; id++) {

ここから,実際に英文と日本文を表示して音声を再生していきます。

とりあえず,繰り返し回数を5回として,5つの英文を表示したら終了してスタート画面に戻ることにします。idは0から4まで変化し,表示する英文と日本文の番号として使用します。

        const en_sentence = texts[id][0];
        const ja_sentence = texts[id][1];
        $('#question-number').text('No. '+(id+1));
        $('#en-sentence').text(en_sentence);
        $('#ja-sentence').text(ja_sentence);

上で用意した配列textsの値を英文の文字列en_sentenceと日本文の文字列ja_sentenceに格納します。たとえば,以下のようになります。

en_sentence = "Due to the rain, our performance in the game was far from perfect."
ja_sentence = "雨のせいで、試合の出来は決して完璧ではありませんでした。"

$('#question-number').text('No. '+(id+1));は問題番号を表示します。同様に,$('#en-sentence').text(en_sentence);で英文,$('#ja-sentence').text(ja_sentence);で日本文を表示していきます。実際には,上で用意したブロック要素の内容を書き換えることで,文字が画面に表示される仕組みです。

        $('#en-sentence').on('click', function(){

次に,英文をクリックしたときの処理です。id="en-sentence"は英文を表示するブロックでした。したがって,ボタンではなく英文自体をクリックした場合に以下の処理を行っていきます。このようにJavaScriptはボタン以外の要素もクリックの対象とすることができるのです。

            $("#en-sentence").effect("highlight", { color: "#ccdeff" }, 2000);

ここで,jquery UIプラグインを用いてエフェクト処理を行います。今回,jquery UIを使用しているのはここだけです。

実際のエフェクトはサンプルサイトで確認するとよいでしょう。英文をクリックしたときに英文の背景が水色なり,ゆっくりともとに戻っていきます。.effect()の詳しい説明は他所に譲ります。

このエフェクトは,ユーザーに英文がクリックされたことを視覚的に確認させるためのものです。これは必ずしも必要ではないかもしれませんが,ユーザーにアプリの動作を直感的に理解してもらい,より多くクリックさせるように誘導します。このように,アプリでは「ユーザーが押したくなるボタン」を用意することは重要な要素です。

            let voice;
            if(id % 2 == 0) {
                voice = 'female';
            } else {
                voice = 'male';
            }

idの値によって女性の声と男性の声を切り替えます。%は割り算の余りを求める演算子でif(id % 2 == 0)は「idを2で割った余りが0ならば」という意味になります。言い換えれば,idの値が偶数ならばということです。実際は,idは0から始まるので最初は女性の声で始まります。

反対に,idの値が奇数の場合は男性の声を使用します。とは言っても,voice = 'male'自体はコード上では使用していません。

            speech_sentence(en_sentence, voice);

上で登場した英文の文字列en_sentenceと声の種類voiceを関数speech_sentence()に渡します。この関数は値を受け取って音声を再生します。関数の中身はあとで紹介します。

        await new Promise((resolve) => {
            $('#confirm').on('click', function(){
                speechSynthesis.cancel();
                $('#en-sentence').off();
                resolve();
            });
        });

Promise()を使用して「次に進む」ボタンがクリックされるまで,次の処理に進まないように待機します。

Promise()の仕組みについては,正直に言えば私もあまり理解していないのですが,とりあえずボタンがクリックされるまで待機したい場合にPromise()の中にクリックの処理を書いておくとよい,というくらいの理解で対処しています。

id="confirm"のブロック要素が「次に進む」のボタンを表しているので,これがクリックされたときにfunction()以下の処理を実行します。

speechSynthesis.cancel()は再生中の音声を止めるためのものでした。

$('#en-sentence').off();は,英文をクリックしたときの設定を消去するためのものです。

この記述は説明が少しやっかいです。「次の進む」ボタンが押され,次の英文を表示したときに,再びクリック処理を設定するのですが,このとき,前のクリック処理が消えずに残っているため,英文が2回再生されることになるのです。そこで,いったんクリックの設定を消去してから再びクリック時の処理を設定するのです。

こうして必要な処理を終えたあと,最後のresolve()によって待機状態を抜け出し,次の英文に進みます。

終了画面

    await new Promise((resolve) => {
        const content = '<div class="size-1"><div class="mb-1">トレーニング終了です。</div>'
            + '<div class="btn-wrapper">'
            + '<button type="button" id="confirm" class="btn btn-confirm size-1">最初に戻る</button>'
            + '</div>';
        $('#display').empty();
        $('#comment_box').empty();
        $('#display').append(content);
        //OKボタンが押されたらスタート画面へ
        $('#confirm').on('click', function() {
            speechSynthesis.cancel();
            location.reload();
            resolve();
        });
    });

すべての英文を表示し終えたときに表示する画面です。詳しい説明は省略しますが,ボタンを押すとlocation.reload()によって最初のスタート画面に戻ります。

音声の再生

function speech_sentence(sentence, voice) {
    speechSynthesis.cancel();
    voices = speechSynthesis.getVoices();
    if(voice == 'female') {
        uttr.voice = voices[3]; //女性の声
    } else {
        uttr.voice = voices[5]; //男性の声
    }
    uttr.volume = 1.0; //音量
    uttr.rate = 0.9; //読み上げ速度
    uttr.pitch = 1.0; //音程
    uttr.lang = 'en-US'; //言語
    uttr.text = sentence; //読み上げテキスト
    speechSynthesis.speak(uttr); //再生
}

大事な部分が最後になってしまいましたが,音声の再生部分です。上で述べた通り,英文をクリックすると関数speech_sentence()が呼び出され,音声を再生します。

英文をクリックしたときに音声が再生中かもしれないので,speechSynthesis.cancel()でいったん音声を消去します。

そして,speechSynthesis.getVoices()で再生できる音声の種類の一覧を取得します。windowsのchromeの場合,たとえばvoices[3]="Google US English"voices[5]="Google UK English Male"といった文字列が格納されています。

最初の話に戻りますが,uttrはオブジェクトであり,これは音声を再生するロボットのようなものであると述べました。uttr.voice = voices[3]とすることで,このロボットのvoiceという設定をvoices[3],つまり女性の声にするという指示を出しているのです。

同様にuttrにはいくつかロボットの動作を設定する項目があり,それが.volume.rateなどです。

読み上げ速度の設定.rateについては,1.0が標準の速度ですが,ここでは少しスピードを遅くしています。この設定については,アプリの提供側で実際に試しながらスピードを決めていくと良いでしょう。教師の側が生徒に身につけさせたいスピードを自由に選択できるところも,オリジナルアプリの大きな利点です。

最後のspeechSynthesis.speak(uttr)が音声を再生する部分です。オブジェクトuttrに必要なデータをセットして,最後に再生ボタンを押すイメージです。

まとめ

この記事では,speech synthesis APIを用いて,教師が独自に用意した英文を表示し,その音声を機械音声で再生させるwebアプリの作成方法を学びました。サンプルとして英文と日本文のjsonファイルを示しましたが,同じようにして教師が独自のjsonファイルを用意することによって,生徒に音読させたい英文を音声付きで提供することができます。

教師がレンタルサーバーなどにファイルをアップロードすることで,生徒はスマートフォンを使って音読の練習を行うことができます。

ここではなるべくシンプルな形でコードを作成しましたが,これを改変してそれぞれの教師独自のトレーニングメニューを用意しても良いでしょう。ここで紹介するコードについて,商用・非商用を問わず著作権者の許諾無しに自由に改変・再配布することを許可します。

最後にコード全体を示します。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>fasgram 英文法トレーニングwebアプリ</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<link rel="stylesheet" href="./fasgram.css">
</head>
<body>
<div id="container">
<header><p class="size-5">fasgram 英文法トレーニングwebアプリ<span class="badge">動作サンプル</span></p></header>
<article>
<audio id="mp3-start" src="./mp3/start.mp3"></audio>
<div id="display"></div>
</article>
<footer></footer>
</div>
<script>
//音声のインスタンス化
let uttr = new SpeechSynthesisUtterance();
let voices = window.speechSynthesis.getVoices();
//スタート画面
function start_display() {
    $('#display').empty();
    const content = '<p class="size-2">ボタンを押して、トレーニングを開始しましょう。</p>'
        + '<div class="btn-wrapper">'
        + '<button type="button" id="start-button" class="btn btn-start size-1">音読練習</button>'
        + '</div>';
    $('#display').append(content);
    $('#start-button').on('click', function() {
        $('#mp3-start')[0].play();
        setTimeout(() => {
            read_aloud();
        },1500);
    });
}
//語句選択問題
async function read_aloud() {
    //画面の初期化とレイアウトの設定
    const content = '<div style="text-align:right;"><button type="button" id="home-btn" class="size-3">最初に戻る</button></div>'
        + '<p id="instruction" class="size-3">英文をタップして音声を再生してください。音声に合わせて,声に出して読みましょう。</p>'
        + '<p id="question-number" class="size-3"></p>'
        + '<p id="en-sentence" class="size-1"></p>'
        + '<p id="ja-sentence" class="size-3"></p>'
        + '<div id="console" class="btn-wrapper">'
        + '<button type="button" id="confirm" class="btn btn-confirm size-1">次に進む</button>'
        + '</div>';
    $('#display').empty().append(content);
    //ホームボタンが押されたら最初に戻る
    $('#home-btn').on('click', function(){
        speechSynthesis.cancel();
        const result = window.confirm('スタート画面に戻ります。');
        if(result) {
            location.reload();
        }
    });
    //データの読み込み
    const data = await fetch('./sentences.json');
    const obj = await data.json();
    const obj_json = JSON.stringify(obj); //json文字列に変換
    const texts = JSON.parse(obj_json); //配列に格納
    //データをシャッフル
    for(let i = 0; i < 1000; i++) {
        let a = Math.floor(Math.random() * texts.length);
        let b = Math.floor(Math.random() * texts.length);
        [texts[a], texts[b]] = [texts[b], texts[a]];
    }
    //音読を順番に行う
    for(let id = 0; id < 5; id++) {
        //英文と日本文の表示
        const en_sentence = texts[id][0];
        const ja_sentence = texts[id][1];
        $('#question-number').text('No. '+(id+1));
        $('#en-sentence').text(en_sentence);
        $('#ja-sentence').text(ja_sentence);
        $('#en-sentence').on('click', function(){
            $("#en-sentence").effect("highlight", { color: "#ccdeff" }, 2000);
            //問題番号が奇数なら女性,偶数なら男性
            let voice;
            if(id % 2 == 0) {
                voice = 'female';
            } else {
                voice = 'male';
            }
            //音声を再生する
            speech_sentence(en_sentence, voice);
        });
        await new Promise((resolve) => {
            $('#confirm').on('click', function(){
                speechSynthesis.cancel();
                $('#en-sentence').off();
                resolve();
            });
        });
    }
    //全問終了のメッセージ
    await new Promise((resolve) => {
        const content = '<div class="size-1"><div class="mb-1">トレーニング終了です。</div>'
            + '<div class="btn-wrapper">'
            + '<button type="button" id="confirm" class="btn btn-confirm size-1">最初に戻る</button>'
            + '</div>';
        $('#display').empty();
        $('#comment_box').empty();
        $('#display').append(content);
        //OKボタンが押されたらスタート画面へ
        $('#confirm').on('click', function() {
            speechSynthesis.cancel();
            location.reload();
            resolve();
        });
    });
}
function speech_sentence(sentence, voice) {
    speechSynthesis.cancel();
    voices = speechSynthesis.getVoices();
    if(voice == 'female') {
        uttr.voice = voices[3]; //女性の声
    } else {
        uttr.voice = voices[5]; //男性の声
    }
    uttr.volume = 1.0; //音量
    uttr.rate = 0.9; //読み上げ速度
    uttr.pitch = 1.0; //音程
    uttr.lang = 'en-US'; //言語
    uttr.text = sentence; //読み上げテキスト
    speechSynthesis.speak(uttr); //再生
}
//スタート画面
start_display();
</script>
</body>
</html>

cssのコード全体を示します。

/*フォントファミリー*/
body {
    font-family: Georgia, sans-serif;
}
/*スマートフォン用の基準文字サイズ*/
body {
    font-size: 24px;
}
/*デスクトップ用の基準文字サイズ*/
@media screen and (min-width:680px) {
    body {
        display: flex;
        justify-content: center;
        font-size: 18px;
    }
}
/*コンテナの幅*/
#container {
    max-width: 680px;
}
/*フォントサイズ*/
.size-1 {
    font-size: 1.5rem;
}
.size-2 {
    font-size: 1.25rem;
}
.size-3 {
    font-size: 1.0rem;
}
.size-4 {
    font-size: 0.75rem;
}
.size-5 {
    font-size: 0.5rem;
}
/*文字色*/
.color-blue {
    color: #3366CC;
}
.color-red {
    color: #FF6600;
}
/*左マージン*/
.ml-1 {
    margin-left: 10px;
}
/*下マージン*/
.mb-1 {
    margin-bottom: 10px;
}
/*中央に表示するメッセージ*/
.message {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    height: 200px;
    background-color: #e5eeff;
    border-radius: 1.0rem;
    border: solid 2px #ffffff;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);/*影*/
}
/*ボタンの共通設定*/
.btn-wrapper {
    display: flex;
    justify-content: center;
    align-items: center;
}
.btn {
    font-weight: 700;
    line-height: 1.5;
    padding: 1rem 4rem;
    cursor: pointer;
    transition: all 0.3s;
    text-align: center;
    vertical-align: middle;
    letter-spacing: 0.25rem;
    border: solid 2px #ffffff;
    border-radius: 1.0rem;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
}
/*スタートボタンの色*/
.btn-start {
    color: #fff;
    background-color: #6699ff;
    border-radius: 2.5rem;
}
.btn-start:hover {
    color: #fff;
    background: #70a1ff;
}
/*OKボタンの色*/
.btn-confirm {
    color: #fff;
    background-color: #99cc33;
}
.btn-confirm:hover {
    color: #fff;
    background: #a7d741;
}
/*大問表示*/
#question-number {
    font-weight: 700;
}
/*選択肢の枠線*/
.box-1 {
    padding: 0.5em 1em;
    margin: 0.25em 0;
    font-weight: bold;
    color: #6091d3;
    background: #FFF;
    border: solid 2px #6091d3;
    border-radius: 10px;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
}
.box-1 p {
    margin: 0; 
    padding: 0;
}
/*バッジ*/
.badge {
    padding: 3px 6px;
    margin-right: 8px;
    margin-left: 1px;
    font-size: 75%;
    color: white;
    border-radius: 6px;
    box-shadow: 0 0 3px #ddd;
    white-space: nowrap;
    background-color: #58ACFA;
}
#home-btn {
    border: 1px solid #ccc;
    border-radius: 5px;
    padding: 10px;
    background: #f1e767;
    background: -webkit-gradient(linear, left top, left bottom, from(#fdfbfb), to(#ebedee));
    background: -webkit-linear-gradient(top, #fdfbfb 0%, #ebedee 100%);
    background: linear-gradient(to bottom, #fdfbfb 0%, #ebedee 100%);
    -webkit-box-shadow: inset 1px 1px 1px #fff;
    box-shadow: inset 1px 1px 1px #fff;
}
#home-btn:hover {
    background: -webkit-gradient(linear, left bottom, left top, from(#fdfbfb), to(#ebedee));
    background: -webkit-linear-gradient(bottom, #fdfbfb 0%, #ebedee 100%);
    background: linear-gradient(to top, #fdfbfb 0%, #ebedee 100%);
  }
#en-sentence {
    padding: 15px;
    border-radius: 10px;
}