0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

さくらのAI EngineのVOICEVOXで長文音声合成もできるようにする

0
Posted at

概要

背景

さくらインターネット株式会社の提供する「さくらのAI Engine」には、音声合成機能が存在する。
APIに文字列を送信することで、読み上げた音声ファイルを返却してくれるというものだ。VOICEVOXの提供するキャラクター8種から任意のものを選択して利用することができる。
しかし、このAPIには1リクエストあたり1,000モーラまで、という制限が存在する。そのため、数千字の文字列を音声合成したい場合、通常は手動で分割して送信しなければならない。
今回はPythonスクリプトを用いて、これを自動でできるようにした。

成果物

読み上げる文章を渡すと、文末や行末でそれを分割し、800文字前後のチャンクに分けて音声を取得できるスクリプトを記述した。このままだと音声ファイルは分割されてしまうので、後からそれを結合して出力できるようにした。

プログラム

Pythonスクリプトの内容は以下の通り:

"""
テキストファイルを読み込み、さくらのAI Engineの音声合成APIを使ってWAV音声を生成するスクリプト。

長いテキストは、句読点(。!?)または改行を境界にして短いブロックに分割し、
個別に音声合成した後、wave モジュールで 1 つの WAV ファイルに結合する。

使い方:
    python generate_voice.py input.txt
    python generate_voice.py input.txt -o output.wav
"""

import argparse
import json
import os
import re
import sys
import tempfile
import urllib.error
import urllib.request
import wave

# --- 設定値 ---
# API キーは環境変数から取得する。
API_KEY = os.environ.get("SAKURA_API_KEY")

# 音声合成 API のエンドポイント URL
URL = "https://api.ai.sakura.ad.jp/v1/audio/speech"

# 1 リクエストあたりの最大文字数。
# 長すぎるとエラーになるため、句読点・改行で区切りながらこの文字数を超えないように分割する。
MAX_LENGTH = 800

def split_text(text: str, max_length: int = MAX_LENGTH) -> list[str]:
    """
    長いテキストを、句読点または改行を区切りとしたブロックに分割する。

    正規表現 (?<=[。!?\n]) は「直前に句読点または改行がある位置」で分割する。

    引数:
        text:入力テキスト全文。
        max_length:1 ブロックあたりの最大文字数。

    戻り値:
        分割後のテキストブロックのリスト。
    """
    # 空文字や改行のみの要素は除去し、意味のあるブロックだけを残す
    parts = [p for p in re.split(r"(?<=[。!?\n])", text) if p.strip()]

    chunks: list[str] = []   # 最終的なブロックを格納するリスト
    current = ""             # 現在組み立て中のブロック

    for part in parts:
        # 現在のブロックに part を追加しても制限内なら結合する
        if len(current) + len(part) <= max_length:
            current += part
        else:
            # 制限を超える場合は、現在のブロックを確定し、新しいブロックを開始する
            if current:
                chunks.append(current)
            current = part

    # ループ終了後に残っているブロックを追加する
    if current:
        chunks.append(current)

    return chunks


def call_speech_api(chunk: str, api_key: str) -> bytes:
    """
    さくらのAI Engine音声合成APIを呼び出し、生成されたWAVデータをバイナリで返す。

    引数:
        chunk:音声合成するテキストブロック。
        api_key:API認証用のBearerトークン。

    戻り値:
        APIから返却されたWAVバイナリデータ。
    """
    payload = {
        "model": "shikokumetan",
        "input": chunk,
        "voice": "normal",
        "response_format": "wav",
    }
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")

    # HTTP POST リクエストを構築
    req = urllib.request.Request(
        URL,
        data=data,
        headers={
            "Accept": "audio/wav",                          # レスポンスは WAV を期待
            "Content-Type": "application/json; charset=utf-8",  # リクエストボディの形式
            "Authorization": f"Bearer {api_key}",           # Bearer 認証トークン
        },
        method="POST",
    )

    with urllib.request.urlopen(req) as res:
        return res.read()


def concatenate_wav_files(temp_files: list[str], output_file: str) -> None:
    """
    複数のWAVファイルを、音声フレームを連結する形で 1 ファイルにまとめる。

    waveモジュールを使うことで、ヘッダー構造を意識せず安全に結合できる。
    ただし、すべての WAV が同じサンプリングレート・チャンネル・ビット深度を
    持っている必要がある。本APIは同じパラメータで生成されるため問題ないが、
    念のため先頭ファイルのパラメータを使って書き出す。

    引数:
        temp_files:結合元の一時WAVファイルパスのリスト(順序あり)。
        output_file:出力先のWAVファイルパス。
    """
    with wave.open(output_file, "wb") as out_wav:
        for i, temp_file in enumerate(temp_files):
            with wave.open(temp_file, "rb") as in_wav:
                if i == 0:
                    # 1 ファイル目のフォーマット情報(チャンネル数、サンプル幅、
                    # フレームレートなど)を出力ファイルに設定する
                    out_wav.setparams(in_wav.getparams())
                # 音声フレームを読み込み、そのまま出力ファイルに追記する
                out_wav.writeframes(in_wav.readframes(in_wav.getnframes()))


def generate_audio(input_file: str, output_file: str = "output.wav") -> int:
    """
    テキストファイルから音声ファイルを生成するメイン処理。

    引数:
        input_file:入力テキストファイルのパス。
        output_file:出力WAVファイルのパス。

    戻り値:
        終了ステータスコード。0 は成功、1 は何らかの失敗。
    """
    # --- 入力ファイルの読み込み ---
    try:
        with open(input_file, "r", encoding="utf-8") as f:
            text = f.read().strip()
    except FileNotFoundError:
        print(f"エラー:指定されたファイルが見つかりません: {input_file}", file=sys.stderr)
        return 1
    except OSError as exc:
        print(f"エラー:ファイルの読み込みに失敗しました: {exc}", file=sys.stderr)
        return 1

    if not text:
        print("テキストが空です。")
        return 1

    # --- API キーの確認 ---
    if not API_KEY:
        print(
            "エラー:APIキーが設定されていません。\n"
            "環境変数SAKURA_API_KEYを設定してください。",
            file=sys.stderr,
        )
        return 1

    # --- テキストの分割 ---
    chunks = split_text(text)
    print(f"テキストを {len(chunks)} 個のブロックに分割しました。")

    # --- ブロックごとに音声合成 ---
    temp_files: list[str] = []  # 生成した一時 WAV ファイルのパスを記録
    try:
        for i, chunk in enumerate(chunks):
            print(f"[{i + 1}/{len(chunks)}] 音声生成中...({len(chunk)}文字)")

            try:
                wav_data = call_speech_api(chunk, API_KEY)
            except urllib.error.HTTPError as exc:
                # API からエラーレスポンスが返された場合
                body = exc.read().decode("utf-8", errors="replace")
                print(
                    f"エラー:API リクエストが失敗しました(HTTP {exc.code}\n{body}",
                    file=sys.stderr,
                )
                return 1
            except urllib.error.URLError as exc:
                # ネットワーク接続エラー、DNS エラー、タイムアウトなど
                print(f"エラー:API への接続に失敗しました: {exc.reason}", file=sys.stderr)
                return 1

            # 一時ファイル名に PID とインデックスを含めることで、
            # 同じマシン上で複数のプロセスを並列実行しても衝突しにくくする
            temp_path = os.path.join(
                tempfile.gettempdir(),
                f"temp_voice_{os.getpid()}_{i}.wav",
            )
            with open(temp_path, "wb") as f:
                f.write(wav_data)
            temp_files.append(temp_path)

        # --- WAV ファイルの結合 ---
        print("音声を結合しています...")
        concatenate_wav_files(temp_files, output_file)
        print(f"成功:{output_file} に保存しました。")
        return 0

    finally:
        # 成功・失敗に関わらず、生成した一時ファイルを削除する
        for temp_file in temp_files:
            try:
                if os.path.exists(temp_file):
                    os.remove(temp_file)
            except OSError as exc:
                # 一時ファイル削除に失敗しても、メイン処理の結果は変えないが警告は出す
                print(f"警告:一時ファイルの削除に失敗しました: {temp_file} ({exc})", file=sys.stderr)


def parse_args() -> argparse.Namespace:
    """
    コマンドライン引数をパースする。

    Returns:
        パース結果の Namespace オブジェクト。
    """
    parser = argparse.ArgumentParser(
        description="テキストファイルからさくらのAI Engine 音声合成APIでWAV音声を生成する。"
    )
    parser.add_argument("input_file", help="読み込むテキストファイルのパス")
    parser.add_argument(
        "-o",
        "--output",
        default="output.wav",
        help="出力 WAV ファイルのパス(デフォルト: output.wav)",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    sys.exit(generate_audio(args.input_file, args.output))

分割と合成のため長いスクリプトになっているが、やっていることは単純で、環境変数からさくらのAI Engine APIキーを、引数からは原文のテキストファイルと出力する音声ファイルパスを取得して、文字列分割・API呼び出し・音声結合をしているだけだ。

呼び出し

呼び出す際は以下のようなコマンドになる。

$env:SAKURA_API_KEY="さくらのAI EngineのAPIキー"
python generate_voice.py テキストファイル -o 出力する音声ファイル

まとめ

今回は短く単純な内容だが、さくらのAI Engineで長文音声合成をしたいと考えている人に役に立つことを願っている。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?