Compare commits
3 Commits
2ade7e1ca6
...
dbcbddedb4
| Author | SHA1 | Date | |
|---|---|---|---|
| dbcbddedb4 | |||
| 529599d98c | |||
| 3c389b0292 |
@ -37,7 +37,7 @@ RUN pip install --no-cache-dir torch torchvision torchaudio \
|
|||||||
|
|
||||||
# Install faster-qwen3-tts and server dependencies
|
# Install faster-qwen3-tts and server dependencies
|
||||||
RUN pip install --no-cache-dir -e ".[demo]"
|
RUN pip install --no-cache-dir -e ".[demo]"
|
||||||
RUN pip install --no-cache-dir pydub soundfile uvicorn fastapi
|
RUN pip install --no-cache-dir pydub soundfile uvicorn fastapi qwen-asr
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@ -210,7 +210,8 @@ The streaming service on port `8023` uses the same generated `config/voices.json
|
|||||||
| `model` | string | `tts-1` | Kept for OpenAI compatibility |
|
| `model` | string | `tts-1` | Kept for OpenAI compatibility |
|
||||||
| `input` | string | required | Text to synthesize |
|
| `input` | string | required | Text to synthesize |
|
||||||
| `voice` | string | first configured voice | Voice ID from the selected service |
|
| `voice` | string | first configured voice | Voice ID from the selected service |
|
||||||
| `response_format` | string | `wav` | `wav`, `pcm`, or `mp3` |
|
| `response_format` | string | `wav` | `wav`, `pcm`, `mp3`, or `zip` (for timestamps) |
|
||||||
|
| `speed` | float | 1.0 | Scales audio tempo via ffmpeg |
|
||||||
| `language` | string | voice config | Per-request override for VoiceDesign/CustomVoice |
|
| `language` | string | voice config | Per-request override for VoiceDesign/CustomVoice |
|
||||||
| `instruct` | string | voice config | Per-request style override for VoiceDesign/CustomVoice |
|
| `instruct` | string | voice config | Per-request style override for VoiceDesign/CustomVoice |
|
||||||
| `max_new_tokens` | int | server default | Per-request generation length override |
|
| `max_new_tokens` | int | server default | Per-request generation length override |
|
||||||
@ -343,6 +344,12 @@ The first request after container startup can be slower because CUDA graph captu
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v6.7 — 2026-06-26
|
||||||
|
**Feature: Native Speed Control and Word-Level Timestamps**
|
||||||
|
- **Speed Parameter:** The `speed` parameter in the OpenAI `SpeechRequest` schema is now fully supported. Audio tempo is natively adjusted using `ffmpeg` without affecting pitch, and works for both streaming and non-streaming responses.
|
||||||
|
- **Word-Level Timestamps:** Added support for a new `response_format: "zip"`. When requested, the server automatically lazy-loads the `Qwen3-ForcedAligner-0.6B` model to generate word-level timestamps (`timer.json`) and returns it alongside the audio in a compressed zip file.
|
||||||
|
- **Input Sanitization:** Automatically strips leading and trailing whitespace from input text to fix a bug where excessive blank space caused the tokenizer to stutter and repeat words.
|
||||||
|
|
||||||
### v6.6 — 2026-06-21
|
### v6.6 — 2026-06-21
|
||||||
**Feature: Eager Background Precomputation of Speaker Embeddings**
|
**Feature: Eager Background Precomputation of Speaker Embeddings**
|
||||||
- The server now automatically precomputes all missing `.pt` files in the background immediately after startup.
|
- The server now automatically precomputes all missing `.pt` files in the background immediately after startup.
|
||||||
|
|||||||
@ -37,7 +37,24 @@ SAMPLE_RATE = 24000
|
|||||||
DEFAULT_MAX_NEW_TOKENS = 2048
|
DEFAULT_MAX_NEW_TOKENS = 2048
|
||||||
_model_lock = threading.Lock()
|
_model_lock = threading.Lock()
|
||||||
_load_model_kwargs = None
|
_load_model_kwargs = None
|
||||||
|
aligner_model = None
|
||||||
|
|
||||||
|
def _get_aligner():
|
||||||
|
global aligner_model
|
||||||
|
if aligner_model is None:
|
||||||
|
try:
|
||||||
|
from qwen_asr import Qwen3ForcedAligner
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="qwen-asr is not installed. Run: pip install qwen-asr")
|
||||||
|
logger.info("Loading Qwen3-ForcedAligner-0.6B...")
|
||||||
|
aligner_model = Qwen3ForcedAligner.from_pretrained(
|
||||||
|
"Qwen/Qwen3-ForcedAligner-0.6B",
|
||||||
|
dtype=torch.bfloat16,
|
||||||
|
device_map="cuda"
|
||||||
|
)
|
||||||
|
logger.info("Aligner loaded.")
|
||||||
|
return aligner_model
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@ -87,7 +104,7 @@ class SpeechRequest(BaseModel):
|
|||||||
model: str = "tts-1"
|
model: str = "tts-1"
|
||||||
input: str
|
input: str
|
||||||
voice: str = "Ryan"
|
voice: str = "Ryan"
|
||||||
response_format: str = "wav"
|
response_format: str = "wav" # wav | pcm | mp3 | zip
|
||||||
speed: float = 1.0
|
speed: float = 1.0
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
instruct: Optional[str] = None
|
instruct: Optional[str] = None
|
||||||
@ -141,20 +158,58 @@ def _request_generation_params(req: SpeechRequest, voice_cfg: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _stream_chunks(params: dict):
|
async def _stream_chunks(params: dict, speed: float):
|
||||||
q: queue.Queue = queue.Queue()
|
q: queue.Queue = queue.Queue()
|
||||||
done = object()
|
done = object()
|
||||||
|
|
||||||
def producer():
|
def producer():
|
||||||
|
process = None
|
||||||
|
if speed != 1.0:
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "-i", "pipe:0",
|
||||||
|
"-filter:a", f"atempo={speed}",
|
||||||
|
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "pipe:1"
|
||||||
|
]
|
||||||
|
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
|
||||||
|
def ffmpeg_reader():
|
||||||
try:
|
try:
|
||||||
with _model_lock:
|
while True:
|
||||||
for chunk, _sr, _timing in tts_model.generate_custom_voice_streaming(**params):
|
out = process.stdout.read(4096)
|
||||||
q.put(chunk)
|
if not out:
|
||||||
except Exception as exc:
|
break
|
||||||
q.put(exc)
|
q.put(out)
|
||||||
|
except Exception as e:
|
||||||
|
q.put(e)
|
||||||
finally:
|
finally:
|
||||||
q.put(done)
|
q.put(done)
|
||||||
|
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=ffmpeg_reader, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _model_lock:
|
||||||
|
for chunk, _sr, _timing in tts_model.generate_custom_voice_streaming(**params):
|
||||||
|
raw = _to_pcm16(chunk)
|
||||||
|
if process:
|
||||||
|
process.stdin.write(raw)
|
||||||
|
process.stdin.flush()
|
||||||
|
else:
|
||||||
|
q.put(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
q.put(exc)
|
||||||
|
finally:
|
||||||
|
if process:
|
||||||
|
try:
|
||||||
|
process.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
q.put(done)
|
||||||
|
|
||||||
|
import threading
|
||||||
threading.Thread(target=producer, daemon=True).start()
|
threading.Thread(target=producer, daemon=True).start()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
while True:
|
while True:
|
||||||
@ -163,7 +218,7 @@ async def _stream_chunks(params: dict):
|
|||||||
break
|
break
|
||||||
if isinstance(item, Exception):
|
if isinstance(item, Exception):
|
||||||
raise item
|
raise item
|
||||||
yield _to_pcm16(item)
|
yield item
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@ -175,18 +230,19 @@ async def health():
|
|||||||
async def create_speech(req: SpeechRequest):
|
async def create_speech(req: SpeechRequest):
|
||||||
if tts_model is None:
|
if tts_model is None:
|
||||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
if not req.input.strip():
|
req.input = req.input.strip()
|
||||||
|
if not req.input:
|
||||||
raise HTTPException(status_code=400, detail="'input' text is empty")
|
raise HTTPException(status_code=400, detail="'input' text is empty")
|
||||||
|
|
||||||
voice_cfg = resolve_voice(req.voice)
|
voice_cfg = resolve_voice(req.voice)
|
||||||
params = _request_generation_params(req, voice_cfg)
|
params = _request_generation_params(req, voice_cfg)
|
||||||
fmt = req.response_format.lower()
|
fmt = req.response_format.lower()
|
||||||
|
|
||||||
content_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
|
content_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg", "zip": "application/zip"}
|
||||||
if fmt not in content_types:
|
if fmt not in content_types:
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
|
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
|
||||||
|
|
||||||
if fmt == "mp3":
|
if fmt in ("mp3", "zip"):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
@ -195,12 +251,46 @@ async def create_speech(req: SpeechRequest):
|
|||||||
|
|
||||||
audio_arrays, sr = await loop.run_in_executor(None, generate)
|
audio_arrays, sr = await loop.run_in_executor(None, generate)
|
||||||
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
||||||
|
|
||||||
|
if req.speed != 1.0:
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "f32le", "-ar", str(sr), "-ac", "1", "-i", "pipe:0",
|
||||||
|
"-filter:a", f"atempo={req.speed}",
|
||||||
|
"-f", "f32le", "-ar", str(sr), "-ac", "1", "pipe:1"
|
||||||
|
]
|
||||||
|
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
process.stdin.write(audio.tobytes())
|
||||||
|
process.stdin.close()
|
||||||
|
out = process.stdout.read()
|
||||||
|
audio = np.frombuffer(out, dtype=np.float32)
|
||||||
|
|
||||||
|
if fmt == "zip":
|
||||||
|
def _align():
|
||||||
|
aligner = _get_aligner()
|
||||||
|
res = aligner.align(audio=(audio, sr), text=req.input, language=voice_cfg.get("language", "Auto"))
|
||||||
|
import dataclasses
|
||||||
|
return [dataclasses.asdict(x) for x in res]
|
||||||
|
|
||||||
|
align_data = await loop.run_in_executor(None, _align)
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
mp3_bytes = _to_mp3_bytes(audio, sr)
|
||||||
|
zip_buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("audio.mp3", mp3_bytes)
|
||||||
|
zf.writestr("timer.json", json.dumps(align_data, ensure_ascii=False))
|
||||||
|
|
||||||
|
return Response(content=zip_buf.getvalue(), media_type=content_types[fmt])
|
||||||
|
|
||||||
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
|
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
|
||||||
|
|
||||||
async def audio_stream():
|
async def audio_stream():
|
||||||
if fmt == "wav":
|
if fmt == "wav":
|
||||||
yield _wav_header(SAMPLE_RATE)
|
yield _wav_header(SAMPLE_RATE)
|
||||||
async for raw in _stream_chunks(params):
|
async for raw in _stream_chunks(params, req.speed):
|
||||||
yield raw
|
yield raw
|
||||||
|
|
||||||
return StreamingResponse(audio_stream(), media_type=content_types[fmt])
|
return StreamingResponse(audio_stream(), media_type=content_types[fmt])
|
||||||
|
|||||||
@ -36,7 +36,24 @@ SAMPLE_RATE = 24000
|
|||||||
DEFAULT_MAX_NEW_TOKENS = 2048
|
DEFAULT_MAX_NEW_TOKENS = 2048
|
||||||
_model_lock = threading.Lock()
|
_model_lock = threading.Lock()
|
||||||
_load_model_kwargs = None
|
_load_model_kwargs = None
|
||||||
|
aligner_model = None
|
||||||
|
|
||||||
|
def _get_aligner():
|
||||||
|
global aligner_model
|
||||||
|
if aligner_model is None:
|
||||||
|
try:
|
||||||
|
from qwen_asr import Qwen3ForcedAligner
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="qwen-asr is not installed. Run: pip install qwen-asr")
|
||||||
|
logger.info("Loading Qwen3-ForcedAligner-0.6B...")
|
||||||
|
aligner_model = Qwen3ForcedAligner.from_pretrained(
|
||||||
|
"Qwen/Qwen3-ForcedAligner-0.6B",
|
||||||
|
dtype=torch.bfloat16,
|
||||||
|
device_map="cuda"
|
||||||
|
)
|
||||||
|
logger.info("Aligner loaded.")
|
||||||
|
return aligner_model
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@ -90,7 +107,7 @@ class SpeechRequest(BaseModel):
|
|||||||
model: str = "tts-1"
|
model: str = "tts-1"
|
||||||
input: str
|
input: str
|
||||||
voice: str = "vd_british_male"
|
voice: str = "vd_british_male"
|
||||||
response_format: str = "wav"
|
response_format: str = "wav" # wav | pcm | mp3 | zip
|
||||||
speed: float = 1.0
|
speed: float = 1.0
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
instruct: Optional[str] = None
|
instruct: Optional[str] = None
|
||||||
@ -151,20 +168,58 @@ def _request_generation_params(req: SpeechRequest, voice_cfg: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _stream_chunks(params: dict):
|
async def _stream_chunks(params: dict, speed: float):
|
||||||
q: queue.Queue = queue.Queue()
|
q: queue.Queue = queue.Queue()
|
||||||
_DONE = object()
|
_DONE = object()
|
||||||
|
|
||||||
def producer():
|
def producer():
|
||||||
|
process = None
|
||||||
|
if speed != 1.0:
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "-i", "pipe:0",
|
||||||
|
"-filter:a", f"atempo={speed}",
|
||||||
|
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "pipe:1"
|
||||||
|
]
|
||||||
|
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
|
||||||
|
def ffmpeg_reader():
|
||||||
try:
|
try:
|
||||||
with _model_lock:
|
while True:
|
||||||
for chunk, _sr, _timing in tts_model.generate_voice_design_streaming(**params):
|
out = process.stdout.read(4096)
|
||||||
q.put(chunk)
|
if not out:
|
||||||
except Exception as exc:
|
break
|
||||||
q.put(exc)
|
q.put(out)
|
||||||
|
except Exception as e:
|
||||||
|
q.put(e)
|
||||||
finally:
|
finally:
|
||||||
q.put(_DONE)
|
q.put(_DONE)
|
||||||
|
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=ffmpeg_reader, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _model_lock:
|
||||||
|
for chunk, _sr, _timing in tts_model.generate_voice_design_streaming(**params):
|
||||||
|
raw = _to_pcm16(chunk)
|
||||||
|
if process:
|
||||||
|
process.stdin.write(raw)
|
||||||
|
process.stdin.flush()
|
||||||
|
else:
|
||||||
|
q.put(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
q.put(exc)
|
||||||
|
finally:
|
||||||
|
if process:
|
||||||
|
try:
|
||||||
|
process.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
q.put(_DONE)
|
||||||
|
|
||||||
|
import threading
|
||||||
threading.Thread(target=producer, daemon=True).start()
|
threading.Thread(target=producer, daemon=True).start()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
while True:
|
while True:
|
||||||
@ -173,7 +228,7 @@ async def _stream_chunks(params: dict):
|
|||||||
break
|
break
|
||||||
if isinstance(item, Exception):
|
if isinstance(item, Exception):
|
||||||
raise item
|
raise item
|
||||||
yield _to_pcm16(item)
|
yield item
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -189,30 +244,65 @@ async def health():
|
|||||||
async def create_speech(req: SpeechRequest):
|
async def create_speech(req: SpeechRequest):
|
||||||
if tts_model is None:
|
if tts_model is None:
|
||||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
if not req.input.strip():
|
req.input = req.input.strip()
|
||||||
|
if not req.input:
|
||||||
raise HTTPException(status_code=400, detail="'input' text is empty")
|
raise HTTPException(status_code=400, detail="'input' text is empty")
|
||||||
|
|
||||||
voice_cfg = resolve_voice(req.voice)
|
voice_cfg = resolve_voice(req.voice)
|
||||||
params = _request_generation_params(req, voice_cfg)
|
params = _request_generation_params(req, voice_cfg)
|
||||||
fmt = req.response_format.lower()
|
fmt = req.response_format.lower()
|
||||||
|
|
||||||
_CONTENT_TYPES = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
|
_CONTENT_TYPES = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg", "zip": "application/zip"}
|
||||||
if fmt not in _CONTENT_TYPES:
|
if fmt not in _CONTENT_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
|
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
|
||||||
|
|
||||||
if fmt == "mp3":
|
if fmt in ("mp3", "zip"):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
def _gen():
|
def _gen():
|
||||||
with _model_lock:
|
with _model_lock:
|
||||||
return tts_model.generate_voice_design(**params)
|
return tts_model.generate_voice_design(**params)
|
||||||
audio_arrays, sr = await loop.run_in_executor(None, _gen)
|
audio_arrays, sr = await loop.run_in_executor(None, _gen)
|
||||||
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
||||||
|
|
||||||
|
if req.speed != 1.0:
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "f32le", "-ar", str(sr), "-ac", "1", "-i", "pipe:0",
|
||||||
|
"-filter:a", f"atempo={req.speed}",
|
||||||
|
"-f", "f32le", "-ar", str(sr), "-ac", "1", "pipe:1"
|
||||||
|
]
|
||||||
|
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
process.stdin.write(audio.tobytes())
|
||||||
|
process.stdin.close()
|
||||||
|
out = process.stdout.read()
|
||||||
|
audio = np.frombuffer(out, dtype=np.float32)
|
||||||
|
|
||||||
|
if fmt == "zip":
|
||||||
|
def _align():
|
||||||
|
aligner = _get_aligner()
|
||||||
|
res = aligner.align(audio=(audio, sr), text=req.input, language=voice_cfg.get("language", "Auto"))
|
||||||
|
import dataclasses
|
||||||
|
return [dataclasses.asdict(x) for x in res]
|
||||||
|
|
||||||
|
align_data = await loop.run_in_executor(None, _align)
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
mp3_bytes = _to_mp3_bytes(audio, sr)
|
||||||
|
zip_buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("audio.mp3", mp3_bytes)
|
||||||
|
zf.writestr("timer.json", json.dumps(align_data, ensure_ascii=False))
|
||||||
|
|
||||||
|
return Response(content=zip_buf.getvalue(), media_type=_CONTENT_TYPES[fmt])
|
||||||
|
|
||||||
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
|
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
|
||||||
|
|
||||||
async def audio_stream():
|
async def audio_stream():
|
||||||
if fmt == "wav":
|
if fmt == "wav":
|
||||||
yield _wav_header(SAMPLE_RATE)
|
yield _wav_header(SAMPLE_RATE)
|
||||||
async for raw in _stream_chunks(params):
|
async for raw in _stream_chunks(params, req.speed):
|
||||||
yield raw
|
yield raw
|
||||||
|
|
||||||
return StreamingResponse(audio_stream(), media_type=_CONTENT_TYPES[fmt])
|
return StreamingResponse(audio_stream(), media_type=_CONTENT_TYPES[fmt])
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
--- /tmp/upstream_openai_server.py 2026-06-21 14:29:34.858114787 +0200
|
diff --git a/examples/openai_server.py b/examples/openai_server.py
|
||||||
+++ build/examples/openai_server.py 2026-06-21 14:26:33.557536313 +0200
|
index 2199e14..d10065f 100644
|
||||||
@@ -36,6 +36,7 @@
|
--- a/examples/openai_server.py
|
||||||
|
+++ b/examples/openai_server.py
|
||||||
|
@@ -36,6 +36,7 @@ API usage:
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -8,7 +10,7 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -66,10 +67,29 @@
|
@@ -66,9 +67,46 @@ app = FastAPI(title="faster-qwen3-tts OpenAI-compatible API")
|
||||||
|
|
||||||
tts_model = None
|
tts_model = None
|
||||||
voices: dict = {}
|
voices: dict = {}
|
||||||
@ -17,7 +19,25 @@
|
|||||||
default_voice: Optional[str] = None
|
default_voice: Optional[str] = None
|
||||||
SAMPLE_RATE = 24000 # updated once the model loads
|
SAMPLE_RATE = 24000 # updated once the model loads
|
||||||
_model_lock = threading.Lock() # prevent concurrent GPU inference
|
_model_lock = threading.Lock() # prevent concurrent GPU inference
|
||||||
|
+aligner_model = None
|
||||||
|
+
|
||||||
|
+def _get_aligner():
|
||||||
|
+ global aligner_model
|
||||||
|
+ if aligner_model is None:
|
||||||
|
+ try:
|
||||||
|
+ from qwen_asr import Qwen3ForcedAligner
|
||||||
|
+ import torch
|
||||||
|
+ except ImportError:
|
||||||
|
+ raise HTTPException(status_code=500, detail="qwen-asr is not installed. Run: pip install qwen-asr")
|
||||||
|
+ logger.info("Loading Qwen3-ForcedAligner-0.6B...")
|
||||||
|
+ aligner_model = Qwen3ForcedAligner.from_pretrained(
|
||||||
|
+ "Qwen/Qwen3-ForcedAligner-0.6B",
|
||||||
|
+ dtype=torch.bfloat16,
|
||||||
|
+ device_map="cuda"
|
||||||
|
+ )
|
||||||
|
+ logger.info("Aligner loaded.")
|
||||||
|
+ return aligner_model
|
||||||
|
+
|
||||||
+
|
+
|
||||||
+def _voice_seed(voice_name: str) -> int:
|
+def _voice_seed(voice_name: str) -> int:
|
||||||
+ """Return a stable per-voice seed derived from the voice name.
|
+ """Return a stable per-voice seed derived from the voice name.
|
||||||
@ -34,11 +54,21 @@
|
|||||||
+ if torch.cuda.is_available():
|
+ if torch.cuda.is_available():
|
||||||
+ torch.cuda.manual_seed_all(seed)
|
+ torch.cuda.manual_seed_all(seed)
|
||||||
+
|
+
|
||||||
+
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request / response models
|
# Request / response models
|
||||||
|
@@ -79,8 +117,8 @@ class SpeechRequest(BaseModel):
|
||||||
|
model: str = "tts-1"
|
||||||
|
input: str
|
||||||
|
voice: str = "alloy"
|
||||||
|
- response_format: str = "wav" # wav | pcm | mp3
|
||||||
|
- speed: float = 1.0 # accepted but not yet applied
|
||||||
|
+ response_format: str = "wav" # wav | pcm | mp3 | zip
|
||||||
|
+ speed: float = 1.0 # scales audio tempo
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -145,6 +165,21 @@
|
@@ -145,6 +183,21 @@ def _to_mp3_bytes(pcm: np.ndarray, sample_rate: int) -> bytes:
|
||||||
|
|
||||||
def resolve_voice(voice_name: str) -> dict:
|
def resolve_voice(voice_name: str) -> dict:
|
||||||
"""Return voice config dict or fall back to default, else raise 400."""
|
"""Return voice config dict or fall back to default, else raise 400."""
|
||||||
@ -60,7 +90,7 @@
|
|||||||
if voice_name in voices:
|
if voice_name in voices:
|
||||||
return voices[voice_name]
|
return voices[voice_name]
|
||||||
if default_voice and default_voice in voices:
|
if default_voice and default_voice in voices:
|
||||||
@@ -168,7 +203,47 @@
|
@@ -168,7 +221,47 @@ def resolve_voice(voice_name: str) -> dict:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@ -105,12 +135,39 @@
|
|||||||
+ return None
|
+ return None
|
||||||
+
|
+
|
||||||
+
|
+
|
||||||
+async def _stream_chunks(voice_cfg: dict, text: str, voice_name: str) -> AsyncGenerator[bytes, None]:
|
+async def _stream_chunks(voice_cfg: dict, text: str, voice_name: str, speed: float) -> AsyncGenerator[bytes, None]:
|
||||||
"""
|
"""
|
||||||
Run generate_voice_clone_streaming in a background thread and yield
|
Run generate_voice_clone_streaming in a background thread and yield
|
||||||
raw PCM bytes for each chunk as they arrive.
|
raw PCM bytes for each chunk as they arrive.
|
||||||
@@ -179,13 +254,19 @@
|
@@ -177,21 +270,63 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No
|
||||||
|
_DONE = object()
|
||||||
|
|
||||||
def producer():
|
def producer():
|
||||||
|
+ process = None
|
||||||
|
+ if speed != 1.0:
|
||||||
|
+ import subprocess
|
||||||
|
+ cmd = [
|
||||||
|
+ "ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
+ "-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "-i", "pipe:0",
|
||||||
|
+ "-filter:a", f"atempo={speed}",
|
||||||
|
+ "-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "1", "pipe:1"
|
||||||
|
+ ]
|
||||||
|
+ process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
+
|
||||||
|
+ def ffmpeg_reader():
|
||||||
|
+ try:
|
||||||
|
+ while True:
|
||||||
|
+ out = process.stdout.read(4096)
|
||||||
|
+ if not out:
|
||||||
|
+ break
|
||||||
|
+ q.put(out)
|
||||||
|
+ except Exception as e:
|
||||||
|
+ q.put(e)
|
||||||
|
+ finally:
|
||||||
|
+ q.put(_DONE)
|
||||||
|
+
|
||||||
|
+ threading.Thread(target=ffmpeg_reader, daemon=True).start()
|
||||||
|
+
|
||||||
try:
|
try:
|
||||||
with _model_lock:
|
with _model_lock:
|
||||||
+ _seed_rng(voice_cfg.get("seed", _voice_seed(voice_name)))
|
+ _seed_rng(voice_cfg.get("seed", _voice_seed(voice_name)))
|
||||||
@ -129,9 +186,65 @@
|
|||||||
+ top_k=voice_cfg.get("top_k", 50),
|
+ top_k=voice_cfg.get("top_k", 50),
|
||||||
+ top_p=voice_cfg.get("top_p", 0.9),
|
+ top_p=voice_cfg.get("top_p", 0.9),
|
||||||
):
|
):
|
||||||
q.put(chunk)
|
- q.put(chunk)
|
||||||
|
+ raw = _to_pcm16(chunk)
|
||||||
|
+ if process:
|
||||||
|
+ process.stdin.write(raw)
|
||||||
|
+ process.stdin.flush()
|
||||||
|
+ else:
|
||||||
|
+ q.put(raw)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -244,11 +325,18 @@
|
q.put(exc)
|
||||||
|
finally:
|
||||||
|
- q.put(_DONE)
|
||||||
|
+ if process:
|
||||||
|
+ try:
|
||||||
|
+ process.stdin.close()
|
||||||
|
+ except Exception:
|
||||||
|
+ pass
|
||||||
|
+ else:
|
||||||
|
+ q.put(_DONE)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=producer, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
@@ -203,7 +338,7 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No
|
||||||
|
break
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
- yield _to_pcm16(item)
|
||||||
|
+ yield item
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@@ -220,7 +355,8 @@ async def health():
|
||||||
|
async def create_speech(req: SpeechRequest):
|
||||||
|
if tts_model is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
|
- if not req.input.strip():
|
||||||
|
+ req.input = req.input.strip()
|
||||||
|
+ if not req.input:
|
||||||
|
raise HTTPException(status_code=400, detail="'input' text is empty")
|
||||||
|
|
||||||
|
voice_cfg = resolve_voice(req.voice)
|
||||||
|
@@ -230,36 +366,77 @@ async def create_speech(req: SpeechRequest):
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"pcm": "audio/pcm",
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
+ "zip": "application/zip",
|
||||||
|
}
|
||||||
|
if fmt not in _CONTENT_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
- detail=f"response_format {fmt!r} not supported. Use: wav, pcm, mp3",
|
||||||
|
+ detail=f"response_format {fmt!r} not supported. Use: wav, pcm, mp3, zip",
|
||||||
|
)
|
||||||
|
content_type = _CONTENT_TYPES[fmt]
|
||||||
|
|
||||||
|
- # --- MP3: generate all audio, then encode (non-streaming) ---
|
||||||
|
- if fmt == "mp3":
|
||||||
|
+ # --- MP3 / ZIP: generate all audio, then encode (non-streaming) ---
|
||||||
|
+ if fmt in ("mp3", "zip"):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _generate():
|
def _generate():
|
||||||
with _model_lock:
|
with _model_lock:
|
||||||
@ -151,16 +264,52 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
audio_arrays, sr = await loop.run_in_executor(None, _generate)
|
audio_arrays, sr = await loop.run_in_executor(None, _generate)
|
||||||
@@ -259,7 +347,7 @@
|
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
||||||
|
+
|
||||||
|
+ if req.speed != 1.0:
|
||||||
|
+ import subprocess
|
||||||
|
+ cmd = [
|
||||||
|
+ "ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
+ "-f", "f32le", "-ar", str(sr), "-ac", "1", "-i", "pipe:0",
|
||||||
|
+ "-filter:a", f"atempo={req.speed}",
|
||||||
|
+ "-f", "f32le", "-ar", str(sr), "-ac", "1", "pipe:1"
|
||||||
|
+ ]
|
||||||
|
+ process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
+ process.stdin.write(audio.tobytes())
|
||||||
|
+ process.stdin.close()
|
||||||
|
+ out = process.stdout.read()
|
||||||
|
+ audio = np.frombuffer(out, dtype=np.float32)
|
||||||
|
+
|
||||||
|
+ if fmt == "zip":
|
||||||
|
+ def _align():
|
||||||
|
+ aligner = _get_aligner()
|
||||||
|
+ res = aligner.align(audio=(audio, sr), text=req.input, language=voice_cfg.get("language", "Auto"))
|
||||||
|
+ import dataclasses
|
||||||
|
+ return [dataclasses.asdict(x) for x in res]
|
||||||
|
+
|
||||||
|
+ align_data = await loop.run_in_executor(None, _align)
|
||||||
|
+
|
||||||
|
+ import zipfile
|
||||||
|
+ mp3_bytes = _to_mp3_bytes(audio, sr)
|
||||||
|
+ zip_buf = io.BytesIO()
|
||||||
|
+ with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
+ zf.writestr("audio.mp3", mp3_bytes)
|
||||||
|
+ zf.writestr("timer.json", json.dumps(align_data, ensure_ascii=False))
|
||||||
|
+
|
||||||
|
+ return Response(content=zip_buf.getvalue(), media_type=content_type)
|
||||||
|
+
|
||||||
|
return Response(content=_to_mp3_bytes(audio, sr), media_type=content_type)
|
||||||
|
|
||||||
|
# --- WAV / PCM: stream chunks as they are generated ---
|
||||||
async def audio_stream():
|
async def audio_stream():
|
||||||
if fmt == "wav":
|
if fmt == "wav":
|
||||||
yield _wav_header(SAMPLE_RATE) # stream with unknown data length
|
yield _wav_header(SAMPLE_RATE) # stream with unknown data length
|
||||||
- async for raw_chunk in _stream_chunks(voice_cfg, req.input):
|
- async for raw_chunk in _stream_chunks(voice_cfg, req.input):
|
||||||
+ async for raw_chunk in _stream_chunks(voice_cfg, req.input, req.voice):
|
+ async for raw_chunk in _stream_chunks(voice_cfg, req.input, req.voice, req.speed):
|
||||||
yield raw_chunk
|
yield raw_chunk
|
||||||
|
|
||||||
return StreamingResponse(audio_stream(), media_type=content_type)
|
return StreamingResponse(audio_stream(), media_type=content_type)
|
||||||
@@ -306,16 +394,20 @@
|
@@ -306,16 +483,20 @@ def _parse_args():
|
||||||
p.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
p.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
||||||
p.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
p.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
||||||
p.add_argument("--device", default="cuda", help="Torch device (default: cuda)")
|
p.add_argument("--device", default="cuda", help="Torch device (default: cuda)")
|
||||||
@ -182,7 +331,7 @@
|
|||||||
with open(args.voices) as f:
|
with open(args.voices) as f:
|
||||||
voices = json.load(f)
|
voices = json.load(f)
|
||||||
default_voice = next(iter(voices))
|
default_voice = next(iter(voices))
|
||||||
@@ -344,6 +436,7 @@
|
@@ -344,6 +525,7 @@ def main():
|
||||||
args.model,
|
args.model,
|
||||||
device=args.device,
|
device=args.device,
|
||||||
dtype=torch.bfloat16,
|
dtype=torch.bfloat16,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user