Pythonやってみる!

(140)【Google Cloud TTS #7】Webブラウザ上で話者(Voice)を指定可能に

131 views

【0】連載内容

(134)【Google Cloud TTS #1】子どもの英会話学習教材を作りたい!
(135)【Google Cloud TTS #2】Google Cloud側の準備作業
(136)【Google Cloud TTS #3】自前サーバー側の準備作業(Ubuntu24)
(137)【Google Cloud TTS #4】WEBブラウザから実行
(138)【Google Cloud TTS #5】話す速度をゆっくりに
(139)【Google Cloud TTS #6】声の大きさ、声の高さを変える。
(140)【Google Cloud TTS #7】Webブラウザ上で話者(Voice)を指定可能に ←今回はココ
(141)【Google Cloud TTS #8】二人以上の会話を入力可能に
(142)【Google Cloud TTS #9】英会話教材を作る。(一先ず完結)

【1】やりたいこと

現時点で、Webブラウザ上では以下の項目を設定できる。
・英文テキスト
・話す速度

今回は Voice指定を追加実装したい。

【2】必要な情報収集

(1) VoiceSelectionParamsクラスで Voiceを指定する。

こちらの VoiceSelectionParams クラスのリファレンスページを参照されたい。
Class VoiceSelectionParams (2.34.0)

プログラム中では以下のように指定すればよい。

    voice = texttospeech.VoiceSelectionParams(
        language_code = "en-US",
        name          = "en-US-Neural2-C",
    )

(2) 指定可能な language_code, name の値は?

こちらの サポートされている音声と言語 のページを参照されたい。
※注意: Google Chromeで開かないと重い…

【3】実装方針

Webブラウザ上で、プルダウンメニューを使って好きな Voiceを選択したい!

では…
英語だけでも数十種類ある Voiceの情報をどこで保持するか?
SQLite3, MariaDB, JSON, プログラム直書き…
どれにしようか?

今日は疲れたのでここで中断しよう…
また明日以降に続きをやろう。

翌朝よく考えた結果…
今回は、実装をシンプルにするために
JSONに音声情報を保存することにした。

この JSONに書かれている音声(=Voice)リストを HTMLの SELECT要素に表示する。

これによりユーザーは 、
・Webページ上で Voiceを選択し、
・英文テキストを入力し、
・話す速度を設定し、
[TTS実行] ボタン押下で MP3ファイルを作成する仕様とする。

【4】JSONデータファイル

英語話者の Voiceデータの中で、子どもの英会話学習に使えそうな音声を選んだ。
※今後使っていく中で、初心者には難しいと感じられる Voiceは削除する予定だ。

voice.json

[
  {
    "age":   "10",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Achernar"
  },
  {
    "age":   "10",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Zephyr"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Achernar"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Erinome"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Sulafat"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Callirrhoe"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Kore"
  },
  {
    "age":   "20",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Leda"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Aoede"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Autonoe"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Kore"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Laomedeia"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Leda"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Zephyr"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Aoede"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Autonoe"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Erinome"
  },
  {
    "age":   "40",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Sulafat"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Callirrhoe"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Despina"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Gacrux"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Vindemiatrix"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Despina"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Laomedeia"
  },
  {
    "age":   "60",
    "sex":   "F",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Vindemiatrix"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Achird"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Rasalgethi"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Alnilam"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Fenrir"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Iapetus"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Orus"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Schedar"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Fenrir"
  },
  {
    "age":   "20",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Rasalgethi"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Achird"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Algieba"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Sadachbia"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Sadaltager"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Umbriel"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Zubenelgenubi"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Iapetus"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Orus"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Sadaltager"
  },
  {
    "age":   "40",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Umbriel"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Charon"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-GB",
    "vname": "en-GB-Chirp3-HD-Enceladus"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Algenib"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Algieba"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Alnilam"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Charon"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Enceladus"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Sadachbia"
  },
  {
    "age":   "60",
    "sex":   "M",
    "lcode": "en-US",
    "vname": "en-US-Chirp3-HD-Zubenelgenubi"
  }

]

【5】Webページ

index.py

#!/opt/webtts/myenv/bin/python
import cgi
import os
import sys
from datetime import datetime

# 自作モジュールのパスを通す
sys.path.append('/opt/webtts/src')
import my_ggtts 
from CVoice import CVoices

# デバッグ用(エラー時に詳細をブラウザに表示)
import cgitb
cgitb.enable()

# フォームデータの取得
form = cgi.FieldStorage()
input_text = form.getvalue("text", "")
# speaking_rateを取得(未指定ならデフォルト1.0。float型に変換)
try:
    speaking_rate = float(form.getvalue("speaking_rate", "1.0"))
except ValueError:
    speaking_rate = 1.0

html_sound = ""      # サウンド再生コントロールを表示する HTML文

# 音声(voice)情報を取得
cvoices = CVoices()
active_vname = ''

#-----------------------------------------------------------------------
# フォームにテキストが入力されていれば TTSを実行する。
if input_text:
    vname = form.getvalue("vname", "")
    if vname:
        active_vname = vname
        vitem = cvoices.get_item(vname)
        if vitem:
            # MP3ファイル名を作る。
            timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
            filename = f"sound-{timestamp}.mp3"
            try:
                my_ggtts.generate_voice( input_text, filename, speaking_rate=speaking_rate, language_code=vitem.lcode, voice_name=vitem.vname)
                # ブラウザからアクセス可能な MP3ファイルの URLパス
                audio_url  = f"/webtts/sound/{filename}"
                html_sound = f"""
                    <hr>
                    <h3>生成結果:</h3>
                    <audio controls autoplay>
                        <source src="{audio_url}?t={os.path.getmtime('/opt/webtts/www/sound/'+filename)}" type="audio/mpeg">
                        お使いのブラウザはaudio要素をサポートしていません。
                    </audio>
                """
            except Exception as e:
                print(f"<p style='color:red;'>エラー発生: {e}</p>")

#-----------------------------------------------------------------------
# Voice選択リストを作成
html_select_voice = cvoices.getHtml_select(active_vname=active_vname)

#-----------------------------------------------------------------------
# HTTPヘッダーの出力
print("Content-Type: text/html; charset=utf-8\n")

#-----------------------------------------------------------------------
# HTMLの出力
print(f"""
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Google Cloud TTS Demo</title>
</head>
<body>
    <h1>Text to Speech</h1>
    <form method="POST">
        <textarea name="text" rows="4" cols="50" placeholder="喋らせたい文字を入力...">{input_text}</textarea><br>
        <div class="setting">
            <label for="rate">読み上げ速度:</label>
            <input type="number" id="rate" name="speaking_rate" value="{speaking_rate:.1f}" step="0.1" min="0.3" max="2.0">
            <small>(0.3 ~ 2.0)</small>
            <label for="rate">音声:</label>
            {html_select_voice}
        </div>
        <button type="submit">音声を生成</button>
    </form>
    {html_sound}
</body></html>
""")

【6】TTS(Text-to-Speech)実行プログラム

my_ggtts.py

import os
from google.cloud import texttospeech

# Text-to-Speech API 鍵ファイルの絶対パス
KEY_PATH = "/opt/webtts/auth/key.json"
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = KEY_PATH

#-----------------------------------------------------------------------
# 引数:   text(合成する文字), filename(保存ファイル名)
# 戻り値: 保存されたファイルの絶対パス
def generate_voice( text, filename, speaking_rate=1.0, language_code='', voice_name='' ):
    client          = texttospeech.TextToSpeechClient()
    synthesis_input = texttospeech.SynthesisInput(text=text)
    
    if not language_code or not voice_name:
        language_code = "en-US"
        voice_name    = "en-US-Chirp3-HD-Rasalgethi"
    
    # 音声設定
    voice        = texttospeech.VoiceSelectionParams(language_code=language_code, name=voice_name)
    audio_config = texttospeech.AudioConfig(audio_encoding=texttospeech.AudioEncoding.MP3, speaking_rate=speaking_rate )
    
    # TTS実行
    try:
        response = client.synthesize_speech(input=synthesis_input, voice=voice, audio_config=audio_config)
    except Exception as e:
        raise RuntimeError(f"TTS failed: {e}")
    
    # MP3ファイル出力
    output_path = os.path.join("/opt/webtts/www/sound", filename)
    with open(output_path, "wb") as out:
        out.write(response.audio_content)
    
    return output_path

CVoice.py

import json
import os
import html

#///////////////////////////////////////////////////////////////////////
@dataclass(frozen=True)
class CVoice:
    lcode: str
    vname: str

#///////////////////////////////////////////////////////////////////////
class CVoices:
    #///////////////////////////////////////////////////////////////////
    def __init__(self, fpath=None):
        self.voices = {}
        if not fpath:
            base_dir = os.path.dirname(os.path.abspath(__file__))
            fpath = os.path.join(base_dir, 'voice.json')
        try:
            with open(fpath, 'r', encoding='utf-8') as f:
                loaddata = json.load(f)
            for voice in loaddata:
                if 'vname' not in voice:
                    print('Skip invalid data:', voice)
                    continue
                self.voices[voice['vname']] = {
                    'age':   voice.get('age'),
                    'sex':   voice.get('sex'),
                    'lcode': voice.get('lcode')
                }
        except Exception as e:
            raise RuntimeError(f"Could not load {fpath}: {e}")

    # public ///////////////////////////////////////////////////////////
    def get_item(self, vname):
        item = self.voices.get(vname)
        if not item:
            return None
        return CVoice(lcode=item['lcode'], vname=vname)

    # public ///////////////////////////////////////////////////////////
    def getHtml_select(self, conditions=None, active_vname='', elm_name='vname'):
        if conditions is None:
            conditions = {}
        filtered = self._get_filtered(conditions)
        options = []
        for vname, info in filtered.items():
            age = '' if info.get('age') is None else str(info.get('age'))
            sex = '' if info.get('sex') is None else str(info.get('sex'))
            escaped_name_attr = html.escape(str(vname), quote=True)
            escaped_name_text = html.escape(str(vname), quote=False)
            escaped_age       = html.escape(age,        quote=False)
            escaped_sex       = html.escape(sex,        quote=False)
            selected = ' selected' if vname == active_vname else ''
            label = f'{escaped_age}{escaped_sex} : {escaped_name_text}'
            options.append(f'<option value="{escaped_name_attr}"{selected}>{label}</option>')
        html_options = ''.join(options)
        escaped_elm_name = html.escape(str(elm_name), quote=True)
        return f'<select name="{escaped_elm_name}">{html_options}</select>'

    # private //////////////////////////////////////////////////////////
    def _get_filtered(self, conditions=None):
        if conditions is None:
            conditions = {}
        return {
            name: info
            for name, info in self.voices.items()
            if all(info.get(k) == v for k, v in conditions.items())
        }

【7】動作したもの

意図した通りに動くものが出来た!

en-GB-Chirp3-HD-Charon0.8お使いのブラウザはaudio要素をサポートしていません。https://www.dogrow.net/python/sample/0140/v001.mp3
en-US-Chirp3-HD-Rasalgethi0.8お使いのブラウザはaudio要素をサポートしていません。https://www.dogrow.net/python/sample/0140/v003.mp3
en-US-Chirp3-HD-Leda0.7お使いのブラウザはaudio要素をサポートしていません。https://www.dogrow.net/python/sample/0140/v002.mp3
en-GB-Chirp3-HD-Gacrux0.5お使いのブラウザはaudio要素をサポートしていません。https://www.dogrow.net/python/sample/0140/v004.mp3

■所感
・0.7倍速は遅すぎるか?
・やはり ElevenLabs の方が声質が良いなぁ、どうしようか…

【8】次にやりたいこと

・二人以上の会話を入力可能にする。
 → (音声キャラクター、英文テキスト)をセットとし、複数セットを順番に TTS処理し、一つのMP3ファイルに結合する。

・音量を上げる。

・そろそろ操作画面のデザインを改善する。(=使い易くする)


アクセス数(直近7日): ※試験運用中、BOT除外簡易実装済
  • 2026-05-07: 0回
  • 2026-05-06: 0回
  • 2026-05-05: 0回
  • 2026-05-04: 0回
  • 2026-05-03: 0回
  • 2026-05-02: 0回
  • 2026-05-01: 0回
  • モバイルバージョンを終了