Land release blocker fixes and workspace cleanup
This commit is contained in:
@@ -225,13 +225,19 @@ mod tests {
|
||||
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
|
||||
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting-notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(
|
||||
LlmPromptPreset::parse("meeting-notes"),
|
||||
LlmPromptPreset::Notes
|
||||
);
|
||||
assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code);
|
||||
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
|
||||
// Unknown values and explicit default fall back safely.
|
||||
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse("random-unknown"), LlmPromptPreset::Default);
|
||||
assert_eq!(
|
||||
LlmPromptPreset::parse("random-unknown"),
|
||||
LlmPromptPreset::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,7 +246,10 @@ mod tests {
|
||||
// be empty so it composes cleanly with dictionary suffix.
|
||||
assert!(LlmPromptPreset::Default.suffix().is_empty());
|
||||
assert!(LlmPromptPreset::Email.suffix().contains("email"));
|
||||
assert!(LlmPromptPreset::Notes.suffix().to_lowercase().contains("bullet"));
|
||||
assert!(LlmPromptPreset::Notes
|
||||
.suffix()
|
||||
.to_lowercase()
|
||||
.contains("bullet"));
|
||||
assert!(LlmPromptPreset::Code.suffix().contains("technical"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +540,12 @@ mod tests {
|
||||
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
|
||||
// Exact-match on trail phrases means legitimate dialogue that
|
||||
// mentions "thanks" or "subscribe" is never dropped.
|
||||
assert!(!is_hallucination("Thanks for the heads up on the migration"));
|
||||
assert!(!is_hallucination("Please subscribe to the RSS feed and tell me when it updates"));
|
||||
assert!(!is_hallucination(
|
||||
"Thanks for the heads up on the migration"
|
||||
));
|
||||
assert!(!is_hallucination(
|
||||
"Please subscribe to the RSS feed and tell me when it updates"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,7 +168,10 @@ mod tests {
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let out = to_plain_text(&[seg(input)]);
|
||||
assert_eq!(out, expected, "input {input:?} should strip to {expected:?}");
|
||||
assert_eq!(
|
||||
out, expected,
|
||||
"input {input:?} should strip to {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,8 @@ impl WavWriter {
|
||||
};
|
||||
let file = std::fs::File::create(path).map_err(KonError::Io)?;
|
||||
let buffered = BufWriter::new(file);
|
||||
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}")))
|
||||
})?;
|
||||
let inner = hound::WavWriter::new(buffered, spec)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_since_flush: 0,
|
||||
@@ -77,9 +76,9 @@ impl WavWriter {
|
||||
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
||||
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush().map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}")))
|
||||
})?;
|
||||
self.inner
|
||||
.flush()
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
|
||||
self.samples_since_flush = 0;
|
||||
Ok(())
|
||||
}
|
||||
@@ -244,8 +243,7 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Write 100 samples (200 bytes at 16-bit).
|
||||
let original =
|
||||
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
||||
let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
||||
write_wav(&path, &original).unwrap();
|
||||
|
||||
// Drop the last 10 bytes — 5 samples' worth. hound's iterator
|
||||
|
||||
@@ -1,29 +1,77 @@
|
||||
/// Store an API key in the OS keychain.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Store an API key in Kon's process-local keystore.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Keys are only held in-process and lost on exit.
|
||||
/// Keys are held in memory for the lifetime of the process and are lost on
|
||||
/// exit. This avoids the undefined behaviour of mutating process environment
|
||||
/// variables from arbitrary threads while keeping the public API safe.
|
||||
///
|
||||
/// # Safety note
|
||||
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
|
||||
/// thread-safe — mutating the environment while other threads read it is
|
||||
/// undefined behaviour. This is acceptable during single-threaded app init
|
||||
/// but must not be called from async/multi-threaded contexts.
|
||||
/// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
|
||||
/// variables so externally injected secrets continue to work.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
||||
/// storage) so keys persist across sessions and are accessed safely.
|
||||
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
|
||||
pub fn store_api_key(provider: &str, key: &str) {
|
||||
// SAFETY: Only safe when called from a single-threaded context (e.g. app
|
||||
// initialisation). See doc comment above.
|
||||
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
|
||||
api_key_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(provider_env_key(provider), key.to_string());
|
||||
}
|
||||
|
||||
/// Retrieve an API key from the OS keychain.
|
||||
/// Retrieve an API key from Kon's process-local keystore.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Returns `None` if no key has been stored this session.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
|
||||
/// Returns a previously stored in-memory key when present, otherwise falls
|
||||
/// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
|
||||
/// operator-supplied secrets still work.
|
||||
pub fn retrieve_api_key(provider: &str) -> Option<String> {
|
||||
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok()
|
||||
let env_key = provider_env_key(provider);
|
||||
api_key_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&env_key)
|
||||
.cloned()
|
||||
.or_else(|| std::env::var(env_key).ok())
|
||||
}
|
||||
|
||||
fn api_key_store() -> &'static Mutex<HashMap<String, String>> {
|
||||
static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
STORE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn provider_env_key(provider: &str) -> String {
|
||||
format!("KON_API_KEY_{}", provider.to_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn unique_provider(prefix: &str) -> String {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
|
||||
format!("{prefix}_{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_key_is_retrievable_without_env_mutation() {
|
||||
let provider = unique_provider("provider");
|
||||
store_api_key(&provider, "secret-token");
|
||||
assert_eq!(
|
||||
retrieve_api_key(&provider),
|
||||
Some("secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_do_not_overlap() {
|
||||
let first = unique_provider("first");
|
||||
let second = unique_provider("second");
|
||||
|
||||
store_api_key(&first, "alpha");
|
||||
store_api_key(&second, "beta");
|
||||
|
||||
assert_eq!(retrieve_api_key(&first), Some("alpha".to_string()));
|
||||
assert_eq!(retrieve_api_key(&second), Some("beta".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,10 +349,7 @@ fn is_event_device(path: &Path) -> bool {
|
||||
/// Return true when the device's reported key set includes the combo's
|
||||
/// configured trigger key. A device that reports no keys at all (for
|
||||
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
||||
fn device_supports_combo(
|
||||
supported: Option<&AttributeSetRef<Key>>,
|
||||
combo: &HotkeyCombo,
|
||||
) -> bool {
|
||||
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
|
||||
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ pub mod prompts;
|
||||
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
||||
const MAX_CONTEXT_TOKENS: u32 = 8192;
|
||||
const CONTEXT_RESERVE_TOKENS: u32 = 64;
|
||||
const GENERATION_SEED: u32 = 0;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -26,6 +28,15 @@ pub enum EngineError {
|
||||
NotLoaded,
|
||||
#[error("LLM load failed: {0}")]
|
||||
LoadFailed(String),
|
||||
#[error(
|
||||
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
|
||||
)]
|
||||
PromptTooLong {
|
||||
prompt_tokens: usize,
|
||||
max_tokens: u32,
|
||||
available_prompt_tokens: u32,
|
||||
context_window: u32,
|
||||
},
|
||||
#[error("inference failed: {0}")]
|
||||
Inference(String),
|
||||
#[error("model output not valid JSON: {0}")]
|
||||
@@ -149,7 +160,7 @@ impl LlmEngine {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let n_ctx = context_window_size(prompt_tokens.len(), config.max_tokens);
|
||||
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
|
||||
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(Some(
|
||||
@@ -317,8 +328,26 @@ impl LlmEngine {
|
||||
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
|
||||
let required = prompt_tokens
|
||||
.saturating_add(max_tokens as usize)
|
||||
.saturating_add(64);
|
||||
DEFAULT_CONTEXT_TOKENS.max(required.min(8192) as u32)
|
||||
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
|
||||
DEFAULT_CONTEXT_TOKENS.max(required.min(MAX_CONTEXT_TOKENS as usize) as u32)
|
||||
}
|
||||
|
||||
fn preflight_context_window(prompt_tokens: usize, max_tokens: u32) -> Result<u32, EngineError> {
|
||||
let required = prompt_tokens
|
||||
.saturating_add(max_tokens as usize)
|
||||
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
|
||||
if required > MAX_CONTEXT_TOKENS as usize {
|
||||
let available_prompt_tokens =
|
||||
MAX_CONTEXT_TOKENS.saturating_sub(max_tokens.saturating_add(CONTEXT_RESERVE_TOKENS));
|
||||
return Err(EngineError::PromptTooLong {
|
||||
prompt_tokens,
|
||||
max_tokens,
|
||||
available_prompt_tokens,
|
||||
context_window: MAX_CONTEXT_TOKENS,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(context_window_size(prompt_tokens, max_tokens))
|
||||
}
|
||||
|
||||
fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
|
||||
@@ -417,4 +446,24 @@ mod tests {
|
||||
let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]);
|
||||
assert_eq!(index, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_preflight_rejects_oversized_prompt_tokens() {
|
||||
let err = preflight_context_window(7_105, 1_024).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
EngineError::PromptTooLong {
|
||||
prompt_tokens: 7_105,
|
||||
max_tokens: 1_024,
|
||||
available_prompt_tokens: 7_104,
|
||||
context_window: MAX_CONTEXT_TOKENS,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_preflight_keeps_prompts_within_budget() {
|
||||
let n_ctx = preflight_context_window(7_104, 1_024).unwrap();
|
||||
assert_eq!(n_ctx, MAX_CONTEXT_TOKENS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
|
||||
@@ -288,7 +290,9 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
|
||||
@@ -311,7 +315,9 @@ async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn text_content(text: String) -> Value {
|
||||
@@ -404,7 +410,10 @@ mod tests {
|
||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
let response = handle_message(&pool, request).await.expect("has response");
|
||||
|
||||
let tools = response.result.expect("ok")["tools"].as_array().unwrap().clone();
|
||||
let tools = response.result.expect("ok")["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let names: Vec<String> = tools
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap().to_string())
|
||||
@@ -426,7 +435,9 @@ mod tests {
|
||||
assert_eq!(resp.jsonrpc, "2.0");
|
||||
assert_eq!(resp.id, Value::Null);
|
||||
assert!(resp.result.is_none());
|
||||
let err = resp.error.expect("parse_error_response must carry an error");
|
||||
let err = resp
|
||||
.error
|
||||
.expect("parse_error_response must carry an error");
|
||||
assert_eq!(err.code, -32700);
|
||||
assert!(err.message.contains("Parse error"));
|
||||
assert!(err.message.contains("expected value"));
|
||||
@@ -449,7 +460,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
kon_storage::migrations::run_migrations(&pool).await.unwrap();
|
||||
kon_storage::migrations::run_migrations(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = handle_message(&pool, request).await.expect("has response");
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -7,10 +7,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db_path = kon_storage::database_path();
|
||||
eprintln!(
|
||||
"[kon-mcp] opening Kon database at {}",
|
||||
db_path.display()
|
||||
);
|
||||
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
|
||||
let pool = kon_storage::init(&db_path).await?;
|
||||
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ pub async fn insert_transcript(
|
||||
pool: &SqlitePool,
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
if !profile_exists(pool, params.profile_id).await? {
|
||||
return Err(KonError::StorageError(format!(
|
||||
"Insert transcript failed: unknown profile id '{}'",
|
||||
params.profile_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -731,6 +738,12 @@ pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
"Default profile cannot be deleted".into(),
|
||||
));
|
||||
}
|
||||
let transcript_count = transcript_count_for_profile(pool, id).await?;
|
||||
if transcript_count > 0 {
|
||||
return Err(KonError::StorageError(format!(
|
||||
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
||||
)));
|
||||
}
|
||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
@@ -789,6 +802,23 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
|
||||
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Profile existence check failed: {e}")))?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Profile transcript count failed: {e}")))
|
||||
}
|
||||
|
||||
// --- Error Logging ---
|
||||
|
||||
/// Log a structured error to the `error_log` table.
|
||||
@@ -865,9 +895,14 @@ mod tests {
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
@@ -1047,11 +1082,19 @@ mod tests {
|
||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s1", "Final test", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s2", "Tag release", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s1")
|
||||
.await
|
||||
.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s2")
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(parent.done);
|
||||
|
||||
@@ -1353,6 +1396,72 @@ mod tests {
|
||||
assert_eq!(profiles.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_transcript_rejects_unknown_profile_id() {
|
||||
let pool = test_pool().await;
|
||||
let err = insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "bad-profile",
|
||||
text: "Hello",
|
||||
source: "microphone",
|
||||
profile_id: "profile-missing",
|
||||
title: None,
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
engine: None,
|
||||
model_id: None,
|
||||
inference_ms: None,
|
||||
sample_rate: None,
|
||||
audio_channels: None,
|
||||
format_mode: None,
|
||||
remove_fillers: false,
|
||||
british_english: false,
|
||||
anti_hallucination: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown profile id must be rejected");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("unknown profile id"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_profile_rejects_when_transcripts_reference_it() {
|
||||
let pool = test_pool().await;
|
||||
let profile = create_profile(&pool, "Referenced", "").await.unwrap();
|
||||
insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "referenced-transcript",
|
||||
text: "Hello",
|
||||
source: "microphone",
|
||||
profile_id: &profile.id,
|
||||
title: None,
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
engine: None,
|
||||
model_id: None,
|
||||
inference_ms: None,
|
||||
sample_rate: None,
|
||||
audio_channels: None,
|
||||
format_mode: None,
|
||||
remove_fillers: false,
|
||||
british_english: false,
|
||||
anti_hallucination: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = delete_profile(&pool, &profile.id)
|
||||
.await
|
||||
.expect_err("profile with transcript references must not delete");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("reassign transcripts first"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_profile_terms_happy_path() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
ON transcripts(profile_id);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
9,
|
||||
"transcript_profile_fk",
|
||||
r#"
|
||||
INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now'));
|
||||
|
||||
DROP TRIGGER IF EXISTS transcripts_ai;
|
||||
DROP TRIGGER IF EXISTS transcripts_ad;
|
||||
DROP TRIGGER IF EXISTS transcripts_au;
|
||||
DROP TABLE IF EXISTS transcripts_fts;
|
||||
DROP INDEX IF EXISTS idx_segments_transcript;
|
||||
DROP INDEX IF EXISTS idx_transcripts_created;
|
||||
DROP INDEX IF EXISTS idx_transcripts_profile_id;
|
||||
|
||||
ALTER TABLE segments RENAME TO segments_old;
|
||||
ALTER TABLE transcripts RENAME TO transcripts_old;
|
||||
|
||||
CREATE TABLE transcripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'microphone',
|
||||
title TEXT,
|
||||
audio_path TEXT,
|
||||
duration REAL NOT NULL DEFAULT 0.0,
|
||||
engine TEXT,
|
||||
model_id TEXT,
|
||||
inference_ms INTEGER,
|
||||
sample_rate INTEGER,
|
||||
audio_channels INTEGER,
|
||||
format_mode TEXT,
|
||||
remove_fillers INTEGER NOT NULL DEFAULT 0,
|
||||
british_english INTEGER NOT NULL DEFAULT 0,
|
||||
anti_hallucination INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
starred INTEGER NOT NULL DEFAULT 0,
|
||||
manual_tags TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT '',
|
||||
segments_json TEXT NOT NULL DEFAULT '',
|
||||
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
|
||||
REFERENCES profiles(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transcripts_created
|
||||
ON transcripts(created_at);
|
||||
CREATE INDEX idx_transcripts_profile_id
|
||||
ON transcripts(profile_id);
|
||||
|
||||
INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
)
|
||||
SELECT
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json,
|
||||
CASE
|
||||
WHEN profile_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = transcripts_old.profile_id
|
||||
)
|
||||
THEN profile_id
|
||||
ELSE '00000000-0000-0000-0000-000000000001'
|
||||
END
|
||||
FROM transcripts_old;
|
||||
|
||||
CREATE TABLE segments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segments_transcript
|
||||
ON segments(transcript_id);
|
||||
|
||||
INSERT INTO segments (id, transcript_id, start_time, end_time, text)
|
||||
SELECT id, transcript_id, start_time, end_time, text
|
||||
FROM segments_old;
|
||||
|
||||
DROP TABLE segments_old;
|
||||
DROP TABLE transcripts_old;
|
||||
|
||||
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
|
||||
text,
|
||||
title,
|
||||
content='transcripts',
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
SELECT rowid, text, COALESCE(title, '')
|
||||
FROM transcripts;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -281,10 +400,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
|
||||
/// out into its own non-transactional migration — reviewer's job to
|
||||
/// flag.
|
||||
async fn run_migrations_slice(
|
||||
pool: &SqlitePool,
|
||||
migrations: &[(i64, &str, &str)],
|
||||
) -> Result<()> {
|
||||
async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -344,12 +460,22 @@ mod tests {
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
async fn fk_test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("pool");
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("enable foreign keys");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -357,7 +483,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -367,10 +493,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_idempotent() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
@@ -379,7 +502,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -440,11 +563,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||
@@ -459,11 +578,108 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations_up_to(&pool, 8).await.expect("migrate to v8");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('profile-valid', 'Valid', '', datetime('now'))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid profile");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-missing'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed orphan transcript");
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-valid'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid transcript");
|
||||
sqlx::query(
|
||||
"INSERT INTO segments (transcript_id, start_time, end_time, text)
|
||||
VALUES ('t-orphan', 0.0, 1.0, 'segment')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed segment");
|
||||
|
||||
run_migrations(&pool).await.expect("migrate to v9");
|
||||
|
||||
let orphan_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read healed orphan");
|
||||
assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID);
|
||||
|
||||
let valid_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read preserved profile");
|
||||
assert_eq!(valid_profile, "profile-valid");
|
||||
|
||||
let segment_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read migrated segments");
|
||||
assert_eq!(segment_count, 1, "segments must survive transcript rebuild");
|
||||
|
||||
let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("read transcript foreign keys");
|
||||
assert!(
|
||||
fk_rows.iter().any(|row| {
|
||||
row.get::<String, _>("table") == "profiles"
|
||||
&& row.get::<String, _>("from") == "profile_id"
|
||||
&& row.get::<String, _>("to") == "id"
|
||||
&& row.get::<String, _>("on_delete") == "RESTRICT"
|
||||
}),
|
||||
"transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT"
|
||||
);
|
||||
|
||||
let fts_hits: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query rebuilt fts");
|
||||
assert_eq!(
|
||||
fts_hits, 1,
|
||||
"fts index must be rebuilt for existing transcripts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -656,7 +872,7 @@ mod tests {
|
||||
run_migrations(&pool).await.expect("baseline migrate");
|
||||
|
||||
const POISON: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
10,
|
||||
"rb-02 atomicity poison",
|
||||
r#"
|
||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||
@@ -680,15 +896,14 @@ mod tests {
|
||||
"poison_marker must not exist; got: {marker:?}"
|
||||
);
|
||||
|
||||
// `schema_version` must not include v9 — version insert is part
|
||||
// `schema_version` must not include v10 — version insert is part
|
||||
// of the same transaction that rolled back.
|
||||
let max: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
assert_eq!(
|
||||
max, 8,
|
||||
max, 9,
|
||||
"schema_version must not advance past the failed migration"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ pub mod transcriber;
|
||||
pub mod whisper_rs_backend;
|
||||
|
||||
pub use concurrency::run_inference;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
#[cfg(feature = "whisper")]
|
||||
pub use local_engine::load_whisper;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||
pub use streaming::{
|
||||
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
||||
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
||||
};
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
pub use transcribe_rs::SpeechModel;
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
|
||||
@@ -557,7 +557,10 @@ mod tests {
|
||||
.expect_err("mismatched sha must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
|
||||
assert!(!dest.exists(), ".part → dest rename must not run on mismatch");
|
||||
assert!(
|
||||
!dest.exists(),
|
||||
".part → dest rename must not run on mismatch"
|
||||
);
|
||||
let part = dest.with_extension("bin.part");
|
||||
assert!(!part.exists(), "failed hash must clean up the .part file");
|
||||
}
|
||||
|
||||
@@ -108,11 +108,7 @@ impl LocalAgreement {
|
||||
|
||||
// Can't commit anything until we have n passes in hand.
|
||||
if self.history.len() < self.n {
|
||||
let tentative = self
|
||||
.history
|
||||
.back()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let tentative = self.history.back().cloned().unwrap_or_default();
|
||||
return CommitDecision {
|
||||
newly_committed: Vec::new(),
|
||||
tentative,
|
||||
|
||||
@@ -121,7 +121,7 @@ impl RmsVadChunker {
|
||||
pending_onset_frames: 0,
|
||||
onset_buffer: Vec::new(),
|
||||
next_sample_index: 0,
|
||||
active_chunk_start: 0,
|
||||
active_chunk_start: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,10 +552,7 @@ mod tests {
|
||||
let mut c = RmsVadChunker::new();
|
||||
assert!(c.flush().is_empty());
|
||||
let _ = c.push(&constant_signal(16_000, 0.0));
|
||||
assert!(
|
||||
c.flush().is_empty(),
|
||||
"flushing pure silence emits nothing"
|
||||
);
|
||||
assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,10 +62,9 @@ impl Transcriber for WhisperRsBackend {
|
||||
"WhisperRsBackend::transcribe_sync entering"
|
||||
);
|
||||
|
||||
let mut state = self
|
||||
.ctx
|
||||
.create_state()
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
|
||||
let mut state = self.ctx.create_state().map_err(|e| {
|
||||
KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||
})?;
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
if let Some(lang) = options.language.as_deref() {
|
||||
@@ -83,9 +82,11 @@ impl Transcriber for WhisperRsBackend {
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
state
|
||||
.full(params, samples)
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?;
|
||||
state.full(params, samples).map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let n = state.full_n_segments();
|
||||
|
||||
@@ -96,7 +97,11 @@ impl Transcriber for WhisperRsBackend {
|
||||
};
|
||||
let text = seg
|
||||
.to_str()
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
|
||||
let start = seg.start_timestamp() as f64 * 0.01;
|
||||
|
||||
@@ -11,43 +11,36 @@ Issues here must land before Kon v0.1 ships. Each is sourced from
|
||||
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
|
||||
should be mirrored as real GitHub issues on `jakejars/kon`.
|
||||
|
||||
## CRITICAL (2 open, 1 resolved)
|
||||
## CRITICAL (0 open, 3 resolved)
|
||||
|
||||
No open CRITICAL blockers.
|
||||
|
||||
## MAJOR (1 open, 8 resolved)
|
||||
|
||||
| # | File | Area | Fix scope |
|
||||
|---|---|---|---|
|
||||
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | large |
|
||||
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | large |
|
||||
|
||||
## MAJOR (5 open, 4 resolved)
|
||||
|
||||
| # | File | Area | Fix scope |
|
||||
|---|---|---|---|
|
||||
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | large |
|
||||
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | medium |
|
||||
| RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium |
|
||||
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | medium |
|
||||
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | medium |
|
||||
|
||||
## Resolved
|
||||
|
||||
| # | File | Area | Resolution |
|
||||
|---|---|---|---|
|
||||
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | Added `LiveTranscriptionState.lifecycle: tokio::sync::Mutex<()>` and hold it across the async spans of both `start_live_transcription_session` and `stop_live_transcription_session`. The running-slot check/insert and stop/take/join sequence are now serialized, so concurrent starts can no longer both pass the empty-slot check and a start during stop blocks until the previous worker fully joins. Two async regression tests cover both races. |
|
||||
| RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. |
|
||||
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | Added a transactional v9 rebuild of `transcripts` that enforces `profile_id REFERENCES profiles(id) ON DELETE RESTRICT`, reassigns any orphaned transcript provenance to `DEFAULT_PROFILE_ID`, rebuilds dependent `segments` / FTS state, and preserves valid profile references. `insert_transcript` now rejects unknown profile ids up front, and `delete_profile` returns a clear reassign-first error when transcripts still reference the profile. Regression tests cover migration reconciliation, invalid inserts, and delete rejection. |
|
||||
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | Replaced the 200+ line `run_live_session` loop with an explicit `LiveSessionRuntime` + `LiveLoopState` structure. Capture startup, runtime mic-error draining, audio chunk processing, overflow handling, stop-tail flush, inference dispatch/drain, and WAV finalisation each live in focused helpers, preserving behaviour while making the lifecycle auditable enough for RB-01 follow-up. Existing live tests and the full `kon` lib suite stay green. |
|
||||
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | `poll_inference` now treats result-channel loss as a listener-lifecycle problem rather than a transcription failure. On the first `result_channel.send(...)` error it marks the live result listener as lost, emits a single warning that transcription will continue in the background, and keeps processing later chunks without retrying the dead channel. Regression test simulates a dead result listener and asserts chunk processing continues with only one warning. |
|
||||
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. |
|
||||
| RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. |
|
||||
| RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. |
|
||||
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | Added an explicit prompt-budget preflight before context creation. If `prompt_tokens + max_tokens + reserve` exceeds the 8192-token cap, `generate` now returns a typed `EngineError::PromptTooLong { ... }` instead of failing late inside inference. Regression tests cover both the over-budget and exact-budget boundaries. |
|
||||
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | Replaced the `std::env::set_var` stub with a process-global `OnceLock<Mutex<HashMap<...>>>` keystore, keeping the API safe from any thread. Retrieval still falls back to read-only `KON_API_KEY_*` env vars for externally supplied secrets. Two regression tests cover store/retrieve and provider isolation. |
|
||||
| RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. |
|
||||
|
||||
## Dependencies
|
||||
## Remaining blocker
|
||||
|
||||
```
|
||||
RB-01 (live session race)
|
||||
└── blocked by RB-04 (run_live_session monolith refactor)
|
||||
|
||||
RB-03 (transcript-profile FK)
|
||||
└── coupled with RB-02 (migrations atomicity)
|
||||
— a v9 migration adding the FK constraint must be transactional
|
||||
```
|
||||
`RB-08` remains open pending manual runtime verification on a real macOS
|
||||
machine (`pmset -g assertions`, background live-session sanity check).
|
||||
|
||||
## How to convert to GitHub issues
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
**Path:** `src-tauri/src/commands/live.rs:193-338`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers)
|
||||
**Labels:** release-blocker, critical, concurrency
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
`LiveTranscriptionState` now includes a dedicated
|
||||
`tokio::sync::Mutex<()>` lifecycle gate. Both
|
||||
`start_live_transcription_session` and
|
||||
`stop_live_transcription_session` acquire that async mutex before
|
||||
touching `running`, and they keep it held across the awaited setup /
|
||||
join work that previously exposed the race windows.
|
||||
|
||||
That changes the two failing interleavings from the review:
|
||||
|
||||
- Two overlapping starts no longer race through the empty-slot check.
|
||||
The second call waits for the first to finish setup, then observes
|
||||
`running.is_some()` and returns the existing
|
||||
`"A live transcription session is already running"` error.
|
||||
- A start launched during stop can no longer sneak in after
|
||||
`running.take()` but before the previous worker has fully joined.
|
||||
It blocks on the lifecycle mutex until the join completes.
|
||||
|
||||
Regression tests in `commands::live::tests`:
|
||||
|
||||
- `concurrent_starts_allow_only_one_session_to_claim_the_slot`
|
||||
- `start_waits_for_stop_to_finish_joining_before_reusing_slot`
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -26,4 +51,4 @@ Large. Will likely require the `run_live_session` monolith refactor (RB-04) to l
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocked by:** RB-04 (`run_live_session` monolith refactor)
|
||||
- Landed after RB-04 (`run_live_session` refactor) made the worker lifecycle explicit enough to guard safely.
|
||||
|
||||
@@ -4,6 +4,33 @@
|
||||
**Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c4--transcript-provenance-can-reference-deleted-profiles)
|
||||
**Labels:** release-blocker, critical, data-integrity, storage
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
Chose the strict provenance path:
|
||||
|
||||
- Migration v9 rebuilds `transcripts` with
|
||||
`profile_id REFERENCES profiles(id) ON DELETE RESTRICT`.
|
||||
- Existing orphaned transcript `profile_id` values are reconciled onto
|
||||
`DEFAULT_PROFILE_ID` during the copy into the rebuilt table.
|
||||
- Because SQLite table renames rewrite dependent references, the
|
||||
migration also rebuilds `segments`, recreates the transcript FTS
|
||||
virtual table/triggers, and repopulates FTS from the rebuilt
|
||||
transcript rows inside the same transaction.
|
||||
|
||||
Application-layer behaviour now matches the schema:
|
||||
|
||||
- `insert_transcript` rejects unknown `profile_id` values with a clear
|
||||
storage error before attempting the insert.
|
||||
- `delete_profile` returns a human-readable reassign-first error when
|
||||
transcripts still reference that profile.
|
||||
|
||||
Regression tests:
|
||||
|
||||
- `migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk`
|
||||
- `insert_transcript_rejects_unknown_profile_id`
|
||||
- `delete_profile_rejects_when_transcripts_reference_it`
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@
|
||||
**Path:** `crates/cloud-providers/src/keystore.rs:6-18`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||
**Labels:** release-blocker, major, unsafe-api, cloud
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
Chose acceptance option 2. The environment-mutation stub is gone;
|
||||
`store_api_key` now writes into a process-global
|
||||
`OnceLock<Mutex<HashMap<String, String>>>`, so the safe signature matches
|
||||
the actual safety properties.
|
||||
|
||||
Additional details:
|
||||
|
||||
- Stored keys now live in-memory only for the life of the process.
|
||||
- `retrieve_api_key` checks the in-memory keystore first, then falls
|
||||
back to read-only `KON_API_KEY_<PROVIDER>` environment variables so
|
||||
externally injected secrets still work.
|
||||
- Module docs now describe the real tradeoff clearly: safe from any
|
||||
thread, but non-persistent until a proper OS keychain backend lands.
|
||||
|
||||
Regression tests:
|
||||
|
||||
- `stored_key_is_retrievable_without_env_mutation`
|
||||
- `providers_do_not_overlap`
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||
**Labels:** release-blocker, major, llm
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
`LlmEngine::generate` still tokenises the whole prompt up front, but it
|
||||
now runs a dedicated prompt-budget preflight before creating the llama
|
||||
context. The chosen behaviour is an early typed failure rather than
|
||||
silent truncation:
|
||||
|
||||
- If `prompt_tokens + max_tokens + 64 reserve tokens` exceeds the
|
||||
8192-token cap, generation returns
|
||||
`EngineError::PromptTooLong { prompt_tokens, max_tokens, available_prompt_tokens, context_window }`.
|
||||
- Prompts that fit exactly within the available budget still proceed and
|
||||
allocate an 8192-token context as before.
|
||||
|
||||
Regression tests:
|
||||
|
||||
- `prompt_preflight_rejects_oversized_prompt_tokens`
|
||||
- `prompt_preflight_keeps_prompts_within_budget`
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
**Path:** `src-tauri/src/commands/live.rs:721-813`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||
**Labels:** release-blocker, major, ipc-lifecycle
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
`poll_inference` no longer propagates `result_channel.send(...)` with `?`.
|
||||
Instead, live-result delivery is routed through a small helper that
|
||||
tracks whether the frontend listener has already been lost:
|
||||
|
||||
- First send failure: mark the result listener as unavailable, log a
|
||||
warning, and best-effort send a `LiveStatusMessage::Warning`
|
||||
explaining that transcription will continue in the background until
|
||||
the user stops the session.
|
||||
- Subsequent chunks: skip re-sending to the dead result channel and
|
||||
keep the worker running.
|
||||
|
||||
Crucially, this path is now separate from actual transcription failure:
|
||||
inference errors still emit `LiveStatusMessage::Error` and stop the
|
||||
session, while listener-loss just stops live preview delivery.
|
||||
|
||||
Regression test:
|
||||
|
||||
- `result_listener_loss_is_warned_once_and_not_treated_as_inference_failure`
|
||||
simulates a dead result channel, confirms the first processed chunk
|
||||
downgrades to a warning, and confirms a second chunk still processes
|
||||
successfully without a second warning.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
|
||||
**Labels:** release-blocker, major, macos, platform
|
||||
|
||||
**Current state (2026-04-23):** `objc2`/`objc2-foundation` have been
|
||||
added behind `cfg(target_os = "macos")`, and the `NSProcessInfo`
|
||||
bridge now calls `beginActivityWithOptions:reason:` / `endActivity:`
|
||||
with the retained activity handle. Isolated `cargo check` validation
|
||||
passes for both `x86_64-apple-darwin` and `aarch64-apple-darwin`.
|
||||
Remaining acceptance gap: manual runtime verification on a real macOS
|
||||
machine (`pmset -g assertions`, background live session). Diagnostic
|
||||
reports now also include a `## Power assertions` section that lists any
|
||||
currently active Kon assertion guards (`reason`, `backend`, `acquired`)
|
||||
at report time, which gives the tester an in-app breadcrumb alongside
|
||||
`pmset`.
|
||||
|
||||
## Problem
|
||||
|
||||
`begin_activity` always returns `Err` on macOS, so `PowerAssertion::begin` converts to `None` and the guard never acquires an `NSProcessInfo beginActivityWithOptions:reason:` assertion. Live recording and LLM cleanup therefore run without App Nap protection on the one platform where it matters.
|
||||
@@ -18,6 +30,21 @@ The stub was deliberate (A.1 #9 acceptance concession — untestable on Linux wi
|
||||
- `end_activity` calls `endActivity:` on the retained handle.
|
||||
- Manual-test on a real macOS box: 10-minute background live session completes without throttling; `pmset -g assertions` shows Kon's activity during capture.
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
1. Launch Kon on a real macOS machine and start a live transcription session.
|
||||
2. While capture is running, background the app for at least several minutes.
|
||||
3. In Terminal, run `pmset -g assertions` and confirm Kon appears with a
|
||||
user-initiated / no-idle-style assertion while the session is active.
|
||||
4. While the session is still running, generate a Kon diagnostic report
|
||||
and confirm the `## Power assertions` section lists an active entry
|
||||
such as `reason=kon live dictation session`, `backend=macos`,
|
||||
`acquired=true`.
|
||||
5. Stop the session and rerun `pmset -g assertions` or regenerate the
|
||||
diagnostic report to confirm the assertion disappears.
|
||||
6. Repeat once for the LLM cleanup path if desired
|
||||
(`reason=kon LLM cleanup`).
|
||||
|
||||
## Fix scope
|
||||
|
||||
Medium. Dep addition + FFI glue + manual verification. Can be done from Linux with `cargo check --target=aarch64-apple-darwin` for compile validation, but runtime behaviour needs a macOS machine.
|
||||
|
||||
@@ -4,6 +4,33 @@
|
||||
**Path:** `src-tauri/src/commands/live.rs:349-579`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||
**Labels:** release-blocker, major, refactor, concurrency
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
`run_live_session` was split around two explicit state holders:
|
||||
|
||||
- `ActiveCapture` owns the cpal stream handle, audio receiver, and
|
||||
optional runtime-error channel.
|
||||
- `LiveLoopState` owns the mutable per-session loop state: resampler,
|
||||
capture buffer, WAV writer, buffer offsets, dropped-audio accounting,
|
||||
in-flight inference task, and duplicate-history buffer.
|
||||
|
||||
The top-level worker is now `LiveSessionRuntime`, with focused methods
|
||||
for:
|
||||
|
||||
- polling in-flight inference
|
||||
- draining microphone runtime warnings
|
||||
- receiving + resampling an audio chunk
|
||||
- dropping pending-buffer overflow
|
||||
- flushing the resampler tail when stop is requested
|
||||
- dispatching inference when enough audio is buffered
|
||||
- draining the last in-flight task
|
||||
- finalising the progressive WAV writer
|
||||
|
||||
This keeps behaviour intact but removes the "everything in one mutable
|
||||
loop" shape that made concurrency review hard. The refactor also made
|
||||
RB-01 straightforward enough to land immediately afterward.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -74,3 +74,7 @@ webkit2gtk = "2.0"
|
||||
# transitively depends on (GTK 3).
|
||||
gtk = "0.18"
|
||||
gdk = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
|
||||
|
||||
@@ -33,8 +33,8 @@ fn assert_localhost_llm_csp() {
|
||||
|
||||
let raw = std::fs::read_to_string(conf_path)
|
||||
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
|
||||
let conf: serde_json::Value = serde_json::from_str(&raw)
|
||||
.expect("build.rs: tauri.conf.json is not valid JSON");
|
||||
let conf: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("build.rs: tauri.conf.json is not valid JSON");
|
||||
|
||||
let csp = conf
|
||||
.pointer("/app/security/csp")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2313,22 +2313,22 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2349,16 +2349,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
|
||||
@@ -2313,22 +2313,22 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2349,16 +2349,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
|
||||
@@ -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