agent: foundation — import legacy codebase from Obsidian vault

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 10:28:24 +00:00
parent 499938591f
commit e13d7d82cc
114 changed files with 17387 additions and 0 deletions

View 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
View File

@@ -0,0 +1,6 @@
node_modules/
target/
build/
dist/
.svelte-kit/
Cargo.lock

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri", "crates/*"]
resolver = "2"

View 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"

View 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};

View 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.

View 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"));
}
}

View 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: &regex_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
View 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"] }

View 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();
}
}

View 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
View 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
View 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};

View 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
View 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
View 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();
}
}

View 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" }

View 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()
}

View File

@@ -0,0 +1,3 @@
pub mod keystore;
pub use keystore::{retrieve_api_key, store_api_key};

12
crates/core/Cargo.toml Normal file
View 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"

View 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
View 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
View 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
View 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,
};

View 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)
}

View 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>>,
}

View 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
View 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
View 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"

View 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());
}
}

View 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
View 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};

View 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);
}
}

View 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"

View 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}"))
})?
}

View 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,
};

View 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());
}
}

View 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());
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,2 @@
[env]
LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"

37
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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())
}

View 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(())
}

View 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())
}

View File

@@ -0,0 +1,6 @@
pub mod audio;
pub mod clipboard;
pub mod hardware;
pub mod models;
pub mod transcription;
pub mod windows;

View 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())
}

View 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(())
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
>&#3559;</span>
</div>
{:else}
<!-- Collapsed: just the decorative character centred -->
<span
class="text-[18px] text-accent inline-block {page.recording ? 'animate-sinhala-spin' : ''}"
title="Kon"
>&#3559;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&#3559;</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>

View 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>

View 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&#10;Jake Sames&#10;Northampton&#10;..."
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&#10;Background&#10;Key Findings&#10;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>

View 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>

View 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">
&rarr; {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">&middot;</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>

View 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 {}
}

View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 += ` &middot; ${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;
}

View 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
View 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")}`;
}

Some files were not shown because too many files have changed in this diff Show More