はじめに
英単語の読み上げ音声データセットを得るため、TTSに読み上げさせたところ意外とうまくいかなかった、という備忘録です。
背景
英単語のみの読み上げ音声データが欲しいです。
"english"や"clear"など、文章ではなく単語単体を読み上げた音声が欲しいです。
既存の読み上げデータセットは文章を読み上げたものが多く、単語を読み上げた使いやすそうなものが見つかりませんでした。
自分で読み上げることもできますが、ネイティブでないため、TTSを利用するほうが品質的に良いと考えます。
そこで、TTSで英単語を読み上げさせる実験を行いました。
OpenAI-TTS
まず、OpenAIのTTSを試してみました。
import openai
from pathlib import Path
def generate_speech_openai(text: str, output_path: str) -> None:
"""OpenAIのTTS APIを使って音声を生成する
Args:
text: 音声化するテキスト
output_path: 出力ファイルパス (.mp3形式)
Raises:
openai.OpenAIError: API呼び出しに失敗した場合
"""
response = openai.audio.speech.create(
model="tts-1", # または "tts-1-hd"
voice="nova", # 他に "alloy", "echo", "onyx", "fable" などあり
input=text
)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(response.content)
words = ["ZYUGANOV", "ZYGOTE", "ZYGOMATIC", "ZYGOMA", "ZYDECO", "ZVEGINTZOV"]
for word in words:
generate_speech_openai(word, output_path=f"openai/{word}.mp3")
単語の癖が強い点はさておき、このコードを実行すると音声ファイルは生成されるものの、ほとんど無音状態になってしまいます。
以下を試しても改善しませんでした:
- 小文字での入力
- 末尾にピリオド(.)や感嘆符(!)を追加
- "hello"などより一般的な単語での実行
"ZYUGANOV is OK"のように、後ろに適当な単語を付加して文章にすると正常に読み上げてくれました。
問題の原因:入力テキストが1単語程度と短い場合、音声合成が適切に動作しないようです。
調べたところ、短い単語が音声合成できないというイシューが既に報告されていました(2024年4月)。現在も改善されていない可能性があります。
Parler-TTS
OpenAI-TTSの制限を受けて、オープンソースモデルであるparler-tts-mini-v1を試してみました。
インストール:
uv add parler-tts
def generate_speech_batch(
prompts_and_paths: list[tuple[str, str]],
description: str = "",
batch_size: int = 4
) -> None:
"""テンソルバッチ処理による効率的な音声生成
Args:
prompts_and_paths: (prompt, output_path) のタプルのリスト
description: 音声の説明文(全プロンプト共通で使用)
batch_size: 一度に処理するバッチサイズ
Note:
GPUメモリ不足の場合はbatch_sizeを小さくしてください。
"""
logger.info(
f"Starting tensor batch speech generation for {len(prompts_and_paths)} items "
f"(batch_size={batch_size})"
)
description = description or "A female speaker delivers a slightly expressive and animated speech with a moderate speed and pitch. The recording is of very high quality, with the speaker's voice sounding clear and very close up."
# バッチごとに処理
for batch_start in range(0, len(prompts_and_paths), batch_size):
batch_end = min(batch_start + batch_size, len(prompts_and_paths))
batch_items = prompts_and_paths[batch_start:batch_end]
try:
logger.debug(
f"Processing batch {batch_start // batch_size + 1}: "
f"items {batch_start + 1}-{batch_end}"
)
# バッチ内のプロンプトを抽出
batch_prompts = [item[0] for item in batch_items]
batch_paths = [item[1] for item in batch_items]
# 出力ディレクトリを作成
for output_path in batch_paths:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# バッチ用のdescriptionとpromptをトークナイズ
# 同じdescriptionを全バッチで使用
descriptions = [description] * len(batch_prompts)
# バッチトークナイズ(パディング付き)
description_tokenized = tokenizer(
descriptions, return_tensors="pt", padding=True, truncation=True
)
description_tokens = description_tokenized.input_ids.to(device)
description_attention_mask = description_tokenized.attention_mask.to(device)
prompt_tokenized = tokenizer(
batch_prompts, return_tensors="pt", padding=True, truncation=True
)
prompt_tokens = prompt_tokenized.input_ids.to(device)
prompt_attention_mask = prompt_tokenized.attention_mask.to(device)
logger.debug(f"Description tokens shape: {description_tokens.shape}")
logger.debug(f"Prompt tokens shape: {prompt_tokens.shape}")
logger.debug(
f"Description attention mask shape: {description_attention_mask.shape}"
)
logger.debug(f"Prompt attention mask shape: {prompt_attention_mask.shape}")
# バッチ生成
with torch.no_grad():
generations = model.generate(
input_ids=description_tokens,
prompt_input_ids=prompt_tokens,
attention_mask=description_attention_mask,
prompt_attention_mask=prompt_attention_mask,
)
# 各生成結果を個別ファイルに保存
for i, (generation, output_path) in enumerate(
zip(generations, batch_paths, strict=False)
):
try:
audio_arr = generation.cpu().numpy().squeeze()
# 多次元配列の場合は最初の次元を取る
if audio_arr.ndim > 1:
audio_arr = (
audio_arr[0] if audio_arr.shape[0] == 1 else audio_arr
)
sf.write(output_path, audio_arr, model.config.sampling_rate)
logger.debug(f"Successfully generated: {output_path}")
except Exception as e:
logger.error(
f"Failed to save audio for '{batch_prompts[i]}' "
f"to '{output_path}': {e}"
)
continue
logger.debug(f"Completed batch {batch_start // batch_size + 1}")
except Exception as e:
logger.error(
f"Failed to process batch {batch_start // batch_size + 1}: {e}",
exc_info=True,
)
# バッチ全体が失敗した場合は個別処理にフォールバック
logger.info("Falling back to individual processing for this batch")
for prompt, output_path in batch_items:
try:
generate_speech(prompt, output_path)
except Exception as fallback_e:
logger.error(f"Fallback also failed for '{prompt}': {fallback_e}")
logger.info("Tensor batch speech generation completed")
promptとoutput_pathのtupleをlistで与えるとバッチで音声合成してくれます。
GPUだと早いですが、CPUでも一応動きます。
GPUを使う場合はflash-attnもインストールするとより早いです。
いろいろ試したところ
- 単語の音声合成はOpenAIよりはマシだが、明らかに変な発音になっていたり、後半に余計な発生がついたりして成功率は高くない(体感よくて7割くらい?)。
- OpenAI-TTS用に"is OK"などをくっつけて文章にしたほうが安定する印象。ただ文章にするならOpenAI-TTSのほうが高品質。
- descriptionは最初空文字で試していたが、文章をなにか書いてあげたほうが安定する印象。
みたいな感じでした。今回はparler-ttsのminiをつかったのでlargeだったらマシだったのかもしれません。
その他のTTS
まだ試せていません。
とりあえず一番サクッと試せそうなのが上記2つだったのでそこを試しました。
あまりうまく行ったとは言えないので、他のものを試してもよいですが、2/2でだめだったので、最近のある程度高品質なTTSモデルは単語のみの合成にあまり適していないという可能性もありそうです。
今後のアイデア
TTSモデルを使うのであれば、文章で合成して、強制アライメントで単語部分だけ抽出するほうが筋がよいかもしれません。その場合、品質的にはOpenAI-TTSですが、競合モデルの開発等に使えないなどの制限を気にする場合は、Parler-TTSを使って、人手で品質チェックもありだと思います。
強制アライメントかつ得られる単語の種類にこだわりが少ないなら、そもそも文章の読み上げデータセット(peoples_speech(など)を活用したほうが早いし品質的にも良さそうです。
あとは自分で録音するとかネイティブに依頼するとかもありかもしれません。