CrispTTS is a versatile command-line Text-to-Speech (TTS) tool designed for synthesizing German speech using a variety of popular local and cloud-based TTS engines. Its modular architecture allows for easy maintenance and straightforward addition of new TTS handlers.
| Project | Role |
|---|---|
| Susurrus | Python GUI + CLI — 30+ ASR, 12 TTS, translation |
| CrispASR | C++ ASR/TTS engine — 26+ ASR, 20+ TTS backends, ggml inference |
| CrispTTS | This repo — Python TTS CLI with 37+ handlers |
| CrisperWeaver | Flutter transcription app — desktop + mobile |
NOTE: This is in experimental / work in progress state. Some Python-only models may be broken due to dependency conflicts. The CrispASR-based handlers (crispasr_*) are the most reliable — they use native C++ inference with no Python ML dependencies.
- 37+ TTS Engine Support:
- CrispASR native C++ engines (16 backends, auto-download, no Python ML deps):
- Kokoro (multilingual, Apache 2.0)
- Orpheus + Kartoffel-Orpheus DE (19 German speakers, llama3.2 license)
- Qwen3-TTS (voice cloning + voice design, Apache 2.0)
- Chatterbox (CFM synthesis, MIT)
- VibeVoice TTS (voice cloning)
- IndexTTS (zero-shot cloning, Apache 2.0)
- VoxCPM2 (48 kHz, 30 languages, Apache 2.0)
- F5-TTS (flow-matching, voice cloning, Apache 2.0)
- MeloTTS (VITS2, 44.1 kHz, MIT)
- Piper (250+ community voices, 30+ languages — faster than Python Piper)
- BananaMind-TTS (Tacotron-lite + HiFi-GAN, en/de)
- Dots.TTS (Qwen2.5 LLM + DiT + BigVGAN, 48 kHz, CAM++ voice cloning)
- CosyVoice3 (multi-GGUF: LLM+flow+CAM+++HiFT, voice cloning)
- CSM/Sesame (Llama backbone + Mimi codec, causal mode, voice cloning)
- OmniVoice (Qwen3 masked iterative, 600+ languages, voice cloning)
- MOSS-TTS-Local (4B transformer + codec-v2, 48 kHz)
- Microsoft Edge TTS (cloud-based, requires
edge-tts) - Coqui TTS (XTTS v2, VITS, etc.)
- Piper (local ONNX, requires
piper-tts) - Orpheus GGUF (local, requires
llama-cpp-python) - Orpheus via LM Studio / Ollama API
- OuteTTS (LlamaCPP or HF backend)
- SpeechT5 (German fine-tune via HF Transformers)
- FastPitch (German via NeMo)
- mlx-audio (Bark, Kokoro, Dia — Apple Silicon)
- LLaSA (hybrid, German, multilingual variants)
- F5-TTS (MLX/PyTorch)
- Kokoro ONNX (lightweight)
- TTS.cpp (GGUF models)
- Zonos (acoustic conditioning)
- Chatterbox Python (Kartoffelbox)
- CrispASR native C++ engines (16 backends, auto-download, no Python ML deps):
- AI Audio Watermarking & Provenance:
- AudioSeal neural watermark (MIT — code + weights; preferred; survives Opus
and 64 kbps MP3 at full confidence;
pip install 'crisptts[robust]') - Spread-spectrum watermark (always on, low-level — 20–25 dB SNR on speech)
- WavMark neural watermark (MIT — code + weights; quietest at 36.3 dB, but
far slower;
pip install 'crisptts[watermark-mit]') - WAV LIST/INFO, MP3 ID3v2, FLAC Vorbis comment, and Opus/OGG metadata marking audio as AI-generated
- C2PA content credentials signed by default for WAV/MP3/FLAC/M4A (core dep; c2pa-audio / CrispASR used as fast paths when present)
- Voice-cloning consent gate (
--i-have-rightsCLI /"i_have_rights": trueAPI) - Spoken AI disclaimer prepended to voice-cloned audio — and to models whose
preset voice is an identifiable person (
speaker_identity,--speaker-identity) — in all 24 EU official languages plus zh/ja/ko (--list-disclosure-langs), bundled for offline use - Persistent consent audit log at
~/.cache/crisptts/consent_audit.log, owner-only, auto-pruned, with--consent-log-erasefor GDPR Art. 17
- AudioSeal neural watermark (MIT — code + weights; preferred; survives Opus
and 64 kbps MP3 at full confidence;
- CrispASR Integration:
--verify: ASR roundtrip verification of TTS output quality--translate: Pre-synthesis translation (EN→DE via m2m100/MadLad)--speech-speed: Rate multiplier (maps to CrispASR--pace)--trim-silence: Remove leading/trailing silence from output--tts-steps: Diffusion model inference steps (quality vs speed)--tts-language: Override language for multilingual models--pitch-shift: Pitch shift in Hz for FastPitch backends--instruct: Natural-language voice descriptions (Qwen3-TTS VoiceDesign)--stream: Stream audio playback during synthesis--output-sample-rate: Resample output to target sample rate
- OpenAI-Compatible API Server (
--server):POST /v1/audio/speech— drop-in replacement for OpenAI TTSGET /v1/audio/models— list all configured models- Voice-cloning consent gate (returns 403 if
i_have_rightsmissing) - All responses watermarked + metadata-injected
- Text Input Flexibility: Synthesize from CLI,
.txt,.md,.html,.pdf,.epub - Smart Text Chunking: Automatic sentence-boundary splitting for long texts
- SSML-lite: Supports
<break>,<prosody rate>,<say-as>,<phoneme>tags in input text - Customizable Output: Save audio to
.wav,.mp3,.flac,.m4aor.opus(.opus/.oggcarry no C2PA manifest, so they require a neural watermark —pip install 'crisptts[robust]') - Direct Playback: Play synthesized audio immediately
- Voice Selection: Override default voices/speakers for most models
- Model Parameter Tuning: JSON-formatted parameters for fine-tuning
- Comprehensive Testing:
--test-all: Test all models with default voices--test-all-speakers: Test all models with all configured voices- 439 unit and live tests
- Modular Design:
config.py+utils.py+handlers/+main.py - Logging: Configurable logging levels
- Automatic Patching: Runtime monkeypatches for library compatibility
crisptts_project/
├── main.py # Main CLI application script
├── config.py # Model configurations and global constants
├── utils.py # Shared utility functions and classes
├── watermark.py # Audio watermarking, metadata, consent gate, C2PA
├── chunking.py # Smart sentence-boundary text splitting
├── server.py # OpenAI-compatible HTTP API server
├── ssml.py # SSML-lite tag preprocessor
├── cache.py # Synthesis result caching (LRU)
├── decoder.py # User-provided decoder for Orpheus models (if used)
├── handlers/ # Package for individual TTS engine handlers
│ ├── __init__.py # Makes 'handlers' a package, exports handler functions
│ ├── crispasr_handler.py # CrispASR native C++ TTS (10 backends)
│ ├── edge_handler.py # Edge TTS cloud service handler
│ ├── piper_handler.py # Piper TTS (ONNX) handler
│ ├── orpheus_gguf_handler.py # Local Orpheus GGUF model handler
│ ├── orpheus_api_handler.py # Handlers for LM Studio and Ollama API
│ ├── outetts_handler.py # OuteTTS model handler
│ ├── speecht5_handler.py # SpeechT5 model handler
│ ├── nemo_handler.py # NeMo FastPitch handler
│ ├── coqui_tts_handler.py # Coqui TTS handler (for XTTS, VITS etc.)
│ ├── kartoffel_handler.py # Orpheus "Kartoffel" Transformers handler
│ ├── kokoro_onnx_handler.py # Kokoro (multilingual but no German) ONNX handler
│ ├── llasa_hybrid_handler.py # LLaSA Hybrid handler
│ ├── tts_cpp_handler.py # TTS.cpp handler supporting GGUF models
│ ├── f5_tts_handler.py # F5-TTS handler (MLX/PyTorch)
│ ├── zonos_handler.py # Zonos acoustic conditioning handler
│ ├── chatterbox_handler.py # Chatterbox/Kartoffelbox handler
│ └── mlx_audio_handler.py # Handler for mlx-audio library (e.g., Bark)
├── tests/ # Unit and integration tests
├── requirements.txt # Python package dependencies
├── pyproject.toml # Project metadata and build config
└── README.md # This documentation file
- Python 3.10+ and
pipfor installing packages - For
mlx-audiobased models: Apple Silicon Mac is required for GPU acceleration - For
TTS.cppa C++ compiler and CMake are required to build the engine
-
Clone/Download Files
git clone https://github.com/CrispStrobe/CrispTTS
-
Create a Virtual Environment (Recommended):
python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate
-
Install Dependencies:
pip install -r requirements.txt
Optional feature groups:
pip install crisptts[robust] # RECOMMENDED: AudioSeal neural watermark (MIT) pip install crisptts[watermark-mit] # WavMark instead — quieter, much slower pip install crisptts[dev] # ruff, bandit, pytest
C2PA signing (
c2pa-python), container metadata (mutagen),soundfileandpydubare core dependencies, not extras — without them the AI-provenance marking cannot be applied and CrispTTS fails closed rather than emitting unmarked synthetic audio. Themetadataandprovenanceextras are kept only so existing install commands keep working.Note: Some libraries like PyTorch, NeMo, LlamaCPP, and
mlx-audiocan have specific installation needs depending on your OS and hardware (e.g., CUDA for Nvidia GPUs, Metal for Apple Silicon). Please refer to their official documentation if you encounter issues. Ensure you haveffmpeginstalled and available in your system's PATH if you encounter issues with audio file format conversions or direct playback (some underlying libraries might need it). -
Install and Build Engine-Specific Dependencies (required for certain handlers):
For TTS.cpp: Clone and build the TTS.cpp project separately.
git clone https://github.com/mmwillet/TTS.cpp.git cd TTS.cpp cmake -B build cmake --build build --config Release cd ..
For kokoro-onnx: Install the Python package and download model files.
pip install kokoro-onnx
-
Environment Variables (Optional but Recommended):
HF_TOKEN: If you need to download models from gated or private Hugging Face repositories, set this environment variable with your Hugging Face API token:export HF_TOKEN="your_huggingface_token_here"
GGML_METAL_NDEBUG=1: Set automatically bymain.pyto reduce verbose Metal logs fromllama-cpp-pythonon macOS.
The config.py file is central to defining which TTS models are available and their default settings.
-
GERMAN_TTS_MODELSDictionary: This is the primary configuration structure. Each key is a uniqueMODEL_IDused in the CLI. The value is a dictionary containing:"handler_function_key"(Optional, defaults toMODEL_ID): The key used to look up the synthesis function inhandlers.ALL_HANDLERS- Specific parameters for that model (e.g.,
model_repo_id,default_voice_id, API URLs,onnx_repo_id, etc.) "notes": A brief description of the model
-
mlx-audioBark Configuration Example: To use themlx-audioBark model, your configuration might look like this, enabling the dual-source strategy for voice prompts (main model frommlx-community, voice NPYs fromsuno):"mlx_audio_bark_de": { "handler_function_key": "mlx_audio", "mlx_model_path": "mlx-community/bark-small", # Main MLX model # Voice prompts will be fetched by the patched handler from "suno/bark-small" "default_voice_id": "v2/de_speaker_3", "available_voices": ["v2/de_speaker_0", "v2/de_speaker_1", "v2/de_speaker_3", "..."], "lang_code": "de", "sample_rate": 24000, "notes": "mlx-audio (Bark) with main model from mlx-community/bark-small and voices from suno/bark-small (via patch)." },
-
Global Constants: API URLs, default voice names, and sample rates are also defined here
-
Adding/Modifying Models: To add a new variation of an existing engine or a completely new engine (after creating its handler), you would add a new entry to
GERMAN_TTS_MODELS
All interactions are done through main.py from your project's root directory.
python main.py [ACTION_FLAG | --model-id <MODEL_ID> [OPTIONS]]
# or using the --backend shortcut for CrispASR engines:
python main.py --backend kokoro --input-text "Hello" --output-file out.wav| Flag | Description |
|---|---|
--list-models |
List all configured TTS models with their notes |
--voice-info MODEL_ID |
Show available voices/speakers for a model |
--test-all |
Test all models with default voices (requires --input-text or --input-file) |
--test-all-speakers |
Test all models with ALL configured voices |
--skip-models M1 M2 ... |
Skip specific model IDs during --test-all / --test-all-speakers |
--detect-watermark FILE |
Detect AI-generated watermark in a WAV file and report confidence |
--server |
Run as HTTP server with OpenAI-compatible endpoints |
--check |
With --list-models: probe CrispASR backends for availability |
| Flag | Default | Description |
|---|---|---|
--model-id MODEL_ID |
— | TTS model to use (see --list-models for choices) |
--backend NAME |
— | Shortcut for CrispASR backends (e.g., kokoro, piper, dots-tts) |
--input-text TEXT |
— | Text to synthesize (mutually exclusive with --input-file) |
--input-file PATH |
— | Input file: .txt, .md, .html, .pdf, .epub |
--output-file PATH |
— | Save audio to file (format detected from extension: .wav, .mp3, .flac, .opus) |
--output-dir DIR |
tts_test_outputs |
Output directory for --test-all / --test-all-speakers |
--play-direct |
off | Play audio immediately after synthesis |
--german-voice-id ID |
model default | Override voice/speaker (name, ID, or path to .wav for cloning) |
--model-params JSON |
— | JSON string of model-specific parameters, e.g. '{"temperature":0.7}' |
--speech-speed FLOAT |
1.0 |
Speech rate multiplier (>1 = faster, <1 = slower) |
--trim-silence |
off | Remove leading/trailing silence from output |
--tts-steps N |
backend default | Diffusion/flow-matching inference steps (quality vs. speed) |
--tts-language LANG |
model default | Override language code for multilingual models (e.g. de, en, zh, ja) |
--pitch-shift HZ |
0 |
Pitch offset in Hz (positive = higher, negative = lower) |
--instruct TEXT |
— | Natural-language voice description for VoiceDesign models (Qwen3-TTS) |
--output-sample-rate HZ |
native | Resample output to target sample rate (e.g. 16000, 22050, 44100) |
--stream |
off | Stream audio playback during synthesis (CrispASR backends only) |
--ref-text TEXT |
— | Transcript of reference voice audio for inline voice cloning (TADA, dots-tts) |
--no-spoken-disclaimer |
off | Skip the AI-disclosure spoken prefix on voice-cloned audio |
--lexicon TSV_PATH |
— | Custom word→phoneme TSV file for CrispASR pronunciation |
--batch |
off | Split input at blank lines, produce numbered output files |
--jobs N |
1 |
Concurrent synthesis jobs for --batch mode |
--normalize |
off | Peak-normalize output audio to -3 dB |
| Flag | Default | Description |
|---|---|---|
--verify |
off | Run ASR on output for roundtrip quality verification |
--verify-backend NAME |
parakeet |
ASR backend for --verify (e.g. parakeet, whisper) |
--translate |
off | Translate input text before synthesis |
--translate-from LANG |
en |
Source language for translation |
--translate-to LANG |
de |
Target language for translation |
--translate-backend NAME |
m2m100 |
Translation backend (m2m100 or madlad) |
| Flag | Default | Description |
|---|---|---|
--no-watermark |
off | Disable all marking layers: audio watermark, metadata, C2PA (debug only) |
--allow-unmarked |
off | Deliver output even if marking fails or is undetectable |
--accept-marking-responsibility |
off | Required for any provenance opt-out; logged as [MARKING] |
--watermark-model PATH |
— | Path to AudioSeal GGUF model for neural watermarking |
--i-have-rights |
off | Consent attestation for voice-cloning models (required) |
--disclosure-lang LANG |
model's language | Language of the spoken AI disclosure on cloned audio |
--list-disclosure-langs |
— | List the languages the spoken disclosure is available in |
--consent-log-prune |
— | Drop consent audit entries past the retention window |
--consent-log-erase [SUBJECT] |
— | Erase consent audit entries (GDPR Art. 17) |
--c2pa-cert PEM |
— | X.509 PEM certificate for C2PA content credentials |
--c2pa-key PEM |
— | PEM private key for C2PA content credentials |
| Flag | Default | Description |
|---|---|---|
--server |
off | Start the HTTP API server |
--server-host ADDR |
127.0.0.1 |
Server bind address |
--server-port PORT |
8880 |
Server port |
--rate-limit N |
10 |
Max synthesis requests per minute per IP (0=unlimited) |
--warm-up MODEL_ID |
— | Pre-synthesize at startup to warm model caches |
Parameters are passed as a JSON string. Available keys depend on the backend:
| Key | Backends | Description |
|---|---|---|
temperature |
Most LLM-based | Sampling temperature (higher = more varied) |
seed |
All CrispASR | Random seed for reproducible output |
top_p |
LLM-based | Nucleus sampling threshold |
repetition_penalty |
LLM-based | Penalize token repetition |
tts_steps |
Diffusion/flow | Number of inference steps |
speech_speed |
CrispASR | Rate multiplier (same as --speech-speed) |
pitch_shift |
FastPitch | Hz offset (same as --pitch-shift) |
top_k |
LLM-based | Top-K candidates |
min_p |
LLM-based | Min-P threshold |
cfg_weight |
Chatterbox | Classifier-free guidance weight |
cfg_scale |
Chatterbox, F5, TADA | CFG scale for acoustic conditioning |
exaggeration |
Chatterbox | Emotion exaggeration factor |
length_scale |
VITS | Duration scaling factor |
speaker_name |
Multi-speaker | Speaker name override |
speaker_id |
Piper | Multi-speaker model ID |
do_sample |
TADA | 0=greedy, 1=sample talker |
num_candidates |
TADA | Acoustic flow-matching candidates |
num_steps |
TADA, flow-matching | FM/diffusion inference steps |
noise_temp |
TADA | FM noise temperature |
noise_scale |
Piper | VITS variance |
noise_w |
Piper | Stochastic duration predictor |
max_speech_tokens |
Chatterbox | Max AR tokens |
tts_speed |
OmniVoice | Target-length speed estimate |
Example:
python main.py --model-id crispasr_chatterbox \
--model-params '{"cfg_weight": 3.0, "exaggeration": 0.7, "temperature": 0.8}' \
--input-text "Emotional speech test." --output-file chatterbox.wavList all available models:
python main.py --list-modelsGet information about voices for a specific model:
python main.py --voice-info edge
python main.py --voice-info mlx_audio_bark_deSynthesize text using a specific model:
python main.py --model-id edge --input-text "Hallo, wie geht es Ihnen heute?" --output-file hallo_edge.mp3 --play-directSynthesize text using mlx-audio Bark (German):
python main.py --model-id mlx_audio_bark_de --input-text "Das ist ein Test mit Bark auf Apple Silicon." --output-file bark_test_de.wavUse a specific German voice (if supported by the model):
python main.py --model-id edge --input-text "Ein Test mit einer anderen Stimme." --german-voice-id de-DE-ConradNeural --output-file conrad_test.mp3Check --voice-info <MODEL_ID> for available voice IDs/formats for that model.
Synthesize text from a file:
python main.py --model-id piper_local --input-file ./my_text.txt --output-file piper_output.wavSupported input file types: .txt, .md, .html, .pdf, .epub.
Use model-specific parameters (as a JSON string):
python main.py --model-id orpheus_gguf --input-text "Ein Test." --model-params "{\"temperature\": 0.8, \"n_gpu_layers\": -1}" --output-file orpheus_custom.wavTest all configured models with default voices:
python main.py --input-text "Dies ist ein kurzer Test für alle Modelle." --test-all --output-dir ./test_resultsTest all models with all their configured available voices/speakers:
python main.py --input-text "Ein Test für alle Stimmen." --test-all-speakers --output-dir ./test_results_all_speakersSpeech speed and pitch control:
python main.py --model-id crispasr_kokoro --input-text "Schneller sprechen." --speech-speed 1.3 --output-file fast.wav
python main.py --model-id crispasr_kokoro --input-text "Höher." --pitch-shift 50 --output-file high.wavSilence trimming and resampling:
python main.py --model-id crispasr_kokoro --input-text "Test." --trim-silence --output-sample-rate 16000 --output-file trimmed_16k.wavVoiceDesign — generate voices from text descriptions:
python main.py --model-id crispasr_qwen3_tts_voicedesign --instruct "A calm elderly man" --input-text "Hallo" --output-file calm.wavStreaming playback (hear audio while it generates):
python main.py --model-id crispasr_kokoro --input-text "Dies wird sofort abgespielt." --streamRun as OpenAI-compatible API server:
python main.py --server --server-port 8880
# Then: curl -X POST http://localhost:8880/v1/audio/speech \
# -H "Content-Type: application/json" \
# -d '{"model":"crispasr_kokoro","input":"Hallo Welt","voice":"af_heart"}' \
# --output speech.wavVoice cloning (with consent attestation):
# CLI
python main.py --model-id coqui_xtts_v2_de_clone --i-have-rights \
--input-text "Hallo" --german-voice-id ref_voice.wav --output-file cloned.wav
# API (include i_have_rights in request body)
curl -X POST http://localhost:8880/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model":"crispasr_f5_tts","input":"Hallo","voice":"ref.wav","i_have_rights":true}' \
--output cloned.wavSSML-lite markup:
python main.py --backend kokoro --output-file out.wav --input-text \
'Hello. <break time="500ms"/> <prosody rate="fast">This part is fast.</prosody> Normal again.'Batch synthesis with parallel jobs:
python main.py --backend kokoro --batch --jobs 4 \
--input-file book.txt --output-dir chapters/Audio normalization:
python main.py --backend kokoro --input-text "Test" --normalize --output-file normalized.wavChange Logging Level (for debugging):
python main.py --model-id edge --input-text "Debug Test." --loglevel DEBUGOverride API URLs (for API-based models like Orpheus LM Studio/Ollama):
python main.py --model-id orpheus_lm_studio --input-text "Hallo API" --lm-studio-api-url http://localhost:5000/v1/completions
python main.py --model-id orpheus_ollama --input-text "Hallo Ollama" --ollama-api-url http://localhost:11223/api/generate --ollama-model-name my-orpheus-ollama-modelRefer to the output of python main.py --list-models for the currently configured models and their notes. The script supports integration with:
- CrispASR native C++ (16 backends: Kokoro, Orpheus, Qwen3-TTS, Chatterbox, VibeVoice, IndexTTS, VoxCPM2, F5-TTS, MeloTTS, Piper, BananaMind, Dots.TTS, CosyVoice3, CSM/Sesame, OmniVoice, MOSS-TTS-Local)
- Microsoft Edge TTS
- Piper TTS
- Orpheus GGUF (via llama-cpp-python)
- Orpheus via LM Studio API
- Orpheus via Ollama API
- OuteTTS (LlamaCPP and Hugging Face ONNX backends)
- SpeechT5 (Hugging Face Transformers)
- FastPitch (NeMo / Hugging Face)
- Coqui TTS (XTTS, VITS, etc.)
- Orpheus "Kartoffel" (Transformers-based)
- LLaSA Hybrid (Experimental MLX + PyTorch)
- mlx-audio (e.g., Bark for Apple Silicon)
- F5-TTS (MLX/PyTorch)
- Zonos (acoustic conditioning)
- Chatterbox/Kartoffelbox (Python)
The modular design makes it easy to add support for new TTS engines:
-
Create a New Handler File: In the
handlers/directory, create a new Python file (e.g.,my_new_tts_handler.py) -
Implement Synthesis Function: Inside this file, write a function that takes the standard arguments:
model_config,text,voice_id_override,model_params_override,output_file_str,play_direct. This function should handle all aspects of using the new TTS engine. -
Update
handlers/__init__.py: Import your new function and add it to theALL_HANDLERSdictionary. -
Update
config.py: Add a new entry toGERMAN_TTS_MODELSfor your new engine.
For all Orpheus-based models (GGUF local, LM Studio API, Ollama API, Kartoffel), this project relies on a user-provided decoder.py file located in the project's root directory. This file must contain a function:
def convert_to_audio(multiframe_tokens: list[int], total_token_count: int) -> bytes | None:
# Your implementation here to convert Orpheus token IDs to raw PCM audio bytes
# (16-bit, 24000 Hz, mono)
# Return audio frame bytes, or None/empty bytes on error.
passIf this file or function is missing, Orpheus models will not produce audible output, and a placeholder will be used.
CrispTTS is a synthesis tool — it does not bundle or redistribute
any voice/model weights. Each model is downloaded at runtime from its
upstream repository into a local cache (Piper voices from
rhasspy/piper-voices,
Coqui models via the TTS library, etc.). You obtain the weights directly
from the source, under that source's terms.
You are responsible for honouring each voice's license for whatever
you produce. Licenses vary per voice and are not uniform across
rhasspy/piper-voices — check the upstream MODEL_CARD (and, where it
only says "See URL", the underlying dataset), because the card fields are
self-reported. Notable cases among the German Piper voices CrispTTS lists:
- thorsten, kerstin — CC0 (public domain).
- eva_k, karlsson, ramona — M-AILABS, BSD-style (commercial OK; retain the copyright notice).
- mls — CC-BY 4.0 (attribution required).
- pavoque — CC BY-NC-SA 4.0 (non-commercial) — do not use the output commercially.
For a redistributable, pre-curated permissive-only GGUF set (the same
voices minus the non-commercial/restricted ones, converted for the
CrispASR/CrisperWeaver native runtime), see
cstr/piper-voices-GGUF.
CrispTTS automatically marks all synthesized audio as AI-generated using a multi-layered provenance system.
Every path that writes an audio file — CLI synthesis, --batch, --test-all and API server responses — marks it through the single watermark.mark_audio_file() entry point, in every supported format (WAV, MP3, FLAC, Opus/OGG). Marking is the last step, after trimming, normalization, resampling and the spoken disclaimer, so nothing downstream can strip it. CrispASR C++ backends watermark at the binary level; all other handlers are watermarked in Python post-synthesis.
Marking follows the file that was written, not the one that was requested. Most handlers force their own container regardless of the extension you ask for — the Edge handler writes .mp3, most local ones .wav — so --output-file out.wav --model-id edge produces out.mp3, and that is the file disclosed, marked, verified and played.
Generation is gated on sufficient marking. Three rules:
- Preflight. Before any model is loaded, CrispTTS checks that the requested output can be marked — supported container, codec dependencies present. If not, synthesis is refused up front, so unmarkable audio is never produced.
- Verified, not assumed. After marking, the watermark is read back, and
the output is delivered only if at least one robust layer is confirmed:
the watermark detected above threshold, or a C2PA manifest signed. Container
metadata alone is never sufficient — any transcode strips it. So an
undetectable watermark is fatal for FLAC and Opus, which cannot carry a
manifest, while a WAV or MP3 may still ship on its manifest alone; the
MarkResultreports which layers actually applied. This catches audio that is silent or otherwise unable to carry a mark, and it applies to CrispASR backends too: their binary-level watermark is verified rather than taken on trust. - Watermark floor.
--no-watermarkis honoured only when another robust layer (a C2PA manifest) still marks the output. When the container cannot carry one, the watermark is forced back on — no path can emit a fully unmarked AI file. This mirrors CrispASR's watertight-CLI guarantee.
Any provenance opt-out (--no-watermark, --allow-unmarked,
--no-spoken-disclaimer) additionally requires --accept-marking-responsibility,
which is recorded as a [MARKING] audit line next to [CONSENT].
| Layer | What | Status | Install |
|---|---|---|---|
| AudioSeal | Neural watermark (Meta, 16-bit message, MIT code and weights). Measured 28.9 dB SNR; survives 64 kbps MP3 and Opus at confidence 1.000 | Auto-detected (preferred) | pip install 'crisptts[robust]' |
| Spread-spectrum | Frequency-domain watermark (32 bins, alpha 0.05 — the active band's default) | Always active | Built-in (numpy) |
| WavMark | Neural watermark (MIT, 16-bit payload, measured 36.3 dB SNR — the quietest option). Slow: read the cost warning below | Auto-detected (fallback) | pip install 'crisptts[watermark-mit]' |
| WAV/MP3/FLAC/Opus metadata | LIST/INFO, ID3v2, Vorbis comments — AI_GENERATED=true |
Always active | Built-in (mutagen, core dep) |
| C2PA credentials | Signed provenance manifests (trainedAlgorithmicMedia) — self-signed unless you supply a certificate |
Always active (WAV/MP3/FLAC/M4A) | Built-in (c2pa-python, core dep) |
| Spoken disclaimer | AI disclosure prepended to voice-cloned audio, in the model's language | Auto for cloning | Built-in |
| Consent gate | Voice-cloning attestation + persistent audit logging | Required for cloning | Built-in |
Watermark backend priority: AudioSeal (Python) > WavMark (MIT) > CrispASR GGUF > spread-spectrum (always-on fallback). Neural backends are lazy-loaded on first synthesis, so --list-models and --help stay fast — measured 0.36 s of CPU, against 0.10 s for starting a bare Python interpreter. (That claim was aspirational until v0.9.7: the __main__ block imported torch unconditionally, so those commands cost 4.3 s of CPU. A test now asserts --help imports no ML framework.)
Both are MIT for code and weights (AudioSeal's weights moved from CC-BY-NC to MIT in April 2024), so the choice is purely operational. Measured here on 10 s of speech at 16 kHz:
| AudioSeal | WavMark | |
|---|---|---|
| Model load | 1.9 s | 6.9 s |
| Embed + detect, 10 s audio | 2.5 s | 12.4 s |
| Embed + detect, 20 s audio | ~3 s | 14.3 s |
| SNR | 28.9 dB | 35.8 dB |
| 64 kbps MP3 | 1.000 | 1.000 |
| Opus round-trip | 1.000 | — |
AudioSeal remains the default: it is still several times faster, and it is the one measured to survive an Opus round-trip, which is the container C2PA cannot sign. WavMark's ~7 dB quieter embed is its real advantage — take it when imperceptibility outranks throughput.
WavMark used to be unusable here (embed ~180 s for 10 s of audio, and a detect that did not return within 10 minutes). Nothing about the model changed; three things about how CrispTTS drives it did:
- Device.
load_wavmark()selected CUDA-or-CPU and never MPS, so every Apple Silicon machine took the slowest device it owned. One forward pass on a 1 s chunk: 16–30 s on CPU at torch's default 4 threads, 5.4 s at 8 threads, 0.54 s on MPS. The loader now prefers CUDA → MPS → CPU, and raises the thread count on the CPU path. - Detection scan.
wavmark.decode_watermarkscans every window position and averages all hits, because it is recovering a payload. CrispTTS only asks "is this marked, with our payload", so it stops at the first batch containing an exact start-bit match: 34.7 s → 9.3 s at 10 s, and 79.3 s → 6.8 s at 20 s. Upstream's cost scales with duration; this does not. - Marks are device-independent: MPS and CPU embeds differ by 2.4e-07, and a file marked on either verifies on the other.
None of this required forking or patching wavmark — only model.encode and
model.decode, its public model API. Note that upstream's encode_watermark
declares min_snr=20, max_snr=38, so 38 dB is the ceiling of its iterative
per-chunk search, not a floor it clears.
Unmarked audio is the worst case for both backends and stays slow (~35 s for 10 s of audio with WavMark): there is no hit to stop on, so the full scan runs.
Confidence values are not comparable across backends. AudioSeal's detector saturates (measured 1.000 watermarked / 0.000 clean); the spread-spectrum detector reads ~0.17 unmarked and ~0.99 marked. Both are gated at 0.65 and each clears it unambiguously, but do not compare a number from one against a number from the other.
The built-in spread-spectrum watermark places a 32-bin comb in FFT bins 64–204
of a 1024-point transform, matching CrispASR's wm_params. The band is defined
in bin indices, not hertz, so the frequency range it occupies scales with
the file's sample rate:
| Output sample rate | Comb occupies | Note |
|---|---|---|
| 16 kHz | ~1.0–3.2 kHz | inside the speech band |
| 24 kHz | ~1.5–4.8 kHz | the design target, and CrispASR's rate |
| 44.1 kHz | ~2.8–8.8 kHz | extends well above the speech band |
| 48 kHz | ~3.0–9.6 kHz | as above — MeloTTS, VoxCPM2, MOSS, Dots.TTS |
Interoperability with CrispASR is unaffected, because both sides address the comb by bin index. Perceptually the placement is not equivalent at every rate, and the "~1.5–4.8 kHz" figure quoted here previously was only ever true at 24 kHz.
The detector was replaced in v0.9.8 and these numbers describe the new one. The old one scored 32 bins by the sign of their excess over neighbouring bins, discarding the size — a coin flip per bin, so its score sat only 1.7 standard deviations above chance at the 0.65 threshold. Measured over a corpus of unmarked audio and marked audio under attack:
| old (sign test) | new | |
|---|---|---|
| False positives at 0.65 | 8.6% | 1.3% |
| True positives at 0.65 | 97.0% | 98.3% |
So the old detector was both flagging genuine recordings as AI-generated and missing 3% of real watermarks. The new one measures how far the comb's excess is consistent across frames, and how far that is specific to our pattern rather than to any pattern (by scoring 15 decoy patterns on the same audio). The embed is unchanged, so files marked by earlier releases and by CrispASR still verify.
Confidence landmarks moved with the statistic — unmarked audio now reads ~0.17 median (it read ~0.44), a healthy mark ~0.99 (it read ~0.84). The threshold is still 0.65.
Fixed in v0.9.12: the stationary-tone false positive. A perfectly stationary synthetic tone used to read as marked (0.88), because every frame is identical so a chance correlation with the comb repeats without end and imitates consistency. The fix is a third condition — the real pattern must out-score the strongest decoy, not merely the decoy median. On such a tone every absent pattern also scores extremely (t 11.44 against a decoy maximum of 19.44), and the real pattern losing to a decoy is the tell. It cost nothing: true positives stayed at 99.4% on the tuning corpus while false positives went to zero there, and 2.5% → 1.3% on the broader one.
Signal-to-noise ratio: 20–25 dB mean, 14–17 dB worst case — not the
"~38 dB" / "~39.5 dB" quoted in earlier revisions of this file. Those figures
came from a single favourable segment. The watermark is low-level and sits
under speech, but on quiet or sparse passages it is not categorically
inaudible, and this README no longer claims that it is. If imperceptibility
matters more to you than the built-in layer's robustness, install a neural
backend: pip install 'crisptts[robust]' brings in AudioSeal at 28.9 dB, and
WavMark reaches 36.3 dB if you can afford it. See Choosing a neural backend
below.
These numbers supersede an earlier table measured on the pre-#260 wideband comb
(0.94 after embed but 0.63 after a resample — below threshold, i.e. the mark
was lost). Moving the comb into the speech band lowered the immediate reading
while making it survive resampling and transcoding.
CRISPASR_WATERMARK_LEGACY=1 restores the old band for A/B against older files;
detection always sweeps both, so previously-marked audio still verifies.
Until v0.9.3 the embed also ran at the legacy band's strength (alpha 0.08)
rather than the speech band's designed 0.05, because watermark_embed() kept
0.08 as a hardcoded signature default when the comb moved. That cost 3–4 dB of
SNR for a confidence gain the threshold never required. Files marked by earlier
versions remain valid and detectable — they are simply louder than intended.
This is why C2PA signing is on by default rather than opt-in: for WAV, MP3, FLAC and M4A output the signed manifest, not the spread-spectrum watermark, is the durable and interoperable provenance layer. The watermark is what survives having the manifest stripped; the manifest is what survives a resample. Neither alone is sufficient for every downstream path, so both are applied.
For Opus/OGG, which C2PA cannot sign, a neural backend is required rather than recommended — see below. Install it also if audio in any container may be transcoded and stripped; it is MIT-licensed and far more robust:
pip install 'crisptts[robust]'CrispTTS logs a warning once per run when the spread-spectrum backend is the only robust layer present. WavMark is an extra rather than a core dependency because it pulls in PyTorch (~2 GB).
Every WAV and MP3 output is signed with a C2PA manifest asserting
digitalSourceType: trainedAlgorithmicMedia — the standard, machine-readable
claim that content is AI-generated — plus a c2pa.training-mining assertion
opting the audio out of AI training.
By default CrispTTS signs with the bundled development certificate in
c2pa_dev_cert.py. Its private key is public, by design: it is in the source
tree and in every wheel. A manifest signed with it proves the file has not been
altered since signing, but it does not attest to who produced the file and
will not validate against C2PA trust lists. CrispTTS never reports such a
signature as trusted — MarkResult.c2pa_signer is "self-signed", and it logs
a warning once per run.
For a credential others can attribute to you, obtain a certificate from a
C2PA-recognised authority and pass --c2pa-cert / --c2pa-key (or set
C2PA_CERT_PATH / C2PA_KEY_PATH); the signer is then reported as
"ca-issued". The certificate must be a chain (leaf followed by its CA)
and the key must be PKCS#8 — c2pa-python rejects a bare self-signed leaf
and a SEC1 key. scripts/make_dev_cert.sh shows a working profile.
WAV, MP3, FLAC and M4A all carry a manifest. FLAC and M4A were excluded
until it turned out that only sign_file() refuses them — c2pa-python's
streaming Builder.sign() signs both, and the result reads back
validation_state: Valid with the trainedAlgorithmicMedia assertion intact.
CrispTTS uses the streaming path for every container, so those two gained a
manifest they were previously denied. A test signs each listed format for
real, so the set cannot drift back into overclaiming.
Opus/OGG cannot. c2pa-rs does not list it among its supported types at
all, and every format string tried (opus, audio/opus, ogg, audio/ogg,
application/ogg) returns NotSupported for embedded signing.
The detached-sidecar route does not rescue it either, and fails in a way worth
recording: Builder.set_no_embed() followed by sign() reports success on
Opus while writing a byte-identical copy of the input — the output begins
OggS and hashes the same as the source. There is no manifest in it. An
earlier revision of this file said a .c2pa sidecar "can be produced"; that
was never verified, and it is false.
So the audio watermark is Opus/OGG's only robust layer, which is why
--no-watermark is overridden for it, and why:
Opus and OGG output requires a neural watermark backend. With only the built-in spread-spectrum comb installed, synthesis to those containers is refused up front — a fixed-key comb as the sole robust layer is not marking that is "robust as far as technically feasible". Install one with
pip install 'crisptts[robust]'— AudioSeal survives an Opus round-trip at confidence 1.000, measured — or choose a manifest-carrying container, or take the duty on yourself with--allow-unmarked --accept-marking-responsibility.
Three signers are tried in order, selectable with
CRISPTTS_C2PA_BACKEND=auto|python|audio|crispasr|off (default auto):
| Order | Backend | Availability |
|---|---|---|
| 1 | c2pa-audio |
Native, fast. Not on PyPI — build from source |
| 2 | c2pa-python |
Always — core dependency, and the only path where CrispTTS controls the manifest |
CrispASR is deliberately not a signing backend. Checked against crispasr
0.8.25: --c2pa-cert / --c2pa-key configure signing of its own synthesis
output and there is no flag that signs an existing file. What it does instead
is better — see Upstream manifests below.
Every native result is verified before it is accepted. c2pa-audio's
sign_wav() takes a certificate and a key but no manifest, so the library
decides its own assertions — and a manifest without trainedAlgorithmicMedia
marks a file as unaltered rather than as AI-generated. CrispTTS therefore
reads the manifest back after any native signer runs; if the AI assertion is
missing, that result is discarded and c2pa-python re-signs with a manifest that
carries it. watermark.manifest_asserts_ai(path) exposes the same check.
CrispASR signs its TTS output during synthesis, by default, with a manifest that
already asserts trainedAlgorithmicMedia. Every marking step rewrites the file,
and any rewrite breaks that manifest's hash — injecting the WAV LIST/INFO
chunk alone takes a CrispASR output from validation_state: Valid to Invalid.
So when a file already carries a manifest asserting AI generation, CrispTTS
leaves it exactly as it is: no metadata injection, no re-signing, reported as
c2pa:preserved. This keeps the upstream signer's identity (softwareAgent: CrispASR TTS) instead of replacing it with ours, and removes the failure mode
where a broken-then-not-repaired manifest made an untampered file look tampered.
CrispTTS also no longer claims the CrispASR watermark as a layer. Measured on
crispasr 0.8.25 kokoro output, CrispTTS's spread-spectrum detector reads 0.44 —
its noise floor — so audio-watermark:upstream is reported only when
verification actually detects a mark.
Voice-cloning models require explicit consent attestation before synthesis is allowed:
- CLI:
--i-have-rightsflag required (synthesis blocked without it) - API:
"i_have_rights": truein request body (returns 403 without it) - Audit log: written to stderr AND
~/.cache/crisptts/consent_audit.log, including a SHA-256 digest of the reference recording.--test-alllogs the attestation too, not just the gate check. See Audit log retention below. - Fails closed: if the gate cannot be evaluated at all (the
watermarkmodule is missing), synthesis is refused rather than allowed through. An unknown cloning status is treated as cloning, not as permission.
Detection, strongest signal first:
- A reference recording as the voice (
.wav,.mp3,.flac,.ogg,.opus,.m4a). Handing the system somebody's voice to imitate is the cloning act itself, so this always gates — even for a model declaredvoice_cloning: false. - The model's explicit
voice_cloningkey inconfig.py. Every shipped model sets it, in both directions; a test asserts this so a new backend cannot be added without answering the question. - Handler key / model-ID keywords, as a fallback for user-supplied model dicts that predate the explicit key. This tier fails open, which is why tier 2 exists.
Whose voice is it? Cloning from a reference recording is not the only route to a deepfake. Art. 3(60) defines one by what the output resembles — "an existing person" — not by how the resemblance was produced, and a single-speaker model finetuned on one identifiable person's recordings produces audio of that person just as much. The voice donor's consent to their recordings being used for training is a licensing question; it is not the audience knowing the audio is synthetic, which is what Art. 50(4) is about.
So every model declaring voice_cloning: false additionally declares
speaker_identity, and a test enforces it:
| Value | Meaning | Spoken disclosure |
|---|---|---|
real_person |
The preset voice is an identifiable individual — a named donor, or a corpus speaker such as VCTK's p225 |
Yes, as for cloning |
synthetic |
A designed or blended voice that is not any one person | No |
unknown |
Provenance not established | No, but warns once per model |
unknown is a question, not a default: guessing "synthetic" would silently
drop a disclosure that may be owed, and guessing "real_person" would prepend a
sentence to every stock TTS voice. Override per run with
--speaker-identity real_person|synthetic|unknown (API: "speaker_identity")
when you know more about a voice than the config does.
The values come from reading each model's own documentation, and the evidence
is recorded as a comment beside every non-obvious entry in config.py. The
seven still marked unknown are the ones whose providers do not say:
| Model(s) | What the provider discloses |
|---|---|
orpheus_lex_au, orpheus_ollama, mlx_audio_orpheus_llama, crispasr_orpheus |
Canopy Labs states 100k+ h of "permissive/non-copyrighted" audio and nothing about the origin of tara, leah, jess, leo, dan, mia, zac, zoe |
edge |
Microsoft's TTS transparency note defines "voice talent" only for custom neural voice; it says nothing about how the prebuilt voices were built, and whoever they were modelled on is not publicly identified |
crispasr_melotts |
MeloTTS documents its architecture lineage but not its speakers |
crispasr_bananamind_tts |
No training-data documentation found |
The real_person entries that are not self-evident from the model name trace
back to identifiable recorded people: Kartoffel-Orpheus's 19 German speakers
were extracted from podcasts and lectures; SauerkrautTTS's Tom and Anna are
original studio recordings (Max and Lena are synthetic, but the model is
classified by what it can speak as); SpeechT5's voice comes from a CMU ARCTIC
x-vector, i.e. one of seven recorded individuals; and the German FastPitch is
trained on HUI-Audio-Corpus-German, whose narrators — Eva, Karlsson and four
others — are the same donors as the Piper voices.
Spoken disclosure — prepended to cloned output and to real_person preset
voices, in all 24 EU official languages plus Chinese, Japanese and Korean
(27 total; --list-disclosure-langs prints them). Art. 50 governs content placed on the EU market, so a disclosure
an EU audience can understand means any EU official language — a German
sentence in front of Greek audio discloses nothing to a Greek listener. The
German and English wording is kept identical to Susurrus's disclosure.spoken
string, so the Crisp projects disclose in the same words.
Which language is used, in order of precedence:
--disclosure-lang(CLI) or"disclosure_lang"(API) — an explicit choice- The model's declared
languageinconfig.py - German, the default — with a warning
Step 3 is a fallback, not a decision. Around half the shipped cloning
backends are multilingual (CosyVoice3, OmniVoice, IndexTTS, Qwen3-TTS,
LLaSA-Multilingual, OuteTTS, Spark, VoxCPM2, MOSS, VibeVoice, F5), and for
those the output language is a property of the input text, not of the model
— so it cannot be derived from the config at all. They declare
"language": "multilingual", which CrispTTS treats as unknown rather than
silently substituting German, and warns that you should pass
--disclosure-lang. A test asserts every cloning model declares a language
key, so a new backend cannot skip the question.
Sources, in order:
- CrispASR kokoro, if the binary is available — local, no network
- Edge TTS, if installed — needs network
- A pre-rendered clip bundled in
crisptts_assets/— no backend, no model download, no network, no configuration
Tier 3 is why disclosure does not fail on an offline machine. It is a real
spoken sentence in the right language, so it counts as a disclosure. All 27
languages ship a clip (~2 MB total in the wheel), so offline disclosure works
in every one of them, not just German. Regenerate with
python scripts/make_disclosure_assets.py after editing DISCLAIMER_TEXTS;
a test fails if any language lacks a bundled clip.
Only if all three fail does CrispTTS fall back to a tone marker, which is
refused: three beeps are an audible signal, not a disclosure a listener can
understand. In that case the output is discarded rather than delivered, and
--no-spoken-disclaimer --accept-marking-responsibility is the way to take the
Art. 50(4) duty on explicitly.
Article 50 of Regulation (EU) 2024/1689 sits in Chapter IV and has applied since 2 August 2026 under Art. 113 — it is in force now, not upcoming. Article 4 (AI literacy, Chapter I) has applied since 2 February 2025. Releasing under an open-source licence does not exempt Article 50 — Art. 2(12) expressly carves it back in. Check the current consolidated text before relying on these dates; the Regulation has been subject to amendment proposals since adoption.
What CrispTTS does for you (provider-side, Art. 50(2)):
- Marks every synthetic audio output in a machine-readable format
- Signs WAV/MP3/FLAC/M4A output with a C2PA manifest by default, so the
trainedAlgorithmicMediaclaim is readable by any C2PA verifier and not only by Crisp tools. Measured, such a file reads backvalidation_state: Validwith the AI assertion intact; the manifest is self-signed, so a verifier additionally reportssigningCredential.untrusted— see Certificate trust, which is about attribution rather than about marking - Fails closed rather than emitting unmarked audio
- Reports honestly what was applied (
MarkResult, serverX-CrispTTS-*headers), and never presents a bundled-certificate signature as trusted - Gates voice cloning behind an attestation and logs it with a digest of the reference recording — and refuses to synthesize at all if that gate cannot be evaluated
- Never plays audio to a listener before it has been marked and verified
- Prepends a spoken AI disclosure to voice-cloned output — and to models whose preset voice is an identifiable person — in any of the 24 EU official languages, and refuses to deliver such audio without one
What remains your responsibility as the deployer (Art. 50(4)):
- Disclosing that content is artificially generated where you publish it — a watermark is machine-readable, not a disclosure to the audience
- Choosing the disclosure language. CrispTTS defaults to the model's
declared language and warns when it cannot determine one, but only you know
what language your audience speaks. Pass
--disclosure-langwhen using a multilingual model — a disclosure the audience cannot understand does not discharge the Art. 50(4) duty - Obtaining genuine consent from anyone whose voice you clone. The
--i-have-rightsflag is an unverified self-attestation; it records your claim, it does not establish a legal basis - Checking the voice/model licences you use (see the licensing section above)
- GDPR: a cloned voice is personal data, and the consent audit log contains reference-audio paths — see Audit log retention below
- Art. 50(5) accessibility. The spoken disclosure is audio, so it does not reach a deaf or hard-of-hearing audience. Art. 50(5) requires the disclosure to conform to the applicable accessibility requirements, so wherever you publish with captions, subtitles or a transcript, the disclosure sentence must be carried into them — it is the first thing in the audio, so a verbatim transcript already contains it. Do not strip it
- Answering
speaker_identityfor models CrispTTS records asunknown. The warning names the model; if its preset voice is an identifiable person, pass--speaker-identity real_person - Art. 4 (AI literacy): ensuring the people operating this tool understand what it does and what its output is. In practice, for CrispTTS, that means whoever runs it should have read this section
Not applicable, having been checked: CrispTTS performs no biometric
categorisation and no emotion recognition (Kartoffelbox's emotion control is
emotion synthesis), so Art. 5 prohibited practices do not engage; it is not
an Annex III high-risk system; and single-purpose TTS models are not
general-purpose AI models, so Chapter V obligations do not attach to the
models converted by convert_f5_to_mlx.py. Art. 50(1) (informing a person
that they are interacting with an AI system) does not attach either: CrispTTS
synthesizes audio on request and holds no conversation with anyone — if you
embed it in something that does talk to people, that system is the one Art.
50(1) binds, and the duty is yours.
C2PA signing is a core dependency precisely because the audio watermark is
readable only by Crisp tooling, and Art. 50(2) asks for marking that is
interoperable. But the default signer is the bundled development
certificate in c2pa_dev_cert.py. Any C2PA verifier will parse the manifest
and read the trainedAlgorithmicMedia assertion; it will also report the
signer as untrusted, because that certificate chains to nothing on the C2PA
known-certificate trust list.
So the layer is interoperable in format and not in trust. CrispTTS does not
paper over this — it warns once per run and MarkResult.c2pa_signer
distinguishes self-signed from ca-issued.
What that does and does not cost you. Earlier revisions of this file called
the self-signed certificate the largest remaining Art. 50(2) gap and told you
to go and buy one. That was wrong, and measuring it says so. Reading a
default-signed CrispTTS file back through c2pa-python:
validation_state : Valid
success : claimSignature.validated, claimSignature.insideValidity,
assertion.hashedURI.match (x3), assertion.dataHash.match
failure : signingCredential.untrusted
action : c2pa.created
digitalSourceType: ...#trainedAlgorithmicMedia
The manifest validates, the signature verifies, the hashes match, and the AI-generation assertion is read out in full by any C2PA tool. The single failure is about the signer, not the marking.
Art. 50(2) requires outputs to be "marked in a machine-readable format and detectable as artificially generated". It does not require the mark to prove who generated it. That is attribution, and it is a different property — a valuable one, but not the one the article asks for. On the evidence above, the default configuration satisfies the marking duty.
Supply your own credential when you want attribution — a claim others can tie back to you, which matters for a publisher, a newsroom, or anyone whose provenance chain has to survive being contested:
crisptts --c2pa-cert /path/to/chain.pem --c2pa-key /path/to/key.pem ...
# or: C2PA_CERT_PATH=... C2PA_KEY_PATH=... crisptts ...CrispASR ships a self-signed certificate too (assets/c2pa/, CN literally
"CrispASR (AI-generated, self-signed)"), so this is the ecosystem's shared
position rather than a CrispTTS shortcut.
The Commission facilitates a voluntary Code of Practice under Art. 50(7); its Section 1 covers provider-side marking and detection. Adherence is not mandatory, and CrispTTS is not a signatory, but signing is the route the Commission designates for demonstrating Art. 50(2) compliance "in a legally certain and predictable manner", so a deployer who needs that certainty should know where this tool stands against it.
The Code declines to mandate a single technique, on the express ground that no one technique satisfies all four of Art. 50(2)'s criteria — effective, interoperable, robust, reliable — and instead asks for layered solutions combining watermarking, metadata and content provenance. CrispTTS's three layers map onto that structure directly:
| Code expectation | CrispTTS | Gap |
|---|---|---|
| Layered rather than single-technique marking | Watermark + container metadata + C2PA manifest | none |
| Marking applied to all generated output | One mark_audio_file() path; fails closed |
none |
| Provenance metadata standard, not proprietary | C2PA trainedAlgorithmicMedia |
signer untrusted by default (above) |
| Robust to common transformations | Survives resample and 64 kbps MP3 (table above) | built-in layer is Crisp-readable only; neural backend is an extra |
| Detection tooling available to third parties | --detect-watermark FILE, and the C2PA manifest reads in any C2PA tool |
watermark detection needs Crisp tooling |
The honest summary: the architecture is what the Code asks for, and the residual gap is about third parties being able to read the mark — a spread-spectrum watermark no one else implements. (The untrusted certificate was listed here as the second gap until v0.9.11, when measuring it showed the manifest validates and carries its AI assertion regardless; see Certificate trust above.)
Deliberately not signing. Adherence would commit this project to a fixed description of how it marks content, and the marking here is still moving — the detector was replaced twice in one week, the preferred neural backend changed, and the certificate question turned out to have been misread for five releases. Signing up to a public commitment while the implementation is changing underneath it produces a claim that goes stale, which is the failure mode this repository has spent most of its recent history correcting.
The Code stays a design target rather than a signature: the table above is the useful part, and it is cheaper to keep honest. Revisit when the marking layers stop changing.
Recorded because it has been assumed rather than analysed in every prior audit. Art. 50(2) binds providers. Under Art. 3(9)–(10) an obligation arises on placing a system on the Union market, and "making available" is defined as supply for distribution or use in the course of a commercial activity. A non-commercial FOSS project published on a code-hosting site has a real argument that it never crosses that line, and that the obligations attach instead to whoever deploys it commercially.
CrispTTS deliberately assumes the stricter reading — that it is a provider and Art. 50(2) applies — and implements accordingly. That is a defensive posture, not a legal conclusion, and it is the maintainer's own view rather than advice. If you are redistributing CrispTTS commercially, or running it as part of a service, the analysis is yours to make and you are much more clearly a provider than the upstream project is.
Timing. Art. 50 has applied since 2 August 2026. Systems already on the market before that date have until 2 December 2026 to meet the Art. 50(2) machine-readable marking requirement; CrispTTS predates the cutoff, so that grace period covers it. This is not a reason to defer — the marking is implemented — but it is the operative deadline.
[CONSENT] and [MARKING] lines are written to
~/.cache/crisptts/consent_audit.log. That file records reference-audio
paths, which routinely contain personal names, so it is personal data:
-
Created
0600, owner-only, rather than at the umask default -
Entries older than 730 days are pruned automatically on every append (GDPR Art. 5(1)(e) storage limitation). Set
CRISPTTS_CONSENT_LOG_RETENTION_DAYSto change the window,0to disable -
--consent-log-pruneprunes on demand -
--consent-log-erase [SUBJECT]handles an Art. 17 erasure request: with a reference-audio path orref_sha256digest it removes only that speaker's lines; with no argument it erases the whole log -
Hash-chained and anchored. Each line carries the SHA-256 of its predecessor, and the entry count plus head hash are mirrored into a sibling
.anchorfile.--consent-log-verifychecks both. The log's whole purpose is to evidence that somebody attested a right to clone a voice, tied to a digest of the exact recording — a text file anyone can silently edit is weak evidence of that. The chain catches edits and deletions; the anchor catches truncation of the tail, which a chain cannot see on its own.This is tamper-evidence, not tamper-proofing: whoever can write the file can rebuild the chain. Ship the log somewhere append-only if you need more.
Retention pruning and Art. 17 erasure both have to remove entries, which is exactly what the chain exists to detect. They are not exempted — they are recorded: the survivors are re-chained and a
[CHAIN-REBUILT]line notes the reason and how many entries went. An unexplained gap is tampering; a gap with a rebuild record beside it is a documented erasure. The rebuild record deliberately does not name the subject, since it has to outlive the erasure it documents.
Lines with no parseable timestamp are kept — an unreadable record is not evidence that it has expired.
C2PA manifests signed with the bundled development certificate prove the file
is unaltered since signing, but will not validate against C2PA trust lists.
Supply --c2pa-cert / --c2pa-key for a credential others can verify.
This is a summary of how the implementation is intended to map onto the regulation, not legal advice.
| Feature | CrispTTS | CrispASR | CrisperWeaver |
|---|---|---|---|
| Spread-spectrum watermark | numpy (Python) | C++ header-only | Dart LSB + native FFI |
| WavMark neural watermark (MIT) | Python (wavmark) | — | — |
| AudioSeal neural watermark | Python + crispasr GGUF | C++ ggml (GGUF) | via CrispASR FFI |
| WAV LIST/INFO metadata | ISFT + ICMT | ISFT + ICMT | ISFT + ICMT + IART + ICRD |
| MP3 ID3v2 tags | TXXX (AI_GENERATED) | TXXX (AI_GENERATED) | TXXX (AI_GENERATED) |
| FLAC/Opus metadata | Vorbis comments (mutagen) | — | — |
| C2PA content credentials | c2pa-python by default; c2pa-audio / CrispASR as fast paths, each verified | c2pa-c (compile-time) | — |
| Spoken AI disclaimer | CrispASR kokoro / Edge TTS / bundled clips, 27 languages (all 24 EU official); refuses if unavailable | Native TTS (cached) | Beep marker |
| Voice-cloning consent gate | CLI + API (403) | CLI + server JSON | GDPR Art. 9(2)(a) consent files |
| Consent audit logging | stderr + consent_audit.log |
[CONSENT] stderr |
[CONSENT] log + .consent.json |
| Post-embed verification | detect after save | detect after save | detect after embed |
| Watermark detection CLI | --detect-watermark |
--detect-watermark |
detect in service |
| Cross-project detection | Yes (shared PRNG key) | Yes (shared PRNG key) | Yes (via CrispASR FFI) |
The layers above answer what is applied. This answers what happens when it doesn't work — which is where the projects genuinely differ:
Re-checked against the sibling repositories on 2026-08-03. The previous version of this table was stale and understated Susurrus on four rows — it had since gained fail-closed discard, non-WAV marking, a declarative floor and an attestation-gated opt-out. A comparison of other people's projects goes out of date silently and unfairly; re-read the code before trusting this.
| Enforcement | CrispTTS | CrispASR | Susurrus | CrisperWeaver |
|---|---|---|---|---|
| Refuse before generating if unmarkable | Yes | — | — | — |
| Marking verified after embedding | Yes (gates) | — | — | — |
| Unmarkable output discarded | Yes | — | Yes | No |
| Watermark floor (opt-out only if C2PA carries it) | Yes | Yes | Yes (declarative) | No |
| Opt-out requires marking attestation | Yes | Yes | Yes | No |
| Non-WAV outputs marked | Yes | Yes | Yes (mp3/flac/m4a/opus) | — |
| Silent no-op on short audio | Refused | — | — | Yes (<4608 samples) |
| Playback marked+verified before it is heard | Yes | — | — | — |
| Consent gate fails closed if unevaluable | Yes | — | — | — |
| Disclosure in all 24 EU official languages | Yes | — | 2 locales | — |
| Audit-log retention limit + erasure command | Yes | — | — | — |
| Hash-chained, anchored audit log | Yes | — | Yes (biometric events) | — |
| Detector reports its own statistical power | p-band per backend | p-value + 3-way verdict | backend named | — |
Where each idea came from, since none of this is one project's work: CrispASR contributed the watermark floor and the attestation gate, and independently found the detector's coin-flip null that Phase 28 fixed here — answering it with binomial p-values where CrispTTS replaced the statistic. Susurrus contributed the hash-chained, anchored audit log adopted above, and had the "fall back to spread-spectrum when the neural detector says no" rule right before CrispTTS did.
The gap CrispTTS still has alone: preflight refusal and verification-as-gate. That cuts both ways — because marking failure here deletes the output, a detector error is not a diagnostic inconvenience the way it is in CrispASR, where "embedding is unconditional and the watertight floor does not consult the detector".
# Default: spread-spectrum watermark + metadata (no extra deps)
python main.py --model-id edge --input-text "Hallo" --output-file out.mp3
# With the AudioSeal neural watermark (MIT, preferred — survives Opus and MP3)
pip install 'crisptts[robust]'
python main.py --model-id edge --input-text "Hallo" --output-file out.mp3
# With C2PA content credentials
pip install c2pa-python
python main.py --c2pa-cert cert.pem --c2pa-key key.pem --model-id edge --input-text "Hallo" --output-file out.mp3
# Voice-cloning models require consent attestation (spoken disclaimer auto-prepended)
python main.py --model-id coqui_xtts_v2_de_clone --i-have-rights --input-text "Hallo" --output-file out.wav
# Multilingual cloning model: say which language the disclosure should be in,
# because the model config cannot know what language your text is
python main.py --model-id crispasr_cosyvoice3_tts --i-have-rights \
--disclosure-lang zh --input-text "你好" --output-file out.wav
# What languages can the spoken disclosure be in?
python main.py --list-disclosure-langs
# Detect watermark in existing audio
python main.py --detect-watermark out.wav
# GDPR housekeeping on the consent audit log
python main.py --consent-log-prune # drop entries past retention
python main.py --consent-log-erase /refs/alice.wav # Art. 17, one speaker
python main.py --consent-log-erase # Art. 17, everything
# Disable ALL marking layers (debug only — you take on the Art. 50 responsibility)
python main.py --no-watermark --model-id edge --input-text "Hallo" --output-file out.mp3
# Keep output even if marking fails (default is to discard it and exit non-zero)
python main.py --allow-unmarked --model-id edge --input-text "Hallo" --output-file out.mp3from watermark import watermark_detect
import soundfile as sf
pcm, sr = sf.read("out.wav", dtype="float32")
confidence = watermark_detect(pcm, sample_rate=sr)
print(f"Watermark confidence: {confidence:.3f}") # >0.65 = AI-generatedThe spread-spectrum watermark uses the same PRNG seed (0x437269737041535F), FFT parameters, and bin selection as CrispASR's C++ implementation and CrisperWeaver's native FFI path. Audio watermarked by any project in the ecosystem can be detected by the others.
CrispTTS includes an OpenAI-compatible HTTP server for integration with applications that use the OpenAI TTS SDK.
# Start the server
python main.py --server --server-port 8880
# Or run directly
python server.py --host 0.0.0.0 --port 8880| Method | Path | Description |
|---|---|---|
| POST | /v1/audio/speech |
Synthesize audio (OpenAI-compatible) |
| GET | /v1/audio/models |
List available models and voices |
| GET | /health |
Health check |
{
"model": "crispasr_kokoro",
"input": "Hallo, wie geht es Ihnen?",
"voice": "af_heart",
"response_format": "wav",
"speed": 1.0,
"i_have_rights": false
}The i_have_rights field is required (and must be true) for voice-cloning models. Omit it or set to false for non-cloning models.
Response: audio bytes with Content-Type and Content-Disposition: attachment headers. Features:
- All output marked, in every response format. Provenance headers report what
was actually applied —
X-CrispTTS-Watermarkedistrueonly when marking really happened, alongsideX-CrispTTS-Watermark-Backend,X-CrispTTS-Watermark-ConfidenceandX-CrispTTS-Provenance-Layers - A response that cannot be marked is a
500, never unmarked audio - Voice-cloning models return 403 unless
i_have_rightsis set. The consent gate runs before the cache lookup, so cached audio cannot bypass it - Concurrent requests handled via threaded server
- Rate limiting: 10 requests/minute/IP (configurable via
--rate-limit) - Synthesis caching: identical requests served from cache (
X-CrispTTS-Cache: hit). Cache keys include the marking mode, so unmarked audio can never be served to a marking-enabled request - Enhanced
/health: reports loaded handlers, memory RSS, registered backends
espeak-ng for Kokoro: The Kokoro backend requires espeak-ng for phonemization. Install via:
pip install py-espeak-ng # installs espeak-ng CLI to ~/.local/bin
# or system-wide: apt install espeak-ngCrispASR voice paths: The CrispASR binary auto-downloads models but voice packs need full paths for older binary versions. Use the cached path directly:
python main.py --model-id crispasr_kokoro \
--german-voice-id ~/.cache/crispasr/kokoro-voice-af_heart.gguf \
--input-text "Test" --output-file out.wavMissing Libraries: If a specific TTS engine fails, ensure you have installed all its required libraries via pip install -r requirements.txt and any extra steps mentioned in their documentation.
mlx-audio Bark Specifics:
- This handler currently requires the main MLX model to be from a repository like
mlx-community/bark-small(which should provide MLX-compatible.safetensorsor model files) - The voice prompts (speaker embeddings) are fetched from
suno/bark-smallby default (due to an included monkey patch inmlx_audio_handler.py) which has a comprehensive set of speaker prompts as separate.npyfiles. This dual-source setup is necessary becausemlx-community/bark-smallhas limited voice prompt files in the required format - If mlx-audio's
load_modelfunction reports "No safetensors found" for the mainmlx_model_path, you may need to convert the target Bark model to MLX format usingpython -m mlx_audio.tts.convertand pointmlx_model_pathto the local converted directory. The voice prompt patch in the handler is designed to work with either an HF repo ID or a local path formlx_model_pathwhen determining how to fetch/locate the.npyprompts fromsuno/bark-smallor aspeaker_embeddingssubfolder
API Keys/Servers: API-based models require the respective servers (LM Studio, Ollama) to be running and accessible.
Model Downloads: First-time use of a model that needs to be downloaded from Hugging Face Hub might take some time. Ensure you have an internet connection. Set HF_TOKEN for gated models.
Verbose Output: Use --loglevel DEBUG for detailed diagnostic information if you encounter issues.
RAM Usage: Local GGUF and large Transformer models can be memory-intensive. Ensure your system has sufficient RAM.
Paths: When providing paths for --input-file, --output-file, or speaker WAV files (--german-voice-id), use appropriate relative or absolute paths.