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:
parent
e511d4d105
commit
b503c4d5d6
1 changed files with 51 additions and 1 deletions
52
speech.py
52
speech.py
|
|
@ -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(["-"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue