Land release blocker fixes and workspace cleanup
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

This commit is contained in:
2026-04-23 00:16:09 +01:00
parent d7363cc913
commit 9b0067b4c0
36 changed files with 1529 additions and 418 deletions

View File

@@ -308,8 +308,7 @@ fn recording_filename() -> String {
/// restarts, so cross-launch collisions are already impossible — the
/// counter is the last-mile guarantee against within-launch same-tick
/// collisions.
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(test)]
mod tests {
@@ -353,10 +352,20 @@ mod tests {
3,
"expected three '-' separated parts, got {parts:?}"
);
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits");
assert_eq!(parts[1].len(), 9, "nanos component is zero-padded to 9 digits");
assert!(
parts[0].chars().all(|c| c.is_ascii_digit()),
"secs is digits"
);
assert_eq!(
parts[1].len(),
9,
"nanos component is zero-padded to 9 digits"
);
assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
assert!(parts[2].len() >= 4, "counter component is zero-padded to >=4 digits");
assert!(
parts[2].len() >= 4,
"counter component is zero-padded to >=4 digits"
);
assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
}

View File

@@ -19,6 +19,7 @@ use kon_storage::{
app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow,
};
use crate::commands::power::active_assertions_snapshot;
use crate::AppState;
const DEFAULT_RECENT_ERRORS: i64 = 50;
@@ -290,6 +291,20 @@ pub async fn generate_diagnostic_report(
}
}
out.push_str("## Power assertions\n\n");
let power_assertions = active_assertions_snapshot();
if power_assertions.is_empty() {
out.push_str("_(no active power assertions at report time)_\n\n");
} else {
for assertion in power_assertions {
out.push_str(&format!(
"- `#{}` reason=`{}` backend=`{}` acquired=`{}`\n",
assertion.id, assertion.reason, assertion.backend, assertion.acquired
));
}
out.push('\n');
}
if opts.include_crashes {
out.push_str("## Crash dumps\n\n");
let crashes = list_crash_files().await.unwrap_or_default();

View File

@@ -11,6 +11,7 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
use tokio::sync::Mutex as AsyncMutex;
use crate::commands::audio::resolve_recording_path;
use crate::commands::build_initial_prompt;
@@ -18,7 +19,9 @@ use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::power::PowerAssertion;
use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_audio::{MicrophoneCapture, StreamingResampler, WavWriter};
use kon_audio::{
AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter,
};
use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
use kon_transcription::LocalEngine;
@@ -57,6 +60,7 @@ const LOW_SIGNAL_TOKENS: &[&str] = &[
#[derive(Default)]
pub struct LiveTranscriptionState {
next_session_id: AtomicU64,
lifecycle: AsyncMutex<()>,
running: Mutex<Option<RunningLiveSession>>,
}
@@ -153,6 +157,292 @@ struct LiveSessionSummary {
audio_path: Option<String>,
}
/// Session worker state is thread-confined to the single blocking task spawned
/// by `start_live_transcription_session`. Cross-thread coordination happens via
/// the stop flag and mpsc channels only; RB-01 will tighten the outer
/// `live_state.running` lock discipline now that this worker lifecycle is
/// explicit and locally structured.
struct ActiveCapture {
/// Keeping the capture handle alive keeps the underlying cpal stream alive.
_capture: MicrophoneCapture,
rx: std::sync::mpsc::Receiver<AudioChunk>,
mic_error_rx: Option<std::sync::mpsc::Receiver<CaptureRuntimeError>>,
}
impl ActiveCapture {
fn start(config: &StartLiveTranscriptionConfig) -> Result<Self, String> {
let (mut capture, rx) = match config.microphone_device.as_deref() {
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
_ => MicrophoneCapture::start(),
}
.map_err(|e| e.to_string())?;
let mic_error_rx = capture.take_error_rx();
Ok(Self {
_capture: capture,
rx,
mic_error_rx,
})
}
fn drain_runtime_errors(
&mut self,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) {
let Some(err_rx) = &self.mic_error_rx else {
return;
};
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
}
#[derive(Default)]
struct LiveLoopState {
resampler: Option<StreamingResampler>,
capture_buffer: Vec<f32>,
wav_writer: Option<WavWriter>,
buffer_start_sample: u64,
dropped_audio_ms: u64,
chunk_id: u32,
inflight: Option<InferenceTask>,
resampler_flushed: bool,
result_listener_lost: bool,
recent_segments: Vec<RecentTranscriptSegment>,
}
impl LiveLoopState {
fn new(wav_writer: Option<WavWriter>) -> Self {
Self {
wav_writer,
..Self::default()
}
}
}
struct LiveSessionRuntime {
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
capture: ActiveCapture,
state: LiveLoopState,
}
impl LiveSessionRuntime {
fn new(
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
) -> Result<Self, String> {
let capture = ActiveCapture::start(&config)?;
let wav_writer = open_wav_writer(&engine, audio_path.as_ref(), session_id, &status_channel);
Ok(Self {
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
capture,
state: LiveLoopState::new(wav_writer),
})
}
fn run(mut self) -> Result<LiveSessionSummary, String> {
loop {
self.poll_inference()?;
self.capture
.drain_runtime_errors(self.session_id, &self.status_channel);
if let Some(chunk) = self.recv_audio()? {
self.process_audio_chunk(chunk)?;
}
self.drop_pending_overflow();
self.flush_tail_if_stopping()?;
if self.dispatch_inference_if_ready() {
continue;
}
if self.should_exit_loop() {
break;
}
}
self.drain_inference()?;
self.finish()
}
fn poll_inference(&mut self) -> Result<(), String> {
let _ = poll_inference(
&mut self.state.inflight,
&mut self.state.result_listener_lost,
self.session_id,
&self.config,
&mut self.state.recent_segments,
&self.dictionary_terms,
&self.result_channel,
&self.status_channel,
)?;
Ok(())
}
fn recv_audio(&mut self) -> Result<Option<AudioChunk>, String> {
match self.capture.rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => Ok(Some(chunk)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(None),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = self.status_channel.send(LiveStatusMessage::Error {
session_id: self.session_id,
message: message.clone(),
});
Err(message)
}
}
}
fn process_audio_chunk(&mut self, chunk: AudioChunk) -> Result<(), String> {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut self.state.resampler {
Some(resampler) => resampler,
None => {
self.state.resampler =
Some(StreamingResampler::new(chunk.sample_rate).map_err(|e| e.to_string())?);
self.state.resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&resampled,
self.session_id,
&self.status_channel,
);
Ok(())
}
fn drop_pending_overflow(&mut self) {
if self.state.inflight.is_none() || self.state.capture_buffer.len() <= MAX_PENDING_SAMPLES {
return;
}
let overflow = self.state.capture_buffer.len() - MAX_PENDING_SAMPLES;
self.state.capture_buffer.drain(..overflow);
self.state.buffer_start_sample = self
.state
.buffer_start_sample
.saturating_add(overflow as u64);
self.state.dropped_audio_ms = self
.state
.dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = self.status_channel.send(LiveStatusMessage::Overload {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
});
}
fn flush_tail_if_stopping(&mut self) -> Result<(), String> {
if !self.stopping() || self.state.resampler_flushed {
return Ok(());
}
if let Some(resampler) = &mut self.state.resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&tail,
self.session_id,
&self.status_channel,
);
}
self.flush_wav_header();
self.state.resampler_flushed = true;
Ok(())
}
fn flush_wav_header(&mut self) {
let Some(writer) = self.state.wav_writer.as_mut() else {
return;
};
if let Err(e) = writer.flush() {
let _ = self.status_channel.send(LiveStatusMessage::Warning {
session_id: self.session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
fn dispatch_inference_if_ready(&mut self) -> bool {
if self.state.inflight.is_some() {
return false;
}
let stopping = self.stopping();
if let Some(task) = maybe_dispatch_chunk(
&self.engine,
&self.config,
&mut self.state.capture_buffer,
&mut self.state.buffer_start_sample,
&mut self.state.chunk_id,
stopping,
&self.status_channel,
self.session_id,
) {
self.state.inflight = Some(task);
return true;
}
false
}
fn stopping(&self) -> bool {
self.stop_flag.load(Ordering::Relaxed)
}
fn should_exit_loop(&self) -> bool {
self.stopping() && self.state.resampler_flushed && self.state.inflight.is_none()
}
fn drain_inference(&mut self) -> Result<(), String> {
while self.state.inflight.is_some() {
self.poll_inference()?;
thread::sleep(Duration::from_millis(10));
}
Ok(())
}
fn finish(mut self) -> Result<LiveSessionSummary, String> {
let audio_path = finalize_wav_writer(
self.state.wav_writer.take(),
self.audio_path.as_ref(),
self.session_id,
&self.status_channel,
);
Ok(LiveSessionSummary {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
audio_path,
})
}
}
struct InferenceTask {
chunk_id: u32,
chunk_start_sample: u64,
@@ -198,6 +488,7 @@ pub async fn start_live_transcription_session(
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
) -> Result<StartLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{
let running = live_state.running.lock().unwrap();
if running.is_some() {
@@ -227,11 +518,8 @@ pub async fn start_live_transcription_session(
// `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks
// up profile fallback + vocabulary injection without further plumbing.
let request_prompt = config.initial_prompt.clone().unwrap_or_default();
config.initial_prompt = build_initial_prompt(
&request_prompt,
&profile.initial_prompt,
&profile_terms,
);
config.initial_prompt =
build_initial_prompt(&request_prompt, &profile.initial_prompt, &profile_terms);
let model_id = config
.model_id
@@ -258,7 +546,10 @@ pub async fn start_live_transcription_session(
// for save_audio=true and silently dropping the recording would
// surprise them worse.
let audio_path = if config.save_audio {
Some(resolve_recording_path(&app, config.output_folder.as_deref())?)
Some(resolve_recording_path(
&app,
config.output_folder.as_deref(),
)?)
} else {
None
};
@@ -299,6 +590,7 @@ pub async fn stop_live_transcription_session(
live_state: tauri::State<'_, LiveTranscriptionState>,
session_id: u64,
) -> Result<StopLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take();
let Some(running) = running else {
return Err("No live transcription session is running".into());
@@ -360,203 +652,54 @@ fn run_live_session(
// lifetime to the session — when the function returns, the Drop
// impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md.
let _power_guard = PowerAssertion::begin("kon live dictation session");
LiveSessionRuntime::new(
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
)?
.run()
}
let (mut capture, rx) = match config.microphone_device.as_deref() {
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
_ => MicrophoneCapture::start(),
}
.map_err(|e| e.to_string())?;
// Drain runtime stream errors into the status channel so the user
// gets a toast when the device disconnects mid-recording instead of
// silently producing empty transcripts. The `_capture` binding keeps
// the cpal stream alive for the duration of the session.
let mic_error_rx = capture.take_error_rx();
let _capture = capture;
let mut resampler: Option<StreamingResampler> = None;
let mut capture_buffer: Vec<f32> = Vec::new();
// Progressive WAV writer (brief item #19). Sample rate comes from
// the loaded backend's capabilities (#13 wiring) so a future
// non-16kHz backend records at its native rate without further
// plumbing. The writer flushes its header every ~500 ms, so the
// file on disk is a playable WAV even if the process is killed.
fn open_wav_writer(
engine: &Arc<LocalEngine>,
audio_path: Option<&PathBuf>,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) -> Option<WavWriter> {
let sample_rate = engine
.capabilities()
.map(|c| c.sample_rate)
.unwrap_or(WHISPER_SAMPLE_RATE);
let mut wav_writer: Option<WavWriter> = match audio_path.as_ref() {
Some(path) => match WavWriter::create(path, sample_rate, 1) {
Ok(w) => Some(w),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Failed to open audio recording file ({}); transcription will continue without saving audio.",
e
),
});
None
}
},
None => None,
};
// `reported_audio_path` is decided at end-of-session based on
// whether the writer finalised successfully, not at open time.
// This way a writer that dies mid-session (append error clearing
// wav_writer, or finalise returning Err) does not leak a stale
// path back to the frontend that might point to a file whose
// header is out of sync with its data chunk.
let mut buffer_start_sample: u64 = 0;
let mut dropped_audio_ms: u64 = 0;
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,
)? {}
// Surface any cpal runtime errors as warnings. Non-fatal: a hard
// disconnect will also drop the audio sender and be caught by
// the `Disconnected` arm below. This lets the user see a toast
// even when cpal recovers without tearing the stream down.
if let Some(err_rx) = &mic_error_rx {
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
match rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut resampler {
Some(resampler) => resampler,
None => {
resampler = Some(
StreamingResampler::new(chunk.sample_rate)
.map_err(|e| e.to_string())?,
);
resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&resampled,
session_id,
&status_channel,
);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = status_channel.send(LiveStatusMessage::Error {
session_id,
message: message.clone(),
});
return Err(message);
}
}
if inflight.is_some() && capture_buffer.len() > MAX_PENDING_SAMPLES {
let overflow = capture_buffer.len() - MAX_PENDING_SAMPLES;
capture_buffer.drain(..overflow);
buffer_start_sample = buffer_start_sample.saturating_add(overflow as u64);
dropped_audio_ms = dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = status_channel.send(LiveStatusMessage::Overload {
let path = audio_path?;
match WavWriter::create(path, sample_rate, 1) {
Ok(writer) => Some(writer),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
message: format!(
"Failed to open audio recording file ({}); transcription will continue without saving audio.",
e
),
});
}
let stopping = stop_flag.load(Ordering::Relaxed);
if stopping && !resampler_flushed {
if let Some(resampler) = &mut resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&tail,
session_id,
&status_channel,
);
}
resampler_flushed = true;
// Final flush for the WAV header so the last chunk's header
// update is on disk before we drop into the inference drain.
if let Some(writer) = wav_writer.as_mut() {
if let Err(e) = writer.flush() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
}
if inflight.is_none() {
if let Some(task) = maybe_dispatch_chunk(
&engine,
&config,
&mut capture_buffer,
&mut buffer_start_sample,
&mut chunk_id,
stopping,
&status_channel,
session_id,
) {
inflight = Some(task);
continue;
}
if stopping && resampler_flushed {
break;
}
None
}
}
}
while inflight.is_some() {
poll_inference(
&mut inflight,
session_id,
&config,
&mut recent_segments,
&dictionary_terms,
&result_channel,
&status_channel,
)?;
thread::sleep(Duration::from_millis(10));
}
// Finalise the progressive WAV writer and decide whether to
// report a path to the frontend. Only a clean finalise produces a
// reported path: a writer that died mid-session (wav_writer was
// already None) or a finalise that itself errored both yield
// `None`, so `StopLiveTranscriptionResponse.audio_path` reflects
// "recording is known-good" rather than "recording was attempted".
let audio_path = match wav_writer.take() {
fn finalize_wav_writer(
wav_writer: Option<WavWriter>,
audio_path: Option<&PathBuf>,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) -> Option<String> {
match wav_writer {
Some(writer) => match writer.finalize() {
Ok(()) => audio_path.as_ref().map(|p| p.to_string_lossy().to_string()),
Ok(()) => audio_path.map(|path| path.to_string_lossy().to_string()),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
@@ -568,13 +711,7 @@ fn run_live_session(
}
},
None => None,
};
Ok(LiveSessionSummary {
session_id,
dropped_audio_ms,
audio_path,
})
}
}
fn append_resampled_audio(
@@ -719,6 +856,7 @@ fn maybe_dispatch_chunk(
fn poll_inference(
inflight: &mut Option<InferenceTask>,
result_listener_lost: &mut bool,
session_id: u64,
config: &StartLiveTranscriptionConfig,
recent_segments: &mut Vec<RecentTranscriptSegment>,
@@ -761,22 +899,30 @@ fn poll_inference(
);
let segment_count = segments.len();
let delivered_segments = segments.clone();
result_channel
.send(LiveResultMessage {
session_id,
chunk_id: task.chunk_id,
chunk_start_secs,
duration: task.duration_secs,
language: timed.transcript.language().to_string(),
inference_ms: timed.inference_ms,
segments,
raw_text,
})
.map_err(|e| e.to_string())?;
let result_message = LiveResultMessage {
session_id,
chunk_id: task.chunk_id,
chunk_start_secs,
duration: task.duration_secs,
language: timed.transcript.language().to_string(),
inference_ms: timed.inference_ms,
segments,
raw_text,
};
let delivered = emit_live_result(
result_channel,
status_channel,
result_listener_lost,
&result_message,
);
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}: {} chunk {} with {} segments in {}ms{}",
if delivered {
"delivered"
} else {
"processed without listener for"
},
task.chunk_id,
segment_count,
timed.inference_ms,
@@ -813,6 +959,33 @@ fn poll_inference(
}
}
fn emit_live_result(
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
result_listener_lost: &mut bool,
result_message: &LiveResultMessage,
) -> bool {
if *result_listener_lost {
return false;
}
match result_channel.send(result_message.clone()) {
Ok(()) => true,
Err(err) => {
*result_listener_lost = true;
eprintln!(
"[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates",
result_message.session_id, result_message.chunk_id, err
);
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id: result_message.session_id,
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
});
false
}
}
}
fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
if trim_before_secs <= 0.0 {
return;
@@ -1150,6 +1323,82 @@ fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
#[cfg(test)]
mod tests {
use super::*;
use tauri::ipc::InvokeResponseBody;
fn noop_status_channel() -> Channel<LiveStatusMessage> {
Channel::new(|_| Ok(()))
}
fn collecting_status_channel(payloads: Arc<Mutex<Vec<String>>>) -> Channel<LiveStatusMessage> {
Channel::new(move |body| {
if let InvokeResponseBody::Json(json) = body {
payloads.lock().unwrap().push(json);
}
Ok(())
})
}
fn dummy_running_session(
id: u64,
release_join: Option<Arc<tokio::sync::Notify>>,
) -> RunningLiveSession {
let stop_flag = Arc::new(AtomicBool::new(false));
let handle = tokio::spawn(async move {
if let Some(notify) = release_join {
notify.notified().await;
}
Ok(LiveSessionSummary {
session_id: id,
dropped_audio_ms: 0,
audio_path: None,
})
});
RunningLiveSession {
id,
stop_flag,
handle,
status_channel: noop_status_channel(),
}
}
async fn test_begin_session_start(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
release_setup: Option<Arc<tokio::sync::Notify>>,
) -> Result<u64, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{
let running = live_state.running.lock().unwrap();
if running.is_some() {
return Err("A live transcription session is already running".into());
}
}
if let Some(notify) = release_setup {
notify.notified().await;
}
*live_state.running.lock().unwrap() = Some(dummy_running_session(session_id, None));
Ok(session_id)
}
async fn test_stop_session(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
) -> Result<LiveSessionSummary, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take();
let Some(running) = running else {
return Err("No live transcription session is running".into());
};
if running.id != session_id {
*live_state.running.lock().unwrap() = Some(running);
return Err(format!("Session {session_id} is not active"));
}
running.stop_flag.store(true, Ordering::Relaxed);
running
.handle
.await
.map_err(|e| format!("Live session task failed: {e}"))?
}
fn segment(start: f64, end: f64, text: &str) -> Segment {
Segment {
@@ -1256,4 +1505,157 @@ mod tests {
assert_eq!(decision.speech_window_count, 3);
assert_eq!(decision.max_consecutive_speech_windows, 3);
}
#[test]
fn result_listener_loss_is_warned_once_and_not_treated_as_inference_failure() {
let statuses = Arc::new(Mutex::new(Vec::new()));
let status_channel = collecting_status_channel(statuses.clone());
let result_channel = Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let config = StartLiveTranscriptionConfig {
engine: "whisper".into(),
model_id: None,
language: Some("en".into()),
initial_prompt: None,
save_audio: false,
output_folder: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: "Raw".into(),
microphone_device: None,
profile_id: None,
};
let mut recent_segments = Vec::new();
let mut result_listener_lost = false;
let (tx1, rx1) = std::sync::mpsc::channel();
tx1.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.8, "first chunk")],
"en".into(),
0.8,
),
inference_ms: 12,
}))
.unwrap();
let mut inflight = Some(InferenceTask {
chunk_id: 1,
chunk_start_sample: 0,
trim_before_secs: 0.0,
duration_secs: 0.8,
rx: rx1,
});
let first = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(first, Some(true));
assert!(result_listener_lost);
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 1);
let warning_count_after_first = statuses.lock().unwrap().len();
assert_eq!(warning_count_after_first, 1);
assert!(
statuses.lock().unwrap()[0].contains("Live preview disconnected"),
"expected a warning about background continuation after listener loss"
);
let (tx2, rx2) = std::sync::mpsc::channel();
tx2.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.9, "second chunk")],
"en".into(),
0.9,
),
inference_ms: 14,
}))
.unwrap();
inflight = Some(InferenceTask {
chunk_id: 2,
chunk_start_sample: 16_000,
trim_before_secs: 0.0,
duration_secs: 0.9,
rx: rx2,
});
let second = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(second, Some(true));
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 2);
assert_eq!(
statuses.lock().unwrap().len(),
warning_count_after_first,
"listener-loss warning should only be emitted once"
);
}
#[tokio::test]
async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_setup = Arc::new(tokio::sync::Notify::new());
let first = tokio::spawn(test_begin_session_start(
live_state.clone(),
1,
Some(release_setup.clone()),
));
tokio::time::sleep(Duration::from_millis(20)).await;
let second = tokio::spawn(test_begin_session_start(live_state.clone(), 2, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!second.is_finished(),
"second start should wait on the lifecycle lock"
);
release_setup.notify_one();
assert_eq!(first.await.unwrap().unwrap(), 1);
let err = second.await.unwrap().unwrap_err();
assert_eq!(err, "A live transcription session is already running");
}
#[tokio::test]
async fn start_waits_for_stop_to_finish_joining_before_reusing_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_join = Arc::new(tokio::sync::Notify::new());
*live_state.running.lock().unwrap() =
Some(dummy_running_session(7, Some(release_join.clone())));
let stop = tokio::spawn(test_stop_session(live_state.clone(), 7));
tokio::time::sleep(Duration::from_millis(20)).await;
let start = tokio::spawn(test_begin_session_start(live_state.clone(), 8, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!start.is_finished(),
"new start should block until stop finishes joining the old worker"
);
release_join.notify_one();
let summary = stop.await.unwrap().unwrap();
assert_eq!(summary.session_id, 7);
assert_eq!(start.await.unwrap().unwrap(), 8);
}
}

View File

@@ -8,10 +8,10 @@ use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::hardware::{self, CpuFeatures};
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
#[cfg(feature = "whisper")]
use kon_transcription::load_whisper;
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
/// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId {
@@ -73,9 +73,7 @@ fn model_capability(
}
}
pub fn load_model_from_disk(
model_id: &ModelId,
) -> Result<Box<dyn Transcriber + Send>, String> {
pub fn load_model_from_disk(model_id: &ModelId) -> Result<Box<dyn Transcriber + Send>, String> {
let entry =
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
@@ -205,8 +203,7 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
// latency instead of the ~45s cold-start documented in
// ufal/whisper_streaming #96 and #135. Silence returns
// empty segments — the *work* is the context allocation.
let silence =
AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let options = TranscriptionOptions::default();
match whisper_engine.transcribe_sync(&silence, &options) {
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
@@ -379,11 +376,7 @@ fn supported_accelerators() -> Vec<String> {
} else {
AcceleratorTarget::Other
};
compose_accelerators(
cfg!(feature = "whisper"),
vulkan_loader_available(),
target,
)
compose_accelerators(cfg!(feature = "whisper"), vulkan_loader_available(), target)
}
/// Report which backend whisper.cpp was actually able to initialise
@@ -405,8 +398,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime."
.into(),
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime.".into(),
),
};
}

View File

@@ -396,7 +396,11 @@ fn detect_focused_window_class_macos() -> Option<String> {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
if name.is_empty() {
None
} else {
Some(name)
}
}
#[cfg(target_os = "windows")]
@@ -421,7 +425,11 @@ fn detect_focused_window_class_windows() -> Option<String> {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
if name.is_empty() {
None
} else {
Some(name)
}
}
fn trigger_paste_keystroke() -> Result<String, String> {
@@ -473,7 +481,10 @@ fn trigger_undo_keystroke() -> Result<String, String> {
}
#[cfg(target_os = "linux")]
fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> {
fn linux_paste(
xdg_session_type: Option<&str>,
wayland_display_set: bool,
) -> Result<String, String> {
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
match run_linux_tool(tool) {
Ok(()) => return Ok(tool.to_string()),

View File

@@ -21,7 +21,17 @@
//! may still decide to idle us. We log when that happens so the
//! diagnostics bundle has a breadcrumb.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone)]
pub struct PowerAssertionSnapshot {
pub id: usize,
pub reason: &'static str,
pub backend: &'static str,
pub acquired: bool,
}
/// Handle for a single power assertion. Dropping it releases the
/// assertion. Holders are expected to keep it alive in a field for
@@ -32,12 +42,30 @@ pub struct PowerAssertion {
#[allow(dead_code)]
id: usize,
reason: &'static str,
backend: &'static str,
acquired: bool,
#[cfg(target_os = "macos")]
activity: Option<objc_bridge::ActivityHandle>,
}
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
fn assertion_registry() -> &'static Mutex<HashMap<usize, PowerAssertionSnapshot>> {
static REGISTRY: OnceLock<Mutex<HashMap<usize, PowerAssertionSnapshot>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
let mut snapshots = assertion_registry()
.lock()
.unwrap()
.values()
.cloned()
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.id);
snapshots
}
impl PowerAssertion {
/// Begin a power assertion for the given reason. On macOS this
/// pins beginActivityWithOptions; on Linux/Windows it logs only
@@ -47,12 +75,16 @@ impl PowerAssertion {
#[cfg(target_os = "macos")]
let activity = objc_bridge::begin_activity(reason).ok();
#[cfg(target_os = "macos")]
let backend = "macos";
#[cfg(target_os = "macos")]
let acquired = activity.is_some();
#[cfg(target_os = "macos")]
if activity.is_none() {
eprintln!(
"[power] macOS App Nap guard could not begin activity for reason '{reason}'"
);
if acquired {
eprintln!("[power] began macOS App Nap guard #{id} for reason '{reason}'");
} else {
eprintln!("[power] macOS App Nap guard could not begin activity for reason '{reason}'");
}
#[cfg(not(target_os = "macos"))]
@@ -63,9 +95,26 @@ impl PowerAssertion {
let _ = reason;
}
#[cfg(not(target_os = "macos"))]
let backend = "noop";
#[cfg(not(target_os = "macos"))]
let acquired = false;
assertion_registry().lock().unwrap().insert(
id,
PowerAssertionSnapshot {
id,
reason,
backend,
acquired,
},
);
Self {
id,
reason,
backend,
acquired,
#[cfg(target_os = "macos")]
activity,
}
@@ -77,64 +126,83 @@ impl Drop for PowerAssertion {
#[cfg(target_os = "macos")]
if let Some(handle) = self.activity.take() {
objc_bridge::end_activity(handle);
eprintln!(
"[power] ended macOS App Nap guard #{} for reason '{}'",
self.id, self.reason
);
}
assertion_registry().lock().unwrap().remove(&self.id);
let _ = (self.reason, self.id);
let _ = (self.backend, self.acquired);
}
}
#[cfg(target_os = "macos")]
mod objc_bridge {
//! Placeholder for the NSProcessInfo App-Nap bridge.
//!
//! A proper implementation calls:
//! `NSProcessInfo *info = [NSProcessInfo processInfo];`
//! `id activity = [info beginActivityWithOptions:
//! (NSActivityUserInitiated | NSActivityLatencyCritical)
//! reason:reasonNSString];`
//! and retains the returned object until `end_activity`.
//!
//! This workstream ships the PowerAssertion RAII guard + wiring
//! so `commands/live.rs` and `commands/llm.rs` can adopt it today
//! (matters on macOS, no-op elsewhere). The actual `objc2` bridge
//! lands in a follow-up commit that can introduce `objc2` +
//! `objc2-foundation` without touching the rest of the workspace
//! in the same change.
//!
//! Until then, `begin_activity` returns Err; callers (`begin()`)
//! log a warning but keep running, so recording continues to work
//! as today — the gap is just the App-Nap protection, not the
//! recording itself.
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString};
pub struct ActivityHandle {
#[allow(dead_code)]
retained: *mut std::ffi::c_void,
activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
}
// SAFETY: The pointer is opaque to Rust; Foundation manages its
// lifetime via retain/release. We never dereference it directly.
unsafe impl Send for ActivityHandle {}
pub fn begin_activity(_reason: &str) -> Result<ActivityHandle, String> {
Err("macOS App Nap bridge not yet wired — objc2 integration tracked for a follow-up".into())
pub fn begin_activity(reason: &str) -> Result<ActivityHandle, String> {
let process_info = NSProcessInfo::processInfo();
let reason = NSString::from_str(reason);
let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical;
let activity = process_info.beginActivityWithOptions_reason(options, &reason);
Ok(ActivityHandle { activity })
}
pub fn end_activity(_handle: ActivityHandle) {}
pub fn end_activity(handle: ActivityHandle) {
let process_info = NSProcessInfo::processInfo();
unsafe {
process_info.endActivity(&handle.activity);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn power_test_guard() -> MutexGuard<'static, ()> {
static TEST_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn clear_assertion_registry() {
assertion_registry().lock().unwrap().clear();
}
#[test]
fn power_assertion_is_a_no_op_drop() {
let _guard = power_test_guard();
clear_assertion_registry();
let guard = PowerAssertion::begin("test-reason");
let snapshots = active_assertions_snapshot();
assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id));
drop(guard);
assert!(active_assertions_snapshot().is_empty());
}
#[test]
fn multiple_assertions_get_unique_ids() {
let _guard = power_test_guard();
clear_assertion_registry();
let a = PowerAssertion::begin("a");
let b = PowerAssertion::begin("b");
assert_ne!(a.id, b.id);
let snapshots = active_assertions_snapshot();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].reason, "a");
assert_eq!(snapshots[1].reason, "b");
drop(a);
drop(b);
assert!(active_assertions_snapshot().is_empty());
}
}

View File

@@ -74,8 +74,10 @@ async fn save_preferences(
/// known crashes that the HANDOVER documents working around with a manual
/// env-var prefix:
///
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ```sh
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ```
///
/// Detect the Wayland session at startup and apply the env vars before
/// anything else loads, so users do not need to remember the prefix and