Files
spark-pocket-tts/pocket_tts/main.py
T

386 lines
13 KiB
Python

import io
import logging
import os
import sys
import tempfile
import threading
from pathlib import Path
from queue import Queue
import typer
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from typing_extensions import Annotated
from pocket_tts.data.audio import stream_audio_chunks
from pocket_tts.default_parameters import (
DEFAULT_AUDIO_PROMPT,
DEFAULT_EOS_THRESHOLD,
DEFAULT_FRAMES_AFTER_EOS,
DEFAULT_LSD_DECODE_STEPS,
DEFAULT_NOISE_CLAMP,
DEFAULT_TEMPERATURE,
DEFAULT_VARIANT,
MAX_TOKEN_PER_CHUNK,
)
from pocket_tts.models.tts_model import TTSModel
from pocket_tts.utils.logging_utils import enable_logging
from pocket_tts.utils.utils import PREDEFINED_VOICES, size_of_dict
logger = logging.getLogger(__name__)
cli_app = typer.Typer(
help="Kyutai Pocket TTS - Text-to-Speech generation tool", pretty_exceptions_show_locals=False
)
# ------------------------------------------------------
# The pocket-tts server implementation
# ------------------------------------------------------
# Global model instance
tts_model: TTSModel | None = None
global_model_state = None
web_app = FastAPI(
title="Kyutai Pocket TTS API", description="Text-to-Speech generation API", version="1.0.0"
)
web_app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"https://pod1-10007.internal.kyutai.org",
"https://kyutai.org",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@web_app.get("/")
async def root():
"""Serve the frontend."""
static_path = Path(__file__).parent / "static" / "index.html"
return FileResponse(static_path)
@web_app.get("/health")
async def health():
return {"status": "healthy"}
def write_to_queue(queue, text_to_generate, model_state):
"""Allows writing to the StreamingResponse as if it were a file."""
class FileLikeToQueue(io.IOBase):
def __init__(self, queue):
self.queue = queue
def write(self, data):
self.queue.put(data)
def flush(self):
pass
def close(self):
self.queue.put(None)
audio_chunks = tts_model.generate_audio_stream(
model_state=model_state, text_to_generate=text_to_generate
)
stream_audio_chunks(FileLikeToQueue(queue), audio_chunks, tts_model.config.mimi.sample_rate)
def generate_data_with_state(text_to_generate: str, model_state: dict):
queue = Queue()
# Run your function in a thread
thread = threading.Thread(target=write_to_queue, args=(queue, text_to_generate, model_state))
thread.start()
# Yield data as it becomes available
i = 0
while True:
data = queue.get()
if data is None:
break
i += 1
yield data
thread.join()
@web_app.post("/tts")
def text_to_speech(
text: str = Form(...),
voice_url: str | None = Form(None),
voice_wav: UploadFile | None = File(None),
):
"""
Generate speech from text using the pre-loaded voice prompt or a custom voice.
Args:
text: Text to convert to speech
voice_url: Optional voice URL (http://, https://, or hf://)
voice_wav: Optional uploaded voice file (mutually exclusive with voice_url)
"""
if not text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")
if voice_url is not None and voice_wav is not None:
raise HTTPException(status_code=400, detail="Cannot provide both voice_url and voice_wav")
# Use the appropriate model state
if voice_url is not None:
if not (
voice_url.startswith("http://")
or voice_url.startswith("https://")
or voice_url.startswith("hf://")
or voice_url in PREDEFINED_VOICES
):
raise HTTPException(
status_code=400, detail="voice_url must start with http://, https://, or hf://"
)
model_state = tts_model._cached_get_state_for_audio_prompt(voice_url)
logging.warning("Using voice from URL: %s", voice_url)
elif voice_wav is not None:
# Use uploaded voice file - preserve extension for format detection
suffix = Path(voice_wav.filename).suffix if voice_wav.filename else ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
content = voice_wav.file.read()
temp_file.write(content)
temp_file.flush()
temp_file_path = temp_file.name
# Close the file before reading it back (required on Windows)
try:
model_state = tts_model.get_state_for_audio_prompt(Path(temp_file_path), truncate=True)
finally:
os.unlink(temp_file_path)
else:
# Use default global model state
model_state = global_model_state
return StreamingResponse(
generate_data_with_state(text, model_state),
media_type="audio/wav",
headers={
"Content-Disposition": "attachment; filename=generated_speech.wav",
"Transfer-Encoding": "chunked",
},
)
@cli_app.command()
def serve(
voice: Annotated[
str, typer.Option(help="Path to voice prompt audio file (voice to clone)")
] = DEFAULT_AUDIO_PROMPT,
host: Annotated[str, typer.Option(help="Host to bind to")] = "localhost",
port: Annotated[int, typer.Option(help="Port to bind to")] = 8000,
reload: Annotated[bool, typer.Option(help="Enable auto-reload")] = False,
config: Annotated[
str,
typer.Option(
help="Path to locally-saved model config .yaml file or model variant signature"
),
] = DEFAULT_VARIANT,
):
"""Start the FastAPI server."""
global tts_model, global_model_state
tts_model = TTSModel.load_model(config)
# Pre-load the voice prompt
global_model_state = tts_model.get_state_for_audio_prompt(voice)
logger.info(f"The size of the model state is {size_of_dict(global_model_state) // 1e6} MB")
uvicorn.run("pocket_tts.main:web_app", host=host, port=port, reload=reload)
# ------------------------------------------------------
# The pocket-tts single generation CLI implementation
# ------------------------------------------------------
@cli_app.command()
def generate(
text: Annotated[
str, typer.Option(help="Text to generate")
] = "Hello world. I am Kyutai's Pocket TTS. I'm fast enough to run on small CPUs. I hope you'll like me.",
voice: Annotated[
str, typer.Option(help="Path to audio conditioning file (voice to clone)")
] = DEFAULT_AUDIO_PROMPT,
quiet: Annotated[bool, typer.Option("-q", "--quiet", help="Disable logging output")] = False,
config: Annotated[
str, typer.Option(help="Model signature or path to config .yaml file")
] = DEFAULT_VARIANT,
lsd_decode_steps: Annotated[
int, typer.Option(help="Number of generation steps")
] = DEFAULT_LSD_DECODE_STEPS,
temperature: Annotated[
float, typer.Option(help="Temperature for generation")
] = DEFAULT_TEMPERATURE,
noise_clamp: Annotated[float, typer.Option(help="Noise clamp value")] = DEFAULT_NOISE_CLAMP,
eos_threshold: Annotated[float, typer.Option(help="EOS threshold")] = DEFAULT_EOS_THRESHOLD,
frames_after_eos: Annotated[
int, typer.Option(help="Number of frames to generate after EOS")
] = DEFAULT_FRAMES_AFTER_EOS,
output_path: Annotated[
str, typer.Option(help="Output path for generated audio")
] = "./tts_output.wav",
device: Annotated[str, typer.Option(help="Device to use")] = "cpu",
max_tokens: Annotated[
int, typer.Option(help="Maximum number of tokens per chunk.")
] = MAX_TOKEN_PER_CHUNK,
):
"""Generate speech using Kyutai Pocket TTS."""
log_level = logging.ERROR if quiet else logging.INFO
with enable_logging("pocket_tts", log_level):
if text == "-":
# Read text from stdin
text = sys.stdin.read()
if not text.strip():
logger.error("No input received from stdin.")
raise typer.Exit(code=1)
tts_model = TTSModel.load_model(
config, temperature, lsd_decode_steps, noise_clamp, eos_threshold
)
tts_model.to(device)
model_state_for_voice = tts_model.get_state_for_audio_prompt(voice)
# Stream audio generation directly to file or stdout
audio_chunks = tts_model.generate_audio_stream(
model_state=model_state_for_voice,
text_to_generate=text,
frames_after_eos=frames_after_eos,
max_tokens=max_tokens,
)
stream_audio_chunks(output_path, audio_chunks, tts_model.config.mimi.sample_rate)
# Only print the result message if not writing to stdout
if output_path != "-":
logger.info("Results written in %s", output_path)
logger.info("-" * 20)
logger.info(
"If you want to try multiple voices and prompts quickly, try the `serve` command."
)
logger.info(
"If you like Kyutai projects, comment, like, subscribe at https://x.com/kyutai_labs"
)
# ----------------------------------------------
# export audio to safetensors CLI implementation
# ----------------------------------------------
@cli_app.command()
def export_voice(
audio_path: Annotated[
str, typer.Argument(help="Audio file or directory to convert and export")
],
export_path: Annotated[str, typer.Argument(help="Output file or directory")],
truncate: Annotated[
bool, typer.Option("-tr", "--truncate", help="Truncate long audio")
] = False,
quiet: Annotated[bool, typer.Option("-q", "--quiet", help="Disable logging output")] = False,
config: Annotated[str, typer.Option(help="Model config path or signature")] = DEFAULT_VARIANT,
lsd_decode_steps: Annotated[
int, typer.Option(help="Number of generation steps")
] = DEFAULT_LSD_DECODE_STEPS,
temperature: Annotated[
float, typer.Option(help="Temperature for generation")
] = DEFAULT_TEMPERATURE,
noise_clamp: Annotated[float, typer.Option(help="Noise clamp value")] = DEFAULT_NOISE_CLAMP,
eos_threshold: Annotated[float, typer.Option(help="EOS threshold")] = DEFAULT_EOS_THRESHOLD,
frames_after_eos: Annotated[
int, typer.Option(help="Number of frames to generate after EOS")
] = DEFAULT_FRAMES_AFTER_EOS,
device: Annotated[str, typer.Option(help="Device to use")] = "cpu",
):
"""Convert and save audio to .safetensors file"""
import re
def url(path):
return path.startswith(("http:", "https:", "hf:"))
def normalize_url(url):
# utils.py expects urls to be xxx:// so normalize them
return re.sub(r"^(http|https|hf)\:\/*(.+)$", r"\1://\2", url)
def likely_file(path):
return not url(path) and not likely_dir(path)
def likely_dir(path):
return not url(path) and (path.endswith(("/", "\\")) or path == ".")
def convert_one(in_path, out_path, join_path):
"""helper convert function"""
voice = in_path.stem
if url(str(in_path)):
in_path = normalize_url(str(in_path))
if join_path:
out_path = out_path / f"{voice}.safetensors"
else:
# ensure output file has correct extension
out_path = out_path.with_suffix(".safetensors")
try:
tts_model.save_audio_prompt(in_path, out_path, truncate)
except Exception as e:
logger.error(f"❌ Unable to export voice '{in_path}': {e}")
return False
logger.info(f"✅ Successfully exported voice '{voice}' to '{out_path}'")
return True
log_level = logging.ERROR if quiet else logging.INFO
success_count = 0
with enable_logging("pocket_tts", log_level):
tts_model = TTSModel.load_model(
config, temperature, lsd_decode_steps, noise_clamp, eos_threshold
)
tts_model.to(device)
in_path = Path(audio_path)
out_path = Path(export_path)
if likely_dir(export_path):
# make sure output dir exists
out_path.mkdir(parents=True, exist_ok=True)
if likely_dir(audio_path): # batch convert whole directory
if not in_path.is_dir():
logger.error(f"Input dir '{audio_path}' does not exists")
exit(1)
if not likely_dir(export_path):
# batch convert, output path must be directory, not file
out_path = Path("./")
for path in Path(in_path).iterdir():
if path.is_file() and path.suffix.lower() in [
".wav",
".mp3",
".flac",
".ogg",
".aiff",
]:
if convert_one(path, out_path, True):
success_count += 1
else: # convert single file
if likely_file(audio_path) and not in_path.exists():
logger.error(f"Input file '{in_path}'' does not exists")
exit(1)
if convert_one(in_path, out_path, likely_dir(export_path)):
success_count += 1
if success_count > 0:
logger.info(f"🎉 Successfully exported {success_count} voices.")
if __name__ == "__main__":
cli_app()