F5-TTS: add SSE streaming mode with per-sentence audio + timing

Add `sse: bool` to the request. For tts-1-f5, stream text/event-stream:
one 'sentence' event per sentence the moment it renders, carrying that
sentence's mp3 (base64) plus exact start_ms/end_ms, then a 'done' event
with total duration_ms. Clients begin playback after the first sentence
(no wait for the full clip) and drive a per-sentence highlight off the
timing. A single generation feeds both audio and timing — no double render.
This commit is contained in:
russell@unturf.com 2026-05-29 15:15:32 -04:00
parent b503c4d5d6
commit 3b58f0c867
No known key found for this signature in database

View file

@ -721,6 +721,7 @@ class GenerateSpeechRequest(BaseModel):
response_format: str = "mp3" # mp3, opus, aac, flac
speed: float = 1.0 # 0.25 - 4.0
timestamps: bool = False # F5 only: return JSON {audio, sentences:[{start_ms,end_ms}]} instead of streaming audio
sse: bool = False # F5 only: stream text/event-stream, one event per sentence carrying its mp3 + timing
def build_ffmpeg_args(response_format, input_format, sample_rate):
# Convert the output to the desired format using ffmpeg
@ -857,8 +858,8 @@ async def generate_speech(request: GenerateSpeechRequest):
else:
raise BadRequestError(f"Voice '{voice}' not found in any model. Please specify a model.", param='voice')
if request.timestamps and model != 'tts-1-f5':
raise BadRequestError("timestamps mode is currently supported only for model 'tts-1-f5'", param='timestamps')
if (request.timestamps or request.sse) and model != 'tts-1-f5':
raise BadRequestError("timestamps and sse modes are currently supported only for model 'tts-1-f5'", param='timestamps')
# Set the Content-Type header based on the requested format
if response_format == "mp3":
@ -1325,6 +1326,54 @@ async def generate_speech(request: GenerateSpeechRequest):
sentences = simple_sentence_split(input_text, max_length=500)
logger.info(f"F5-TTS streaming {len(sentences)} sentence chunks from {len(input_text)} chars")
# Opt-in: stream Server-Sent Events. Per sentence we emit one event the
# instant its audio renders, carrying that sentence's mp3 (base64) plus
# exact start/end ms. Clients start playback after sentence 0 (no waiting
# for the whole clip) and drive a per-sentence highlight off the timing.
# One generation feeds both audio and timing — no double render.
if request.sse:
def _to_mp3(pcm_bytes):
ff = build_ffmpeg_args("mp3", input_format="f32le", sample_rate="24000")
ff.append("-")
proc = subprocess.Popen(
ff, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
out, _ = proc.communicate(pcm_bytes)
return out
async def sse_gen():
cursor_ms = 0.0
try:
for idx, sentence in enumerate(sentences):
pcm = await asyncio.to_thread(
f5_model.tts,
text=sentence,
ref_audio=ref_audio,
ref_text=ref_text,
speed=speed,
)
dur_ms = (len(pcm) // 4) / 24000.0 * 1000.0
mp3 = await asyncio.to_thread(_to_mp3, pcm)
evt = {
"index": idx,
"text": sentence,
"start_ms": round(cursor_ms),
"end_ms": round(cursor_ms + dur_ms),
"audio_b64": base64.b64encode(mp3).decode("ascii"),
}
cursor_ms += dur_ms
yield f"event: sentence\ndata: {json.dumps(evt)}\n\n"
yield f"event: done\ndata: {json.dumps({'duration_ms': round(cursor_ms)})}\n\n"
except Exception as e:
logger.error(f"F5-TTS SSE error: {e}")
yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(
sse_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Opt-in: generate the whole clip up front and return JSON carrying exact
# per-sentence timing. F5 already synthesizes one PCM chunk per sentence,
# so each chunk's sample count IS its duration — no forced aligner needed.