agent: foundation — import legacy codebase from Obsidian vault
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rsync -av --exclude='node_modules' --exclude='target' --exclude='build' --exclude='.git' \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/\" \"/c/Users/jakea/CORBEL-Projects/kon/\")",
|
||||
"Bash(cp -r \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/\"* \"/c/Users/jakea/CORBEL-Projects/kon/\")",
|
||||
"Bash(cp -r \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/\".* \"/c/Users/jakea/CORBEL-Projects/kon/\")",
|
||||
"Bash(cp \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/vite.config.js\" \"/c/Users/jakea/CORBEL-Projects/kon/vite.config.js\")",
|
||||
"Bash(cp \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/.gitignore\" \"/c/Users/jakea/CORBEL-Projects/kon/.gitignore\")",
|
||||
"Bash(cp -r \"/c/Users/jakea/Obsidian/CORBEL/Projects/Transcriber/kon/.svelte-kit\" \"/c/Users/jakea/CORBEL-Projects/kon/.svelte-kit\")",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
target/
|
||||
build/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
Cargo.lock
|
||||
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["src-tauri", "crates/*"]
|
||||
resolver = "2"
|
||||
9
crates/ai-formatting/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "kon-ai-formatting"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
regex-lite = "0.1"
|
||||
6
crates/ai-formatting/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod llm_client;
|
||||
pub mod pipeline;
|
||||
pub mod rule_based;
|
||||
|
||||
pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
pub use rule_based::{format_text, is_hallucination, remove_fillers, to_british_english};
|
||||
5
crates/ai-formatting/src/llm_client.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Placeholder for future LLM sidecar integration (e.g., mistral.rs for smart formatting).
|
||||
//!
|
||||
//! When implemented, this module will expose a client that sends transcription
|
||||
//! segments to a local LLM for context-aware punctuation, paragraph splitting,
|
||||
//! and stylistic cleanup beyond what the rule-based pipeline can achieve.
|
||||
119
crates/ai-formatting/src/pipeline.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use kon_core::constants::SMART_PARAGRAPH_GAP_SECS;
|
||||
use kon_core::types::Segment;
|
||||
|
||||
use crate::rule_based;
|
||||
|
||||
/// Post-processing options for a transcription pipeline run.
|
||||
pub struct PostProcessOptions {
|
||||
pub remove_fillers: bool,
|
||||
pub british_english: bool,
|
||||
pub anti_hallucination: bool,
|
||||
pub format_mode: FormatMode,
|
||||
}
|
||||
|
||||
/// How aggressively to format the transcript text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FormatMode {
|
||||
Raw,
|
||||
Clean,
|
||||
Smart,
|
||||
}
|
||||
|
||||
impl FormatMode {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"Clean" => Self::Clean,
|
||||
"Smart" => Self::Smart,
|
||||
_ => Self::Raw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply all post-processing steps to a list of segments.
|
||||
/// Modifies segments in place. Composed from individual pure functions.
|
||||
pub fn post_process_segments(segments: &mut Vec<Segment>, options: &PostProcessOptions) {
|
||||
if options.anti_hallucination {
|
||||
segments.retain(|seg| !rule_based::is_hallucination(&seg.text));
|
||||
}
|
||||
|
||||
for seg in segments.iter_mut() {
|
||||
if options.remove_fillers {
|
||||
seg.text = rule_based::remove_fillers(&seg.text);
|
||||
}
|
||||
if options.british_english {
|
||||
seg.text = rule_based::to_british_english(&seg.text);
|
||||
}
|
||||
if options.format_mode != FormatMode::Raw {
|
||||
seg.text = rule_based::format_text(&seg.text);
|
||||
}
|
||||
}
|
||||
|
||||
if options.format_mode == FormatMode::Smart && segments.len() > 1 {
|
||||
for i in (1..segments.len()).rev() {
|
||||
let gap = segments[i].start - segments[i - 1].end;
|
||||
if gap > SMART_PARAGRAPH_GAP_SECS {
|
||||
segments[i].text = format!("\n\n{}", segments[i].text.trim_start());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_segments() -> Vec<Segment> {
|
||||
vec![
|
||||
Segment {
|
||||
start: 0.0,
|
||||
end: 1.0,
|
||||
text: "um hello world".into(),
|
||||
},
|
||||
Segment {
|
||||
start: 1.0,
|
||||
end: 2.0,
|
||||
text: "[blank_audio]".into(),
|
||||
},
|
||||
Segment {
|
||||
start: 5.0,
|
||||
end: 6.0,
|
||||
text: "organize the color scheme".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_process_applies_all_filters() {
|
||||
let mut segments = make_segments();
|
||||
let options = PostProcessOptions {
|
||||
remove_fillers: true,
|
||||
british_english: true,
|
||||
anti_hallucination: true,
|
||||
format_mode: FormatMode::Clean,
|
||||
};
|
||||
|
||||
post_process_segments(&mut segments, &options);
|
||||
|
||||
assert_eq!(segments.len(), 2);
|
||||
let lower0 = segments[0].text.to_lowercase();
|
||||
let lower1 = segments[1].text.to_lowercase();
|
||||
assert!(!lower0.contains("um"));
|
||||
assert!(lower1.contains("organise"));
|
||||
assert!(lower1.contains("colour"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_process_adds_paragraph_breaks_on_long_pauses() {
|
||||
let mut segments = make_segments();
|
||||
let options = PostProcessOptions {
|
||||
remove_fillers: false,
|
||||
british_english: false,
|
||||
anti_hallucination: false,
|
||||
format_mode: FormatMode::Smart,
|
||||
};
|
||||
|
||||
post_process_segments(&mut segments, &options);
|
||||
|
||||
assert!(segments[2].text.starts_with("\n\n"));
|
||||
}
|
||||
}
|
||||
291
crates/ai-formatting/src/rule_based.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Compiled filler word regexes (built once, reused across calls).
|
||||
/// Uses \b word boundaries instead of lookbehinds (regex-lite does not
|
||||
/// support lookaround assertions).
|
||||
static FILLER_REGEXES: LazyLock<Vec<regex_lite::Regex>> = LazyLock::new(|| {
|
||||
let fillers = [
|
||||
"um",
|
||||
"uh",
|
||||
"er",
|
||||
"ah",
|
||||
"like",
|
||||
"you know",
|
||||
"sort of",
|
||||
"kind of",
|
||||
"I mean",
|
||||
"basically",
|
||||
"actually",
|
||||
"literally",
|
||||
];
|
||||
fillers
|
||||
.iter()
|
||||
.filter_map(|filler| {
|
||||
let escaped = regex_lite::escape(filler);
|
||||
let pattern = format!(r"(?i)\b{escaped}\b[,.]?\s*");
|
||||
regex_lite::Regex::new(&pattern).ok()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Remove common filler words from transcription text (case-insensitive).
|
||||
pub fn remove_fillers(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
for re in FILLER_REGEXES.iter() {
|
||||
result = re.replace_all(&result, " ").to_string();
|
||||
}
|
||||
|
||||
// Collapse runs of whitespace in a single pass.
|
||||
let mut collapsed = String::with_capacity(result.len());
|
||||
let mut prev_space = false;
|
||||
for ch in result.chars() {
|
||||
if ch == ' ' {
|
||||
if !prev_space {
|
||||
collapsed.push(' ');
|
||||
}
|
||||
prev_space = true;
|
||||
} else {
|
||||
prev_space = false;
|
||||
collapsed.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
collapsed.trim().to_string()
|
||||
}
|
||||
|
||||
/// Replacement pairs for American to British English conversion.
|
||||
///
|
||||
/// All entries are plain base words (no regex metacharacters). The
|
||||
/// `to_british_english` function wraps every entry with `\b` word
|
||||
/// boundaries and optional suffix matching automatically.
|
||||
static BRITISH_REPLACEMENTS: &[(&str, &str)] = &[
|
||||
// -ize → -ise (and inflected forms)
|
||||
("organize", "organise"),
|
||||
("recognize", "recognise"),
|
||||
("realize", "realise"),
|
||||
("analyze", "analyse"),
|
||||
("apologize", "apologise"),
|
||||
("authorize", "authorise"),
|
||||
("categorize", "categorise"),
|
||||
("characterize", "characterise"),
|
||||
("customize", "customise"),
|
||||
("digitize", "digitise"),
|
||||
("emphasize", "emphasise"),
|
||||
("finalize", "finalise"),
|
||||
("generalize", "generalise"),
|
||||
("harmonize", "harmonise"),
|
||||
("initialize", "initialise"),
|
||||
("maximize", "maximise"),
|
||||
("minimize", "minimise"),
|
||||
("modernize", "modernise"),
|
||||
("normalize", "normalise"),
|
||||
("optimize", "optimise"),
|
||||
("prioritize", "prioritise"),
|
||||
("revolutionize", "revolutionise"),
|
||||
("specialize", "specialise"),
|
||||
("standardize", "standardise"),
|
||||
("summarize", "summarise"),
|
||||
("utilize", "utilise"),
|
||||
// -or → -our
|
||||
("color", "colour"),
|
||||
("favor", "favour"),
|
||||
("honor", "honour"),
|
||||
("humor", "humour"),
|
||||
("labor", "labour"),
|
||||
("neighbor", "neighbour"),
|
||||
("behavior", "behaviour"),
|
||||
// -er → -re
|
||||
("center", "centre"),
|
||||
("fiber", "fibre"),
|
||||
("liter", "litre"),
|
||||
("meter", "metre"),
|
||||
("theater", "theatre"),
|
||||
// -ense → -ence
|
||||
("defense", "defence"),
|
||||
("offense", "offence"),
|
||||
// Other
|
||||
("catalog", "catalogue"),
|
||||
("dialog", "dialogue"),
|
||||
];
|
||||
|
||||
/// Convert American English spelling to British English (word-boundary aware).
|
||||
pub fn to_british_english(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
for (us, uk) in BRITISH_REPLACEMENTS {
|
||||
// Every entry in BRITISH_REPLACEMENTS is a plain ASCII base word.
|
||||
// We wrap it with \b boundaries and optional suffix matching here.
|
||||
let pattern = format!("(?i)\\b{}(?:d|s|r|rs)?\\b", regex_lite::escape(us));
|
||||
|
||||
if let Ok(re) = regex_lite::Regex::new(&pattern) {
|
||||
result = re
|
||||
.replace_all(&result, |caps: ®ex_lite::Captures| {
|
||||
let Some(m) = caps.get(0) else {
|
||||
return String::new();
|
||||
};
|
||||
let matched = m.as_str();
|
||||
let base_len = us.len();
|
||||
// SAFETY: byte indexing is correct here because both
|
||||
// the US base word and the suffix characters (d, s, r)
|
||||
// are guaranteed ASCII by the BRITISH_REPLACEMENTS table.
|
||||
debug_assert!(us.is_ascii(), "BRITISH_REPLACEMENTS entries must be ASCII");
|
||||
debug_assert!(matched.is_ascii(), "matched text expected to be ASCII");
|
||||
let suffix = if matched.len() > base_len {
|
||||
&matched[base_len..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let Some(first_char) = matched.chars().next() else {
|
||||
return String::new();
|
||||
};
|
||||
if first_char.is_uppercase() {
|
||||
let mut chars = uk.chars();
|
||||
let Some(first_uk) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let upper_first: String = first_uk.to_uppercase().collect();
|
||||
format!("{}{}{}", upper_first, chars.collect::<String>(), suffix)
|
||||
} else {
|
||||
format!("{uk}{suffix}")
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Basic formatting: capitalise sentences, fix spacing, clean punctuation.
|
||||
pub fn format_text(text: &str) -> String {
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut capitalise_next = true;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
|
||||
if c == ' ' && i + 1 < chars.len() && chars[i + 1] == ' ' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if capitalise_next && c.is_alphabetic() {
|
||||
result.extend(c.to_uppercase());
|
||||
capitalise_next = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
|
||||
if c == '.' || c == '!' || c == '?' {
|
||||
capitalise_next = true;
|
||||
}
|
||||
if c == '\n' {
|
||||
capitalise_next = true;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Known hallucination markers that should be filtered from transcriptions.
|
||||
static HALLUCINATION_MARKERS: &[&str] = &["[blank_audio]", "[music]", "[silence]"];
|
||||
|
||||
static AUTO_THANKS_PHRASES: &[&str] = &["thank you.", "thanks.", "you.", "thank you for watching."];
|
||||
|
||||
/// Returns true if a segment's text looks like a hallucination.
|
||||
pub fn is_hallucination(text: &str) -> bool {
|
||||
let trimmed = text.trim().to_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for marker in HALLUCINATION_MARKERS {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if trimmed.len() < 15 {
|
||||
for phrase in AUTO_THANKS_PHRASES {
|
||||
if trimmed == *phrase {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remove_fillers_strips_um_and_uh() {
|
||||
let input = "So um I was thinking uh about this";
|
||||
let result = remove_fillers(input);
|
||||
assert!(!result.contains("um"));
|
||||
assert!(!result.contains("uh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_fillers_preserves_legitimate_words() {
|
||||
let input = "The umbrella was actually useful";
|
||||
let result = remove_fillers(input);
|
||||
assert!(result.contains("umbrella"));
|
||||
assert!(result.contains("useful"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_british_english_converts_ize_to_ise() {
|
||||
assert!(to_british_english("organize").contains("organise"));
|
||||
assert!(to_british_english("realize").contains("realise"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_british_english_preserves_case() {
|
||||
let result = to_british_english("Organize the files");
|
||||
assert!(result.starts_with("Organise"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_british_english_handles_colour() {
|
||||
assert!(to_british_english("the color is red").contains("colour"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_text_capitalises_after_full_stops() {
|
||||
let result = format_text("hello world. this is a test");
|
||||
assert!(result.starts_with('H'));
|
||||
assert!(result.contains(". T"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_text_handles_empty_string() {
|
||||
assert_eq!(format_text(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hallucination_detects_blank_audio() {
|
||||
assert!(is_hallucination("[blank_audio]"));
|
||||
assert!(is_hallucination(" [music] "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hallucination_detects_auto_thanks() {
|
||||
assert!(is_hallucination("Thank you."));
|
||||
assert!(is_hallucination("thanks."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hallucination_allows_real_text() {
|
||||
assert!(!is_hallucination("The meeting is at three o'clock."));
|
||||
}
|
||||
}
|
||||
27
crates/audio/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "kon-audio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
|
||||
# Microphone capture
|
||||
cpal = "0.17"
|
||||
|
||||
# Voice activity detection — deferred until ort version conflict between
|
||||
# VAD crates (ort rc.10) and transcribe-rs (ort rc.12) is resolved upstream.
|
||||
# silero-vad-rust = { version = "6", default-features = false }
|
||||
|
||||
# High-quality resampling (sinc interpolation)
|
||||
rubato = "0.15"
|
||||
|
||||
# WAV file I/O
|
||||
hound = "3.5"
|
||||
|
||||
# Audio file decoding (mp3, aac, flac, wav, ogg, etc.)
|
||||
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "pcm", "vorbis", "wav", "ogg", "isomp4"] }
|
||||
|
||||
# Async runtime for threading
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
75
crates/audio/src/capture.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
|
||||
/// A chunk of captured audio from the microphone.
|
||||
pub struct AudioChunk {
|
||||
pub samples: Vec<f32>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
/// Manages microphone capture via cpal.
|
||||
/// Call `start()` to begin capturing, which returns a receiver for audio chunks.
|
||||
/// Call `stop()` to end the stream.
|
||||
pub struct MicrophoneCapture {
|
||||
stream: Option<cpal::Stream>,
|
||||
}
|
||||
|
||||
impl MicrophoneCapture {
|
||||
/// Start capturing audio from the default input device.
|
||||
/// Returns a receiver that yields AudioChunks as they arrive.
|
||||
pub fn start() -> Result<(Self, mpsc::Receiver<AudioChunk>)> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_input_device().ok_or_else(|| {
|
||||
KonError::AudioCaptureFailed("No input device found".into())
|
||||
})?;
|
||||
|
||||
let config = device.default_input_config().map_err(|e| {
|
||||
KonError::AudioCaptureFailed(format!("No input config: {e}"))
|
||||
})?;
|
||||
|
||||
let sample_rate = config.sample_rate();
|
||||
let channels = config.channels() as u16;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<AudioChunk>();
|
||||
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], _info: &cpal::InputCallbackInfo| {
|
||||
let _ = tx.send(AudioChunk {
|
||||
samples: data.to_vec(),
|
||||
sample_rate,
|
||||
channels,
|
||||
});
|
||||
},
|
||||
|err| eprintln!("audio capture error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| {
|
||||
KonError::AudioCaptureFailed(format!("Build stream failed: {e}"))
|
||||
})?;
|
||||
|
||||
stream.play().map_err(|e| {
|
||||
KonError::AudioCaptureFailed(format!("Stream play failed: {e}"))
|
||||
})?;
|
||||
|
||||
Ok((Self { stream: Some(stream) }, rx))
|
||||
}
|
||||
|
||||
/// Stop capturing audio.
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(stream) = self.stream.take() {
|
||||
let _ = stream.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicrophoneCapture {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
19
crates/audio/src/concurrency.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::path::Path;
|
||||
|
||||
use kon_core::error::Result;
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
use crate::decode::decode_audio_file;
|
||||
use crate::resample::resample_to_16khz;
|
||||
|
||||
/// Decode and resample an audio file on a blocking thread.
|
||||
/// Returns 16kHz mono AudioSamples ready for transcription.
|
||||
pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
|
||||
let path = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let audio = decode_audio_file(&path)?;
|
||||
resample_to_16khz(&audio)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| kon_core::error::KonError::AudioDecodeFailed(format!("Task join error: {e}")))?
|
||||
}
|
||||
104
crates/audio/src/decode.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
/// Decode an audio file to mono f32 PCM samples.
|
||||
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
|
||||
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
||||
let file = File::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
let track = format
|
||||
.default_track()
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(KonError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
||||
}
|
||||
|
||||
let track_id = track.id;
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
|
||||
|
||||
let mut samples: Vec<f32> = Vec::new();
|
||||
let mut decode_errors = 0u32;
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(ref e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(symphonia::core::errors::Error::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
decode_errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let channels = spec.channels.count();
|
||||
let mut sample_buf =
|
||||
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
|
||||
let buf = sample_buf.samples();
|
||||
if channels == 1 {
|
||||
samples.extend_from_slice(buf);
|
||||
} else {
|
||||
for chunk in buf.chunks(channels) {
|
||||
let sum: f32 = chunk.iter().sum();
|
||||
samples.push(sum / channels as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
if decode_errors > 0 {
|
||||
return Err(KonError::AudioDecodeFailed(format!(
|
||||
"No audio decoded ({decode_errors} packets failed — file may be corrupt)"
|
||||
)));
|
||||
}
|
||||
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
|
||||
}
|
||||
|
||||
Ok(AudioSamples::new(samples, sample_rate, 1))
|
||||
}
|
||||
13
crates/audio/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod capture;
|
||||
pub mod concurrency;
|
||||
pub mod decode;
|
||||
pub mod resample;
|
||||
pub mod vad;
|
||||
pub mod wav;
|
||||
|
||||
pub use capture::{AudioChunk, MicrophoneCapture};
|
||||
pub use concurrency::decode_and_resample;
|
||||
pub use decode::decode_audio_file;
|
||||
pub use resample::resample_to_16khz;
|
||||
pub use vad::SpeechDetector;
|
||||
pub use wav::{read_wav, write_wav};
|
||||
105
crates/audio/src/resample.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use rubato::{SincFixedIn, SincInterpolationParameters, SincInterpolationType, Resampler, WindowFunction};
|
||||
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
/// Resample audio to 16kHz mono using sinc interpolation (rubato).
|
||||
/// Returns a new AudioSamples at the target sample rate.
|
||||
pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
|
||||
let from_rate = audio.sample_rate();
|
||||
let target_rate = WHISPER_SAMPLE_RATE;
|
||||
|
||||
if from_rate == target_rate {
|
||||
return Ok(AudioSamples::mono_16khz(audio.samples().to_vec()));
|
||||
}
|
||||
|
||||
if from_rate == 0 {
|
||||
return Err(KonError::AudioDecodeFailed(
|
||||
"Cannot resample: source rate is 0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ratio = target_rate as f64 / from_rate as f64;
|
||||
let chunk_size = 1024;
|
||||
|
||||
let params = SincInterpolationParameters {
|
||||
sinc_len: 256,
|
||||
f_cutoff: 0.95,
|
||||
oversampling_factor: 128,
|
||||
interpolation: SincInterpolationType::Cubic,
|
||||
window: WindowFunction::Blackman,
|
||||
};
|
||||
|
||||
let mut resampler = SincFixedIn::<f32>::new(
|
||||
ratio,
|
||||
1.1,
|
||||
params,
|
||||
chunk_size,
|
||||
1, // mono
|
||||
)
|
||||
.map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!("Resampler init failed: {e}"))
|
||||
})?;
|
||||
|
||||
let samples = audio.samples();
|
||||
let mut output_samples: Vec<f32> = Vec::new();
|
||||
|
||||
let mut offset = 0;
|
||||
while offset < samples.len() {
|
||||
let end = (offset + chunk_size).min(samples.len());
|
||||
let mut chunk = samples[offset..end].to_vec();
|
||||
|
||||
if chunk.len() < chunk_size {
|
||||
chunk.resize(chunk_size, 0.0);
|
||||
}
|
||||
|
||||
let input = vec![chunk];
|
||||
let result = resampler.process(&input, None).map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!("Resample failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !result.is_empty() && !result[0].is_empty() {
|
||||
output_samples.extend_from_slice(&result[0]);
|
||||
}
|
||||
|
||||
offset += chunk_size;
|
||||
}
|
||||
|
||||
// Trim to expected length (padding may have added extra samples)
|
||||
let expected_len = (samples.len() as f64 * ratio) as usize;
|
||||
output_samples.truncate(expected_len);
|
||||
|
||||
Ok(AudioSamples::mono_16khz(output_samples))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resample_passthrough_at_16khz() {
|
||||
let input = AudioSamples::mono_16khz(vec![0.1, 0.2, 0.3]);
|
||||
let output = resample_to_16khz(&input).unwrap();
|
||||
assert_eq!(output.sample_rate(), 16000);
|
||||
assert_eq!(output.samples().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resample_preserves_approximate_duration() {
|
||||
let rate = 48000;
|
||||
let duration_secs = 1.0;
|
||||
let num_samples = (rate as f64 * duration_secs) as usize;
|
||||
let samples: Vec<f32> =
|
||||
(0..num_samples).map(|i| (i as f32 * 0.001).sin()).collect();
|
||||
|
||||
let input = AudioSamples::new(samples, rate, 1);
|
||||
let output = resample_to_16khz(&input).unwrap();
|
||||
|
||||
let output_duration = output.samples().len() as f64 / 16000.0;
|
||||
assert!(
|
||||
(output_duration - duration_secs).abs() < 0.1,
|
||||
"Duration mismatch: expected ~{duration_secs}s, got {output_duration}s"
|
||||
);
|
||||
}
|
||||
}
|
||||
35
crates/audio/src/vad.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Voice Activity Detection — stubbed.
|
||||
//
|
||||
// Both `voice_activity_detector` and `silero-vad-rust` pin ort 2.0.0-rc.10
|
||||
// which conflicts with transcribe-rs requiring ort 2.0.0-rc.12.
|
||||
// When the ort ecosystem aligns (likely at 2.0.0 stable), add Silero VAD here.
|
||||
//
|
||||
// For now, all audio is treated as speech. This matches v0.2 behaviour
|
||||
// (no VAD) and doesn't affect core functionality.
|
||||
|
||||
use kon_core::constants::VAD_SPEECH_THRESHOLD;
|
||||
|
||||
/// Stub speech detector. Treats all audio as speech.
|
||||
#[derive(Default)]
|
||||
pub struct SpeechDetector {
|
||||
threshold: f64,
|
||||
}
|
||||
|
||||
impl SpeechDetector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
threshold: VAD_SPEECH_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Always returns true (no VAD filtering until ort conflict resolved).
|
||||
pub fn is_speech(&self, _samples: &[f32]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn threshold(&self) -> f64 {
|
||||
self.threshold
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {}
|
||||
}
|
||||
83
crates/audio/src/wav.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use std::path::Path;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
/// Write f32 PCM samples to a 16-bit WAV file.
|
||||
pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
|
||||
let spec = hound::WavSpec {
|
||||
channels: audio.channels(),
|
||||
sample_rate: audio.sample_rate(),
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = hound::WavWriter::create(path, spec)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
|
||||
for &sample in audio.samples() {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||
writer
|
||||
.write_sample(int_sample)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?;
|
||||
}
|
||||
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a WAV file to f32 PCM AudioSamples.
|
||||
pub fn read_wav(path: &Path) -> Result<AudioSamples> {
|
||||
let reader = hound::WavReader::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("WAV open failed: {e}")))?;
|
||||
|
||||
let spec = reader.spec();
|
||||
let sample_rate = spec.sample_rate;
|
||||
let channels = spec.channels;
|
||||
|
||||
let samples: Vec<f32> = match spec.sample_format {
|
||||
hound::SampleFormat::Int => reader
|
||||
.into_samples::<i32>()
|
||||
.filter_map(|s| s.ok())
|
||||
.map(|s| s as f32 / (1 << (spec.bits_per_sample - 1)) as f32)
|
||||
.collect(),
|
||||
hound::SampleFormat::Float => reader
|
||||
.into_samples::<f32>()
|
||||
.filter_map(|s| s.ok())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
Ok(AudioSamples::new(samples, sample_rate, channels))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wav_roundtrip() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("kon_test_roundtrip.wav");
|
||||
|
||||
let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]);
|
||||
write_wav(&path, &original).unwrap();
|
||||
|
||||
let loaded = read_wav(&path).unwrap();
|
||||
assert_eq!(loaded.sample_rate(), 16000);
|
||||
assert_eq!(loaded.samples().len(), 5);
|
||||
|
||||
// 16-bit quantisation introduces small error
|
||||
for (a, b) in original.samples().iter().zip(loaded.samples().iter()) {
|
||||
assert!(
|
||||
(a - b).abs() < 0.001,
|
||||
"Sample mismatch: original={a}, loaded={b}"
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
8
crates/cloud-providers/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-cloud-providers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "BYOK cloud STT provider stubs and API key storage for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
29
crates/cloud-providers/src/keystore.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Store an API key in the OS keychain.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Keys are only held in-process and lost on exit.
|
||||
///
|
||||
/// # Safety note
|
||||
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
|
||||
/// thread-safe — mutating the environment while other threads read it is
|
||||
/// undefined behaviour. This is acceptable during single-threaded app init
|
||||
/// but must not be called from async/multi-threaded contexts.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
||||
/// storage) so keys persist across sessions and are accessed safely.
|
||||
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
|
||||
pub fn store_api_key(provider: &str, key: &str) {
|
||||
// SAFETY: Only safe when called from a single-threaded context (e.g. app
|
||||
// initialisation). See doc comment above.
|
||||
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
|
||||
}
|
||||
|
||||
/// Retrieve an API key from the OS keychain.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Returns `None` if no key has been stored this session.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
|
||||
pub fn retrieve_api_key(provider: &str) -> Option<String> {
|
||||
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok()
|
||||
}
|
||||
3
crates/cloud-providers/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod keystore;
|
||||
|
||||
pub use keystore::{retrieve_api_key, store_api_key};
|
||||
12
crates/core/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "kon-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core types, constants, traits, hardware detection, and model registry for Kon"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
sysinfo = "0.35"
|
||||
async-trait = "0.1"
|
||||
49
crates/core/src/constants.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
/// Audio pipeline constants.
|
||||
pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
|
||||
pub const WHISPER_CHANNELS: u16 = 1;
|
||||
|
||||
/// Parakeet mel spectrogram constants.
|
||||
pub const PARAKEET_N_FFT: usize = 512;
|
||||
pub const PARAKEET_HOP_LENGTH: usize = 160;
|
||||
pub const PARAKEET_WIN_LENGTH: usize = 400;
|
||||
pub const PARAKEET_N_MELS: usize = 80;
|
||||
pub const PARAKEET_PRE_EMPHASIS: f32 = 0.97;
|
||||
pub const PARAKEET_BLANK_TOKEN: usize = 1024;
|
||||
pub const PARAKEET_LOG_GUARD: f32 = 5.960_464_5e-8; // 2^-24
|
||||
|
||||
/// Chunk timing for live transcription.
|
||||
pub const CHUNK_INTERVAL_MS: u64 = 3000;
|
||||
pub const MIN_CHUNK_SAMPLES: usize = 8000;
|
||||
|
||||
/// Post-processing thresholds.
|
||||
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
|
||||
|
||||
/// Thread count for inference. Leaves headroom for the UI thread.
|
||||
pub const MIN_INFERENCE_THREADS: usize = 4;
|
||||
|
||||
/// History limits.
|
||||
pub const HISTORY_MAX_ENTRIES: usize = 100;
|
||||
|
||||
/// RAM thresholds for model recommendations (in GB).
|
||||
pub const RAM_MINIMUM_FOR_LOCAL_STT: f64 = 2.0;
|
||||
pub const RAM_THRESHOLD_LIGHTWEIGHT: f64 = 4.0;
|
||||
pub const RAM_THRESHOLD_STANDARD: f64 = 8.0;
|
||||
pub const RAM_THRESHOLD_COMFORTABLE: f64 = 16.0;
|
||||
|
||||
/// VAD configuration defaults.
|
||||
pub const VAD_SPEECH_THRESHOLD: f64 = 0.5;
|
||||
pub const VAD_MIN_SPEECH_DURATION_MS: u32 = 250;
|
||||
pub const VAD_MAX_SPEECH_DURATION_S: u32 = 30;
|
||||
pub const VAD_MIN_SILENCE_DURATION_MS: u32 = 300;
|
||||
pub const VAD_SPEECH_PAD_MS: u32 = 100;
|
||||
|
||||
/// Model download chunk size for progress reporting.
|
||||
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
|
||||
|
||||
/// Inference thread count based on available parallelism.
|
||||
pub fn inference_thread_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(1))
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
.max(MIN_INFERENCE_THREADS)
|
||||
}
|
||||
60
crates/core/src/error.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::types::ModelId;
|
||||
|
||||
/// Structured error type for Kon.
|
||||
///
|
||||
/// Implements `Serialize` so errors can be sent to the frontend as
|
||||
/// structured JSON rather than opaque strings.
|
||||
#[derive(Debug, thiserror::Error, Serialize)]
|
||||
pub enum KonError {
|
||||
#[error("model not found: {0}")]
|
||||
ModelNotFound(ModelId),
|
||||
|
||||
#[error("model not downloaded: {0}")]
|
||||
ModelNotDownloaded(ModelId),
|
||||
|
||||
#[error("engine not loaded: call load_model first")]
|
||||
EngineNotLoaded,
|
||||
|
||||
#[error("transcription failed: {0}")]
|
||||
TranscriptionFailed(String),
|
||||
|
||||
#[error("audio decode failed: {0}")]
|
||||
AudioDecodeFailed(String),
|
||||
|
||||
#[error("audio capture failed: {0}")]
|
||||
AudioCaptureFailed(String),
|
||||
|
||||
#[error("model download failed: {0}")]
|
||||
DownloadFailed(String),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
FileNotFound(PathBuf),
|
||||
|
||||
#[error("storage error: {0}")]
|
||||
StorageError(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(
|
||||
#[from]
|
||||
#[serde(serialize_with = "serialize_io_error")]
|
||||
std::io::Error,
|
||||
),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Serialises `std::io::Error` as its display string, since it does
|
||||
/// not implement `Serialize` natively.
|
||||
fn serialize_io_error<S: serde::Serializer>(
|
||||
err: &std::io::Error,
|
||||
s: S,
|
||||
) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&err.to_string())
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, KonError>;
|
||||
105
crates/core/src/hardware.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use sysinfo::System;
|
||||
|
||||
use crate::types::Megabytes;
|
||||
|
||||
/// Detected system capabilities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemProfile {
|
||||
pub ram: Megabytes,
|
||||
pub cpu: CpuInfo,
|
||||
pub gpu: Option<GpuInfo>,
|
||||
pub os: Os,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CpuInfo {
|
||||
pub logical_processors: usize,
|
||||
pub brand: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GpuInfo {
|
||||
pub vendor: GpuVendor,
|
||||
pub vram: Megabytes,
|
||||
pub acceleration: GpuAcceleration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GpuVendor {
|
||||
Nvidia,
|
||||
Amd,
|
||||
Intel,
|
||||
Apple,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GpuAcceleration {
|
||||
pub cuda: bool,
|
||||
pub metal: bool,
|
||||
pub vulkan: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Os {
|
||||
Windows,
|
||||
Linux,
|
||||
MacOs,
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
/// Probes RAM from a shared `System` instance.
|
||||
fn probe_ram_from(sys: &System) -> Megabytes {
|
||||
let total_bytes = sys.total_memory();
|
||||
Megabytes(total_bytes / (1024 * 1024))
|
||||
}
|
||||
|
||||
/// Probes CPU info from a shared `System` instance.
|
||||
fn probe_cpu_from(sys: &System) -> CpuInfo {
|
||||
CpuInfo {
|
||||
logical_processors: sys.cpus().len(),
|
||||
brand: sys
|
||||
.cpus()
|
||||
.first()
|
||||
.map(|c| c.brand().to_string())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probe_gpu() -> Option<GpuInfo> {
|
||||
// GPU detection via wgpu or platform-specific APIs.
|
||||
// Placeholder: returns None until wgpu or nvml integration is added.
|
||||
None
|
||||
}
|
||||
|
||||
pub fn probe_os() -> Os {
|
||||
#[cfg(target_os = "windows")]
|
||||
return Os::Windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
return Os::Linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
return Os::MacOs;
|
||||
#[cfg(target_os = "ios")]
|
||||
return Os::Ios;
|
||||
#[cfg(target_os = "android")]
|
||||
return Os::Android;
|
||||
|
||||
// Fallback for unsupported targets — treat as Linux since
|
||||
// most exotic/embedded targets are Unix-like.
|
||||
#[allow(unreachable_code)]
|
||||
Os::Linux
|
||||
}
|
||||
|
||||
/// Composes the individual probes using a single `System` snapshot.
|
||||
/// `System::new_all()` is expensive — calling it once rather than
|
||||
/// per-probe avoids redundant OS queries.
|
||||
pub fn probe_system() -> SystemProfile {
|
||||
let sys = System::new_all();
|
||||
SystemProfile {
|
||||
ram: probe_ram_from(&sys),
|
||||
cpu: probe_cpu_from(&sys),
|
||||
gpu: probe_gpu(),
|
||||
os: probe_os(),
|
||||
}
|
||||
}
|
||||
13
crates/core/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod constants;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod model_registry;
|
||||
pub mod providers;
|
||||
pub mod recommendation;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{KonError, Result};
|
||||
pub use types::{
|
||||
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment,
|
||||
Transcript, TranscriptMetadata, TranscriptionOptions,
|
||||
};
|
||||
166
crates/core/src/model_registry.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::types::{Megabytes, ModelId};
|
||||
|
||||
/// Which inference backend a model uses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Engine {
|
||||
Whisper,
|
||||
Parakeet,
|
||||
Moonshine,
|
||||
}
|
||||
|
||||
/// Qualitative speed classification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedTier {
|
||||
Instant,
|
||||
Fast,
|
||||
Moderate,
|
||||
Slow,
|
||||
}
|
||||
|
||||
/// Qualitative accuracy classification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccuracyTier {
|
||||
Excellent,
|
||||
Great,
|
||||
Good,
|
||||
}
|
||||
|
||||
/// Language support scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LanguageSupport {
|
||||
EnglishOnly,
|
||||
Multilingual(u16),
|
||||
}
|
||||
|
||||
/// File required for a model download.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelFile {
|
||||
pub filename: &'static str,
|
||||
pub url: &'static str,
|
||||
pub size: Megabytes,
|
||||
}
|
||||
|
||||
/// All metadata for a single downloadable model.
|
||||
/// This is pure data — no scoring logic lives here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: ModelId,
|
||||
pub engine: Engine,
|
||||
pub display_name: &'static str,
|
||||
pub disk_size: Megabytes,
|
||||
pub ram_required: Megabytes,
|
||||
pub speed_tier: SpeedTier,
|
||||
pub accuracy_tier: AccuracyTier,
|
||||
pub languages: LanguageSupport,
|
||||
pub files: Vec<ModelFile>,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
vec![
|
||||
ModelEntry {
|
||||
id: ModelId::new("parakeet-ctc-0.6b-int8"),
|
||||
engine: Engine::Parakeet,
|
||||
display_name: "Parakeet CTC 0.6B (int8)",
|
||||
disk_size: Megabytes(613),
|
||||
ram_required: Megabytes(600),
|
||||
speed_tier: SpeedTier::Instant,
|
||||
accuracy_tier: AccuracyTier::Great,
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![
|
||||
ModelFile {
|
||||
filename: "encoder-model.onnx",
|
||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx",
|
||||
size: Megabytes(1),
|
||||
},
|
||||
ModelFile {
|
||||
filename: "model_int8.onnx_data",
|
||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx_data",
|
||||
size: Megabytes(611),
|
||||
},
|
||||
ModelFile {
|
||||
filename: "tokenizer.json",
|
||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json",
|
||||
size: Megabytes(1),
|
||||
},
|
||||
],
|
||||
description: "Fastest local model — near-instant transcription",
|
||||
},
|
||||
ModelEntry {
|
||||
id: ModelId::new("whisper-tiny-en"),
|
||||
engine: Engine::Whisper,
|
||||
display_name: "Whisper Tiny (English)",
|
||||
disk_size: Megabytes(75),
|
||||
ram_required: Megabytes(390),
|
||||
speed_tier: SpeedTier::Fast,
|
||||
accuracy_tier: AccuracyTier::Good,
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-tiny.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
||||
size: Megabytes(75),
|
||||
}],
|
||||
description: "Bundled with app — works instantly",
|
||||
},
|
||||
ModelEntry {
|
||||
id: ModelId::new("whisper-base-en"),
|
||||
engine: Engine::Whisper,
|
||||
display_name: "Whisper Base (English)",
|
||||
disk_size: Megabytes(142),
|
||||
ram_required: Megabytes(500),
|
||||
speed_tier: SpeedTier::Fast,
|
||||
accuracy_tier: AccuracyTier::Good,
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-base.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
||||
size: Megabytes(142),
|
||||
}],
|
||||
description: "Good balance of speed and accuracy",
|
||||
},
|
||||
ModelEntry {
|
||||
id: ModelId::new("whisper-small-en"),
|
||||
engine: Engine::Whisper,
|
||||
display_name: "Whisper Small (English)",
|
||||
disk_size: Megabytes(466),
|
||||
ram_required: Megabytes(1024),
|
||||
speed_tier: SpeedTier::Moderate,
|
||||
accuracy_tier: AccuracyTier::Great,
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-small.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
||||
size: Megabytes(466),
|
||||
}],
|
||||
description: "Accuracy-first English transcription",
|
||||
},
|
||||
ModelEntry {
|
||||
id: ModelId::new("whisper-medium-en"),
|
||||
engine: Engine::Whisper,
|
||||
display_name: "Whisper Medium (English)",
|
||||
disk_size: Megabytes(1500),
|
||||
ram_required: Megabytes(2600),
|
||||
speed_tier: SpeedTier::Slow,
|
||||
accuracy_tier: AccuracyTier::Excellent,
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-medium.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
|
||||
size: Megabytes(1500),
|
||||
}],
|
||||
description: "Best Whisper accuracy — needs 4+ GB RAM",
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
/// Returns all known models. Pure data, no scoring.
|
||||
pub fn all_models() -> &'static [ModelEntry] {
|
||||
&ALL_MODELS
|
||||
}
|
||||
|
||||
/// Find a model by its ID.
|
||||
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
|
||||
ALL_MODELS.iter().find(|m| &m.id == id)
|
||||
}
|
||||
40
crates/core/src/providers.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::types::{AudioSamples, EngineName, Transcript, TranscriptionOptions};
|
||||
|
||||
/// Any speech-to-text engine implements this trait.
|
||||
/// Base types know nothing about their derivatives.
|
||||
#[async_trait]
|
||||
pub trait SpeechToText: Send + Sync {
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
options: &TranscriptionOptions,
|
||||
) -> Result<Transcript>;
|
||||
|
||||
fn name(&self) -> &EngineName;
|
||||
|
||||
fn is_available(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Any text post-processor implements this trait.
|
||||
#[async_trait]
|
||||
pub trait TextProcessor: Send + Sync {
|
||||
async fn process(&self, text: &str, instruction: &str) -> Result<String>;
|
||||
|
||||
fn name(&self) -> &EngineName;
|
||||
|
||||
fn is_available(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Holds the active provider instances. Constructed at startup,
|
||||
/// rebuilt when user changes provider in settings.
|
||||
// TODO: Wire into Tauri app state once multi-engine switching is implemented.
|
||||
#[allow(dead_code)]
|
||||
pub struct ProviderRegistry {
|
||||
pub stt: Arc<dyn SpeechToText>,
|
||||
pub text: Option<Arc<dyn TextProcessor>>,
|
||||
}
|
||||
180
crates/core/src/recommendation.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use crate::hardware::SystemProfile;
|
||||
use crate::model_registry::{all_models, AccuracyTier, Engine, ModelEntry, SpeedTier};
|
||||
use crate::types::Megabytes;
|
||||
|
||||
/// A model's suitability score for a given system. Higher is better.
|
||||
/// No boolean flags — position in the ranked list conveys recommendation.
|
||||
pub struct ScoredModel {
|
||||
pub entry: &'static ModelEntry,
|
||||
pub score: f64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Scores a single model against a system profile.
|
||||
/// Pure function, no side effects.
|
||||
pub fn score_model(model: &'static ModelEntry, profile: &SystemProfile) -> Option<ScoredModel> {
|
||||
if model.ram_required > profile.ram {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut score = 0.0;
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
|
||||
score += match model.speed_tier {
|
||||
SpeedTier::Instant => 40.0,
|
||||
SpeedTier::Fast => 30.0,
|
||||
SpeedTier::Moderate => 20.0,
|
||||
SpeedTier::Slow => 10.0,
|
||||
};
|
||||
|
||||
score += match model.accuracy_tier {
|
||||
AccuracyTier::Excellent => 30.0,
|
||||
AccuracyTier::Great => 20.0,
|
||||
AccuracyTier::Good => 10.0,
|
||||
};
|
||||
|
||||
if let Some(gpu) = &profile.gpu {
|
||||
let has_accel = match model.engine {
|
||||
Engine::Whisper => {
|
||||
gpu.acceleration.metal || gpu.acceleration.vulkan || gpu.acceleration.cuda
|
||||
}
|
||||
Engine::Parakeet | Engine::Moonshine => {
|
||||
gpu.acceleration.cuda || gpu.acceleration.vulkan
|
||||
}
|
||||
};
|
||||
if has_accel {
|
||||
score += 15.0;
|
||||
reasons.push("GPU accelerated on your system".into());
|
||||
}
|
||||
}
|
||||
|
||||
let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0));
|
||||
if headroom > Megabytes::from_gb(4.0) {
|
||||
score += 10.0;
|
||||
}
|
||||
|
||||
let reason = if reasons.is_empty() {
|
||||
model.description.to_string()
|
||||
} else {
|
||||
reasons.join(". ")
|
||||
};
|
||||
|
||||
Some(ScoredModel {
|
||||
entry: model,
|
||||
score,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// Scores all models and returns them ranked.
|
||||
/// Index 0 is the recommendation. No flag arguments.
|
||||
pub fn rank_recommendations(profile: &SystemProfile) -> Vec<ScoredModel> {
|
||||
let mut scored: Vec<ScoredModel> = all_models()
|
||||
.iter()
|
||||
.filter_map(|model| score_model(model, profile))
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
scored
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hardware::{CpuInfo, GpuAcceleration, GpuInfo, GpuVendor, Os};
|
||||
|
||||
fn profile_with_ram(ram: Megabytes) -> SystemProfile {
|
||||
SystemProfile {
|
||||
ram,
|
||||
cpu: CpuInfo {
|
||||
logical_processors: 8,
|
||||
brand: "Test CPU".into(),
|
||||
},
|
||||
gpu: None,
|
||||
os: Os::Windows,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_with_gpu(ram: Megabytes) -> SystemProfile {
|
||||
SystemProfile {
|
||||
ram,
|
||||
cpu: CpuInfo {
|
||||
logical_processors: 8,
|
||||
brand: "Test CPU".into(),
|
||||
},
|
||||
gpu: Some(GpuInfo {
|
||||
vendor: GpuVendor::Nvidia,
|
||||
vram: Megabytes(8192),
|
||||
acceleration: GpuAcceleration {
|
||||
cuda: true,
|
||||
metal: false,
|
||||
vulkan: true,
|
||||
},
|
||||
}),
|
||||
os: Os::Windows,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_model_excludes_models_exceeding_available_ram() {
|
||||
let profile = profile_with_ram(Megabytes(256));
|
||||
let model = all_models()
|
||||
.iter()
|
||||
.find(|m| m.ram_required > Megabytes(256))
|
||||
.expect("need a model larger than 256 MB");
|
||||
|
||||
let result = score_model(model, &profile);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_model_includes_models_fitting_in_ram() {
|
||||
let profile = profile_with_ram(Megabytes(16384));
|
||||
let model = &all_models()[0];
|
||||
|
||||
let result = score_model(model, &profile);
|
||||
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_model_boosts_gpu_accelerated_models() {
|
||||
let model = all_models()
|
||||
.iter()
|
||||
.find(|m| m.engine == Engine::Parakeet)
|
||||
.expect("need a Parakeet model");
|
||||
|
||||
let gpu_score = score_model(model, &profile_with_gpu(Megabytes(16384)))
|
||||
.unwrap()
|
||||
.score;
|
||||
let cpu_score = score_model(model, &profile_with_ram(Megabytes(16384)))
|
||||
.unwrap()
|
||||
.score;
|
||||
|
||||
assert!(gpu_score > cpu_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_recommendations_places_highest_score_first() {
|
||||
let profile = profile_with_ram(Megabytes(16384));
|
||||
|
||||
let ranked = rank_recommendations(&profile);
|
||||
|
||||
assert!(ranked.len() >= 2);
|
||||
assert!(ranked[0].score >= ranked[1].score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_recommendations_returns_empty_for_very_low_ram() {
|
||||
let profile = profile_with_ram(Megabytes(128));
|
||||
|
||||
let ranked = rank_recommendations(&profile);
|
||||
|
||||
assert!(ranked.is_empty());
|
||||
}
|
||||
}
|
||||
194
crates/core/src/types.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prevents passing raw strings where model IDs are expected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ModelId(String);
|
||||
|
||||
impl ModelId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevents passing raw strings where engine names are expected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EngineName(String);
|
||||
|
||||
impl EngineName {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EngineName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevents mixing up bytes, megabytes, and gigabytes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
pub struct Megabytes(pub u64);
|
||||
|
||||
impl Megabytes {
|
||||
pub fn from_gb(gb: f64) -> Self {
|
||||
Self((gb * 1024.0) as u64)
|
||||
}
|
||||
|
||||
pub fn as_gb(&self) -> f64 {
|
||||
self.0 as f64 / 1024.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Megabytes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.0 >= 1024 {
|
||||
write!(f, "{:.1} GB", self.as_gb())
|
||||
} else {
|
||||
write!(f, "{} MB", self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps raw audio samples with metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSamples {
|
||||
samples: Vec<f32>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
impl AudioSamples {
|
||||
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
|
||||
Self {
|
||||
samples,
|
||||
sample_rate,
|
||||
channels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mono_16khz(samples: Vec<f32>) -> Self {
|
||||
Self {
|
||||
samples,
|
||||
sample_rate: crate::constants::WHISPER_SAMPLE_RATE,
|
||||
channels: crate::constants::WHISPER_CHANNELS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn samples(&self) -> &[f32] {
|
||||
&self.samples
|
||||
}
|
||||
|
||||
pub fn into_samples(self) -> Vec<f32> {
|
||||
self.samples
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.samples.len() as f64 / self.sample_rate as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// A single timed segment of a transcription.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The result of a transcription.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transcript {
|
||||
segments: Vec<Segment>,
|
||||
language: String,
|
||||
duration: f64,
|
||||
}
|
||||
|
||||
impl Transcript {
|
||||
pub fn new(segments: Vec<Segment>, language: String, duration: f64) -> Self {
|
||||
Self {
|
||||
segments,
|
||||
language,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn segments(&self) -> &[Segment] {
|
||||
&self.segments
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Options passed to a transcription engine.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TranscriptionOptions {
|
||||
pub language: Option<String>,
|
||||
pub initial_prompt: Option<String>,
|
||||
}
|
||||
|
||||
/// Full provenance metadata for a transcript.
|
||||
/// Captures everything needed to reproduce the transcription.
|
||||
// TODO: Attach to Transcript once the store layer persists transcription provenance.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscriptMetadata {
|
||||
pub engine: String,
|
||||
pub model_id: ModelId,
|
||||
pub inference_ms: u64,
|
||||
pub sample_rate: u32,
|
||||
pub audio_channels: u16,
|
||||
pub format_mode: String,
|
||||
pub remove_fillers: bool,
|
||||
pub british_english: bool,
|
||||
pub anti_hallucination: bool,
|
||||
}
|
||||
|
||||
/// Progress update during model download.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadProgress {
|
||||
pub model_id: ModelId,
|
||||
pub file_name: String,
|
||||
pub bytes_downloaded: u64,
|
||||
pub total_bytes: u64,
|
||||
pub percent: u8,
|
||||
}
|
||||
17
crates/storage/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "kon-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "SQLite persistence, BM25 search, and file storage for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
|
||||
# SQLite with compile-time checked queries
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
385
crates/storage/src/database.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use std::path::Path;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
|
||||
/// Initialise the SQLite database with connection pool and run migrations.
|
||||
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Database connect failed: {e}")))?;
|
||||
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Run schema migrations via the versioned migration system.
|
||||
async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
crate::migrations::run_migrations(pool).await
|
||||
}
|
||||
|
||||
// --- Transcript CRUD ---
|
||||
|
||||
/// Parameters for inserting a transcript with full provenance.
|
||||
pub struct InsertTranscriptParams<'a> {
|
||||
pub id: &'a str,
|
||||
pub text: &'a str,
|
||||
pub source: &'a str,
|
||||
pub title: Option<&'a str>,
|
||||
pub audio_path: Option<&'a str>,
|
||||
pub duration: f64,
|
||||
pub engine: Option<&'a str>,
|
||||
pub model_id: Option<&'a str>,
|
||||
pub inference_ms: Option<i64>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub audio_channels: Option<i32>,
|
||||
pub format_mode: Option<&'a str>,
|
||||
pub remove_fillers: bool,
|
||||
pub british_english: bool,
|
||||
pub anti_hallucination: bool,
|
||||
}
|
||||
|
||||
pub async fn insert_transcript(
|
||||
pool: &SqlitePool,
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(params.id)
|
||||
.bind(params.text)
|
||||
.bind(params.source)
|
||||
.bind(params.title)
|
||||
.bind(params.audio_path)
|
||||
.bind(params.duration)
|
||||
.bind(params.engine)
|
||||
.bind(params.model_id)
|
||||
.bind(params.inference_ms)
|
||||
.bind(params.sample_rate)
|
||||
.bind(params.audio_channels)
|
||||
.bind(params.format_mode)
|
||||
.bind(params.remove_fillers)
|
||||
.bind(params.british_english)
|
||||
.bind(params.anti_hallucination)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
||||
let row = sqlx::query(
|
||||
"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 FROM transcripts WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?;
|
||||
|
||||
Ok(row.map(|r| transcript_row_from(&r)))
|
||||
}
|
||||
|
||||
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
|
||||
let rows = sqlx::query(
|
||||
"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 FROM transcripts ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
|
||||
|
||||
Ok(rows.iter().map(transcript_row_from).collect())
|
||||
}
|
||||
|
||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Delete transcript failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Task CRUD ---
|
||||
|
||||
pub async fn insert_task(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
text: &str,
|
||||
bucket: &str,
|
||||
source_transcript_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(bucket)
|
||||
.bind(source_transcript_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| TaskRow {
|
||||
id: r.get("id"),
|
||||
text: r.get("text"),
|
||||
bucket: r.get("bucket"),
|
||||
list_id: r.get("list_id"),
|
||||
effort: r.get("effort"),
|
||||
done: r.get("done"),
|
||||
done_at: r.get("done_at"),
|
||||
created_at: r.get("created_at"),
|
||||
source_transcript_id: r.get("source_transcript_id"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Complete task failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM tasks WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Delete task failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Settings CRUD ---
|
||||
|
||||
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Set setting failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
|
||||
let row = sqlx::query("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get setting failed: {e}")))?;
|
||||
Ok(row.map(|r| r.get("value")))
|
||||
}
|
||||
|
||||
// --- Row types ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptRow {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub source: String,
|
||||
pub title: Option<String>,
|
||||
pub audio_path: Option<String>,
|
||||
pub duration: f64,
|
||||
pub engine: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub inference_ms: Option<i64>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub audio_channels: Option<i32>,
|
||||
pub format_mode: Option<String>,
|
||||
pub remove_fillers: bool,
|
||||
pub british_english: bool,
|
||||
pub anti_hallucination: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRow {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub bucket: String,
|
||||
pub list_id: Option<String>,
|
||||
pub effort: Option<String>,
|
||||
pub done: bool,
|
||||
pub done_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
}
|
||||
|
||||
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
TranscriptRow {
|
||||
id: r.get("id"),
|
||||
text: r.get("text"),
|
||||
source: r.get("source"),
|
||||
title: r.get("title"),
|
||||
audio_path: r.get("audio_path"),
|
||||
duration: r.get("duration"),
|
||||
engine: r.get("engine"),
|
||||
model_id: r.get("model_id"),
|
||||
inference_ms: r.get("inference_ms"),
|
||||
sample_rate: r.get("sample_rate"),
|
||||
audio_channels: r.get("audio_channels"),
|
||||
format_mode: r.get("format_mode"),
|
||||
remove_fillers: r.get("remove_fillers"),
|
||||
british_english: r.get("british_english"),
|
||||
anti_hallucination: r.get("anti_hallucination"),
|
||||
created_at: r.get("created_at"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Error Logging ---
|
||||
|
||||
/// Log a structured error to the `error_log` table.
|
||||
///
|
||||
/// Available for Tauri command handlers to persist errors for diagnostics.
|
||||
/// Each entry records context (which subsystem), an optional error code,
|
||||
/// the human-readable message, and optional JSON metadata.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// log_error(&pool, "transcription", Some("WHISPER_INIT"), "Model load failed", None).await?;
|
||||
/// ```
|
||||
///
|
||||
/// TODO: Wire this into Tauri command error paths so runtime failures are
|
||||
/// persisted for user-facing error history and crash diagnostics.
|
||||
pub async fn log_error(
|
||||
pool: &SqlitePool,
|
||||
context: &str,
|
||||
error_code: Option<&str>,
|
||||
message: &str,
|
||||
metadata: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(context)
|
||||
.bind(error_code)
|
||||
.bind(message)
|
||||
.bind(metadata)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Error log failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "t1",
|
||||
text: "Hello world",
|
||||
source: "microphone",
|
||||
title: Some("Test"),
|
||||
audio_path: None,
|
||||
duration: 1.5,
|
||||
engine: Some("whisper"),
|
||||
model_id: Some("whisper-tiny-en"),
|
||||
inference_ms: Some(250),
|
||||
sample_rate: Some(48000),
|
||||
audio_channels: Some(1),
|
||||
format_mode: Some("Clean"),
|
||||
remove_fillers: true,
|
||||
british_english: true,
|
||||
anti_hallucination: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
|
||||
assert_eq!(t.text, "Hello world");
|
||||
assert_eq!(t.source, "microphone");
|
||||
assert_eq!(t.duration, 1.5);
|
||||
assert_eq!(t.engine.as_deref(), Some("whisper"));
|
||||
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
|
||||
assert_eq!(t.inference_ms, Some(250));
|
||||
assert!(t.remove_fillers);
|
||||
assert!(t.british_english);
|
||||
|
||||
let list = list_transcripts(&pool, 10).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
delete_transcript(&pool, "t1").await.unwrap();
|
||||
let deleted = get_transcript(&pool, "t1").await.unwrap();
|
||||
assert!(deleted.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
insert_task(&pool, "task1", "Buy groceries", "today", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = list_tasks(&pool).await.unwrap();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].text, "Buy groceries");
|
||||
assert!(!tasks[0].done);
|
||||
|
||||
complete_task(&pool, "task1").await.unwrap();
|
||||
let tasks = list_tasks(&pool).await.unwrap();
|
||||
assert!(tasks[0].done);
|
||||
|
||||
delete_task(&pool, "task1").await.unwrap();
|
||||
let tasks = list_tasks(&pool).await.unwrap();
|
||||
assert!(tasks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
set_setting(&pool, "theme", "dark").await.unwrap();
|
||||
let val = get_setting(&pool, "theme").await.unwrap();
|
||||
assert_eq!(val.as_deref(), Some("dark"));
|
||||
|
||||
set_setting(&pool, "theme", "light").await.unwrap();
|
||||
let val = get_setting(&pool, "theme").await.unwrap();
|
||||
assert_eq!(val.as_deref(), Some("light"));
|
||||
|
||||
let missing = get_setting(&pool, "nonexistent").await.unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
}
|
||||
28
crates/storage/src/file_storage.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the app data directory.
|
||||
/// Windows: %LOCALAPPDATA%/kon
|
||||
/// Unix: ~/.kon
|
||||
///
|
||||
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
|
||||
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
|
||||
/// path logic across crates.
|
||||
pub fn app_data_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon")
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the SQLite database file.
|
||||
pub fn database_path() -> PathBuf {
|
||||
app_data_dir().join("kon.db")
|
||||
}
|
||||
|
||||
/// Directory for saved audio recordings.
|
||||
pub fn recordings_dir() -> PathBuf {
|
||||
app_data_dir().join("recordings")
|
||||
}
|
||||
10
crates/storage/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod database;
|
||||
pub mod file_storage;
|
||||
pub mod migrations;
|
||||
|
||||
pub use database::{
|
||||
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
|
||||
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
|
||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
||||
168
crates/storage/src/migrations.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use sqlx::SqlitePool;
|
||||
use kon_core::error::{KonError, Result};
|
||||
|
||||
/// Each migration is a (version, description, sql) tuple.
|
||||
/// Migrations MUST be append-only — never modify an existing migration.
|
||||
/// Column defaults and NOT NULL constraints must exactly match the existing schema.
|
||||
const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
(1, "initial schema", r#"
|
||||
CREATE TABLE IF NOT EXISTS 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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL DEFAULT 'inbox',
|
||||
list_id TEXT,
|
||||
effort TEXT,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
done_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
source_transcript_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
built_in INTEGER NOT NULL DEFAULT 0,
|
||||
profile_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS error_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
context TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
message TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transcripts_created ON transcripts(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_transcript ON segments(transcript_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_bucket ON tasks(bucket);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
|
||||
"#),
|
||||
];
|
||||
|
||||
/// Ensure the schema_version table exists and run any pending migrations.
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?;
|
||||
|
||||
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
|
||||
for (version, description, sql) in MIGRATIONS {
|
||||
if *version > current {
|
||||
log::info!("Running migration {}: {}", version, description);
|
||||
|
||||
let statements: Vec<&str> = sql.split(';')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
for statement in statements {
|
||||
sqlx::query(statement)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
|
||||
.bind(version)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
|
||||
|
||||
log::info!("Migration {} complete", version);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_idempotent() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
18
crates/transcription/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "kon-transcription"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
|
||||
# Unified STT engine (Parakeet via ONNX, Whisper via whisper.cpp)
|
||||
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp"] }
|
||||
|
||||
# Async runtime for spawn_blocking
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
# Model downloads
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
futures-util = "0.3"
|
||||
22
crates/transcription/src/concurrency.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{AudioSamples, TranscriptionOptions};
|
||||
|
||||
use crate::local_engine::{LocalEngine, TimedTranscript};
|
||||
|
||||
/// Runs inference on a blocking thread. Encapsulates all threading concerns.
|
||||
/// Callers never see spawn_blocking — they call this async function.
|
||||
pub async fn run_inference(
|
||||
engine: Arc<LocalEngine>,
|
||||
audio: AudioSamples,
|
||||
options: TranscriptionOptions,
|
||||
) -> Result<TimedTranscript> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
engine.transcribe_sync(&audio, &options)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!("Task join error: {e}"))
|
||||
})?
|
||||
}
|
||||
11
crates/transcription/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod concurrency;
|
||||
pub mod local_engine;
|
||||
pub mod model_manager;
|
||||
|
||||
pub use concurrency::run_inference;
|
||||
pub use local_engine::{
|
||||
load_parakeet, load_whisper, LocalEngine, TimedTranscript,
|
||||
};
|
||||
pub use model_manager::{
|
||||
download, is_downloaded, list_downloaded, model_dir, models_dir,
|
||||
};
|
||||
155
crates/transcription/src/local_engine.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{
|
||||
AudioSamples, EngineName, ModelId, Segment, Transcript,
|
||||
TranscriptionOptions,
|
||||
};
|
||||
|
||||
/// Result of a timed transcription: transcript + inference duration.
|
||||
pub struct TimedTranscript {
|
||||
pub transcript: Transcript,
|
||||
pub inference_ms: u64,
|
||||
}
|
||||
|
||||
/// Wraps any transcribe-rs engine in Kon's SpeechToText trait.
|
||||
/// Encapsulates threading: inference always runs on a blocking thread.
|
||||
/// The rest of the app never imports transcribe-rs directly.
|
||||
pub struct LocalEngine {
|
||||
engine: Mutex<Option<Box<dyn SpeechModel + Send>>>,
|
||||
engine_name: EngineName,
|
||||
loaded_model_id: Mutex<Option<ModelId>>,
|
||||
}
|
||||
|
||||
impl LocalEngine {
|
||||
pub fn new(engine_name: EngineName) -> Self {
|
||||
Self {
|
||||
engine: Mutex::new(None),
|
||||
engine_name,
|
||||
loaded_model_id: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, model: Box<dyn SpeechModel + Send>, model_id: ModelId) {
|
||||
let mut guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = Some(model);
|
||||
let mut id_guard = self
|
||||
.loaded_model_id
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*id_guard = Some(model_id);
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &EngineName {
|
||||
&self.engine_name
|
||||
}
|
||||
|
||||
pub fn loaded_model_id(&self) -> Option<ModelId> {
|
||||
let guard = self
|
||||
.loaded_model_id
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
let guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.is_some()
|
||||
}
|
||||
|
||||
/// Run transcription synchronously with timing.
|
||||
/// Called from within spawn_blocking.
|
||||
pub fn transcribe_sync(
|
||||
&self,
|
||||
audio: &AudioSamples,
|
||||
options: &TranscriptionOptions,
|
||||
) -> Result<TimedTranscript> {
|
||||
let mut guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let engine =
|
||||
guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
|
||||
|
||||
let opts = TranscribeOptions {
|
||||
language: options.language.clone(),
|
||||
translate: false,
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let result: TranscriptionResult = engine
|
||||
.transcribe(audio.samples(), &opts)
|
||||
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
|
||||
let inference_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
let segments = result
|
||||
.segments
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| Segment {
|
||||
start: s.start as f64,
|
||||
end: s.end as f64,
|
||||
text: s.text,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(TimedTranscript {
|
||||
transcript: Transcript::new(
|
||||
segments,
|
||||
options
|
||||
.language
|
||||
.clone()
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
audio.duration_secs(),
|
||||
),
|
||||
inference_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a Parakeet model from a directory path.
|
||||
pub fn load_parakeet(
|
||||
model_dir: &Path,
|
||||
) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
use transcribe_rs::onnx::Quantization;
|
||||
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(
|
||||
model_dir,
|
||||
&Quantization::Int8,
|
||||
)
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!(
|
||||
"Failed to load Parakeet: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(Box::new(model))
|
||||
}
|
||||
|
||||
/// Load a Whisper model from a GGML file path.
|
||||
pub fn load_whisper(
|
||||
model_path: &Path,
|
||||
) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
let engine =
|
||||
transcribe_rs::whisper_cpp::WhisperEngine::load(model_path)
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!(
|
||||
"Failed to load Whisper: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(Box::new(engine))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_reports_not_available_before_loading() {
|
||||
let engine = LocalEngine::new(EngineName::new("test"));
|
||||
assert!(!engine.is_loaded());
|
||||
assert!(engine.loaded_model_id().is_none());
|
||||
}
|
||||
}
|
||||
165
crates/transcription/src/model_manager.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::model_registry::{find_model, ModelFile};
|
||||
use kon_core::types::{DownloadProgress, ModelId};
|
||||
|
||||
/// Resolve the models storage directory.
|
||||
/// Windows: %LOCALAPPDATA%/kon/models
|
||||
/// Unix: ~/.kon/models
|
||||
pub fn models_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA")
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon").join("models")
|
||||
} else {
|
||||
dirs_path().join("models")
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_path() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA")
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon")
|
||||
} else {
|
||||
let home =
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the directory path where a specific model's files are stored.
|
||||
pub fn model_dir(id: &ModelId) -> PathBuf {
|
||||
models_dir().join(id.as_str())
|
||||
}
|
||||
|
||||
/// Check whether all files for a model have been downloaded.
|
||||
pub fn is_downloaded(id: &ModelId) -> bool {
|
||||
let entry = match find_model(id) {
|
||||
Some(e) => e,
|
||||
None => return false,
|
||||
};
|
||||
let dir = model_dir(id);
|
||||
entry.files.iter().all(|f| dir.join(f.filename).exists())
|
||||
}
|
||||
|
||||
/// List all downloaded model IDs.
|
||||
pub fn list_downloaded() -> Vec<ModelId> {
|
||||
kon_core::model_registry::all_models()
|
||||
.iter()
|
||||
.filter(|m| is_downloaded(&m.id))
|
||||
.map(|m| m.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Download all files for a model, calling the progress callback per chunk.
|
||||
/// Files are downloaded to a .part suffix and atomically renamed on completion.
|
||||
pub async fn download(
|
||||
id: &ModelId,
|
||||
progress: impl Fn(DownloadProgress) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let entry = find_model(id)
|
||||
.ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
|
||||
|
||||
let dir = model_dir(id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
for file in &entry.files {
|
||||
let dest = dir.join(file.filename);
|
||||
if dest.exists() {
|
||||
continue;
|
||||
}
|
||||
download_file(file, &dest, id, &progress).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_file(
|
||||
file: &ModelFile,
|
||||
dest: &Path,
|
||||
model_id: &ModelId,
|
||||
progress: &(impl Fn(DownloadProgress) + Send),
|
||||
) -> Result<()> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let part_path = dest.with_extension(
|
||||
dest.extension()
|
||||
.map(|e| format!("{}.part", e.to_string_lossy()))
|
||||
.unwrap_or_else(|| "part".to_string()),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
let response = client
|
||||
.get(file.url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
let total_bytes = response.content_length().unwrap_or(0);
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_percent: u8 = 0;
|
||||
|
||||
let mut out = std::fs::File::create(&part_path)?;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut out, &chunk)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percent = if total_bytes > 0 {
|
||||
((downloaded as f64 / total_bytes as f64) * 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if percent != last_percent {
|
||||
last_percent = percent;
|
||||
progress(DownloadProgress {
|
||||
model_id: model_id.clone(),
|
||||
file_name: file.filename.to_string(),
|
||||
bytes_downloaded: downloaded,
|
||||
total_bytes,
|
||||
percent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drop(out);
|
||||
std::fs::rename(&part_path, dest)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_dir_returns_correct_path() {
|
||||
let id = ModelId::new("whisper-tiny-en");
|
||||
let path = model_dir(&id);
|
||||
assert!(path.to_string_lossy().contains("whisper-tiny-en"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_downloaded_returns_false_for_missing_model() {
|
||||
let id = ModelId::new("nonexistent-model");
|
||||
assert!(!is_downloaded(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_downloaded_returns_empty_when_no_models() {
|
||||
let list = list_downloaded();
|
||||
// In test environment, no models are downloaded
|
||||
// This just verifies the function doesn't panic
|
||||
assert!(list.len() <= kon_core::model_registry::all_models().len());
|
||||
}
|
||||
}
|
||||
9
docs/design/phase2/fox/nanobanana-fox-mark.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048" width="1024" height="1024">
|
||||
<defs>
|
||||
<linearGradient id="Gradient1" gradientUnits="userSpaceOnUse" x1="1529.56" y1="106.143" x2="1542.76" y2="565.94">
|
||||
<stop offset="0" stop-color="rgb(240,168,94)"/>
|
||||
<stop offset="1" stop-color="rgb(255,243,213)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="rgb(235,155,69)" d="M 1185.28 113.465 C 1258.63 151.889 1340.95 212.953 1400.56 270.758 C 1418.18 287.842 1435.77 305.444 1452.59 323.304 C 1459.42 330.557 1465.75 339.915 1473.44 346.165 C 1479.83 351.36 1492.78 342.243 1500.15 341.79 C 1518.87 340.639 1538.54 338.247 1557.27 340.211 C 1566.41 341.168 1588.26 349.432 1595.93 346.286 C 1598.96 345.042 1665.1 276.227 1677.42 264.962 C 1730.28 216.383 1787.68 172.993 1848.84 135.391 C 1859.64 128.833 1877.24 117.558 1888.31 112.854 L 1889.96 112.165 C 1900.18 165.472 1912.19 327.395 1901.77 378.559 C 1902.52 387.903 1900.71 404.822 1899.51 414.815 C 1892.28 475.088 1879.32 537.933 1853.88 593.18 C 1872.22 623.189 1888.37 653.18 1904.04 684.653 C 1907.93 692.459 1918.62 712.155 1921.01 719.698 C 1916.71 723.203 1909.07 731.125 1904.29 735.442 C 1892.98 745.47 1881.54 755.351 1869.98 765.082 C 1824.77 802.917 1765.31 843.912 1713.25 870.516 C 1624.2 916.022 1539.62 937.365 1442.25 904.905 C 1346.25 872.902 1272.3 822.011 1194.83 758.054 C 1184.46 749.491 1158.84 728.938 1153.58 718.466 C 1164.15 684.264 1197.71 628.849 1217.27 597.143 C 1212.79 584.171 1207.43 571.864 1203.84 558.599 C 1199.74 548.839 1197.49 538.957 1194.85 528.779 C 1183.71 485.847 1172.93 440.008 1169.23 395.86 C 1167.53 375.605 1168.18 348.094 1168.22 327.16 C 1168.34 268.434 1167.37 210.459 1177.1 152.321 C 1179.3 139.155 1180.68 126.067 1185.28 113.465 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
14
jsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
2579
package-lock.json
generated
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "kon",
|
||||
"version": "0.1.0",
|
||||
"description": "Kon — Think out loud",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
2
src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"
|
||||
37
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "kon"
|
||||
version = "0.1.0"
|
||||
description = "Kon — Think out loud"
|
||||
authors = ["CORBEL Ltd"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "kon_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Workspace crates
|
||||
kon-core = { path = "../crates/core" }
|
||||
kon-audio = { path = "../crates/audio" }
|
||||
kon-transcription = { path = "../crates/transcription" }
|
||||
kon-ai-formatting = { path = "../crates/ai-formatting" }
|
||||
kon-storage = { path = "../crates/storage" }
|
||||
kon-cloud-providers = { path = "../crates/cloud-providers" }
|
||||
|
||||
# Tauri
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Async runtime (spawn_blocking for inference)
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
arboard = "3.6.1"
|
||||
tauri-plugin-mcp = "0.7.1"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
22
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "tasks-float"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
||||
2663
src-tauri/gen/schemas/desktop-schema.json
Normal file
2663
src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
49
src-tauri/src/commands/audio.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
/// Save PCM f32 samples as a WAV file. Returns the file path.
|
||||
#[tauri::command]
|
||||
pub async fn save_audio(
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
output_folder: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let recordings_dir = if let Some(ref folder) = output_folder {
|
||||
if !folder.is_empty() {
|
||||
PathBuf::from(folder)
|
||||
} else {
|
||||
app.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|e: tauri::Error| e.to_string())?
|
||||
.join("recordings")
|
||||
}
|
||||
} else {
|
||||
app.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|e: tauri::Error| e.to_string())?
|
||||
.join("recordings")
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&recordings_dir)
|
||||
.map_err(|e| format!("Failed to create recordings dir: {e}"))?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let filename = format!("kon-{timestamp}.wav");
|
||||
let path = recordings_dir.join(&filename);
|
||||
let path_clone = path.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let audio = AudioSamples::mono_16khz(samples);
|
||||
kon_audio::write_wav(&path_clone, &audio).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
12
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use arboard::Clipboard;
|
||||
|
||||
/// Copy text to the system clipboard via arboard.
|
||||
#[tauri::command]
|
||||
pub fn copy_to_clipboard(text: String) -> Result<(), String> {
|
||||
let mut clipboard =
|
||||
Clipboard::new().map_err(|e| format!("Clipboard init failed: {e}"))?;
|
||||
clipboard
|
||||
.set_text(&text)
|
||||
.map_err(|e| format!("Clipboard write failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
69
src-tauri/src/commands/hardware.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use kon_core::hardware::{self, Os};
|
||||
use kon_core::recommendation;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemInfo {
|
||||
pub ram_mb: u64,
|
||||
pub cpu_brand: String,
|
||||
pub cpu_cores: usize,
|
||||
pub os: String,
|
||||
pub gpu: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ModelRecommendation {
|
||||
pub id: String,
|
||||
pub display_name: &'static str,
|
||||
pub disk_size_mb: u64,
|
||||
pub ram_required_mb: u64,
|
||||
pub description: &'static str,
|
||||
pub score: f64,
|
||||
pub reason: String,
|
||||
pub is_downloaded: bool,
|
||||
}
|
||||
|
||||
/// Probe system hardware and return a summary.
|
||||
#[tauri::command]
|
||||
pub fn probe_system() -> Result<SystemInfo, String> {
|
||||
let profile = hardware::probe_system();
|
||||
Ok(SystemInfo {
|
||||
ram_mb: profile.ram.0,
|
||||
cpu_brand: profile.cpu.brand,
|
||||
cpu_cores: profile.cpu.logical_processors,
|
||||
os: match profile.os {
|
||||
Os::Windows => "Windows",
|
||||
Os::Linux => "Linux",
|
||||
Os::MacOs => "macOS",
|
||||
Os::Ios => "iOS",
|
||||
Os::Android => "Android",
|
||||
}
|
||||
.to_string(),
|
||||
gpu: profile.gpu.map(|g| format!("{:?}", g.vendor)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rank models for the current system and return recommendations.
|
||||
#[tauri::command]
|
||||
pub fn rank_models() -> Result<Vec<ModelRecommendation>, String> {
|
||||
let profile = hardware::probe_system();
|
||||
let ranked = recommendation::rank_recommendations(&profile);
|
||||
|
||||
Ok(ranked
|
||||
.into_iter()
|
||||
.map(|scored| {
|
||||
let downloaded = kon_transcription::is_downloaded(&scored.entry.id);
|
||||
ModelRecommendation {
|
||||
id: scored.entry.id.as_str().to_string(),
|
||||
display_name: scored.entry.display_name,
|
||||
disk_size_mb: scored.entry.disk_size.0,
|
||||
ram_required_mb: scored.entry.ram_required.0,
|
||||
description: scored.entry.description,
|
||||
score: scored.score,
|
||||
reason: scored.reason,
|
||||
is_downloaded: downloaded,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
6
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod audio;
|
||||
pub mod clipboard;
|
||||
pub mod hardware;
|
||||
pub mod models;
|
||||
pub mod transcription;
|
||||
pub mod windows;
|
||||
174
src-tauri/src/commands/models.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::AppState;
|
||||
use kon_core::types::ModelId;
|
||||
use kon_transcription::model_manager;
|
||||
|
||||
/// Map legacy size strings to ModelId.
|
||||
fn whisper_model_id(size: &str) -> ModelId {
|
||||
match size.to_lowercase().as_str() {
|
||||
"tiny" => ModelId::new("whisper-tiny-en"),
|
||||
"base" => ModelId::new("whisper-base-en"),
|
||||
"small" => ModelId::new("whisper-small-en"),
|
||||
"medium" => ModelId::new("whisper-medium-en"),
|
||||
other => ModelId::new(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parakeet_model_id(name: &str) -> ModelId {
|
||||
match name {
|
||||
"ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"),
|
||||
other => ModelId::new(other),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Whisper model commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_model(
|
||||
app: tauri::AppHandle,
|
||||
size: String,
|
||||
) -> Result<String, String> {
|
||||
let id = whisper_model_id(&size);
|
||||
let app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
let _ = app_clone.emit("model-download-progress", &progress);
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("Model {} downloaded", size))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_model(size: String) -> Result<bool, String> {
|
||||
let id = whisper_model_id(&size);
|
||||
Ok(model_manager::is_downloaded(&id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_models() -> Result<Vec<String>, String> {
|
||||
let ids = model_manager::list_downloaded();
|
||||
Ok(ids
|
||||
.into_iter()
|
||||
.filter(|id| id.as_str().starts_with("whisper-"))
|
||||
.map(|id| {
|
||||
match id.as_str() {
|
||||
"whisper-tiny-en" => "Tiny".to_string(),
|
||||
"whisper-base-en" => "Base".to_string(),
|
||||
"whisper-small-en" => "Small".to_string(),
|
||||
"whisper-medium-en" => "Medium".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_model(
|
||||
state: tauri::State<'_, AppState>,
|
||||
size: String,
|
||||
) -> Result<String, String> {
|
||||
let id = whisper_model_id(&size);
|
||||
let dir = model_manager::model_dir(&id);
|
||||
let model_file = dir.join(
|
||||
kon_core::model_registry::find_model(&id)
|
||||
.ok_or_else(|| format!("Unknown model: {size}"))?
|
||||
.files
|
||||
.first()
|
||||
.ok_or_else(|| format!("No files for model: {size}"))?
|
||||
.filename,
|
||||
);
|
||||
|
||||
if !model_file.exists() {
|
||||
return Err(format!("Model not downloaded: {size}"));
|
||||
}
|
||||
|
||||
let engine = state.whisper_engine.clone();
|
||||
let mid = id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = kon_transcription::load_whisper(&model_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
engine.load(model, mid);
|
||||
Ok::<_, String>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(format!("Model {} loaded", size))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
|
||||
Ok(state.whisper_engine.is_loaded())
|
||||
}
|
||||
|
||||
// --- Parakeet model commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_parakeet_model(
|
||||
app: tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<String, String> {
|
||||
let id = parakeet_model_id(&name);
|
||||
let app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
let _ = app_clone.emit("parakeet-download-progress", &progress);
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("Parakeet model {} downloaded", name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_parakeet_model(name: String) -> Result<bool, String> {
|
||||
let id = parakeet_model_id(&name);
|
||||
Ok(model_manager::is_downloaded(&id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_parakeet_models() -> Result<Vec<String>, String> {
|
||||
let ids = model_manager::list_downloaded();
|
||||
Ok(ids
|
||||
.into_iter()
|
||||
.filter(|id| id.as_str().starts_with("parakeet-"))
|
||||
.map(|id| {
|
||||
match id.as_str() {
|
||||
"parakeet-ctc-0.6b-int8" => "ctc-int8".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_parakeet_model(
|
||||
state: tauri::State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<String, String> {
|
||||
let id = parakeet_model_id(&name);
|
||||
let dir = model_manager::model_dir(&id);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(format!("Parakeet model not downloaded: {name}"));
|
||||
}
|
||||
|
||||
let engine = state.parakeet_engine.clone();
|
||||
let mid = id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = kon_transcription::load_parakeet(&dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
engine.load(model, mid);
|
||||
Ok::<_, String>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(format!("Parakeet model {} loaded", name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_parakeet_engine(
|
||||
state: tauri::State<AppState>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(state.parakeet_engine.is_loaded())
|
||||
}
|
||||
163
src-tauri/src/commands/transcription.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
// Tauri command handlers must match the frontend's invoke() parameter lists,
|
||||
// so the argument counts are dictated by the Svelte code.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
use kon_core::types::{Segment, TranscriptionOptions};
|
||||
|
||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
chunk_id: u32,
|
||||
language: String,
|
||||
initial_prompt: String,
|
||||
remove_fillers: bool,
|
||||
british_english: bool,
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
) -> Result<(), String> {
|
||||
let engine = state.whisper_engine.clone();
|
||||
let audio = kon_core::AudioSamples::mono_16khz(samples);
|
||||
let options = TranscriptionOptions {
|
||||
language: Some(language),
|
||||
initial_prompt: Some(initial_prompt),
|
||||
};
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
remove_fillers,
|
||||
british_english,
|
||||
anti_hallucination,
|
||||
format_mode: FormatMode::parse(&format_mode),
|
||||
},
|
||||
);
|
||||
|
||||
app.emit(
|
||||
"transcription-result",
|
||||
serde_json::json!({
|
||||
"status": "transcription",
|
||||
"segments": segments,
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_file(
|
||||
state: tauri::State<'_, AppState>,
|
||||
path: String,
|
||||
language: String,
|
||||
initial_prompt: String,
|
||||
remove_fillers: bool,
|
||||
british_english: bool,
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let engine = state.whisper_engine.clone();
|
||||
let options = TranscriptionOptions {
|
||||
language: Some(language),
|
||||
initial_prompt: Some(initial_prompt),
|
||||
};
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
let audio = kon_audio::decode_audio_file(Path::new(&path))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resampled =
|
||||
kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
|
||||
engine
|
||||
.transcribe_sync(&resampled, &options)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
remove_fillers,
|
||||
british_english,
|
||||
anti_hallucination,
|
||||
format_mode: FormatMode::parse(&format_mode),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"segments": segments,
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"inference_ms": timed.inference_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm_parakeet(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
chunk_id: u32,
|
||||
remove_fillers: bool,
|
||||
british_english: bool,
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
) -> Result<(), String> {
|
||||
let engine = state.parakeet_engine.clone();
|
||||
let audio = kon_core::AudioSamples::mono_16khz(samples);
|
||||
let options = TranscriptionOptions::default();
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
remove_fillers,
|
||||
british_english,
|
||||
anti_hallucination,
|
||||
format_mode: FormatMode::parse(&format_mode),
|
||||
},
|
||||
);
|
||||
|
||||
app.emit(
|
||||
"transcription-result",
|
||||
serde_json::json!({
|
||||
"status": "transcription",
|
||||
"segments": segments,
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
58
src-tauri/src/commands/windows.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
/// Open a floating always-on-top task window.
|
||||
#[tauri::command]
|
||||
pub async fn open_task_window(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("tasks-float") {
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
app.emit("task-window-focus", ())
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"tasks-float",
|
||||
WebviewUrl::App("/float".into()),
|
||||
)
|
||||
.title("Kon Tasks")
|
||||
.inner_size(480.0, 520.0)
|
||||
.min_inner_size(400.0, 400.0)
|
||||
.always_on_top(true)
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the transcript viewer window.
|
||||
#[tauri::command]
|
||||
pub async fn open_viewer_window(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcript-viewer") {
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"transcript-viewer",
|
||||
WebviewUrl::App("/viewer".into()),
|
||||
)
|
||||
.title("Kon - Viewer")
|
||||
.inner_size(600.0, 700.0)
|
||||
.min_inner_size(450.0, 500.0)
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
mod commands;
|
||||
mod tray;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use kon_core::types::EngineName;
|
||||
use kon_transcription::LocalEngine;
|
||||
|
||||
/// Shared app state holding the transcription engines.
|
||||
pub struct AppState {
|
||||
pub whisper_engine: Arc<LocalEngine>,
|
||||
pub parakeet_engine: Arc<LocalEngine>,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.manage(AppState {
|
||||
whisper_engine: Arc::new(LocalEngine::new(
|
||||
EngineName::new("whisper"),
|
||||
)),
|
||||
parakeet_engine: Arc::new(LocalEngine::new(
|
||||
EngineName::new("parakeet"),
|
||||
)),
|
||||
})
|
||||
.setup(|app| {
|
||||
if let Err(e) = tray::setup(app) {
|
||||
eprintln!("Failed to setup tray: {e}");
|
||||
}
|
||||
|
||||
// Close-to-tray: hide window instead of exiting
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
let win = main_window.clone();
|
||||
main_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } =
|
||||
event
|
||||
{
|
||||
api.prevent_close();
|
||||
let _ = win.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Whisper model management
|
||||
commands::models::download_model,
|
||||
commands::models::check_model,
|
||||
commands::models::list_models,
|
||||
commands::models::load_model,
|
||||
commands::models::check_engine,
|
||||
// Parakeet model management
|
||||
commands::models::download_parakeet_model,
|
||||
commands::models::check_parakeet_model,
|
||||
commands::models::list_parakeet_models,
|
||||
commands::models::load_parakeet_model,
|
||||
commands::models::check_parakeet_engine,
|
||||
// Transcription
|
||||
commands::transcription::transcribe_pcm,
|
||||
commands::transcription::transcribe_file,
|
||||
commands::transcription::transcribe_pcm_parakeet,
|
||||
// Audio
|
||||
commands::audio::save_audio,
|
||||
// Windows
|
||||
commands::windows::open_task_window,
|
||||
commands::windows::open_viewer_window,
|
||||
// Clipboard
|
||||
commands::clipboard::copy_to_clipboard,
|
||||
// Hardware
|
||||
commands::hardware::probe_system,
|
||||
commands::hardware::rank_models,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Kon");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
kon_lib::run()
|
||||
}
|
||||
59
src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
|
||||
let status = MenuItemBuilder::with_id("status", "Ready")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&status)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Image::new(&[0u8; 4], 1, 1));
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.tooltip("Kon — Ready")
|
||||
.menu(&menu)
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if let Some(window) =
|
||||
tray.app_handle().get_webview_window("main")
|
||||
{
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
40
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kon",
|
||||
"version": "0.1.0",
|
||||
"identifier": "uk.co.corbel.kon",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Kon",
|
||||
"width": 1020,
|
||||
"height": 720,
|
||||
"minWidth": 1020,
|
||||
"minHeight": 540,
|
||||
"decorations": false,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost asset: https://asset.localhost http://localhost:* ws://localhost:*; media-src 'self' asset: https://asset.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
307
src/app.css
Normal file
@@ -0,0 +1,307 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Instrument+Serif:ital@0;1&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* === Kon Design Tokens === */
|
||||
@theme {
|
||||
/* Surfaces — warm layered depth */
|
||||
--color-bg: #0f0e0c;
|
||||
--color-bg-elevated: #171614;
|
||||
--color-bg-card: #1b1a17;
|
||||
--color-bg-input: #151412;
|
||||
--color-border: #2c2923;
|
||||
--color-border-subtle: #221f1b;
|
||||
|
||||
/* Text — warm hierarchy */
|
||||
--color-text: #f0ece4;
|
||||
--color-text-secondary: #9a9486;
|
||||
--color-text-tertiary: #716b60;
|
||||
|
||||
/* Accent — warm amber/copper */
|
||||
--color-accent: #e8a87c;
|
||||
--color-accent-hover: #d4976a;
|
||||
--color-accent-subtle: #e8a87c10;
|
||||
--color-accent-glow: #e8a87c25;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #7ec89a;
|
||||
--color-danger: #e87171;
|
||||
--color-warning: #e8c86e;
|
||||
|
||||
/* Layout */
|
||||
--color-sidebar: #13120f;
|
||||
--color-nav-active: #201e1a;
|
||||
--color-hover: #1e1c18;
|
||||
|
||||
/* === Layout Tokens === */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--title-size: 26px;
|
||||
}
|
||||
|
||||
/* === Button Component Classes === */
|
||||
.btn-sm { @apply px-2.5 py-1 text-[11px]; }
|
||||
.btn-md { @apply px-3 py-1.5 text-[12px]; }
|
||||
.btn-lg { @apply px-4 py-2 text-[13px]; }
|
||||
|
||||
/* === Light theme overrides === */
|
||||
html.light {
|
||||
--color-bg: #faf8f5;
|
||||
--color-bg-elevated: #f3f0eb;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-input: #f0ede8;
|
||||
--color-border: #ddd8d0;
|
||||
--color-border-subtle: #e6e2da;
|
||||
|
||||
--color-text: #1a1816;
|
||||
--color-text-secondary: #5c574d;
|
||||
--color-text-tertiary: #8a8578;
|
||||
|
||||
--color-accent: #d4956a;
|
||||
--color-accent-hover: #c48a60;
|
||||
--color-accent-subtle: #d4956a10;
|
||||
--color-accent-glow: #d4956a25;
|
||||
|
||||
--color-success: #4a9e6a;
|
||||
--color-danger: #d45454;
|
||||
--color-warning: #c9a84e;
|
||||
|
||||
--color-sidebar: #f5f2ed;
|
||||
--color-nav-active: #eae6e0;
|
||||
--color-hover: #ede9e3;
|
||||
}
|
||||
|
||||
html.light ::-webkit-scrollbar-thumb {
|
||||
background: #d0ccc4;
|
||||
}
|
||||
html.light ::-webkit-scrollbar-thumb:hover {
|
||||
background: #b8b3aa;
|
||||
}
|
||||
html.light .grain::after {
|
||||
opacity: 0.015;
|
||||
}
|
||||
|
||||
/* === Base === */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* === Display font === */
|
||||
.font-display {
|
||||
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* === Transcript font — optimised for reading === */
|
||||
.font-transcript {
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: var(--font-size-transcript, 15px);
|
||||
line-height: 1.85;
|
||||
letter-spacing: 0.01em;
|
||||
font-feature-settings: "liga" 1, "kern" 1;
|
||||
}
|
||||
|
||||
/* === Grain texture overlay === */
|
||||
.grain::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
z-index: 50;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* === Scrollbar — thin, appears on hover === */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2c2923;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3d3830;
|
||||
}
|
||||
|
||||
/* === Smooth transitions (global) === */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, opacity, box-shadow;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Opt out for inputs */
|
||||
textarea, input, [data-no-transition] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* === Focus ring — consistent, accessible === */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* === Selection colour === */
|
||||
::selection {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes pulse-warm {
|
||||
0%, 100% { box-shadow: 0 0 24px rgba(232, 168, 124, 0.25), 0 0 48px rgba(232, 168, 124, 0.08); }
|
||||
50% { box-shadow: 0 0 32px rgba(232, 168, 124, 0.4), 0 0 64px rgba(232, 168, 124, 0.15); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-warm {
|
||||
animation: pulse-warm 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 250ms ease-out;
|
||||
}
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* === Task sidebar slide === */
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slide-out-right {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Task extraction highlight */
|
||||
@keyframes highlight-warm {
|
||||
0% { border-left-color: var(--color-accent); }
|
||||
100% { border-left-color: transparent; }
|
||||
}
|
||||
|
||||
/* Checkbox pop */
|
||||
@keyframes scale-pop {
|
||||
0% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 200ms ease-out;
|
||||
}
|
||||
.animate-highlight-warm {
|
||||
border-left: 3px solid transparent;
|
||||
animation: highlight-warm 2s ease-out;
|
||||
}
|
||||
.animate-scale-pop {
|
||||
animation: scale-pop 50ms ease-out;
|
||||
}
|
||||
|
||||
/* Window focus glow */
|
||||
@keyframes glow-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
|
||||
30% { box-shadow: 0 0 20px 4px rgba(232, 168, 124, 0.35), inset 0 0 12px rgba(232, 168, 124, 0.08); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 700ms ease-out;
|
||||
}
|
||||
|
||||
html.light .animate-glow-pulse {
|
||||
animation: glow-pulse-light 700ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse-light {
|
||||
0% { box-shadow: 0 0 0 0 rgba(212, 149, 106, 0); }
|
||||
30% { box-shadow: 0 0 20px 4px rgba(212, 149, 106, 0.3), inset 0 0 12px rgba(212, 149, 106, 0.06); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(212, 149, 106, 0); }
|
||||
}
|
||||
|
||||
/* Float window entrance */
|
||||
@keyframes float-enter {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-float-enter {
|
||||
animation: float-enter 200ms ease-out;
|
||||
}
|
||||
|
||||
/* === Custom titlebar buttons === */
|
||||
.titlebar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 32px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Sinhala character spin (decorative, during recording) */
|
||||
@keyframes sinhala-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-sinhala-spin {
|
||||
animation: sinhala-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Recording indicator dot (follows mouse via JS, see +layout.svelte) */
|
||||
|
||||
/* === Reduced Motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
13
src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Kon</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
173
src/lib/Sidebar.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<script>
|
||||
import { page, settings, profiles, tasks, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
const navItems = [
|
||||
{ id: "dictation", label: "Dictation", icon: "mic" },
|
||||
{ id: "files", label: "Files", icon: "file" },
|
||||
{ id: "tasks", label: "Tasks", icon: "tasks" },
|
||||
{ id: "history", label: "History", icon: "clock" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const icons = {
|
||||
mic: "M12 1a4 4 0 0 0-4 4v7a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4ZM8 17.95A7 7 0 0 1 5 12h2a5 5 0 0 0 10 0h2a7 7 0 0 1-3 5.95V21h3v2H5v-2h3v-3.05Z",
|
||||
file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Zm4 18H6V4h7v5h5v11Z",
|
||||
tasks: "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
|
||||
clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7Z",
|
||||
user: "M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-5.33 0-8 2.67-8 4v2h16v-2c0-1.33-2.67-4-8-4Z",
|
||||
settings: "M19.14 12.94a7.07 7.07 0 0 0 .06-.94 7.07 7.07 0 0 0-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.04 7.04 0 0 0-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58a7.07 7.07 0 0 0 0 1.88l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.49.37 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58ZM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2Z",
|
||||
// Hamburger / collapse toggle
|
||||
menu: "M3 6h18M3 12h18M3 18h18",
|
||||
// Chevron right (expand)
|
||||
expand: "M9 18l6-6-6-6",
|
||||
};
|
||||
|
||||
let collapsed = $derived(settings.sidebarCollapsed);
|
||||
|
||||
function toggleCollapsed() {
|
||||
settings.sidebarCollapsed = !settings.sidebarCollapsed;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function navigate(id) {
|
||||
page.current = id;
|
||||
if (id !== "dictation") {
|
||||
page.taskSidebarOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="flex flex-col bg-sidebar border-r border-border-subtle h-full transition-all duration-200 overflow-hidden
|
||||
{collapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
|
||||
>
|
||||
<!-- Logo + toggle -->
|
||||
<div class="flex items-center {collapsed ? 'justify-center px-0 pt-4 pb-1' : 'px-5 pt-4 pb-1'} relative">
|
||||
{#if !collapsed}
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<h1 class="font-display text-[26px] text-text tracking-tight leading-none italic">Kon</h1>
|
||||
<span
|
||||
class="text-[18px] text-accent inline-block {page.recording ? 'animate-sinhala-spin' : ''}"
|
||||
title="Kon"
|
||||
>෧</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed: just the decorative character centred -->
|
||||
<span
|
||||
class="text-[18px] text-accent inline-block {page.recording ? 'animate-sinhala-spin' : ''}"
|
||||
title="Kon"
|
||||
>෧</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
<p class="text-[10px] text-text-tertiary px-5 mt-1.5 tracking-[0.12em] uppercase">Think out loud</p>
|
||||
{/if}
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="{collapsed ? 'mx-2' : 'mx-5'} my-4 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Toggle button -->
|
||||
<div class="px-2 mb-1 flex {collapsed ? 'justify-center' : 'justify-end'}">
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded-md text-text-tertiary hover:bg-hover hover:text-text transition-colors"
|
||||
onclick={toggleCollapsed}
|
||||
title={collapsed ? 'Expand sidebar ([)' : 'Collapse sidebar ([)'}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{#if collapsed}
|
||||
<!-- Chevron right -->
|
||||
<svg class="w-[14px] h-[14px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons.expand} />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Chevron left -->
|
||||
<svg class="w-[14px] h-[14px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex flex-col gap-0.5 {collapsed ? 'px-1' : 'px-3'}">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class="group flex items-center rounded-lg text-[13px] text-left w-full
|
||||
{collapsed ? 'justify-center px-0 py-2.5' : 'gap-2.5 px-3 py-2'}
|
||||
{page.current === item.id
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
onclick={() => navigate(item.id)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<svg
|
||||
class="w-[16px] h-[16px] flex-shrink-0
|
||||
{page.current === item.id ? 'text-accent' : 'text-text-tertiary group-hover:text-text-secondary'}"
|
||||
viewBox="0 0 24 24" fill="currentColor"
|
||||
>
|
||||
<path d={icons[item.icon]} />
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
{item.label}
|
||||
{#if item.id === "tasks" && taskCount > 0}
|
||||
<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">
|
||||
{taskCount}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if item.id === "tasks" && taskCount > 0}
|
||||
<!-- Badge dot in collapsed mode -->
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-accent"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if !collapsed}
|
||||
<!-- Profile selector -->
|
||||
<div class="px-4 pb-3">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5 px-1">Profile</p>
|
||||
<select
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text-secondary
|
||||
focus:outline-none focus:border-accent appearance-none cursor-pointer"
|
||||
bind:value={page.activeProfile}
|
||||
>
|
||||
<option>None</option>
|
||||
{#each profiles as profile}
|
||||
<option>{profile.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="mx-5 mb-3 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-[7px] h-[7px] rounded-full flex-shrink-0"
|
||||
class:animate-pulse-soft={page.recording}
|
||||
style="background: {page.statusColor}"
|
||||
></span>
|
||||
<span class="text-[11px] text-text-secondary">{page.status}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-text-tertiary mt-1.5">{settings.formatMode} · v1.0</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed: status dot only -->
|
||||
<div class="pb-5 flex justify-center">
|
||||
<span
|
||||
class="w-[7px] h-[7px] rounded-full"
|
||||
class:animate-pulse-soft={page.recording}
|
||||
style="background: {page.statusColor}"
|
||||
title={page.status}
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
7
src/lib/components/Card.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
let { children, classes = "" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-bg-card border border-border rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,0.2)] {classes}">
|
||||
{@render children()}
|
||||
</div>
|
||||
73
src/lib/components/HotkeyRecorder.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let recording = $state(false);
|
||||
let captured = $state(false);
|
||||
|
||||
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Wait for a non-modifier key
|
||||
if (modifierKeys.has(e.key)) return;
|
||||
|
||||
const parts = [];
|
||||
if (e.ctrlKey) parts.push("Ctrl");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.metaKey) parts.push("Super");
|
||||
|
||||
// Must have at least one modifier
|
||||
if (parts.length === 0) {
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
||||
settings.globalHotkey = parts.join("+");
|
||||
saveSettings();
|
||||
recording = false;
|
||||
captured = true;
|
||||
setTimeout(() => { captured = false; }, 1500);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
recording = false;
|
||||
}
|
||||
|
||||
let chips = $derived(settings.globalHotkey.split("+"));
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
onkeydown={handleKeyDown}
|
||||
onblur={handleBlur}
|
||||
aria-label="Record hotkey"
|
||||
>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Press new shortcut...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary">+</span>
|
||||
{/if}
|
||||
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
|
||||
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</button>
|
||||
105
src/lib/components/ModelDownloader.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
|
||||
let { modelSize = "base", onComplete = () => {} } = $props();
|
||||
|
||||
let progress = $state(0);
|
||||
let downloading = $state(false);
|
||||
let downloaded = $state(0);
|
||||
let total = $state(0);
|
||||
let error = $state("");
|
||||
let unlisten = null;
|
||||
|
||||
const modelInfo = {
|
||||
tiny: { size: "~75 MB", accuracy: "Basic" },
|
||||
base: { size: "~142 MB", accuracy: "Good" },
|
||||
small: { size: "~466 MB", accuracy: "Better" },
|
||||
medium: { size: "~1.5 GB", accuracy: "Best" },
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen("model-download-progress", (event) => {
|
||||
const data = event.payload;
|
||||
progress = data.progress;
|
||||
downloaded = data.downloaded;
|
||||
total = data.total;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
});
|
||||
|
||||
async function startDownload() {
|
||||
downloading = true;
|
||||
error = "";
|
||||
progress = 0;
|
||||
try {
|
||||
await invoke("download_model", { size: modelSize });
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Download failed";
|
||||
downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
return `${(bytes / 1073741824).toFixed(2)} GB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full px-8 animate-fade-in">
|
||||
<Card classes="max-w-md w-full">
|
||||
<div class="p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-5">
|
||||
<svg class="w-8 h-8 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
|
||||
<p class="text-[13px] text-text-secondary mb-1">
|
||||
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-6">
|
||||
{modelInfo[modelSize]?.size ?? "?"} · {modelInfo[modelSize]?.accuracy ?? "?"} accuracy · 100% offline
|
||||
</p>
|
||||
|
||||
{#if downloading}
|
||||
<div class="mb-4">
|
||||
<div class="h-[6px] bg-bg-elevated rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-300 shadow-[0_0_8px_rgba(232,168,124,0.4)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
{progress}% · {formatBytes(downloaded)} / {formatBytes(total)}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-xl text-[14px] font-medium text-white bg-accent hover:bg-accent-hover
|
||||
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
|
||||
onclick={startDownload}
|
||||
>
|
||||
Download Model
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-[12px] text-danger mt-3">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-[10px] text-text-tertiary mt-5">
|
||||
Models are cached locally. No data leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
18
src/lib/components/SegmentedButton.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
let { options = [], value = $bindable(""), size = "default" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="inline-flex bg-bg-elevated rounded-[10px] p-[3px] gap-[2px]">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="rounded-lg font-medium
|
||||
{size === 'small' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-[6px] text-[12px]'}
|
||||
{value === option
|
||||
? 'bg-accent text-white shadow-[0_1px_4px_rgba(232,168,124,0.3)]'
|
||||
: 'text-text-secondary hover:text-text hover:bg-hover'}"
|
||||
onclick={() => value = option}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
95
src/lib/components/TaskSidebar.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
|
||||
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
|
||||
|
||||
let quickInput = $state("");
|
||||
let recentlyAdded = $state(new Set());
|
||||
|
||||
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
function handleQuickAdd(e) {
|
||||
if (e.key === "Enter" && quickInput.trim()) {
|
||||
addTask({ text: quickInput.trim(), bucket: "inbox" });
|
||||
quickInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
page.taskSidebarOpen = false;
|
||||
}
|
||||
|
||||
// Track recently added tasks for highlight animation
|
||||
let prevTaskIds = $state(new Set(tasks.map((t) => t.id)));
|
||||
$effect(() => {
|
||||
const currentIds = new Set(tasks.map((t) => t.id));
|
||||
for (const id of currentIds) {
|
||||
if (!prevTaskIds.has(id)) {
|
||||
recentlyAdded.add(id);
|
||||
setTimeout(() => { recentlyAdded.delete(id); }, 2000);
|
||||
}
|
||||
}
|
||||
prevTaskIds = currentIds;
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col w-[280px] min-w-[280px] bg-sidebar border-l border-border-subtle h-full animate-slide-in-right">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 px-4 pt-4 pb-3">
|
||||
<h3 class="text-[13px] font-semibold text-text">Tasks</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{taskCount}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-accent"
|
||||
onclick={() => page.current = "tasks"}
|
||||
>View all</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text rounded"
|
||||
onclick={close}
|
||||
aria-label="Close task sidebar"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick add -->
|
||||
<div class="px-3 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Add task..."
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto px-3 pb-3 min-h-0">
|
||||
{#if activeTasks.length === 0}
|
||||
<p class="text-[11px] text-text-tertiary text-center py-6 opacity-60">No active tasks</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each activeTasks as task (task.id)}
|
||||
<div
|
||||
class="group flex items-start gap-2 px-2.5 py-1.5 rounded-lg hover:bg-hover
|
||||
{recentlyAdded.has(task.id) ? 'animate-highlight-warm' : ''}"
|
||||
>
|
||||
<button
|
||||
class="mt-0.5 w-4 h-4 rounded border-[1.5px] border-border-subtle hover:border-accent flex-shrink-0"
|
||||
onclick={() => completeTask(task.id)}
|
||||
aria-label="Complete task"
|
||||
></button>
|
||||
<span class="text-[11px] text-text leading-snug flex-1 min-w-0">{task.text}</span>
|
||||
<span class="w-2 h-2 rounded-full mt-1 flex-shrink-0 {BUCKET_DOT_COLORS[task.bucket] || 'bg-text-tertiary'}"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
74
src/lib/components/Titlebar.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script>
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let maximised = $state(false);
|
||||
|
||||
async function handleMinimise() {
|
||||
await getCurrentWindow().minimize();
|
||||
}
|
||||
|
||||
async function handleMaximise() {
|
||||
await getCurrentWindow().toggleMaximize();
|
||||
maximised = await getCurrentWindow().isMaximized();
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
await getCurrentWindow().hide();
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
// Track maximise state
|
||||
$effect(() => {
|
||||
let unlisten;
|
||||
getCurrentWindow().onResized(async () => {
|
||||
maximised = await getCurrentWindow().isMaximized();
|
||||
}).then((fn) => unlisten = fn);
|
||||
return () => unlisten?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center h-[32px] select-none bg-sidebar border-b border-border-subtle"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- Left spacer: aligns with sidebar width -->
|
||||
<div
|
||||
class="transition-all duration-200 {settings.sidebarCollapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
|
||||
data-tauri-drag-region
|
||||
></div>
|
||||
|
||||
<!-- Centre: drag area -->
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
|
||||
<!-- Window controls -->
|
||||
<div class="flex items-center h-full">
|
||||
<button class="titlebar-btn hover:bg-hover" onclick={handleMinimise} aria-label="Minimise">
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<rect y="5" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="titlebar-btn hover:bg-hover" onclick={handleMaximise} aria-label="Maximise">
|
||||
{#if maximised}
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<path d="M3 1h8v8h-2v2H1V3h2V1zm1 1v1h5v5h1V2H4zm-2 2v6h6V4H2z" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="titlebar-btn hover:bg-danger/20 hover:text-danger" onclick={handleClose} aria-label="Close">
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
27
src/lib/components/Toggle.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
let { checked = $bindable(false), label = "", description = "" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3 py-2.5">
|
||||
<button
|
||||
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
|
||||
{checked ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-border'}
|
||||
active:scale-95 transition-all duration-200"
|
||||
onclick={() => checked = !checked}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||
transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]
|
||||
{checked ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-tight">{label}</p>
|
||||
{#if description}
|
||||
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
29
src/lib/components/UnicodeSpinner.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let { type = "dots", speed = 100, classes = "" } = $props();
|
||||
|
||||
const sequences = {
|
||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
circle: ["◴", "◷", "◶", "◵"],
|
||||
pulse: ["◉", "◎", "○", "◎"],
|
||||
stars: ["·", "✢", "✳", "✶", "✻", "✽"],
|
||||
orbit: ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
|
||||
};
|
||||
|
||||
let frame = $state(0);
|
||||
let interval = null;
|
||||
let chars = sequences[type] || sequences.dots;
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
frame = (frame + 1) % chars.length;
|
||||
}, speed);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="inline-block {classes}" aria-hidden="true">{chars[frame]}</span>
|
||||
721
src/lib/pages/DictationPage.svelte
Normal file
@@ -0,0 +1,721 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { page, settings, templates, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
||||
import { exportTranscript } from "$lib/utils/export.js";
|
||||
import { extractTasks } from "$lib/utils/taskExtractor.js";
|
||||
import { pad } from "$lib/utils/time.js";
|
||||
import { MAX_PCM_SAMPLES, MIN_CHUNK_SAMPLES, CHUNK_INTERVAL_MS, FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
|
||||
let transcript = $state("");
|
||||
let segments = $state([]);
|
||||
let timerInterval = $state(null);
|
||||
let startTime = $state(0);
|
||||
let chunkId = $state(0);
|
||||
let modelReady = $state(false);
|
||||
let modelLoading = $state(false);
|
||||
let needsDownload = $state(false);
|
||||
let error = $state("");
|
||||
let showExportMenu = $state(false);
|
||||
let transcribing = $state(false);
|
||||
let saved = $state(false);
|
||||
let extractedCount = $state(0);
|
||||
let aiProcessing = $state(false);
|
||||
let aiStatus = $state("");
|
||||
let unlisten = null;
|
||||
let chunkTimeOffset = 0;
|
||||
|
||||
// Cursor-based insertion
|
||||
let textareaEl = $state(null);
|
||||
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
|
||||
|
||||
// Template
|
||||
let activeTemplate = $state("");
|
||||
let showTemplateMenu = $state(false);
|
||||
|
||||
// Deduplication: track which chunk IDs have been processed
|
||||
let processedChunks = new Set();
|
||||
|
||||
// AudioWorklet state
|
||||
let audioContext = null;
|
||||
let workletNode = null;
|
||||
let mediaStream = null;
|
||||
let pcmBuffer = [];
|
||||
let chunkTimer = null;
|
||||
let allSamples = []; // Accumulate all PCM for audio saving
|
||||
|
||||
// Global hotkey listener
|
||||
let hotkeyHandler = () => toggleRecording();
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen("transcription-result", (event) => {
|
||||
let result;
|
||||
try {
|
||||
result = typeof event.payload === "string"
|
||||
? JSON.parse(event.payload)
|
||||
: event.payload;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse transcription result:", e);
|
||||
return;
|
||||
}
|
||||
handleResult(result);
|
||||
});
|
||||
|
||||
window.addEventListener("kon:toggle-recording", hotkeyHandler);
|
||||
|
||||
await checkModelState();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
window.removeEventListener("kon:toggle-recording", hotkeyHandler);
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
if (page.recording) {
|
||||
page.recording = false;
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function handleResult(result) {
|
||||
if (result.status === "transcription" && result.segments) {
|
||||
// Deduplication guard: skip if this chunk_id was already processed
|
||||
if (result.chunk_id != null && processedChunks.has(result.chunk_id)) {
|
||||
return;
|
||||
}
|
||||
if (result.chunk_id != null) processedChunks.add(result.chunk_id);
|
||||
const text = result.segments.map((s) => s.text).join(" ").trim();
|
||||
if (text) {
|
||||
if (insertPos >= 0) {
|
||||
// Insert at cursor position
|
||||
const before = transcript.slice(0, insertPos);
|
||||
const after = transcript.slice(insertPos);
|
||||
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
||||
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
|
||||
transcript = before + spaceBefore + text + spaceAfter + after;
|
||||
insertPos += spaceBefore.length + text.length + spaceAfter.length;
|
||||
// Move cursor to end of inserted text
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.selectionStart = insertPos;
|
||||
textareaEl.selectionEnd = insertPos;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Append mode
|
||||
// First chunk (id=1) starts the transcript; subsequent chunks append to it.
|
||||
// When transcript is empty, assign directly; otherwise prepend a space separator.
|
||||
if (!transcript) {
|
||||
transcript = text;
|
||||
} else {
|
||||
transcript += " " + text;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset segment timestamps to be absolute
|
||||
const offset = chunkTimeOffset;
|
||||
const adjusted = result.segments.map((s) => ({
|
||||
...s,
|
||||
start: s.start + offset,
|
||||
end: s.end + offset,
|
||||
}));
|
||||
segments = [...segments, ...adjusted];
|
||||
if (result.duration) {
|
||||
chunkTimeOffset += result.duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.recording && result.chunk_id === chunkId) {
|
||||
finaliseTranscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkModelState() {
|
||||
try {
|
||||
if (settings.engine === "parakeet") {
|
||||
const loaded = await invoke("check_parakeet_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_parakeet_model", { name: "ctc-int8" });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
} else {
|
||||
const loaded = await invoke("check_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_model", { size: settings.modelSize.toLowerCase() });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to check model";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModel() {
|
||||
modelLoading = true;
|
||||
page.status = "Loading model...";
|
||||
page.statusColor = "#e8c86e";
|
||||
error = "";
|
||||
|
||||
try {
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
} else {
|
||||
await invoke("load_model", { size: settings.modelSize.toLowerCase() });
|
||||
}
|
||||
modelReady = true;
|
||||
modelLoading = false;
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to load model";
|
||||
modelLoading = false;
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
}
|
||||
}
|
||||
|
||||
function onModelDownloaded() {
|
||||
needsDownload = false;
|
||||
loadModel();
|
||||
}
|
||||
|
||||
async function toggleRecording() {
|
||||
if (page.recording) {
|
||||
await stopRecording();
|
||||
} else {
|
||||
await startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
error = "";
|
||||
saved = false;
|
||||
if (!modelReady) {
|
||||
if (needsDownload) return;
|
||||
await loadModel();
|
||||
if (!modelReady) return;
|
||||
}
|
||||
|
||||
// Capture cursor position for insert mode
|
||||
if (textareaEl && transcript.length > 0) {
|
||||
insertPos = textareaEl.selectionStart ?? transcript.length;
|
||||
} else {
|
||||
insertPos = -1; // Append mode for empty textarea
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
await audioContext.audioWorklet.addModule("/pcm-processor.js");
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
workletNode = new AudioWorkletNode(audioContext, "pcm-processor");
|
||||
|
||||
pcmBuffer = [];
|
||||
chunkId = 0;
|
||||
chunkTimeOffset = 0;
|
||||
processedChunks.clear();
|
||||
|
||||
// Only clear transcript if not in insert mode (fresh recording)
|
||||
if (insertPos === -1) {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
}
|
||||
|
||||
allSamples = [];
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === "pcm") {
|
||||
pcmBuffer = pcmBuffer.concat(e.data.samples);
|
||||
if (settings.saveAudio) {
|
||||
allSamples = allSamples.concat(e.data.samples);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(workletNode);
|
||||
|
||||
startTime = Date.now();
|
||||
page.recording = true;
|
||||
page.status = "Recording...";
|
||||
page.statusColor = "#e87171";
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
chunkTimer = setInterval(sendChunk, CHUNK_INTERVAL_MS);
|
||||
} catch (err) {
|
||||
error = `Microphone access denied: ${err.message}`;
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
page.recording = false;
|
||||
page.status = "Finalising...";
|
||||
page.statusColor = "#e8c86e";
|
||||
|
||||
const waitForTranscription = () => new Promise((resolve) => {
|
||||
const check = () => transcribing ? setTimeout(check, 100) : resolve();
|
||||
check();
|
||||
});
|
||||
await waitForTranscription();
|
||||
await sendChunk();
|
||||
|
||||
cleanup();
|
||||
|
||||
if (chunkId === 0) {
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (workletNode) {
|
||||
workletNode.disconnect();
|
||||
workletNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChunk() {
|
||||
if (pcmBuffer.length < MIN_CHUNK_SAMPLES) return;
|
||||
if (transcribing) return;
|
||||
|
||||
chunkId++;
|
||||
const currentChunkId = chunkId;
|
||||
const samples = [...pcmBuffer];
|
||||
pcmBuffer = [];
|
||||
if (samples.length > MAX_PCM_SAMPLES) {
|
||||
console.warn(`PCM buffer truncated from ${samples.length} to ${MAX_PCM_SAMPLES} samples (~5 min at 16 kHz)`);
|
||||
samples.length = MAX_PCM_SAMPLES;
|
||||
}
|
||||
transcribing = true;
|
||||
|
||||
try {
|
||||
let initialPrompt = "";
|
||||
if (page.activeProfile && page.activeProfile !== "None") {
|
||||
initialPrompt = page.activeProfile;
|
||||
}
|
||||
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("transcribe_pcm_parakeet", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
} else {
|
||||
await invoke("transcribe_pcm", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
language: settings.language,
|
||||
initialPrompt,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("transcribe_pcm failed:", err);
|
||||
error = typeof err === "string" ? err : err.message || "Transcription failed";
|
||||
if (!page.recording) {
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
}
|
||||
} finally {
|
||||
transcribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finaliseTranscription() {
|
||||
if (transcript.trim()) {
|
||||
if (settings.autoCopy) {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
}
|
||||
|
||||
// Save audio if enabled — capture path for history replay
|
||||
let audioPath = null;
|
||||
if (settings.saveAudio && allSamples.length > 0) {
|
||||
try {
|
||||
audioPath = await invoke("save_audio", {
|
||||
samples: allSamples,
|
||||
outputFolder: settings.outputFolder || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("save_audio failed:", err);
|
||||
}
|
||||
allSamples = [];
|
||||
}
|
||||
|
||||
const historyId = crypto.randomUUID();
|
||||
addToHistory({
|
||||
id: historyId,
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "live",
|
||||
preview: transcript.slice(0, 120),
|
||||
text: transcript,
|
||||
segments: segments,
|
||||
duration: (Date.now() - startTime) / 1000,
|
||||
language: settings.language,
|
||||
template: activeTemplate || undefined,
|
||||
audioPath,
|
||||
});
|
||||
|
||||
// Extract tasks from transcript
|
||||
const extracted = extractTasks(transcript);
|
||||
extractedCount = extracted.length;
|
||||
for (const item of extracted) {
|
||||
addTask({
|
||||
text: item.text,
|
||||
bucket: "inbox",
|
||||
sourceTranscriptId: historyId,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-open task sidebar if tasks were extracted
|
||||
if (extracted.length > 0) {
|
||||
page.taskSidebarOpen = true;
|
||||
}
|
||||
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
||||
}
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
insertPos = -1;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
page.timerText = `${pad(Math.floor(elapsed / 60))}:${pad(elapsed % 60)}`;
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
if (transcript) invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
}
|
||||
|
||||
function clearTranscript() {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
page.timerText = "00:00";
|
||||
error = "";
|
||||
saved = false;
|
||||
insertPos = -1;
|
||||
activeTemplate = "";
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
if (!transcript) return;
|
||||
showExportMenu = false;
|
||||
const content = exportTranscript(transcript, segments, format);
|
||||
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
|
||||
const ext = extMap[format] || "txt";
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `kon-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function applyTemplate(template) {
|
||||
activeTemplate = template.name;
|
||||
showTemplateMenu = false;
|
||||
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
|
||||
segments = [];
|
||||
// Position cursor at first section body
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
const firstNewline = transcript.indexOf("\n\n") + 2;
|
||||
textareaEl.selectionStart = firstNewline;
|
||||
textareaEl.selectionEnd = firstNewline;
|
||||
textareaEl.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function manualExtractTasks() {
|
||||
if (!transcript.trim() || aiProcessing) return;
|
||||
aiProcessing = true;
|
||||
aiStatus = "Extracting tasks...";
|
||||
try {
|
||||
const extracted = extractTasks(transcript);
|
||||
for (const item of extracted) {
|
||||
addTask({ text: item.text, bucket: "inbox" });
|
||||
}
|
||||
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
|
||||
} catch (err) {
|
||||
aiStatus = typeof err === "string" ? err : "Extraction failed";
|
||||
} finally {
|
||||
aiProcessing = false;
|
||||
setTimeout(() => { aiStatus = ""; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function saveTypedText() {
|
||||
if (!transcript.trim()) return;
|
||||
addToHistory({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "typed",
|
||||
preview: transcript.slice(0, 120),
|
||||
text: transcript,
|
||||
segments: [],
|
||||
duration: 0,
|
||||
language: settings.language,
|
||||
});
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
let wordCount = $derived.by(() => {
|
||||
const trimmed = transcript.trim();
|
||||
return trimmed ? trimmed.split(/\s+/).length : 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full animate-fade-in">
|
||||
<div aria-live="assertive" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">
|
||||
{#if page.recording}Recording started{/if}
|
||||
</div>
|
||||
{#if needsDownload}
|
||||
<ModelDownloader modelSize={settings.modelSize.toLowerCase()} onComplete={onModelDownloaded} />
|
||||
{:else}
|
||||
<!-- Control strip -->
|
||||
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
|
||||
<!-- Record button -->
|
||||
<button
|
||||
class="relative flex items-center justify-center w-[40px] h-[40px] min-w-[40px] flex-shrink-0 rounded-full text-white
|
||||
active:scale-[0.93] transition-all duration-200
|
||||
{page.recording
|
||||
? 'bg-danger animate-pulse-warm'
|
||||
: modelLoading
|
||||
? 'bg-warning opacity-60 cursor-wait'
|
||||
: 'bg-accent hover:bg-accent-hover shadow-[0_4px_20px_rgba(232,168,124,0.3)]'}"
|
||||
onclick={toggleRecording}
|
||||
disabled={modelLoading}
|
||||
aria-label={page.recording ? "Stop recording" : "Start recording"}
|
||||
>
|
||||
{#if page.recording}
|
||||
<span class="w-[14px] h-[14px] rounded-[3px] bg-white"></span>
|
||||
{:else if modelLoading}
|
||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="w-[14px] h-[14px] rounded-full bg-white/90"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Waveform placeholder (Phase 2: live visualiser) -->
|
||||
<div class="flex-1 min-h-[32px] rounded-md border border-border-subtle bg-bg-elevated/40 flex items-center px-3">
|
||||
{#if page.recording}
|
||||
<span class="flex items-end gap-[3px] h-[18px]">
|
||||
{#each [0.4, 0.8, 0.5, 1, 0.6, 0.9, 0.4, 0.7, 0.5, 0.8, 0.3, 0.6] as h, i}
|
||||
<span
|
||||
class="w-[2px] rounded-full bg-danger/70 animate-pulse-soft"
|
||||
style="height: {h * 100}%; animation-delay: {i * 60}ms"
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{#if modelLoading}
|
||||
Loading model...
|
||||
{:else if saved}
|
||||
<span class="text-success animate-fade-in">
|
||||
Saved{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
|
||||
</span>
|
||||
{:else}
|
||||
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[10px] text-text-tertiary border border-border-subtle">Ctrl+Shift+R</kbd>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<span class="text-[20px] font-bold text-text tabular-nums tracking-tight leading-none flex-shrink-0" style="font-variant-numeric: tabular-nums;">
|
||||
{page.timerText}
|
||||
</span>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<span class="text-[11px] text-text-tertiary flex-shrink-0 min-w-[60px] text-right">
|
||||
{#if page.recording}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
|
||||
<span class="font-medium text-danger tracking-wide">REC</span>
|
||||
</span>
|
||||
{:else if modelLoading}
|
||||
<span class="text-warning">Loading</span>
|
||||
{:else}
|
||||
<span class="text-success">Ready</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Separator -->
|
||||
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
||||
|
||||
<!-- Task sidebar toggle -->
|
||||
<button
|
||||
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
|
||||
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
|
||||
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
|
||||
aria-label="Toggle task sidebar"
|
||||
>
|
||||
<svg class="w-4 h-4 {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
{#if taskCount > 0}
|
||||
<span class="absolute -top-0.5 -right-0.5 text-[9px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
|
||||
{taskCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
|
||||
<!-- Template selector -->
|
||||
{#if templates.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
|
||||
>Template</button>
|
||||
{#if showTemplateMenu}
|
||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
|
||||
{#each templates as template}
|
||||
<button
|
||||
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text
|
||||
{activeTemplate === template.name ? 'text-accent' : ''}"
|
||||
onclick={() => applyTemplate(template)}
|
||||
>{template.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="btn-md rounded-lg flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
|
||||
onclick={manualExtractTasks}
|
||||
disabled={aiProcessing || !transcript.trim()}
|
||||
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
|
||||
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
|
||||
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
|
||||
onclick={saveTypedText}
|
||||
disabled={!transcript.trim()}
|
||||
>Save</button>
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={copyAll}
|
||||
>Copy</button>
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={clearTranscript}
|
||||
>Clear</button>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
|
||||
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
|
||||
>Export</button>
|
||||
{#if showExportMenu}
|
||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
|
||||
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
|
||||
<button
|
||||
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => handleExport(fmt)}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template indicator -->
|
||||
{#if activeTemplate}
|
||||
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
|
||||
<span class="text-[11px] text-accent font-medium">Template: {activeTemplate}</span>
|
||||
<span class="text-[11px] text-text-tertiary">Click a section, then record to fill it</span>
|
||||
<div class="flex-1"></div>
|
||||
<button class="text-[11px] text-text-tertiary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transcript area -->
|
||||
<div class="flex-1 px-5 pt-3 pb-3 min-h-0">
|
||||
<Card classes="h-full flex flex-col">
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0"
|
||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||
bind:value={transcript}
|
||||
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
||||
data-no-transition
|
||||
aria-label="Transcript"
|
||||
aria-live="polite"
|
||||
></textarea>
|
||||
|
||||
<!-- Status footer (inside transcript card) -->
|
||||
<div class="flex items-center justify-between px-6 pb-3 pt-1 flex-shrink-0">
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{#if wordCount > 0}
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
{:else}
|
||||
0 words
|
||||
{/if}
|
||||
{#if insertPos >= 0 && page.recording}
|
||||
· <span class="text-accent">inserting at cursor</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if aiStatus}
|
||||
<span class="text-[11px] text-accent animate-fade-in">{aiStatus}</span>
|
||||
<span class="text-[11px] text-text-tertiary">·</span>
|
||||
{/if}
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
244
src/lib/pages/FilesPage.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { settings, addToHistory } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import { exportTranscript } from "$lib/utils/export.js";
|
||||
|
||||
let fileTranscript = $state("");
|
||||
let segments = $state([]);
|
||||
let isDragOver = $state(false);
|
||||
let progress = $state(0);
|
||||
let progressText = $state("");
|
||||
let fileName = $state("");
|
||||
let error = $state("");
|
||||
let transcribing = $state(false);
|
||||
let showExportMenu = $state(false);
|
||||
|
||||
let unlistenDrop = null;
|
||||
let unlistenEnter = null;
|
||||
let unlistenLeave = null;
|
||||
|
||||
onMount(async () => {
|
||||
// Native Tauri file drop events
|
||||
unlistenDrop = await listen("tauri://drag-drop", async (event) => {
|
||||
isDragOver = false;
|
||||
if (event.payload?.paths?.length > 0) {
|
||||
await transcribeFiles(event.payload.paths);
|
||||
}
|
||||
});
|
||||
unlistenEnter = await listen("tauri://drag-enter", () => { isDragOver = true; });
|
||||
unlistenLeave = await listen("tauri://drag-leave", () => { isDragOver = false; });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlistenDrop) unlistenDrop();
|
||||
if (unlistenEnter) unlistenEnter();
|
||||
if (unlistenLeave) unlistenLeave();
|
||||
});
|
||||
|
||||
async function handleBrowse() {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const paths = await open({
|
||||
multiple: true,
|
||||
filters: [{
|
||||
name: "Audio/Video",
|
||||
extensions: ["mp3", "wav", "m4a", "mp4", "flac", "ogg", "webm", "mov", "mkv"],
|
||||
}],
|
||||
});
|
||||
if (paths) {
|
||||
const pathList = Array.isArray(paths) ? paths : [paths];
|
||||
await transcribeFiles(pathList);
|
||||
}
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to open file dialog";
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribeFiles(paths) {
|
||||
transcribing = true;
|
||||
error = "";
|
||||
fileTranscript = "";
|
||||
segments = [];
|
||||
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const path = paths[i];
|
||||
fileName = path.split(/[\\/]/).pop() || path;
|
||||
progress = (i / paths.length) * 100;
|
||||
progressText = `Transcribing ${i + 1}/${paths.length}...`;
|
||||
|
||||
try {
|
||||
const result = await invoke("transcribe_file", {
|
||||
path,
|
||||
language: settings.language,
|
||||
initialPrompt: "",
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
|
||||
const text = result.segments.map((s) => s.text).join(" ").trim();
|
||||
|
||||
if (paths.length > 1) {
|
||||
fileTranscript += `--- ${fileName} ---\n${text}\n\n`;
|
||||
} else {
|
||||
fileTranscript = text;
|
||||
}
|
||||
|
||||
segments = [...segments, ...result.segments];
|
||||
|
||||
addToHistory({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: fileName,
|
||||
preview: text.slice(0, 120),
|
||||
text,
|
||||
segments: result.segments,
|
||||
duration: result.duration,
|
||||
language: result.language,
|
||||
});
|
||||
} catch (err) {
|
||||
error = `Failed: ${fileName} — ${typeof err === "string" ? err : err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
progress = 100;
|
||||
progressText = "Done";
|
||||
transcribing = false;
|
||||
|
||||
setTimeout(() => {
|
||||
progress = 0;
|
||||
progressText = "";
|
||||
fileName = "";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
if (fileTranscript) invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
if (!fileTranscript) return;
|
||||
showExportMenu = false;
|
||||
const content = exportTranscript(fileTranscript, segments, format);
|
||||
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
|
||||
const ext = extMap[format] || "txt";
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `kon-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||
<!-- Title -->
|
||||
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-4">File Transcription</h2>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div class="px-7 pb-3">
|
||||
<Card>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed m-3
|
||||
transition-all duration-200
|
||||
{isDragOver
|
||||
? 'border-accent bg-accent-subtle scale-[1.01]'
|
||||
: 'border-border hover:border-text-tertiary'}"
|
||||
role="region"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center mb-3">
|
||||
<svg class="w-5 h-5 text-text-tertiary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[13px] text-text-secondary text-center">
|
||||
Drop audio or video files here
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary text-center mt-1.5">
|
||||
MP3, WAV, M4A, MP4, FLAC, OGG
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Browse buttons + progress -->
|
||||
<div class="flex items-center gap-2 px-7 pb-3">
|
||||
<button
|
||||
class="btn-lg rounded-lg font-medium text-white bg-accent hover:bg-accent-hover
|
||||
shadow-[0_2px_8px_rgba(232,168,124,0.2)] active:scale-[0.97] transition-all duration-150"
|
||||
onclick={handleBrowse}
|
||||
disabled={transcribing}
|
||||
>
|
||||
Browse Files
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
{#if progressText}
|
||||
<span class="text-[12px] text-text-secondary animate-fade-in">{progressText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="px-7 pb-2 animate-fade-in">
|
||||
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
<div class="px-7 pb-3 animate-fade-in">
|
||||
<div class="h-[3px] bg-bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500 shadow-[0_0_8px_rgba(232,168,124,0.4)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if fileName}
|
||||
<p class="text-[11px] text-text-tertiary mt-1.5">{fileName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File transcript -->
|
||||
<div class="flex-1 px-7 pb-3 min-h-0">
|
||||
<Card classes="h-full flex flex-col">
|
||||
<textarea
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
||||
placeholder="Transcribed text will appear here..."
|
||||
bind:value={fileTranscript}
|
||||
data-no-transition
|
||||
aria-label="File transcript"
|
||||
aria-live="polite"
|
||||
></textarea>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Bottom actions -->
|
||||
<div class="flex gap-0.5 px-7 pb-4">
|
||||
<button class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text" onclick={copyAll}>Copy</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => showExportMenu = !showExportMenu}
|
||||
>Export</button>
|
||||
{#if showExportMenu}
|
||||
<div class="absolute left-0 bottom-full mb-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
|
||||
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
|
||||
<button
|
||||
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => handleExport(fmt)}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
170
src/lib/pages/FirstRunPage.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script>
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { page, settings } from "$lib/stores/page.svelte.js";
|
||||
import UnicodeSpinner from "$lib/components/UnicodeSpinner.svelte";
|
||||
|
||||
let systemInfo = $state(null);
|
||||
let models = $state([]);
|
||||
let probing = $state(true);
|
||||
let downloading = $state(false);
|
||||
let downloadProgress = $state(0);
|
||||
let downloadingModel = $state("");
|
||||
let error = $state("");
|
||||
let ready = $state(false);
|
||||
|
||||
async function probe() {
|
||||
try {
|
||||
systemInfo = await invoke("probe_system");
|
||||
models = await invoke("rank_models");
|
||||
probing = false;
|
||||
} catch (e) {
|
||||
error = `Hardware probe failed: ${e}`;
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndGo(modelId) {
|
||||
downloading = true;
|
||||
downloadingModel = modelId;
|
||||
downloadProgress = 0;
|
||||
|
||||
const unlisten = await listen("model-download-progress", (event) => {
|
||||
downloadProgress = event.payload.percent;
|
||||
});
|
||||
|
||||
const unlistenParakeet = await listen("parakeet-download-progress", (event) => {
|
||||
downloadProgress = event.payload.percent;
|
||||
});
|
||||
|
||||
try {
|
||||
if (modelId.startsWith("whisper-")) {
|
||||
const sizeMap = {
|
||||
"whisper-tiny-en": "tiny",
|
||||
"whisper-base-en": "base",
|
||||
"whisper-small-en": "small",
|
||||
"whisper-medium-en": "medium",
|
||||
};
|
||||
await invoke("download_model", { size: sizeMap[modelId] || modelId });
|
||||
await invoke("load_model", { size: sizeMap[modelId] || modelId });
|
||||
settings.engine = "whisper";
|
||||
settings.modelSize = (sizeMap[modelId] || modelId).charAt(0).toUpperCase() + (sizeMap[modelId] || modelId).slice(1);
|
||||
} else if (modelId.startsWith("parakeet-")) {
|
||||
await invoke("download_parakeet_model", { name: "ctc-int8" });
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
settings.engine = "parakeet";
|
||||
}
|
||||
|
||||
ready = true;
|
||||
setTimeout(() => {
|
||||
page.current = "dictation";
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
error = `Download failed: ${e}`;
|
||||
downloading = false;
|
||||
} finally {
|
||||
unlisten();
|
||||
unlistenParakeet();
|
||||
}
|
||||
}
|
||||
|
||||
function skipSetup() {
|
||||
page.current = "dictation";
|
||||
}
|
||||
|
||||
// Start probing on mount
|
||||
probe();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full px-8 py-12 max-w-xl mx-auto">
|
||||
{#if probing}
|
||||
<div class="text-center">
|
||||
<UnicodeSpinner type="dots" speed={80} classes="text-2xl text-accent" />
|
||||
<h2 class="text-xl font-medium text-text mt-4">Setting up Kon</h2>
|
||||
<p class="text-sm text-text-secondary mt-2">Detecting your hardware...</p>
|
||||
</div>
|
||||
|
||||
{:else if ready}
|
||||
<div class="text-center">
|
||||
<span class="text-4xl">෧</span>
|
||||
<h2 class="text-xl font-medium text-text mt-4">Ready to go</h2>
|
||||
<p class="text-sm text-text-secondary mt-2">Starting Kon...</p>
|
||||
</div>
|
||||
|
||||
{:else if downloading}
|
||||
<div class="text-center w-full">
|
||||
<h2 class="text-xl font-medium text-text">Downloading model</h2>
|
||||
<p class="text-sm text-text-secondary mt-2">{downloadingModel}</p>
|
||||
<div class="w-full bg-bg-input rounded-full h-2 mt-6">
|
||||
<div
|
||||
class="bg-accent h-2 rounded-full transition-all duration-300"
|
||||
style="width: {downloadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-text-tertiary mt-2">{downloadProgress}%</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
<h2 class="text-2xl font-medium text-text text-center">Welcome to Kon</h2>
|
||||
<p class="text-sm text-text-secondary text-center mt-2">Think out loud</p>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if systemInfo}
|
||||
<div class="mt-8 p-4 rounded-lg bg-bg-input border border-border">
|
||||
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<span class="text-text-secondary">RAM</span>
|
||||
<span class="text-text">{Math.round(systemInfo.ram_mb / 1024)} GB</span>
|
||||
<span class="text-text-secondary">CPU</span>
|
||||
<span class="text-text truncate" title={systemInfo.cpu_brand}>{systemInfo.cpu_brand}</span>
|
||||
<span class="text-text-secondary">Cores</span>
|
||||
<span class="text-text">{systemInfo.cpu_cores}</span>
|
||||
<span class="text-text-secondary">OS</span>
|
||||
<span class="text-text">{systemInfo.os}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if models.length > 0}
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Recommended models</h3>
|
||||
<div class="space-y-2">
|
||||
{#each models as model, i}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border transition-colors
|
||||
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
|
||||
onclick={() => downloadAndGo(model.id)}
|
||||
disabled={model.is_downloaded}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-text">{model.display_name}</span>
|
||||
{#if i === 0}
|
||||
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">Recommended</span>
|
||||
{/if}
|
||||
{#if model.is_downloaded}
|
||||
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">Downloaded</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-text-tertiary">{model.disk_size_mb} MB</span>
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary mt-1">{model.description}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="mt-6 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||
onclick={skipSetup}
|
||||
>
|
||||
Skip setup — I'll configure later
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
344
src/lib/pages/HistoryPage.svelte
Normal file
@@ -0,0 +1,344 @@
|
||||
<script>
|
||||
import { onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { history, saveHistory, deleteFromHistory } from "$lib/stores/page.svelte.js";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||
import { PLAYBACK_SPEEDS, FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let expandedId = $state(null);
|
||||
let playingId = $state(null);
|
||||
let audioEl = $state(null);
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let playbackRate = $state(1);
|
||||
let animFrameId = null;
|
||||
|
||||
onDestroy(() => {
|
||||
stopPlayback();
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
searchQuery
|
||||
? history.filter((h) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
(h.text && h.text.toLowerCase().includes(q)) ||
|
||||
(h.preview && h.preview.toLowerCase().includes(q)) ||
|
||||
(h.source && h.source.toLowerCase().includes(q)) ||
|
||||
(h.title && h.title.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
: history
|
||||
);
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm("Delete all history? This can't be undone.")) return;
|
||||
history.splice(0);
|
||||
saveHistory();
|
||||
expandedId = null;
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
expandedId = expandedId === id ? null : id;
|
||||
}
|
||||
|
||||
function copyItem(item) {
|
||||
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
|
||||
}
|
||||
|
||||
function removeItem(item) {
|
||||
const idx = history.indexOf(item);
|
||||
if (idx !== -1) {
|
||||
deleteFromHistory(idx);
|
||||
if (expandedId === item.id) expandedId = null;
|
||||
if (playingId === item.id) stopPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function renameItem(item) {
|
||||
const name = prompt("Name this transcript:", item.title || "");
|
||||
if (name !== null) {
|
||||
item.title = name.trim();
|
||||
item.preview = name.trim() ? `${name.trim()} — ${item.text.slice(0, 80)}` : item.text.slice(0, 120);
|
||||
saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay(item) {
|
||||
if (playingId === item.id) {
|
||||
if (audioEl && !audioEl.paused) {
|
||||
audioEl.pause();
|
||||
cancelAnimationFrame(animFrameId);
|
||||
} else if (audioEl) {
|
||||
audioEl.play();
|
||||
tickTime();
|
||||
}
|
||||
return;
|
||||
}
|
||||
stopPlayback();
|
||||
try {
|
||||
const src = convertFileSrc(item.audioPath);
|
||||
const audio = new Audio(src);
|
||||
audio.playbackRate = playbackRate;
|
||||
audio.onloadedmetadata = () => { duration = audio.duration; };
|
||||
audio.onended = () => { stopPlayback(); };
|
||||
audio.onerror = () => { stopPlayback(); };
|
||||
audio.play();
|
||||
audioEl = audio;
|
||||
playingId = item.id;
|
||||
tickTime();
|
||||
} catch {
|
||||
stopPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (audioEl) { audioEl.pause(); audioEl.src = ""; audioEl = null; }
|
||||
playingId = null;
|
||||
currentTime = 0;
|
||||
duration = 0;
|
||||
cancelAnimationFrame(animFrameId);
|
||||
}
|
||||
|
||||
function tickTime() {
|
||||
if (audioEl) {
|
||||
currentTime = audioEl.currentTime;
|
||||
if (!audioEl.paused) {
|
||||
animFrameId = requestAnimationFrame(tickTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seekTo(e) {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (audioEl) {
|
||||
audioEl.currentTime = val;
|
||||
currentTime = val;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpeed(speed) {
|
||||
playbackRate = speed;
|
||||
if (audioEl) audioEl.playbackRate = speed;
|
||||
}
|
||||
|
||||
async function openViewer(item) {
|
||||
try {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
await invoke("open_viewer_window");
|
||||
} catch {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
window.open("/viewer", "_blank", "width=600,height=700");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
||||
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
||||
<span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span>
|
||||
<div class="flex-1"></div>
|
||||
{#if history.length > 0}
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
onclick={clearAll}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-7 pb-4">
|
||||
<Card>
|
||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||
<svg class="w-4 h-4 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search all transcripts..."
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<span class="text-[11px] text-text-tertiary mr-2">{filtered.length} results</span>
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-text"
|
||||
onclick={() => searchQuery = ""}
|
||||
>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- History list -->
|
||||
<div class="flex-1 px-7 pb-4 min-h-0">
|
||||
<Card classes="h-full flex flex-col overflow-hidden">
|
||||
{#if filtered.length === 0}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[13px] text-text-tertiary">
|
||||
{searchQuery ? "No matching transcripts" : "No history yet"}
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
{searchQuery ? "Try a different search" : "Transcripts auto-save after recording"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filtered as item}
|
||||
<!-- Compact row -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle hover:bg-hover cursor-pointer transition-colors duration-150"
|
||||
onclick={() => toggleExpand(item.id)}
|
||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded={expandedId === item.id}
|
||||
>
|
||||
<!-- Play button (if audio exists) -->
|
||||
{#if item.audioPath}
|
||||
<button
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0
|
||||
{playingId === item.id ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-accent hover:bg-accent/20'}"
|
||||
onclick={(e) => { e.stopPropagation(); togglePlay(item); }}
|
||||
aria-label={playingId === item.id && audioEl && !audioEl.paused ? "Pause" : "Play"}
|
||||
>
|
||||
{#if playingId === item.id && audioEl && !audioEl.paused}
|
||||
<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-2.5 h-2.5 ml-0.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer to keep alignment when no audio -->
|
||||
<div class="w-6 h-6 flex-shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Title / preview text (truncated) -->
|
||||
<span class="flex-1 truncate text-[13px] text-text">
|
||||
{item.title || item.preview || item.text.slice(0, 80)}
|
||||
</span>
|
||||
|
||||
<!-- Duration -->
|
||||
{#if item.duration}
|
||||
<span class="text-[11px] text-text-tertiary flex-shrink-0 tabular-nums">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Source icon -->
|
||||
<span class="flex-shrink-0 text-text-tertiary" title={item.source}>
|
||||
{#if item.source && item.source.toLowerCase().includes("file")}
|
||||
<!-- File icon -->
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Microphone icon -->
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Date (right-aligned) -->
|
||||
<span class="text-[11px] text-text-tertiary flex-shrink-0 text-right min-w-[90px]">
|
||||
{item.date}
|
||||
</span>
|
||||
|
||||
<!-- Expand chevron -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0 transition-transform duration-150 {expandedId === item.id ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Expanded detail -->
|
||||
{#if expandedId === item.id}
|
||||
<div class="px-4 py-4 bg-bg-elevated border-b border-border animate-slide-up">
|
||||
<!-- Audio player (if playing this item) -->
|
||||
{#if item.audioPath && playingId === item.id}
|
||||
<div class="flex items-center gap-3 mb-4 px-1">
|
||||
<span class="text-[10px] text-text-tertiary tabular-nums w-[80px]">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.1"
|
||||
value={currentTime}
|
||||
oninput={seekTo}
|
||||
class="flex-1 accent-accent h-1"
|
||||
data-no-transition
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="flex gap-0.5">
|
||||
{#each PLAYBACK_SPEEDS as speed}
|
||||
<button
|
||||
class="text-[9px] px-1.5 py-0.5 rounded-full
|
||||
{playbackRate === speed
|
||||
? 'bg-accent/15 text-accent font-medium'
|
||||
: 'text-text-tertiary hover:text-text-secondary'}"
|
||||
onclick={(e) => { e.stopPropagation(); setSpeed(speed); }}
|
||||
>{speed}x</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Full transcript -->
|
||||
<p class="text-[13px] text-text leading-relaxed whitespace-pre-wrap mb-4">{item.text}</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); renameItem(item); }}
|
||||
>Rename</button>
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
|
||||
>Copy</button>
|
||||
{#if item.audioPath && item.segments && item.segments.length > 0}
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); openViewer(item); }}
|
||||
>Open viewer</button>
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-danger transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); removeItem(item); }}
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
264
src/lib/pages/ProfilesPage.svelte
Normal file
@@ -0,0 +1,264 @@
|
||||
<script>
|
||||
import { profiles, saveProfiles, templates, saveTemplates, page } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||
|
||||
let tab = $state("Profiles");
|
||||
let showNewDialog = $state(false);
|
||||
let newName = $state("");
|
||||
|
||||
// ---- Profiles ----
|
||||
|
||||
function createProfile() {
|
||||
if (!newName.trim()) return;
|
||||
profiles.push({ name: newName.trim(), words: "", saved: false });
|
||||
saveProfiles();
|
||||
newName = "";
|
||||
showNewDialog = false;
|
||||
}
|
||||
|
||||
function deleteProfile(index) {
|
||||
if (page.activeProfile === profiles[index].name) {
|
||||
page.activeProfile = "None";
|
||||
}
|
||||
profiles.splice(index, 1);
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
function saveProfile(index) {
|
||||
saveProfiles();
|
||||
profiles[index].saved = true;
|
||||
setTimeout(() => { if (profiles[index]) profiles[index].saved = false; }, 2000);
|
||||
}
|
||||
|
||||
function wordCount(words) {
|
||||
return words.split("\n").filter((w) => w.trim()).length;
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
|
||||
function createTemplate() {
|
||||
if (!newName.trim()) return;
|
||||
templates.push({ name: newName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
|
||||
saveTemplates();
|
||||
newName = "";
|
||||
showNewDialog = false;
|
||||
}
|
||||
|
||||
function deleteTemplate(index) {
|
||||
templates.splice(index, 1);
|
||||
saveTemplates();
|
||||
}
|
||||
|
||||
function saveTemplate(index) {
|
||||
saveTemplates();
|
||||
templates[index]._saved = true;
|
||||
setTimeout(() => { if (templates[index]) templates[index]._saved = false; }, 2000);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (tab === "Profiles") createProfile();
|
||||
else createTemplate();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 px-7 pt-6 pb-2">
|
||||
<h2 class="font-display text-[26px] italic text-text">
|
||||
{tab === "Profiles" ? "Profiles" : "Templates"}
|
||||
</h2>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="btn-lg rounded-lg font-medium text-white bg-accent hover:bg-accent-hover
|
||||
shadow-[0_2px_8px_rgba(232,168,124,0.2)] active:scale-[0.97] transition-all duration-150"
|
||||
onclick={() => showNewDialog = true}
|
||||
>
|
||||
+ New {tab === "Profiles" ? "Profile" : "Template"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
<div class="px-7 pb-4">
|
||||
<SegmentedButton options={["Profiles", "Templates"]} bind:value={tab} />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-[12px] text-text-secondary px-7 pb-5 leading-relaxed max-w-lg">
|
||||
{#if tab === "Profiles"}
|
||||
Add custom vocabulary to improve transcription accuracy.
|
||||
Names, places, and specialist terms that Whisper might misspell.
|
||||
{:else}
|
||||
Create structured templates for dictation. Sections become headings
|
||||
you can click and record into — fill reports, meeting notes, or any repeating format.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- New dialog -->
|
||||
{#if showNewDialog}
|
||||
<div class="px-7 pb-4 animate-slide-up">
|
||||
<Card>
|
||||
<div class="p-5">
|
||||
<p class="text-[14px] font-semibold text-text mb-3">
|
||||
New {tab === "Profiles" ? "Profile" : "Template"}
|
||||
</p>
|
||||
<input
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2.5 text-[13px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)] mb-2"
|
||||
placeholder={tab === "Profiles" ? "e.g. Work, DnD Campaign, Interview..." : "e.g. Meeting Notes, Report, Daily Log..."}
|
||||
bind:value={newName}
|
||||
onkeydown={(e) => e.key === "Enter" && handleCreate()}
|
||||
data-no-transition
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
{tab === "Profiles"
|
||||
? "You can add vocabulary after creating the profile."
|
||||
: "You can edit sections after creating the template."}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
|
||||
active:scale-[0.97] transition-all duration-150"
|
||||
onclick={handleCreate}
|
||||
>Create</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { showNewDialog = false; newName = ""; }}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 px-7 pb-6 space-y-3">
|
||||
{#if tab === "Profiles"}
|
||||
<!-- Profile cards -->
|
||||
{#if profiles.length === 0 && !showNewDialog}
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-5.33 0-8 2.67-8 4v2h16v-2c0-1.33-2.67-4-8-4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[13px] text-text-tertiary">No profiles yet</p>
|
||||
<p class="text-[11px] text-text-tertiary">Create one to improve transcription accuracy</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each profiles as profile, i}
|
||||
<div class="animate-slide-up">
|
||||
<Card>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-[15px] font-semibold text-text">{profile.name}</h3>
|
||||
<span class="text-[11px] text-text-tertiary px-2 py-0.5 rounded-md bg-bg-elevated">
|
||||
{wordCount(profile.words)} {wordCount(profile.words) === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="btn-sm rounded-md text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
onclick={() => deleteProfile(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-tertiary mb-3">
|
||||
One word or phrase per line. These will be prioritised during transcription.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
class="w-full h-[140px] bg-bg-elevated border border-border-subtle rounded-xl px-4 py-3
|
||||
text-[13px] text-text leading-relaxed resize-none
|
||||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
|
||||
placeholder="CORBEL Jake Sames Northampton ..."
|
||||
bind:value={profile.words}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
|
||||
active:scale-[0.97] transition-all duration-150"
|
||||
onclick={() => saveProfile(i)}
|
||||
>Save</button>
|
||||
{#if profile.saved}
|
||||
<span class="text-[11px] text-success font-medium animate-fade-in">Saved</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{:else}
|
||||
<!-- Template cards -->
|
||||
{#if templates.length === 0 && !showNewDialog}
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Zm4 18H6V4h7v5h5v11Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[13px] text-text-tertiary">No templates yet</p>
|
||||
<p class="text-[11px] text-text-tertiary">Create one for structured dictation</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each templates as template, i}
|
||||
<div class="animate-slide-up">
|
||||
<Card>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-[15px] font-semibold text-text">{template.name}</h3>
|
||||
<span class="text-[11px] text-text-tertiary px-2 py-0.5 rounded-md bg-bg-elevated">
|
||||
{template.sections.length} sections
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="btn-sm rounded-md text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
onclick={() => deleteTemplate(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-tertiary mb-3">
|
||||
One section per line. These become headings in your transcript.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
class="w-full h-[140px] bg-bg-elevated border border-border-subtle rounded-xl px-4 py-3
|
||||
text-[13px] text-text leading-relaxed resize-none
|
||||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
|
||||
placeholder="Summary Background Key Findings Recommendations"
|
||||
value={template.sections.join("\n")}
|
||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); }}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
|
||||
active:scale-[0.97] transition-all duration-150"
|
||||
onclick={() => saveTemplate(i)}
|
||||
>Save</button>
|
||||
{#if template._saved}
|
||||
<span class="text-[11px] text-success font-medium animate-fade-in">Saved</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="mt-3 pt-3 border-t border-border-subtle">
|
||||
<p class="text-[10px] text-text-tertiary uppercase tracking-wider mb-2">Preview</p>
|
||||
<div class="text-[12px] text-text-secondary space-y-1">
|
||||
{#each template.sections as section}
|
||||
<p class="font-medium text-text"># {section}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
703
src/lib/pages/SettingsPage.svelte
Normal file
@@ -0,0 +1,703 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
let engineStatus = $state("Checking...");
|
||||
let engineOk = $state(false);
|
||||
let downloadedModels = $state([]);
|
||||
let downloadingModel = $state("");
|
||||
let downloadProgress = $state(0);
|
||||
let unlisten = null;
|
||||
|
||||
// Parakeet state
|
||||
let parakeetStatus = $state("Checking...");
|
||||
let parakeetOk = $state(false);
|
||||
let parakeetDownloaded = $state(false);
|
||||
let parakeetDownloading = $state(false);
|
||||
let parakeetProgress = $state(0);
|
||||
let unlistenParakeet = null;
|
||||
|
||||
// Profiles & Templates
|
||||
let showProfiles = $state(false);
|
||||
let showTemplates = $state(false);
|
||||
let editingProfile = $state(-1);
|
||||
let editingTemplate = $state(-1);
|
||||
let newProfileName = $state("");
|
||||
let newTemplateName = $state("");
|
||||
let showNewProfile = $state(false);
|
||||
let showNewTemplate = $state(false);
|
||||
|
||||
// Accordion state — first section open by default
|
||||
let openSection = $state('transcription');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const loaded = await invoke("check_engine");
|
||||
engineOk = loaded;
|
||||
engineStatus = loaded ? "Model loaded" : "No model loaded";
|
||||
} catch {
|
||||
engineStatus = "Engine not ready";
|
||||
}
|
||||
|
||||
try {
|
||||
downloadedModels = await invoke("list_models");
|
||||
} catch {}
|
||||
|
||||
// Parakeet status
|
||||
try {
|
||||
parakeetOk = await invoke("check_parakeet_engine");
|
||||
parakeetDownloaded = await invoke("check_parakeet_model", { name: "ctc-int8" });
|
||||
parakeetStatus = parakeetOk ? "Model loaded" : parakeetDownloaded ? "Downloaded (not loaded)" : "Not downloaded";
|
||||
} catch {
|
||||
parakeetStatus = "Engine not ready";
|
||||
}
|
||||
|
||||
unlisten = await listen("model-download-progress", (event) => {
|
||||
downloadProgress = event.payload.percent || event.payload.progress || 0;
|
||||
});
|
||||
|
||||
unlistenParakeet = await listen("parakeet-download-progress", (event) => {
|
||||
parakeetProgress = event.payload.percent || event.payload.progress || 0;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
if (unlistenParakeet) unlistenParakeet();
|
||||
});
|
||||
|
||||
// Auto-save on any change
|
||||
$effect(() => {
|
||||
void settings.engine;
|
||||
void settings.modelSize;
|
||||
void settings.language;
|
||||
void settings.device;
|
||||
void settings.formatMode;
|
||||
void settings.removeFillers;
|
||||
void settings.antiHallucination;
|
||||
void settings.britishEnglish;
|
||||
void settings.autoCopy;
|
||||
void settings.includeTimestamps;
|
||||
void settings.theme;
|
||||
void settings.fontSize;
|
||||
void settings.saveAudio;
|
||||
void settings.outputFolder;
|
||||
void settings.globalHotkey;
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
async function downloadModel(size) {
|
||||
downloadingModel = size;
|
||||
downloadProgress = 0;
|
||||
try {
|
||||
await invoke("download_model", { size });
|
||||
downloadedModels = await invoke("list_models");
|
||||
downloadingModel = "";
|
||||
} catch (err) {
|
||||
downloadingModel = "";
|
||||
engineStatus = typeof err === "string" ? err : "Download failed";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedModel() {
|
||||
const size = settings.modelSize.toLowerCase();
|
||||
engineStatus = "Loading...";
|
||||
engineOk = false;
|
||||
try {
|
||||
await invoke("load_model", { size });
|
||||
engineOk = true;
|
||||
engineStatus = `${settings.modelSize} model loaded`;
|
||||
} catch (err) {
|
||||
engineOk = false;
|
||||
engineStatus = typeof err === "string" ? err : "Load failed";
|
||||
}
|
||||
}
|
||||
|
||||
function isModelDownloaded(size) {
|
||||
return downloadedModels.includes(size.toLowerCase());
|
||||
}
|
||||
|
||||
const modelDescriptions = {
|
||||
Tiny: "~75MB · fastest, lower accuracy",
|
||||
Base: "~150MB · balanced for most use",
|
||||
Small: "~500MB · noticeably more accurate",
|
||||
Medium: "~1.5GB · best quality, slower",
|
||||
};
|
||||
|
||||
async function downloadParakeet() {
|
||||
parakeetDownloading = true;
|
||||
parakeetProgress = 0;
|
||||
try {
|
||||
await invoke("download_parakeet_model", { name: "ctc-int8" });
|
||||
parakeetDownloaded = true;
|
||||
parakeetDownloading = false;
|
||||
parakeetStatus = "Downloaded (not loaded)";
|
||||
} catch (err) {
|
||||
parakeetDownloading = false;
|
||||
parakeetStatus = typeof err === "string" ? err : "Download failed";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParakeet() {
|
||||
parakeetStatus = "Loading...";
|
||||
parakeetOk = false;
|
||||
try {
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
parakeetOk = true;
|
||||
parakeetStatus = "Model loaded";
|
||||
} catch (err) {
|
||||
parakeetOk = false;
|
||||
parakeetStatus = typeof err === "string" ? err : "Load failed";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profile management ---
|
||||
function createProfile() {
|
||||
if (!newProfileName.trim()) return;
|
||||
const name = newProfileName.trim();
|
||||
profiles.push({ name, words: "" });
|
||||
saveProfiles();
|
||||
addProfileTaskList(name);
|
||||
newProfileName = "";
|
||||
showNewProfile = false;
|
||||
editingProfile = profiles.length - 1;
|
||||
}
|
||||
|
||||
function deleteProfile(index) {
|
||||
const name = profiles[index].name;
|
||||
if (page.activeProfile === name) {
|
||||
page.activeProfile = "None";
|
||||
}
|
||||
removeProfileTaskList(name);
|
||||
profiles.splice(index, 1);
|
||||
saveProfiles();
|
||||
editingProfile = -1;
|
||||
}
|
||||
|
||||
function profileWordCount(words) {
|
||||
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
|
||||
}
|
||||
|
||||
// --- Template management ---
|
||||
function createTemplate() {
|
||||
if (!newTemplateName.trim()) return;
|
||||
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
|
||||
saveTemplates();
|
||||
newTemplateName = "";
|
||||
showNewTemplate = false;
|
||||
editingTemplate = templates.length - 1;
|
||||
}
|
||||
|
||||
function deleteTemplate(index) {
|
||||
templates.splice(index, 1);
|
||||
saveTemplates();
|
||||
editingTemplate = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||
<!-- Title -->
|
||||
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-5">Settings</h2>
|
||||
|
||||
<div class="px-7 pb-8">
|
||||
<Card>
|
||||
<!-- Transcription -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'transcription' ? null : 'transcription'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Transcription</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'transcription' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'transcription'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
|
||||
<!-- Engine selector -->
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Engine</p>
|
||||
<SegmentedButton options={["whisper", "parakeet"]} bind:value={settings.engine} />
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
{settings.engine === "whisper" ? "OpenAI Whisper — 99+ languages, reliable" :
|
||||
"Nvidia Parakeet — faster on CPU, English-focused, auto-punctuated"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Format mode -->
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Format Mode</p>
|
||||
<SegmentedButton options={["Raw", "Clean", "Smart"]} bind:value={settings.formatMode} />
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
{settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" :
|
||||
settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" :
|
||||
"Structured with lists, headings, and sections"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Engine-specific model management -->
|
||||
{#if settings.engine === "whisper"}
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Whisper Model</p>
|
||||
<SegmentedButton options={["Tiny", "Base", "Small", "Medium"]} bind:value={settings.modelSize} />
|
||||
<p class="text-[11px] text-text-tertiary mt-2">{modelDescriptions[settings.modelSize]}</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
{#if isModelDownloaded(settings.modelSize)}
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
|
||||
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
|
||||
Downloaded
|
||||
</span>
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-accent"
|
||||
onclick={loadSelectedModel}
|
||||
>Load model</button>
|
||||
{:else if downloadingModel === settings.modelSize.toLowerCase()}
|
||||
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
|
||||
{:else}
|
||||
<button
|
||||
class="text-[11px] text-accent hover:text-accent-hover"
|
||||
onclick={() => downloadModel(settings.modelSize.toLowerCase())}
|
||||
>Download {settings.modelSize}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Parakeet Model</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-3">Parakeet CTC 0.6B (int8) — ~613MB, near-instant transcription</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if parakeetOk}
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
|
||||
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
|
||||
Model loaded
|
||||
</span>
|
||||
{:else if parakeetDownloaded}
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-text-secondary">
|
||||
<span class="w-[6px] h-[6px] rounded-full bg-text-tertiary"></span>
|
||||
Downloaded
|
||||
</span>
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-accent"
|
||||
onclick={loadParakeet}
|
||||
>Load model</button>
|
||||
{:else if parakeetDownloading}
|
||||
<span class="text-[11px] text-warning">{parakeetProgress}% downloading...</span>
|
||||
{:else}
|
||||
<button
|
||||
class="text-[11px] text-accent hover:text-accent-hover"
|
||||
onclick={downloadParakeet}
|
||||
>Download Parakeet</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Compute device -->
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Compute Device</p>
|
||||
<select
|
||||
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
|
||||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
|
||||
appearance-none cursor-pointer w-[220px]"
|
||||
bind:value={settings.device}
|
||||
>
|
||||
<option value="auto">Auto (CPU)</option>
|
||||
<option value="cuda">CUDA (NVIDIA GPU)</option>
|
||||
<option value="cpu">CPU</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-text-tertiary mt-2">GPU acceleration requires building with CUDA feature</p>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Language</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
|
||||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
|
||||
appearance-none cursor-pointer w-[140px]"
|
||||
bind:value={settings.language}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="nl">Dutch</option>
|
||||
<option value="pl">Polish</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="zh">Chinese</option>
|
||||
</select>
|
||||
{#if settings.language === "en"}
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[11px] border animate-fade-in
|
||||
{settings.britishEnglish
|
||||
? 'bg-accent/10 border-accent/30 text-accent font-medium'
|
||||
: 'bg-bg-input border-border text-text-tertiary hover:text-text-secondary'}"
|
||||
onclick={() => { settings.britishEnglish = !settings.britishEnglish; }}
|
||||
title={settings.britishEnglish ? "British English spelling active" : "Click to enable British English spelling"}
|
||||
>
|
||||
{#if settings.britishEnglish}
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
British English
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'processing' ? null : 'processing'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Processing</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'processing' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'processing'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<div class="space-y-0.5">
|
||||
<Toggle
|
||||
bind:checked={settings.removeFillers}
|
||||
label="Remove filler words"
|
||||
description="Strips um, uh, like, you know from output"
|
||||
/>
|
||||
<Toggle
|
||||
bind:checked={settings.antiHallucination}
|
||||
label="Anti-hallucination shield"
|
||||
description="Detects phantom phrases Whisper generates during silence"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI Assistant -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'ai' ? null : 'ai'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">AI Assistant</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'ai' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'ai'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">Local LLM for smart task extraction, transcript cleanup, and formatting. Runs 100% offline.</p>
|
||||
<div class="bg-bg-input rounded-lg px-3 py-2.5 border border-border-subtle">
|
||||
<p class="text-[12px] text-text-secondary font-medium mb-1">Coming soon</p>
|
||||
<p class="text-[11px] text-text-tertiary">AI-powered cleanup and smart extraction are being rebuilt with a faster engine. Task extraction currently uses rule-based matching, which runs automatically after each recording.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profiles & Templates -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'profiles' ? null : 'profiles'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Profiles & Templates</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'profiles' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'profiles'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!-- Profiles section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showProfiles = !showProfiles}
|
||||
>
|
||||
<svg class="w-3 h-3 text-text-tertiary transition-transform {showProfiles ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profiles.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
|
||||
|
||||
{#if showProfiles}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each profiles as profile, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profileWordCount(profile.words)} words
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingProfile = editingProfile === i ? -1 : i}
|
||||
>{editingProfile === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteProfile(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingProfile === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text leading-relaxed resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One word or phrase per line..."
|
||||
bind:value={profile.words}
|
||||
oninput={() => saveProfiles()}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showNewProfile}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Profile name..."
|
||||
bind:value={newProfileName}
|
||||
onkeydown={(e) => e.key === "Enter" && createProfile()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Templates section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showTemplates = !showTemplates}
|
||||
>
|
||||
<svg class="w-3 h-3 text-text-tertiary transition-transform {showTemplates ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{templates.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each templates as template, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{template.sections.length} sections
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
|
||||
>{editingTemplate === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteTemplate(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingTemplate === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text leading-relaxed resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One section per line..."
|
||||
value={template.sections.join("\n")}
|
||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showNewTemplate}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Template name..."
|
||||
bind:value={newTemplateName}
|
||||
onkeydown={(e) => e.key === "Enter" && createTemplate()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'output' ? null : 'output'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Output</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'output' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'output'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<div class="space-y-0.5">
|
||||
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
|
||||
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
|
||||
<Toggle
|
||||
bind:checked={settings.saveAudio}
|
||||
label="Save audio recordings"
|
||||
description="Saves raw audio as .wav files (~2MB per minute). Stored locally."
|
||||
/>
|
||||
{#if settings.saveAudio}
|
||||
<div class="ml-[50px] mt-2 animate-fade-in">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Output Folder</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-bg-input rounded-lg px-3 py-1.5 border border-border-subtle">
|
||||
<p class="text-[11px] text-text-secondary truncate">
|
||||
{settings.outputFolder || "Default (app data)"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-[11px] text-accent hover:text-accent-hover whitespace-nowrap"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const folder = await open({ directory: true, title: "Select output folder" });
|
||||
if (folder) { settings.outputFolder = folder; saveSettings(); }
|
||||
} catch {}
|
||||
}}
|
||||
>Change</button>
|
||||
{#if settings.outputFolder}
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-text-secondary whitespace-nowrap"
|
||||
onclick={() => { settings.outputFolder = ""; saveSettings(); }}
|
||||
>Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hotkey -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'hotkey' ? null : 'hotkey'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Global Hotkey</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'hotkey' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'hotkey'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
|
||||
<HotkeyRecorder />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'appearance' ? null : 'appearance'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Appearance</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'appearance' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'appearance'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
|
||||
<SegmentedButton options={["Dark", "Light", "System"]} bind:value={settings.theme} />
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||||
Font Size <span class="font-normal text-text-secondary ml-1">{settings.fontSize}px</span>
|
||||
</p>
|
||||
<input
|
||||
type="range" min="10" max="24" step="1"
|
||||
bind:value={settings.fontSize}
|
||||
class="w-[200px] accent-accent"
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'about' ? null : 'about'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">About</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'about' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'about'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!-- Engine status -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="w-[7px] h-[7px] rounded-full {engineOk ? 'bg-success' : 'bg-warning'}"></span>
|
||||
<span class="text-[12px] text-text-secondary">{engineStatus}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
{#each [
|
||||
"100% offline — all processing on your machine",
|
||||
"No Python required — compiled Whisper engine",
|
||||
"No cloud — audio never leaves your computer",
|
||||
"No accounts — no sign-up, no tracking",
|
||||
"No telemetry — zero data collection",
|
||||
] as item}
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-3.5 h-3.5 text-success mt-0.5 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17Z" />
|
||||
</svg>
|
||||
<p class="text-[11px] text-text-secondary">{item}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-tertiary mt-5">Kon v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
517
src/lib/pages/TasksPage.svelte
Normal file
@@ -0,0 +1,517 @@
|
||||
<script>
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
||||
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
||||
} from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import { formatTimestamp } from "$lib/utils/time.js";
|
||||
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
||||
|
||||
let activeBucket = $state("all");
|
||||
let activeListId = $state("all");
|
||||
let showCompleted = $state(false);
|
||||
let quickInput = $state("");
|
||||
let searchQuery = $state("");
|
||||
let sidebarCollapsed = $state(false);
|
||||
let showNewList = $state(false);
|
||||
let newListName = $state("");
|
||||
let sortMode = $state("date");
|
||||
let showSortMenu = $state(false);
|
||||
let contextMenuListId = $state(null);
|
||||
let editingListId = $state(null);
|
||||
let editingName = $state("");
|
||||
|
||||
const buckets = [
|
||||
{ id: "all", label: "All", icon: "M4 6h16M4 12h16M4 18h16" },
|
||||
{ id: "inbox", label: "Inbox", icon: "M20 12V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6m16 0v6a2 2 0 0 0-2 2H6a2 2 0 0 0-2-2v-6m16 0H4" },
|
||||
{ id: "today", label: "Today", icon: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm-1-13v5l4.28 2.54.72-1.21-3.5-2.08V7h-1.5Z" },
|
||||
{ id: "soon", label: "Soon", icon: "M17 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2ZM7 7h10M7 11h10M7 15h5" },
|
||||
{ id: "later", label: "Later", icon: "M5 3v18l7-3 7 3V3H5Z" },
|
||||
];
|
||||
|
||||
|
||||
let filteredTasks = $derived.by(() => {
|
||||
let list = tasks.filter((t) => !t.done);
|
||||
// Bucket filter
|
||||
if (activeBucket !== "all") {
|
||||
list = list.filter((t) => t.bucket === activeBucket);
|
||||
}
|
||||
// List filter
|
||||
if (activeListId !== "all") {
|
||||
if (activeListId === "inbox") {
|
||||
list = list.filter((t) => !t.listId);
|
||||
} else {
|
||||
list = list.filter((t) => t.listId === activeListId);
|
||||
}
|
||||
}
|
||||
// Search
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
||||
}
|
||||
// Sort
|
||||
if (sortMode === "quick-first") {
|
||||
list.sort((a, b) => (EFFORT_ORDER[a.effort] || 4) - (EFFORT_ORDER[b.effort] || 4));
|
||||
} else if (sortMode === "deep-first") {
|
||||
list.sort((a, b) => (EFFORT_ORDER[b.effort] || 4) - (EFFORT_ORDER[a.effort] || 4));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
let completedTasks = $derived.by(() => {
|
||||
let list = tasks.filter((t) => t.done);
|
||||
if (activeBucket !== "all") {
|
||||
list = list.filter((t) => t.bucket === activeBucket);
|
||||
}
|
||||
if (activeListId !== "all") {
|
||||
if (activeListId === "inbox") {
|
||||
list = list.filter((t) => !t.listId);
|
||||
} else {
|
||||
list = list.filter((t) => t.listId === activeListId);
|
||||
}
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
||||
}
|
||||
return list.slice(0, 20);
|
||||
});
|
||||
|
||||
let bucketCounts = $derived.by(() => {
|
||||
const counts = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
|
||||
for (const t of tasks) {
|
||||
if (t.done) continue;
|
||||
counts.all++;
|
||||
if (counts[t.bucket] !== undefined) counts[t.bucket]++;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
function countForList(listId) {
|
||||
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
||||
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
||||
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
||||
}
|
||||
|
||||
let activeListName = $derived(
|
||||
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
|
||||
);
|
||||
|
||||
function handleQuickAdd(e) {
|
||||
if (e.key === "Enter" && quickInput.trim()) {
|
||||
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
||||
addTask({
|
||||
text: quickInput.trim(),
|
||||
bucket: activeBucket === "all" ? "inbox" : activeBucket,
|
||||
listId,
|
||||
});
|
||||
quickInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function setBucket(taskId, bucket) {
|
||||
updateTask(taskId, { bucket });
|
||||
}
|
||||
|
||||
function setEffort(taskId, effort) {
|
||||
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
|
||||
}
|
||||
|
||||
|
||||
function getListName(listId) {
|
||||
if (!listId) return null;
|
||||
return taskLists.find((l) => l.id === listId)?.name || null;
|
||||
}
|
||||
|
||||
async function popOutTasks() {
|
||||
try {
|
||||
await invoke("open_task_window");
|
||||
} catch {
|
||||
window.open("/float", "_blank", "width=380,height=520");
|
||||
}
|
||||
}
|
||||
|
||||
// List management
|
||||
function handleCreateList(e) {
|
||||
if (e.key === "Enter" && newListName.trim()) {
|
||||
addTaskList(newListName.trim());
|
||||
newListName = "";
|
||||
showNewList = false;
|
||||
}
|
||||
if (e.key === "Escape") { showNewList = false; newListName = ""; }
|
||||
}
|
||||
|
||||
function startRenaming(list) {
|
||||
editingListId = list.id;
|
||||
editingName = list.name;
|
||||
contextMenuListId = null;
|
||||
}
|
||||
|
||||
function finishRenaming() {
|
||||
if (editingListId && editingName.trim()) {
|
||||
renameTaskList(editingListId, editingName.trim());
|
||||
}
|
||||
editingListId = null;
|
||||
editingName = "";
|
||||
}
|
||||
|
||||
function handleDeleteList(id) {
|
||||
if (activeListId === id) activeListId = "all";
|
||||
deleteTaskList(id);
|
||||
contextMenuListId = null;
|
||||
}
|
||||
|
||||
function toggleContextMenu(e, listId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenuListId = contextMenuListId === listId ? null : listId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full animate-fade-in">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center px-7 pt-6 pb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-display text-text italic">Tasks</h2>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">Extracted from transcripts or added manually</p>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={popOutTasks}
|
||||
aria-label="Pop out task window"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
Pop out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-7 pb-2">
|
||||
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent transition-colors">
|
||||
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search tasks..."
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick capture -->
|
||||
<div class="px-7 pb-3">
|
||||
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-xl px-4 py-2.5 focus-within:border-accent transition-colors">
|
||||
<svg class="w-4 h-4 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Add a task to {activeListName}{activeBucket !== 'all' ? ` (${activeBucket})` : ''}... (Enter to save)"
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
/>
|
||||
{#if quickInput.trim()}
|
||||
<span class="text-[10px] text-text-tertiary px-1.5 py-0.5 rounded bg-bg-elevated border border-border-subtle">
|
||||
→ {activeBucket === "all" ? "inbox" : activeBucket}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bucket tabs + sort -->
|
||||
<div class="flex items-center gap-1 px-7 pb-3">
|
||||
<nav aria-label="Task filters">
|
||||
{#each buckets as bucket}
|
||||
<button
|
||||
class="flex items-center gap-1.5 btn-md rounded-lg transition-colors
|
||||
{activeBucket === bucket.id
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
onclick={() => activeBucket = bucket.id}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 {activeBucket === bucket.id ? 'text-accent' : 'text-text-tertiary'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={bucket.icon} />
|
||||
</svg>
|
||||
{bucket.label}
|
||||
{#if bucketCounts[bucket.id] > 0}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{bucketCounts[bucket.id]}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[11px] text-text-tertiary hover:text-text-secondary hover:bg-hover"
|
||||
onclick={() => showSortMenu = !showSortMenu}
|
||||
aria-label="Sort tasks"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
|
||||
</svg>
|
||||
{sortMode === "date" ? "" : sortMode === "quick-first" ? "Quick first" : "Deep first"}
|
||||
</button>
|
||||
{#if showSortMenu}
|
||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]">
|
||||
{#each [["date", "By date"], ["quick-first", "Quick first"], ["deep-first", "Deep first"]] as [mode, label]}
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[11px] hover:bg-hover
|
||||
{sortMode === mode ? 'text-accent font-medium' : 'text-text-secondary hover:text-text'}"
|
||||
onclick={() => { sortMode = mode; showSortMenu = false; }}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content: sidebar + tasks -->
|
||||
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
|
||||
<!-- List sidebar -->
|
||||
<div
|
||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden transition-[width] duration-150
|
||||
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
||||
>
|
||||
<!-- Collapse toggle -->
|
||||
<button
|
||||
class="flex items-center justify-center h-[32px] text-text-tertiary hover:text-text-secondary border-b border-border-subtle"
|
||||
onclick={() => sidebarCollapsed = !sidebarCollapsed}
|
||||
aria-label={sidebarCollapsed ? "Expand list sidebar" : "Collapse list sidebar"}
|
||||
>
|
||||
<svg class="w-3 h-3 transition-transform {sidebarCollapsed ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- List items -->
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#each taskLists as list (list.id)}
|
||||
{#if editingListId === list.id && !sidebarCollapsed}
|
||||
<div class="px-1.5 py-0.5">
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
|
||||
bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
||||
onblur={finishRenaming}
|
||||
data-no-transition
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left text-[11px]
|
||||
{activeListId === list.id
|
||||
? 'border-l-2 border-accent text-text font-medium bg-accent/5'
|
||||
: 'border-l-2 border-transparent text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
onclick={() => { activeListId = list.id; contextMenuListId = null; }}
|
||||
ondblclick={() => { if (!list.builtIn && !sidebarCollapsed) startRenaming(list); }}
|
||||
oncontextmenu={(e) => { if (!list.builtIn) toggleContextMenu(e, list.id); }}
|
||||
title={sidebarCollapsed ? `${list.name} (${countForList(list.id)})` : ""}
|
||||
>
|
||||
{#if sidebarCollapsed}
|
||||
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center mx-auto">
|
||||
{countForList(list.id)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex-1 truncate">{list.name}</span>
|
||||
{#if countForList(list.id) > 0}
|
||||
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center">
|
||||
{countForList(list.id)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Context menu -->
|
||||
{#if contextMenuListId === list.id && !sidebarCollapsed}
|
||||
<div class="absolute left-2 top-full mt-0.5 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[100px]">
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => startRenaming(list)}
|
||||
>Rename</button>
|
||||
<div class="my-1 h-px bg-border-subtle"></div>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-danger hover:bg-hover"
|
||||
onclick={() => handleDeleteList(list.id)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- New list -->
|
||||
{#if !sidebarCollapsed}
|
||||
<div class="px-2 py-2 border-t border-border-subtle">
|
||||
{#if showNewList}
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="List name..."
|
||||
bind:value={newListName}
|
||||
onkeydown={handleCreateList}
|
||||
onblur={() => { showNewList = false; newListName = ""; }}
|
||||
data-no-transition
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="w-full text-left text-[10px] text-accent hover:text-accent-hover px-1"
|
||||
onclick={() => showNewList = true}
|
||||
>+ New list</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
{#if filteredTasks.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-center py-12 opacity-60">
|
||||
<svg class="w-10 h-10 text-text-tertiary mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
{searchQuery ? "No matching tasks"
|
||||
: activeListId !== "all" && activeBucket !== "all"
|
||||
? `No ${activeBucket} tasks in ${activeListName}`
|
||||
: activeListId !== "all"
|
||||
? `No tasks in ${activeListName}`
|
||||
: activeBucket === "all" ? "No tasks yet" : `Nothing in ${activeBucket}`}
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
{searchQuery ? "Try a different search" : "Tasks are extracted from transcripts or added above"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each filteredTasks as task (task.id)}
|
||||
<div class="group flex items-start gap-3 p-3 rounded-xl bg-bg-card border border-border-subtle hover:border-border transition-colors">
|
||||
<!-- Checkbox -->
|
||||
<button
|
||||
aria-label="Complete task"
|
||||
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-border-subtle hover:border-accent flex-shrink-0 flex items-center justify-center transition-colors"
|
||||
onclick={() => completeTask(task.id)}
|
||||
></button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<!-- Bucket pills -->
|
||||
{#each ["today", "soon", "later"] as b}
|
||||
<button
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border transition-colors
|
||||
{task.bucket === b
|
||||
? `${BUCKET_COLORS[b]} border-current bg-current/10 font-medium`
|
||||
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
||||
onclick={() => setBucket(task.id, b)}
|
||||
>{b}</button>
|
||||
{/each}
|
||||
<span class="text-border-subtle">·</span>
|
||||
<!-- Effort pills -->
|
||||
{#each ["quick", "medium", "deep"] as e}
|
||||
<button
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border transition-colors
|
||||
{task.effort === e
|
||||
? 'text-accent border-accent/30 bg-accent/10 font-medium'
|
||||
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
||||
onclick={() => setEffort(task.id, e)}
|
||||
>{EFFORT_LABELS[e]}</button>
|
||||
{/each}
|
||||
<!-- Timestamp -->
|
||||
{#if task.createdAt}
|
||||
<span class="text-[10px] text-text-tertiary ml-auto">{formatTimestamp(task.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- List name (only when viewing all lists) -->
|
||||
{#if activeListId === "all" && getListName(task.listId)}
|
||||
<p class="text-[10px] text-text-tertiary italic mt-1">{getListName(task.listId)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
aria-label="Delete task"
|
||||
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Completed section -->
|
||||
{#if completedTasks.length > 0}
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
|
||||
onclick={() => showCompleted = !showCompleted}
|
||||
>
|
||||
<svg class="w-3 h-3 transition-transform {showCompleted ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
Completed ({completedTasks.length})
|
||||
</button>
|
||||
{#if showCompleted}
|
||||
<div class="flex flex-col gap-1 animate-fade-in">
|
||||
{#each completedTasks as task (task.id)}
|
||||
<div class="group flex items-start gap-3 px-4 py-2.5 rounded-xl opacity-50 hover:opacity-70 transition-opacity">
|
||||
<button
|
||||
aria-label="Uncomplete task"
|
||||
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-accent bg-accent/20 flex-shrink-0 flex items-center justify-center"
|
||||
onclick={() => uncompleteTask(task.id)}
|
||||
>
|
||||
<svg class="w-3 h-3 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text-secondary line-through">{task.text}</p>
|
||||
{#if task.doneAt}
|
||||
<p class="text-[10px] text-text-tertiary mt-0.5">Completed {formatTimestamp(task.doneAt)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Delete completed task"
|
||||
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
353
src/lib/stores/page.svelte.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/** @type {{ current: string, status: string, statusColor: string, activeProfile: string, recording: boolean, timerText: string, handedness: string, taskSidebarOpen: boolean }} */
|
||||
export const page = $state({
|
||||
current: "dictation",
|
||||
status: "Ready",
|
||||
statusColor: "#7ec89a",
|
||||
activeProfile: "None",
|
||||
recording: false,
|
||||
timerText: "00:00",
|
||||
handedness: "Right",
|
||||
taskSidebarOpen: false,
|
||||
});
|
||||
|
||||
// ---- Settings (persisted to localStorage) ----
|
||||
|
||||
const SETTINGS_KEY = "kon_settings";
|
||||
|
||||
const defaults = {
|
||||
engine: "whisper",
|
||||
modelSize: "Base",
|
||||
language: "en",
|
||||
device: "auto",
|
||||
formatMode: "Smart",
|
||||
removeFillers: true,
|
||||
antiHallucination: true,
|
||||
britishEnglish: true,
|
||||
autoCopy: true,
|
||||
includeTimestamps: true,
|
||||
theme: "Dark",
|
||||
fontSize: 14,
|
||||
llmModelSize: "small",
|
||||
llmEnabled: false,
|
||||
saveAudio: false,
|
||||
outputFolder: "",
|
||||
globalHotkey: "Ctrl+Shift+R",
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
return { ...defaults };
|
||||
}
|
||||
|
||||
export const settings = $state(loadSettings());
|
||||
|
||||
/** Save current settings to localStorage */
|
||||
export function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- Profiles (persisted to localStorage) ----
|
||||
|
||||
const PROFILES_KEY = "kon_profiles";
|
||||
|
||||
function loadProfiles() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const profiles = $state(loadProfiles());
|
||||
|
||||
export function saveProfiles() {
|
||||
try {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- History (persisted to localStorage) ----
|
||||
|
||||
const HISTORY_KEY = "kon_history";
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const history = $state(loadHistory());
|
||||
|
||||
export function saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function addToHistory(entry) {
|
||||
history.unshift(entry);
|
||||
if (history.length > 100) history.length = 100;
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
export function deleteFromHistory(index) {
|
||||
history.splice(index, 1);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
// ---- Tasks (persisted to localStorage) ----
|
||||
|
||||
const TASKS_KEY = "kon_tasks";
|
||||
|
||||
function loadTasks() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TASKS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const tasks = $state(loadTasks());
|
||||
|
||||
export function saveTasks() {
|
||||
try {
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function addTask(task) {
|
||||
tasks.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
text: task.text,
|
||||
bucket: task.bucket || "inbox",
|
||||
listId: task.listId || null,
|
||||
context: task.context || "",
|
||||
effort: task.effort || "",
|
||||
done: false,
|
||||
doneAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceTranscriptId: task.sourceTranscriptId || null,
|
||||
notes: "",
|
||||
});
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
|
||||
export function updateTask(id, updates) {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
Object.assign(tasks[idx], updates);
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTask(id) {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
}
|
||||
|
||||
export function completeTask(id) {
|
||||
updateTask(id, { done: true, doneAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
export function uncompleteTask(id) {
|
||||
updateTask(id, { done: false, doneAt: null });
|
||||
}
|
||||
|
||||
// ---- BroadcastChannel for multi-window task sync ----
|
||||
|
||||
const taskChannel = typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel("kon_tasks")
|
||||
: null;
|
||||
|
||||
if (taskChannel) {
|
||||
taskChannel.onmessage = (event) => {
|
||||
if (event.data.type === "tasks_updated") {
|
||||
tasks.length = 0;
|
||||
tasks.push(...event.data.tasks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastTasks() {
|
||||
if (taskChannel) {
|
||||
taskChannel.postMessage({ type: "tasks_updated", tasks: $state.snapshot(tasks) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Task Lists (persisted to localStorage) ----
|
||||
|
||||
const TASK_LISTS_KEY = "kon_task_lists";
|
||||
|
||||
function loadTaskLists() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TASK_LISTS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [
|
||||
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
||||
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
|
||||
];
|
||||
}
|
||||
|
||||
export const taskLists = $state(loadTaskLists());
|
||||
|
||||
export function saveTaskLists() {
|
||||
try {
|
||||
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
|
||||
} catch {}
|
||||
broadcastTaskLists();
|
||||
}
|
||||
|
||||
export function addTaskList(name, profileId = null) {
|
||||
taskLists.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
builtIn: false,
|
||||
profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
export function addProfileTaskList(profileName) {
|
||||
const exists = taskLists.find((l) => l.profileId === profileName);
|
||||
if (exists) return exists.id;
|
||||
const id = crypto.randomUUID();
|
||||
taskLists.push({
|
||||
id,
|
||||
name: profileName,
|
||||
builtIn: false,
|
||||
profileId: profileName,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
saveTaskLists();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeProfileTaskList(profileName) {
|
||||
const idx = taskLists.findIndex((l) => l.profileId === profileName);
|
||||
if (idx >= 0) {
|
||||
const listId = taskLists[idx].id;
|
||||
for (const t of tasks) {
|
||||
if (t.listId === listId) t.listId = null;
|
||||
}
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
taskLists.splice(idx, 1);
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function moveTaskToList(taskId, listId) {
|
||||
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
|
||||
}
|
||||
|
||||
export function moveTaskListToProfile(listId, profileId) {
|
||||
const list = taskLists.find((l) => l.id === listId);
|
||||
if (list && !list.builtIn) {
|
||||
list.profileId = profileId || null;
|
||||
if (profileId && !list.name) list.name = profileId;
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function renameTaskList(id, name) {
|
||||
const list = taskLists.find((l) => l.id === id);
|
||||
if (list && !list.builtIn) {
|
||||
list.name = name;
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTaskList(id) {
|
||||
const idx = taskLists.findIndex((l) => l.id === id);
|
||||
if (idx >= 0 && !taskLists[idx].builtIn) {
|
||||
for (const t of tasks) {
|
||||
if (t.listId === id) t.listId = null;
|
||||
}
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
taskLists.splice(idx, 1);
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function moveTaskList(id, direction) {
|
||||
const idx = taskLists.findIndex((l) => l.id === id);
|
||||
const targetIdx = idx + direction;
|
||||
if (targetIdx < 0 || targetIdx >= taskLists.length) return;
|
||||
if (taskLists[targetIdx].builtIn) return;
|
||||
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
export function sortTaskLists(mode) {
|
||||
const builtIn = taskLists.filter((l) => l.builtIn);
|
||||
const custom = taskLists.filter((l) => !l.builtIn);
|
||||
if (mode === "alpha") {
|
||||
custom.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (mode === "date") {
|
||||
custom.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
}
|
||||
taskLists.length = 0;
|
||||
taskLists.push(...builtIn, ...custom);
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
// BroadcastChannel for task lists
|
||||
const taskListChannel = typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel("kon_task_lists")
|
||||
: null;
|
||||
|
||||
if (taskListChannel) {
|
||||
taskListChannel.onmessage = (event) => {
|
||||
if (event.data.type === "task_lists_updated") {
|
||||
taskLists.length = 0;
|
||||
taskLists.push(...event.data.lists);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastTaskLists() {
|
||||
if (taskListChannel) {
|
||||
taskListChannel.postMessage({ type: "task_lists_updated", lists: $state.snapshot(taskLists) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Templates (persisted to localStorage) ----
|
||||
|
||||
const TEMPLATES_KEY = "kon_templates";
|
||||
|
||||
function loadTemplates() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TEMPLATES_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [
|
||||
{ name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] },
|
||||
{ name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] },
|
||||
{ name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] },
|
||||
];
|
||||
}
|
||||
|
||||
export const templates = $state(loadTemplates());
|
||||
|
||||
export function saveTemplates() {
|
||||
try {
|
||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
||||
} catch {}
|
||||
}
|
||||
30
src/lib/utils/constants.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Timing
|
||||
export const FEEDBACK_TIMEOUT_MS = 3000;
|
||||
export const HOTKEY_FEEDBACK_MS = 1500;
|
||||
export const HISTORY_MAX_ENTRIES = 100;
|
||||
export const MAX_PCM_SAMPLES = 4_800_000; // 5 min at 16kHz
|
||||
export const SIDEBAR_MAX_TASKS = 30;
|
||||
export const CHUNK_INTERVAL_MS = 3000;
|
||||
export const MIN_CHUNK_SAMPLES = 8000;
|
||||
|
||||
// Buckets
|
||||
export const BUCKET_COLORS = {
|
||||
inbox: "text-text-tertiary",
|
||||
today: "text-accent",
|
||||
soon: "text-warning",
|
||||
later: "text-text-secondary",
|
||||
};
|
||||
|
||||
export const BUCKET_DOT_COLORS = {
|
||||
inbox: "bg-text-tertiary",
|
||||
today: "bg-accent",
|
||||
soon: "bg-warning",
|
||||
later: "bg-text-secondary",
|
||||
};
|
||||
|
||||
// Effort
|
||||
export const EFFORT_LABELS = { quick: "Quick", medium: "Medium", deep: "Deep" };
|
||||
export const EFFORT_ORDER = { quick: 1, medium: 2, deep: 3, "": 4 };
|
||||
|
||||
// Playback
|
||||
export const PLAYBACK_SPEEDS = [0.5, 1, 1.5, 2, 3, 5];
|
||||
118
src/lib/utils/export.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||
|
||||
/**
|
||||
* Export transcript in various formats.
|
||||
* @param {string} text - Plain text transcript
|
||||
* @param {Array<{start: number, end: number, text: string}>} segments - Timed segments
|
||||
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function exportTranscript(text, segments, format) {
|
||||
switch (format) {
|
||||
case "srt":
|
||||
return toSRT(segments);
|
||||
case "vtt":
|
||||
return toVTT(segments);
|
||||
case "md":
|
||||
return toMarkdown(text, segments);
|
||||
case "csv":
|
||||
return toCSV(segments);
|
||||
case "html":
|
||||
return toHTML(text, segments);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function toSRT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
return segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toVTT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
let out = "WEBVTT\n\n";
|
||||
out += segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCSV(segments) {
|
||||
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
||||
let csv = "Start,End,Text\n";
|
||||
for (const seg of segments) {
|
||||
const text = seg.text.trim().replace(/"/g, '""');
|
||||
csv += `${seg.start.toFixed(2)},${seg.end.toFixed(2)},"${text}"\n`;
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function toHTML(text, segments) {
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Kon Transcript</title>
|
||||
<style>
|
||||
body { font-family: "DM Sans", system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; color: #1a1816; line-height: 1.7; }
|
||||
h1 { font-family: "Instrument Serif", Georgia, serif; font-style: italic; }
|
||||
.meta { color: #9a9486; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.segment { display: flex; gap: 1rem; padding: 0.4rem 0; }
|
||||
.timestamp { color: #9a9486; font-size: 0.8rem; min-width: 3rem; font-variant-numeric: tabular-nums; padding-top: 0.15rem; }
|
||||
.text { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Transcript</h1>
|
||||
<p class="meta">${new Date().toLocaleDateString("en-GB")}`;
|
||||
if (segments?.length > 0) {
|
||||
const dur = segments[segments.length - 1].end;
|
||||
html += ` · ${Math.round(dur)}s`;
|
||||
}
|
||||
html += `</p>\n`;
|
||||
|
||||
if (segments?.length > 0) {
|
||||
for (const seg of segments) {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
html += `<div class="segment"><span class="timestamp">${pad(mins)}:${pad(secs)}</span><span class="text">${escapeHtml(seg.text.trim())}</span></div>\n`;
|
||||
}
|
||||
} else {
|
||||
html += `<p>${escapeHtml(text).replace(/\n/g, "<br>")}</p>`;
|
||||
}
|
||||
html += `</body></html>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function toMarkdown(text, segments) {
|
||||
let md = `# Transcription\n\n`;
|
||||
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
||||
if (segments && segments.length > 0) {
|
||||
const duration = segments[segments.length - 1].end;
|
||||
md += `**Duration:** ${Math.round(duration)}s\n`;
|
||||
}
|
||||
md += `\n---\n\n`;
|
||||
|
||||
if (segments && segments.length > 0) {
|
||||
segments.forEach((seg) => {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
md += `[${pad(mins)}:${pad(secs)}] ${seg.text.trim()}\n\n`;
|
||||
});
|
||||
} else {
|
||||
md += text;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
127
src/lib/utils/taskExtractor.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Rule-based task extraction from transcripts.
|
||||
* Identifies action items from natural speech patterns.
|
||||
*/
|
||||
|
||||
// Action verbs that typically start task sentences
|
||||
const ACTION_VERBS = [
|
||||
"call", "email", "send", "write", "build", "fix", "update", "check",
|
||||
"review", "schedule", "book", "create", "set up", "follow up", "organise",
|
||||
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
|
||||
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
|
||||
"move", "add", "remove", "delete", "install", "configure", "contact",
|
||||
"message", "tell", "ask", "invite", "remind", "finish", "complete",
|
||||
"start", "begin", "plan", "design", "implement", "refactor",
|
||||
];
|
||||
|
||||
// Phrases that signal a task
|
||||
const TASK_PHRASES = [
|
||||
"need to", "needs to", "should", "must", "have to", "has to",
|
||||
"want to", "going to", "gonna", "got to", "gotta",
|
||||
"don't forget to", "remember to", "make sure to", "make sure we",
|
||||
"let's", "we should", "i should", "we need to", "i need to",
|
||||
"we have to", "i have to", "we must", "i must",
|
||||
];
|
||||
|
||||
// Explicit markers (user intentionally marks a task)
|
||||
const EXPLICIT_MARKERS = [
|
||||
"action:", "action item:", "todo:", "task:", "to do:",
|
||||
"action item", "follow up:", "follow-up:",
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract tasks from a transcript string.
|
||||
* Returns an array of { text, confidence } objects.
|
||||
*/
|
||||
export function extractTasks(transcript) {
|
||||
if (!transcript || !transcript.trim()) return [];
|
||||
|
||||
const tasks = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Split into sentences (rough but effective for speech)
|
||||
const sentences = splitSentences(transcript);
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const trimmed = sentence.trim();
|
||||
if (trimmed.length < 5) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
let matched = false;
|
||||
let confidence = 0;
|
||||
|
||||
// Check explicit markers (highest confidence)
|
||||
for (const marker of EXPLICIT_MARKERS) {
|
||||
if (lower.startsWith(marker) || lower.includes(marker)) {
|
||||
const taskText = extractAfterMarker(trimmed, marker);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.95);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check task phrases (high confidence)
|
||||
for (const phrase of TASK_PHRASES) {
|
||||
const idx = lower.indexOf(phrase);
|
||||
if (idx >= 0) {
|
||||
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.8);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check if sentence starts with an action verb (medium confidence)
|
||||
const firstWord = lower.split(/\s+/)[0];
|
||||
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
|
||||
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
|
||||
addTask(tasks, seen, capitalise(trimmed), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function splitSentences(text) {
|
||||
// Split on sentence-ending punctuation, newlines, or common speech breaks
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+|\n+/)
|
||||
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
|
||||
.filter((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
function extractAfterMarker(sentence, marker) {
|
||||
const lower = sentence.toLowerCase();
|
||||
const idx = lower.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
|
||||
}
|
||||
|
||||
function extractTaskFromPhrase(sentence, phrase, idx) {
|
||||
// Extract "need to X" → "X"
|
||||
const afterPhrase = sentence.slice(idx + phrase.length).trim();
|
||||
if (afterPhrase) return afterPhrase;
|
||||
return null;
|
||||
}
|
||||
|
||||
function capitalise(text) {
|
||||
if (!text) return text;
|
||||
// Clean up leading punctuation/whitespace
|
||||
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
|
||||
if (!cleaned) return text;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
function addTask(tasks, seen, text, confidence) {
|
||||
// Remove trailing punctuation for dedup
|
||||
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
|
||||
}
|
||||
46
src/lib/utils/time.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Pad number to 2 digits */
|
||||
export function pad(n) {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Format seconds as M:SS (e.g. 1:05) */
|
||||
export function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/** Format seconds as human duration (e.g. 2m 30s) */
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
||||
export function formatTimestamp(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
||||
" " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
||||
export function formatTimeSRT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)},${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
||||
export function formatTimeVTT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||