Compare commits
1 Commits
d7363cc913
...
pre-consol
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0067b4c0 |
@@ -225,13 +225,19 @@ mod tests {
|
|||||||
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
|
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
|
||||||
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
|
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
|
||||||
assert_eq!(LlmPromptPreset::parse("meeting"), 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("code"), LlmPromptPreset::Code);
|
||||||
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
|
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
|
||||||
// Unknown values and explicit default fall back safely.
|
// Unknown values and explicit default fall back safely.
|
||||||
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
|
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
|
||||||
assert_eq!(LlmPromptPreset::parse(""), 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]
|
#[test]
|
||||||
@@ -240,7 +246,10 @@ mod tests {
|
|||||||
// be empty so it composes cleanly with dictionary suffix.
|
// be empty so it composes cleanly with dictionary suffix.
|
||||||
assert!(LlmPromptPreset::Default.suffix().is_empty());
|
assert!(LlmPromptPreset::Default.suffix().is_empty());
|
||||||
assert!(LlmPromptPreset::Email.suffix().contains("email"));
|
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"));
|
assert!(LlmPromptPreset::Code.suffix().contains("technical"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -540,8 +540,12 @@ mod tests {
|
|||||||
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
|
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
|
||||||
// Exact-match on trail phrases means legitimate dialogue that
|
// Exact-match on trail phrases means legitimate dialogue that
|
||||||
// mentions "thanks" or "subscribe" is never dropped.
|
// mentions "thanks" or "subscribe" is never dropped.
|
||||||
assert!(!is_hallucination("Thanks for the heads up on the migration"));
|
assert!(!is_hallucination(
|
||||||
assert!(!is_hallucination("Please subscribe to the RSS feed and tell me when it updates"));
|
"Thanks for the heads up on the migration"
|
||||||
|
));
|
||||||
|
assert!(!is_hallucination(
|
||||||
|
"Please subscribe to the RSS feed and tell me when it updates"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -168,7 +168,10 @@ mod tests {
|
|||||||
];
|
];
|
||||||
for (input, expected) in cases {
|
for (input, expected) in cases {
|
||||||
let out = to_plain_text(&[seg(input)]);
|
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 file = std::fs::File::create(path).map_err(KonError::Io)?;
|
||||||
let buffered = BufWriter::new(file);
|
let buffered = BufWriter::new(file);
|
||||||
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
|
let inner = hound::WavWriter::new(buffered, spec)
|
||||||
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}")))
|
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||||
})?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner,
|
inner,
|
||||||
samples_since_flush: 0,
|
samples_since_flush: 0,
|
||||||
@@ -77,9 +76,9 @@ impl WavWriter {
|
|||||||
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
||||||
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
||||||
pub fn flush(&mut self) -> Result<()> {
|
pub fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush().map_err(|e| {
|
self.inner
|
||||||
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}")))
|
.flush()
|
||||||
})?;
|
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
|
||||||
self.samples_since_flush = 0;
|
self.samples_since_flush = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -244,8 +243,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
// Write 100 samples (200 bytes at 16-bit).
|
// Write 100 samples (200 bytes at 16-bit).
|
||||||
let original =
|
let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
||||||
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
|
||||||
write_wav(&path, &original).unwrap();
|
write_wav(&path, &original).unwrap();
|
||||||
|
|
||||||
// Drop the last 10 bytes — 5 samples' worth. hound's iterator
|
// 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
|
/// Keys are held in memory for the lifetime of the process and are lost on
|
||||||
/// added. Keys are only held in-process and lost on exit.
|
/// exit. This avoids the undefined behaviour of mutating process environment
|
||||||
|
/// variables from arbitrary threads while keeping the public API safe.
|
||||||
///
|
///
|
||||||
/// # Safety note
|
/// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
|
||||||
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
|
/// variables so externally injected secrets continue to work.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
||||||
/// storage) so keys persist across sessions and are accessed safely.
|
/// 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) {
|
pub fn store_api_key(provider: &str, key: &str) {
|
||||||
// SAFETY: Only safe when called from a single-threaded context (e.g. app
|
api_key_store()
|
||||||
// initialisation). See doc comment above.
|
.lock()
|
||||||
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
|
.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
|
/// Returns a previously stored in-memory key when present, otherwise falls
|
||||||
/// added. Returns `None` if no key has been stored this session.
|
/// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
|
||||||
///
|
/// operator-supplied secrets still work.
|
||||||
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
|
|
||||||
pub fn retrieve_api_key(provider: &str) -> Option<String> {
|
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
|
/// 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
|
/// configured trigger key. A device that reports no keys at all (for
|
||||||
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
||||||
fn device_supports_combo(
|
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
|
||||||
supported: Option<&AttributeSetRef<Key>>,
|
|
||||||
combo: &HotkeyCombo,
|
|
||||||
) -> bool {
|
|
||||||
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
|
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};
|
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
|
||||||
|
|
||||||
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
||||||
|
const MAX_CONTEXT_TOKENS: u32 = 8192;
|
||||||
|
const CONTEXT_RESERVE_TOKENS: u32 = 64;
|
||||||
const GENERATION_SEED: u32 = 0;
|
const GENERATION_SEED: u32 = 0;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -26,6 +28,15 @@ pub enum EngineError {
|
|||||||
NotLoaded,
|
NotLoaded,
|
||||||
#[error("LLM load failed: {0}")]
|
#[error("LLM load failed: {0}")]
|
||||||
LoadFailed(String),
|
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}")]
|
#[error("inference failed: {0}")]
|
||||||
Inference(String),
|
Inference(String),
|
||||||
#[error("model output not valid JSON: {0}")]
|
#[error("model output not valid JSON: {0}")]
|
||||||
@@ -149,7 +160,7 @@ impl LlmEngine {
|
|||||||
return Ok(String::new());
|
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 thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||||
let ctx_params = LlamaContextParams::default()
|
let ctx_params = LlamaContextParams::default()
|
||||||
.with_n_ctx(Some(
|
.with_n_ctx(Some(
|
||||||
@@ -317,8 +328,26 @@ impl LlmEngine {
|
|||||||
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
|
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
|
||||||
let required = prompt_tokens
|
let required = prompt_tokens
|
||||||
.saturating_add(max_tokens as usize)
|
.saturating_add(max_tokens as usize)
|
||||||
.saturating_add(64);
|
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
|
||||||
DEFAULT_CONTEXT_TOKENS.max(required.min(8192) as u32)
|
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> {
|
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()]);
|
let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]);
|
||||||
assert_eq!(index, Some(5));
|
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();
|
.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> {
|
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();
|
.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> {
|
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();
|
.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 {
|
fn text_content(text: String) -> Value {
|
||||||
@@ -404,7 +410,10 @@ mod tests {
|
|||||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
let response = handle_message(&pool, request).await.expect("has response");
|
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
|
let names: Vec<String> = tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool["name"].as_str().unwrap().to_string())
|
.map(|tool| tool["name"].as_str().unwrap().to_string())
|
||||||
@@ -426,7 +435,9 @@ mod tests {
|
|||||||
assert_eq!(resp.jsonrpc, "2.0");
|
assert_eq!(resp.jsonrpc, "2.0");
|
||||||
assert_eq!(resp.id, Value::Null);
|
assert_eq!(resp.id, Value::Null);
|
||||||
assert!(resp.result.is_none());
|
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_eq!(err.code, -32700);
|
||||||
assert!(err.message.contains("Parse error"));
|
assert!(err.message.contains("Parse error"));
|
||||||
assert!(err.message.contains("expected value"));
|
assert!(err.message.contains("expected value"));
|
||||||
@@ -449,7 +460,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
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");
|
let response = handle_message(&pool, request).await.expect("has response");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let db_path = kon_storage::database_path();
|
let db_path = kon_storage::database_path();
|
||||||
eprintln!(
|
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
|
||||||
"[kon-mcp] opening Kon database at {}",
|
|
||||||
db_path.display()
|
|
||||||
);
|
|
||||||
let pool = kon_storage::init(&db_path).await?;
|
let pool = kon_storage::init(&db_path).await?;
|
||||||
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ pub async fn insert_transcript(
|
|||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
params: &InsertTranscriptParams<'_>,
|
params: &InsertTranscriptParams<'_>,
|
||||||
) -> Result<()> {
|
) -> 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(
|
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)
|
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
@@ -731,6 +738,12 @@ pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
"Default profile cannot be deleted".into(),
|
"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 = ?")
|
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -789,6 +802,23 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 ---
|
// --- Error Logging ---
|
||||||
|
|
||||||
/// Log a structured error to the `error_log` table.
|
/// Log a structured error to the `error_log` table.
|
||||||
@@ -865,9 +895,14 @@ mod tests {
|
|||||||
|
|
||||||
async fn test_pool() -> SqlitePool {
|
async fn test_pool() -> SqlitePool {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
.connect("sqlite::memory:")
|
.connect("sqlite::memory:")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
run_migrations(&pool).await.unwrap();
|
run_migrations(&pool).await.unwrap();
|
||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
@@ -1047,11 +1082,19 @@ mod tests {
|
|||||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
|
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap();
|
insert_subtask(&pool, "s1", "Final test", "p1")
|
||||||
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap();
|
.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, "s1")
|
||||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
complete_subtask_and_check_parent(&pool, "s2")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||||
assert!(parent.done);
|
assert!(parent.done);
|
||||||
|
|
||||||
@@ -1353,6 +1396,72 @@ mod tests {
|
|||||||
assert_eq!(profiles.len(), 1);
|
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]
|
#[tokio::test]
|
||||||
async fn list_profile_terms_happy_path() {
|
async fn list_profile_terms_happy_path() {
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|||||||
@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
ON transcripts(profile_id);
|
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.
|
/// 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
|
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
|
||||||
/// out into its own non-transactional migration — reviewer's job to
|
/// out into its own non-transactional migration — reviewer's job to
|
||||||
/// flag.
|
/// flag.
|
||||||
async fn run_migrations_slice(
|
async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
|
||||||
pool: &SqlitePool,
|
|
||||||
migrations: &[(i64, &str, &str)],
|
|
||||||
) -> Result<()> {
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
@@ -344,12 +460,22 @@ mod tests {
|
|||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
#[tokio::test]
|
async fn fk_test_pool() -> SqlitePool {
|
||||||
async fn test_migrations_run_on_empty_db() {
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
.connect("sqlite::memory:")
|
.connect("sqlite::memory:")
|
||||||
.await
|
.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();
|
run_migrations(&pool).await.unwrap();
|
||||||
|
|
||||||
@@ -357,7 +483,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 8);
|
assert_eq!(count, 9);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -367,10 +493,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_migrations_idempotent() {
|
async fn test_migrations_idempotent() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = fk_test_pool().await;
|
||||||
.connect("sqlite::memory:")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
run_migrations(&pool).await.unwrap();
|
run_migrations(&pool).await.unwrap();
|
||||||
run_migrations(&pool).await.unwrap();
|
run_migrations(&pool).await.unwrap();
|
||||||
@@ -379,7 +502,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 8);
|
assert_eq!(count, 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -440,11 +563,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = fk_test_pool().await;
|
||||||
.max_connections(1)
|
|
||||||
.connect("sqlite::memory:")
|
|
||||||
.await
|
|
||||||
.expect("pool");
|
|
||||||
run_migrations(&pool).await.expect("migrate");
|
run_migrations(&pool).await.expect("migrate");
|
||||||
|
|
||||||
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||||
@@ -459,11 +578,108 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parent_task_id_cascade_delete() {
|
async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = fk_test_pool().await;
|
||||||
.connect("sqlite::memory:")
|
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
|
.await
|
||||||
.unwrap();
|
.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
|
||||||
|
.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();
|
run_migrations(&pool).await.unwrap();
|
||||||
|
|
||||||
@@ -656,7 +872,7 @@ mod tests {
|
|||||||
run_migrations(&pool).await.expect("baseline migrate");
|
run_migrations(&pool).await.expect("baseline migrate");
|
||||||
|
|
||||||
const POISON: &[(i64, &str, &str)] = &[(
|
const POISON: &[(i64, &str, &str)] = &[(
|
||||||
9,
|
10,
|
||||||
"rb-02 atomicity poison",
|
"rb-02 atomicity poison",
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||||
@@ -680,15 +896,14 @@ mod tests {
|
|||||||
"poison_marker must not exist; got: {marker:?}"
|
"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.
|
// of the same transaction that rolled back.
|
||||||
let max: i64 =
|
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.expect("read schema_version");
|
.expect("read schema_version");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
max, 8,
|
max, 9,
|
||||||
"schema_version must not advance past the failed migration"
|
"schema_version must not advance past the failed migration"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ pub mod transcriber;
|
|||||||
pub mod whisper_rs_backend;
|
pub mod whisper_rs_backend;
|
||||||
|
|
||||||
pub use concurrency::run_inference;
|
pub use concurrency::run_inference;
|
||||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
|
||||||
#[cfg(feature = "whisper")]
|
#[cfg(feature = "whisper")]
|
||||||
pub use local_engine::load_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 model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||||
pub use streaming::{
|
pub use streaming::{
|
||||||
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
||||||
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
||||||
};
|
};
|
||||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
|
||||||
pub use transcribe_rs::SpeechModel;
|
pub use transcribe_rs::SpeechModel;
|
||||||
|
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||||
|
|||||||
@@ -557,7 +557,10 @@ mod tests {
|
|||||||
.expect_err("mismatched sha must fail");
|
.expect_err("mismatched sha must fail");
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
|
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");
|
let part = dest.with_extension("bin.part");
|
||||||
assert!(!part.exists(), "failed hash must clean up the .part file");
|
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.
|
// Can't commit anything until we have n passes in hand.
|
||||||
if self.history.len() < self.n {
|
if self.history.len() < self.n {
|
||||||
let tentative = self
|
let tentative = self.history.back().cloned().unwrap_or_default();
|
||||||
.history
|
|
||||||
.back()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
return CommitDecision {
|
return CommitDecision {
|
||||||
newly_committed: Vec::new(),
|
newly_committed: Vec::new(),
|
||||||
tentative,
|
tentative,
|
||||||
|
|||||||
@@ -552,10 +552,7 @@ mod tests {
|
|||||||
let mut c = RmsVadChunker::new();
|
let mut c = RmsVadChunker::new();
|
||||||
assert!(c.flush().is_empty());
|
assert!(c.flush().is_empty());
|
||||||
let _ = c.push(&constant_signal(16_000, 0.0));
|
let _ = c.push(&constant_signal(16_000, 0.0));
|
||||||
assert!(
|
assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
|
||||||
c.flush().is_empty(),
|
|
||||||
"flushing pure silence emits nothing"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -62,10 +62,9 @@ impl Transcriber for WhisperRsBackend {
|
|||||||
"WhisperRsBackend::transcribe_sync entering"
|
"WhisperRsBackend::transcribe_sync entering"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut state = self
|
let mut state = self.ctx.create_state().map_err(|e| {
|
||||||
.ctx
|
KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||||
.create_state()
|
})?;
|
||||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
|
|
||||||
|
|
||||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||||
if let Some(lang) = options.language.as_deref() {
|
if let Some(lang) = options.language.as_deref() {
|
||||||
@@ -83,9 +82,11 @@ impl Transcriber for WhisperRsBackend {
|
|||||||
params.set_print_progress(false);
|
params.set_print_progress(false);
|
||||||
params.set_print_realtime(false);
|
params.set_print_realtime(false);
|
||||||
|
|
||||||
state
|
state.full(params, samples).map_err(|e| {
|
||||||
.full(params, samples)
|
KonError::TranscriptionFailed(
|
||||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?;
|
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let n = state.full_n_segments();
|
let n = state.full_n_segments();
|
||||||
|
|
||||||
@@ -96,7 +97,11 @@ impl Transcriber for WhisperRsBackend {
|
|||||||
};
|
};
|
||||||
let text = seg
|
let text = seg
|
||||||
.to_str()
|
.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();
|
.to_string();
|
||||||
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
|
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
|
||||||
let start = seg.start_timestamp() as f64 * 0.01;
|
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
|
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
|
||||||
should be mirrored as real GitHub issues on `jakejars/kon`.
|
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 |
|
| # | 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-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
|
## Resolved
|
||||||
|
|
||||||
| # | File | Area | Resolution |
|
| # | 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-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-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-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-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`. |
|
| 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-08` remains open pending manual runtime verification on a real macOS
|
||||||
RB-01 (live session race)
|
machine (`pmset -g assertions`, background live-session sanity check).
|
||||||
└── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to convert to GitHub issues
|
## How to convert to GitHub issues
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,31 @@
|
|||||||
**Path:** `src-tauri/src/commands/live.rs:193-338`
|
**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)
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers)
|
||||||
**Labels:** release-blocker, critical, concurrency
|
**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
|
## Problem
|
||||||
|
|
||||||
@@ -26,4 +51,4 @@ Large. Will likely require the `run_live_session` monolith refactor (RB-04) to l
|
|||||||
|
|
||||||
## Dependencies
|
## 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`
|
**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)
|
**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
|
**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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,28 @@
|
|||||||
**Path:** `crates/cloud-providers/src/keystore.rs:6-18`
|
**Path:** `crates/cloud-providers/src/keystore.rs:6-18`
|
||||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||||
**Labels:** release-blocker, major, unsafe-api, cloud
|
**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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,25 @@
|
|||||||
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
|
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
|
||||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||||
**Labels:** release-blocker, major, llm
|
**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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,31 @@
|
|||||||
**Path:** `src-tauri/src/commands/live.rs:721-813`
|
**Path:** `src-tauri/src/commands/live.rs:721-813`
|
||||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||||
**Labels:** release-blocker, major, ipc-lifecycle
|
**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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
|
||||||
**Labels:** release-blocker, major, macos, platform
|
**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
|
## 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.
|
`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.
|
- `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-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
|
## 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.
|
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`
|
**Path:** `src-tauri/src/commands/live.rs:349-579`
|
||||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||||
**Labels:** release-blocker, major, refactor, concurrency
|
**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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -74,3 +74,7 @@ webkit2gtk = "2.0"
|
|||||||
# transitively depends on (GTK 3).
|
# transitively depends on (GTK 3).
|
||||||
gtk = "0.18"
|
gtk = "0.18"
|
||||||
gdk = "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)
|
let raw = std::fs::read_to_string(conf_path)
|
||||||
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
|
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
|
||||||
let conf: serde_json::Value = serde_json::from_str(&raw)
|
let conf: serde_json::Value =
|
||||||
.expect("build.rs: tauri.conf.json is not valid JSON");
|
serde_json::from_str(&raw).expect("build.rs: tauri.conf.json is not valid JSON");
|
||||||
|
|
||||||
let csp = conf
|
let csp = conf
|
||||||
.pointer("/app/security/csp")
|
.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."
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"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.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2349,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"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.",
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
|||||||
@@ -2313,22 +2313,22 @@
|
|||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"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.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2349,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"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",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"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.",
|
"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
|
/// restarts, so cross-launch collisions are already impossible — the
|
||||||
/// counter is the last-mile guarantee against within-launch same-tick
|
/// counter is the last-mile guarantee against within-launch same-tick
|
||||||
/// collisions.
|
/// collisions.
|
||||||
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 =
|
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
std::sync::atomic::AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -353,10 +352,20 @@ mod tests {
|
|||||||
3,
|
3,
|
||||||
"expected three '-' separated parts, got {parts:?}"
|
"expected three '-' separated parts, got {parts:?}"
|
||||||
);
|
);
|
||||||
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits");
|
assert!(
|
||||||
assert_eq!(parts[1].len(), 9, "nanos component is zero-padded to 9 digits");
|
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[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()));
|
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,
|
app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::commands::power::active_assertions_snapshot;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
const DEFAULT_RECENT_ERRORS: i64 = 50;
|
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 {
|
if opts.include_crashes {
|
||||||
out.push_str("## Crash dumps\n\n");
|
out.push_str("## Crash dumps\n\n");
|
||||||
let crashes = list_crash_files().await.unwrap_or_default();
|
let crashes = list_crash_files().await.unwrap_or_default();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
||||||
use crate::commands::audio::resolve_recording_path;
|
use crate::commands::audio::resolve_recording_path;
|
||||||
use crate::commands::build_initial_prompt;
|
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::commands::power::PowerAssertion;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||||
use kon_audio::{MicrophoneCapture, StreamingResampler, WavWriter};
|
use kon_audio::{
|
||||||
|
AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter,
|
||||||
|
};
|
||||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||||
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
|
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
|
||||||
use kon_transcription::LocalEngine;
|
use kon_transcription::LocalEngine;
|
||||||
@@ -57,6 +60,7 @@ const LOW_SIGNAL_TOKENS: &[&str] = &[
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct LiveTranscriptionState {
|
pub struct LiveTranscriptionState {
|
||||||
next_session_id: AtomicU64,
|
next_session_id: AtomicU64,
|
||||||
|
lifecycle: AsyncMutex<()>,
|
||||||
running: Mutex<Option<RunningLiveSession>>,
|
running: Mutex<Option<RunningLiveSession>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +157,292 @@ struct LiveSessionSummary {
|
|||||||
audio_path: Option<String>,
|
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 {
|
struct InferenceTask {
|
||||||
chunk_id: u32,
|
chunk_id: u32,
|
||||||
chunk_start_sample: u64,
|
chunk_start_sample: u64,
|
||||||
@@ -198,6 +488,7 @@ pub async fn start_live_transcription_session(
|
|||||||
result_channel: Channel<LiveResultMessage>,
|
result_channel: Channel<LiveResultMessage>,
|
||||||
status_channel: Channel<LiveStatusMessage>,
|
status_channel: Channel<LiveStatusMessage>,
|
||||||
) -> Result<StartLiveTranscriptionResponse, String> {
|
) -> Result<StartLiveTranscriptionResponse, String> {
|
||||||
|
let _lifecycle = live_state.lifecycle.lock().await;
|
||||||
{
|
{
|
||||||
let running = live_state.running.lock().unwrap();
|
let running = live_state.running.lock().unwrap();
|
||||||
if running.is_some() {
|
if running.is_some() {
|
||||||
@@ -227,11 +518,8 @@ pub async fn start_live_transcription_session(
|
|||||||
// `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks
|
// `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks
|
||||||
// up profile fallback + vocabulary injection without further plumbing.
|
// up profile fallback + vocabulary injection without further plumbing.
|
||||||
let request_prompt = config.initial_prompt.clone().unwrap_or_default();
|
let request_prompt = config.initial_prompt.clone().unwrap_or_default();
|
||||||
config.initial_prompt = build_initial_prompt(
|
config.initial_prompt =
|
||||||
&request_prompt,
|
build_initial_prompt(&request_prompt, &profile.initial_prompt, &profile_terms);
|
||||||
&profile.initial_prompt,
|
|
||||||
&profile_terms,
|
|
||||||
);
|
|
||||||
|
|
||||||
let model_id = config
|
let model_id = config
|
||||||
.model_id
|
.model_id
|
||||||
@@ -258,7 +546,10 @@ pub async fn start_live_transcription_session(
|
|||||||
// for save_audio=true and silently dropping the recording would
|
// for save_audio=true and silently dropping the recording would
|
||||||
// surprise them worse.
|
// surprise them worse.
|
||||||
let audio_path = if config.save_audio {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -299,6 +590,7 @@ pub async fn stop_live_transcription_session(
|
|||||||
live_state: tauri::State<'_, LiveTranscriptionState>,
|
live_state: tauri::State<'_, LiveTranscriptionState>,
|
||||||
session_id: u64,
|
session_id: u64,
|
||||||
) -> Result<StopLiveTranscriptionResponse, String> {
|
) -> Result<StopLiveTranscriptionResponse, String> {
|
||||||
|
let _lifecycle = live_state.lifecycle.lock().await;
|
||||||
let running = live_state.running.lock().unwrap().take();
|
let running = live_state.running.lock().unwrap().take();
|
||||||
let Some(running) = running else {
|
let Some(running) = running else {
|
||||||
return Err("No live transcription session is running".into());
|
return Err("No live transcription session is running".into());
|
||||||
@@ -360,34 +652,32 @@ fn run_live_session(
|
|||||||
// lifetime to the session — when the function returns, the Drop
|
// lifetime to the session — when the function returns, the Drop
|
||||||
// impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md.
|
// impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md.
|
||||||
let _power_guard = PowerAssertion::begin("kon live dictation session");
|
let _power_guard = PowerAssertion::begin("kon live dictation session");
|
||||||
|
LiveSessionRuntime::new(
|
||||||
let (mut capture, rx) = match config.microphone_device.as_deref() {
|
session_id,
|
||||||
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
|
engine,
|
||||||
_ => MicrophoneCapture::start(),
|
config,
|
||||||
|
audio_path,
|
||||||
|
dictionary_terms,
|
||||||
|
result_channel,
|
||||||
|
status_channel,
|
||||||
|
stop_flag,
|
||||||
|
)?
|
||||||
|
.run()
|
||||||
}
|
}
|
||||||
.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;
|
fn open_wav_writer(
|
||||||
let mut capture_buffer: Vec<f32> = Vec::new();
|
engine: &Arc<LocalEngine>,
|
||||||
|
audio_path: Option<&PathBuf>,
|
||||||
// Progressive WAV writer (brief item #19). Sample rate comes from
|
session_id: u64,
|
||||||
// the loaded backend's capabilities (#13 wiring) so a future
|
status_channel: &Channel<LiveStatusMessage>,
|
||||||
// non-16kHz backend records at its native rate without further
|
) -> Option<WavWriter> {
|
||||||
// plumbing. The writer flushes its header every ~500 ms, so the
|
|
||||||
// file on disk is a playable WAV even if the process is killed.
|
|
||||||
let sample_rate = engine
|
let sample_rate = engine
|
||||||
.capabilities()
|
.capabilities()
|
||||||
.map(|c| c.sample_rate)
|
.map(|c| c.sample_rate)
|
||||||
.unwrap_or(WHISPER_SAMPLE_RATE);
|
.unwrap_or(WHISPER_SAMPLE_RATE);
|
||||||
let mut wav_writer: Option<WavWriter> = match audio_path.as_ref() {
|
let path = audio_path?;
|
||||||
Some(path) => match WavWriter::create(path, sample_rate, 1) {
|
match WavWriter::create(path, sample_rate, 1) {
|
||||||
Ok(w) => Some(w),
|
Ok(writer) => Some(writer),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = status_channel.send(LiveStatusMessage::Warning {
|
let _ = status_channel.send(LiveStatusMessage::Warning {
|
||||||
session_id,
|
session_id,
|
||||||
@@ -398,165 +688,18 @@ fn run_live_session(
|
|||||||
});
|
});
|
||||||
None
|
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)) {
|
fn finalize_wav_writer(
|
||||||
Ok(chunk) => {
|
wav_writer: Option<WavWriter>,
|
||||||
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
|
audio_path: Option<&PathBuf>,
|
||||||
let resampler = match &mut resampler {
|
session_id: u64,
|
||||||
Some(resampler) => resampler,
|
status_channel: &Channel<LiveStatusMessage>,
|
||||||
None => {
|
) -> Option<String> {
|
||||||
resampler = Some(
|
match wav_writer {
|
||||||
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 {
|
|
||||||
session_id,
|
|
||||||
dropped_audio_ms,
|
|
||||||
message: "Kon dropped older audio to keep live dictation responsive".into(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
Some(writer) => match writer.finalize() {
|
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) => {
|
Err(e) => {
|
||||||
let _ = status_channel.send(LiveStatusMessage::Warning {
|
let _ = status_channel.send(LiveStatusMessage::Warning {
|
||||||
session_id,
|
session_id,
|
||||||
@@ -568,13 +711,7 @@ fn run_live_session(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => None,
|
None => None,
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(LiveSessionSummary {
|
|
||||||
session_id,
|
|
||||||
dropped_audio_ms,
|
|
||||||
audio_path,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_resampled_audio(
|
fn append_resampled_audio(
|
||||||
@@ -719,6 +856,7 @@ fn maybe_dispatch_chunk(
|
|||||||
|
|
||||||
fn poll_inference(
|
fn poll_inference(
|
||||||
inflight: &mut Option<InferenceTask>,
|
inflight: &mut Option<InferenceTask>,
|
||||||
|
result_listener_lost: &mut bool,
|
||||||
session_id: u64,
|
session_id: u64,
|
||||||
config: &StartLiveTranscriptionConfig,
|
config: &StartLiveTranscriptionConfig,
|
||||||
recent_segments: &mut Vec<RecentTranscriptSegment>,
|
recent_segments: &mut Vec<RecentTranscriptSegment>,
|
||||||
@@ -761,9 +899,7 @@ fn poll_inference(
|
|||||||
);
|
);
|
||||||
let segment_count = segments.len();
|
let segment_count = segments.len();
|
||||||
let delivered_segments = segments.clone();
|
let delivered_segments = segments.clone();
|
||||||
|
let result_message = LiveResultMessage {
|
||||||
result_channel
|
|
||||||
.send(LiveResultMessage {
|
|
||||||
session_id,
|
session_id,
|
||||||
chunk_id: task.chunk_id,
|
chunk_id: task.chunk_id,
|
||||||
chunk_start_secs,
|
chunk_start_secs,
|
||||||
@@ -772,11 +908,21 @@ fn poll_inference(
|
|||||||
inference_ms: timed.inference_ms,
|
inference_ms: timed.inference_ms,
|
||||||
segments,
|
segments,
|
||||||
raw_text,
|
raw_text,
|
||||||
})
|
};
|
||||||
.map_err(|e| e.to_string())?;
|
let delivered = emit_live_result(
|
||||||
|
result_channel,
|
||||||
|
status_channel,
|
||||||
|
result_listener_lost,
|
||||||
|
&result_message,
|
||||||
|
);
|
||||||
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
||||||
eprintln!(
|
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,
|
task.chunk_id,
|
||||||
segment_count,
|
segment_count,
|
||||||
timed.inference_ms,
|
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) {
|
fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
|
||||||
if trim_before_secs <= 0.0 {
|
if trim_before_secs <= 0.0 {
|
||||||
return;
|
return;
|
||||||
@@ -1150,6 +1323,82 @@ fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn segment(start: f64, end: f64, text: &str) -> Segment {
|
||||||
Segment {
|
Segment {
|
||||||
@@ -1256,4 +1505,157 @@ mod tests {
|
|||||||
assert_eq!(decision.speech_window_count, 3);
|
assert_eq!(decision.speech_window_count, 3);
|
||||||
assert_eq!(decision.max_consecutive_speech_windows, 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::hardware::{self, CpuFeatures};
|
||||||
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
|
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
|
||||||
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
|
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
|
||||||
use kon_transcription::model_manager;
|
|
||||||
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
|
|
||||||
#[cfg(feature = "whisper")]
|
#[cfg(feature = "whisper")]
|
||||||
use kon_transcription::load_whisper;
|
use kon_transcription::load_whisper;
|
||||||
|
use kon_transcription::model_manager;
|
||||||
|
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
|
||||||
|
|
||||||
/// Map legacy size strings to ModelId.
|
/// Map legacy size strings to ModelId.
|
||||||
fn whisper_model_id(size: &str) -> ModelId {
|
fn whisper_model_id(size: &str) -> ModelId {
|
||||||
@@ -73,9 +73,7 @@ fn model_capability(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_model_from_disk(
|
pub fn load_model_from_disk(model_id: &ModelId) -> Result<Box<dyn Transcriber + Send>, String> {
|
||||||
model_id: &ModelId,
|
|
||||||
) -> Result<Box<dyn Transcriber + Send>, String> {
|
|
||||||
let entry =
|
let entry =
|
||||||
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
|
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
|
// latency instead of the ~4–5s cold-start documented in
|
||||||
// ufal/whisper_streaming #96 and #135. Silence returns
|
// ufal/whisper_streaming #96 and #135. Silence returns
|
||||||
// empty segments — the *work* is the context allocation.
|
// empty segments — the *work* is the context allocation.
|
||||||
let silence =
|
let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
|
||||||
AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
|
|
||||||
let options = TranscriptionOptions::default();
|
let options = TranscriptionOptions::default();
|
||||||
match whisper_engine.transcribe_sync(&silence, &options) {
|
match whisper_engine.transcribe_sync(&silence, &options) {
|
||||||
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
|
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
|
||||||
@@ -379,11 +376,7 @@ fn supported_accelerators() -> Vec<String> {
|
|||||||
} else {
|
} else {
|
||||||
AcceleratorTarget::Other
|
AcceleratorTarget::Other
|
||||||
};
|
};
|
||||||
compose_accelerators(
|
compose_accelerators(cfg!(feature = "whisper"), vulkan_loader_available(), target)
|
||||||
cfg!(feature = "whisper"),
|
|
||||||
vulkan_loader_available(),
|
|
||||||
target,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report which backend whisper.cpp was actually able to initialise
|
/// Report which backend whisper.cpp was actually able to initialise
|
||||||
@@ -405,8 +398,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
|
|||||||
kind: "cpu".into(),
|
kind: "cpu".into(),
|
||||||
label: "CPU (fallback)".into(),
|
label: "CPU (fallback)".into(),
|
||||||
reason: Some(
|
reason: Some(
|
||||||
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime."
|
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime.".into(),
|
||||||
.into(),
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,11 @@ fn detect_focused_window_class_macos() -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -421,7 +425,11 @@ fn detect_focused_window_class_windows() -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
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> {
|
fn trigger_paste_keystroke() -> Result<String, String> {
|
||||||
@@ -473,7 +481,10 @@ fn trigger_undo_keystroke() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[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) {
|
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
|
||||||
match run_linux_tool(tool) {
|
match run_linux_tool(tool) {
|
||||||
Ok(()) => return Ok(tool.to_string()),
|
Ok(()) => return Ok(tool.to_string()),
|
||||||
|
|||||||
@@ -21,7 +21,17 @@
|
|||||||
//! may still decide to idle us. We log when that happens so the
|
//! may still decide to idle us. We log when that happens so the
|
||||||
//! diagnostics bundle has a breadcrumb.
|
//! diagnostics bundle has a breadcrumb.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
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
|
/// Handle for a single power assertion. Dropping it releases the
|
||||||
/// assertion. Holders are expected to keep it alive in a field for
|
/// assertion. Holders are expected to keep it alive in a field for
|
||||||
@@ -32,12 +42,30 @@ pub struct PowerAssertion {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
id: usize,
|
id: usize,
|
||||||
reason: &'static str,
|
reason: &'static str,
|
||||||
|
backend: &'static str,
|
||||||
|
acquired: bool,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
activity: Option<objc_bridge::ActivityHandle>,
|
activity: Option<objc_bridge::ActivityHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
|
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 {
|
impl PowerAssertion {
|
||||||
/// Begin a power assertion for the given reason. On macOS this
|
/// Begin a power assertion for the given reason. On macOS this
|
||||||
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
||||||
@@ -47,12 +75,16 @@ impl PowerAssertion {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let activity = objc_bridge::begin_activity(reason).ok();
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
if activity.is_none() {
|
if acquired {
|
||||||
eprintln!(
|
eprintln!("[power] began macOS App Nap guard #{id} for reason '{reason}'");
|
||||||
"[power] macOS App Nap guard could not begin activity for reason '{reason}'"
|
} else {
|
||||||
);
|
eprintln!("[power] macOS App Nap guard could not begin activity for reason '{reason}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
@@ -63,9 +95,26 @@ impl PowerAssertion {
|
|||||||
let _ = reason;
|
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 {
|
Self {
|
||||||
id,
|
id,
|
||||||
reason,
|
reason,
|
||||||
|
backend,
|
||||||
|
acquired,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
activity,
|
activity,
|
||||||
}
|
}
|
||||||
@@ -77,64 +126,83 @@ impl Drop for PowerAssertion {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let Some(handle) = self.activity.take() {
|
if let Some(handle) = self.activity.take() {
|
||||||
objc_bridge::end_activity(handle);
|
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.reason, self.id);
|
||||||
|
let _ = (self.backend, self.acquired);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod objc_bridge {
|
mod objc_bridge {
|
||||||
//! Placeholder for the NSProcessInfo App-Nap bridge.
|
use objc2::rc::Retained;
|
||||||
//!
|
use objc2::runtime::ProtocolObject;
|
||||||
//! A proper implementation calls:
|
use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString};
|
||||||
//! `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.
|
|
||||||
|
|
||||||
pub struct ActivityHandle {
|
pub struct ActivityHandle {
|
||||||
#[allow(dead_code)]
|
activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
|
||||||
retained: *mut std::ffi::c_void,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: The pointer is opaque to Rust; Foundation manages its
|
|
||||||
// lifetime via retain/release. We never dereference it directly.
|
|
||||||
unsafe impl Send for ActivityHandle {}
|
unsafe impl Send for ActivityHandle {}
|
||||||
|
|
||||||
pub fn begin_activity(_reason: &str) -> Result<ActivityHandle, String> {
|
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())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn power_assertion_is_a_no_op_drop() {
|
fn power_assertion_is_a_no_op_drop() {
|
||||||
|
let _guard = power_test_guard();
|
||||||
|
clear_assertion_registry();
|
||||||
let guard = PowerAssertion::begin("test-reason");
|
let guard = PowerAssertion::begin("test-reason");
|
||||||
|
let snapshots = active_assertions_snapshot();
|
||||||
|
assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id));
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
assert!(active_assertions_snapshot().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_assertions_get_unique_ids() {
|
fn multiple_assertions_get_unique_ids() {
|
||||||
|
let _guard = power_test_guard();
|
||||||
|
clear_assertion_registry();
|
||||||
let a = PowerAssertion::begin("a");
|
let a = PowerAssertion::begin("a");
|
||||||
let b = PowerAssertion::begin("b");
|
let b = PowerAssertion::begin("b");
|
||||||
assert_ne!(a.id, b.id);
|
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
|
/// known crashes that the HANDOVER documents working around with a manual
|
||||||
/// env-var prefix:
|
/// env-var prefix:
|
||||||
///
|
///
|
||||||
|
/// ```sh
|
||||||
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
|
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
|
||||||
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
|
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
|
||||||
|
/// ```
|
||||||
///
|
///
|
||||||
/// Detect the Wayland session at startup and apply the env vars before
|
/// 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
|
/// anything else loads, so users do not need to remember the prefix and
|
||||||
|
|||||||
Reference in New Issue
Block a user