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-Charon | 0.8 | |
| en-US-Chirp3-HD-Rasalgethi | 0.8 | |
| en-US-Chirp3-HD-Leda | 0.7 | |
| en-GB-Chirp3-HD-Gacrux | 0.5 |
■所感
・0.7倍速は遅すぎるか?
・やはり ElevenLabs の方が声質が良いなぁ、どうしようか…
【8】次にやりたいこと
・二人以上の会話を入力可能にする。
→ (音声キャラクター、英文テキスト)をセットとし、複数セットを順番に TTS処理し、一つのMP3ファイルに結合する。
・音量を上げる。
・そろそろ操作画面のデザインを改善する。(=使い易くする)