Files
Lumotia/src-tauri/src/commands/transcription.rs
Jake 089349d966
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — rust workspace crates magnotia-* -> lumotia-*
Phase 2 of the rebrand cascade. Renames all 9 workspace crates from
magnotia-* to lumotia-* plus the src-tauri binary crate name:

- magnotia-ai-formatting   -> lumotia-ai-formatting
- magnotia-audio           -> lumotia-audio
- magnotia-cloud-providers -> lumotia-cloud-providers
- magnotia-core            -> lumotia-core
- magnotia-hotkey          -> lumotia-hotkey
- magnotia-llm             -> lumotia-llm
- magnotia-mcp             -> lumotia-mcp
- magnotia-storage         -> lumotia-storage
- magnotia-transcription   -> lumotia-transcription
- magnotia                 -> lumotia (src-tauri binary)
- magnotia_lib             -> lumotia_lib (src-tauri lib target)

Crate directories (crates/audio/ etc.) stay as-is; only the Cargo.toml
[package] name field changes plus all consumer module imports
(magnotia_core -> lumotia_core, etc.).

Remaining magnotia_* references at this point are intentional and
scoped to later phases: tracing targets (Phase 4), DB setting keys
magnotia_preferences/magnotia_history (Phase 5).

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:48:09 +01:00

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 lumotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use lumotia_core::constants::WHISPER_SAMPLE_RATE;
use lumotia_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<lumotia_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<lumotia_transcription::LocalEngine>,
engine_name: &str,
samples: Vec<f32>,
options: TranscriptionOptions,
) -> Result<lumotia_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(lumotia_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(|| lumotia_storage::DEFAULT_PROFILE_ID.to_string());
let profile = lumotia_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> =
lumotia_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) =
lumotia_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 = lumotia_audio::decode_audio_file_limited(
Path::new(&path),
Some(MAX_FILE_TRANSCRIPTION_SECS),
)
.map_err(|e| e.to_string())?;
let resampled = lumotia_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,
}))
}