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:
parent
b503c4d5d6
commit
3b58f0c867
1 changed files with 51 additions and 2 deletions
53
speech.py
53
speech.py
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue