0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

今更ですが、SST → LLM → TTS 連携をやってみた

0
Posted at

はじめに

というわけで、今流行りの? SST → LLM → TTS をやってみました。
「SST」は、音声テキスト変換(Speach to Text)、LLM は、大規模言語モデル(Large Language Model)でいわゆる「ちゃっぴー」、「TTS」は「SST」の逆で、テキスト音声変換(Text to Speach)です。

で、これで何ができるのかというと、音声で問いかけるとちゃっぴーみたいな人が回答して、それを音声で返してくれる、という「あれ」ができるわけです。

実行環境

Windows11 上に立てた WSL2(Debian12)の中にいろいろインストールしました。
あと GPU は RTX5060Ti で、WSL内で使用する CUDA は 12.8 を使いました。
#nvidia-smi をすると CUDA Version 12.9 とでますが、12.8でも動くみたいなのでそのまま使いました。
#インストールするアプリの関係でそうなってます。

音声テキスト変換(SST)

Whisper.cpp をインストールしました。なんか音声テキスト変換の定番みたいです。

$ git clone https://github.com/ggml-org/whisper.cpp.git
$ cd whisper.cpp

$ cmake -B build -DGGML_CUDA=1 -DWHISPER_FFMPEG=1 -DGGML_CUDA_ARCH=89 -DCMAKE_CUDA_ARCHITECTURES="89;89-real" -DWHISPER_SDL2=1 -DCUDAToolkit_ROOT=/usr/local/cuda-12.8 -DCMAKE_CUDA_COMPILER=/usr/local/cuda-12.8/bin/nvcc

CUDAを使うので -DGGML_CUDA=1 です、あと扱える音声ファイルの形式を増やしたいので -DWHISPER_FFMPEG=1 にしてます。
あと、ストリーミングは今回は使ってませんが、 -DWHISPER_SDL2=1 で可能な設定もしておきます。
残りのパラメーターは CUDA を使うための各種設定です。この辺りは自身の環境に合わせてください。

そしたらコンパイル

$ cmake --build build --config Release

コンパイルが終わったら、モデルをダウンロードします。
今回は、ggml-large-v3-turbo.bin を使用します。#なんか良いということだったので。

$ cd models
$ ./download-ggml-model.sh large-v3-turbo

はい、これで Whisper.cpp のインストールは完了です。
試してみましょう。
適当な音声ファイルを用意して、以下を実行してみます。

$ ./build/bin/whisper-cli -m ./models/ggml-large-v3-turbo.bin -f ./samples/mana1.wav -l ja

つらつらログが出力されますが、

ggml_cuda_init: found 1 CUDA devices:
  Device 0: NVIDIA GeForce RTX 5060 Ti, compute capability 12.0, VMM: yes
…
whisper_backend_init_gpu: device 0: CUDA0 (type: 1)
whisper_backend_init_gpu: found GPU device 0: CUDA0 (type: 1, cnt: 0)
whisper_backend_init_gpu: using CUDA0 backend

があれば、GPU使ってるということで、大丈夫です。
で、本題の音声テキスト変換はその少し下の方に出ています。

[00:00:00.000 --> 00:00:09.980]  ねえねえ、聞いて。隊長さんが選んでくれたこの服、マナにぴったりだって、みんな褒めてくれたんだよ。嬉しいなあ。

ちゃんと変換できてますね。

whisper_print_timings:     load time =  8463.63 ms
whisper_print_timings:     fallbacks =   0 p /   0 h
whisper_print_timings:      mel time =    15.67 ms
whisper_print_timings:   sample time =    53.18 ms /   248 runs (     0.21 ms per run)
whisper_print_timings:   encode time =   534.19 ms /     1 runs (   534.19 ms per run)
whisper_print_timings:   decode time =     0.00 ms /     1 runs (     0.00 ms per run)
whisper_print_timings:   batchd time =   340.34 ms /   246 runs (     1.38 ms per run)
whisper_print_timings:   prompt time =     0.00 ms /     1 runs (     0.00 ms per run)
whisper_print_timings:    total time =  9439.58 ms

時間も、モデル読み込み時間(load time)を除くと、943.38ms なので、1秒以下で約10秒の音声データを
テキストに変換できた、ということになります。いいですね。
なので、これを API として利用することにします。

$ ./build/bin/whisper-server -m ./models/ggml-large-v3-turbo.bin -l ja --host 0.0.0.0 --port 7840 --public ./var --tmp-dir ./var --convert -fa

です。
--convert は音声ファイルをwhisperが解析できる形に自動変換するオプションです。
--fa は処理に flash-attention という高速解析の機能を使用する、というオプションです。
これはなくても速かったですけど、おまじないとしてつけておきます。
--public ./var と --tmp-dir ./var は音声自動変換等で使用するディレクトリの指定みたいです。

で、アクセスは

$ curl -v -X POST "http://localhost:7840/inference" -F "file=@mana1.wav"

です。回答は json形式で返ってきます。

{"text":"ねぇねぇ、聞いて!隊長さんが選んでくれたこの服、マナにぴったりだって、みんな褒めてくれたんだよ!嬉しいなぁ!\n"}

大規模言語モデル(LLM)

llama.cpp をインストールしました。これも定番ですね。

$ git clone https://github.com/ggerganov/llama.cpp
$ cd llama.cpp

$ cmake -B build -DGGML_CUDA=1 -DCMAKE_CUDA_COMPILER=/usr/local/cuda-12.8/bin/nvcc -DCMAKE_BUILD_WITH_INSTALL_RPATH=1 -DCUDAToolkit_ROOT=/usr/local/cuda-12.8 -DGGML_CUDA_ARCH=89 -DCMAKE_CUDA_ARCHITECTURES="89;89-real" -DBUILD_SHARED_LIBS=0
$ cmake --build build --config Release

です。
オプションは Whisper.cpp の時に使用したものとほぼ同じです。

そしたらモデルですが、今回は Phi-4-Q4_K_M.gguf を使用しました。
ダウンロードは、このあたりからしてみてください。
 → https://huggingface.co/unsloth/phi-4-GGUF

そして今回は、llama-swap という動的にモデルを変更することができるラッパーもインストールしてみました。

ここからバイナリ形式のものを取ってきてインストールします。最新版で良いと思います。
 → https://github.com/mostlygeek/llama-swap/releases/tag/v194

インストールとかは llama-swap で検索すると出てくるのでそちらを見てください。
そんなに難しくはありません。
参考までに今回使用している config.yaml を載せておきます。

# llama-swap configuration
healthCheckTimeout: 120
logLevel: info
startPort: 60000
macros:
  "latest-llama": >
    ${env.HOME}/src/cpp/llama.cpp/build/bin/llama-server
    --port ${PORT}
  "models_dir": ${env.HOME}/src/cpp/llama.cpp/models
models:
  "Phi-4-Q4_K_M":
    cmd: |
      ${latest-llama}
      --model ${models_dir}/Phi-4-Q4_K_M.gguf

です。起動は以下。

$ ./llama-server --config ./config.yaml --listen 0.0.0.0:11344

ブラウザで11344ポートにアクセスするといろいろ情報をみることができます。
リクエストでモデルを指定するのですが、まあそれでも大丈夫だと思いますが、ロード時間短縮のためにブラウザからモデルをロードしておきましょう。

アクセスは、こんな感じ。

$ curl -X POST -H "Content-Type: application/json" -d '{"messages": [{"role": "system", "content": "簡潔に、50文字程度で答えてく ださい。"}, {"role": "user", "content": "ルビーちゃん、何が好き?"}], "model": "Phi-4-Q4_K_M"}' http://localhost:11344/v1/chat/completions

json形式で返ってきます。

{"choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"ルビーちゃんは、おもちゃで遊んだり、新しいことを学んだり、外で散歩するのが好きです。また、飼い主さんと一緒にいる時間も大切にしています。"}}],"created":1772606810,"model":"Phi-4-Q4_K_M.gguf","system_fingerprint":"b8116-492bc3197","object":"chat.completion","usage":{"completion_tokens":70,"prompt_tokens":42,"total_tokens":112},"id":"chatcmpl-uekfOmHZFQHT1t8iVoJ52YYMbWZ2SdQn","timings":{"cache_n":0,"prompt_n":42,"prompt_ms":660.939,"prompt_per_token_ms":15.736642857142856,"prompt_per_second":63.54595507300977,"predicted_n":70,"predicted_ms":1706.247,"predicted_per_token_ms":24.374957142857145,"predicted_per_second":41.02571315876306}}%

ちょっと的外れな回答ですね。
Phi-4 は 2025年1月公開なので、まだ情報が古いので、仕方ありません。

テキスト音声変換(TTS)

いろいろあるみたいです。最初は Qwen3-TTS を試みたのですが、どうにも生成に時間がかかってしまうので、諦めまして、次に ChatLLM.cpp というのも試してみました。
C++ だし、最近 Qwen3-TTSモデルにも対応した、って書いてあったから、速いかなって思いましたが、やはりこれも生成が遅くてだめでした。cuda とか flash-attention とかちゃんと認識してるっぽいんですけどね。

いずれも、”こんにちは” を生成するのに8秒とかかかりました。AIさんに聞いてみるとそれは遅すぎるみたいな。。。

Qwen3-TTS はモデルも軽くてしっかりしているので、生成的には良いのですが、内部的に特殊なことをしてるみたいなので、まあもう少し待ちなんでしょうかね。。。

というわけで、軽量TTSである Kokoro-82M TTS をインストールしてみました。

Kokoro は pip でインストールみたいなので、準備します。

$ mkdir kokoro
$ cd kokoro
$ python -m venv venv
$ source ./venv/bin/activate
$ pip install torch==2.8.0+cu128 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
$ pip install ./wheels/flash_attn-2.8.3+cu128torch2.8-cp311-cp311-linux_x86_64.whl

wheel はここから取得してください。今回は v0.7.16 のところからです。
 → https://github.com/mjun0812/flash-attention-prebuild-wheels/releases
torch と cuda と python のバージョンでいろいろあるので、自分のやつと合っているものを取得

ではインストール。

$ pip install kokoro>=1.0.0 misaki[en,ja] soundfile

[en,ja]で英語と日本語対応ということみたいです。
が、kokoro は辞書がないみたいなので、別途インストールが必要です。

$ sudo apt install espeak-ng
$ python -m unidic download

辞書(unidic)をダウンロード。この辞書は国立国語研究所がメンテしてるらしい。

で、APIサーバですが、何もないのでコードを書きます。

$ vi api_server.py

api_server.py
# base code by Gemini
import torch
import io
import asyncio
import soundfile as sf
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import uvicorn
from kokoro import KPipeline
from contextlib import asynccontextmanager

class TTSRequest(BaseModel):
    text: str
    voice: str = "jf_alpha"
    speed: float = 1.0

# モデル保持、アプリの状態保持
app_state = {}
# 最大待機時間(秒)
TIMEOUT_SECONDS = 30
 
@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Initializing GPU Resources...")
    app_state["pipeline"] = KPipeline(lang_code='j', device='cuda')
    app_state["lock"] = asyncio.Lock()
    with torch.inference_mode():
        _ = list(app_state["pipeline"]("起動", voice="jf_alpha"))
    yield
    app_state.clear()

app = FastAPI(lifespan=lifespan)
@app.post("/tts")
async def tts_post_endpoint(request: TTSRequest):
    if not request.text:
        raise HTTPException(status_code=400, detail="Text is empty")
    pipeline = app_state["pipeline"]
    lock = app_state["lock"]
    async def generate_chunks():
        try:
            # 1. 指定時間内にロックを取得し、生成を開始できるか監視
            async with asyncio.timeout(TIMEOUT_SECONDS):
                async with lock:
                    with torch.inference_mode():
                        generator = pipeline(request.text, voice=request.voice, speed=request.speed)
                        for _, _, audio in generator:
                            buffer = io.BytesIO()
                            sf.write(buffer, audio, 24000, format='WAV')
                            yield buffer.getvalue()
                            await asyncio.sleep(0) # イベントループに処理を戻す
        except asyncio.TimeoutError:
            # 2. タイムアウト時のログ出力(サーバー側)
            print(f"Timeout: Request dropped after {TIMEOUT_SECONDS}s of waiting.")
            # StreamingResponse内でraiseしてもクライアントには伝わりにくいが
            # ジェネレータを中断させるために必要
            raise 

    # 注意: StreamingResponseを開始した後にタイムアウトすると、
    # クライアントには「途中で切れたWAV」が届きます。
    # 完全に安全にするには、まずロック取得だけを個別にタイムアウト判定します。
    
    try:
        # ロック取得そのものにタイムアウトを設定
        await asyncio.wait_for(lock.acquire(), timeout=TIMEOUT_SECONDS)
    except asyncio.TimeoutError:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="サーバーが混み合っています。しばらく経ってから再度お試しください。"
        )
    finally:
        # ロックを取得できた場合のみ、一旦解放してジェネレータに渡す
        # (StreamingResponse内の async with lock で再度取得するため)
        if lock.locked() and not any(task.get_name() == "generator" for task in [asyncio.current_task()]):
            lock.release()
    return StreamingResponse(generate_chunks(), media_type="audio/wav")

if __name__ == "__main__":
    # uvicornのワーカー数は必ず「1」にしてください(GPUメモリ競合を避けるため)
    uvicorn.run(app, host="0.0.0.0", port=7850, workers=1)

はい、実行。

$ python ./api_server.py

アクセスはこんな感じ。

$ curl -X POST -H "Content-Type: application/json" -d '{"text": "私の答え、見せてあげます!", "voice": "jf_alpha"}' http://localhost:7850/tts --output out_voice.wav

これで大体

real time: 0.0878 [sec]
audio length: 2.8750 [sec]
RTF(Real Time Factor): 0.0305

です。速いです。が、音質というか、やっぱりちょっと癖があって、長い文章だと区切りの間とか漢字も読み間違えたりします。このへんはあれなんですかね、速さと引き換えなんでしょうか。
要考察ですが、とりあえずこれでやってみます。

連携するサービス

というわけで、以上まででそれぞれの要素ができたので、今度はこれを連携するものを作ります。
何か良いものがあるかもしれませんが、とりあえず自作してみました。

Ruby + Sinatra で作りました。
ソース全部だとたくさんなのでポイントを抜粋しますね。

Gemfile で以下を追加です。

gem "faye-websocket"
gem "puma"
gem "faraday"
gem "faraday-multipart"

faye-websocket はその名の通り WebSocket を扱う gem です。
今回は、ブラウザを使ってやるのでこれです。
puma はウェブサーバで、faye-websocket を使う時には、これか thin らしいですが puma にしました。
faraday は API へ問い合わせたりするのに使います。

で、とりあえず各要素へアクセスするものを作っておきます。クラス変数にしました。

  # ---- whisper.cpp server ---
  @@whisper = Faraday.new(url: "http://localhost:7840") do |f|
    f.request :multipart
    f.adapter Faraday.default_adapter
  end
  # ---- llama-swap ----
  @@llama = Faraday.new(url: "http://localhost:11344") do |f|
    f.headers["Content-Type"] = "application/json"
    f.adapter Faraday.default_adapter
  end
  # ---- Kokoro(TTS) ----
  @@kokoro = Faraday.new(url: "http://localhost:7850") do |f|
    f.headers["Content-Type"] = "application/json"
    f.adapter Faraday.default_adapter
  end

実装のメイン部分です。

  get "/cable" do
    if Faye::WebSocket.websocket?(request.env)
      ws = Faye::WebSocket.new(request.env)

      ws.on :open do |event|
      end

      ws.on :message do |event|
        txt = ""; ans = ""; wav = nil

        # save recorded voice
        tf = Tempfile.open("webm_", varpath) do |fp|
          fp.write event.data
          fp # 最後に自分自身を返さないと tf.path とかできないみたい。。。ぐぬぬ
        end
        # send to whisper.cpp(Speak to Text)
        stt_endpoint = "/inference"
        stt_payload = { file: Faraday::Multipart::FilePart.new(tf.path,
                                                               "audio/webm") }
        stt_response = @@whisper.post(stt_endpoint, stt_payload)
        txt = JSON.parse(stt_response.body)["text"]
        File.unlink(tf.path) # Tempfile はここで削除しておきます。

        # send response
        EM.next_tick do
          ws.send({"txt": txt, "ans": ans, "wav": wav}.to_json)
          
          # send to llama(LLM)
          begin
            llm_endpoint = "/v1/chat/completions"
            llm_payload =
              { model: "Phi-4-Q4_K_M",
                messages: [
                  { role: "system", content: "簡潔に50文字程度で答えなさい。" },
                  { role: "user", content: "#{txt}" }
                ]
              }
            llm_response = @@llama.post(llm_endpoint) do |req|
              req.body = llm_payload.to_json
            end
            res = JSON.parse(llm_response.body)
            ans = res.dig("choices", 0, "message", "content")
          rescue Faraday::Error => e
            puts "Error: #{e.message}"
          end

          EM.next_tick do
            # send response
            ws.send({"txt": txt, "ans": ans, "wav": wav}.to_json)
            
            # send to TTS(Kokoro)
            begin
              tts_endpoint = "/tts"
              tts_payload = { "text": ans,
                              "voice": "jf_alpha",
                              "speed": 1.25 }
              tts_response = @@kokoro.post(tts_endpoint) do |req|
                req.body = tts_payload.to_json
              end
              wav = Base64.encode64(tts_response.body)
            rescue Faraday::Error => e
              puts "Error: #{e.message}"
            end

            EM.next_tick do
              # send response
              ws.send({"txt": txt, "ans": ans, "wav": wav}.to_json)
            end
          end
        end
      end

      ws.on :close do |event|
        ws = nil
      end

      ws.rack_response
    end
  end

ですです。

ws.on :message do |event| で音声を受信して、それぞれの処理に渡します。
まあベタですが、受信した音声を一旦ファイルに保存して、Whisper に渡します。
そしたらテキストが返ってくるので、それを llama-swap に渡します。
少しすると応答が返ってくるので、それを kokoro に渡して、最後戻ってきた音声データをクライアントに返します。

で、各ターンでどうなっているかを示すために、EM.next_tick do で各ターン毎に情報を返すようにしてみました。

以上はサーバ側でしたので、ブラウザ側を作っていきます。

<!DOCTYPE html>
<html>
  <head>
    <title>VoiceSocket Test</title>
  </head>
  <body>
    <div class="description"><p>VoiceSocket Test</p></div>

    <div class="voice-talk">
      <button id="talkstart" value="0" style="float: left;">Talk Start</button>
      <div style="font-size: small;"><span id="talkstatus" style="padding: 0 8px;">disconnected</span></div>
    </div>
    <div class="show-talk" style="clear: both; padding-top: 8px;">
      <div style="float: left;">計測時間:</div><div id="raptime">0 [ms]</div>
      <div style="clear: both;"></div>
      <div style="float: left;">あなた:</div><div id="talkcontent">talk</div>
      <div style="clear: both;"></div>
      <div style="float: left;">わたし:</div><div id="talkanswer">answer</div>
    </div>

    <script>
      const prot = location.protocol == "https" ? "wss" : "ws";
      const ws = new WebSocket(`${prot}://${location.host}/cable`);

      const AudioContext = window.AudioContext || window.webkitAudioContext;
      const audioCtx = new AudioContext();
  
      const statSig = document.getElementById("talkstatus");
      const talkcnt = document.getElementById("talkcontent");
      const talkans = document.getElementById("talkanswer");
      const raptime = document.getElementById("raptime");
  
      var stime = 0, etime = 0;

      ws.binaryType = "arraybuffer"; // use binary transration

      // socket receive
      ws.onmessage = async(e) => {
        let res = JSON.parse(e.data);
        talkcnt.innerText = res["txt"];
        talkans.innerText = res["ans"];

        if(res["wav"] != null && res["wav"].length > 0){
          etime = performance.now(); // rap-time end
          raptime.innerText = Math.round(etime - stime) + " [ms]";

          // play wav
          let audio_data = atob(res["wav"]);
          const alen = audio_data.length;
          const bytes = new Uint8Array(alen);
          for(let i = 0; i < alen; i++){
            bytes[i] = audio_data.charCodeAt(i);
          }      
          const blob = new Blob([bytes.buffer], { type: 'audio/webm' });
          const audio = new Audio(URL.createObjectURL(blob));
          audio.play();
        }
      };

      // mic capture and sending
      var recorder;
      var chunks = [];
      document.getElementById("talkstart").onclick = async(event) => {
        if(audioCtx.state == 'suspended'){
          audioCtx.resume();
        }
  
        let val = event.target.value;
        if(val == 0){
          //console.log(" -> recorder start");
          const stream = await navigator.mediaDevices.getUserMedia(
            { audio: true }
          );

          chunks = [];
          recorder = new MediaRecorder(stream);
          recorder.ondataavailable = (e) => {
            if(e.data.size > 0){
              chunks.push(e.data);
            }
          };
          recorder.onstop = (e) => {
            const blob = new Blob(chunks, { type: 'audio/webm' });
            if(ws.readyState == WebSocket.OPEN){
              ws.send(blob);
              statSig.innerText = "talk data send done.";
            }
          };
          recorder.start();

          setTimeout(function(){
            event.target.textContent = "Takl Stop";
            event.target.value = 1;
            statSig.innerText = "now recording...";
            stime = performance.now(); // rap-time start
          }, 2000); // delayd show queuing start message      
        } else {
          //console.log(" -> recorder stop");
          if(recorder && recorder.state !== "inactive"){
            recorder.stop();
            recorder.stream.getTracks().forEach(track => track.stop());
            event.target.textContent = "Takl Start";
            event.target.value = 0;
            statSig.innerText = "disconnected";
          }
        }
      };  
    </script>
  </body>
</html>

今回は、トランシーバー方式を採用してみました。
ボタンをクリックして、話して、終わったらもう一回ボタンを押す、というやり方です。
無音検知とかもあるみたいですが、とりあえず動作確認したかったのでこの方式にしました。
ポイントとしては、ボタンクリックしてから2秒待って「録音開始」を表示しているところですか。
すぐに音声入力すると最初のところがキレてしまうので、あえて少し待ってから話してもらうようにしています。

まあでも、もしかするとまだ現時点ではこういったトランシーバー方式の方がもしかするとわかりやすいかもしれないなぁと思ったりもします。#言い訳。

動作確認

というわけで、動かしてみました。
静止画ですいません。こんな感じです。

web-gamen.png

まあ Phi-4 があれなのは置いておいて、喋り終わってから回答が再生されるまで、この場合だと約4秒でした。いろいろ試しましたが、長くても8秒とかで、平均的には5-6秒くらい。
まあ別段に待ち時間長いとは感じませんでした。
あーあと、初回はちょっと時間がかかりますが、2回目以降が平均5-6秒ですね。

まとめ

とりあえずなんかできました。
思った以上に Whisper.cpp が優秀で、ちゃんとテキストに変換してくれました。そして速い。
なので、Phi-4 の回答がちょっと的外れが多くて残念ですが、これはモデルを変えたり、あるいは学習させたものを使ったりすれば改善されると思うので、このあたり今後の課題ですかね。
あと音声も変換速度重視だったので、読み間違えとか変な間とかあってちょっと聞くにたえないですので、また別のものを探す必要もあるかもですね。Qwen3-TTS が使えると本当はよいのですが。。。

あとは、クライアント側で文字だけじゃなくてキャラクターと連動したりすると、もっと見た目がよくなったりするのではないかと思ったりもしますね。

しかし昔はこういうのってはるか未来の出来事かと思っていましたが、もう個人でもできるレベルにまで民主化されてきたんですねー。感慨深いです。
はい、以上でした。なにかの参考になれば幸いです。

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?