Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ members = [
"crates/buzz-voice",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
exclude = ["desktop/src-tauri", "crates/sherpa-onnx-sys"]
resolver = "2"

[workspace.package]
Expand Down Expand Up @@ -171,3 +171,4 @@ strip = true
# allowlist for the auth token). Revert to crates.io once #449 lands upstream.
[patch.crates-io]
aws-creds = { git = "https://github.com/tlongwell-block/rust-s3", rev = "c9fce3620dd434c1f810101d672cf384268dbb0f" }
sherpa-onnx-sys = { path = "crates/sherpa-onnx-sys" }
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ RUN apt-get update \
# locations. The normal runtime strips it below; runtime-debug retains it.
ENV CARGO_PROFILE_RELEASE_DEBUG=line-tables-only
COPY --from=planner /build/recipe.json recipe.json
# The sherpa sys patch keeps its published 1.13.4 version so Cargo can replace
# the registry crate. It is excluded from the workspace recipe because
# cargo-chef masks workspace package versions to 0.0.1.
COPY --from=planner /build/crates/sherpa-onnx-sys crates/sherpa-onnx-sys
# Cook the full workspace recipe — relay deps include workspace siblings, so
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
RUN cargo chef cook --release --recipe-path recipe.json
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile.push-gateway
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=planner /build/recipe.json recipe.json
# Keep the patched sherpa sys crate at its published version; cargo-chef masks
# workspace package versions, so the patch remains outside the workspace recipe.
COPY --from=planner /build/crates/sherpa-onnx-sys crates/sherpa-onnx-sys
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \
Expand Down
7 changes: 6 additions & 1 deletion crates/buzz-voice/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ license.workspace = true
repository.workspace = true
description = "Reusable local voice primitives for Buzz"

[features]
default = ["static"]
static = ["sherpa-onnx/static"]
shared = ["sherpa-onnx/shared"]

[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"
sherpa-onnx = { version = "1.12", default-features = false }
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
130 changes: 127 additions & 3 deletions crates/buzz-voice/src/pocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//!
//! `huddle::models` writes the complete attribution beside the cached bytes.

use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

Expand All @@ -39,6 +40,42 @@ pub const VOICE_FILE_EXT: &str = "wav";

const TTS_NUM_THREADS: usize = 1;

thread_local! {
static ACTIVE_SYNTHESIS_ENGINES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}

struct SynthesisCallGuard {
engine_id: usize,
}

impl SynthesisCallGuard {
fn enter(engine_id: usize) -> Result<Self, String> {
ACTIVE_SYNTHESIS_ENGINES.with(|active| {
let mut active = active.borrow_mut();
if active.contains(&engine_id) {
return Err("Pocket TTS callback re-entered the active engine".to_string());
}
active.push(engine_id);
Ok(Self { engine_id })
})
}

fn is_active(engine_id: usize) -> bool {
ACTIVE_SYNTHESIS_ENGINES.with(|active| active.borrow().contains(&engine_id))
}
}

impl Drop for SynthesisCallGuard {
fn drop(&mut self) {
ACTIVE_SYNTHESIS_ENGINES.with(|active| {
let mut active = active.borrow_mut();
if let Some(index) = active.iter().rposition(|engine| *engine == self.engine_id) {
active.remove(index);
}
});
}
}

/// Loaded reference voice samples and their original sample rate.
#[derive(Debug, Clone)]
pub struct VoiceStyle {
Expand Down Expand Up @@ -90,6 +127,9 @@ 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> {
if SynthesisCallGuard::is_active(self as *const Self as usize) {
return Err("Pocket TTS callback re-entered the active engine".to_string());
}
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
Expand All @@ -104,12 +144,36 @@ impl PocketTts {
/// 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> {
self.synth_chunk_with_callback(text, lang, style, steps, None::<fn(&[f32], f32) -> bool>)
}

/// Synthesize text, allowing the caller to stop generation early.
///
/// The callback receives PCM accumulated after each decoded text chunk
/// and progress in `[0, 1]`. During latent generation the callback is
/// invoked with an empty sample slice so cancellation can remain
/// responsive before PCM is available. Return `true` to continue or
/// `false` to stop and return the audio generated so far. Progress is
/// monotonic across split text chunks. Calls back into the same
/// [`PocketTts`] return an error instead of blocking on its engine lock.
pub fn synth_chunk_with_callback<F>(
&self,
text: &str,
_lang: &str,
style: &VoiceStyle,
_steps: usize,
) -> Result<Vec<f32>, String> {
mut callback: Option<F>,
) -> Result<Vec<f32>, String>
where
F: FnMut(&[f32], f32) -> bool + 'static,
{
let _call_guard = SynthesisCallGuard::enter(self as *const Self as usize)?;
let Some(prepared) = prepare_april_prompt(text) else {
return Ok(Vec::new());
};
Expand All @@ -119,15 +183,47 @@ impl PocketTts {
.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 chunk_count = chunks.len();
for (index, chunk) in chunks.into_iter().enumerate() {
let prepared = prepare_april_prompt(&chunk)
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
samples.extend(engine.synth_chunk(&prepared, style)?);
let progress_offset = index as f32 / chunk_count as f32;
let progress_scale = 1.0 / chunk_count as f32;
let (chunk_samples, cancelled) = engine.synth_chunk_with_callback(
&prepared,
style,
&mut callback,
progress_offset,
progress_scale,
)?;
samples.extend(chunk_samples);
if cancelled {
break;
}
let progress = (index + 1) as f32 / chunk_count as f32;
if !callback_allows_progress(&mut callback, &samples, progress)? {
break;
}
}
Ok(samples)
}
}

fn callback_allows_progress<F>(
callback: &mut Option<F>,
samples: &[f32],
progress: f32,
) -> Result<bool, String>
where
F: FnMut(&[f32], f32) -> bool,
{
let Some(callback) = callback.as_mut() else {
return Ok(true);
};
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(samples, progress)))
.map_err(|_| "Pocket TTS synthesis callback panicked".to_string())
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -147,6 +243,34 @@ mod tests {
.any(|artifact| artifact.filename == "flow_lm_main.onnx"));
}

#[test]
fn callback_can_cancel_before_pcm_is_available() {
let mut callback = Some(|samples: &[f32], progress: f32| {
assert!(samples.is_empty());
assert_eq!(progress, 0.25);
false
});
assert!(!callback_allows_progress(&mut callback, &[], 0.25).expect("callback"));
}

#[test]
fn callback_panic_is_reported_without_unwinding() {
let mut callback = Some(|_: &[f32], _: f32| -> bool {
panic!("callback failure");
});
assert_eq!(
callback_allows_progress(&mut callback, &[], 0.0).unwrap_err(),
"Pocket TTS synthesis callback panicked"
);
}

#[test]
fn active_engine_reentry_is_rejected() {
let _guard = SynthesisCallGuard::enter(42).expect("first call");
assert!(SynthesisCallGuard::enter(42).is_err());
assert!(SynthesisCallGuard::is_active(42));
}

#[test]
#[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
fn production_api_emits_non_silent_april_int8_pcm() {
Expand Down
41 changes: 32 additions & 9 deletions crates/buzz-voice/src/pocket_april.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,17 @@ impl AprilPocketTts {
.collect()
}

pub(crate) fn synth_chunk(
pub(crate) fn synth_chunk_with_callback<F>(
&mut self,
prepared: &AprilPreparedPrompt,
style: &VoiceStyle,
) -> Result<Vec<f32>, String> {
callback: &mut Option<F>,
progress_offset: f32,
progress_scale: f32,
) -> Result<(Vec<f32>, bool), String>
where
F: FnMut(&[f32], f32) -> bool,
{
let voice_embeddings = self.voice_embeddings(style)?;
let mut flow_state = self.condition_voice(&voice_embeddings)?;
let token_ids = self
Expand All @@ -321,7 +327,7 @@ impl AprilPocketTts {
.map(i64::from)
.collect::<Vec<_>>();
if token_ids.is_empty() {
return Ok(Vec::new());
return Ok((Vec::new(), false));
}
if token_ids.len() > self.bundle.max_token_per_chunk {
return Err(format!(
Expand All @@ -335,9 +341,15 @@ impl AprilPocketTts {
let text_embeddings = self.text_embeddings(token_ids)?;
self.run_flow_main_prefix(&text_embeddings, &mut flow_state)?;
let max_frames = estimate_max_frames(token_count, self.bundle.frame_rate);
let latents =
self.generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state)?;
self.decode_latents(&latents)
let (latents, cancelled) = self.generate_latents(
max_frames,
prepared.frames_after_eos,
&mut flow_state,
callback,
progress_offset,
progress_scale,
)?;
Ok((self.decode_latents(&latents)?, cancelled))
}

fn prepared_token_count(&self, text: &str) -> Result<usize, String> {
Expand Down Expand Up @@ -497,18 +509,29 @@ impl AprilPocketTts {
replace_state_from_outputs(state, &mut outputs)
}

fn generate_latents(
fn generate_latents<F>(
&mut self,
max_frames: usize,
frames_after_eos: usize,
state: &mut [StateValue],
) -> Result<Vec<f32>, String> {
callback: &mut Option<F>,
progress_offset: f32,
progress_scale: f32,
) -> Result<(Vec<f32>, bool), String>
where
F: FnMut(&[f32], f32) -> bool,
{
let mut current = vec![f32::NAN; self.bundle.latent_dim];
let mut latents = Vec::with_capacity(max_frames * self.bundle.latent_dim);
let mut eos_step = None;
let mut rng = rand::rng();

for step in 0..max_frames {
let local_progress = step as f32 / max_frames as f32;
let progress = progress_offset + local_progress * progress_scale;
if !super::callback_allows_progress(callback, &[], progress)? {
return Ok((latents, true));
}
let sequence = Tensor::from_array((
vec![1_i64, 1, self.bundle.latent_dim as i64],
current.clone().into_boxed_slice(),
Expand Down Expand Up @@ -594,7 +617,7 @@ impl AprilPocketTts {
current.clone_from(&noise);
latents.extend_from_slice(&noise);
}
Ok(latents)
Ok((latents, false))
}

fn decode_latents(&mut self, latents: &[f32]) -> Result<Vec<f32>, String> {
Expand Down
63 changes: 63 additions & 0 deletions crates/sherpa-onnx-sys/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.

[package]
edition = "2021"
name = "sherpa-onnx-sys"
version = "1.13.4"
build = "build.rs"
links = "sherpa-onnx"
include = [
"src/**",
"build.rs",
"Cargo.toml",
"README.md",
"LICENSE*",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Raw FFI bindings to the sherpa-onnx C API"
readme = "README.md"
keywords = [
"ffi",
"speech",
"sherpa-onnx",
"bindings",
]
categories = ["external-ffi-bindings"]
license = "Apache-2.0"
repository = "https://github.com/k2-fsa/sherpa-onnx"

[package.metadata.docs.rs]
default-features = false
features = []

[features]
default = ["static"]
shared = []
static = []

[lib]
name = "sherpa_onnx_sys"
path = "src/lib.rs"

[build-dependencies.bzip2]
version = "0.4"

[build-dependencies.tar]
version = "0.4"

[build-dependencies.ureq]
version = "2.12"
features = ["proxy-from-env"]
Loading
Loading