Copy the files in the new repo for public release

This commit is contained in:
gabrieldemarmiesse
2026-01-12 11:25:16 +01:00
commit f5fa328a58
54 changed files with 6411 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Upload Python Package
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v2
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build wheels
run: uv build
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
+39
View File
@@ -0,0 +1,39 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --dev
- name: Run pre-commit
uses: pre-commit/action@v3.0.0
pytest:
runs-on: ubuntu-latest
needs: pre-commit
strategy:
matrix:
python-version: ['3.10', '3.14']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --dev -p ${{ matrix.python-version }}
- name: Run pytest
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: uv run -p ${{ matrix.python-version }} pytest ./tests
Executable
+88
View File
@@ -0,0 +1,88 @@
# Byte-compiled / optimized / DLL files
__pycache__
*.py[cod]
*$py.class
# C extensions
*.so
# macOS dir files
.DS_Store
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.ipynb_checkpoints
# Tests and linter
.pytest_cache/
.mypy_cache/
.coverage
coverage.json
# docs
/api_docs
# dotenv
.env
.envrc
# virtualenv
.venv
venv/
ENV/
# egs with manifest files
egs/*
!egs/example
# local datasets
dataset/*
!dataset/example
# personal notebooks & scripts
*/local_scripts
*/notes
.vscode/
/notebooks
/local_scripts
/notes
# cython output
/audiocraft/metrics/abx/*.c
# rust-analyzer
Cargo.toml
Cargo.lock
target/
# nfs files
.nfs*
.coverage.*
/weights
test_en_audio_4000.model
profile_output*
*.safetensors
*.wav
*.model
*.pt
# Agents files, use a symlink to AGENTS.md instead.
CLAUDE.md
QWEN.md
.claude/
+19
View File
@@ -0,0 +1,19 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.7
hooks:
- id: ruff-check
args: [ --fix ]
- id: ruff-check
args: [ --select=I, --fix ]
- id: ruff-format
# Use ruff format on markdown files
- id: ruff-format
types_or: [markdown]
require_serial: true
entry: doccmd --language python --no-pad-file --command "ruff format"
additional_dependencies: ["doccmd"]
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.8.22
hooks:
- id: uv-lock
+1
View File
@@ -0,0 +1 @@
3.10
+132
View File
@@ -0,0 +1,132 @@
# AGENTS.md
This file provides guidance to AI agents when working with code in this repository.
## Project Overview
pocket-tts is a CPU-based text-to-speech (TTS) model. The project uses a flow-based language model architecture with a neural audio codec (Mimi) for efficient speech synthesis.
**Key Architecture Components:**
- **FlowLMModel**: Transformer-based flow language model that generates latent representations from text using Lagrangian Self Distillation (LSD)
- **MimiModel**: Neural audio codec (from the `moshi` package) that compresses/decompresses audio to/from latent representations
- **Conditioners**: Text processing via SentencePiece tokenizer and lookup table embeddings
- **Streaming Architecture**: The entire pipeline supports streaming generation via stateful modules
## Common Commands
### Setup and Development
```bash
# Install pre-commit hooks
uvx pre-commit install
# Run tests (3 parallel workers)
uv run pytest -n 3 -v
# Run a single test
uv run pytest tests/test_python_api.py -v
# Run CLI locally (editable install)
uv run pocket-tts generate
uv run pocket-tts serve
```
### Linting and Formatting
Pre-commit handles this automatically, but you can run manually:
```bash
# Ruff will run automatically on commit via pre-commit
# Includes: ruff-check, ruff-format (with --fix), and import sorting
```
### Building (No Build Step)
This is a pure Python package with Rust extensions in `training/rust_exts/audio_ds/` for training-time audio processing. The main package does not require building.
## Code Structure
### Main Package (`pocket_tts
**Entry Points:**
- `main.py`: CLI implementation with Typer (two commands: `generate` and `serve`)
- `__init__.py`: Public API exports only `TTSModel`
**Core Models (`models/`):**
- `tts_model.py`: Main `TTSModel` class - orchestrates the entire TTS pipeline
- `load_model()`: Downloads weights from HuggingFace and initializes models
- `get_state_for_audio_prompt()`: Encodes audio prompt (voice) into model state
- `generate_audio_stream()`: Streaming generation that yields audio chunks
- Uses LRU cache for voice prompts to avoid reprocessing
- `flow_lm.py`: `FlowLMModel` - transformer that generates latent audio codes from text
**Modules (`modules/`):**
- `transformer.py`: `StreamingTransformer` and `StreamingMultiheadAttention` with RoPE embeddings
- `stateful_module.py`: Base class for streaming support (maintains KV cache and state)
- `rope.py`: Rotary Position Embeddings
- `mlp.py`: `SimpleMLPAdaLN` (AdaLN-conditioned MLP for flow prediction)
- `conv.py`: Convolution utilities
- `seanet.py`: SEANet encoder/decoder (imported from moshi)
**Conditioners (`conditioners/`):**
- `text.py`: `LUTConditioner` - SentencePiece tokenizer + embedding lookup table for text
**Data (`data/`):**
- `audio.py`: Audio I/O utilities (reading, writing WAV, streaming)
- `audio_utils.py`: Audio processing (resampling, conversion)
**Utils (`utils/`):**
- `config.py`: Pydantic config models for FlowLM and Mimi
- `utils.py`: HuggingFace downloads, timing utilities
**Configuration (`config/`):**
- `b6369a24.yaml`: Model configuration (transformer dims, layers, vocab size, etc.)
### Testing (`tests/`)
- `test_python_api.py`: Tests for public Python API
- `test_cli_generate.py`: Tests for CLI generate command
- `test_documentation_examples.py`: Ensures docs examples work
## Development Workflow
### Key Patterns
1. **Streaming Generation**: The model generates audio frame-by-frame (12.5 Hz frame rate, 80ms per frame). All modules inherit from `StatefulModule` to maintain internal state.
2. **Voice Cloning**: Audio prompts are encoded via Mimi encoder to create "voice state" (latent representations + speaker embeddings). This state is cached via `lru_cache` on `_cached_get_state_for_audio_prompt()`.
3. **Flow Matching**: Uses Lagrangian Self Distillation (LSD) with configurable decode steps. Fewer steps = faster but lower quality.
4. **EOS Detection**: Model predicts end-of-speech via an EOS head. Generation continues for `frames_after_eos` frames after EOS is detected.
5. **Config-Driven**: Model architecture is defined in YAML configs. Weights are loaded from HuggingFace Hub via `safetensors`.
### Important Implementation Details
- **Thread Safety**: The code is NOT thread-safe. Server mode does not support concurrent requests.
- **Batching**: Batch size is always 1. No batching support currently.
- **Device**: Defaults to CPU. GPU does not provide speedup for this small model.
- **Torch Threads**: `torch.set_num_threads(1)` in `tts_model.py` for optimal CPU performance
- **dtype**: Models use float32 by default (configurable in YAML)
- **Beartype**: Runtime type checking is enabled via beartype claw in `__init__.py`
### Adding Features
When adding features, be aware of:
- The streaming architecture: any changes to model forward passes need to maintain state correctly
- The config system: new model parameters must be added to config classes in `utils/config.py`
- The public API: only `TTSModel` is exported; keep implementation details internal
- Ruff formatting: line length 100, LF line endings, skip magic trailing comma
### Model Weights
Weights are downloaded from HuggingFace Hub on first use:
- Model weights: `hf://kyutai/pocket-tts/tts_b6369a24.safetensors`
- Tokenizer: `hf://kyutai/pocket-tts/tokenizer.model`
- Voice prompts: `hf://kyutai/tts-voices/<speaker>/<style>.wav`
The `download_if_necessary()` utility handles `hf://` URLs and caches locally.
## Common Gotchas
1. **PyTorch Version**: Requires PyTorch 2.5+. Version 2.4.0 produces incorrect audio.
2. **Python Version**: Supports 3.11, 3.12, 3.13 only.
3. **uv Python Preference**: Set to "only-managed" in pyproject.toml because system Python may lack headers.
4. **Moshi Dependency**: The package depends on `moshi` for the MimiModel audio codec.
5. **CPU-Only PyTorch**: Uses PyTorch CPU index from `download.pytorch.org/whl/cpu` in uv config.
+60
View File
@@ -0,0 +1,60 @@
# Contributing to Kyutai Pocket TTS
We welcome contributions from the community!
We recommend using `uv` to work on this project. While you can use `pip` too, `uv` is less error-prone and faster.
The instructions below assume you are using `uv`.
## Installing the pre-commit
```bash
uvx pre-commit install
```
This will handle file formatting and linting.
If you want to manually run the pre-commit hooks on all files, use:
```bash
uvx pre-commit run --all-files
```
## Running tests
```bash
uv run pytest -n 3 -v
```
This will run the test suite with 3 parallel workers.
## Running the CLI locally
You can run the CLI commands with:
```bash
uv run pocket-tts generate
```
## Coding agents
We use `AGENTS.md` to manage the coding agents context. If your coding agent does
not supports reading from this file directly, you can just
use a symlink. The most common ones are already added in the `.gitignore` file.
```bash
ln -s AGENTS.md CLAUDE.md
# or
ln -s AGENTS.md QWEN.md
```
## How does this model work?
Here is a high-level overview of the architecture:
![Architecture Diagram](./docs/model_arch.png)
Overall the model has four main components:
* The mimi vae encoder, to encode the audio prompts into a latent representation.
* The text "encoder" which is just a simple tokenizer + embedding layer.
* The calm model (`flow_lm` in the codebase) to generate autoregegressive the audio latents.
* The mimi vae decoder to decode the audio latents into waveform.
Note that two threads run in parallel in the current implementation:
* One with the calm model generating the latents.
* One with the mimi vae decoder decoding the latents into audio.
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/astral-sh/uv:debian
WORKDIR /app
COPY ./pyproject.toml .
COPY ./uv.lock .
COPY ./README.md .
COPY ./.python-version .
COPY ./pocket_tts ./pocket_tts
RUN uv run pocket-tts serve --help && \
rm -rf /root/.cache/uv && \
uv run pocket-tts serve --help
CMD ["uv", "run", "pocket-tts", "serve"]
+23
View File
@@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+115
View File
@@ -0,0 +1,115 @@
# Pocket TTS
![logo](./docs/logo.png)
A lightweight text-to-speech (TTS) application designed to run efficiently on CPUs.
Forget about the hassle of using GPUs and web APIs serving TTS models. With Kyutai's Pocket TTS, generating audio is just a pip install and a function call away.
Supports Python 3.10, 3.11, 3.12, 3.13 and 3.14. Requires PyTorch 2.5+. Does not require the gpu version of PyTorch.
[🔊 Demo](https://kyutai.org/tts) |
[🐱‍💻GitHub Repository](https://github.com/kyutai-labs/pocket-tts) |
[🤗 Hugging Face Model Card](https://huggingface.co/kyutai/pocket-tts) |
[📄 Paper](https://arxiv.org/abs/2509.06926) |
[📚 Documentation](https://github.com/kyutai-labs/pocket-tts/tree/main/docs)
## Features
* Runs on CPU
* Small model size (100M parameters)
* Audio streaming
* Low latency (~150ms for 2-3 sentences)
* Faster than real-time (~6x real-time on a CPU of MacBook Air M4)
* Uses only 2 CPU cores
* Very simple codebase and easy to use
* Voice cloning
* English only at the moment
## Trying it from the website, without installing anything
Navigate to the [Kyutai website](https://kyutai.org/tts) to try it out directly in your browser. You can input text, select different voices, and generate speech without any installation.
## Trying it with the CLI
### The `generate` command
You can use pocket-tts directly from the command line. We recommend using
`uv` as it installs any dependencies on the fly in an isolated environment (uv installation instructions [here](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer)).
You can also use `pip install pocket-tts` to install it manually.
This will generate a wav file `./tts_output.wav` saying the default text with the default voice, and display some speed statistics.
```bash
uvx pocket-tts generate
# or if you installed it manually with pip:
pocket-tts generate
```
Modify the voice with `--voice` and the text with `--text`. We provide a small catalog of voices:
```
"marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma"
```
Feel free to check out the [generate documentation](https://github.com/kyutai-labs/pocket-tts/tree/main/docs/generate.md) for more details and examples.
### The `serve` command
You can also run a local server to generate audio via HTTP requests.
```bash
uvx pocket-tts serve
# or if you installed it manually with pip:
pocket-tts serve
```
Navigate to `http://localhost:8000` to try the web interface, it's faster than the command line as the model is kept in memory between requests.
You can check out the [serve documentation](https://github.com/kyutai-labs/pocket-tts/tree/main/docs/serve.md) for more details and examples.
## Using it as a Python library
Install the package with
```bash
pip install pocket-tts
# or
uv add pocket-tts
```
You can use this package as a simple Python library to generate audio from text.
```python
from pocket_tts import TTSModel
import scipy.io.wavfile
tts_model = TTSModel.load_model()
voice_state = tts_model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
audio = tts_model.generate_audio(voice_state, "Hello world, this is a test.")
# Audio is a 1D torch tensor containing PCM data.
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
```
You can have multiple voice states around if
you have multiple voices you want to use. `load_model()`
and `get_state_for_audio_prompt()` are relatively slow operations,
so we recommend to keep the model and voice states in memory if you can.
You can check out the [Python API documentation](https://github.com/kyutai-labs/pocket-tts/tree/main/docs/python-api.md) for more details and examples.
## Unsupported features
At the moment, we do not support (but would love pull requests adding):
- [Running the TTS inside a web browser (WebAssembly)](https://github.com/kyutai-labs/pocket-tts/issues/1)
- [A compiled version with for example `torch.compile()` or `candle`.](https://github.com/kyutai-labs/pocket-tts/issues/2)
- [Handling long prompts by splitting them in a smart way.](https://github.com/kyutai-labs/pocket-tts/issues/5)
- [Adding silence in the text input to generate pauses.](https://github.com/kyutai-labs/pocket-tts/issues/6)
- [Quantization to run the computation in int8.](https://github.com/kyutai-labs/pocket-tts/issues/7)
We tried running this TTS model on the GPU but did not observe a speedup compared to CPU execution,
notably because we use a batch size of 1 and a very small model.
## Development and local setup
We accept contributions! Feel free to open issues or pull requests on GitHub.
You can find development instructions in the [CONTRIBUTING.md](https://github.com/kyutai-labs/pocket-tts/tree/main/CONTRIBUTING.md) file. You'll also find there how to have an editable install of the package for local development.
## Prohibited use.
Use of our model must comply with all applicable laws and regulations and must not result in, involve, or facilitate any illegal, harmful, deceptive, fraudulent, or unauthorized activity. Prohibited uses include, without limitation, voice impersonation or cloning without explicit and lawful consent; misinformation, disinformation, or deception (including fake news, fraudulent calls, or presenting generated content as genuine recordings of real people or events); and the generation of unlawful, harmful, libelous, abusive, harassing, discriminatory, hateful, or privacy-invasive content. We disclaim all liability for any non-compliant use.
+83
View File
@@ -0,0 +1,83 @@
# Generate Command Documentation
The `generate` command allows you to generate speech from text directly from the command line using Kyutai Pocket TTS.
## Basic Usage
```bash
uvx pocket-tts generate
# or if installed manually:
pocket-tts generate
```
This will generate a WAV file `./tts_output.wav` with the default text and voice.
## Command Options
### Core Options
- `--text TEXT`: Text to generate (default: "Hello world! I am Kyutai Pocket TTS. I'm fast enough to run on small CPUs. I hope you'll like me.")
- `--voice VOICE`: Path to audio conditioning file (voice to clone) (default: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"). Urls and local paths are supported.
- `--output-path OUTPUT_PATH`: Output path for generated audio (default: "./tts_output.wav")
### Generation Parameters
- `--variant VARIANT`: Model signature (default: "b6369a24")
- `--lsd-decode-steps LSD_DECODE_STEPS`: Number of generation steps (default: 1)
- `--temperature TEMPERATURE`: Temperature for generation (default: 0.7)
- `--noise-clamp NOISE_CLAMP`: Noise clamp value (default: None)
- `--eos-threshold EOS_THRESHOLD`: EOS threshold (default: -4.0)
- `--frames-after-eos FRAMES_AFTER_EOS`: Number of frames to generate after EOS (default: None, auto-calculated based on the text length). Each frame is 80ms.
### Performance Options
- `--device DEVICE`: Device to use (default: "cpu", you may not get a speedup by using a gpu since it's a small model)
- `--quiet`, `-q`: Disable logging output
## Examples
### Basic Generation
```bash
# Generate with default settings
pocket-tts generate
# Custom text
pocket-tts generate --text "Hello, this is a custom message."
# Custom output path
pocket-tts generate --output-path "./my_audio.wav"
```
### Voice Selection
```bash
# Use different voice from HuggingFace
pocket-tts generate --voice "hf://kyutai/tts-voices/jessica-jian/casual.wav"
# Use local voice file
pocket-tts generate --voice "./my_voice.wav"
```
### Quality Tuning
```bash
# Higher quality (more steps)
pocket-tts generate --lsd-decode-steps 5 --temperature 0.5
# More expressive (higher temperature)
pocket-tts generate --temperature 1.0
# Adjust EOS threshold, smaller means finishing earlier.
pocket-tts generate --eos-threshold -3.0
```
## Output Format
The generate command always outputs WAV files in the following format:
- **Sample Rate**: 24kHz
- **Channels**: Mono
- **Bit Depth**: 16-bit PCM
- **Format**: Standard WAV file
For more advanced usage, see the [Python API documentation](python-api.md) or consider using the [serve command](serve.md) for web-based generation and quick iteration.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+223
View File
@@ -0,0 +1,223 @@
# Python API Documentation
Kyutai Pocket TTS provides a Python API for integrating text-to-speech capabilities into your applications.
## Installation
```bash
pip install pocket-tts
```
## Quick Start
```python
from pocket_tts import TTSModel
import scipy.io.wavfile
# Load the model
tts_model = TTSModel.load_model()
# Get voice state from an audio file
voice_state = tts_model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# Generate audio
audio = tts_model.generate_audio(voice_state, "Hello world, this is a test.")
# Save to file
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
```
## Core Classes
### TTSModel
The main class for text-to-speech generation.
#### Class Methods
##### `load_model(variant="b6369a24", temp=0.7, lsd_decode_steps=1, noise_clamp=None, eos_threshold=-4.0)`
Load and return a TTSModel instance with pre-trained weights.
**Parameters:**
- `variant` (str): Model variant identifier (default: "b6369a24")
- `temp` (float): Sampling temperature for generation (default: 0.7)
- `lsd_decode_steps` (int): Number of generation steps (default: 1)
- `noise_clamp` (float | None): Maximum value for noise sampling (default: None)
- `eos_threshold` (float): Threshold for end-of-sequence detection (default: -4.0)
**Returns:**
- `TTSModel`: Loaded model instance on CPU
**Example:**
```python
from pocket_tts import TTSModel
# Load with default settings
model = TTSModel.load_model()
# Load with custom parameters
model = TTSModel.load_model(variant="b6369a24", temp=0.5, lsd_decode_steps=5, eos_threshold=-3.0)
```
#### Properties
##### `device` (str)
Returns the device type where the model is running ("cpu" or "cuda").
By default, the model runs on CPU.
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
print(f"Model running on: {model.device}")
```
##### `sample_rate` (int)
Returns the generated audio sample rate (typically 24000 Hz).
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
print(f"Sample rate: {model.sample_rate} Hz")
```
#### Methods
##### `get_state_for_audio_prompt(audio_conditioning, truncate=False)`
Extract model state for a given audio file or URL (voice cloning).
**Parameters:**
- `audio_conditioning` (Path | str | torch.Tensor): Audio file path, URL, or tensor
- `truncate` (bool): Whether to truncate the audio (default: False)
**Returns:**
- `dict`: Model state dictionary containing hidden states and positional information
**Example:**
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
# From HuggingFace URL
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
# From local file
voice_state = model.get_state_for_audio_prompt("./my_voice.wav")
# From HTTP URL
voice_state = model.get_state_for_audio_prompt(
"https://huggingface.co/kyutai/tts-voices/resolve"
"/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
)
```
##### `generate_audio(model_state, text_to_generate, frames_after_eos=None, copy_state=True)`
Generate complete audio tensor from text input.
**Parameters:**
- `model_state` (dict): Model state from `get_state_for_audio_prompt()`
- `text_to_generate` (str): Text to convert to speech
- `frames_after_eos` (int | None): Frames to generate after EOS detection (default: None)
- `copy_state` (bool): Whether to copy the state (default: True)
**Returns:**
- `torch.Tensor`: Audio 1D tensor with shape [samples]
**Example:**
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
# Generate audio
audio = model.generate_audio(voice_state, "Hello world!", frames_after_eos=2, copy_state=True)
print(f"Generated audio shape: {audio.shape}")
print(f"Audio duration: {audio.shape[-1] / model.sample_rate:.2f} seconds")
```
##### `generate_audio_stream(model_state, text_to_generate, frames_after_eos=None, copy_state=True)`
Generate audio streaming chunks from text input.
**Parameters:** Same as `generate_audio()`
**Yields:**
- `torch.Tensor`: Audio chunks with shape [samples]
**Example:**
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
# Stream generation
for chunk in model.generate_audio_stream(voice_state, "Long text content..."):
# Process each chunk as it's generated
print(f"Generated chunk: {chunk.shape[0]} samples")
# Could save chunks to file or play in real-time
```
## Advanced Usage
### Voice Management
```python
from pocket_tts import TTSModel
model = TTSModel.load_model()
# Preload multiple voices
voices = {
"casual": model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav"),
"funny": model.get_state_for_audio_prompt(
"https://huggingface.co/kyutai/tts-voices/resolve/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
),
}
# Generate with different voices
casual_audio = model.generate_audio(voices["casual"], "Hey there!")
funny_audio = model.generate_audio(voices["funny"], "Good morning.")
```
### Batch Processing
```python
from pocket_tts import TTSModel
import scipy.io.wavfile
import torch
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
# Process multiple texts efficiently by re-using the same voice state
texts = [
"First sentence to generate.",
"Second sentence to generate.",
"Third sentence to generate.",
]
audios = []
for text in texts:
audio = model.generate_audio(voice_state, text)
audios.append(audio)
# Concatenate all audio
full_audio = torch.cat(audios, dim=0)
scipy.io.wavfile.write("batch_output.wav", model.sample_rate, full_audio.numpy())
```
### Streaming to File
You can refer to our CLI implementation which can stream audio to a wav file.
For more information about the command-line interface, see the [Generate Documentation](generate.md) or [Serve Documentation](serve.md).
+48
View File
@@ -0,0 +1,48 @@
# Serve Command Documentation
The `serve` command starts a FastAPI web server that provides both a web interface and HTTP API for text-to-speech generation.
## Basic Usage
```bash
uvx pocket-tts serve
# or if installed manually:
pocket-tts serve
```
This starts a server on `http://localhost:8000` with the default voice model.
## Command Options
- `--voice VOICE`: Path to voice prompt audio file (voice to clone) (default: "hf://kyutai/tts-voices/alba-mackenna/casual.wav")
- `--host HOST`: Host to bind to (default: "localhost")
- `--port PORT`: Port to bind to (default: 8000)
- `--reload`: Enable auto-reload for development
## Examples
### Basic Server
```bash
# Start with default settings
pocket-tts serve
# Custom host and port
pocket-tts serve --host "localhost" --port 8080
```
### Custom Voice
```bash
# Use different voice
pocket-tts serve --default-voice "hf://kyutai/tts-voices/jessica-jian/casual.wav"
# Use local voice file
pocket-tts serve --default-voice "./my_voice.wav"
```
## Web Interface
Once the server is running, navigate to `http://localhost:8000` to access the web interface.
For more advanced usage, see the [Python API documentation](python-api.md) for direct integration with the TTS model.
+16
View File
@@ -0,0 +1,16 @@
from beartype import BeartypeConf
from beartype.claw import beartype_this_package
beartype_this_package(conf=BeartypeConf(is_color=False))
from pocket_tts.models.tts_model import TTSModel # noqa: E402
# Public methods:
# TTSModel.device
# TTSModel.sample_rate
# TTSModel.load_model
# TTSModel.generate_audio
# TTSModel.generate_audio_stream
# TTSModel.get_state_for_audio_prompt
__all__ = ["TTSModel"]
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
from pocket_tts.main import cli_app
if __name__ == "__main__":
cli_app()
View File
+38
View File
@@ -0,0 +1,38 @@
import logging
from typing import Generic, NamedTuple, TypeVar
import torch
from torch import nn
logger = logging.getLogger(__name__)
Prepared = TypeVar("Prepared") # represents the prepared condition input type.
class TokenizedText(NamedTuple):
tokens: torch.Tensor # should be long tensor.
class BaseConditioner(nn.Module, Generic[Prepared]):
"""Base model for all conditioner modules.
Args:
dim (int): internal dim of the model.
output_dim (int): Output dim of the conditioner.
force_linear (bool, optional): Force linear projection even when `dim == output_dim`.
output_bias (bool): if True, the output projection will have a bias.
learn_padding (bool): if True, the padding value will be learnt, zero otherwise.
"""
def __init__(
self, dim: int, output_dim: int, output_bias: bool = False, force_linear: bool = True
):
super().__init__()
self.dim = dim
self.output_dim = output_dim
assert force_linear or dim != output_dim
assert not output_bias
def forward(self, inputs: TokenizedText) -> torch.Tensor:
return self._get_condition(inputs)
+61
View File
@@ -0,0 +1,61 @@
import logging
import sentencepiece
import torch
from torch import nn
from pocket_tts.conditioners.base import BaseConditioner, TokenizedText
from pocket_tts.utils.utils import download_if_necessary
logger = logging.getLogger(__name__)
class SentencePieceTokenizer:
"""This tokenizer should be used for natural language descriptions.
For example:
["he didn't, know he's going home.", 'shorter sentence'] =>
[[78, 62, 31, 4, 78, 25, 19, 34],
[59, 77, PAD, PAD, PAD, PAD, PAD, PAD]]
Args:
n_bins (int): should be equal to the number of elements in the sentencepiece tokenizer.
tokenizer_path (str): path to the sentencepiece tokenizer model.
"""
def __init__(self, nbins: int, tokenizer_path: str) -> None:
logger.info("Loading sentencepiece tokenizer from %s", tokenizer_path)
tokenizer_path = download_if_necessary(tokenizer_path)
self.sp = sentencepiece.SentencePieceProcessor(str(tokenizer_path))
assert nbins == self.sp.vocab_size(), (
f"sentencepiece tokenizer has vocab size={self.sp.vocab_size()} but nbins={nbins} was specified"
)
def __call__(self, text: str) -> TokenizedText:
return TokenizedText(torch.tensor(self.sp.encode(text, out_type=int))[None, :])
class LUTConditioner(BaseConditioner):
"""Lookup table TextConditioner.
Args:
n_bins (int): Number of bins.
dim (int): Hidden dim of the model (text-encoder/LUT).
output_dim (int): Output dim of the conditioner.
tokenizer (str): Name of the tokenizer.
possible_values (list[str] or None): list of possible values for the tokenizer.
"""
def __init__(self, n_bins: int, tokenizer_path: str, dim: int, output_dim: int):
super().__init__(dim=dim, output_dim=output_dim)
self.tokenizer = SentencePieceTokenizer(n_bins, tokenizer_path)
self.embed = nn.Embedding(n_bins + 1, self.dim) # n_bins + 1 for padding.
def prepare(self, x: str) -> TokenizedText:
tokens = self.tokenizer(x)
tokens = tokens[0].to(self.embed.weight.device)
return TokenizedText(tokens)
def _get_condition(self, inputs: TokenizedText) -> torch.Tensor:
embeds = self.embed(inputs[0])
return embeds
+57
View File
@@ -0,0 +1,57 @@
# sig: b6369a24
weights_path: hf://kyutai/pocket-tts/tts_b6369a24.safetensors
weights_path_without_voice_cloning: hf://kyutai/pocket-tts-without-voice-cloning/tts_b6369a24.safetensors
flow_lm:
dtype: float32
flow:
depth: 6
dim: 512
transformer:
d_model: 1024
hidden_scale: 4
max_period: 10000
num_heads: 16
num_layers: 6
lookup_table:
dim: 1024
n_bins: 4000
tokenizer: sentencepiece
tokenizer_path: hf://kyutai/pocket-tts-without-voice-cloning/tokenizer.model
#weights_path: flow_lm_b6369a24.safetensors
mimi:
dtype: float32
sample_rate: 24000
channels: 1
frame_rate: 12.5
seanet:
dimension: 512
channels: 1
n_filters: 64
n_residual_layers: 1
ratios:
- 6
- 5
- 4
kernel_size: 7
residual_kernel_size: 3
last_kernel_size: 3
dilation_base: 2
pad_mode: constant
compress: 2
transformer:
d_model: 512
num_heads: 8
num_layers: 2
layer_scale: 0.01
context: 250
dim_feedforward: 2048
input_dimension: 512
output_dimensions:
- 512
quantizer:
dimension: 32
output_dimension: 512
#weights_path: mimi_b6369a24.safetensors
+2
View File
@@ -0,0 +1,2 @@
"""Audio loading and writing support. Datasets for raw audio
or also including some metadata."""
+126
View File
@@ -0,0 +1,126 @@
"""
Audio IO methods are defined in this module (info, read, write),
We rely on av library for faster read when possible, otherwise on torchaudio.
"""
import logging
import os
import sys
import wave
from contextlib import nullcontext
from pathlib import Path
from typing import Any
import numpy as np
import torch
from beartype.typing import Iterator
logger = logging.getLogger(__name__)
FIRST_CHUNK_LENGTH_SECONDS = float(os.environ.get("FIRST_CHUNK_LENGTH_SECONDS", "0"))
def audio_read(filepath: str | Path) -> tuple[torch.Tensor, int]:
"""Read audio using Python's wave module."""
with wave.open(str(filepath), "rb") as wav_file:
sample_rate = wav_file.getframerate()
# Read all audio data as 16-bit signed integers
raw_data = wav_file.readframes(-1)
samples = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32) / 32768.0
# Return as mono tensor (channels, samples)
wav = torch.from_numpy(samples.reshape(1, -1))
return wav, sample_rate
class StreamingWAVWriter:
"""WAV writer using Python's standard library wave module."""
def __init__(self, output_stream, sample_rate: int):
self.output_stream = output_stream
self.sample_rate = sample_rate
self.wave_writer = None
self.first_chunk_buffer = []
def write_header(self, sample_rate: int):
"""Initialize WAV writer with header."""
# For stdout streaming, we need to handle the unseekable stream case
# The wave module supports unseekable streams since Python 3.4
self.wave_writer = wave.open(self.output_stream, "wb")
self.wave_writer.setnchannels(1) # Mono
self.wave_writer.setsampwidth(2) # 16-bit
self.wave_writer.setframerate(sample_rate)
self.wave_writer.setnframes(1_000_000_000)
def write_pcm_data(self, audio_chunk: torch.Tensor):
"""Write PCM data using wave module."""
# Convert to int16 PCM bytes
chunk_int16 = (audio_chunk.clamp(-1, 1) * 32767).short()
chunk_bytes = chunk_int16.detach().cpu().numpy().tobytes()
if self.first_chunk_buffer is not None:
self.first_chunk_buffer.append(chunk_bytes)
total_length = sum(len(c) for c in self.first_chunk_buffer)
target_length = (
int(self.sample_rate * FIRST_CHUNK_LENGTH_SECONDS) * 2
) # 2 bytes per sample
if total_length < target_length:
return
self._flush()
return
# Use writeframesraw to avoid frame count validation for streaming
self.wave_writer.writeframesraw(chunk_bytes)
def _flush(self):
if self.first_chunk_buffer is not None:
self.wave_writer.writeframesraw(b"".join(self.first_chunk_buffer))
self.first_chunk_buffer = None
def finalize(self):
"""Close the wave writer."""
self._flush()
# Let's add 200ms of silence to ensure proper playback
silence_duration_sec = 0.2
num_silence_samples = int(self.sample_rate * silence_duration_sec)
self.wave_writer.writeframesraw(bytes(num_silence_samples * 2))
if self.wave_writer:
# do not update the header for unseekable streams
self.wave_writer._patchheader = lambda: None
self.wave_writer.close()
def is_file_like(obj):
"""Check if object has basic file-like methods."""
return all(hasattr(obj, attr) for attr in ["write", "close"])
def stream_audio_chunks(
path: str | Path | None | Any, audio_chunks: Iterator[torch.Tensor], sample_rate: int
):
"""Stream audio chunks to a WAV file or stdout, optionally playing them."""
if path == "-":
f = sys.stdout.buffer
elif path is None:
f = nullcontext()
elif is_file_like(path):
f = path
else:
f = open(path, "wb")
with f:
if path is not None:
writer = StreamingWAVWriter(f, sample_rate)
writer.write_header(sample_rate)
for chunk in audio_chunks:
# Then write to file
if path is not None:
writer.write_pcm_data(chunk)
if path is not None:
writer.finalize()
+28
View File
@@ -0,0 +1,28 @@
"""Various utilities for audio convertion (pcm format, sample rate and channels),
and volume normalization."""
import torch
from scipy.signal import resample_poly
def convert_audio(
wav: torch.Tensor, from_rate: int | float, to_rate: int | float, to_channels: int
) -> torch.Tensor:
"""Convert audio to new sample rate and number of audio channels."""
if from_rate != to_rate:
# Convert to numpy for scipy resampling
wav_np = wav.detach().cpu().numpy()
# Calculate resampling parameters
gcd = int(torch.gcd(torch.tensor(from_rate), torch.tensor(to_rate)).item())
up = int(to_rate // gcd)
down = int(from_rate // gcd)
# Resample using scipy
resampled_np = resample_poly(wav_np, up, down, axis=-1)
# Convert back to torch tensor
wav = torch.from_numpy(resampled_np).to(wav.device).to(wav.dtype)
assert wav.shape[-2] == to_channels
return wav
+7
View File
@@ -0,0 +1,7 @@
DEFAULT_AUDIO_PROMPT = "marius"
DEFAULT_VARIANT = "b6369a24"
DEFAULT_TEMPERATURE = 0.7
DEFAULT_LSD_DECODE_STEPS = 1
DEFAULT_NOISE_CLAMP = None
DEFAULT_EOS_THRESHOLD = -4.0
DEFAULT_FRAMES_AFTER_EOS = None
+254
View File
@@ -0,0 +1,254 @@
import io
import logging
import os
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,
)
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 = 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, truncate=True)
logging.warning("Using voice from URL: %s", voice_url)
elif voice_wav is not None:
# Use uploaded voice file
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_file:
content = voice_wav.file.read()
temp_file.write(content)
temp_file.flush()
try:
model_state = tts_model.get_state_for_audio_prompt(
Path(temp_file.name), truncate=True
)
finally:
os.unlink(temp_file.name)
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,
):
"""Start the FastAPI server."""
global tts_model, global_model_state
tts_model = TTSModel.load_model(DEFAULT_VARIANT)
# 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,
variant: Annotated[str, typer.Option(help="Model 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,
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",
):
"""Generate speech using Kyutai Pocket TTS."""
if "cuda" in device:
# Cuda graphs capturing does not play nice with multithreading.
os.environ["NO_CUDA_GRAPH"] = "1"
log_level = logging.ERROR if quiet else logging.INFO
with enable_logging("pocket_tts", log_level):
tts_model = TTSModel.load_model(
variant, 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,
)
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)
if __name__ == "__main__":
cli_app()
+3
View File
@@ -0,0 +1,3 @@
"""
Models for EnCodec (and Mimi), and FlowLMModel.
"""
+209
View File
@@ -0,0 +1,209 @@
import logging
from functools import partial
import torch
import torch.export
from beartype.typing import Callable
from torch import nn
from typing_extensions import Self
from pocket_tts.conditioners.text import LUTConditioner
from pocket_tts.modules.mimi_transformer import StreamingTransformer
from pocket_tts.modules.mlp import SimpleMLPAdaLN
from pocket_tts.utils.config import FlowLMConfig
logger = logging.getLogger(__name__)
FlowNet2 = Callable[[torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor]
def lsd_decode(v_t: FlowNet2, x_0: torch.Tensor, num_steps: int = 1) -> torch.Tensor:
"""Rebuilds the data sample from starting point x_0.
Lagrangian Self Distillation (https://arxiv.org/pdf/2505.18825)
Args:
v_t: Function taking t and x_t as input and returning the flow.
x_0: Starting point from the known distribution.
num_steps: Number of steps to take.
Returns:
x_1_hat: (B, D) Reconstructed data sample.
"""
current = x_0
for i in range(num_steps):
s = i / num_steps
t = (i + 1) / num_steps
flow_dir = v_t(
s * torch.ones_like(x_0[..., :1]), t * torch.ones_like(x_0[..., :1]), current
)
current += flow_dir / num_steps
return current
class FlowLMModel(nn.Module):
"""Transformer-based flow language model on multiple streams of latents.
Args:
conditioner (LUTConditioner): Text conditioner for processing text inputs.
flow: Flow module that defines the flow loss and sampling strategy.
flow_net: Trainable function (cond, t, x_t) -> u_t.
dim (int): Dimension of the transformer encoder.
norm (str): Normalization method.
attribute_dropouts (dict): Attribute dropout probabilities.
ldim (int): Latent dimension.
stats_ema_decay (float): Decay for the EMA of the latent statistics.
**kwargs: Additional parameters for the transformer encoder.
"""
def __init__(
self,
conditioner: LUTConditioner,
flow_net: SimpleMLPAdaLN,
transformer: StreamingTransformer,
dim: int = 128,
ldim: int = 64,
stats_ema_decay: float = 0.999,
text_padding_weight: float = 1.0,
dtype=None,
):
super().__init__()
self.conditioner = conditioner
self.ldim = ldim
self.stats_ema_decay = stats_ema_decay
self.dim = dim
self.text_padding_weight = text_padding_weight
self.dtype = dtype
self.flow_net = flow_net
self.register_buffer("emb_std", torch.ones(ldim, dtype=dtype))
self.register_buffer("emb_mean", torch.zeros(ldim, dtype=dtype))
self.bos_emb = torch.nn.Parameter(torch.randn(ldim, dtype=dtype))
self.input_linear = nn.Linear(self.ldim, dim, bias=False, dtype=dtype)
self.transformer = transformer
self.out_norm = nn.LayerNorm(dim, eps=1e-5)
self.out_eos = nn.Linear(dim, 1, dtype=dtype)
@property
def device(self) -> str:
return next(self.parameters()).device.type
def forward(
self,
sequence: torch.Tensor,
text_embeddings: torch.Tensor,
model_state: dict,
lsd_decode_steps: int,
temp: float,
noise_clamp: float | None,
eos_threshold: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Apply language model on sequence and conditions.
Given a tensor of sequence of shape [B, S, ldim], returns the loss in training mode
or the reconstructed latent in generation mode.
Args:
sequence (torch.Tensor): Latents to model.
text_embeddings (torch.Tensor): Pre-computed conditioning
tensor.
lsd_decode_steps (int): Number of steps to decode when generating audio.
If zero, the model computes the loss.
Returns:
(output, eos_output, metrics). If `lsd_decode_steps` is zero, `output` is the loss tensor of shape [B, S],
otherwise it is the reconstructed latent.
"""
# NaN values signal a BOS position.
sequence = torch.where(torch.isnan(sequence), self.bos_emb, sequence)
input_ = self.input_linear(sequence)
transformer_out = self.backbone(input_, text_embeddings, sequence, model_state=model_state)
transformer_out = transformer_out.to(torch.float32)
assert lsd_decode_steps > 0
transformer_out = transformer_out[:, -1]
out_eos = self.out_eos(transformer_out) > eos_threshold
noise_shape = transformer_out.shape[:-1] + (self.ldim,)
std = temp**0.5
noise = torch.empty(noise_shape, dtype=transformer_out.dtype, device=transformer_out.device)
if noise_clamp is None:
torch.nn.init.normal_(noise, mean=0.0, std=std)
else:
torch.nn.init.trunc_normal_(noise, mean=0.0, std=std, a=-noise_clamp, b=noise_clamp)
conditioned_flow = partial(self.flow_net, transformer_out)
return lsd_decode(conditioned_flow, noise, lsd_decode_steps), out_eos
def backbone(
self, input_, text_embeddings: torch.Tensor, sequence, model_state: dict
) -> torch.Tensor:
# Most of the time, one of those two tensors is empty, it allows us
# to input text or audio embeddings into the model without adding an
# if-else branch.
# print("text_embeddings shape:", text_embeddings.shape)
# if text_embeddings.numel() != 0:
# torch.save(text_embeddings, "debug_flow_lm_text_embeddings.pt")
input_ = torch.cat([text_embeddings, input_], dim=1)
# transformer_out = self.transformer(input_, model_state=model_state)
transformer_out = self.transformer(input_, model_state)
if self.out_norm:
transformer_out = self.out_norm(transformer_out)
# remove the prefix from the model outputs (condition is prepended)
transformer_out = transformer_out[:, -sequence.shape[1] :]
return transformer_out
def _sample_next_latent(
self,
sequence: torch.Tensor,
text_embeddings: torch.Tensor,
model_state: dict,
lsd_decode_steps: int,
temp: float,
noise_clamp: float | None,
eos_threshold: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Sample next latent from the model given a sequence and a set of conditions.
Args:
sequence (torch.Tensor): Current sequence of shape [B, K, S]
with K corresponding to the number of codebooks and S the number of sequence steps.
S = 1 in streaming mode, except for the first step that contains a bigger prompt.
text_embeddings (torch.Tensor): Condition tensor.
n_steps (int): Number of flow steps to decode when generating audio.
Returns:
next_latent (torch.Tensor), is_eos (torch.Tensor): Next latent tensor of shape [B, 1, ldim]
and is_eos tensor of shape [B, 1] with 1 on EOS positions.
"""
result = self(
sequence=sequence,
text_embeddings=text_embeddings,
lsd_decode_steps=lsd_decode_steps,
temp=temp,
noise_clamp=noise_clamp,
eos_threshold=eos_threshold,
model_state=model_state,
)
return result
@classmethod
def from_pydantic_config(cls, config: FlowLMConfig, latent_dim: int) -> Self:
d_model = config.transformer.d_model
flow_mlp = SimpleMLPAdaLN.from_pydantic_config(config, latent_dim, d_model)
conditioner = LUTConditioner(
n_bins=config.lookup_table.n_bins,
tokenizer_path=str(config.lookup_table.tokenizer_path),
dim=config.lookup_table.dim,
output_dim=d_model,
)
transformer = StreamingTransformer.from_pydantic_config(config.transformer)
return cls(
flow_net=flow_mlp,
transformer=transformer,
dim=d_model,
conditioner=conditioner,
ldim=latent_dim,
dtype=getattr(torch, config.dtype),
)
+111
View File
@@ -0,0 +1,111 @@
import logging
import torch
from torch import nn
from pocket_tts.modules.conv import pad_for_conv1d
from pocket_tts.modules.dummy_quantizer import DummyQuantizer
from pocket_tts.modules.mimi_transformer import ProjectedTransformer
from pocket_tts.modules.resample import ConvDownsample1d, ConvTrUpsample1d
from pocket_tts.modules.seanet import SEANetDecoder, SEANetEncoder
logger = logging.getLogger()
class MimiModel(nn.Module):
def __init__(
self,
encoder: SEANetEncoder,
decoder: SEANetDecoder,
quantizer: DummyQuantizer,
frame_rate: float,
encoder_frame_rate: float,
sample_rate: int,
channels: int,
encoder_transformer: ProjectedTransformer,
decoder_transformer: ProjectedTransformer,
):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.encoder_transformer = encoder_transformer
self.decoder_transformer = decoder_transformer
self.quantizer = quantizer
self.frame_rate = frame_rate
self.sample_rate = sample_rate
self.channels = channels
self.encoder_frame_rate = encoder_frame_rate
# We will need the dimension for the resampling. In general the encoder will be a SeanetEncoder
# which exposes a `dimension` attribute.
dimension = encoder.dimension
assert isinstance(dimension, int), (
f"Dimension should be int, got {dimension} of type {type(dimension)}."
)
self.dimension = dimension
if encoder_frame_rate != frame_rate:
assert self.encoder_frame_rate > self.frame_rate, "Cannot upsample with conv."
downsample_stride = self.encoder_frame_rate / self.frame_rate
assert downsample_stride == int(downsample_stride), (
f"Only integer strides are supported, got {downsample_stride}"
)
self.downsample = ConvDownsample1d(int(downsample_stride), dimension=dimension)
self.upsample = ConvTrUpsample1d(int(downsample_stride), dimension=dimension)
@property
def frame_size(self) -> int:
return int(self.sample_rate / self.frame_rate)
def _to_framerate(self, x: torch.Tensor):
# Convert from the encoder frame rate to the overall framerate.
_, _, length = x.shape
frame_rate = self.encoder_frame_rate
new_frame_rate = self.frame_rate
if frame_rate == new_frame_rate:
return x
return self.downsample(x, model_state=None)
def _to_encoder_framerate(self, x: torch.Tensor, mimi_state) -> torch.Tensor:
# Convert from overall framerate to the encoder frame rate.
_, _, length = x.shape
frame_rate = self.encoder_frame_rate
new_frame_rate = self.frame_rate
if frame_rate == new_frame_rate:
return x
return self.upsample(x, mimi_state)
def forward(self, x: torch.Tensor):
raise NotImplementedError()
def decode_from_latent(self, latent: torch.Tensor, mimi_state) -> torch.Tensor:
emb = self._to_encoder_framerate(latent, mimi_state)
(emb,) = self.decoder_transformer(emb, mimi_state)
out = self.decoder(emb, mimi_state)
# out contains extra padding added by the encoder and decoder
return out
def encode_to_latent(self, x: torch.Tensor) -> torch.Tensor:
"""Projects a batch of waveforms to unquantized latent space.
Args:
x (torch.Tensor): Float tensor of shape [B, C, T].
Returns:
Unquantized embeddings.
"""
assert x.dim() == 3, (
f"CompressionModel._encode_to_unquantized_latent expects audio of shape [B, C, T] but got {x.shape}"
)
frame_size = self.frame_size
# The underlying convolutions no longer accept partial inputs,
# `x` needs to be exactly a multiple of the frame size,
# reproducing the previous padding behavior here.
x = pad_for_conv1d(x, frame_size, frame_size)
emb = self.encoder(x, model_state=None)
(emb,) = self.encoder_transformer(emb, model_state=None)
emb = self._to_framerate(emb)
return emb
+719
View File
@@ -0,0 +1,719 @@
import copy
import logging
import os
import queue
import statistics
import threading
import time
from functools import lru_cache
from pathlib import Path
import safetensors
import torch
from torch import nn
from torch.nn import functional as F
from typing_extensions import Self
from pocket_tts.conditioners.base import TokenizedText
from pocket_tts.data.audio import audio_read
from pocket_tts.data.audio_utils import convert_audio
from pocket_tts.default_parameters import (
DEFAULT_EOS_THRESHOLD,
DEFAULT_LSD_DECODE_STEPS,
DEFAULT_NOISE_CLAMP,
DEFAULT_TEMPERATURE,
DEFAULT_VARIANT,
)
from pocket_tts.models.flow_lm import FlowLMModel
from pocket_tts.models.mimi import MimiModel
from pocket_tts.modules import mimi_transformer
from pocket_tts.modules.dummy_quantizer import DummyQuantizer
from pocket_tts.modules.seanet import SEANetDecoder, SEANetEncoder
from pocket_tts.modules.stateful_module import increment_steps, init_states
from pocket_tts.utils.config import Config, load_config
from pocket_tts.utils.utils import (
PREDEFINED_VOICES,
display_execution_time,
download_if_necessary,
load_predefined_voice,
size_of_dict,
)
from pocket_tts.utils.weights_loading import get_flow_lm_state_dict, get_mimi_state_dict
torch.set_num_threads(1)
logger = logging.getLogger(__name__)
KEYS_TO_MODIFY = [
"mimi.decoder.model.0.conv.conv.bias",
"mimi.decoder.model.0.conv.conv.weight",
"mimi.decoder.model.11.conv.conv.bias",
"mimi.decoder.model.11.conv.conv.weight",
"mimi.decoder.model.2.convtr.convtr.bias",
"mimi.decoder.model.2.convtr.convtr.weight",
"mimi.decoder.model.3.block.1.conv.conv.bias",
"mimi.decoder.model.3.block.1.conv.conv.weight",
"mimi.decoder.model.3.block.3.conv.conv.bias",
"mimi.decoder.model.3.block.3.conv.conv.weight",
"mimi.decoder.model.5.convtr.convtr.bias",
"mimi.decoder.model.5.convtr.convtr.weight",
"mimi.decoder.model.6.block.1.conv.conv.bias",
"mimi.decoder.model.6.block.1.conv.conv.weight",
"mimi.decoder.model.6.block.3.conv.conv.bias",
"mimi.decoder.model.6.block.3.conv.conv.weight",
"mimi.decoder.model.8.convtr.convtr.bias",
"mimi.decoder.model.8.convtr.convtr.weight",
"mimi.decoder.model.9.block.1.conv.conv.bias",
"mimi.decoder.model.9.block.1.conv.conv.weight",
"mimi.decoder.model.9.block.3.conv.conv.bias",
"mimi.decoder.model.9.block.3.conv.conv.weight",
"mimi.downsample.conv.conv.conv.weight",
"mimi.encoder.model.0.conv.conv.bias",
"mimi.encoder.model.0.conv.conv.weight",
"mimi.encoder.model.1.block.1.conv.conv.bias",
"mimi.encoder.model.1.block.1.conv.conv.weight",
"mimi.encoder.model.1.block.3.conv.conv.bias",
"mimi.encoder.model.1.block.3.conv.conv.weight",
"mimi.encoder.model.11.conv.conv.bias",
"mimi.encoder.model.11.conv.conv.weight",
"mimi.encoder.model.3.conv.conv.bias",
"mimi.encoder.model.3.conv.conv.weight",
"mimi.encoder.model.4.block.1.conv.conv.bias",
"mimi.encoder.model.4.block.1.conv.conv.weight",
"mimi.encoder.model.4.block.3.conv.conv.bias",
"mimi.encoder.model.4.block.3.conv.conv.weight",
"mimi.encoder.model.6.conv.conv.bias",
"mimi.encoder.model.6.conv.conv.weight",
"mimi.encoder.model.7.block.1.conv.conv.bias",
"mimi.encoder.model.7.block.1.conv.conv.weight",
"mimi.encoder.model.7.block.3.conv.conv.bias",
"mimi.encoder.model.7.block.3.conv.conv.weight",
"mimi.encoder.model.9.conv.conv.bias",
"mimi.encoder.model.9.conv.conv.weight",
"mimi.upsample.convtr.convtr.convtr.weight",
]
KEY_WITH_REMOVED_ZERO = [
"mimi.encoder_transformer.transformer.layers.0.self_attn.in_projs.0.weight",
"mimi.encoder_transformer.transformer.layers.0.self_attn.out_projs.0.weight",
"mimi.encoder_transformer.transformer.layers.1.self_attn.in_projs.0.weight",
"mimi.encoder_transformer.transformer.layers.1.self_attn.out_projs.0.weight",
"mimi.decoder_transformer.transformer.layers.0.self_attn.in_projs.0.weight",
"mimi.decoder_transformer.transformer.layers.0.self_attn.out_projs.0.weight",
"mimi.decoder_transformer.transformer.layers.1.self_attn.in_projs.0.weight",
"mimi.decoder_transformer.transformer.layers.1.self_attn.out_projs.0.weight",
]
def adapt_state_dict_of_TTSModel(state_dict: dict) -> dict:
# should be removed before release
new_state_dict = {}
for key, value in state_dict.items():
if key == "mimi.quantizer.input_proj.weight":
continue
if key in KEYS_TO_MODIFY:
new_key = key.replace(".conv.conv.", ".conv.").replace(".convtr.convtr.", ".convtr.")
new_state_dict[new_key] = value
elif key in KEY_WITH_REMOVED_ZERO:
new_key = key.replace("s.0.weight", ".weight")
new_state_dict[new_key] = value
else:
new_state_dict[key] = value
return new_state_dict
class TTSModel(nn.Module):
def __init__(
self,
flow_lm: FlowLMModel,
temp: float,
lsd_decode_steps: int,
noise_clamp: float | None,
eos_threshold,
config: Config,
):
super().__init__()
self.flow_lm = flow_lm
self.temp = temp
self.lsd_decode_steps = lsd_decode_steps
self.noise_clamp = noise_clamp
self.eos_threshold = eos_threshold
self.config = config
self.has_voice_cloning = True
@property
def device(self) -> str:
return next(self.parameters()).device.type
@property
def sample_rate(self) -> int:
return self.config.mimi.sample_rate
@classmethod
def _from_pydantic_config(
cls, config: Config, temp, lsd_decode_steps, noise_clamp: float | None, eos_threshold
) -> Self:
flow_lm = FlowLMModel.from_pydantic_config(
config.flow_lm, latent_dim=config.mimi.quantizer.dimension
)
tts_model = cls(flow_lm, temp, lsd_decode_steps, noise_clamp, eos_threshold, config)
return tts_model
@classmethod
def _from_pydantic_config_with_weights(
cls, config: Config, temp, lsd_decode_steps, noise_clamp: float | None, eos_threshold
) -> Self:
tts_model = cls._from_pydantic_config(
config, temp, lsd_decode_steps, noise_clamp, eos_threshold
)
tts_model.flow_lm.speaker_proj_weight = torch.nn.Parameter(
torch.zeros((1024, 512), dtype=torch.float32)
)
if config.flow_lm.weights_path is not None:
if config.mimi.weights_path is None:
raise ValueError(
"If you specify flow_lm.weights_path you should specify mimi.weights_path"
)
logger.info(f"Loading FlowLM weights from {config.flow_lm.weights_path}")
state_dict_flowlm = get_flow_lm_state_dict(
download_if_necessary(config.flow_lm.weights_path)
)
tts_model.flow_lm.load_state_dict(state_dict_flowlm, strict=True)
# safetensors.torch.save_file(tts_model.state_dict(), "7442637a.safetensors")
# Create mimi config directly from the provided config using model_dump
mimi_config = config.mimi.model_dump()
# Build mimi model from config
encoder = SEANetEncoder(**mimi_config["seanet"])
decoder = SEANetDecoder(**mimi_config["seanet"])
encoder_transformer = mimi_transformer.ProjectedTransformer(**mimi_config["transformer"])
decoder_transformer = mimi_transformer.ProjectedTransformer(**mimi_config["transformer"])
quantizer = DummyQuantizer(**mimi_config["quantizer"])
tts_model.mimi = MimiModel(
encoder,
decoder,
quantizer,
channels=mimi_config["channels"],
sample_rate=mimi_config["sample_rate"],
frame_rate=mimi_config["frame_rate"],
encoder_frame_rate=mimi_config["sample_rate"] / encoder.hop_length,
encoder_transformer=encoder_transformer,
decoder_transformer=decoder_transformer,
).to(device="cpu")
# Load mimi weights from the config safetensors file with complete mapping for strict loading
if config.mimi.weights_path is not None:
if config.flow_lm.weights_path is None:
raise ValueError(
"If you specify mimi.weights_path you should specify flow_lm.weights_path"
)
logger.info(f"Loading Mimi weights from {config.mimi.weights_path}")
mimi_state = get_mimi_state_dict(download_if_necessary(config.mimi.weights_path))
tts_model.mimi.load_state_dict(mimi_state, strict=True)
tts_model.mimi.eval()
# tts_model.to(dtype=torch.float32)
# uncomment to save the weights
# tts_model = tts_model.to(dtype=torch.bfloat16)
# safetensors.torch.save_file(tts_model.state_dict(), "tts_b6369a24.safetensors")
if config.weights_path is not None:
logger.info(f"Loading TTSModel weights from {config.weights_path}")
try:
weights_file = download_if_necessary(config.weights_path)
except Exception:
tts_model.has_voice_cloning = False
weights_file = download_if_necessary(config.weights_path_without_voice_cloning)
state_dict = safetensors.torch.load_file(weights_file)
state_dict = adapt_state_dict_of_TTSModel(state_dict)
tts_model.load_state_dict(state_dict, strict=True)
if config.flow_lm.weights_path is None and config.weights_path is None:
logger.warning(
"No weights_path specified for FlowLM or TTSModel, model is uninitialized!"
)
size_in_mb = size_of_dict(tts_model.state_dict()) // 1e6
logging.info(f"TTS Model loaded successfully. Its size is {size_in_mb} MB")
return tts_model
def load_model(
variant: str = DEFAULT_VARIANT,
temp: float | int = DEFAULT_TEMPERATURE,
lsd_decode_steps: int = DEFAULT_LSD_DECODE_STEPS,
noise_clamp: float | int | None = DEFAULT_NOISE_CLAMP,
eos_threshold: float = DEFAULT_EOS_THRESHOLD,
) -> Self:
"""Load a pre-trained TTS model with specified configuration.
This class method loads a complete TTS model including the flow language model
and Mimi compression model from pre-trained weights. The model is initialized
with the specified generation parameters and ready for inference.
Args:
variant: Model variant identifier corresponding to a config file name
(e.g., '610b0b2c'). Must match a YAML file in the config directory.
temp: Sampling temperature for generation. Higher values produce more
diverse but potentially lower quality output.
lsd_decode_steps: Number of steps for Lagrangian Self Distillation
decoding. More steps can improve quality but increase computation.
noise_clamp: Maximum value for noise sampling. If None, no clamping
is applied. Helps prevent extreme values in generation.
eos_threshold: Threshold for end-of-sequence detection. Higher values
make the model more likely to continue generating.
Returns:
TTSModel: Fully initialized model with loaded weights on cpu, ready for
text-to-speech generation.
Raises:
FileNotFoundError: If the specified config file or model weights
are not found.
ValueError: If the configuration is invalid or incompatible.
"""
config = load_config(Path(__file__).parents[1] / f"config/{variant}.yaml")
tts_model = TTSModel._from_pydantic_config_with_weights(
config, temp, lsd_decode_steps, noise_clamp, eos_threshold
)
return tts_model
def _run_flow_lm_and_increment_step(
self,
model_state: dict,
text_tokens: torch.Tensor | None = None,
backbone_input_latents: torch.Tensor | None = None,
audio_conditioning: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""First one is the backbone output, second one is the audio decoding output."""
if text_tokens is None:
text_tokens = torch.zeros((1, 0), dtype=torch.int64, device=self.flow_lm.device)
if backbone_input_latents is None:
backbone_input_latents = torch.empty(
(1, 0, self.flow_lm.ldim), dtype=self.flow_lm.dtype, device=self.flow_lm.device
)
if audio_conditioning is None:
audio_conditioning = torch.empty(
(1, 0, self.flow_lm.dim), dtype=self.flow_lm.dtype, device=self.flow_lm.device
)
output = self._run_flow_lm(
text_tokens=text_tokens,
backbone_input_latents=backbone_input_latents,
model_state=model_state,
audio_conditioning=audio_conditioning,
)
increment_by = (
text_tokens.shape[1] + backbone_input_latents.shape[1] + audio_conditioning.shape[1]
)
increment_steps(self.flow_lm, model_state, increment=increment_by)
return output
def _run_flow_lm(
self,
model_state: dict,
text_tokens: torch.Tensor,
backbone_input_latents: torch.Tensor,
audio_conditioning: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
text_embeddings = self.flow_lm.conditioner(TokenizedText(text_tokens))
text_embeddings = torch.cat([text_embeddings, audio_conditioning], dim=1)
output_embeddings, is_eos = self.flow_lm._sample_next_latent(
backbone_input_latents,
text_embeddings,
model_state=model_state,
lsd_decode_steps=self.lsd_decode_steps,
temp=self.temp,
noise_clamp=self.noise_clamp,
eos_threshold=self.eos_threshold,
)
return output_embeddings[:, None, :], is_eos
def _encode_audio(self, audio: torch.Tensor) -> torch.Tensor:
encoded = self.mimi.encode_to_latent(audio)
latents = encoded.transpose(-1, -2).to(torch.float32)
conditioning = F.linear(latents, self.flow_lm.speaker_proj_weight)
return conditioning
def _decode_audio_worker(self, latents_queue: queue.Queue, result_queue: queue.Queue):
"""Worker thread function for decoding audio latents from queue with immediate streaming."""
try:
audio_chunks = []
mimi_state = init_states(self.mimi, batch_size=1, sequence_length=1000)
while True:
latent = latents_queue.get()
if latent is None:
break
mimi_decoding_input = latent * self.flow_lm.emb_std + self.flow_lm.emb_mean
transposed = mimi_decoding_input.transpose(-1, -2)
quantized = self.mimi.quantizer(transposed)
t = time.monotonic()
audio_frame = self.mimi.decode_from_latent(quantized, mimi_state)
increment_steps(self.mimi, mimi_state, increment=16)
audio_frame_duration = audio_frame.shape[2] / self.config.mimi.sample_rate
# We could log the timings here.
logger.debug(
" " * 30 + "Decoded %d ms of audio with mimi in %d ms",
int(audio_frame_duration * 1000),
int((time.monotonic() - t) * 1000),
)
audio_chunks.append(audio_frame)
result_queue.put(("chunk", audio_frame))
latents_queue.task_done()
# Signal completion
result_queue.put(("done", None))
except Exception as e:
# Put error in result queue
result_queue.put(("error", e))
@torch.no_grad
def generate_audio(
self,
model_state: dict,
text_to_generate: str,
frames_after_eos: int | None = None,
copy_state: bool = True,
) -> torch.Tensor:
"""Generate complete audio tensor from text input.
This method generates the full audio output for the given text prompt
and returns it as a single tensor. It internally uses the streaming
generation method but collects all chunks before returning.
This method is NOT thread-safe; separate model instances should be used
for concurrent generation.
Args:
model_state: Model state dictionary containing hidden states and
positional information. Can be obtained from get_state_for_audio_prompt()
or init_states(). The state may be modified during generation.
text_to_generate: Input text to convert to speech. The text will be
automatically formatted (capitalization, punctuation) for optimal
generation quality.
frames_after_eos: Number of additional frames to generate after
detecting end-of-sequence. If None, automatically determined
based on text length (1-3 frames).
copy_state: Whether to create a deep copy of the model state before
generation. If True, preserves the original state for reuse.
If False, modifies the input state in-place. Defaults to True.
Returns:
torch.Tensor: Generated audio tensor with shape [channels, samples]
at the model's sample rate (typically 24kHz). The audio is
normalized and ready for playback or saving.
You can get the sample rate from the `sample_rate` attribute.
Raises:
ValueError: If text_to_generate is empty or invalid.
RuntimeError: If generation fails due to model errors.
"""
audio_chunks = []
for chunk in self.generate_audio_stream(
model_state=model_state,
text_to_generate=text_to_generate,
frames_after_eos=frames_after_eos,
copy_state=copy_state,
):
audio_chunks.append(chunk)
return torch.cat(audio_chunks, dim=0)
@torch.no_grad
def generate_audio_stream(
self,
model_state: dict,
text_to_generate: str,
frames_after_eos: int | None = None,
copy_state: bool = True,
):
"""Generate audio streaming chunks from text input.
This method generates audio from text and yields chunks as they become
available, enabling real-time playback or processing. It uses multithreading
to parallelize generation and decoding for optimal performance.
This method is NOT thread-safe; separate model instances should be used
for concurrent generation.
Args:
model_state: Model state dictionary containing hidden states and
positional information. Can be obtained from get_state_for_audio_prompt()
or init_states(). The state may be modified during generation.
text_to_generate: Input text to convert to speech. The text will be
automatically formatted (capitalization, punctuation) for optimal
generation quality.
frames_after_eos: Number of additional frames to generate after
detecting end-of-sequence. If None, automatically determined
based on text length (1-3 frames). Defaults to None.
copy_state: Whether to create a deep copy of the model state before
generation. If True, preserves the original state for reuse.
If False, modifies the input state in-place. Defaults to True.
Yields:
torch.Tensor: Audio chunks with shape [samples] at the model's
sample rate (typically 24kHz). Chunks are yielded as soon as
they are decoded, enabling real-time streaming.
Raises:
ValueError: If text_to_generate is empty or invalid.
RuntimeError: If generation fails due to model errors or threading issues.
Note:
This method uses multithreading to parallelize latent generation
and audio decoding. Generation performance is logged including
real-time factor (RTF) metrics.
"""
text_to_generate, frames_after_eos_guess = prepare_text_prompt(text_to_generate)
if frames_after_eos is None:
frames_after_eos = frames_after_eos_guess
if copy_state:
model_state = copy.deepcopy(model_state)
# Set up multithreaded generation and decoding
latents_queue = queue.Queue()
result_queue = queue.Queue()
# Start decoder worker thread
decoder_thread = threading.Thread(
target=self._decode_audio_worker, args=(latents_queue, result_queue), daemon=True
)
logger.info("starting timer now!")
t_generating = time.monotonic()
decoder_thread.start()
# Generate latents and add them to queue (decoder processes them in parallel)
self._generate(
model_state=model_state,
text_to_generate=text_to_generate,
frames_after_eos=frames_after_eos,
latents_queue=latents_queue,
result_queue=result_queue,
)
# Stream audio chunks as they become available
total_generated_samples = 0
while True:
result = result_queue.get()
if result[0] == "chunk":
# Audio chunk available immediately for streaming/playback
audio_chunk = result[1]
total_generated_samples += audio_chunk.shape[-1]
yield audio_chunk[0, 0] # Remove batch, channel
elif result[0] == "done":
# Generation complete
break
elif result[0] == "error":
# Wait for decoder thread to finish cleanly before propagating error
with display_execution_time("Waiting for mimi decoder to finish"):
decoder_thread.join()
# Propagate error
raise result[1]
# Wait for decoder thread to finish cleanly
with display_execution_time("Waiting for mimi decoder to finish"):
decoder_thread.join()
# Print timing information
duration_generated_audio = int(
total_generated_samples * 1000 / self.config.mimi.sample_rate
)
generation_time = int((time.monotonic() - t_generating) * 1000)
real_time_factor = duration_generated_audio / generation_time
logger.info(
"Generated: %d ms of audio in %d ms so %.2fx faster than real-time",
duration_generated_audio,
generation_time,
real_time_factor,
)
@torch.no_grad
def _generate(
self,
model_state: dict,
text_to_generate: str,
frames_after_eos: int,
latents_queue: queue.Queue,
result_queue: queue.Queue,
):
gen_len_sec = len(text_to_generate.split()) * 1 + 2.0
max_gen_len = int(gen_len_sec * 12.5)
prepared = self.flow_lm.conditioner.prepare(text_to_generate)
with display_execution_time("Prompting text"):
self._run_flow_lm_and_increment_step(
model_state=model_state, text_tokens=prepared.tokens
)
def run_generation():
try:
self._autoregressive_generation(
model_state, max_gen_len, frames_after_eos, latents_queue
)
except Exception as e:
logger.error(f"Error in autoregressive generation: {e}")
# Signal decoder to stop by putting None (completion sentinel)
if latents_queue is not None:
latents_queue.put(None)
# Report error to main thread
if result_queue is not None:
result_queue.put(("error", e))
generation_thread = threading.Thread(target=run_generation, daemon=True)
generation_thread.start()
def _autoregressive_generation(
self, model_state: dict, max_gen_len: int, frames_after_eos: int, latents_queue: queue.Queue
):
backbone_input = torch.full(
(1, 1, self.flow_lm.ldim),
fill_value=float("NaN"),
device=next(iter(self.flow_lm.parameters())).device,
dtype=self.flow_lm.dtype,
)
steps_times = []
eos_step = None
for generation_step in range(max_gen_len):
with display_execution_time("Generating latent", print_output=False) as timer:
next_latent, is_eos = self._run_flow_lm_and_increment_step(
model_state=model_state, backbone_input_latents=backbone_input
)
if is_eos.item() and eos_step is None:
eos_step = generation_step
if eos_step is not None and generation_step >= eos_step + frames_after_eos:
break
# Add generated latent to queue for immediate decoding
latents_queue.put(next_latent)
backbone_input = next_latent
steps_times.append(timer.elapsed_time_ms)
else:
if os.environ.get("KPOCKET_TTS_ERROR_WITHOUT_EOS", "0") == "1":
raise RuntimeError("Generation reached maximum length without EOS!")
logger.warning(
"Maximum generation length reached without EOS, this very often indicates an error."
)
# Add sentinel value to signal end of generation
latents_queue.put(None)
logger.info("Average generation step time: %d ms", int(statistics.mean(steps_times)))
@lru_cache(maxsize=2)
def _cached_get_state_for_audio_prompt(
self, audio_conditioning: Path | str | torch.Tensor, truncate: bool = False
) -> dict:
return self.get_state_for_audio_prompt(audio_conditioning, truncate)
@torch.no_grad
def get_state_for_audio_prompt(
self, audio_conditioning: Path | str | torch.Tensor, truncate: bool = False
) -> dict:
"""Create model state conditioned on audio prompt for continuation.
This method processes an audio prompt and creates a model state that
captures the acoustic characteristics (speaker voice, style, prosody)
for use in subsequent text-to-speech generation. The resulting state
enables voice cloning and audio continuation with speaker consistency.
Args:
audio_conditioning: Audio prompt to condition on. Can be:
- Path: Local file path to audio file
- str: URL to download audio file from
- torch.Tensor: Pre-loaded audio tensor with shape [channels, samples]
truncate: Whether to truncate long audio prompts to 30 seconds.
Helps prevent memory issues with very long inputs. Defaults to False.
Returns:
dict: Model state dictionary containing hidden states and positional
information conditioned on the audio prompt. This state can be
passed to `generate_audio()` or `generate_audio_stream()` for
voice-consistent generation.
Raises:
FileNotFoundError: If audio file path doesn't exist.
ValueError: If audio tensor is invalid or empty.
RuntimeError: If audio processing or encoding fails.
Note:
- Audio is automatically resampled to the model's sample rate (24kHz)
- The audio is encoded using the Mimi compression model and projected
to the flow model's latent space
- Processing time is logged for performance monitoring
- The state preserves speaker characteristics for voice cloning
"""
if isinstance(audio_conditioning, str) and audio_conditioning in PREDEFINED_VOICES:
# We get the audio conditioning directly from the safetensors file.
prompt = load_predefined_voice(audio_conditioning)
else:
if not self.has_voice_cloning and isinstance(audio_conditioning, (str, Path)):
raise ValueError(
f"We could not download the weights for the model with voice cloning, "
f"but you're trying to use voice cloning. "
f"Without voice cloning, you can use our catalog of voices {list(PREDEFINED_VOICES)}. "
f"If you want access to the model with voice cloning, go to "
f"https://huggingface.co/kyutai/pocket-tts and accept the terms, "
f"then make sure you're logged in locally with `uvx hf auth login`."
)
if isinstance(audio_conditioning, str):
audio_conditioning = download_if_necessary(audio_conditioning)
if isinstance(audio_conditioning, Path):
audio, conditioning_sample_rate = audio_read(audio_conditioning)
if truncate:
max_samples = int(30 * conditioning_sample_rate) # 30 seconds of audio
if audio.shape[-1] > max_samples:
audio = audio[..., :max_samples]
logger.info(f"Audio truncated to first 30 seconds ({max_samples} samples)")
audio_conditioning = convert_audio(
audio, conditioning_sample_rate, self.config.mimi.sample_rate, 1
)
with display_execution_time("Encoding audio prompt"):
prompt = self._encode_audio(audio_conditioning.unsqueeze(0).to(self.device))
# import safetensors.torch
# safetensors.torch.save_file({"audio_prompt": prompt}, "embeddings/eponine.safetensors")
model_state = init_states(self.flow_lm, batch_size=1, sequence_length=1000)
with display_execution_time("Prompting audio"):
self._run_flow_lm_and_increment_step(model_state=model_state, audio_conditioning=prompt)
return model_state
def prepare_text_prompt(text: str) -> tuple[str, int]:
text = text.strip()
if text == "":
raise ValueError("Text prompt cannot be empty")
number_of_words = len(text.split())
if number_of_words <= 4:
frames_after_eos_guess = 3
else:
frames_after_eos_guess = 1
# Make sure it starts with an uppercase letter
if not text[0].isupper():
text = text[0].upper() + text[1:]
# Let's make sure it ends with some kind of punctuation
# If it ends with a letter or digit, we add a period.
if text[-1].isalnum():
text = text + "."
# The model does not perform well when there are very few tokens, so
# we can add empty spaces at the beginning to increase the token count.
if len(text.split()) < 5:
text = " " * 8 + text
return text, frames_after_eos_guess
+1
View File
@@ -0,0 +1 @@
"""Modules used for building the models."""
+161
View File
@@ -0,0 +1,161 @@
import math
import warnings
import torch
from torch import nn
from torch.nn import functional as F
from pocket_tts.modules.stateful_module import StatefulModule
def get_extra_padding_for_conv1d(
x: torch.Tensor, kernel_size: int, stride: int, padding_total: int = 0
) -> int:
"""See `pad_for_conv1d`."""
length = x.shape[-1]
n_frames = (length - kernel_size + padding_total) / stride + 1
ideal_length = (math.ceil(n_frames) - 1) * stride + (kernel_size - padding_total)
return ideal_length - length
def pad_for_conv1d(x: torch.Tensor, kernel_size: int, stride: int, padding_total: int = 0):
"""Pad for a convolution to make sure that the last window is full.
Extra padding is added at the end. This is required to ensure that we can rebuild
an output of the same length, as otherwise, even with padding, some time steps
might get removed.
For instance, with total padding = 4, kernel size = 4, stride = 2:
0 0 1 2 3 4 5 0 0 # (0s are padding)
1 2 3 # (output frames of a convolution, last 0 is never used)
0 0 1 2 3 4 5 0 # (output of tr. conv., but pos. 5 is going to get removed as padding)
1 2 3 4 # once you removed padding, we are missing one time step !
"""
extra_padding = get_extra_padding_for_conv1d(x, kernel_size, stride, padding_total)
return F.pad(x, (0, extra_padding))
class StreamingConv1d(StatefulModule):
"""Conv1d with some builtin handling of asymmetric or causal padding
and normalization.
"""
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size: int,
stride: int = 1,
dilation: int = 1,
groups: int = 1,
bias: bool = True,
pad_mode: str = "constant",
):
super().__init__()
assert pad_mode in ["constant", "replicate"], pad_mode
self.pad_mode = pad_mode
# warn user on unusual setup between dilation and stride
if stride > 1 and dilation > 1:
warnings.warn(
"StreamingConv1d has been initialized with stride > 1 and dilation > 1"
f" (kernel_size={kernel_size} stride={stride}, dilation={dilation})."
)
self.conv = nn.Conv1d(
in_channels,
out_channels,
kernel_size,
stride,
dilation=dilation,
groups=groups,
bias=bias,
)
@property
def _stride(self) -> int:
return self.conv.stride[0]
@property
def _kernel_size(self) -> int:
return self.conv.kernel_size[0]
@property
def _effective_kernel_size(self) -> int:
dilation = self.conv.dilation[0]
return (self._kernel_size - 1) * dilation + 1 # effective kernel size with dilations
def init_state(self, batch_size: int, sequence_length: int) -> dict[str, torch.Tensor]:
stride = self._stride
# Effective kernel size accounting for dilation.
kernel = self._effective_kernel_size
previous = torch.zeros(batch_size, self.conv.in_channels, kernel - stride)
first = torch.ones(batch_size, dtype=torch.bool)
return dict(previous=previous, first=first)
def forward(self, x, model_state: dict | None):
B, C, T = x.shape
S = self._stride
assert T > 0 and T % S == 0, "Steps must be multiple of stride"
if model_state is None:
state = self.init_state(B, 0)
else:
state = self.get_state(model_state)
TP = state["previous"].shape[-1]
if TP and self.pad_mode == "replicate":
assert T >= TP, "Not enough content to pad streaming."
init = x[..., :1]
state["previous"][:] = torch.where(
state["first"].view(-1, 1, 1), init, state["previous"]
)
if TP:
x = torch.cat([state["previous"], x], dim=-1)
y = self.conv(x)
if TP:
state["previous"][:] = x[..., -TP:]
if self.pad_mode == "replicate":
state["first"] = torch.zeros_like(state["first"])
return y
class StreamingConvTranspose1d(StatefulModule):
"""ConvTranspose1d with some builtin handling of asymmetric or causal padding
and normalization.
"""
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size: int,
stride: int = 1,
groups: int = 1,
bias: bool = True,
):
super().__init__()
self.convtr = nn.ConvTranspose1d(
in_channels, out_channels, kernel_size, stride, groups=groups, bias=bias
)
@property
def _stride(self) -> int:
return self.convtr.stride[0]
@property
def _kernel_size(self) -> int:
return self.convtr.kernel_size[0]
def init_state(self, batch_size: int, sequence_length: int) -> dict[str, torch.Tensor]:
K = self._kernel_size
S = self._stride
return dict(partial=torch.zeros(batch_size, self.convtr.out_channels, K - S))
def forward(self, x, mimi_state: dict):
layer_state = self.get_state(mimi_state)["partial"]
y = self.convtr(x)
PT = layer_state.shape[-1]
if PT > 0:
y[..., :PT] += layer_state
bias = self.convtr.bias
for_partial = y[..., -PT:]
if bias is not None:
for_partial -= bias[:, None]
layer_state[:] = for_partial
y = y[..., :-PT]
return y
+18
View File
@@ -0,0 +1,18 @@
import torch
from torch import nn
class DummyQuantizer(nn.Module):
"""Simplified quantizer that only provides output projection for TTS.
This removes all unnecessary quantization logic since we don't use actual quantization.
"""
def __init__(self, dimension: int, output_dimension: int):
super().__init__()
self.dimension = dimension
self.output_dimension = output_dimension
self.output_proj = torch.nn.Conv1d(self.dimension, self.output_dimension, 1, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.output_proj(x)
+11
View File
@@ -0,0 +1,11 @@
import torch
import torch.nn as nn
class LayerScale(nn.Module):
def __init__(self, channels: int, init: float):
super().__init__()
self.scale = nn.Parameter(torch.full((channels,), init))
def forward(self, x: torch.Tensor):
return self.scale * x
+285
View File
@@ -0,0 +1,285 @@
from typing import NamedTuple
import torch
import torch.nn as nn
from einops import rearrange
from torch.nn import functional as F
from typing_extensions import Self
from pocket_tts.modules.layer_scale import LayerScale
from pocket_tts.modules.rope import RotaryEmbedding
from pocket_tts.modules.stateful_module import StatefulModule
from pocket_tts.modules.transformer import StreamingMultiheadAttention
from pocket_tts.utils.config import FlowLMTransformerConfig
class KVCacheResult(NamedTuple):
keys: torch.Tensor
values: torch.Tensor
positions: torch.Tensor
@staticmethod
def from_kv(keys: torch.Tensor, values: torch.Tensor) -> "KVCacheResult":
B, H, T, D = keys.shape
assert tuple(values.shape[:-1]) == (B, H, T)
positions = torch.arange(T, device=keys.device, dtype=torch.long)
return KVCacheResult(keys, values, positions.expand(B, -1))
def complete(
cache: torch.Tensor, end_offset: torch.Tensor, k: torch.Tensor, v: torch.Tensor
) -> KVCacheResult:
capacity = cache.shape[3]
assert k.shape[:-1] == v.shape[:-1], (k.shape, v.shape)
B, H, T, D = k.shape
assert T > 0
indexes = torch.arange(T, device=end_offset.device, dtype=end_offset.dtype)
indexes = indexes + end_offset.view(-1, 1)
indexes = indexes % capacity
# indexes is [B, T]
# k is [B, H, T, D]
# cache is [B, H, T', D]
this_indexes = indexes.view(B, 1, T, 1)
this_indexes = this_indexes.expand(-1, H, T, D)
cache[0].scatter_(2, this_indexes, k)
cache[1].scatter_(2, this_indexes, v)
keys = cache[0]
values = cache[1]
indexes = torch.arange(capacity, device=end_offset.device, dtype=torch.long)
# end_index correspond to the actual index where the last value was written.
last_offset = end_offset.view(-1, 1) + T - 1
end_index = last_offset % capacity
delta = indexes - end_index
positions = torch.where(delta <= 0, last_offset + delta, last_offset + delta - capacity)
end_offset[:] = end_offset + T
invalid = indexes >= end_offset.view(-1, 1)
positions = torch.where(invalid, torch.full_like(positions, -1), positions)
return KVCacheResult(keys, values, positions)
class MimiStreamingMultiheadAttention(StatefulModule):
def __init__(self, embed_dim: int, num_heads: int, context: int, rope: RotaryEmbedding):
super().__init__()
self.embed_dim = embed_dim
self.context = context
self.rope = rope
self.num_heads = num_heads
out_dim = 3 * embed_dim
self.out_proj = nn.Linear(embed_dim, embed_dim, bias=False)
self.in_proj = nn.Linear(embed_dim, out_dim, bias=False)
def init_state(self, batch_size: int, sequence_length: int) -> dict[str, torch.Tensor]:
dim_per_head = self.embed_dim // self.num_heads
state = {}
state["offset"] = torch.zeros(batch_size, dtype=torch.long)
state["cache"] = torch.zeros((2, batch_size, self.num_heads, sequence_length, dim_per_head))
state["end_offset"] = torch.zeros(batch_size, dtype=torch.long)
return state
def increment_step(self, state, increment: int = 1):
state["offset"] += increment
def _complete_kv(self, k, v, model_state: dict | None) -> KVCacheResult:
if model_state is None:
return KVCacheResult.from_kv(k, v)
else:
layer_state = self.get_state(model_state)
return complete(layer_state["cache"], layer_state["end_offset"], k, v)
def forward(self, query: torch.Tensor, model_state: dict | None) -> torch.Tensor:
B, T = query.shape[:2]
if model_state is None:
offset = torch.zeros(B, device=query.device, dtype=torch.long)
else:
offset = self.get_state(model_state)["offset"]
projected = self.in_proj(query)
q, k, v = rearrange(projected, "b t (p h d) -> p b h t d", p=3, h=self.num_heads)
# Permute from [b, h, t, d] to [b, t, h, d] for rope
q = q.permute(0, 2, 1, 3)
k = k.permute(0, 2, 1, 3)
q, k = self.rope(q, k, offset)
# Permute back from [b, t, h, d] to [b, h, t, d]
q = q.permute(0, 2, 1, 3)
k = k.permute(0, 2, 1, 3)
k, v, pos_k = self._complete_kv(k, v, model_state)
pos_k = pos_k[:, None]
pos_q = offset.view(-1, 1, 1) + torch.arange(T, device=q.device, dtype=torch.long).view(
-1, 1
)
delta = pos_q - pos_k
attn_bias = (pos_k >= 0) & (delta >= 0)
attn_bias = attn_bias & (delta < self.context)
attn_bias = attn_bias[:, None]
x = F.scaled_dot_product_attention(q, k, v, attn_bias, dropout_p=0.0)
x = rearrange(x, "b h t d -> b t (h d)")
x = self.out_proj(x)
return x
class StreamingTransformerLayer(nn.Module):
def __init__(
self,
d_model: int,
num_heads: int,
dim_feedforward: int,
context: int | None,
rope: RotaryEmbedding,
layer_scale: float | None = None,
attention_kind: str = "mimi",
):
super().__init__()
# Redefine self_attn to our streaming multi-head attention
if attention_kind == "mimi":
# TODO: we should actually use StreamingMultiheadAttention here and add context window
# support. And we should then delete MimiStreamingMultiheadAttention.
# The implementation is really close.
self.self_attn = MimiStreamingMultiheadAttention(
context=context, rope=rope, embed_dim=d_model, num_heads=num_heads
)
else:
self.self_attn = StreamingMultiheadAttention(
rope=rope, embed_dim=d_model, num_heads=num_heads
)
self.norm1 = nn.LayerNorm(d_model, eps=1e-5)
self.norm2 = nn.LayerNorm(d_model, eps=1e-5)
self.linear1 = nn.Linear(d_model, dim_feedforward, bias=False)
self.linear2 = nn.Linear(dim_feedforward, d_model, bias=False)
if layer_scale is None:
self.layer_scale_1 = nn.Identity()
self.layer_scale_2 = nn.Identity()
else:
self.layer_scale_1 = LayerScale(d_model, layer_scale)
self.layer_scale_2 = LayerScale(d_model, layer_scale)
def _ff_block(self, x: torch.Tensor) -> torch.Tensor:
x_orig = x
x = self.norm2(x)
update = self.linear2(F.gelu(self.linear1(x)))
return x_orig.to(update) + self.layer_scale_2(update)
def _sa_block(self, x: torch.Tensor, model_state: dict | None) -> torch.Tensor:
x_orig = x
x = self.norm1(x)
update = self.self_attn(x, model_state)
return x_orig.to(update) + self.layer_scale_1(update)
def forward(self, x: torch.Tensor, model_state: dict | None) -> torch.Tensor:
x = self._sa_block(x, model_state)
x = self._ff_block(x)
return x
class StreamingTransformer(nn.Module):
def __init__(
self,
d_model: int,
num_heads: int,
num_layers: int,
layer_scale: float | None = None,
dim_feedforward: int | list[int] = 2048,
context: int | None = None,
max_period: float = 10_000.0,
kind: str = "mimi",
):
super().__init__()
assert d_model % num_heads == 0
self.max_period = max_period
self.rope = RotaryEmbedding(max_period=max_period)
self.layers = nn.ModuleList()
for _ in range(num_layers):
self.layers.append(
StreamingTransformerLayer(
d_model=d_model,
num_heads=num_heads,
dim_feedforward=dim_feedforward,
context=context,
rope=self.rope,
layer_scale=layer_scale,
attention_kind=kind,
)
)
@classmethod
def from_pydantic_config(cls, config: FlowLMTransformerConfig) -> Self:
dim_feedforward = int(config.d_model * config.hidden_scale)
return cls(
d_model=config.d_model,
num_heads=config.num_heads,
num_layers=config.num_layers,
dim_feedforward=dim_feedforward,
max_period=float(config.max_period),
kind="flow_lm",
)
def forward(self, x: torch.Tensor, model_state: dict | None):
for layer in self.layers:
x = layer(x, model_state)
return x
class ProjectedTransformer(nn.Module):
def __init__(
self,
input_dimension: int,
output_dimensions: tuple[int, ...],
d_model: int,
num_heads: int,
num_layers: int,
layer_scale: float,
context: int,
max_period: float,
dim_feedforward: int,
):
super().__init__()
self.transformer = StreamingTransformer(
d_model=d_model,
num_heads=num_heads,
num_layers=num_layers,
layer_scale=layer_scale,
context=context,
max_period=max_period,
dim_feedforward=dim_feedforward,
)
self.input_dimension = input_dimension
self.output_dimensions = output_dimensions
self.input_proj = None
if d_model != input_dimension:
self.input_proj = nn.Linear(input_dimension, d_model, bias=False)
self.output_projs = nn.ModuleList()
for output_dimension in output_dimensions:
if d_model == output_dimension:
self.output_projs.append(nn.Identity())
else:
self.output_projs.append(nn.Linear(d_model, output_dimension, bias=False))
def forward(self, x, model_state: dict | None):
x = x.transpose(1, 2)
if self.input_proj is not None:
x = self.input_proj(x)
z = self.transformer(x, model_state)
ys = []
for output_proj in self.output_projs:
y = output_proj(z)
y = y.transpose(1, 2)
ys.append(y)
return ys
+215
View File
@@ -0,0 +1,215 @@
"""
Taken from
https://github.com/LTH14/mar/blob/fe470ac24afbee924668d8c5c83e9fec60af3a73/models/diffloss.py
"""
import math
import torch
import torch.nn as nn
from typing_extensions import Self
from pocket_tts.utils.config import FlowLMConfig
def modulate(x, shift, scale):
return x * (1 + scale) + shift
def _rms_norm(x: torch.Tensor, alpha: torch.Tensor, eps: float):
assert x.dim() >= alpha.dim()
x_dtype = x.dtype
var = eps + x.var(dim=-1, keepdim=True)
y = (x * (alpha.to(var) * torch.rsqrt(var))).to(x_dtype)
return y
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
alpha_shape = (dim,)
self.alpha = nn.Parameter(torch.full(alpha_shape, 1.0, requires_grad=True))
def forward(self, x: torch.Tensor):
return _rms_norm(x, self.alpha, self.eps)
class LayerNorm(nn.Module):
"""Reimplementation of LayerNorm because the default one doesn't support jvp."""
def __init__(self, channels, eps=1e-6, elementwise_affine=True):
super().__init__()
self.eps = eps
if elementwise_affine:
self.weight = nn.Parameter(torch.ones(channels))
self.bias = nn.Parameter(torch.zeros(channels))
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, unbiased=False, keepdim=True)
x = (x - mean) / torch.sqrt(var + self.eps)
if hasattr(self, "weight"):
x = x * self.weight + self.bias
return x
class TimestepEmbedder(nn.Module):
"""Embeds scalar timesteps into vector representations."""
def __init__(
self, hidden_size: int, frequency_embedding_size: int = 256, max_period: int = 10000
):
super().__init__()
blocks = [
nn.Linear(frequency_embedding_size, hidden_size, bias=True),
nn.SiLU(),
nn.Linear(hidden_size, hidden_size, bias=True),
]
blocks.append(RMSNorm(hidden_size))
self.mlp = nn.Sequential(*blocks)
self.frequency_embedding_size = frequency_embedding_size
half = frequency_embedding_size // 2
self.register_buffer(
"freqs", torch.exp(-math.log(max_period) * torch.arange(start=0, end=half) / half)
)
def forward(self, t):
args = t * self.freqs.to(t.dtype)
embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
assert not (self.frequency_embedding_size % 2)
t_emb = self.mlp(embedding)
return t_emb
class ResBlock(nn.Module):
"""
A residual block that can optionally change the number of channels.
:param channels: the number of input channels.
"""
def __init__(self, channels):
super().__init__()
self.channels = channels
self.in_ln = LayerNorm(channels, eps=1e-6)
self.mlp = nn.Sequential(
nn.Linear(channels, channels, bias=True),
nn.SiLU(),
nn.Linear(channels, channels, bias=True),
)
self.adaLN_modulation = nn.Sequential(
nn.SiLU(), nn.Linear(channels, 3 * channels, bias=True)
)
def forward(self, x, y):
shift_mlp, scale_mlp, gate_mlp = self.adaLN_modulation(y).chunk(3, dim=-1)
h = modulate(self.in_ln(x), shift_mlp, scale_mlp)
h = self.mlp(h)
return x + gate_mlp * h
class FinalLayer(nn.Module):
"""
The final layer adopted from DiT.
"""
def __init__(self, model_channels, out_channels):
super().__init__()
self.norm_final = LayerNorm(model_channels, elementwise_affine=False, eps=1e-6)
self.linear = nn.Linear(model_channels, out_channels, bias=True)
self.adaLN_modulation = nn.Sequential(
nn.SiLU(), nn.Linear(model_channels, 2 * model_channels, bias=True)
)
def forward(self, x, c):
shift, scale = self.adaLN_modulation(c).chunk(2, dim=-1)
x = modulate(self.norm_final(x), shift, scale)
x = self.linear(x)
return x
class SimpleMLPAdaLN(nn.Module):
"""Taken from https://arxiv.org/abs/2406.11838.
The MLP for Diffusion Loss.
:param in_channels: channels in the input Tensor.
:param model_channels: base channel count for the model.
:param out_channels: channels in the output Tensor.
:param cond_channels: channels in the condition.
:param num_res_blocks: number of residual blocks per downsample.
"""
def __init__(
self,
in_channels,
model_channels,
out_channels,
cond_channels,
num_res_blocks,
num_time_conds=1,
):
super().__init__()
self.in_channels = in_channels
self.model_channels = model_channels
self.out_channels = out_channels
self.num_res_blocks = num_res_blocks
self.num_time_conds = num_time_conds
assert num_time_conds != 1
self.time_embed = nn.ModuleList(
[TimestepEmbedder(model_channels) for _ in range(num_time_conds)]
)
self.cond_embed = nn.Linear(cond_channels, model_channels)
self.input_proj = nn.Linear(in_channels, model_channels)
res_blocks = []
for i in range(num_res_blocks):
res_blocks.append(ResBlock(model_channels))
self.res_blocks = nn.ModuleList(res_blocks)
self.final_layer = FinalLayer(model_channels, out_channels)
@classmethod
def from_pydantic_config(cls, cfg: FlowLMConfig, latent_dim: int, cond_dim: int) -> Self:
config = cfg.flow
flow_dim = config.dim
flow_depth = config.depth
num_time_conds = 2
return SimpleMLPAdaLN(
latent_dim, flow_dim, latent_dim, cond_dim, flow_depth, num_time_conds=num_time_conds
)
def forward(
self, c: torch.Tensor, s: torch.Tensor, t: torch.Tensor, x: torch.Tensor
) -> torch.Tensor:
"""
Apply the model to an input batch.
:param c: conditioning from AR transformer.
:param s: start time tensor.
:param t: target time tensor.
:param x: an [N x C] Tensor of inputs.
:return: an [N x C] Tensor of outputs.
"""
# Combine time conditions
ts = [s, t]
x = self.input_proj(x)
assert len(ts) == self.num_time_conds, (
f"Expected {self.num_time_conds} time conditions, got {len(ts)}"
)
assert self.num_time_conds != 1
t_combined = (
sum(self.time_embed[i](ts[i]) for i in range(self.num_time_conds)) / self.num_time_conds
)
c = self.cond_embed(c)
y = t_combined + c
for block in self.res_blocks:
x = block(x, y)
return self.final_layer(x, y)
+46
View File
@@ -0,0 +1,46 @@
import torch
from torch import nn
from pocket_tts.modules.conv import StreamingConv1d, StreamingConvTranspose1d
class ConvDownsample1d(nn.Module):
"""
Downsampling by some integer amount `stride` using convolutions
with a kernel size of twice the stride.
"""
def __init__(self, stride: int, dimension: int):
super().__init__()
self.conv = StreamingConv1d(
dimension,
dimension,
kernel_size=2 * stride,
stride=stride,
groups=1,
bias=False,
pad_mode="replicate",
)
def forward(self, x: torch.Tensor, model_state: dict | None):
return self.conv(x, model_state)
class ConvTrUpsample1d(nn.Module):
"""
Upsample by some integer amount `stride` using transposed convolutions.
"""
def __init__(self, stride: int, dimension: int):
super().__init__()
self.convtr = StreamingConvTranspose1d(
dimension,
dimension,
kernel_size=2 * stride,
stride=stride,
groups=dimension,
bias=False,
)
def forward(self, x: torch.Tensor, model_state: dict | None):
return self.convtr(x, model_state)
+74
View File
@@ -0,0 +1,74 @@
import math
import torch
from torch import nn
def apply_rope(
q: torch.Tensor,
k: torch.Tensor,
offset: int | torch.Tensor = 0,
max_period: int | float = 10_000,
):
"""
Args:
q (torch.Tensor): Queries, shape `[B, T, H, D]`.
k (torch.Tensor): Keys, shape `[B, T, H, D]`.
offset (int): Current offset, e.g. when streaming.
max_period (float): Maximum period for the cos and sin.
"""
B, T, H, D = q.shape
Bk, Tk, Hk, Dk = k.shape
assert (B, T, D) == (Bk, Tk, Dk)
assert D > 0
assert D % 2 == 0
assert max_period > 0
ds = torch.arange(D // 2, device=q.device, dtype=torch.float32)
freqs = torch.exp(ds * (-math.log(max_period) * 2 / D))
# could be optimized in one call
ts = torch.arange(T, device=q.device, dtype=torch.float32)
ts += offset
ts = ts.view(-1, 1, 1)
q = q.view(B, T, H, D // 2, 2)
k = k.view(B, T, Hk, D // 2, 2)
# convention is `r` suffix is real part, `i` is imaginary.
qr = q[..., 0].float()
qi = q[..., 1].float()
kr = k[..., 0].float()
ki = k[..., 1].float()
rotr = torch.cos(freqs * ts)
roti = torch.sin(freqs * ts)
qor = qr * rotr - qi * roti
qoi = qr * roti + qi * rotr
kor = kr * rotr - ki * roti
koi = kr * roti + ki * rotr
dtype = q.dtype
qo = torch.stack([qor.to(dtype), qoi.to(dtype)], dim=-1)
ko = torch.stack([kor.to(dtype), koi.to(dtype)], dim=-1)
return qo.view(B, T, H, D), ko.view(B, T, Hk, D)
class RotaryEmbedding(nn.Module):
"""Rotary positional embedding (RoPE) from [Su et al 2022](https://arxiv.org/abs/2104.09864).
Args:
max_period (float): Maximum period of the rotation frequencies.
"""
def __init__(self, max_period: float | int = 10000.0):
super().__init__()
self.max_period = max_period
def forward(self, q: torch.Tensor, k: torch.Tensor, offset: torch.Tensor | int):
"""Apply rope rotation to query or key tensor."""
return apply_rope(q, k, offset, self.max_period)
+180
View File
@@ -0,0 +1,180 @@
import numpy as np
import torch.nn as nn
from .conv import StreamingConv1d, StreamingConvTranspose1d
class SEANetResnetBlock(nn.Module):
def __init__(
self,
dim: int,
kernel_sizes: list[int] = [3, 1],
dilations: list[int] = [1, 1],
pad_mode: str = "reflect",
compress: int = 2,
):
super().__init__()
assert len(kernel_sizes) == len(dilations), (
"Number of kernel sizes should match number of dilations"
)
hidden = dim // compress
block = nn.ModuleList([])
for i, (kernel_size, dilation) in enumerate(zip(kernel_sizes, dilations)):
in_chs = dim if i == 0 else hidden
out_chs = dim if i == len(kernel_sizes) - 1 else hidden
block += [
nn.ELU(alpha=1.0),
StreamingConv1d(
in_chs, out_chs, kernel_size=kernel_size, dilation=dilation, pad_mode=pad_mode
),
]
self.block = block
def forward(self, x, model_state: dict | None):
v = x
for layer in self.block:
if isinstance(layer, StreamingConv1d):
v = layer(v, model_state)
else:
v = layer(v)
assert x.shape == v.shape, (x.shape, v.shape, x.shape)
return x + v
class SEANetEncoder(nn.Module):
def __init__(
self,
channels: int = 1,
dimension: int = 128,
n_filters: int = 32,
n_residual_layers: int = 3,
ratios: list[int] = [8, 5, 4, 2],
kernel_size: int = 7,
last_kernel_size: int = 7,
residual_kernel_size: int = 3,
dilation_base: int = 2,
pad_mode: str = "reflect",
compress: int = 2,
):
super().__init__()
self.channels = channels
self.dimension = dimension
self.n_filters = n_filters
self.ratios = list(reversed(ratios))
del ratios
self.n_residual_layers = n_residual_layers
self.hop_length = int(np.prod(self.ratios))
self.n_blocks = len(self.ratios) + 2 # first and last conv + residual blocks
mult = 1
model = nn.ModuleList(
[StreamingConv1d(channels, mult * n_filters, kernel_size, pad_mode=pad_mode)]
)
# Downsample to raw audio scale
for i, ratio in enumerate(self.ratios):
# Add residual layers
for j in range(n_residual_layers):
model += [
SEANetResnetBlock(
mult * n_filters,
kernel_sizes=[residual_kernel_size, 1],
dilations=[dilation_base**j, 1],
pad_mode=pad_mode,
compress=compress,
)
]
# Add downsampling layers
model += [
nn.ELU(alpha=1.0),
StreamingConv1d(
mult * n_filters,
mult * n_filters * 2,
kernel_size=ratio * 2,
stride=ratio,
pad_mode=pad_mode,
),
]
mult *= 2
model += [
nn.ELU(alpha=1.0),
StreamingConv1d(mult * n_filters, dimension, last_kernel_size, pad_mode=pad_mode),
]
self.model = model
def forward(self, x, model_state: dict | None):
for layer in self.model:
if isinstance(layer, (StreamingConv1d, SEANetResnetBlock)):
x = layer(x, model_state)
else:
x = layer(x)
return x
class SEANetDecoder(nn.Module):
def __init__(
self,
channels: int = 1,
dimension: int = 128,
n_filters: int = 32,
n_residual_layers: int = 3,
ratios: list[int] = [8, 5, 4, 2],
kernel_size: int = 7,
last_kernel_size: int = 7,
residual_kernel_size: int = 3,
dilation_base: int = 2,
pad_mode: str = "reflect",
compress: int = 2,
):
super().__init__()
self.dimension = dimension
self.channels = channels
self.n_filters = n_filters
self.ratios = ratios
del ratios
self.n_residual_layers = n_residual_layers
self.hop_length = int(np.prod(self.ratios))
self.n_blocks = len(self.ratios) + 2 # first and last conv + residual blocks
mult = int(2 ** len(self.ratios))
model = nn.ModuleList(
[StreamingConv1d(dimension, mult * n_filters, kernel_size, pad_mode=pad_mode)]
)
# Upsample to raw audio scale
for _, ratio in enumerate(self.ratios):
# Add upsampling layers
model += [
nn.ELU(alpha=1.0),
StreamingConvTranspose1d(
mult * n_filters, mult * n_filters // 2, kernel_size=ratio * 2, stride=ratio
),
]
# Add residual layers
for j in range(n_residual_layers):
model += [
SEANetResnetBlock(
mult * n_filters // 2,
kernel_sizes=[residual_kernel_size, 1],
dilations=[dilation_base**j, 1],
pad_mode=pad_mode,
compress=compress,
)
]
mult //= 2
# Add final layers
model += [
nn.ELU(alpha=1.0),
StreamingConv1d(n_filters, channels, last_kernel_size, pad_mode=pad_mode),
]
self.model = model
def forward(self, z, model_state: dict | None):
for layer in self.model:
if isinstance(layer, (StreamingConvTranspose1d, SEANetResnetBlock, StreamingConv1d)):
z = layer(z, model_state)
else:
z = layer(z)
return z
+45
View File
@@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
import torch
from torch import nn
def init_states(
model: nn.Module, batch_size: int, sequence_length: int
) -> dict[str, dict[str, torch.Tensor]]:
result = {}
for module_name, module in model.named_modules():
if not isinstance(module, StatefulModule):
continue
module._module_absolute_name = module_name
module_state = module.init_state(batch_size, sequence_length=sequence_length)
result[module_name] = module_state
return result
def increment_steps(
module: nn.Module, model_state: dict[str, dict[str, torch.Tensor]], increment: int = 1
):
# print("incrementing steps by", increment)
for module_name, module in module.named_modules():
if not isinstance(module, StatefulModule):
continue
module.increment_step(model_state[module_name], increment)
class StatefulModule(ABC, nn.Module):
def __init__(self, *args, **kwds):
self._module_absolute_name = None
return super().__init__(*args, **kwds)
@abstractmethod
def init_state(self, batch_size: int, sequence_length: int):
"""Initialize the state."""
raise NotImplementedError
def increment_step(self, state: dict, increment: int = 1):
pass
def get_state(self, model_state: dict[str, dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
"""Get the state for this module from the model state."""
return model_state[self._module_absolute_name]
+126
View File
@@ -0,0 +1,126 @@
import torch
import torch.nn as nn
from torch.nn import functional as F
from pocket_tts.modules.rope import RotaryEmbedding
from pocket_tts.modules.stateful_module import StatefulModule
def complete_kv(
cache: torch.Tensor, current_end: torch.Tensor, k: torch.Tensor, v: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
current_end = current_end.shape[0]
cache[0, :, current_end : current_end + k.shape[1]] = k
cache[1, :, current_end : current_end + v.shape[1]] = v
valid = cache[:, :, : current_end + k.shape[1]]
return valid[0], valid[1]
def _materialize_causal_mask(
shape: tuple[int, ...], shift: int, device: str | torch.device = "cpu"
) -> torch.Tensor:
dtype = torch.float32
num_queries, num_keys = shape[-2:]
shift = num_keys - num_queries
tensor = torch.full(shape, dtype=dtype, fill_value=1, device=device)
mask = torch.tril(tensor, diagonal=shift).to(dtype)
mask = torch.log(mask)
return mask.to(dtype)
class StreamingMultiheadAttention(StatefulModule):
"""Similar to `nn.MultiheadAttention` but with support for streaming.
Args:
embed_dim (int): Dimension to project to.
num_heads (int): Number of heads.
context (int, optional): Number of time steps the attention can access to.
Can access `context` time steps into the past.
rope (`RotaryEmbedding`, optional): Rope embedding to use.
device (torch.device, optional): Device on which to initialize.
dtype (torch.dtype, optional): dtype to use.
"""
def __init__(self, embed_dim: int, num_heads: int, rope: RotaryEmbedding):
super().__init__()
self.embed_dim = embed_dim
self.rope = rope
self.num_heads = num_heads
out_dim = embed_dim
num_kv = num_heads
kv_dim = (embed_dim // num_heads) * num_kv
out_dim += 2 * kv_dim
mult = 1
in_proj = nn.Linear(embed_dim, mult * out_dim, bias=False)
# We try to follow the default PyTorch MHA convention, to easily compare results.
self.in_proj_weight = in_proj.weight
self.out_proj = nn.Linear(embed_dim, mult * embed_dim, bias=False)
def _get_mask(self, shape: tuple[int, int], shift: int, device: torch.device) -> torch.Tensor:
return _materialize_causal_mask(shape, shift=shift, device=device)
def init_state(self, batch_size: int, sequence_length: int) -> dict[str, torch.Tensor]:
dim_per_head = self.embed_dim // self.num_heads
initial_current_end = torch.zeros((0,)).to(self.in_proj_weight.device)
return dict(
current_end=initial_current_end,
cache=torch.full(
(2, batch_size, sequence_length, self.num_heads, dim_per_head),
float("NaN"),
device=self.in_proj_weight.device,
dtype=self.in_proj_weight.dtype,
),
)
def increment_step(self, state: dict, increment: int = 1):
new_size = state["current_end"].shape[0] + increment
state["current_end"] = torch.zeros((new_size,)).to(state["current_end"].device)
def _complete_kv(self, k, v, state: dict | None):
k, v = complete_kv(state["cache"], state["current_end"], k, v)
return k, v
def _apply_rope(self, query: torch.Tensor, key: torch.Tensor, state: dict | None):
# Apply rope embeddings to query and key tensors.
streaming_offset = self._streaming_offset(state)
return self.rope(query, key, offset=streaming_offset)
def _streaming_offset(self, state: dict | None) -> torch.Tensor | int:
return state["current_end"].shape[0]
def check_model_state(self, model_state: dict):
if model_state is None:
raise ValueError("model_state must be provided")
return self.get_state(model_state)
def forward(self, query: torch.Tensor, model_state: dict | None):
state = self.check_model_state(model_state)
projected = nn.functional.linear(query, self.in_proj_weight)
# Reshape from (b, t, p*h*d) to (b, t, p, h, d) where p=3, h=num_heads
b, t, _ = projected.shape
d = self.embed_dim // self.num_heads
packed = projected.view(b, t, 3, self.num_heads, d)
q, k, v = torch.unbind(packed, dim=2)
q, k = self._apply_rope(q, k, state)
k, v = self._complete_kv(k, v, state)
mask_shape = (query.shape[1], query.shape[1] + state["current_end"].shape[0])
shift = state["current_end"].shape[0]
attn_mask = self._get_mask(mask_shape, shift=shift, device=q.device)
q, k, v = [x.transpose(1, 2) for x in (q, k, v)]
x = F.scaled_dot_product_attention(q, k, v, attn_mask)
x = x.transpose(1, 2)
# Reshape from (b, t, h, d) to (b, t, h*d)
b, t, h, d = x.shape
x = x.reshape(b, t, h * d)
x = self.out_proj(x)
return x
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pocket TTS - Streaming</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="max-w-xl mx-auto p-4 space-y-4">
<h1 class="text-xl font-bold text-center">Pocket TTS</h1>
<div class="bg-white p-4 rounded-lg shadow">
<textarea
id="text-input"
class="w-full border rounded p-2"
placeholder="Enter your text here..."
rows="4"
>Hello world. I am Kyutai's Pocket TTS. I'm fast enough to run on small CPUs. I hope you'll like me.</textarea>
</div>
<div class="bg-white p-4 rounded-lg shadow">
<label for="voice-url-input" class="block text-sm font-medium text-gray-700 mb-2">
Optional voice URL (leave empty to use default voice):
</label>
<input
type="text"
id="voice-url-input"
class="w-full border rounded p-2"
placeholder="hf://kyutai/tts-voices/alba-mackenna/casual.wav"
value="marius"
/>
<p class="text-xs text-gray-500 mt-1">
Supports: http://, https://, or hf:// URLs.<br>
You can also use predefined voices:<br>
"marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma".
</p>
</div>
<div class="bg-white p-4 rounded-lg shadow">
<label for="voice-wav-input" class="block text-sm font-medium text-gray-700 mb-2">
Or upload a WAV file for voice cloning:
</label>
<input
type="file"
id="voice-wav-input"
class="w-full border rounded p-2"
accept=".wav,audio/wav"
/>
<p class="text-xs text-gray-500 mt-1">
Upload a WAV file to use as voice reference. Takes precedence over voice URL.
</p>
</div>
<button
id="generate-btn"
class="w-full px-4 py-2 rounded text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
<span id="generate-text">Generate audio</span>
</button>
<div id="status" class="hidden">
<div class="flex items-center space-x-2">
<div id="status-spinner" class="spinner w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span id="status-text"></span>
</div>
</div>
<div id="audio-section" class="hidden">
<div class="bg-white p-4 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-2">Generated Audio</h3>
<audio id="audio" controls class="w-full"></audio>
<div class="mt-2">
<a id="download-link" class="text-blue-600 text-sm hover:underline">Download</a>
</div>
</div>
</div>
</div>
<script>
class StreamingWavPlayer {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.sampleRate = 0;
this.numChannels = 0;
this.headerParsed = false;
this.headerBuffer = new Uint8Array(44);
this.headerBytesReceived = 0;
this.nextStartTime = 0;
this.isPlaying = false;
this.minBufferSize = 16384;
this.pcmData = new Uint8Array(0);
}
parseWavHeader(header) {
const view = new DataView(header.buffer);
const riff = String.fromCharCode.apply(null, Array.from(header.slice(0, 4)));
const wave = String.fromCharCode.apply(null, Array.from(header.slice(8, 12)));
if (riff !== 'RIFF' || wave !== 'WAVE') {
throw new Error('Invalid WAV file');
}
this.numChannels = view.getUint16(22, true);
this.sampleRate = view.getUint32(24, true);
const bitsPerSample = view.getUint16(34, true);
console.log(`WAV Format: ${this.sampleRate}Hz, ${this.numChannels} channels, ${bitsPerSample} bits`);
this.headerParsed = true;
}
appendPcmData(newData) {
const newBuffer = new Uint8Array(this.pcmData.length + newData.length);
newBuffer.set(this.pcmData);
newBuffer.set(newData, this.pcmData.length);
this.pcmData = newBuffer;
}
async tryPlayBuffer() {
if (!this.headerParsed || this.pcmData.length < this.minBufferSize) {
return;
}
const bytesPerSample = this.numChannels * 2;
const samplesToPlay = Math.floor(this.pcmData.length / bytesPerSample);
const bytesToPlay = samplesToPlay * bytesPerSample;
if (bytesToPlay === 0) return;
const dataToPlay = this.pcmData.slice(0, bytesToPlay);
this.pcmData = this.pcmData.slice(bytesToPlay);
const audioBuffer = this.audioContext.createBuffer(
this.numChannels,
samplesToPlay,
this.sampleRate
);
const int16Data = new Int16Array(dataToPlay.buffer, dataToPlay.byteOffset, samplesToPlay * this.numChannels);
for (let channel = 0; channel < this.numChannels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < samplesToPlay; i++) {
channelData[i] = int16Data[i * this.numChannels + channel] / 32768;
}
}
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
const currentTime = this.audioContext.currentTime;
const startTime = Math.max(currentTime, this.nextStartTime);
source.start(startTime);
// Track first audio playback
if (!this.firstAudioPlayed && window.firstAudioCallback) {
this.firstAudioPlayed = true;
window.firstAudioCallback();
}
this.nextStartTime = startTime + audioBuffer.duration;
this.isPlaying = true;
if (this.pcmData.length >= this.minBufferSize) {
setTimeout(() => this.tryPlayBuffer(), 10);
}
}
addChunk(chunk) {
if (!this.headerParsed) {
const headerBytesNeeded = 44 - this.headerBytesReceived;
const bytesToCopy = Math.min(headerBytesNeeded, chunk.length);
this.headerBuffer.set(
chunk.slice(0, bytesToCopy),
this.headerBytesReceived
);
this.headerBytesReceived += bytesToCopy;
if (this.headerBytesReceived >= 44) {
this.parseWavHeader(this.headerBuffer);
if (chunk.length > bytesToCopy) {
this.appendPcmData(chunk.slice(bytesToCopy));
}
}
} else {
this.appendPcmData(chunk);
}
this.tryPlayBuffer();
}
stop() {
this.audioContext.close();
this.isPlaying = false;
}
}
// Application state
let streamingPlayer = null;
let currentAudioBlob = null;
// DOM elements
const textInput = document.getElementById('text-input');
const generateBtn = document.getElementById('generate-btn');
const generateText = document.getElementById('generate-text');
const status = document.getElementById('status');
const statusText = document.getElementById('status-text');
const statusSpinner = document.getElementById('status-spinner');
const audioSection = document.getElementById('audio-section');
const audio = document.getElementById('audio');
const downloadLink = document.getElementById('download-link');
// Event listeners
generateBtn.addEventListener('click', generateAudio);
function showStatus(message, isLoading = false) {
statusText.textContent = message;
statusSpinner.style.display = isLoading ? 'block' : 'none';
status.classList.remove('hidden');
}
function hideStatus() {
status.classList.add('hidden');
}
async function generateAudio() {
const text = textInput.value.trim();
if (!text) {
showStatus('Please enter some text to generate speech.', false);
setTimeout(hideStatus, 3000);
return;
}
// Stop any currently playing audio
if (streamingPlayer) {
streamingPlayer.stop();
streamingPlayer = null;
}
// Track timing
const startTime = performance.now();
let firstAudioTime = null;
// Set callback for first audio
window.firstAudioCallback = () => {
if (!firstAudioTime) {
firstAudioTime = performance.now();
const timeToFirstAudio = ((firstAudioTime - startTime) / 1000).toFixed(2);
showStatus(`First audio in ${timeToFirstAudio}s...`, true);
}
};
// Update UI
generateBtn.disabled = true;
generateText.textContent = 'Generating...';
showStatus('Generating speech...', true);
audioSection.classList.add('hidden');
try {
const formData = new FormData();
formData.append('text', text);
// Add voice URL if provided
const voiceUrl = document.getElementById('voice-url-input').value.trim();
if (voiceUrl) {
formData.append('voice_url', voiceUrl);
}
// Add voice WAV file if uploaded
const voiceWavFile = document.getElementById('voice-wav-input').files[0];
if (voiceWavFile) {
formData.append('voice_wav', voiceWavFile);
}
const response = await fetch('/tts', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
// Clone the response for both streaming and blob collection
const responseForPlayback = response.clone();
const responseForHistory = response.clone();
// Start streaming playback
const reader = responseForPlayback.body.getReader();
streamingPlayer = new StreamingWavPlayer();
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
streamingPlayer.addChunk(value);
}
}
} catch (e) {
console.error('Error processing stream:', e);
}
};
// Start processing the stream
processStream();
// Also collect the full blob for download/audio element
const blob = await responseForHistory.blob();
const totalTime = ((performance.now() - startTime) / 1000).toFixed(2);
currentAudioBlob = blob;
// Update UI with audio
const audioUrl = URL.createObjectURL(blob);
audio.src = audioUrl;
// Wait for audio metadata to load to get duration
audio.addEventListener('loadedmetadata', () => {
const audioDuration = audio.duration;
const speedRatio = (audioDuration / parseFloat(totalTime)).toFixed(1);
const timeToFirst = firstAudioTime ? ((firstAudioTime - startTime) / 1000).toFixed(2) : 'N/A';
showStatus(
`✨ First audio: ${timeToFirst}s | Total: ${totalTime}s | ${speedRatio}x faster than real-time`,
false
);
});
downloadLink.href = audioUrl;
downloadLink.download = `tts-audio.wav`;
audioSection.classList.remove('hidden');
} catch (error) {
console.error('Error generating audio:', error);
showStatus(`Error: ${error.message}`, false);
setTimeout(hideStatus, 3000);
} finally {
// Re-enable button
generateBtn.disabled = false;
generateText.textContent = 'Generate Audio';
}
}
// Focus on text input when page loads
window.addEventListener('load', () => {
textInput.focus();
});
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
"""Utilities."""
+122
View File
@@ -0,0 +1,122 @@
"""Configuration models for loading YAML config files."""
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
# Flow configuration
class FlowConfig(StrictModel):
dim: int
depth: int
# Transformer configuration for FlowLM
class FlowLMTransformerConfig(StrictModel):
hidden_scale: int
max_period: int
d_model: int
num_heads: int
num_layers: int
class LookupTable(StrictModel):
dim: int
n_bins: int
tokenizer: str
tokenizer_path: str
# Root configuration
class FlowLMConfig(StrictModel):
"""Root configuration model for YAML config files."""
dtype: str
# Nested configurations
flow: FlowConfig
transformer: FlowLMTransformerConfig
# conditioning
lookup_table: LookupTable
weights_path: str | None = None
# SEANet configuration
class SEANetConfig(StrictModel):
dimension: int
channels: int
n_filters: int
n_residual_layers: int
ratios: list[int]
kernel_size: int
residual_kernel_size: int
last_kernel_size: int
dilation_base: int
pad_mode: str
compress: int
# Transformer configuration for Mimi
class MimiTransformerConfig(StrictModel):
d_model: int
input_dimension: int
output_dimensions: tuple[int, ...]
num_heads: int
num_layers: int
layer_scale: float
context: int
max_period: float = 10000.0
dim_feedforward: int
# Quantizer configuration
class QuantizerConfig(StrictModel):
dimension: int
output_dimension: int
# Root configuration
class MimiConfig(StrictModel):
"""Root configuration model for Mimi YAML config files."""
dtype: str
# Sample rate and channels
sample_rate: int
channels: int
frame_rate: float
# SEANet configurations
seanet: SEANetConfig
# Transformer
transformer: MimiTransformerConfig
# Quantizer
quantizer: QuantizerConfig
weights_path: str | None = None
class Config(StrictModel):
flow_lm: FlowLMConfig
mimi: MimiConfig
weights_path: str | None = None
weights_path_without_voice_cloning: str | None = None
def load_config(yaml_path: str | Path) -> Config:
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise FileNotFoundError(f"Config file not found: {yaml_path}")
with open(yaml_path, "r") as f:
config_dict = yaml.safe_load(f)
return Config(**config_dict)
+26
View File
@@ -0,0 +1,26 @@
import torch
from torch.utils._python_dispatch import TorchDispatchMode
def to_str(obj):
if isinstance(obj, (torch.Tensor, torch.nn.Parameter)):
return f"T(s={list(obj.shape)})"
elif isinstance(obj, (list, tuple)):
return "[" + ", ".join(to_str(o) for o in obj) + "]"
elif isinstance(obj, dict):
return "{" + ", ".join(f"{to_str(k)}: {to_str(v)}" for k, v in obj.items()) + "}"
else:
return str(obj)
class LoggingMode(TorchDispatchMode):
"""Useful to check implementation differences."""
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
output = func(*args, **kwargs or {})
print(
f"Aten function called: {func}, args: "
f"{to_str(args)}, kwargs: {to_str(kwargs)} -> "
f"output: {to_str(output)}"
)
return output
+41
View File
@@ -0,0 +1,41 @@
import logging
from contextlib import contextmanager
class PocketTTSFilter(logging.Filter):
def filter(self, record):
return record.name.startswith("pocket_tts")
@contextmanager
def enable_logging(library_name, level):
# Get the specific logger and its parent
logger = logging.getLogger(library_name)
parent_logger = logging.getLogger("pocket_tts")
# Store original configuration
old_level = logger.level
old_parent_level = parent_logger.level
old_handlers = parent_logger.handlers.copy()
# Configure logging format for pocket_tts logger
parent_logger.setLevel(level)
# Clear existing handlers and add our custom formatter with filter
parent_logger.handlers.clear()
handler = logging.StreamHandler()
formatter = logging.Formatter("%(levelname)s: %(message)s")
handler.setFormatter(formatter)
handler.addFilter(PocketTTSFilter())
parent_logger.addHandler(handler)
parent_logger.propagate = False
try:
yield logger
finally:
# Restore original configuration
logger.setLevel(old_level)
parent_logger.setLevel(old_parent_level)
parent_logger.handlers.clear()
for h in old_handlers:
parent_logger.addHandler(h)
+102
View File
@@ -0,0 +1,102 @@
import hashlib
import logging
import time
from pathlib import Path
import requests
import safetensors.torch
import torch
from huggingface_hub import hf_hub_download
from torch import nn
PROJECT_ROOT = Path(__file__).parent.parent.parent
PREDEFINED_VOICES = {
"marius": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/marius.safetensors",
"javert": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/javert.safetensors",
"jean": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/jean.safetensors",
"fantine": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/fantine.safetensors",
"cosette": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/cosette.safetensors",
"eponine": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/eponine.safetensors",
"azelma": "hf://kyutai/pocket-tts-without-voice-cloning/embeddings/azelma.safetensors",
}
def make_cache_directory() -> Path:
cache_dir = Path.home() / ".cache" / "pocket_tts"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def print_nb_parameters(model: nn.Module, model_name: str):
logger = logging.getLogger(__name__)
state_dict = model.state_dict()
total = 0
for key, value in state_dict.items():
logger.info("%s: %,d", key, value.numel())
total += value.numel()
logger.info("Total number of parameters in %s: %,d", model_name, total)
def size_of_dict(state_dict: dict) -> int:
total_size = 0
for value in state_dict.values():
if isinstance(value, torch.Tensor):
total_size += value.numel() * value.element_size()
elif isinstance(value, dict):
total_size += size_of_dict(value)
return total_size
class display_execution_time:
def __init__(self, task_name: str, print_output: bool = True):
self.task_name = task_name
self.print_output = print_output
self.start_time = None
self.elapsed_time_ms = None
self.logger = logging.getLogger(__name__)
def __enter__(self):
self.start_time = time.monotonic()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
end_time = time.monotonic()
self.elapsed_time_ms = int((end_time - self.start_time) * 1000)
if self.print_output:
self.logger.info("%s took %d ms", self.task_name, self.elapsed_time_ms)
return False # Don't suppress exceptions
def download_if_necessary(file_path: str) -> Path:
if file_path.startswith("http://") or file_path.startswith("https://"):
cache_dir = make_cache_directory()
cached_file = cache_dir / (
hashlib.sha256(file_path.encode()).hexdigest() + "." + file_path.split(".")[-1]
)
if not cached_file.exists():
response = requests.get(file_path)
response.raise_for_status()
with open(cached_file, "wb") as f:
f.write(response.content)
return cached_file
elif file_path.startswith("hf://"):
file_path = file_path.removeprefix("hf://")
splitted = file_path.split("/")
repo_id = "/".join(splitted[:2])
filename = "/".join(splitted[2:])
cached_file = hf_hub_download(repo_id=repo_id, filename=filename)
return Path(cached_file)
else:
return Path(file_path)
def load_predefined_voice(voice_name: str) -> torch.Tensor:
if voice_name not in PREDEFINED_VOICES:
raise ValueError(
f"Predefined voice '{voice_name}' not found"
f", available voices are {list(PREDEFINED_VOICES)}."
)
voice_file = download_if_necessary(PREDEFINED_VOICES[voice_name])
# There is only one tensor in the file.
return safetensors.torch.load_file(voice_file)["audio_prompt"]
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
import safetensors
def get_flow_lm_state_dict(path: Path) -> dict:
state_dict = {}
with safetensors.safe_open(path, framework="pt", device="cpu") as f:
for key in f.keys():
if (
key.startswith("flow.w_s_t.")
or key == "condition_provider.conditioners.transcript_in_segment.learnt_padding"
or key == "condition_provider.conditioners.speaker_wavs.learnt_padding"
):
# skip lookup table weights
continue
new_name = key
if key == "condition_provider.conditioners.transcript_in_segment.embed.weight":
new_name = "conditioner.embed.weight"
if key == "condition_provider.conditioners.speaker_wavs.output_proj.weight":
new_name = "speaker_proj_weight"
state_dict[new_name] = f.get_tensor(key)
return state_dict
def get_mimi_state_dict(path: Path) -> dict:
state_dict = {}
with safetensors.safe_open(path, framework="pt", device="cpu") as f:
for key in f.keys():
if key.startswith("model.quantizer.vq.") or key == "model.quantizer.logvar_proj.weight":
# skip vq weights
continue
state_dict[key.removeprefix("model.")] = f.get_tensor(key)
return state_dict
+71
View File
@@ -0,0 +1,71 @@
[project]
name = "pocket-tts"
requires-python = ">= 3.10,<3.15"
description = "Kyutai's pocket-sized TTS!"
version = "0.1.0"
readme = "README.md"
dependencies = [
"numpy>=2",
# I tried 2.4.0 but the generated audio is wrong, so 2.5+ is needed.
"torch>=2.5.0",
"pydantic>=2",
"sentencepiece>=0.2.1",
"beartype>=0.22.5",
"safetensors>=0.4.0",
"typer>=0.10.0",
"typing_extensions>=4.0.0",
"fastapi>=0.100",
"uvicorn>=0.13.0",
"python-multipart>=0.0.21",
"scipy>=1.5.0",
"einops>=0.4.0",
"huggingface_hub>=0.10",
"requests>=2.20.0",
]
[dependency-groups]
dev = [
"coverage>=7.6.12",
"line-profiler>=5.0.0",
"pytest>=9.0.2",
"pytest-xdist>=3.8.0",
]
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.ruff]
line-length = 100
[tool.ruff.format]
line-ending = "lf"
# avoid having a trailing comma changing
# the behavior of the formatter
skip-magic-trailing-comma = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["pocket_tts"]
[project.scripts]
pocket-tts = "pocket_tts.main:cli_app"
[tool.uv]
# System python distribution can have header files missing, so we use only the python
# distributions provided by uv which have everything included.
python-preference = "only-managed"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
+3
View File
@@ -0,0 +1,3 @@
import os
os.environ["POCKET_TTS_ERROR_WITHOUT_EOS"] = "1"
+180
View File
@@ -0,0 +1,180 @@
"""Integration tests for the CLI generate command using real implementation."""
from typer.testing import CliRunner
from pocket_tts.data.audio import audio_read
from pocket_tts.default_parameters import DEFAULT_VARIANT
from pocket_tts.main import cli_app
other_voice = "https://huggingface.co/kyutai/tts-voices/resolve/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
runner = CliRunner()
def test_generate_basic_usage(tmp_path):
"""Test basic generate command with default parameters."""
output_file = tmp_path / "test_output.wav"
result = runner.invoke(
cli_app,
["generate", "--text", "Hello world, this is a test.", "--output-path", str(output_file)],
)
assert result.exit_code == 0
# Verify output file was created and contains audio
assert output_file.exists()
assert output_file.stat().st_size > 0
# Verify it's a valid audio file
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000 # Expected sample rate
def test_generate_with_custom_voice(tmp_path):
"""Test generate command with custom voice prompt."""
output_file = tmp_path / "custom_voice_test.wav"
result = runner.invoke(
cli_app,
[
"generate",
"--text",
"Testing custom voice.",
"--voice",
other_voice,
"--output-path",
str(output_file),
],
)
assert result.exit_code == 0
assert output_file.exists()
# Verify audio content
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000
def test_generate_with_custom_parameters(tmp_path):
"""Test generate command with custom generation parameters."""
output_file = tmp_path / "custom_params_test.wav"
result = runner.invoke(
cli_app,
[
"generate",
"--text",
"Testing custom parameters.",
"--variant",
DEFAULT_VARIANT,
"--temperature",
"0.8",
"--lsd-decode-steps",
"2",
"--eos-threshold",
"-3.0",
"--frames-after-eos",
"7",
"--output-path",
str(output_file),
],
)
assert result.exit_code == 0
assert output_file.exists()
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000
def test_generate_verbose_mode(tmp_path):
"""Test generate command with verbose logging."""
output_file = tmp_path / "verbose_test.wav"
result = runner.invoke(
cli_app,
["generate", "--text", "Testing verbose mode.", "-q", "--output-path", str(output_file)],
)
assert result.exit_code == 0
assert output_file.exists()
def test_generate_default_text(tmp_path):
"""Test generate command with default text when no text provided."""
output_file = tmp_path / "default_text_test.wav"
result = runner.invoke(cli_app, ["generate", "--output-path", str(output_file)])
assert result.exit_code == 0
assert output_file.exists()
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000
def test_generate_long_text(tmp_path):
"""Test generate command with longer text."""
long_text = "This is a longer text to test the TTS system. " * 5
output_file = tmp_path / "long_text_test.wav"
result = runner.invoke(
cli_app, ["generate", "--text", long_text, "--output-path", str(output_file)]
)
assert result.exit_code == 0
assert output_file.exists()
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000
# Longer text should produce longer audio
assert audio.shape[1] > 24000 * 10 # At least 10 second of audio
def test_generate_command_help():
"""Test that generate command help is displayed correctly."""
result = runner.invoke(cli_app, ["generate", "--help"])
assert result.exit_code == 0
assert "Generate speech using Kyutai Pocket TTS" in result.output
assert "--text" in result.output
assert "--voice" in result.output
assert "--output-path" in result.output
assert "--variant" in result.output
assert "--temperature" in result.output
assert "--device" in result.output
def test_generate_multiple_runs(tmp_path):
"""Test multiple consecutive generate commands."""
for i in range(3):
output_file = tmp_path / f"test_run_{i + 1}.wav"
result = runner.invoke(
cli_app,
[
"generate",
"--text",
f"This is test run number {i + 1}.",
"--output-path",
str(output_file),
],
)
assert result.exit_code == 0
assert output_file.exists()
audio, sample_rate = audio_read(str(output_file))
assert audio.shape[0] == 1 # Mono channel
assert audio.shape[1] > 0 # Has audio samples
assert sample_rate == 24000
+172
View File
@@ -0,0 +1,172 @@
"""We also add the imports in the functions to make sure we didn't forget them."""
# ruff: noqa: F841
import pytest
def test_readme_example():
import scipy.io.wavfile
from pocket_tts import TTSModel
tts_model = TTSModel.load_model()
voice_state = tts_model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
audio = tts_model.generate_audio(voice_state, "Hello world, this is a test.")
# Audio is a torch tensor containing PCM data.
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
def test_quick_start():
import scipy.io.wavfile
from pocket_tts import TTSModel
# Load the model
tts_model = TTSModel.load_model()
# Get voice state from an audio file
voice_state = tts_model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# Generate audio
audio = tts_model.generate_audio(voice_state, "Hello world, this is a test.")
# Save to file
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
def test_load_model():
from pocket_tts import TTSModel
# Load with default settings
model = TTSModel.load_model()
# Load with custom parameters
model = TTSModel.load_model(
variant="b6369a24", temp=0.5, lsd_decode_steps=5, eos_threshold=-3.0
)
def test_device():
from pocket_tts import TTSModel
model = TTSModel.load_model()
print(f"Model running on: {model.device}")
def test_sample_rate():
from pocket_tts import TTSModel
model = TTSModel.load_model()
print(f"Sample rate: {model.sample_rate} Hz")
@pytest.fixture
def make_my_voice_file():
import requests
url = "https://huggingface.co/kyutai/tts-voices/resolve/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
response = requests.get(url)
with open("my_voice.wav", "wb") as f:
f.write(response.content)
@pytest.mark.usefixtures("make_my_voice_file")
def test_get_state_for_audio_prompt():
from pocket_tts import TTSModel
model = TTSModel.load_model()
# From HuggingFace URL
voice_state = model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# From local file
voice_state = model.get_state_for_audio_prompt("./my_voice.wav")
# From HTTP URL
voice_state = model.get_state_for_audio_prompt(
"https://huggingface.co/kyutai/tts-voices/resolve"
"/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
)
def test_generate_audio():
from pocket_tts import TTSModel
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# Generate audio
audio = model.generate_audio(voice_state, "Hello world!", frames_after_eos=2, copy_state=True)
print(f"Generated audio shape: {audio.shape}")
print(f"Audio duration: {audio.shape[-1] / model.sample_rate:.2f} seconds")
def test_generate_audio_stream():
from pocket_tts import TTSModel
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# Stream generation
for chunk in model.generate_audio_stream(voice_state, "Long text content..."):
# Process each chunk as it's generated
print(f"Generated chunk: {chunk.shape[0]} samples")
# Could save chunks to file or play in real-time
def test_voice_management():
from pocket_tts import TTSModel
model = TTSModel.load_model()
# Preload multiple voices
voices = {
"casual": model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
),
"funny": model.get_state_for_audio_prompt(
"https://huggingface.co/kyutai/tts-voices/resolve/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
),
}
# Generate with different voices
casual_audio = model.generate_audio(voices["casual"], "Hey there!")
funny_audio = model.generate_audio(voices["funny"], "Good morning.")
def test_batch_processing():
import scipy.io.wavfile
import torch
from pocket_tts import TTSModel
model = TTSModel.load_model()
voice_state = model.get_state_for_audio_prompt(
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
)
# Process multiple texts efficiently by re-using the same voice state
texts = [
"First sentence to generate.",
"Second sentence to generate.",
"Third sentence to generate.",
]
audios = []
for text in texts:
audio = model.generate_audio(voice_state, text)
audios.append(audio)
# Concatenate all audio
full_audio = torch.cat(audios, dim=0)
scipy.io.wavfile.write("batch_output.wav", model.sample_rate, full_audio.numpy())
View File
Generated
+1621
View File
File diff suppressed because it is too large Load Diff