Skip to content
Merged
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: 5 additions & 5 deletions crates/voxctrl-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub enum ConfigError {

// ── Engine sub-configs ────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WhisperCppConfig {
/// Directory containing GGUF model files. Empty = platform default.
pub model_dir: String,
Expand All @@ -42,7 +42,7 @@ impl Default for WhisperCppConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MoonshineConfig {
/// "base" or "tiny"
pub model_size: String,
Expand All @@ -59,7 +59,7 @@ impl Default for MoonshineConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ParakeetConfig {
pub model_size: String,
pub language: String,
Expand All @@ -74,7 +74,7 @@ impl Default for ParakeetConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteOpenAiConfig {
/// Remote OpenAI-compatible endpoint URL, e.g. "http://localhost:8000/v1"
pub endpoint: String,
Expand Down Expand Up @@ -120,7 +120,7 @@ impl Default for BackendChoice {
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct EngineConfig {
#[serde(default)]
pub backend: BackendChoice,
Expand Down
156 changes: 123 additions & 33 deletions crates/voxctrl-inference/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,34 @@ impl InferenceEngine {
self.backend.unload();
}

/// Update engine configuration. If backend or backend model settings changed,
/// re-creates the backend and returns `true` (meaning the caller should reload).
pub fn update_config(&mut self, new_config: Arc<AppConfig>) -> bool {
let backend_changed = self.config.engine.backend != new_config.engine.backend
|| (new_config.engine.backend == BackendChoice::WhisperCpp
&& self.config.engine.whisper_cpp != new_config.engine.whisper_cpp)
|| (new_config.engine.backend == BackendChoice::Moonshine
&& self.config.engine.moonshine != new_config.engine.moonshine)
|| (new_config.engine.backend == BackendChoice::Parakeet
&& self.config.engine.parakeet != new_config.engine.parakeet)
|| (new_config.engine.backend == BackendChoice::RemoteOpenAi
&& self.config.engine.remote_openai != new_config.engine.remote_openai);

self.config = new_config.clone();

if backend_changed {
info!(
"Inference backend configuration changed, switching backend to {:?}",
new_config.engine.backend
);
self.backend.unload();
self.backend = build_backend(&new_config);
true
} else {
false
}
}

/// Transcribe and post-process. Returns the final text.
pub fn process(&self, req: InferenceRequest) -> Result<InferenceOutput> {
if req.audio.is_empty() {
Expand Down Expand Up @@ -533,6 +561,19 @@ pub fn run_worker(
config: Arc<AppConfig>,
rx: Receiver<InferenceRequest>,
tx: Sender<InferenceOutput>,
) {
let (_dummy_tx, dummy_rx) = crossbeam_channel::unbounded();
run_worker_with_config(config, rx, tx, dummy_rx);
}

/// Run the inference engine on a dedicated OS thread with dynamic config reloading.
/// Receives `InferenceRequest` from `rx`, sends `InferenceOutput` to `tx`,
/// and updates/reloads the backend whenever `config_rx` receives a new `AppConfig`.
pub fn run_worker_with_config(
config: Arc<AppConfig>,
rx: Receiver<InferenceRequest>,
tx: Sender<InferenceOutput>,
config_rx: Receiver<Arc<AppConfig>>,
) {
std::thread::Builder::new()
.name("voxctrl-inference".into())
Expand All @@ -554,42 +595,70 @@ pub fn run_worker(
}
};

while let Ok(req) = rx.recv() {
if !loaded {
match engine.load() {
Ok(()) => {
info!("Inference engine ready (loaded on demand)");
loaded = true;
}
Err(e) => {
error!("Inference backend still not loadable: {e:#}");
let _ = tx.send(InferenceOutput {
text: String::new(),
target_id: req.target_id,
raw_text: String::new(),
inference_ms: 0,
language: String::new(),
error: Some(format!("{e:#}")),
});
continue;
loop {
crossbeam_channel::select! {
recv(rx) -> req_res => {
let req = match req_res {
Ok(r) => r,
Err(_) => break,
};

if !loaded {
match engine.load() {
Ok(()) => {
info!("Inference engine ready (loaded on demand)");
loaded = true;
}
Err(e) => {
error!("Inference backend still not loadable: {e:#}");
let _ = tx.send(InferenceOutput {
text: String::new(),
target_id: req.target_id,
raw_text: String::new(),
inference_ms: 0,
language: String::new(),
error: Some(format!("{e:#}")),
});
continue;
}
}
}
}
}

match engine.process(req) {
Ok(output) => {
let _ = tx.send(output);
match engine.process(req) {
Ok(output) => {
let _ = tx.send(output);
}
Err(e) => {
error!("Inference error: {:?}", e);
let _ = tx.send(InferenceOutput {
text: "".to_string(),
target_id: "".to_string(),
raw_text: "".to_string(),
inference_ms: 0,
language: "".to_string(),
error: Some(format!("{e:#}")),
});
}
}
}
Err(e) => {
error!("Inference error: {:?}", e);
let _ = tx.send(InferenceOutput {
text: "".to_string(),
target_id: "".to_string(),
raw_text: "".to_string(),
inference_ms: 0,
language: "".to_string(),
error: Some(format!("{e:#}")),
});
recv(config_rx) -> new_cfg_res => {
let new_cfg = match new_cfg_res {
Ok(c) => c,
Err(_) => break,
};
let needs_reload = engine.update_config(new_cfg);
if needs_reload {
loaded = match engine.load() {
Ok(()) => {
info!("Inference engine ready with new backend");
true
}
Err(e) => {
error!("Failed to load new inference backend: {e:#}");
false
}
};
}
}
}
}
Expand Down Expand Up @@ -649,4 +718,25 @@ mod tests {
assert_eq!(backend.name(), "remote-openai");
assert!(backend.is_loaded());
}

#[test]
fn test_engine_update_config_switches_backend() {
let cfg = AppConfig::default();
let mut engine = InferenceEngine::new(Arc::new(cfg.clone()));
assert_eq!(engine.backend.name(), "whisper-cpp");

let mut new_cfg = cfg.clone();
new_cfg.engine.backend = BackendChoice::RemoteOpenAi;
new_cfg.engine.remote_openai.endpoint = "http://localhost:5000/v1".to_string();
let reloaded = engine.update_config(Arc::new(new_cfg));
assert!(reloaded);
assert_eq!(engine.backend.name(), "remote-openai");

// Non-backend config change should not trigger backend reload
let mut features_cfg = engine.config.as_ref().clone();
features_cfg.features.remove_fillers = !features_cfg.features.remove_fillers;
let reloaded_features = engine.update_config(Arc::new(features_cfg));
assert!(!reloaded_features);
assert_eq!(engine.backend.name(), "remote-openai");
}
}
88 changes: 67 additions & 21 deletions crates/voxctrl-inference/src/parakeet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,31 +170,19 @@ fn load_vocab(path: &Path) -> Result<Vec<String>> {
}

fn detokenize(tokens: &[usize], vocab: &[String]) -> String {
let mut text = String::new();
let mut raw = String::new();
for &tok_id in tokens {
if tok_id >= vocab.len() {
continue;
}
let tok = &vocab[tok_id];
if tok == "<blk>" || tok == "<unk>" || tok.is_empty() {
if tok == "<blk>" || tok == "<unk>" || tok == "<pad>" || tok.starts_with("<|") || tok.is_empty() {
continue;
}
// Handle SentencePiece whitespace prefix ( or Ġ)
if let Some(stripped) = tok.strip_prefix(' ') {
if !text.is_empty() {
text.push(' ');
}
text.push_str(stripped);
} else if let Some(stripped) = tok.strip_prefix('Ġ') {
if !text.is_empty() {
text.push(' ');
}
text.push_str(stripped);
} else {
text.push_str(tok);
}
raw.push_str(tok);
}
text
let converted = raw.replace('\u{2581}', " ").replace('Ġ', " ");
converted.trim().to_string()
}

// ── Loaded State ──────────────────────────────────────────────────────────────
Expand All @@ -206,6 +194,9 @@ struct Loaded {
vocab: Vec<String>,
targets_is_i32: bool,
decoder_enc_shape_time_first: bool,
logits_idx: usize,
state_1_idx: usize,
state_2_idx: usize,
}

// ── Backend ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -331,13 +322,34 @@ impl TranscriptionBackend for ParakeetBackend {
})
.unwrap_or(false);

let logits_idx = decoder
.outputs()
.iter()
.position(|o| o.name() == "outputs")
.unwrap_or(0);

let state_1_idx = decoder
.outputs()
.iter()
.position(|o| o.name() == "output_states_1")
.unwrap_or(2);

let state_2_idx = decoder
.outputs()
.iter()
.position(|o| o.name() == "output_states_2")
.unwrap_or(3);

*self.state.lock().unwrap() = Some(Loaded {
preprocessor,
encoder,
decoder,
vocab,
targets_is_i32,
decoder_enc_shape_time_first,
logits_idx,
state_1_idx,
state_2_idx,
});
self.loaded = true;
Ok(())
Expand Down Expand Up @@ -472,7 +484,7 @@ fn run_inference(state: &mut Loaded, audio: &[f32]) -> Result<String> {
}

// ── 3. Decoder: TDT Greedy Search Loop ───────────────────────────────────
let vocab_size = state.vocab.len().min(BLANK_TOKEN_ID);
let vocab_size = state.vocab.len();
let output_dim = vocab_size + NUM_DURATION_CLASSES;
let blank_idx = BLANK_TOKEN_ID;

Expand Down Expand Up @@ -547,7 +559,7 @@ fn run_inference(state: &mut Loaded, audio: &[f32]) -> Result<String> {
];

let dec_out = state.decoder.run(dec_feed).context("decoder step run")?;
let (_, ldata) = dec_out[0]
let (_, ldata) = dec_out[state.logits_idx]
.try_extract_tensor::<f32>()
.context("extract decoder logits")?;

Expand All @@ -567,10 +579,10 @@ fn run_inference(state: &mut Loaded, audio: &[f32]) -> Result<String> {
emitted_tokens.push(best_token);
current_token = best_token;

let (_, next_s1) = dec_out[1]
let (_, next_s1) = dec_out[state.state_1_idx]
.try_extract_tensor::<f32>()
.context("extract next state_1")?;
let (_, next_s2) = dec_out[2]
let (_, next_s2) = dec_out[state.state_2_idx]
.try_extract_tensor::<f32>()
.context("extract next state_2")?;
state_1 = next_s1.to_vec();
Expand Down Expand Up @@ -614,4 +626,38 @@ mod tests {
let text = detokenize(&tokens, &vocab);
assert_eq!(text, "Hello world!");
}
#[test]
fn test_inspect_decoder() {
let path = std::path::Path::new("/home/jrufer/.local/share/voxctrl/models/parakeet/tdt-0.6b-v3/decoder_joint-model.int8.onnx");
if !path.exists() {
return;
}
let session = ParakeetBackend::build_session(path).unwrap();
let outputs = session.outputs();
let s1_idx = outputs.iter().position(|o| o.name() == "output_states_1").unwrap_or(2);
let s2_idx = outputs.iter().position(|o| o.name() == "output_states_2").unwrap_or(3);
let logits_idx = outputs.iter().position(|o| o.name() == "outputs").unwrap_or(0);
assert_eq!(logits_idx, 0);
assert_eq!(s1_idx, 2);
assert_eq!(s2_idx, 3);
}

#[test]
fn test_parakeet_transcribe_silence() {
let _dir = model_size_dir("", "tdt-0.6b-v3");
if !is_model_downloaded("tdt-0.6b-v3", "") {
return;
}
let mut backend = ParakeetBackend::new(ParakeetConfig::default());
backend.load().expect("load parakeet");
let req = TranscribeRequest {
audio: vec![0.0f32; 16000],
language: None,
word_timestamps: false,
initial_prompt: None,
};
let res = backend.transcribe(&req).expect("transcribe silence");
println!("Parakeet transcribe silence result: {:?}", res.text);
assert_eq!(res.text, "");
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ pub async fn save_config(
guard.save().map_err(|e| e.to_string())?;
info!("Config saved");

// Hot-reload inference engine configuration
let _ = state.inference_config_tx.send(Arc::new(new_config.clone()));

let (overlay_position, overlay_monitor) = (
guard.data.ui.overlay_position.clone(),
guard.data.ui.overlay_monitor.clone(),
Expand Down
Loading
Loading