diff options
| author | soryu <soryu@soryu.co> | 2026-01-28 03:47:31 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-28 03:47:31 +0000 |
| commit | c3de071511de5e8a8d63ea4ca47c815cb6450215 (patch) | |
| tree | d843045e3b9dd1228e9ce5771abe4efbc574fbf3 | |
| parent | 20be7ba19964a6eba60079c17399cf8ff6e60cea (diff) | |
| download | soryu-c3de071511de5e8a8d63ea4ca47c815cb6450215.tar.gz soryu-c3de071511de5e8a8d63ea4ca47c815cb6450215.zip | |
feat: add voice loading and selection for TTS cloning
Add voice reference audio loading so the TTS speak handler can perform
voice cloning using reference WAV files from the voices/ directory.
- Add voice.rs module: loads manifest.json and reference.wav for a given
voice_id, decodes via symphonia, resamples to 24kHz for the TTS engine
- Update speak.rs: resolve voice_id from the speak request (default
"makima"), load reference audio, pass it to engine.generate()
- Add voices/makima/README.md with instructions for obtaining reference
audio (extraction from YouTube, recording, ffmpeg conversion)
- Graceful fallback: if reference audio is missing, TTS proceeds without
voice cloning using the model's default voice
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/speak.rs | 77 | ||||
| -rw-r--r-- | makima/src/server/handlers/voice.rs | 252 | ||||
| -rw-r--r-- | voices/makima/README.md | 105 |
4 files changed, 428 insertions, 7 deletions
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 8207399..8af2a37 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -19,6 +19,7 @@ pub mod mesh_ws; pub mod repository_history; pub mod speak; pub mod templates; +pub mod voice; pub mod transcript_analysis; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/speak.rs b/makima/src/server/handlers/speak.rs index 75e7780..3ed2620 100644 --- a/makima/src/server/handlers/speak.rs +++ b/makima/src/server/handlers/speak.rs @@ -15,6 +15,9 @@ //! See `makima/src/tts/` for the TTS engine implementation. //! See `docs/specs/qwen3-tts-spec.md` for the full protocol specification. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + use axum::{ extract::{ws::Message, ws::WebSocket, State, WebSocketUpgrade}, response::Response, @@ -32,9 +35,9 @@ enum ClientMessage { /// Request speech synthesis for the given text. Speak { text: String, - /// Optional voice ID (e.g., "makima"). Not yet used — reserved for future voice selection. + /// Optional voice ID (e.g., "makima"). Used to load reference audio for voice cloning. + /// Defaults to "makima" if not specified. #[serde(default)] - #[allow(dead_code)] voice: Option<String>, }, /// Cancel any in-progress synthesis. @@ -76,6 +79,10 @@ async fn handle_speak_socket(socket: WebSocket, state: SharedState) { let (mut sender, mut receiver) = socket.split(); + // Cancellation flag shared between the message loop and inference. + // Each new Speak request resets it to false; Cancel sets it to true. + let cancel_flag: Arc<AtomicBool> = Arc::new(AtomicBool::new(false)); + // Process incoming messages while let Some(msg) = receiver.next().await { let msg = match msg { @@ -102,13 +109,41 @@ async fn handle_speak_socket(socket: WebSocket, state: SharedState) { }; match client_msg { - ClientMessage::Speak { text, .. } => { + ClientMessage::Speak { text, voice } => { + let voice_id = voice + .as_deref() + .unwrap_or(super::voice::DEFAULT_VOICE_ID); + tracing::info!( session_id = %session_id, text_len = text.len(), + voice_id = %voice_id, "TTS speak request" ); + // Load voice reference audio for cloning + let voice_ref = match super::voice::load_reference_audio(voice_id) { + Ok(v) => { + tracing::debug!( + session_id = %session_id, + voice_id = %voice_id, + voice_name = %v.manifest.name, + samples = v.samples.len(), + "Voice reference loaded" + ); + Some(v) + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + voice_id = %voice_id, + error = %e, + "Failed to load voice reference, proceeding without cloning" + ); + None + } + }; + // Get or lazily load the TTS engine let engine = match state.get_tts_engine().await { Ok(e) => e, @@ -138,9 +173,21 @@ async fn handle_speak_socket(socket: WebSocket, state: SharedState) { continue; } - // Run TTS inference (no voice reference for now — uses default) - match engine.generate(&text, None, None).await { + // Reset the cancel flag for this new generation request + cancel_flag.store(false, Ordering::Relaxed); + + // Run TTS inference with optional voice reference for cloning + // and the cancel flag so it can be stopped early. + let (ref_audio, ref_rate) = match &voice_ref { + Some(v) => (Some(v.samples.as_slice()), Some(v.sample_rate)), + None => (None, None), + }; + let flag = cancel_flag.clone(); + match engine.generate(&text, ref_audio, ref_rate, Some(flag)).await { Ok(chunks) => { + // Check if generation was cancelled + let was_cancelled = cancel_flag.load(Ordering::Relaxed); + for chunk in &chunks { // Send binary PCM audio data let pcm_bytes = chunk.to_pcm16_bytes(); @@ -157,12 +204,13 @@ async fn handle_speak_socket(socket: WebSocket, state: SharedState) { } } - // Signal end of audio + // Signal end of audio (include cancelled status) let end_msg = serde_json::json!({ "type": "audio_end", "sample_rate": engine.sample_rate(), "format": "pcm_s16le", "channels": 1, + "cancelled": was_cancelled, }); let _ = sender .send(Message::Text(end_msg.to_string().into())) @@ -185,16 +233,18 @@ async fn handle_speak_socket(socket: WebSocket, state: SharedState) { } ClientMessage::Cancel => { tracing::info!(session_id = %session_id, "TTS cancel requested"); - // TODO: support cancellation of in-progress inference + cancel_flag.store(true, Ordering::Relaxed); } ClientMessage::Stop => { tracing::info!(session_id = %session_id, "TTS stop requested, closing"); + cancel_flag.store(true, Ordering::Relaxed); break; } } } Message::Close(_) => { tracing::info!(session_id = %session_id, "TTS WebSocket closed by client"); + cancel_flag.store(true, Ordering::Relaxed); break; } _ => { @@ -271,4 +321,17 @@ mod tests { let msg: ClientMessage = serde_json::from_str(json).unwrap(); assert!(matches!(msg, ClientMessage::Stop)); } + + #[test] + fn test_client_message_parse_speak_with_voice() { + let json = r#"{"type": "speak", "text": "Hello", "voice": "makima"}"#; + let msg: ClientMessage = serde_json::from_str(json).unwrap(); + match msg { + ClientMessage::Speak { text, voice } => { + assert_eq!(text, "Hello"); + assert_eq!(voice.as_deref(), Some("makima")); + } + _ => panic!("Expected Speak message"), + } + } } diff --git a/makima/src/server/handlers/voice.rs b/makima/src/server/handlers/voice.rs new file mode 100644 index 0000000..91b650d --- /dev/null +++ b/makima/src/server/handlers/voice.rs @@ -0,0 +1,252 @@ +//! Voice loading utilities for TTS voice cloning. +//! +//! Loads voice manifests and reference audio from the `voices/` directory. +//! Each voice is a directory containing: +//! - `manifest.json` — voice metadata (name, sample rate, backend, etc.) +//! - `reference.wav` — reference audio clip for voice cloning (5-15s, 24kHz mono) + +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +use crate::tts::{resample_to_24k, SAMPLE_RATE}; + +/// Default voice ID used when no voice is specified. +pub const DEFAULT_VOICE_ID: &str = "makima"; + +/// Voice manifest loaded from `voices/{voice_id}/manifest.json`. +#[derive(Debug, Clone, Deserialize)] +pub struct VoiceManifest { + pub name: String, + pub id: String, + #[serde(default)] + pub description: Option<String>, + #[serde(default = "default_language")] + pub language: String, + #[serde(default)] + pub accent: Option<String>, + #[serde(default = "default_sample_rate")] + pub sample_rate: u32, + #[serde(default)] + pub format: Option<String>, + #[serde(default)] + pub model_backend: Option<String>, + #[serde(default = "default_reference_audio")] + pub reference_audio: String, + #[serde(default)] + pub notes: Option<String>, +} + +fn default_language() -> String { + "en".to_string() +} + +fn default_sample_rate() -> u32 { + 24_000 +} + +fn default_reference_audio() -> String { + "reference.wav".to_string() +} + +/// Loaded voice reference: manifest + decoded PCM samples at 24kHz. +#[derive(Debug, Clone)] +pub struct VoiceReference { + pub manifest: VoiceManifest, + /// PCM f32 samples resampled to 24kHz mono. + pub samples: Vec<f32>, + /// Always 24000 after resampling. + pub sample_rate: u32, +} + +/// Resolve the base directory for voice data. +/// +/// Looks for the `voices/` directory relative to the current working directory, +/// or falls back to the executable's directory. +fn voices_base_dir() -> PathBuf { + // Try current working directory first + let cwd = std::env::current_dir().unwrap_or_default(); + let cwd_voices = cwd.join("voices"); + if cwd_voices.is_dir() { + return cwd_voices; + } + + // Try relative to executable + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + let exe_voices = exe_dir.join("voices"); + if exe_voices.is_dir() { + return exe_voices; + } + // Try one level up (common in target/debug layout) + if let Some(parent) = exe_dir.parent() { + let parent_voices = parent.join("voices"); + if parent_voices.is_dir() { + return parent_voices; + } + // Two levels up (target/debug -> project root) + if let Some(grandparent) = parent.parent() { + let gp_voices = grandparent.join("voices"); + if gp_voices.is_dir() { + return gp_voices; + } + } + } + } + } + + // Default: assume cwd/voices + cwd_voices +} + +/// Load a voice manifest from `voices/{voice_id}/manifest.json`. +pub fn load_manifest(voice_id: &str) -> Result<VoiceManifest, VoiceLoadError> { + let base = voices_base_dir(); + let manifest_path = base.join(voice_id).join("manifest.json"); + + if !manifest_path.exists() { + return Err(VoiceLoadError::NotFound(voice_id.to_string())); + } + + let data = std::fs::read_to_string(&manifest_path).map_err(|e| { + VoiceLoadError::Io(format!( + "failed to read manifest at {}: {e}", + manifest_path.display() + )) + })?; + + let manifest: VoiceManifest = serde_json::from_str(&data).map_err(|e| { + VoiceLoadError::InvalidManifest(format!("failed to parse manifest: {e}")) + })?; + + Ok(manifest) +} + +/// Load a voice's reference audio as f32 PCM samples resampled to 24kHz. +/// +/// Uses symphonia (via `crate::audio`) to decode the WAV file, then +/// resamples to 24kHz using `tts::resample_to_24k`. +pub fn load_reference_audio(voice_id: &str) -> Result<VoiceReference, VoiceLoadError> { + let manifest = load_manifest(voice_id)?; + + let base = voices_base_dir(); + let audio_path = base.join(voice_id).join(&manifest.reference_audio); + + if !audio_path.exists() { + return Err(VoiceLoadError::MissingAudio(format!( + "reference audio not found at {}. See voices/{}/README.md for instructions.", + audio_path.display(), + voice_id, + ))); + } + + load_reference_audio_from_path(&audio_path, manifest) +} + +/// Load reference audio from a specific file path with a pre-loaded manifest. +fn load_reference_audio_from_path( + audio_path: &Path, + manifest: VoiceManifest, +) -> Result<VoiceReference, VoiceLoadError> { + // Use symphonia-based decoder from crate::audio to decode the WAV + let pcm = crate::audio::to_16k_mono_from_path(audio_path).map_err(|e| { + VoiceLoadError::AudioDecode(format!("failed to decode {}: {e}", audio_path.display())) + })?; + + // The audio module decodes to 16kHz mono; we need 24kHz for TTS. + // Resample from 16kHz to 24kHz. + let samples = if pcm.sample_rate == SAMPLE_RATE { + pcm.samples + } else { + resample_to_24k(&pcm.samples, pcm.sample_rate) + }; + + tracing::info!( + voice_id = %manifest.id, + voice_name = %manifest.name, + samples_len = samples.len(), + duration_secs = samples.len() as f32 / SAMPLE_RATE as f32, + "Loaded voice reference audio" + ); + + Ok(VoiceReference { + manifest, + samples, + sample_rate: SAMPLE_RATE, + }) +} + +/// Errors that can occur when loading a voice. +#[derive(Debug)] +pub enum VoiceLoadError { + /// Voice directory not found. + NotFound(String), + /// IO error reading files. + Io(String), + /// Manifest JSON is invalid. + InvalidManifest(String), + /// Reference audio file is missing. + MissingAudio(String), + /// Failed to decode audio. + AudioDecode(String), +} + +impl std::fmt::Display for VoiceLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VoiceLoadError::NotFound(id) => { + write!(f, "voice '{id}' not found (no voices/{id}/manifest.json)") + } + VoiceLoadError::Io(msg) => write!(f, "voice IO error: {msg}"), + VoiceLoadError::InvalidManifest(msg) => write!(f, "invalid voice manifest: {msg}"), + VoiceLoadError::MissingAudio(msg) => write!(f, "missing reference audio: {msg}"), + VoiceLoadError::AudioDecode(msg) => write!(f, "audio decode error: {msg}"), + } + } +} + +impl std::error::Error for VoiceLoadError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_voice_id() { + assert_eq!(DEFAULT_VOICE_ID, "makima"); + } + + #[test] + fn test_manifest_deserialize() { + let json = r#"{ + "name": "Test Voice", + "id": "test", + "sample_rate": 24000, + "reference_audio": "reference.wav" + }"#; + let manifest: VoiceManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.name, "Test Voice"); + assert_eq!(manifest.id, "test"); + assert_eq!(manifest.sample_rate, 24000); + assert_eq!(manifest.reference_audio, "reference.wav"); + assert_eq!(manifest.language, "en"); + } + + #[test] + fn test_manifest_deserialize_defaults() { + let json = r#"{"name": "Minimal", "id": "min"}"#; + let manifest: VoiceManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.language, "en"); + assert_eq!(manifest.sample_rate, 24000); + assert_eq!(manifest.reference_audio, "reference.wav"); + } + + #[test] + fn test_load_nonexistent_voice() { + let result = load_manifest("nonexistent_voice_xyz"); + assert!(result.is_err()); + match result.unwrap_err() { + VoiceLoadError::NotFound(id) => assert_eq!(id, "nonexistent_voice_xyz"), + other => panic!("Expected NotFound, got: {other}"), + } + } +} diff --git a/voices/makima/README.md b/voices/makima/README.md new file mode 100644 index 0000000..8553daf --- /dev/null +++ b/voices/makima/README.md @@ -0,0 +1,105 @@ +# Makima Voice Reference Audio + +This directory contains the voice profile for **Makima** — the default TTS voice used by the makima system for voice cloning. + +## What You Need + +A **reference audio clip** (`reference.wav`) of Makima's Japanese voice actress (Tomori Kusunoki) speaking English. + +### Requirements + +| Property | Value | +|----------------|------------------------------------------| +| **Filename** | `reference.wav` | +| **Duration** | 5–15 seconds (10s ideal) | +| **Format** | WAV (PCM) | +| **Sample Rate**| 24 kHz (will be resampled if different) | +| **Channels** | Mono (1 channel) | +| **Bit Depth** | 16-bit or 32-bit float | + +### Why These Parameters? + +- **5–15 seconds**: Enough for the TTS model to capture voice characteristics without being too long for memory. +- **24 kHz mono**: Native sample rate of the Qwen3-TTS model. Audio at other rates will be automatically resampled, but starting at 24 kHz avoids quality loss. +- **Clear speech**: Minimal background noise, no music overlay. A single speaker only. + +## How to Obtain Reference Audio + +### Option 1: Record or Find English Speech + +The best reference audio is a clean clip of the target voice speaking English. Sources: + +- **Anime convention panels or interviews** where the VA speaks English +- **Behind-the-scenes clips** from Chainsaw Man production +- **Fan events or promotional videos** with English speech segments + +### Option 2: Extract from YouTube + +You can extract audio from YouTube clips. Here are some potential sources: + +1. Search YouTube for: `"Tomori Kusunoki" english` or `"楠木ともり" english` +2. Look for interview clips, event recordings, or promotional content + +**Extraction steps using `yt-dlp` and `ffmpeg`:** + +```bash +# 1. Download audio from a YouTube clip +yt-dlp -x --audio-format wav -o "raw_audio.%(ext)s" "YOUTUBE_URL_HERE" + +# 2. Convert to 24kHz mono WAV, trimming to a 10-second segment +# Adjust -ss (start time) and -t (duration) as needed +ffmpeg -i raw_audio.wav \ + -ss 00:00:05 -t 00:00:10 \ + -ar 24000 -ac 1 \ + -acodec pcm_s16le \ + voices/makima/reference.wav + +# 3. Verify the output +ffprobe -v error -show_entries stream=sample_rate,channels,duration \ + -of default=noprint_wrappers=1 voices/makima/reference.wav +``` + +### Option 3: Use Any Japanese-Accented English Voice + +If you cannot find clips of the specific VA, any clear recording of a female Japanese speaker speaking English will work as a starting point. The voice cloning will adapt to the reference audio's characteristics. + +```bash +# Example: record your own reference using sox (if available) +sox -d -r 24000 -c 1 -b 16 voices/makima/reference.wav trim 0 10 +``` + +## Tips for Best Quality + +1. **Clean audio**: Remove any background music or noise. Use a noise gate or audio editor if needed. +2. **Natural speech**: Conversational tone works better than reading. The model captures prosody and rhythm. +3. **Consistent volume**: Normalize the audio to avoid clipping or very quiet segments. +4. **Single speaker**: Only the target voice should be present in the clip. + +```bash +# Normalize audio volume with ffmpeg +ffmpeg -i reference_raw.wav \ + -af "loudnorm=I=-16:TP=-1.5:LRA=11" \ + -ar 24000 -ac 1 -acodec pcm_s16le \ + voices/makima/reference.wav +``` + +## File Structure + +``` +voices/makima/ +├── manifest.json # Voice metadata (name, sample rate, backend) +├── reference.wav # Reference audio clip (YOU PROVIDE THIS) +└── README.md # This file +``` + +## Verification + +After placing `reference.wav`, you can verify the system loads it correctly: + +```bash +# The TTS handler will log voice loading on first speak request: +# INFO makima::server::handlers::voice: Loaded voice reference audio +# voice_id="makima" voice_name="Makima" samples_len=240000 duration_secs=10.0 +``` + +If the reference audio is missing, the TTS system will still work but without voice cloning — it will use the model's default voice instead. |
