Replaces 22 production eprintln! sites with structured tracing events across 8 files. Closes Area B of the post-prognosis residuals plan (docs/superpowers/plans/2026-05-12-engine-slop-residuals.md). Files touched (22 sites): - crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings - crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning - src-tauri/src/commands/transcription.rs (1) — chunking dispatch info - src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning - src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning - src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail - src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle - src-tauri/src/commands/live.rs (8) — session start, chunk dispatch, per-chunk delivery, inference errors, worker disconnects, listener loss, status-channel cascade Levels: error for unrecoverable failures (inference disconnect, panic, status cascade), warn for recoverable degradation (LLM fallback, malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle (session start, chunk processed, App Nap acquire/release, warmup complete, chunking dispatch), debug for per-chunk noise (speech-gate skip, chunk dispatch). Two new dependencies and two new filter targets: - tracing = "0.1" added to crates/hotkey and crates/ai-formatting - Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets emit at the default level Out of scope (intentional, left as-is): - crates/mcp/src/main.rs — CLI binary, stderr is the log contract (module docstring) so the JSON-RPC stdout stream stays clean - crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs — test/example diagnostic output relies on --nocapture stdio semantics Discovery during sweep (not fixed — separate follow-up): hotkey crate has 6 existing log:: calls (log::error/warn/info/debug) but the workspace builds tracing-subscriber without the tracing-log feature, so those events are currently silent. Worth a follow-up to either add the tracing-log bridge or migrate hotkey's existing log:: calls to tracing::. Verification: - cargo fmt --all - cargo check --workspace --all-targets — clean - cargo test --workspace — 330+ tests, zero failures - rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits Pre-existing working-tree churn in crates/llm/, src/lib/pages/, src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes deliberately left unstaged per Jake's instruction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
8.6 KiB
Rust
255 lines
8.6 KiB
Rust
// Tauri command handlers must match the frontend's invoke() parameter lists,
|
|
// so the argument counts are dictated by the Svelte code.
|
|
#![allow(clippy::too_many_arguments)]
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use crate::commands::build_initial_prompt;
|
|
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
|
use crate::commands::security::ensure_main_window;
|
|
use crate::AppState;
|
|
use magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
|
use magnotia_core::constants::WHISPER_SAMPLE_RATE;
|
|
use magnotia_core::types::{AudioSamples, Segment, Transcript, TranscriptionOptions};
|
|
|
|
const PARAKEET_CHUNK_THRESHOLD_SECS: usize = 18;
|
|
const PARAKEET_CHUNK_SECS: usize = 15;
|
|
const PARAKEET_CHUNK_OVERLAP_SECS: usize = 1;
|
|
const FILE_CHUNK_THRESHOLD_SECS: usize = 8 * 60;
|
|
const FILE_CHUNK_SECS: usize = 3 * 60;
|
|
const FILE_CHUNK_OVERLAP_SECS: usize = 2;
|
|
const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0;
|
|
|
|
struct ChunkingStrategy {
|
|
chunk_samples: usize,
|
|
overlap_samples: usize,
|
|
}
|
|
|
|
fn pick_engine(
|
|
state: &AppState,
|
|
engine: &str,
|
|
) -> Result<Arc<magnotia_transcription::LocalEngine>, String> {
|
|
match engine {
|
|
"whisper" => Ok(state.whisper_engine.clone()),
|
|
"parakeet" => Ok(state.parakeet_engine.clone()),
|
|
other => Err(format!("Unknown engine: {other}")),
|
|
}
|
|
}
|
|
|
|
fn pick_chunking_strategy(engine_name: &str, sample_count: usize) -> Option<ChunkingStrategy> {
|
|
let samples_per_second = WHISPER_SAMPLE_RATE as usize;
|
|
match engine_name {
|
|
"parakeet" if sample_count > PARAKEET_CHUNK_THRESHOLD_SECS * samples_per_second => {
|
|
Some(ChunkingStrategy {
|
|
chunk_samples: PARAKEET_CHUNK_SECS * samples_per_second,
|
|
overlap_samples: PARAKEET_CHUNK_OVERLAP_SECS * samples_per_second,
|
|
})
|
|
}
|
|
_ if sample_count > FILE_CHUNK_THRESHOLD_SECS * samples_per_second => {
|
|
Some(ChunkingStrategy {
|
|
chunk_samples: FILE_CHUNK_SECS * samples_per_second,
|
|
overlap_samples: FILE_CHUNK_OVERLAP_SECS * samples_per_second,
|
|
})
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
|
|
if trim_before_secs <= 0.0 {
|
|
return;
|
|
}
|
|
|
|
segments.retain(|segment| segment.end > trim_before_secs);
|
|
for segment in segments.iter_mut() {
|
|
if segment.start < trim_before_secs {
|
|
segment.start = trim_before_secs;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn transcribe_samples_sync(
|
|
engine: Arc<magnotia_transcription::LocalEngine>,
|
|
engine_name: &str,
|
|
samples: Vec<f32>,
|
|
options: TranscriptionOptions,
|
|
) -> Result<magnotia_transcription::TimedTranscript, String> {
|
|
let Some(strategy) = pick_chunking_strategy(engine_name, samples.len()) else {
|
|
let audio = AudioSamples::mono_16khz(samples);
|
|
return engine
|
|
.transcribe_sync(&audio, &options)
|
|
.map_err(|e| e.to_string());
|
|
};
|
|
|
|
let total_duration_secs = samples.len() as f64 / WHISPER_SAMPLE_RATE as f64;
|
|
let stride = strategy
|
|
.chunk_samples
|
|
.saturating_sub(strategy.overlap_samples)
|
|
.max(1);
|
|
let chunk_count = ((samples.len().saturating_sub(1)) / stride) + 1;
|
|
tracing::info!(
|
|
engine = engine_name,
|
|
duration_secs = total_duration_secs,
|
|
chunk_count,
|
|
"chunking audio for transcription"
|
|
);
|
|
|
|
let mut all_segments = Vec::new();
|
|
let mut total_inference_ms = 0u64;
|
|
let mut chunk_start = 0usize;
|
|
|
|
while chunk_start < samples.len() {
|
|
let chunk_end = (chunk_start + strategy.chunk_samples).min(samples.len());
|
|
let chunk_audio = AudioSamples::mono_16khz(samples[chunk_start..chunk_end].to_vec());
|
|
let timed = engine
|
|
.transcribe_sync(&chunk_audio, &options)
|
|
.map_err(|e| e.to_string())?;
|
|
total_inference_ms = total_inference_ms.saturating_add(timed.inference_ms);
|
|
|
|
let mut chunk_segments = timed.transcript.segments().to_vec();
|
|
if chunk_start > 0 {
|
|
trim_overlap_segments(
|
|
&mut chunk_segments,
|
|
strategy.overlap_samples as f64 / WHISPER_SAMPLE_RATE as f64,
|
|
);
|
|
}
|
|
|
|
let chunk_offset_secs = chunk_start as f64 / WHISPER_SAMPLE_RATE as f64;
|
|
for segment in &mut chunk_segments {
|
|
segment.start += chunk_offset_secs;
|
|
segment.end += chunk_offset_secs;
|
|
}
|
|
all_segments.extend(chunk_segments);
|
|
|
|
if chunk_end >= samples.len() {
|
|
break;
|
|
}
|
|
chunk_start = chunk_end.saturating_sub(strategy.overlap_samples);
|
|
}
|
|
|
|
Ok(magnotia_transcription::TimedTranscript {
|
|
transcript: Transcript::new(
|
|
all_segments,
|
|
options.language.clone().unwrap_or_else(|| "en".to_string()),
|
|
total_duration_secs,
|
|
),
|
|
inference_ms: total_inference_ms,
|
|
})
|
|
}
|
|
|
|
fn join_segment_text(segments: &[Segment]) -> String {
|
|
segments
|
|
.iter()
|
|
.map(|segment| segment.text.trim())
|
|
.filter(|segment| !segment.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
|
#[tauri::command]
|
|
pub async fn transcribe_file(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
path: String,
|
|
engine: Option<String>,
|
|
model_id: Option<String>,
|
|
language: String,
|
|
initial_prompt: String,
|
|
remove_fillers: bool,
|
|
british_english: bool,
|
|
anti_hallucination: bool,
|
|
format_mode: String,
|
|
profile_id: Option<String>,
|
|
) -> Result<serde_json::Value, String> {
|
|
ensure_main_window(&window)?;
|
|
let resolved_profile_id =
|
|
profile_id.unwrap_or_else(|| magnotia_storage::DEFAULT_PROFILE_ID.to_string());
|
|
|
|
let profile = magnotia_storage::database::get_profile(&state.db, &resolved_profile_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("Profile {resolved_profile_id} not found"))?;
|
|
|
|
let profile_terms: Vec<String> =
|
|
magnotia_storage::database::list_profile_terms(&state.db, &resolved_profile_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.into_iter()
|
|
.map(|t| t.term)
|
|
.collect();
|
|
|
|
let engine_name = engine.unwrap_or_else(|| "whisper".to_string());
|
|
let model_id =
|
|
model_id.unwrap_or_else(|| default_model_id_for_engine(&engine_name).to_string());
|
|
// None: transcribe paths don't enforce sequential-GPU mode. That's
|
|
// owned by the Settings-level load flows (see models.rs).
|
|
ensure_model_loaded(&state, &engine_name, &model_id, None).await?;
|
|
|
|
let engine = pick_engine(&state, &engine_name)?;
|
|
let options = TranscriptionOptions {
|
|
language: Some(language),
|
|
initial_prompt: build_initial_prompt(
|
|
&initial_prompt,
|
|
&profile.initial_prompt,
|
|
&profile_terms,
|
|
),
|
|
};
|
|
let engine_name_for_worker = engine_name.clone();
|
|
let path_for_probe = Path::new(&path);
|
|
if let Some(duration_secs) =
|
|
magnotia_audio::probe_audio_duration_secs(path_for_probe).map_err(|e| e.to_string())?
|
|
{
|
|
if duration_secs > MAX_FILE_TRANSCRIPTION_SECS {
|
|
return Err(format!(
|
|
"File is {:.1} hours long. Magnotia imports up to 2 hours at a time.",
|
|
duration_secs / 3600.0
|
|
));
|
|
}
|
|
}
|
|
|
|
let timed = tokio::task::spawn_blocking(move || {
|
|
let audio = magnotia_audio::decode_audio_file_limited(
|
|
Path::new(&path),
|
|
Some(MAX_FILE_TRANSCRIPTION_SECS),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let resampled = magnotia_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
|
|
transcribe_samples_sync(
|
|
engine,
|
|
&engine_name_for_worker,
|
|
resampled.into_samples(),
|
|
options,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())??;
|
|
|
|
let dictionary_terms = profile_terms.clone();
|
|
|
|
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
|
let raw_text = join_segment_text(&segments);
|
|
post_process_segments(
|
|
&mut segments,
|
|
&PostProcessOptions {
|
|
remove_fillers,
|
|
british_english,
|
|
anti_hallucination,
|
|
format_mode: FormatMode::parse(&format_mode),
|
|
dictionary_terms,
|
|
},
|
|
Some(state.llm_engine.as_ref()),
|
|
);
|
|
|
|
Ok(serde_json::json!({
|
|
"engine": engine_name,
|
|
"modelId": model_id,
|
|
"segments": segments,
|
|
"language": timed.transcript.language(),
|
|
"duration": timed.transcript.duration(),
|
|
"inference_ms": timed.inference_ms,
|
|
"raw_text": raw_text,
|
|
}))
|
|
}
|