Skip to content

Streaming overlap: drain listener tail, fast duration estimates, seam-safe chunk scheduling - #5

Open
WinstonLiyt wants to merge 4 commits into
mainfrom
streaming-overlap-fixes
Open

WinstonLiyt wants to merge 4 commits into
mainfrom
streaming-overlap-fixes

Conversation

@WinstonLiyt

@WinstonLiyt WinstonLiyt commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes and speed-ups for the streaming overlap pipeline (python -m streaming.overlap: streaming TTS on the speaker side, streaming ASR + tree updates on the listener side).

metric (4 motions, 24 speeches) before after
playback starvation events (chunk arrives late) 5 (19.9 s total) 0
next chunk ready ahead of need, median / min 54.7 s / -4.4 s 85.3 s / +4.1 s
refinement timed out (non-first chunks) 11 / 98 0 / 108
audio at the end of each speech never transcribed for the opponent, median 11.8 s 0 s
single-chunk speeches (no streaming at all) 2 0
FastSpeech duration estimate, median 4.91 s 0.02 s
LLM rewrite call, median 8.2 s 1.6 s
TTS pipeline wall clock per speech, median 67.9 s 21.6 s
first-chunk latency, median / max 60 s / 121 s 41 s / 53 s
max abs budget remaining 40.6 s 4.0 s
seam silence between chunks, p90 / max 720 / 1240 ms 390 / 425 ms
ffmpeg decode warnings from the read/write race 46 0

Opening/rebuttal audio totals stay within 232-238 s of the 240 s budget in both versions; overrun is unchanged (2.7 s -> 2.5 s median).

What was wrong

  1. The listener dropped the last 8-14 s of every speech. It transcribes in 15 s windows and had no drain at the end of a turn; since overlap turns are marked tree_via_streaming, the full-text analysis was skipped too, so the closing sentences of every speech never reached the opponent's tree.
  2. Every duration estimate reloaded FastSpeech2 (checkpoint + vocoder + 206k-line lexicon + G2p), 4.9 s per call and ~20 GB GPU per process.
  3. The rewrite LLM ran with default reasoning effort, ~10 s per call for a length-constrained rewrite.
  4. Chunks arrived late because the refinement deadline ignored the work that happens after selection (worker joins, optional speed re-synthesis, decode, file write, bridge copy).
  5. Closings without paragraph breaks became one chunk, with no streaming or refinement and up to 40 s of unused budget; chunk 0's in_range was hard-coded True.
  6. Seam silence varied from 0.2 s to 1.2 s because tts-1 trailing silence is not constant.
  7. continuous_audio.mp3 was rewritten in place while the listener read it; a Whisper request with no timeout once hung the listener for the rest of a turn; the playback simulator inserted ~1 s of bookkeeping between chunks.
  8. The system prompt's word targets (520 / 260) correspond to 211 s of speech at the measured 2.46 words/s of tts-1 echo, so refinement was mostly expanding text (median 1.16x); with 580 / 290 it is 1.03x.

Changes by file

  • src/utils/fs_wrapper.py, src/utils/time_estimator.py: shared FastSpeech2 instance, duration-only forward (no decoder/vocoder), cached lexicon/G2p, vectorized position table. Bit-identical durations.
  • src/tts_streaming.py: reasoning_effort="minimal" for rewrites; sentence-packing chunker; 2 s deadline margin; non-blocking worker cleanup; gated speed adjustment; seam silence normalization; atomic chunk writes; honest chunk-0 in_range; optional calibration ablation (TTS_STREAM_CALIB=1, off by default, documented negative result).
  • src/streaming/env.py: final drain of the remaining audio at turn end; decode cache keyed by mtime/size; Whisper call with 30 s timeout and retries.
  • src/streaming/overlap.py: atomic write of continuous_audio.mp3; bookkeeping time charged against playback sleep.
  • src/utils/prompts/others.py: word targets 520/520/260 -> 580/580/290.

Not changed, worth knowing

  • First-chunk latency (~41 s median) is almost entirely tree planning and speech writing before TTS-Start, not the TTS pipeline.

How to reproduce

cd src
export NLTK_DATA=<conda-env>/nltk_data
CUDA_VISIBLE_DEVICES=0 python -m streaming.overlap --config configs/test2/case1/overlap_deepseek-chat.yml

Per-chunk timing lands in log_files/<id>_outputs/*_chunks/chunk_profile.csv; playback and listener events in log_files/<id>.log.

The streaming TTS pipeline calls the FastSpeech2 duration estimator for every
refinement candidate, and each call constructed a new FastSpeechWrapper, i.e.
it reloaded the checkpoint and the HiFi-GAN vocoder from disk, re-read the
206k-line lexicon, rebuilt the G2p model, and for long inputs recomputed the
sinusoid position table in pure Python (~1M calls). Median cost per estimate
was 4.9 s (p90 9 s) and each process held ~20 GB of GPU memory.

- Add get_shared_wrapper(): a lazily created, process-wide instance guarded by
  a lock; inference is serialized on the same lock.
- Skip the vocoder by default: audio length equals mel_len * hop_length, so the
  duration is taken from the encoder + variance adaptor output and the decoder,
  postnet and vocoder are never run. Results are bit-identical to the old path
  (verified on 7 paragraphs, max abs diff 0.0 s).
- Cache the lexicon and G2p instance across calls.
- Replace get_sinusoid_encoding_table with a vectorized numpy version.
- LengthEstimator now uses the shared instance as well.

Per-estimate latency drops from 4.9 s to ~0.01 s; GPU memory per process from
~20 GB to ~0.14 GB.
…scheduling

Measured on four full debates (log ids 130-133 before, 135-138 after; same
motions, same models): playback starvation events 5 -> 0, timed-out
refinements 11% -> 0%, TTS pipeline wall clock per speech 67.9 s -> 21.6 s,
first-chunk latency median 60 s -> 41 s. Audio totals stay within +-4 s of the
240 s budget.

Rewrite latency
- Call gpt-5-mini with reasoning_effort="minimal" in _revise_to_n_words. A
  length-constrained rewrite needs no chain of thought; the default effort spent
  700-1600 reasoning tokens per call (10.8 s vs 1.8 s, same word-count accuracy).

Chunking
- Add split_into_chunks(): paragraphs remain the unit, but a text with fewer
  than MIN_STREAM_CHUNKS paragraphs is re-chunked by packing whole sentences to
  ~TARGET_CHUNK_S of audio, and a paragraph longer than MAX_CHUNK_CHARS is split
  at sentence boundaries into balanced pieces. Two of eight closings previously
  came back as a single unbroken paragraph and became one ~100 s chunk with no
  streaming, no refinement and up to 40 s of unused budget.

Scheduling at the chunk boundary
- Stop refining REFINE_DEADLINE_MARGIN_S (2 s) before the previous chunk
  finishes playing; everything after selection (decode, file write, bridge copy,
  playback stability poll) used to land past the deadline and produced audible
  gaps of up to 5.7 s.
- Do not join refine workers after selection; a worker blocked in an LLM call
  stalled the main pipeline by the join timeout (2 s per worker).
- Only re-synthesize with adjusted speed when at least SPEED_ADJUST_MIN_SLACK_S
  of playback slack remains, and log when it happens.
- Write chunk_NNN.mp3 via a temp file + rename so the copy bridge never sees a
  partial file.

Seam quality
- Normalize head/tail silence of every chunk (_normalize_seam_silence): keep
  60 ms of leading silence, exactly 250 ms of trailing silence, 10 ms fades.
  tts-1 output carried 100-1200 ms of trailing silence, so consecutive chunks
  paused anywhere between 0.2 s and 1.24 s; seam silence p90 goes from 720 ms
  to 390 ms (max 1240 -> 425 ms). Budget accounting uses the trimmed length.

Reporting
- chunk 0 is still never refined, but its FastSpeech estimate is now computed
  (cheap after the estimator change) and in_range reflects the actual audio
  instead of a hard-coded True, so chunk_profile.csv no longer overstates the
  hit rate.

Ablation
- Optional online calibration of the duration estimate (TTS_STREAM_CALIB=1),
  off by default: an offline replay over 206 chunks showed the residual is
  text-dependent noise (lag-1 autocorrelation -0.03, mean bias -1%), and an
  EMA correction slightly increases error.
…SR, remove file races

Listener (streaming/env.py)
- Drain at turn end: transcribe whatever audio remains past the last emitted
  window (>= 1 s) and push it into the debate tree with a forced flush. The
  listener processes audio in min_audio_seconds (15 s) windows and previously
  discarded the remainder when the speech ended; because overlap turns are
  marked tree_via_streaming and skip the full-text analysis, the last 8-14 s
  of every speech (measured on 24 speeches) never reached the opponent's tree.
- Cache the decoded continuous_audio.mp3 keyed by (mtime, size). The file is
  rewritten once per chunk but was polled and fully decoded every second
  (~0.5 s of CPU for a 4-minute file).
- Give the Whisper request a 30 s timeout with up to 3 attempts. One request
  in run 138 never returned, the listener thread froze at 135 s of a 236 s
  speech, and the main thread waited the full 300 s join timeout.

Playback simulator (streaming/overlap.py)
- Export continuous_audio.mp3 to a temp file and os.replace() it, so the
  listener never opens a half-written file (46 ffmpeg decode warnings over
  four runs before, 0 after).
- Charge the decode + export time of each chunk against its simulated playback
  sleep instead of inserting it as silence between chunks. The cursor still
  advances by the chunk's real duration, so the listener is unaffected; the
  per-seam timeline overhead drops from 1.07 s to 0.17 s.
Across 48 speeches, tts-1 (voice "echo") speaks at 2.46 words/s, so the
prompt's "around 520 words" for a 4-minute statement corresponds to 211 s
and "around 260 words" for closing to 106 s. Original drafts came out at a
median 94% of the budget (closings ~80%), and the streaming refinement then
rewrote most chunks upward (median 1.16x the original length, 21 of 70
rewritten chunks expanded by more than 20%), i.e. it was generating filler to
fill time. With 580 / 580 / 290 words the drafts land at 96% of budget and the
rewrite ratio drops to 1.03x.
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