F5-TTS: add opt-in sentence timestamps mode

Add `timestamps: bool` to GenerateSpeechRequest. When set for tts-1-f5,
generate the whole clip up front and return JSON {audio (base64), format,
sample_rate, duration_ms, sentences:[{index,text,start_ms,end_ms}]} instead
of streaming audio. F5 already synthesizes one PCM chunk per sentence, so
each chunk's sample count is its exact duration — no forced aligner needed.
Lets clients highlight each sentence as it is spoken. Non-F5 models with
timestamps=true get a clear 400. Default off preserves streaming behavior.
This commit is contained in:
russell@unturf.com 2026-05-29 12:29:18 -04:00
parent e511d4d105
commit b503c4d5d6
No known key found for this signature in database

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import asyncio
import base64
import contextlib
import gc
import os
@ -13,7 +14,7 @@ import time
import yaml
import json
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, JSONResponse
from loguru import logger
from openedai import OpenAIStub, BadRequestError, ServiceUnavailableError
from pydantic import BaseModel
@ -719,6 +720,7 @@ class GenerateSpeechRequest(BaseModel):
voice: str = "alloy" # alloy, echo, fable, onyx, nova, and shimmer
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
def build_ffmpeg_args(response_format, input_format, sample_rate):
# Convert the output to the desired format using ffmpeg
@ -855,6 +857,9 @@ 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')
# Set the Content-Type header based on the requested format
if response_format == "mp3":
media_type = "audio/mpeg"
@ -1320,6 +1325,51 @@ 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: 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.
# Trades streaming for exact sync (used by clients that highlight as they read).
if request.timestamps:
pcm_parts = []
sentence_meta = []
cursor_ms = 0.0
for idx, sentence in enumerate(sentences):
audio_bytes = await asyncio.to_thread(
f5_model.tts,
text=sentence,
ref_audio=ref_audio,
ref_text=ref_text,
speed=speed,
)
# f5_wrapper.tts() returns float32 PCM @ 24kHz (4 bytes/sample).
dur_ms = (len(audio_bytes) // 4) / 24000.0 * 1000.0
sentence_meta.append({
"index": idx,
"text": sentence,
"start_ms": round(cursor_ms),
"end_ms": round(cursor_ms + dur_ms),
})
cursor_ms += dur_ms
pcm_parts.append(audio_bytes)
pcm = b"".join(pcm_parts)
ff = build_ffmpeg_args(response_format, input_format="f32le", sample_rate="24000")
ff.extend(["-"])
ff_proc = subprocess.Popen(ff, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out_audio, ff_err = await asyncio.to_thread(ff_proc.communicate, pcm)
if ff_proc.returncode != 0:
logger.error(f"F5-TTS timestamps ffmpeg failed: {ff_err.decode('utf-8', 'replace')}")
raise ServiceUnavailableError("Audio encoding failed")
logger.info(f"F5-TTS timestamps: {len(sentences)} sentences, {round(cursor_ms)}ms total")
return JSONResponse({
"audio": base64.b64encode(out_audio).decode("ascii"),
"format": response_format,
"sample_rate": 24000,
"duration_ms": round(cursor_ms),
"sentences": sentence_meta,
})
# F5-TTS outputs float32 PCM at 24kHz
ffmpeg_args = build_ffmpeg_args(response_format, input_format="f32le", sample_rate="24000")
ffmpeg_args.extend(["-"])