Two new Settings → AI knobs that compose cleanly with what already shipped (aiTier, LLM model, translator prompt framing). **B.1 #15 — Named cleanup presets.** LlmPromptPreset enum (Default / Email / Notes / Code) appends a short context hint onto the CLEANUP_PROMPT just before generation. Presets shape tone and structure ("email paragraph", "bulleted meeting notes", "preserve technical terms") without licensing the content-editing the translator-not-editor framing forbids. cleanup_transcript_text_cmd now takes `preset: Option<String>` which runs through the new LlmPromptPreset::parse (normalises aliases like "meeting-notes", collapses unknown values to Default). **A.1 #28 — Sequential-GPU guard.** New LocalEngine::unload drops the backend + model_id so a subsequent load actually reclaims VRAM. load_llm_model, load_model, and load_parakeet_model Tauri commands grow an optional `concurrent: bool` argument. When concurrent is Some(false), loading LLM first unloads whisper+parakeet, and vice versa — prevents VRAM OOM on tight-VRAM setups. Default is the previous parallel behaviour so nothing changes for multi-GB cards. Transcribe-in-progress paths (transcribe_pcm, transcribe_file, live) pass None, so mid-dictation model loads don't accidentally tear down the LLM. Settings UI (AI section): - Cleanup preset segmented button + descriptive copy for each option. - GPU concurrency segmented button with explicit trade-off text ("faster transitions vs fits in tight VRAM"). Frontend wiring: - settings.llmPromptPreset flows from DictationPage's cleanupTranscriptIfEnabled into the Tauri command. - settings.aiGpuConcurrency flows from both DictationPage (auto-load on record) and SettingsPage (manual load/unload buttons) as `concurrent: "parallel" === true` to the load commands. Tests: three new preset cases in crates/ai-formatting/src/llm_client.rs (parse aliases, suffix non-empty for non-default, default suffix empty). All 139 existing lib tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
391 lines
13 KiB
Rust
391 lines
13 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 tauri::Emitter;
|
|
|
|
use crate::commands::build_initial_prompt;
|
|
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
|
use crate::AppState;
|
|
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
|
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
|
use kon_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;
|
|
|
|
struct ChunkingStrategy {
|
|
chunk_samples: usize,
|
|
overlap_samples: usize,
|
|
}
|
|
|
|
fn pick_engine(
|
|
state: &AppState,
|
|
engine: &str,
|
|
) -> Result<Arc<kon_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<kon_transcription::LocalEngine>,
|
|
engine_name: &str,
|
|
samples: Vec<f32>,
|
|
options: TranscriptionOptions,
|
|
) -> Result<kon_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;
|
|
eprintln!(
|
|
"[transcription] chunking {total_duration_secs:.2}s of {engine_name} audio into {chunk_count} chunk(s)"
|
|
);
|
|
|
|
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(kon_transcription::TimedTranscript {
|
|
transcript: Transcript::new(
|
|
all_segments,
|
|
options.language.clone().unwrap_or_else(|| "en".to_string()),
|
|
total_duration_secs,
|
|
),
|
|
inference_ms: total_inference_ms,
|
|
})
|
|
}
|
|
|
|
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
|
#[tauri::command]
|
|
pub async fn transcribe_pcm(
|
|
state: tauri::State<'_, AppState>,
|
|
app: tauri::AppHandle,
|
|
samples: Vec<f32>,
|
|
chunk_id: u32,
|
|
language: String,
|
|
initial_prompt: String,
|
|
remove_fillers: bool,
|
|
british_english: bool,
|
|
anti_hallucination: bool,
|
|
format_mode: String,
|
|
profile_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let resolved_profile_id =
|
|
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
|
|
|
let profile = kon_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> =
|
|
kon_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 = state.whisper_engine.clone();
|
|
let options = TranscriptionOptions {
|
|
language: Some(language),
|
|
initial_prompt: build_initial_prompt(
|
|
&initial_prompt,
|
|
&profile.initial_prompt,
|
|
&profile_terms,
|
|
),
|
|
};
|
|
|
|
let timed = tokio::task::spawn_blocking(move || {
|
|
let audio = AudioSamples::mono_16khz(samples);
|
|
engine
|
|
.transcribe_sync(&audio, &options)
|
|
.map_err(|e| e.to_string())
|
|
})
|
|
.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()),
|
|
);
|
|
|
|
app.emit(
|
|
"transcription-result",
|
|
serde_json::json!({
|
|
"status": "transcription",
|
|
"segments": segments,
|
|
"language": timed.transcript.language(),
|
|
"duration": timed.transcript.duration(),
|
|
"chunk_id": chunk_id,
|
|
"inference_ms": timed.inference_ms,
|
|
"raw_text": raw_text,
|
|
}),
|
|
)
|
|
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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(
|
|
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> {
|
|
let resolved_profile_id =
|
|
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
|
|
|
let profile = kon_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> =
|
|
kon_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 timed = tokio::task::spawn_blocking(move || {
|
|
let audio = kon_audio::decode_audio_file(Path::new(&path)).map_err(|e| e.to_string())?;
|
|
let resampled = kon_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,
|
|
}))
|
|
}
|
|
|
|
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
|
|
#[tauri::command]
|
|
pub async fn transcribe_pcm_parakeet(
|
|
state: tauri::State<'_, AppState>,
|
|
app: tauri::AppHandle,
|
|
samples: Vec<f32>,
|
|
chunk_id: u32,
|
|
remove_fillers: bool,
|
|
british_english: bool,
|
|
anti_hallucination: bool,
|
|
format_mode: String,
|
|
profile_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let resolved_profile_id =
|
|
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
|
|
|
// Validate the profile exists so parakeet and whisper behave identically
|
|
// when a bogus id slips through from the frontend.
|
|
kon_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> =
|
|
kon_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 = state.parakeet_engine.clone();
|
|
let options = TranscriptionOptions::default();
|
|
|
|
let timed = tokio::task::spawn_blocking(move || {
|
|
transcribe_samples_sync(engine, "parakeet", 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()),
|
|
);
|
|
|
|
app.emit(
|
|
"transcription-result",
|
|
serde_json::json!({
|
|
"status": "transcription",
|
|
"segments": segments,
|
|
"language": timed.transcript.language(),
|
|
"duration": timed.transcript.duration(),
|
|
"chunk_id": chunk_id,
|
|
"inference_ms": timed.inference_ms,
|
|
"raw_text": raw_text,
|
|
}),
|
|
)
|
|
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|