Skip to content

feat(examples): 音声パイプラインを MoQT で可視化し、ターンごとの遅延を計測する - #358

Open
yuki-uchida wants to merge 15 commits into
feat/python-stt-examplefrom
feat/python-voice-pipeline
Open

feat(examples): 音声パイプラインを MoQT で可視化し、ターンごとの遅延を計測する#358
yuki-uchida wants to merge 15 commits into
feat/python-stt-examplefrom
feat/python-voice-pipeline

Conversation

@yuki-uchida

@yuki-uchida yuki-uchida commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

概要

音声 track ごとに VAD → STT → LLM → TTS のパイプラインを動かし、各 stage を環境変数で選べるようにしました。
返答テキストは transcript track に、合成音声は Opus で <ns>/reply track に配信します。#353 の上に積んでいます。

やったこと

  • 結果は 2 つの track で返す。<ns>/pipeline は group 0 にパイプラインの構造(nodes / edges)、group N にターン N の各ステージの start / done と所要時間・書き起こし・返答・音声 packet 数。<ns>/reply は同じ group N に返答音声(Opus)。つまり 1 会話ターン = 1 GroupID
  • ブラウザ example を React 化し、topology object から React Flow でグラフを描画。ステージの進行に合わせてノードが点灯し、ターンごとの遅延(発話開始 → 再生完了)を表で表示。発話開始は vad イベントが名指しする audio object の送信時刻から求める
  • moqt::TrackWriter / Python binding の start_group / write_group に group id を指定できるようにした(逆行は拒否)
  • pipeline/base.py の 4 プロトコル(VoiceActivityDetector / SpeechToText / LanguageModel / TextToSpeech)と、PIPELINE_VAD=silero|energyPIPELINE_STT=whisper|deepgram|openai|wavPIPELINE_LLM=gemini|echo|nonePIPELINE_TTS=gemini|none で選ぶ factory 表。none はその stage 以降を止める
  • Silero は faster-whisper 同梱の ONNX セッションを状態持ち回りでストリーミング利用。VAD が上流で区切るため Deepgram / OpenAI は websocket ではなく発話単位の REST 呼び出しに変更。Gemini LLM は google-genai の chat、Gemini TTS の 24 kHz PCM は PyAV で Opus に再エンコードして <ns>/reply に 1 返答 = 1 group で配信
  • ブラウザ example は reply track も購読し、WebCodecs の AudioDecoder で復号して AudioContext で再生。transcript の JSON に type(transcript / reply)を追加し、you: / assistant: として表示

やらないこと

  • Deepgram / OpenAI の実サービスに対する動作確認(API キーなし)。返答再生中に新しい発話が来たときの割り込み(barge-in)。応答遅延の最適化。遅れて subscribe した相手への過去ターンの配信

影響範囲

  • faster-whisper / onnxruntime が example の必須依存になり(Silero が既定 VAD)、google-genai / httpx を追加、websockets を削除

テスト

  • pytest 16 件: フェイク stage でのパイプライン順序・LLM / TTS の省略・stage 失敗後の継続、energy VAD、Silero VAD(ONNX セッションを差し替えて発話終了と pre-roll を検証)、実 MoQT セッションで PUBLISH / PUBLISH_NAMESPACE 両フローから transcript / reply の JSON と Opus の reply object が届くこと、他 track の SUBSCRIBE 拒否
  • 手動: 実 Silero + Whisper(base)に合成音声 → 1 発話・1 transcript。Playwright の Chrome に擬似マイクとして WAV を与え、echo LLM で you: / assistant: の 2 行が表示されることを確認
  • Gemini は実 API キーで確認: マイク音声 → Whisper → gemini-3.6-flash の返答 → gemini-3.1-flash-tts-preview の音声(24 kHz)→ Opus 301 packet を reply track へ配信、までブラウザ example 経由で通過
  • React Flow の可視化を Playwright で確認: topology から 8 ノードを描画し、ターン 1 が group 1 に載ることと、stt 2091 ms / llm 3151 ms / tts 6288 ms / 再生 5417 ms / ターン合計 26461 ms(発話 9.5 秒を含む)が表に出ることを確認

備考

  • ponytail の over-engineering レビューを 4 巡かけ、PcmFormat / OpusEncoder / TrackFanout / イベントログと GET /transcripts / 誰も設定しない env つまみを削除した(3 コミット)。途中で見つかった不具合(track ごとに Whisper モデルを重複ロード、終了した会話が hub に残る、購読者がいなくても TTS を Opus 化、Silero の pre-roll が 2 発話目以降 32 ms に縮む)は fix コミットに分けている

@yuki-uchida yuki-uchida changed the title feat(examples): STT サーバーを VAD → STT → LLM → TTS の差し替え可能なパイプラインにする feat(examples): 音声パイプラインを MoQT で可視化し、ターンごとの遅延を計測する Sep 8, 2026
…line

Each audio track now feeds a VoicePipeline whose stages are chosen by
environment variables: PIPELINE_VAD (silero via the ONNX model bundled
with faster-whisper, or energy), PIPELINE_STT (whisper, deepgram,
openai, wav), PIPELINE_LLM (gemini, echo, none) and PIPELINE_TTS
(gemini, none). Stages implement the protocols in pipeline/base.py and
are registered in factory tables in pipeline/__init__.py, so a new
service is one class plus one table entry. LLM and TTS are optional;
'none' stops the pipeline after the previous stage.

Because the VAD now segments upstream, the speech-to-text stages are
per-utterance requests: Deepgram uses its pre-recorded endpoint and
OpenAI the audio transcriptions endpoint instead of websockets; Whisper
keeps its no_speech_prob filter. Utterances are processed by a worker
task so the track reader never waits for a model or a remote service.

Results go back over MoQT: transcripts and replies as JSON objects on
<namespace>/transcript, synthesized speech re-encoded to Opus on
<namespace>/reply, one group per reply.

Verified with fakes in pytest and, with the real Silero and Whisper
models, on synthesized speech; Deepgram, OpenAI and Gemini are
implemented against their documented APIs but not called by tests.
The page now subscribes to <namespace>/reply as well, decodes each Opus
object with WebCodecs and schedules it on an AudioContext, and renders
transcript and reply objects as 'you:' / 'assistant:' lines.
…g review

Merge TranscriptEvent/ReplyTextEvent into TextEvent(kind), drop the
mono-only PcmFormat.channels, let Conversation own its counters and Opus
encoder instead of threading a state object through the sink, key the
result-track fanouts by (namespace, track) in one dict, let google-genai
read GEMINI_API_KEY itself, return faster-whisper's own segments instead
of mirroring them, turn never-passed constructor arguments into module
constants, and share one subscribeResultTrack helper in the browser
example.
OpusEncoder became encode_opus(): its only caller encoded and flushed in
one expression, and flush() threw the codec state away, so the instance
never carried state. SpeechToText.transcribe drops its pcm_format
argument, which only ever held the PIPELINE_PCM constant. The
per-namespace TrackFanout objects become one dict of writer lists with a
module-level write_group. The in-process event log and GET /transcripts
go away: transcripts already leave on the MoQT transcript track and
through the log. The STT factory loses its track_label argument, which
only the wav debug stage read and which it did not need. Track-name
environment knobs nothing can set (the browser and tests hardcode the
names) become constants, never-passed constructor arguments become
module constants, and the tests build tones with numpy and reuse
encode_opus instead of repeating the encoder.
A pipeline is built per audio track, so each concurrent track loaded its
own copy of the Whisper weights (hundreds of megabytes); the model is now
cached per (model, device, compute_type). VoiceHub also kept every
Conversation, and with it the track's pipeline and model handles, after
the track ended; the entry is removed when run() returns.
PcmFormat had one field left, so the stages pass sample rates as ints and
PIPELINE_SAMPLE_RATE documents the shared format. ReplyAudioEvent only
wrapped a SynthesizedSpeech and a timestamp nobody read, so the
synthesized speech is the event. VoicePipeline starts its worker in the
constructor instead of an async start() with one caller, EnergyVad's
silence window and the test tone's shape become constants, and the
Whisper stage transcribes in one executor hop now that the model itself
is cached.

The Silero test no longer shells out to macOS `say` (it was skipped
everywhere else, so nothing covered the emitting path in CI): the ONNX
session is injected, and queued probabilities drive the state machine
through push(), which also pins down the pre-roll and the pause. The
stage-failure test now asserts that the utterances after the failure are
transcribed rather than reading a private field.
…-roll

The reply branch encoded Opus even when nobody subscribed to the reply
track, and looking the writers up with setdefault inserted empty lists,
so stats() reported subscriber entries for tracks nobody had subscribed
to. It now reads the writers without inserting and returns early when
there are none.

SileroVad reset its pre-roll history to the single window that ended an
utterance, so every utterance after the first started with ~32 ms of
audio before the speech instead of SPEECH_PAD_MS; the history now keeps
the tail of the utterance just emitted.
AudioDecoder's target sample rate and the wav stage's TRANSCRIPT_DIR were
parameters no caller set; the decoder reads PIPELINE_SAMPLE_RATE and the
wav stage defaults to recordings/. The README no longer claims a test
that synthesizes speech with macOS `say`.
The Gemini stages need an API key in the environment, and the ignore rule
only covered a .env at the repository root, so a key file next to an
example could be committed. Tracked .env.example files stay visible.
A sourced .env needs export on every line or the child process does not
inherit the variable; uv reads plain KEY=value lines instead.
Verified with a real API key and fixed what the first call exposed.

GeminiLlm discarded the genai.Client after creating the chat, and the
SDK's httpx client closes itself when collected, so the first
send_message failed with "Cannot send a request, as the client has been
closed"; the client is now kept alive by the stage.

The default models did not work with a newly issued key:
gemini-2.5-flash answers 404 "no longer available to new users" and
gemini-flash-latest returned 503 after a minute of retries, so the chat
default is gemini-3.6-flash (the model the 404 recommends).
gemini-2.5-flash-preview-tts rejected two of four short inputs with
"Model tried to generate text, but it should only be used for TTS",
while gemini-3.1-flash-tts-preview answered all four, so that is the TTS
default.

The synthesized PCM rate is read from the response mime type instead of
being assumed to be 24 kHz.
start_group() and write_group() only ever used the next id in sequence,
so an application that numbers groups by something of its own — a
conversation turn, a source frame — could not put its objects in the
matching group. Both now take an optional id and reject one that would
go backwards, since draft-14 §10.4.1 has group ids increase within a
track.
start_group() and write_group() take an optional group_id so Python can
number groups by its own key.
The server described itself only in its logs and published transcripts
as loose JSON objects, so a client could not tell which reply belonged
to which utterance or where the time went.

The transcript track becomes <namespace>/pipeline. Group 0 carries the
pipeline's nodes and edges, sent to every new subscriber, so a client can
draw the graph without knowing which implementations are configured.
Group N carries conversation turn N: the utterance the VAD cut (with the
audio object that completed it), each stage's start and duration, the
transcript and the reply, and the packet count of the synthesized audio.
The reply audio itself rides in group N of <namespace>/reply, so a turn
is one group id on both tracks.

VoicePipeline now owns the turn numbering and the stage timing; feed()
takes an opaque marker so the transport can name the audio it consumed
without the pipeline knowing about MoQT.
…turn

The example becomes a React page. It reads the topology object from the
pipeline track and draws it with React Flow, lighting each node as the
stage events of the current turn arrive, and lists every turn with the
server's stage durations.

The turn is timed in this browser's clock: the vad event names the audio
object that completed the utterance, the publisher remembers when it sent
that object, and the reply player reports when the last sample of the
turn's audio has played, so the table shows the delay from the start of
speech to the end of playback.
@yuki-uchida
yuki-uchida force-pushed the feat/python-voice-pipeline branch from 9776230 to dcf85ee Compare September 8, 2026 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant