feat: OpenWhispr-inspired transcription polish pass
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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
@@ -13,9 +14,7 @@ use tauri::ipc::Channel;
|
||||
use crate::commands::audio::persist_audio_samples;
|
||||
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_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
use kon_audio::{MicrophoneCapture, StreamingResampler};
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
|
||||
@@ -27,9 +26,30 @@ const FINAL_CHUNK_MIN_SAMPLES: usize = 4_000; // 0.25s
|
||||
const MAX_PENDING_SAMPLES: usize = CHUNK_SAMPLES;
|
||||
const SPEECH_FRAME_SAMPLES: usize = 800; // 50ms
|
||||
const MIN_SPEECH_FRAMES: usize = 1; // any plausible speech-like frame
|
||||
const RMS_SPEECH_THRESHOLD: f32 = 0.001;
|
||||
const PEAK_SPEECH_THRESHOLD: f32 = 0.004;
|
||||
const SILENCE_RMS_THRESHOLD: f32 = 0.001;
|
||||
const SPEECH_WINDOW_RMS_THRESHOLD: f32 = 0.0014;
|
||||
const SPEECH_WINDOW_PEAK_THRESHOLD: f32 = 0.004;
|
||||
const STRONG_SPEECH_RMS_THRESHOLD: f32 = 0.003;
|
||||
const STRONG_SPEECH_PEAK_THRESHOLD: f32 = 0.012;
|
||||
const FLATLINE_PEAK_THRESHOLD: f32 = 0.0005;
|
||||
const DUPLICATE_TRANSCRIPT_WINDOW_SECS: f64 = 6.0;
|
||||
const DUPLICATE_TRANSCRIPT_MERGE_LIMIT: usize = 3;
|
||||
const DUPLICATE_HISTORY_RETENTION_SECS: f64 = 8.0;
|
||||
const DUPLICATE_CHECK_LEADING_SECS: f64 = 1.5;
|
||||
const TOKEN_COVERAGE_THRESHOLD: f64 = 0.6;
|
||||
const TOKEN_SEQUENCE_THRESHOLD: f64 = 0.6;
|
||||
const MIN_TOKENS_FOR_OVERLAP: usize = 3;
|
||||
const MEANINGFUL_TOKEN_COVERAGE_THRESHOLD: f64 = 0.55;
|
||||
const MEANINGFUL_TOKEN_SEQUENCE_THRESHOLD: f64 = 0.55;
|
||||
const MIN_MEANINGFUL_TOKENS_FOR_OVERLAP: usize = 4;
|
||||
const LOW_SIGNAL_TOKENS: &[&str] = &[
|
||||
"a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by", "d", "did", "do",
|
||||
"does", "for", "from", "had", "has", "have", "he", "her", "here", "his", "how", "i", "if",
|
||||
"in", "is", "it", "ll", "m", "me", "my", "of", "on", "or", "our", "out", "re", "s", "she",
|
||||
"so", "t", "that", "the", "their", "them", "there", "these", "they", "this", "those", "to",
|
||||
"ve", "was", "we", "well", "were", "what", "when", "where", "which", "who", "why", "with",
|
||||
"without", "you", "your",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LiveTranscriptionState {
|
||||
@@ -131,6 +151,34 @@ struct InferenceTask {
|
||||
rx: std::sync::mpsc::Receiver<Result<kon_transcription::TimedTranscript, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RecentTranscriptSegment {
|
||||
start_secs: f64,
|
||||
end_secs: f64,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct SpeechGateState {
|
||||
peak_rms: f32,
|
||||
peak_amplitude: f32,
|
||||
window_count: usize,
|
||||
speech_window_count: usize,
|
||||
consecutive_speech_windows: usize,
|
||||
max_consecutive_speech_windows: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct SpeechGateDecision {
|
||||
skip: bool,
|
||||
reason: &'static str,
|
||||
peak_rms: f32,
|
||||
peak_amplitude: f32,
|
||||
window_count: usize,
|
||||
speech_window_count: usize,
|
||||
max_consecutive_speech_windows: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_live_transcription_session(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -183,10 +231,7 @@ pub async fn start_live_transcription_session(
|
||||
.unwrap_or_else(|| default_model_id_for_engine(&config.engine).to_string());
|
||||
eprintln!(
|
||||
"[live] starting session: engine={}, model={}, language={:?}, save_audio={}",
|
||||
config.engine,
|
||||
model_id,
|
||||
config.language,
|
||||
config.save_audio
|
||||
config.engine, model_id, config.language, config.save_audio
|
||||
);
|
||||
ensure_model_loaded(&state, &config.engine, &model_id).await?;
|
||||
|
||||
@@ -250,10 +295,7 @@ pub async fn stop_live_transcription_session(
|
||||
.map_err(|e| format!("Live session task failed: {e}"))??;
|
||||
|
||||
let audio_path = if let Some(samples) = summary.audio_samples {
|
||||
Some(
|
||||
persist_audio_samples(&app, samples, running.output_folder.clone())
|
||||
.await?,
|
||||
)
|
||||
Some(persist_audio_samples(&app, samples, running.output_folder.clone()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -273,10 +315,7 @@ pub async fn stop_live_transcription_session(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn pick_engine(
|
||||
state: &AppState,
|
||||
engine: &str,
|
||||
) -> Result<Arc<LocalEngine>, String> {
|
||||
fn pick_engine(state: &AppState, engine: &str) -> Result<Arc<LocalEngine>, String> {
|
||||
match engine {
|
||||
"whisper" => Ok(state.whisper_engine.clone()),
|
||||
"parakeet" => Ok(state.parakeet_engine.clone()),
|
||||
@@ -317,12 +356,14 @@ fn run_live_session(
|
||||
let mut chunk_id: u32 = 0;
|
||||
let mut inflight: Option<InferenceTask> = None;
|
||||
let mut resampler_flushed = false;
|
||||
let mut recent_segments: Vec<RecentTranscriptSegment> = Vec::new();
|
||||
|
||||
loop {
|
||||
if let Some(_done) = poll_inference(
|
||||
&mut inflight,
|
||||
session_id,
|
||||
&config,
|
||||
&mut recent_segments,
|
||||
&dictionary_terms,
|
||||
&result_channel,
|
||||
&status_channel,
|
||||
@@ -358,18 +399,12 @@ fn run_live_session(
|
||||
}
|
||||
};
|
||||
|
||||
let resampled =
|
||||
resampler.push_samples(&mono).map_err(|e| e.to_string())?;
|
||||
append_resampled_audio(
|
||||
&mut capture_buffer,
|
||||
&mut kept_audio,
|
||||
&resampled,
|
||||
);
|
||||
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
|
||||
append_resampled_audio(&mut capture_buffer, &mut kept_audio, &resampled);
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
let message =
|
||||
"Microphone capture disconnected unexpectedly".to_string();
|
||||
let message = "Microphone capture disconnected unexpectedly".to_string();
|
||||
let _ = status_channel.send(LiveStatusMessage::Error {
|
||||
session_id,
|
||||
message: message.clone(),
|
||||
@@ -426,6 +461,7 @@ fn run_live_session(
|
||||
&mut inflight,
|
||||
session_id,
|
||||
&config,
|
||||
&mut recent_segments,
|
||||
&dictionary_terms,
|
||||
&result_channel,
|
||||
&status_channel,
|
||||
@@ -485,17 +521,33 @@ fn maybe_dispatch_chunk(
|
||||
&capture_buffer[..target_len]
|
||||
};
|
||||
|
||||
if !has_enough_speech(speech_window) {
|
||||
let skipped_ms =
|
||||
(target_len as u64 * 1000) / WHISPER_SAMPLE_RATE as u64;
|
||||
let speech_gate = evaluate_speech_gate(speech_window);
|
||||
if speech_gate.skip {
|
||||
let skipped_ms = (target_len as u64 * 1000) / WHISPER_SAMPLE_RATE as u64;
|
||||
let gate_reason = match speech_gate.reason {
|
||||
"silence" => "near-silence",
|
||||
"insufficient_speech" => "insufficient speech energy",
|
||||
other => other,
|
||||
};
|
||||
eprintln!(
|
||||
"[live] session {session_id}: skipped {skipped_ms}ms chunk as near-silence"
|
||||
"[live] session {session_id}: skipped {skipped_ms}ms chunk as {gate_reason} \
|
||||
(peak_rms={:.6}, peak={:.6}, speech_windows={}/{}, max_consecutive={})",
|
||||
speech_gate.peak_rms,
|
||||
speech_gate.peak_amplitude,
|
||||
speech_gate.speech_window_count,
|
||||
speech_gate.window_count,
|
||||
speech_gate.max_consecutive_speech_windows,
|
||||
);
|
||||
let _ = status_channel.send(LiveStatusMessage::Warning {
|
||||
session_id,
|
||||
message: format!(
|
||||
"Skipped {skipped_ms}ms of near-silent audio. If this keeps happening, try a louder mic level or move closer to the microphone."
|
||||
),
|
||||
message: match speech_gate.reason {
|
||||
"silence" => format!(
|
||||
"Skipped {skipped_ms}ms of near-silent audio. If this keeps happening, try a louder mic level or move closer to the microphone."
|
||||
),
|
||||
_ => format!(
|
||||
"Skipped {skipped_ms}ms of low-confidence audio. If this keeps happening, try a louder mic level or reduce background noise."
|
||||
),
|
||||
},
|
||||
});
|
||||
capture_buffer.drain(..target_len);
|
||||
*buffer_start_sample = buffer_start_sample.saturating_add(target_len as u64);
|
||||
@@ -553,6 +605,7 @@ fn poll_inference(
|
||||
inflight: &mut Option<InferenceTask>,
|
||||
session_id: u64,
|
||||
config: &StartLiveTranscriptionConfig,
|
||||
recent_segments: &mut Vec<RecentTranscriptSegment>,
|
||||
dictionary_terms: &[String],
|
||||
result_channel: &Channel<LiveResultMessage>,
|
||||
status_channel: &Channel<LiveStatusMessage>,
|
||||
@@ -563,8 +616,7 @@ fn poll_inference(
|
||||
|
||||
match task.rx.try_recv() {
|
||||
Ok(Ok(timed)) => {
|
||||
let mut segments: Vec<Segment> =
|
||||
timed.transcript.segments().to_vec();
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
trim_overlap_segments(&mut segments, task.trim_before_secs);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
@@ -576,25 +628,37 @@ fn poll_inference(
|
||||
dictionary_terms: dictionary_terms.to_vec(),
|
||||
},
|
||||
);
|
||||
let chunk_start_secs = task.chunk_start_sample as f64 / WHISPER_SAMPLE_RATE as f64;
|
||||
let skipped_duplicates = filter_duplicate_boundary_segments(
|
||||
&mut segments,
|
||||
chunk_start_secs,
|
||||
recent_segments,
|
||||
);
|
||||
let segment_count = segments.len();
|
||||
let delivered_segments = segments.clone();
|
||||
|
||||
result_channel
|
||||
.send(LiveResultMessage {
|
||||
session_id,
|
||||
chunk_id: task.chunk_id,
|
||||
chunk_start_secs: task.chunk_start_sample as f64
|
||||
/ WHISPER_SAMPLE_RATE as f64,
|
||||
chunk_start_secs,
|
||||
duration: task.duration_secs,
|
||||
language: timed.transcript.language().to_string(),
|
||||
inference_ms: timed.inference_ms,
|
||||
segments,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
||||
eprintln!(
|
||||
"[live] session {session_id}: delivered chunk {} with {} segments in {}ms",
|
||||
"[live] session {session_id}: delivered chunk {} with {} segments in {}ms{}",
|
||||
task.chunk_id,
|
||||
segment_count,
|
||||
timed.inference_ms
|
||||
timed.inference_ms,
|
||||
if skipped_duplicates > 0 {
|
||||
format!(" (skipped {skipped_duplicates} duplicate boundary segment(s))")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
|
||||
*inflight = None;
|
||||
@@ -636,34 +700,314 @@ fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_enough_speech(samples: &[f32]) -> bool {
|
||||
if samples.is_empty() {
|
||||
fn filter_duplicate_boundary_segments(
|
||||
segments: &mut Vec<Segment>,
|
||||
chunk_start_secs: f64,
|
||||
recent_segments: &[RecentTranscriptSegment],
|
||||
) -> usize {
|
||||
if recent_segments.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut skipped = 0usize;
|
||||
segments.retain(|segment| {
|
||||
if segment.start > DUPLICATE_CHECK_LEADING_SECS {
|
||||
return true;
|
||||
}
|
||||
|
||||
let absolute_start = chunk_start_secs + segment.start;
|
||||
let candidates = build_nearby_transcript_candidates(recent_segments, absolute_start);
|
||||
if candidates.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let duplicate = candidates.iter().any(|candidate| {
|
||||
transcripts_overlap(&segment.text, candidate)
|
||||
|| transcripts_loosely_overlap(&segment.text, candidate)
|
||||
});
|
||||
|
||||
if duplicate {
|
||||
skipped += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
|
||||
skipped
|
||||
}
|
||||
|
||||
fn remember_recent_segments(
|
||||
recent_segments: &mut Vec<RecentTranscriptSegment>,
|
||||
segments: &[Segment],
|
||||
chunk_start_secs: f64,
|
||||
) {
|
||||
if segments.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for segment in segments {
|
||||
let text = segment.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
recent_segments.push(RecentTranscriptSegment {
|
||||
start_secs: chunk_start_secs + segment.start,
|
||||
end_secs: chunk_start_secs + segment.end,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let cutoff = recent_segments
|
||||
.last()
|
||||
.map(|segment| segment.end_secs - DUPLICATE_HISTORY_RETENTION_SECS)
|
||||
.unwrap_or(0.0);
|
||||
recent_segments.retain(|segment| segment.end_secs >= cutoff);
|
||||
}
|
||||
|
||||
fn build_nearby_transcript_candidates(
|
||||
recent_segments: &[RecentTranscriptSegment],
|
||||
timestamp_secs: f64,
|
||||
) -> Vec<String> {
|
||||
let mut nearby: Vec<&RecentTranscriptSegment> = recent_segments
|
||||
.iter()
|
||||
.filter(|segment| {
|
||||
!segment.text.trim().is_empty()
|
||||
&& (segment.end_secs - timestamp_secs).abs() <= DUPLICATE_TRANSCRIPT_WINDOW_SECS
|
||||
})
|
||||
.collect();
|
||||
|
||||
nearby.sort_by(|left, right| {
|
||||
left.start_secs
|
||||
.partial_cmp(&right.start_secs)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let mut texts: Vec<String> = Vec::new();
|
||||
for start in 0..nearby.len() {
|
||||
let mut merged = String::new();
|
||||
for end in start..nearby.len().min(start + DUPLICATE_TRANSCRIPT_MERGE_LIMIT) {
|
||||
if !merged.is_empty() {
|
||||
merged.push(' ');
|
||||
}
|
||||
merged.push_str(nearby[end].text.trim());
|
||||
if !texts.iter().any(|existing| existing == &merged) {
|
||||
texts.push(merged.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
texts
|
||||
}
|
||||
|
||||
fn normalize_transcript_text(text: &str) -> String {
|
||||
let mut normalized = String::with_capacity(text.len());
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for lower in ch.to_lowercase() {
|
||||
normalized.push(lower);
|
||||
}
|
||||
} else {
|
||||
normalized.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
normalized.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn count_common_tokens<'a>(a: &[&'a str], b: &[&'a str]) -> usize {
|
||||
let mut counts: HashMap<&'a str, usize> = HashMap::new();
|
||||
for token in a {
|
||||
*counts.entry(*token).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut common = 0usize;
|
||||
for token in b {
|
||||
if let Some(remaining) = counts.get_mut(*token) {
|
||||
if *remaining > 0 {
|
||||
*remaining -= 1;
|
||||
common += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
common
|
||||
}
|
||||
|
||||
fn longest_common_token_subsequence(a: &[&str], b: &[&str]) -> usize {
|
||||
let mut prev = vec![0usize; b.len() + 1];
|
||||
let mut curr = vec![0usize; b.len() + 1];
|
||||
|
||||
for token_a in a {
|
||||
for (j, token_b) in b.iter().enumerate() {
|
||||
curr[j + 1] = if token_a == token_b {
|
||||
prev[j] + 1
|
||||
} else {
|
||||
prev[j + 1].max(curr[j])
|
||||
};
|
||||
}
|
||||
prev.clone_from(&curr);
|
||||
curr.fill(0);
|
||||
}
|
||||
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
fn is_low_signal_token(token: &str) -> bool {
|
||||
LOW_SIGNAL_TOKENS
|
||||
.iter()
|
||||
.any(|low_signal| *low_signal == token)
|
||||
}
|
||||
|
||||
fn meaningful_tokens<'a>(text: &'a str) -> Vec<&'a str> {
|
||||
text.split_whitespace()
|
||||
.filter(|token| !token.is_empty() && token.len() > 1 && !is_low_signal_token(token))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn transcripts_overlap(a: &str, b: &str) -> bool {
|
||||
let normalized_a = normalize_transcript_text(a);
|
||||
let normalized_b = normalize_transcript_text(b);
|
||||
if normalized_a.is_empty() || normalized_b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if normalized_a == normalized_b
|
||||
|| normalized_a.contains(&normalized_b)
|
||||
|| normalized_b.contains(&normalized_a)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let tokens_a: Vec<&str> = normalized_a.split_whitespace().collect();
|
||||
let tokens_b: Vec<&str> = normalized_b.split_whitespace().collect();
|
||||
let shorter = tokens_a.len().min(tokens_b.len());
|
||||
if shorter < MIN_TOKENS_FOR_OVERLAP {
|
||||
return false;
|
||||
}
|
||||
|
||||
let common = count_common_tokens(&tokens_a, &tokens_b);
|
||||
if common as f64 / shorter as f64 >= TOKEN_COVERAGE_THRESHOLD {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sequence = longest_common_token_subsequence(&tokens_a, &tokens_b);
|
||||
sequence as f64 / shorter as f64 >= TOKEN_SEQUENCE_THRESHOLD
|
||||
}
|
||||
|
||||
fn transcripts_loosely_overlap(a: &str, b: &str) -> bool {
|
||||
let normalized_a = normalize_transcript_text(a);
|
||||
let normalized_b = normalize_transcript_text(b);
|
||||
if normalized_a.is_empty() || normalized_b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if normalized_a == normalized_b
|
||||
|| normalized_a.contains(&normalized_b)
|
||||
|| normalized_b.contains(&normalized_a)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let tokens_a = meaningful_tokens(&normalized_a);
|
||||
let tokens_b = meaningful_tokens(&normalized_b);
|
||||
let shorter = tokens_a.len().min(tokens_b.len());
|
||||
if shorter < MIN_MEANINGFUL_TOKENS_FOR_OVERLAP {
|
||||
return false;
|
||||
}
|
||||
|
||||
let common = count_common_tokens(&tokens_a, &tokens_b);
|
||||
if common as f64 / shorter as f64 >= MEANINGFUL_TOKEN_COVERAGE_THRESHOLD {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sequence = longest_common_token_subsequence(&tokens_a, &tokens_b);
|
||||
sequence as f64 / shorter as f64 >= MEANINGFUL_TOKEN_SEQUENCE_THRESHOLD
|
||||
}
|
||||
|
||||
fn record_speech_window(state: &mut SpeechGateState, rms: f32, peak: f32) {
|
||||
state.window_count += 1;
|
||||
state.peak_rms = state.peak_rms.max(rms);
|
||||
state.peak_amplitude = state.peak_amplitude.max(peak);
|
||||
|
||||
let is_speech_window =
|
||||
rms >= SPEECH_WINDOW_RMS_THRESHOLD && peak >= SPEECH_WINDOW_PEAK_THRESHOLD;
|
||||
if !is_speech_window {
|
||||
state.consecutive_speech_windows = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.speech_window_count += 1;
|
||||
state.consecutive_speech_windows += 1;
|
||||
state.max_consecutive_speech_windows = state
|
||||
.max_consecutive_speech_windows
|
||||
.max(state.consecutive_speech_windows);
|
||||
}
|
||||
|
||||
fn speech_gate_decision(state: SpeechGateState, chunk_peak: f32) -> SpeechGateDecision {
|
||||
if state.window_count == 0 {
|
||||
return SpeechGateDecision {
|
||||
skip: false,
|
||||
reason: "unavailable",
|
||||
peak_rms: state.peak_rms,
|
||||
peak_amplitude: state.peak_amplitude,
|
||||
window_count: state.window_count,
|
||||
speech_window_count: state.speech_window_count,
|
||||
max_consecutive_speech_windows: state.max_consecutive_speech_windows,
|
||||
};
|
||||
}
|
||||
|
||||
let reason = if chunk_peak < FLATLINE_PEAK_THRESHOLD || state.peak_rms < SILENCE_RMS_THRESHOLD {
|
||||
Some("silence")
|
||||
} else if state.speech_window_count < MIN_SPEECH_FRAMES
|
||||
&& state.peak_rms < STRONG_SPEECH_RMS_THRESHOLD
|
||||
&& state.peak_amplitude < STRONG_SPEECH_PEAK_THRESHOLD
|
||||
{
|
||||
Some("insufficient_speech")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
SpeechGateDecision {
|
||||
skip: reason.is_some(),
|
||||
reason: reason.unwrap_or("speech_detected"),
|
||||
peak_rms: state.peak_rms,
|
||||
peak_amplitude: state.peak_amplitude,
|
||||
window_count: state.window_count,
|
||||
speech_window_count: state.speech_window_count,
|
||||
max_consecutive_speech_windows: state.max_consecutive_speech_windows,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_speech_gate(samples: &[f32]) -> SpeechGateDecision {
|
||||
if samples.is_empty() {
|
||||
return SpeechGateDecision {
|
||||
skip: true,
|
||||
reason: "silence",
|
||||
peak_rms: 0.0,
|
||||
peak_amplitude: 0.0,
|
||||
window_count: 0,
|
||||
speech_window_count: 0,
|
||||
max_consecutive_speech_windows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let chunk_peak = samples
|
||||
.iter()
|
||||
.map(|sample| sample.abs())
|
||||
.fold(0.0_f32, f32::max);
|
||||
if chunk_peak < FLATLINE_PEAK_THRESHOLD {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut speech_frames = 0usize;
|
||||
let mut state = SpeechGateState::default();
|
||||
for frame in samples.chunks(SPEECH_FRAME_SAMPLES) {
|
||||
let len = frame.len().max(1) as f32;
|
||||
let rms = (frame.iter().map(|sample| sample * sample).sum::<f32>() / len)
|
||||
.sqrt();
|
||||
let rms = (frame.iter().map(|sample| sample * sample).sum::<f32>() / len).sqrt();
|
||||
let peak = frame
|
||||
.iter()
|
||||
.map(|sample| sample.abs())
|
||||
.fold(0.0_f32, f32::max);
|
||||
if rms >= RMS_SPEECH_THRESHOLD || peak >= PEAK_SPEECH_THRESHOLD {
|
||||
speech_frames += 1;
|
||||
}
|
||||
record_speech_window(&mut state, rms, peak);
|
||||
}
|
||||
|
||||
speech_frames >= MIN_SPEECH_FRAMES
|
||||
speech_gate_decision(state, chunk_peak)
|
||||
}
|
||||
|
||||
fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
|
||||
@@ -676,3 +1020,114 @@ fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
|
||||
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn segment(start: f64, end: f64, text: &str) -> Segment {
|
||||
Segment {
|
||||
start,
|
||||
end,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcripts_overlap_detects_boundary_repeat() {
|
||||
assert!(transcripts_overlap(
|
||||
"I need to go to the shops tomorrow",
|
||||
"to go to the shops tomorrow"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_overlap_ignores_low_signal_only_match() {
|
||||
assert!(!transcripts_loosely_overlap(
|
||||
"I think we should do that soon",
|
||||
"we should maybe do it soon"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_boundary_filter_skips_repeated_opening_segment() {
|
||||
let recent_segments = vec![RecentTranscriptSegment {
|
||||
start_secs: 10.0,
|
||||
end_secs: 12.0,
|
||||
text: "I need to go to the shops tomorrow".to_string(),
|
||||
}];
|
||||
let mut segments = vec![
|
||||
segment(0.2, 1.0, "Need to go to the shops tomorrow"),
|
||||
segment(1.8, 2.4, "While I am there I need some cheese"),
|
||||
];
|
||||
|
||||
let skipped = filter_duplicate_boundary_segments(&mut segments, 11.8, &recent_segments);
|
||||
|
||||
assert_eq!(skipped, 1);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].text, "While I am there I need some cheese");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_recent_segments_prunes_old_history() {
|
||||
let mut recent_segments = vec![RecentTranscriptSegment {
|
||||
start_secs: 0.0,
|
||||
end_secs: 1.0,
|
||||
text: "old text".to_string(),
|
||||
}];
|
||||
|
||||
remember_recent_segments(
|
||||
&mut recent_segments,
|
||||
&[segment(0.0, 0.8, "new text")],
|
||||
DUPLICATE_HISTORY_RETENTION_SECS + 1.0,
|
||||
);
|
||||
|
||||
assert_eq!(recent_segments.len(), 1);
|
||||
assert_eq!(recent_segments[0].text, "new text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_gate_treats_near_silence_as_skippable() {
|
||||
let samples = vec![0.0004_f32, 0.0002, 0.0003, 0.0001]
|
||||
.into_iter()
|
||||
.cycle()
|
||||
.take(SPEECH_FRAME_SAMPLES * 3)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let decision = evaluate_speech_gate(&samples);
|
||||
|
||||
assert!(decision.skip);
|
||||
assert_eq!(decision.reason, "silence");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_gate_rejects_isolated_noise_without_speech_windows() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..(SPEECH_FRAME_SAMPLES * 3) {
|
||||
let sample = if i % SPEECH_FRAME_SAMPLES == 0 {
|
||||
0.010
|
||||
} else {
|
||||
0.0011
|
||||
};
|
||||
samples.push(sample);
|
||||
}
|
||||
|
||||
let decision = evaluate_speech_gate(&samples);
|
||||
|
||||
assert!(decision.skip);
|
||||
assert_eq!(decision.reason, "insufficient_speech");
|
||||
assert_eq!(decision.speech_window_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_gate_allows_sustained_speech_like_audio() {
|
||||
let samples = vec![0.014_f32; SPEECH_FRAME_SAMPLES * 3];
|
||||
|
||||
let decision = evaluate_speech_gate(&samples);
|
||||
|
||||
assert!(!decision.skip);
|
||||
assert_eq!(decision.reason, "speech_detected");
|
||||
assert_eq!(decision.speech_window_count, 3);
|
||||
assert_eq!(decision.max_consecutive_speech_windows, 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user