Land release blocker fixes and workspace cleanup
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ~4–5s 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(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user