Skip to content
Open
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
10 changes: 10 additions & 0 deletions desktop/src-tauri/Cargo.lock

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

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ user-idle = { version = "0.6", default-features = false }
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
sys-locale = "0.3.2"
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
Expand Down
414 changes: 345 additions & 69 deletions desktop/src-tauri/src/huddle/models.rs

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions desktop/src-tauri/src/huddle/models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,159 @@ fn ready_destination_removes_stale_backup() {
assert!(model_dir.exists());
assert!(!backup_dir.exists());
}

// ── STT model selection (issue #2478) ─────────────────────────────────────────

#[test]
fn defaults_to_english_without_override_or_locale() {
assert_eq!(select_stt_model(None, None).id, "parakeet-en");
assert_eq!(select_stt_model(None, Some("en-US")).id, "parakeet-en");
assert_eq!(select_stt_model(None, Some("en")).id, "parakeet-en");
assert_eq!(select_stt_model(None, None).auto_select_languages, &["en"]);
}

#[test]
fn native_system_locale_precedes_environment_fallback() {
let locale = first_usable_locale([Some("ko-KR".to_string()), Some("en_US.UTF-8".to_string())]);

assert_eq!(locale.as_deref(), Some("ko-KR"));
}

#[test]
fn supported_european_locale_selects_parakeet_v3() {
for locale in ["de-DE", "uk_UA", "fr", "es-ES", "pl_PL.UTF-8"] {
let model = select_stt_model(None, Some(locale));
assert_eq!(model.id, "parakeet-v3", "locale {locale}");
}
}

#[test]
fn cjk_locales_select_sensevoice() {
for locale in ["zh-CN", "ja_JP", "ko-KR", "yue_HK.UTF-8"] {
assert_eq!(
select_stt_model(None, Some(locale)).id,
"sensevoice",
"locale {locale}"
);
}
}

#[test]
fn unsupported_locale_does_not_select_an_incompatible_multilingual_model() {
assert_eq!(select_stt_model(None, Some("ar-SA")).id, "parakeet-en");
}

#[test]
fn explicit_override_wins_over_locale() {
assert_eq!(
select_stt_model(Some("parakeet-v3"), Some("en-US")).id,
"parakeet-v3"
);
assert_eq!(
select_stt_model(Some("parakeet-en"), Some("de-DE")).id,
"parakeet-en"
);
assert_eq!(
select_stt_model(Some("PARAKEET-V3"), None).id,
"parakeet-v3"
);
assert_eq!(
select_stt_model(Some("SENSEVOICE"), Some("de-DE")).id,
"sensevoice"
);
}

#[test]
fn unknown_or_empty_override_falls_back() {
assert_eq!(
select_stt_model(Some("does-not-exist"), Some("en-US")).id,
"parakeet-en"
);
assert_eq!(
select_stt_model(Some("does-not-exist"), Some("fr-FR")).id,
"parakeet-v3"
);
assert_eq!(select_stt_model(Some(" "), None).id, "parakeet-en");
}

#[test]
fn registry_invariants_hold() {
assert!(!STT_MODELS.is_empty());
assert_eq!(default_stt_model().id, "parakeet-en");
assert!(
default_stt_model().archive_sha256.is_some(),
"English default must ship a pinned SHA-256"
);
assert!(STT_MODELS
.iter()
.any(|model| model.auto_select_languages.len() > 1));
for (index, model) in STT_MODELS.iter().enumerate() {
assert!(!model.model_files.is_empty(), "{} has no files", model.id);
assert!(model.max_download_bytes > 0, "{} has no size cap", model.id);
for other in &STT_MODELS[index + 1..] {
assert!(
!model.id.eq_ignore_ascii_case(other.id),
"duplicate model id {}",
model.id
);
for language in model.auto_select_languages {
assert!(
!other.auto_select_languages.contains(language),
"locale {language} is auto-selected by both {} and {}",
model.id,
other.id
);
}
}
}
}

#[test]
fn expected_files_always_include_license_sidecar() {
for model in STT_MODELS {
let files = stt_expected_files(model);
assert!(
files.contains(&STT_LICENSE_FILE_NAME),
"{} missing license sidecar in expected files",
model.id
);
for file in model.model_files {
assert!(files.contains(file), "{} missing {file}", model.id);
}
}
}

#[test]
fn readiness_uses_per_model_expected_files() {
let model = stt_model_by_id("parakeet-v3").expect("v3 registered");
let temp = tempfile::tempdir().expect("tempdir");
let slot = ModelSlot::new(model.dir_name, stt_expected_files(model), model.version);
let dir = temp.path().join(model.dir_name);
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join(MANIFEST_FILENAME), model.version).expect("manifest");

std::fs::write(dir.join("encoder.int8.onnx"), b"x").expect("write");
std::fs::write(dir.join("tokens.txt"), b"x").expect("write");
assert!(!slot.is_ready(temp.path()));

for file in stt_expected_files(model) {
std::fs::write(dir.join(file), b"x").expect("write");
}
assert!(slot.is_ready(temp.path()));
}

#[test]
fn sensevoice_readiness_uses_single_model_file() {
let model = stt_model_by_id("sensevoice").expect("SenseVoice registered");
let temp = tempfile::tempdir().expect("tempdir");
let slot = ModelSlot::new(model.dir_name, stt_expected_files(model), model.version);
let dir = temp.path().join(model.dir_name);
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join(MANIFEST_FILENAME), model.version).expect("manifest");
std::fs::write(dir.join("model.int8.onnx"), b"x").expect("model");
std::fs::write(dir.join("tokens.txt"), b"x").expect("tokens");
assert!(!slot.is_ready(temp.path()));

std::fs::write(dir.join(STT_LICENSE_FILE_NAME), b"x").expect("license");
assert!(slot.is_ready(temp.path()));
}
10 changes: 6 additions & 4 deletions desktop/src-tauri/src/huddle/models_voice_upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,11 @@ mod tests {
TTS_MODEL_VERSION
);
assert!(!model_dir.join("marius.wav").exists());
assert!(
ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION)
.is_ready(temp.path())
);
assert!(ModelSlot::new(
TTS_MODEL_DIR_NAME,
TTS_EXPECTED_FILES.to_vec(),
TTS_MODEL_VERSION,
)
.is_ready(temp.path()));
}
}
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/huddle/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ pub(crate) async fn maybe_start_stt_pipeline(
return Ok(false); // Models not downloaded yet — voice-only mode.
}
let model_dir = models::stt_model_dir().ok_or("STT model directory not found")?;
// The selected model's family decides how the offline recognizer is
// configured (English CTC vs multilingual transducer — issue #2478).
let stt_family = models::stt_model_family();

let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?;

Expand Down Expand Up @@ -361,6 +364,7 @@ pub(crate) async fn maybe_start_stt_pipeline(
let constructed = tokio::task::spawn_blocking(move || {
stt::SttPipeline::new(
model_dir,
stt_family,
tts_active,
ptt_active_for_stt,
manual_mic_unmuted_for_stt,
Expand Down
73 changes: 64 additions & 9 deletions desktop/src-tauri/src/huddle/stt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ use std::{

use tokio::sync::mpsc as tokio_mpsc;

use super::models::SttFamily;

// ── Public pipeline handle ────────────────────────────────────────────────────

/// Bounded audio queue capacity.
Expand Down Expand Up @@ -86,6 +88,7 @@ impl SttPipeline {
/// thread on every `recv_timeout` call).
pub fn new(
model_dir: PathBuf,
family: SttFamily,
tts_active: Arc<AtomicBool>,
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
Expand All @@ -102,6 +105,7 @@ impl SttPipeline {
.spawn(move || {
stt_worker(
model_dir,
family,
audio_rx,
text_tx,
shutdown_worker,
Expand Down Expand Up @@ -200,8 +204,10 @@ const TTS_COOLDOWN: Duration = Duration::from_millis(150);
/// shows it's safe on the minimum-spec target.
const STT_NUM_THREADS: i32 = 1;

#[allow(clippy::too_many_arguments)]
fn stt_worker(
model_dir: PathBuf,
family: SttFamily,
audio_rx: Receiver<Vec<u8>>,
text_tx: tokio_mpsc::Sender<String>,
shutdown: Arc<AtomicBool>,
Expand All @@ -227,26 +233,75 @@ fn stt_worker(

// ── 3. Initialise sherpa-onnx recognizer ─────────────────────────────────
//
// Parakeet TDT-CTC 110M ships as a single `model.int8.onnx` (CTC head) plus
// `tokens.txt`. sherpa-onnx infers the model family from which inner config
// has a `model` path set, so we don't need to set `model_type` explicitly.
// (See rust-api-examples/parakeet_tdt_ctc_simulate_streaming_microphone.rs
// in k2-fsa/sherpa-onnx.)
// sherpa-onnx infers the model family from which inner config has model
// paths set, so we populate exactly one family sub-config (issue #2478):
//
// NemoCtc — single `model.int8.onnx` (CTC head), e.g. Parakeet 110M en.
// Transducer — `encoder/decoder/joiner.int8.onnx`, e.g. Parakeet 0.6B v3
// (multilingual). See k2-fsa/sherpa-onnx offline-transducer
// NeMo examples.
// SenseVoice — single `model.int8.onnx`, automatic language detection,
// and inverse text normalization.
//
// `tokens.txt` is shared by every family and lives on the parent config.
use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig};

let tokens_path = model_dir.join("tokens.txt");
let model_path = model_dir.join("model.int8.onnx");
if !tokens_path.exists() || !model_path.exists() {
if !tokens_path.exists() {
eprintln!(
"buzz-desktop: STT model not found at {} — STT disabled",
"buzz-desktop: STT tokens.txt not found at {} — STT disabled",
model_dir.display()
);
drain_until_shutdown(audio_rx, &shutdown);
return;
}

let mut cfg = OfflineRecognizerConfig::default();
cfg.model_config.nemo_ctc.model = Some(model_path.to_string_lossy().into_owned());
match family {
SttFamily::NemoCtc => {
let model_path = model_dir.join("model.int8.onnx");
if !model_path.exists() {
eprintln!(
"buzz-desktop: STT model.int8.onnx not found at {} — STT disabled",
model_dir.display()
);
drain_until_shutdown(audio_rx, &shutdown);
return;
}
cfg.model_config.nemo_ctc.model = Some(model_path.to_string_lossy().into_owned());
}
SttFamily::Transducer => {
let encoder = model_dir.join("encoder.int8.onnx");
let decoder = model_dir.join("decoder.int8.onnx");
let joiner = model_dir.join("joiner.int8.onnx");
if !encoder.exists() || !decoder.exists() || !joiner.exists() {
eprintln!(
"buzz-desktop: STT transducer files (encoder/decoder/joiner) missing at {} \
— STT disabled",
model_dir.display()
);
drain_until_shutdown(audio_rx, &shutdown);
return;
}
cfg.model_config.transducer.encoder = Some(encoder.to_string_lossy().into_owned());
cfg.model_config.transducer.decoder = Some(decoder.to_string_lossy().into_owned());
cfg.model_config.transducer.joiner = Some(joiner.to_string_lossy().into_owned());
}
SttFamily::SenseVoice => {
let model_path = model_dir.join("model.int8.onnx");
if !model_path.exists() {
eprintln!(
"buzz-desktop: STT model.int8.onnx not found at {} — STT disabled",
model_dir.display()
);
drain_until_shutdown(audio_rx, &shutdown);
return;
}
cfg.model_config.sense_voice.model = Some(model_path.to_string_lossy().into_owned());
cfg.model_config.sense_voice.language = Some("auto".to_string());
cfg.model_config.sense_voice.use_itn = true;
}
}
cfg.model_config.tokens = Some(tokens_path.to_string_lossy().into_owned());
cfg.model_config.num_threads = STT_NUM_THREADS;
// Explicit — defaults are not part of the API contract, and noisy debug
Expand Down