refactor(voice): extract reusable Pocket primitives

Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
This commit is contained in:
John Matthew Tennant
2026-07-29 12:06:23 -04:00
committed by John Tennant
parent 211d17c585
commit ace19c659e
12 changed files with 809 additions and 234 deletions
Generated
+547 -60
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,6 +26,7 @@ members = [
"crates/buzz-pair-relay",
"crates/buzz-relay-mesh",
"crates/buzz-dev-mcp",
"crates/buzz-voice",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
+1
View File
@@ -276,6 +276,7 @@ test-unit:
#!/usr/bin/env bash
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
cargo nextest run -p buzz-voice --lib
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
# 0001; cutover/backfill stays an operator script, not startup state)
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "buzz-voice"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Reusable local voice primitives for Buzz"
[dependencies]
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] }
ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] }
rand = "0.10"
sentencepiece-model = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sherpa-onnx = "1.12"
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
+55
View File
@@ -0,0 +1,55 @@
//! Reusable local voice primitives for Buzz.
pub mod pocket;
pub use pocket::{
load_text_to_speech, load_voice_style, PocketTts, VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE,
VOICE_FILE_EXT,
};
/// One immutable artifact required by the April Pocket bundle.
///
/// `filename` is the bundle-relative file name, `sha256` pins its contents,
/// `size_bytes` supports download progress and validation, and `quantized`
/// identifies the INT8 components.
pub type PocketModelArtifact = pocket::PocketModelArtifact;
/// Capabilities of Buzz Desktop's April Pocket model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PocketModelInfo {
/// Language bundle selected from the pinned export.
pub bundle_id: &'static str,
/// Upstream model repository.
pub source_model_id: &'static str,
/// Pinned upstream model revision.
pub revision: &'static str,
/// PCM output sample rate.
pub sample_rate: u32,
/// Maximum input size declared by the bundle.
pub max_token_per_chunk: usize,
/// Immutable files required by the runtime.
pub artifacts: &'static [PocketModelArtifact],
/// Components quantized in the selected bundle.
pub quantized_components: &'static [&'static str],
}
/// Language bundle selected from the pinned export.
pub const APRIL_BUNDLE_ID: &str = pocket::APRIL_BUNDLE_ID;
/// Pinned upstream export repository.
pub const APRIL_MODEL_ID: &str = pocket::APRIL_MODEL_ID;
/// Pinned revision containing the April bundle.
pub const APRIL_MODEL_REVISION: &str = pocket::APRIL_MODEL_REVISION;
/// Return immutable metadata for Buzz Desktop's April Pocket model.
pub const fn april_model_info() -> PocketModelInfo {
let info = pocket::april_model_info();
PocketModelInfo {
bundle_id: info.bundle_id,
source_model_id: info.source_model_id,
revision: info.revision,
sample_rate: info.sample_rate,
max_token_per_chunk: info.max_token_per_chunk,
artifacts: info.artifacts,
quantized_components: info.quantized_components,
}
}
+166
View File
@@ -0,0 +1,166 @@
//! April 2026 Pocket TTS engine for Buzz Desktop.
//!
//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned
//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding.
//! Buzz selects the upstream three-graph INT8 variant while retaining the
//! full-precision Mimi encoder and text conditioner specified by that variant.
//!
//! ## Attribution
//!
//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0.
//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0.
//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0.
//!
//! `huddle::models` writes the complete attribution beside the cached bytes.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use sherpa_onnx::Wave;
#[path = "pocket_april.rs"]
mod pocket_april;
#[path = "pocket_models.rs"]
mod pocket_models;
use pocket_april::{prepare_april_prompt, AprilPocketTts};
pub(crate) use pocket_models::{
april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION,
};
/// Pocket TTS emits 24 kHz mono PCM.
pub const SAMPLE_RATE: u32 = 24_000;
/// Bundled reference voice name without its extension.
pub const DEFAULT_VOICE: &str = "reference_sample";
/// Pocket voice files are reference WAVs.
pub const VOICE_FILE_EXT: &str = "wav";
const TTS_NUM_THREADS: usize = 1;
/// Loaded reference voice samples and their original sample rate.
#[derive(Debug, Clone)]
pub struct VoiceStyle {
samples: Vec<f32>,
sample_rate: i32,
}
/// Load a Pocket reference voice WAV from disk.
pub fn load_voice_style(path: &Path) -> Result<VoiceStyle, String> {
let path_str = path
.to_str()
.ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?;
let wave = Wave::read(path_str)
.ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?;
let samples = wave.samples().to_vec();
if samples.is_empty() {
return Err(format!("voice WAV is empty: {}", path.display()));
}
Ok(VoiceStyle {
samples,
sample_rate: wave.sample_rate(),
})
}
/// Resident April INT8 Pocket TTS engine.
pub struct PocketTts {
inner: Mutex<AprilPocketTts>,
}
/// Load Buzz Desktop's pinned April INT8 model.
pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
let dir = PathBuf::from(model_dir);
for artifact in april_model_info().artifacts {
let path = dir.join(artifact.filename);
if !path.is_file() {
return Err(format!(
"incomplete Pocket TTS {} INT8 bundle: missing {}",
APRIL_BUNDLE_ID,
path.display()
));
}
}
Ok(PocketTts {
inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?),
})
}
impl PocketTts {
/// Split text into synthesis units that satisfy the bundle's exact
/// 50-token input limit.
pub fn split_text_into_chunks(&self, text: &str) -> Result<Vec<String>, String> {
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
self.inner
.lock()
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?
.split_prompt(&prepared)
}
/// Synthesize text with the supplied reference voice.
///
/// Pocket detects language from text and this model uses one synthesis
/// step, so `_lang` and `_steps` intentionally do not affect output.
pub fn synth_chunk(
&self,
text: &str,
_lang: &str,
style: &VoiceStyle,
_steps: usize,
) -> Result<Vec<f32>, String> {
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
let mut engine = self
.inner
.lock()
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?;
let chunks = engine.split_prompt(&prepared)?;
let mut samples = Vec::new();
for chunk in chunks {
let prepared = prepare_april_prompt(&chunk)
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
samples.extend(engine.synth_chunk(&prepared, style)?);
}
Ok(samples)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_model_is_april_int8_only() {
let info = april_model_info();
assert_eq!(info.max_token_per_chunk, 50);
assert_eq!(info.sample_rate, SAMPLE_RATE);
assert!(info
.artifacts
.iter()
.any(|artifact| artifact.filename == "flow_lm_main_int8.onnx"));
assert!(!info
.artifacts
.iter()
.any(|artifact| artifact.filename == "flow_lm_main.onnx"));
}
#[test]
#[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
fn production_api_emits_non_silent_april_int8_pcm() {
let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
.expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory");
let engine = load_text_to_speech(&dir).expect("load April INT8 engine");
let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav"))
.expect("load reference voice");
let samples = engine
.synth_chunk("Bright birds begin beside the bay.", "en", &style, 1)
.expect("synthesize through the production API");
assert!(!samples.is_empty());
assert!(samples.iter().all(|sample| sample.is_finite()));
assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6));
}
}
+15 -5
View File
@@ -1049,6 +1049,7 @@ dependencies = [
"buzz-media",
"buzz-persona",
"buzz-sdk",
"buzz-voice",
"bytes",
"bzip2 0.6.1",
"chrono",
@@ -1075,11 +1076,8 @@ dependencies = [
"notify-rust",
"objc2-app-kit",
"opus",
"ort",
"ort-sys",
"plist",
"png 0.18.1",
"rand 0.10.2",
"regex",
"reqwest 0.13.4",
"rodio",
@@ -1087,7 +1085,6 @@ dependencies = [
"rusqlite",
"rustls",
"security-framework 3.7.0",
"sentencepiece-model",
"serde",
"serde_json",
"serde_yaml",
@@ -1107,7 +1104,6 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tempfile",
"tokenizers",
"tokio",
"tokio-tungstenite 0.29.0",
"tokio-util",
@@ -1174,6 +1170,20 @@ dependencies = [
"uuid",
]
[[package]]
name = "buzz-voice"
version = "0.1.0"
dependencies = [
"ort",
"ort-sys",
"rand 0.10.2",
"sentencepiece-model",
"serde",
"serde_json",
"sherpa-onnx",
"tokenizers",
]
[[package]]
name = "by_address"
version = "1.2.1"
+1 -5
View File
@@ -76,9 +76,6 @@ bytes = "1"
futures-util = "0.3"
opus = "0.3"
neteq = { version = "0.8", default-features = false }
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] }
ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] }
rand = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
@@ -92,6 +89,7 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
@@ -119,7 +117,6 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
zip = "8"
flate2 = "1"
sherpa-onnx = "1.12"
sentencepiece-model = "0.1"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
axum = "0.8"
@@ -130,7 +127,6 @@ audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"
tracing = "0.1"
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
[dev-dependencies]
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
+2 -164
View File
@@ -1,166 +1,4 @@
//! April 2026 Pocket TTS engine for Buzz Desktop.
//!
//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned
//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding.
//! Buzz selects the upstream three-graph INT8 variant while retaining the
//! full-precision Mimi encoder and text conditioner specified by that variant.
//!
//! ## Attribution
//!
//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0.
//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0.
//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0.
//!
//! `huddle::models` writes the complete attribution beside the cached bytes.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use sherpa_onnx::Wave;
#[path = "pocket_april.rs"]
mod pocket_april;
#[path = "pocket_models.rs"]
mod pocket_models;
use pocket_april::{prepare_april_prompt, AprilPocketTts};
pub(crate) use pocket_models::{
pub use buzz_voice_pkg::pocket::*;
pub(crate) use buzz_voice_pkg::{
april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION,
};
/// Pocket TTS emits 24 kHz mono PCM.
pub const SAMPLE_RATE: u32 = 24_000;
/// Bundled reference voice name without its extension.
pub const DEFAULT_VOICE: &str = "reference_sample";
/// Pocket voice files are reference WAVs.
pub const VOICE_FILE_EXT: &str = "wav";
const TTS_NUM_THREADS: usize = 1;
/// Loaded reference voice samples and their original sample rate.
#[derive(Debug, Clone)]
pub struct VoiceStyle {
samples: Vec<f32>,
sample_rate: i32,
}
/// Load a Pocket reference voice WAV from disk.
pub fn load_voice_style(path: &Path) -> Result<VoiceStyle, String> {
let path_str = path
.to_str()
.ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?;
let wave = Wave::read(path_str)
.ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?;
let samples = wave.samples().to_vec();
if samples.is_empty() {
return Err(format!("voice WAV is empty: {}", path.display()));
}
Ok(VoiceStyle {
samples,
sample_rate: wave.sample_rate(),
})
}
/// Resident April INT8 Pocket TTS engine.
pub struct PocketTts {
inner: Mutex<AprilPocketTts>,
}
/// Load Buzz Desktop's pinned April INT8 model.
pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
let dir = PathBuf::from(model_dir);
for artifact in april_model_info().artifacts {
let path = dir.join(artifact.filename);
if !path.is_file() {
return Err(format!(
"incomplete Pocket TTS {} INT8 bundle: missing {}",
APRIL_BUNDLE_ID,
path.display()
));
}
}
Ok(PocketTts {
inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?),
})
}
impl PocketTts {
/// Split text into synthesis units that satisfy the bundle's exact
/// 50-token input limit.
pub fn split_text_into_chunks(&self, text: &str) -> Result<Vec<String>, String> {
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
self.inner
.lock()
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?
.split_prompt(&prepared)
}
/// Synthesize text with the supplied reference voice.
///
/// Pocket detects language from text and this model uses one synthesis
/// step, so `_lang` and `_steps` intentionally do not affect output.
pub fn synth_chunk(
&self,
text: &str,
_lang: &str,
style: &VoiceStyle,
_steps: usize,
) -> Result<Vec<f32>, String> {
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
let mut engine = self
.inner
.lock()
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?;
let chunks = engine.split_prompt(&prepared)?;
let mut samples = Vec::new();
for chunk in chunks {
let prepared = prepare_april_prompt(&chunk)
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
samples.extend(engine.synth_chunk(&prepared, style)?);
}
Ok(samples)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_model_is_april_int8_only() {
let info = april_model_info();
assert_eq!(info.max_token_per_chunk, 50);
assert_eq!(info.sample_rate, SAMPLE_RATE);
assert!(info
.artifacts
.iter()
.any(|artifact| artifact.filename == "flow_lm_main_int8.onnx"));
assert!(!info
.artifacts
.iter()
.any(|artifact| artifact.filename == "flow_lm_main.onnx"));
}
#[test]
#[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
fn production_api_emits_non_silent_april_int8_pcm() {
let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
.expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory");
let engine = load_text_to_speech(&dir).expect("load April INT8 engine");
let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav"))
.expect("load reference voice");
let samples = engine
.synth_chunk("Bright birds begin beside the bay.", "en", &style, 1)
.expect("synthesize through the production API");
assert!(!samples.is_empty());
assert!(samples.iter().all(|sample| sample.is_finite()));
assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6));
}
}
+3
View File
@@ -84,6 +84,9 @@ run_unit_tests() {
run_test_step "buzz-auth unit tests" \
cargo test -p buzz-auth --lib -- --nocapture
run_test_step "buzz-voice tests" \
cargo test -p buzz-voice --lib -- --nocapture
# buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator
# invariant (exactly the consolidated 0001; cutover/backfill stays an operator
# script, not startup state) and the tenant-scoping lints. The Postgres-backed