Major quality pass on top of Phase 2. Five substantive changes plus
cross-cutting touches across audio, hotkey, transcription, and Tauri
command layers.
Transcription quality
- Long-audio chunking in commands/transcription.rs: Parakeet and large
file transcription now chunk-and-recompose with overlap trimming, so
the live-path chunking advantage extends to file-based workflows.
- Stateful live speech gate in commands/live.rs on top of the earlier
duplicate-boundary filtering — distinguishes start-of-speech from
mid-speech and holds state across chunks.
Auto-learning corrections
- New crates/ai-formatting/src/correction_learning.rs: extracts user
text corrections from viewer edits and proposes additions to the
active profile's vocabulary.
- src-tauri/src/commands/profiles.rs bridge for frontend-driven
confirmation of learned terms.
- src/routes/viewer/+page.svelte hooks the learning path into the
segment-edit flow so corrections feed profile_terms without a
separate 'train this profile' UX.
Transcript profile provenance
- Migration v8 (crates/storage/src/migrations.rs) adds profile_id to
transcripts, defaulting to DEFAULT_PROFILE_ID so existing rows stay
valid.
- crates/storage/src/database.rs: TranscriptRow + CRUD carry profile_id.
- src-tauri/src/commands/transcripts.rs: add_transcript accepts and
persists profile_id.
- DictationPage.svelte + FilesPage.svelte send activeProfileId on
capture so learned corrections are attributed to the right profile.
Cleanup prompt contract
- crates/ai-formatting/src/llm_client.rs hardened: the CLEANUP_PROMPT
now specifies concrete do/do-not rules, ready for a real model-backed
cleanup pass. The llm_client is still a stub — kon-llm remains unwired
— but the prompt shape is final.
Cross-cutting polish
- Minor touches in audio (capture/decode/resample), hotkey (lib/linux/stub),
core, transcription (concurrency/model_manager/local_engine/whisper_rs),
and the rest of src-tauri/src/commands/*: error-path tightening, log
clarity, TS-migration follow-ups (@ts-nocheck additions for incremental
typing).
Verified locally: npm run check, cargo test -p kon-ai-formatting,
cargo test -p kon-storage, cargo test -p kon --lib commands::live::tests,
cargo check — all green.
Scope boundary: kon-llm crate is still a stub; task extraction remains
rule-based. Bundled local-LLM runtime is the next clean step and is not
in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
3.4 KiB
Rust
109 lines
3.4 KiB
Rust
use std::fs::File;
|
|
use std::path::Path;
|
|
|
|
use symphonia::core::audio::SampleBuffer;
|
|
use symphonia::core::codecs::DecoderOptions;
|
|
use symphonia::core::formats::FormatOptions;
|
|
use symphonia::core::io::MediaSourceStream;
|
|
use symphonia::core::meta::MetadataOptions;
|
|
use symphonia::core::probe::Hint;
|
|
|
|
use kon_core::error::{KonError, Result};
|
|
use kon_core::types::AudioSamples;
|
|
|
|
/// Decode an audio file to mono f32 PCM samples.
|
|
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
|
|
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
|
let file = File::open(path)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
let mut hint = Hint::new();
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
hint.with_extension(ext);
|
|
}
|
|
|
|
let probed = symphonia::default::get_probe()
|
|
.format(
|
|
&hint,
|
|
mss,
|
|
&FormatOptions::default(),
|
|
&MetadataOptions::default(),
|
|
)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
|
|
|
let mut format = probed.format;
|
|
|
|
let track = format
|
|
.default_track()
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
|
let sample_rate = track
|
|
.codec_params
|
|
.sample_rate
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
|
|
|
if sample_rate == 0 {
|
|
return Err(KonError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
|
}
|
|
|
|
let track_id = track.id;
|
|
|
|
let mut decoder = symphonia::default::get_codecs()
|
|
.make(&track.codec_params, &DecoderOptions::default())
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
|
|
|
|
let mut samples: Vec<f32> = Vec::new();
|
|
let mut decode_errors = 0u32;
|
|
|
|
loop {
|
|
let packet = match format.next_packet() {
|
|
Ok(p) => p,
|
|
Err(symphonia::core::errors::Error::IoError(ref e))
|
|
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
|
{
|
|
break;
|
|
}
|
|
Err(symphonia::core::errors::Error::ResetRequired) => break,
|
|
Err(_) => break,
|
|
};
|
|
|
|
if packet.track_id() != track_id {
|
|
continue;
|
|
}
|
|
|
|
let decoded = match decoder.decode(&packet) {
|
|
Ok(d) => d,
|
|
Err(_) => {
|
|
decode_errors += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let spec = *decoded.spec();
|
|
let channels = spec.channels.count();
|
|
let mut sample_buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
|
sample_buf.copy_interleaved_ref(decoded);
|
|
|
|
let buf = sample_buf.samples();
|
|
if channels == 1 {
|
|
samples.extend_from_slice(buf);
|
|
} else {
|
|
for chunk in buf.chunks(channels) {
|
|
let sum: f32 = chunk.iter().sum();
|
|
samples.push(sum / channels as f32);
|
|
}
|
|
}
|
|
}
|
|
|
|
if samples.is_empty() {
|
|
if decode_errors > 0 {
|
|
return Err(KonError::AudioDecodeFailed(format!(
|
|
"No audio decoded ({decode_errors} packets failed — file may be corrupt)"
|
|
)));
|
|
}
|
|
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
|
|
}
|
|
|
|
Ok(AudioSamples::new(samples, sample_rate, 1))
|
|
}
|