diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 63288e8..39fc805 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -25,3 +25,6 @@ symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "pcm", "vorbis" # Async runtime for threading tokio = { version = "1", features = ["rt", "sync"] } + +# Serde for DeviceInfo (returned across the Tauri boundary) +serde = { version = "1", features = ["derive"] } diff --git a/crates/audio/src/capture.rs b/crates/audio/src/capture.rs index 94abcc8..a7ab53b 100644 --- a/crates/audio/src/capture.rs +++ b/crates/audio/src/capture.rs @@ -1,9 +1,25 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; +use cpal::{FromSample, Sample, SampleFormat, SizedSample}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use serde::{Deserialize, Serialize}; use kon_core::error::{KonError, Result}; +const AUDIO_CHANNEL_CAPACITY: usize = 32; + +/// Validation window. We listen for this long and compute RMS to decide +/// whether the chosen device is delivering real audio (vs a silent monitor). +const DEVICE_VALIDATION_MS: u64 = 350; + +/// Below this RMS amplitude (peak ±1.0 scale) the input is treated as +/// silence. PulseAudio/PipeWire monitor sources for an idle speaker +/// typically deliver dead-zero samples; real microphones yield ~0.0005+ +/// even in a quiet room. Conservative floor: 1e-5. +const SILENCE_RMS_FLOOR: f32 = 1e-5; + /// A chunk of captured audio from the microphone. pub struct AudioChunk { pub samples: Vec, @@ -11,53 +27,180 @@ pub struct AudioChunk { pub channels: u16, } +/// Public-facing description of an audio input device. +/// Returned by `list_devices()` and used by the UI device picker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceInfo { + /// Device name as reported by cpal/the host. + pub name: String, + /// Default sample rate in Hz. + pub sample_rate: u32, + /// Default channel count. + pub channels: u16, + /// True if the device name matches a known monitor-source pattern + /// (PulseAudio/PipeWire loopback of speaker output). + pub is_likely_monitor: bool, + /// True if cpal reports this as the host's default input device. + pub is_default: bool, +} + /// 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, + /// Name of the device that is actually capturing. + pub device_name: String, + /// Counter incremented every time the capture callback drops a chunk + /// because the channel was full. Read via `dropped_chunks()`. + dropped_chunks: Arc, } impl MicrophoneCapture { - /// Start capturing audio from the default input device. - /// Returns a receiver that yields AudioChunks as they arrive. + /// Number of audio chunks dropped because the downstream channel was full + /// since this capture started. Should stay at 0 in normal use; non-zero + /// indicates downstream backpressure or a stuck consumer. + pub fn dropped_chunks(&self) -> u64 { + self.dropped_chunks.load(Ordering::Relaxed) + } + + /// Enumerate every input device the host knows about, with the metadata + /// needed by the device-picker UI. + pub fn list_devices() -> Result> { + let host = cpal::default_host(); + let default_name = host + .default_input_device() + .and_then(|d| d.name().ok()) + .unwrap_or_default(); + + let devices = host + .input_devices() + .map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?; + + let mut out = Vec::new(); + for device in devices { + let name = device.name().unwrap_or_else(|_| "".to_string()); + let (sample_rate, channels) = match device.default_input_config() { + Ok(cfg) => (cfg.sample_rate(), cfg.channels() as u16), + Err(_) => (0, 0), + }; + let is_likely_monitor = is_monitor_name(&name); + let is_default = !default_name.is_empty() && name == default_name; + out.push(DeviceInfo { + name, + sample_rate, + channels, + is_likely_monitor, + is_default, + }); + } + Ok(out) + } + + /// Start capturing from the device whose name matches `device_name` exactly. + /// If no match is found, returns an error rather than silently falling back. + pub fn start_with_device( + device_name: &str, + ) -> Result<(Self, mpsc::Receiver)> { + let host = cpal::default_host(); + let devices = host + .input_devices() + .map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?; + + for device in devices { + let name = device.name().unwrap_or_default(); + if name == device_name { + eprintln!("[kon-audio] start_with_device: opening explicit device '{name}'"); + return open_and_validate(device, &name, /* require_audio = */ true); + } + } + + Err(KonError::AudioCaptureFailed(format!( + "Selected device '{device_name}' not found in current host enumeration. \ + It may have been disconnected. Open Settings → Audio to pick another." + ))) + } + + /// Start capturing audio with auto-selection. + /// + /// Selection rules: + /// 1. Try the host default input device first if it exists AND is not a monitor source. + /// 2. Otherwise, try non-monitor devices in enumeration order. + /// 3. Validate the chosen device by RMS energy (not just receipt of bytes) over + /// a short window — this is what defeats the "silent monitor source wins" bug. + /// 4. If no non-monitor device produces real audio, fall back to monitor sources + /// as a last resort (with a clear log line). Never accept dead silence. pub fn start() -> Result<(Self, mpsc::Receiver)> { let host = cpal::default_host(); - let device = host.default_input_device().ok_or_else(|| { - KonError::AudioCaptureFailed("No input device found".into()) - })?; + let default_name = host + .default_input_device() + .and_then(|d| d.name().ok()) + .unwrap_or_default(); - let config = device.default_input_config().map_err(|e| { - KonError::AudioCaptureFailed(format!("No input config: {e}")) - })?; + let mut all_devices: Vec = + host.input_devices() + .map_err(|e| { + KonError::AudioCaptureFailed(format!("input_devices: {e}")) + })? + .collect(); - let sample_rate = config.sample_rate(); - let channels = config.channels() as u16; + // Sort: default first, then non-monitor, then monitor-as-last-resort. + all_devices.sort_by_key(|d| { + let n = d.name().unwrap_or_default(); + let is_default = !default_name.is_empty() && n == default_name; + let is_monitor = is_monitor_name(&n); + // Smaller key = tried first. + match (is_default, is_monitor) { + (true, false) => 0, // default, real input + (false, false) => 1, // any other real input + (true, true) => 2, // default but is a monitor (very rare) + (false, true) => 3, // monitor source — last resort + } + }); - let (tx, rx) = mpsc::channel::(); + eprintln!( + "[kon-audio] start: enumerated {} input device(s) (default='{}')", + all_devices.len(), + default_name + ); - 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}")) - })?; + // First pass: require real audio energy. + for device in &all_devices { + let name = device.name().unwrap_or_default(); + if is_monitor_name(&name) { + continue; // Save monitor sources for second pass. + } + match open_and_validate(device.clone(), &name, true) { + Ok(result) => return Ok(result), + Err(e) => { + eprintln!("[kon-audio] '{name}' rejected: {e}"); + } + } + } - stream.play().map_err(|e| { - KonError::AudioCaptureFailed(format!("Stream play failed: {e}")) - })?; + // Second pass: accept anything that delivers bytes (monitor sources + // included). Better to capture from a monitor than fail entirely. + eprintln!( + "[kon-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources" + ); + for device in &all_devices { + let name = device.name().unwrap_or_default(); + match open_and_validate(device.clone(), &name, false) { + Ok(result) => { + eprintln!( + "[kon-audio] FALLBACK: capturing from '{name}' (likely monitor source). \ + Recordings may be silent or contain system audio." + ); + return Ok(result); + } + Err(_) => continue, + } + } - Ok((Self { stream: Some(stream) }, rx)) + Err(KonError::AudioCaptureFailed( + "No working microphone found. Check that an input device is connected, \ + that PulseAudio/PipeWire is running, and that the app has microphone permission. \ + Then open Settings → Audio to pick a device explicitly." + .into(), + )) } /// Stop capturing audio. @@ -73,3 +216,164 @@ impl Drop for MicrophoneCapture { self.stop(); } } + +/// Heuristic: identify a PulseAudio/PipeWire monitor source by name. +/// Common patterns: +/// - ".monitor" suffix (PulseAudio convention) +/// - "Monitor of " prefix (longer human-readable name) +/// - "Loopback" anywhere (some PipeWire configurations) +fn is_monitor_name(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.ends_with(".monitor") + || lower.starts_with("monitor of ") + || lower.contains("monitor of ") + || lower.contains("loopback") +} + +/// Open the given device and validate it produces non-silent audio. +/// If `require_audio` is false, accept any data (used for monitor fallback). +fn open_and_validate( + device: cpal::Device, + name: &str, + require_audio: bool, +) -> Result<(MicrophoneCapture, mpsc::Receiver)> { + let config = device.default_input_config().map_err(|e| { + KonError::AudioCaptureFailed(format!("default_input_config: {e}")) + })?; + let sample_rate = config.sample_rate(); + let channels = config.channels() as u16; + let format = config.sample_format(); + + eprintln!( + "[kon-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})", + sr = sample_rate, + ch = channels, + fmt = format + ); + + let (tx, rx) = mpsc::sync_channel::(AUDIO_CHANNEL_CAPACITY); + let requeue_tx = tx.clone(); + let dropped_chunks = Arc::new(AtomicU64::new(0)); + + let stream = match format { + SampleFormat::F32 => build_input_stream::(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), + SampleFormat::I16 => build_input_stream::(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), + SampleFormat::U16 => build_input_stream::(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), + other => { + return Err(KonError::AudioCaptureFailed(format!( + "unsupported sample format {other:?}" + ))) + } + } + .map_err(|e| KonError::AudioCaptureFailed(format!("build_input_stream: {e}")))?; + + stream + .play() + .map_err(|e| KonError::AudioCaptureFailed(format!("stream.play: {e}")))?; + + // Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS. + let deadline = std::time::Instant::now() + + std::time::Duration::from_millis(DEVICE_VALIDATION_MS); + let mut collected: Vec = Vec::new(); + let mut total_samples = 0_usize; + let mut sum_sq: f64 = 0.0; + + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining) { + Ok(chunk) => { + for &s in &chunk.samples { + sum_sq += (s as f64) * (s as f64); + } + total_samples += chunk.samples.len(); + collected.push(chunk); + } + Err(_) => break, + } + } + + if total_samples == 0 { + return Err(KonError::AudioCaptureFailed( + "device delivered zero samples in validation window".into(), + )); + } + + let rms = (sum_sq / total_samples as f64).sqrt() as f32; + eprintln!( + "[kon-audio] '{name}' validation: {samples} samples, rms={rms:.6}", + samples = total_samples + ); + + if require_audio && rms < SILENCE_RMS_FLOOR { + return Err(KonError::AudioCaptureFailed(format!( + "device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})" + ))); + } + + // Re-queue the collected chunks so downstream gets them. + for chunk in collected { + let _ = requeue_tx.try_send(chunk); + } + + eprintln!("[kon-audio] selected microphone: '{name}'"); + Ok(( + MicrophoneCapture { + stream: Some(stream), + device_name: name.to_string(), + dropped_chunks, + }, + rx, + )) +} + +fn build_input_stream( + device: &cpal::Device, + supported_config: &cpal::SupportedStreamConfig, + sample_rate: u32, + channels: u16, + tx: mpsc::SyncSender, + dropped_chunks: Arc, +) -> std::result::Result +where + T: Sample + SizedSample, + f32: FromSample, +{ + let config: cpal::StreamConfig = supported_config.clone().into(); + device.build_input_stream( + &config, + move |data: &[T], _| { + let samples: Vec = data.iter().copied().map(f32::from_sample).collect(); + let chunk = AudioChunk { + samples, + sample_rate, + channels, + }; + // try_send fails if the channel is full. Track that explicitly + // rather than swallowing it — Codex review 2026/04/17 caught + // this as a silent-failure risk under sustained load. + if tx.try_send(chunk).is_err() { + dropped_chunks.fetch_add(1, Ordering::Relaxed); + } + }, + |err| eprintln!("[kon-audio] capture error: {err}"), + None, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_pattern_detection() { + assert!(is_monitor_name("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor")); + assert!(is_monitor_name("Monitor of Built-in Audio Analog Stereo")); + assert!(is_monitor_name("Some Loopback Device")); + assert!(!is_monitor_name("Blue Yeti USB")); + assert!(!is_monitor_name("alsa_input.pci-0000_00_1f.3.analog-stereo")); + assert!(!is_monitor_name("")); + } +} diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 385d7b4..30795b4 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -2,12 +2,14 @@ pub mod capture; pub mod concurrency; pub mod decode; pub mod resample; +pub mod streaming_resample; pub mod vad; pub mod wav; -pub use capture::{AudioChunk, MicrophoneCapture}; +pub use capture::{AudioChunk, DeviceInfo, MicrophoneCapture}; pub use concurrency::decode_and_resample; pub use decode::decode_audio_file; pub use resample::resample_to_16khz; +pub use streaming_resample::StreamingResampler; pub use vad::SpeechDetector; pub use wav::{read_wav, write_wav}; diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 51d72d6..ff13c29 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,13 +1,183 @@ use std::path::PathBuf; +use std::sync::{Arc, Mutex}; -use tauri::Manager; +use tauri::{Emitter, Manager}; +use tokio::sync::mpsc as tokio_mpsc; +use kon_audio::{DeviceInfo, MicrophoneCapture}; +use kon_core::constants::WHISPER_SAMPLE_RATE; use kon_core::types::AudioSamples; -/// Save PCM f32 samples as a WAV file. Returns the file path. +/// Enumerate every input device available to cpal, with metadata for the +/// Settings device-picker UI. Includes a flag for likely PulseAudio / +/// PipeWire monitor sources so the UI can warn the user. #[tauri::command] -pub async fn save_audio( +pub async fn list_audio_devices() -> Result, String> { + tokio::task::spawn_blocking(MicrophoneCapture::list_devices) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| e.to_string()) +} + +/// Shared state for native microphone capture. +pub struct NativeCaptureState { + /// Stop signal sender — dropping this stops the accumulator task. + stop_tx: Mutex>>, + /// All captured samples (16kHz mono) for save_audio. + all_samples: Arc>>, +} + +impl NativeCaptureState { + pub fn new() -> Self { + Self { + stop_tx: Mutex::new(None), + all_samples: Arc::new(Mutex::new(Vec::new())), + } + } +} + +/// Start native microphone capture via cpal. +/// Streams 16kHz mono PCM chunks to the frontend via `native-pcm` events. +#[tauri::command] +pub async fn start_native_capture( app: tauri::AppHandle, + state: tauri::State<'_, NativeCaptureState>, +) -> Result<(), String> { + eprintln!("[native-capture] start_native_capture called"); + + // Stop any existing capture + if let Some(tx) = state.stop_tx.lock().unwrap().take() { + drop(tx); + } + + let (capture, rx) = MicrophoneCapture::start().map_err(|e| { + eprintln!("[native-capture] MicrophoneCapture::start failed: {e}"); + e.to_string() + })?; + eprintln!("[native-capture] cpal capture started successfully"); + + // Wrap capture in Arc so it can be moved into the blocking task + let capture = Arc::new(Mutex::new(Some(capture))); + let capture_clone = capture.clone(); + + let all_samples = state.all_samples.clone(); + all_samples.lock().unwrap().clear(); + + let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1); + *state.stop_tx.lock().unwrap() = Some(stop_tx); + + let all_samples_clone = all_samples.clone(); + + // Spawn a task that reads cpal chunks, downsamples to 16kHz mono, + // and emits events to the frontend + tokio::spawn(async move { + let mut pcm_buffer: Vec = Vec::new(); + let chunk_size = 8000_usize; // ~0.5s at 16kHz + + loop { + // Check for stop signal (non-blocking) + if stop_rx.try_recv().is_ok() { + break; + } + + // Drain available audio chunks from cpal (non-blocking) + let mut got_data = false; + while let Ok(chunk) = rx.try_recv() { + got_data = true; + let sample_rate = chunk.sample_rate; + let channels = chunk.channels as usize; + + // Downmix to mono if stereo + let mono: Vec = if channels > 1 { + chunk + .samples + .chunks(channels) + .map(|frame| frame.iter().sum::() / channels as f32) + .collect() + } else { + chunk.samples + }; + + // Downsample to 16kHz using simple decimation + // (acceptable quality for speech — same approach as pcm-processor.js) + let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64; + if (ratio - 1.0).abs() < 0.01 { + pcm_buffer.extend_from_slice(&mono); + } else { + let mut pos: f64 = 0.0; + for &s in &mono { + pos += 1.0; + if pos >= ratio { + pcm_buffer.push(s); + pos -= ratio; + } + } + } + } + + // Emit chunks to frontend when we have enough + while pcm_buffer.len() >= chunk_size { + let chunk: Vec = pcm_buffer.drain(..chunk_size).collect(); + + // Store for save_audio + if let Ok(mut all) = all_samples_clone.lock() { + all.extend_from_slice(&chunk); + } + + let _ = app.emit("native-pcm", serde_json::json!({ + "samples": chunk, + })); + } + + if !got_data { + // Avoid busy-spinning when no audio data is available + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + // Emit any remaining samples + if !pcm_buffer.is_empty() { + if let Ok(mut all) = all_samples_clone.lock() { + all.extend_from_slice(&pcm_buffer); + } + let _ = app.emit("native-pcm", serde_json::json!({ + "samples": pcm_buffer, + })); + } + + // Drop the capture to stop the cpal stream + if let Ok(mut cap) = capture_clone.lock() { + cap.take(); + } + }); + + Ok(()) +} + +/// Stop native microphone capture. Returns all captured samples (16kHz mono). +#[tauri::command] +pub async fn stop_native_capture( + state: tauri::State<'_, NativeCaptureState>, +) -> Result, String> { + // Extract the stop sender without holding the guard across an await + let stop_tx = state.stop_tx.lock().unwrap().take(); + if let Some(tx) = stop_tx { + let _ = tx.send(()).await; + } + + // Brief delay to let the accumulator flush + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let samples = { + let mut all = state.all_samples.lock().unwrap(); + std::mem::take(&mut *all) + }; + + Ok(samples) +} + +pub async fn persist_audio_samples( + app: &tauri::AppHandle, samples: Vec, output_folder: Option, ) -> Result { @@ -47,3 +217,13 @@ pub async fn save_audio( Ok(path.to_string_lossy().to_string()) } + +/// Save PCM f32 samples as a WAV file. Returns the file path. +#[tauri::command] +pub async fn save_audio( + app: tauri::AppHandle, + samples: Vec, + output_folder: Option, +) -> Result { + persist_audio_samples(&app, samples, output_folder).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63feb2f..902ccc2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod tray; use std::sync::Arc; +use std::time::Instant; use sqlx::SqlitePool; use tauri::Manager; @@ -47,6 +48,7 @@ fn build_preferences_script(prefs_json: Option) -> String { if (a.fontSize) el.style.setProperty('--font-size-body', a.fontSize + 'px'); if (a.letterSpacing != null) el.style.setProperty('--letter-spacing-body', a.letterSpacing + 'em'); if (a.lineHeight) el.style.setProperty('--line-height-body', String(a.lineHeight)); + if (a.transcriptSize) el.style.setProperty('--text-transcript', a.transcriptSize + 'px'); if (a.bionicReading) el.dataset.bionicReading = 'true'; if (a.reduceMotion === 'on' || (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) el.dataset.reduceMotion = 'true'; }} @@ -75,15 +77,19 @@ pub fn run() { .setup(|app| { // Initialise database (blocking in setup — runs once at startup) let db_path = database_path(); + let t0 = Instant::now(); let db = tauri::async_runtime::block_on(async { init_db(&db_path).await }) .map_err(|e| Box::new(e) as Box)?; + eprintln!("[startup] DB init: {:?}", t0.elapsed()); // Load saved preferences for webview injection + let t1 = Instant::now(); let prefs_json = tauri::async_runtime::block_on(async { get_setting(&db, "kon_preferences").await.unwrap_or(None) }); + eprintln!("[startup] Preferences load: {:?}", t1.elapsed()); let init_script = build_preferences_script(prefs_json); // Apply preferences to the main window (defined in tauri.conf.json) @@ -136,6 +142,8 @@ pub fn run() { app.manage(PreferencesScript(init_script)); app.manage(commands::hotkey::HotkeyState::new()); + app.manage(commands::audio::NativeCaptureState::new()); + app.manage(commands::live::LiveTranscriptionState::default()); app.manage(AppState { whisper_engine: Arc::new(LocalEngine::new( @@ -162,6 +170,7 @@ pub fn run() { commands::models::list_models, commands::models::load_model, commands::models::check_engine, + commands::models::get_runtime_capabilities, // Parakeet model management commands::models::download_parakeet_model, commands::models::check_parakeet_model, @@ -174,6 +183,11 @@ pub fn run() { commands::transcription::transcribe_pcm_parakeet, // Audio commands::audio::save_audio, + commands::audio::start_native_capture, + commands::audio::stop_native_capture, + commands::audio::list_audio_devices, + commands::live::start_live_transcription_session, + commands::live::stop_live_transcription_session, // Windows commands::windows::open_task_window, commands::windows::open_viewer_window, diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 02ba6d0..308e858 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -10,21 +10,39 @@ import ZonePicker from "$lib/components/ZonePicker.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { clampTextLines } from "$lib/utils/textMeasure.js"; + import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { Check, ChevronRight } from "lucide-svelte"; - import { open } from "@tauri-apps/plugin-dialog"; const prefs = getPreferences(); let engineStatus = $state("Checking..."); let engineOk = $state(false); let downloadedModels = $state([]); + let runtimeCapabilities = $state(null); let downloadingModel = $state(""); let downloadProgress = $state(0); let unlisten = null; + let outputFolderEl = $state(null); + let outputFolderWidth = $state(0); // Parakeet state let parakeetStatus = $state("Checking..."); let parakeetOk = $state(false); + + // Audio device picker (Settings → Audio → Microphone) + let audioDevices = $state([]); + let audioDevicesError = $state(null); + + async function refreshAudioDevices() { + audioDevicesError = null; + try { + audioDevices = await invoke("list_audio_devices"); + } catch (err) { + audioDevicesError = "Could not enumerate audio devices: " + (err?.message || err); + audioDevices = []; + } + } let parakeetDownloaded = $state(false); let parakeetDownloading = $state(false); let parakeetProgress = $state(0); @@ -42,9 +60,66 @@ // Accordion state — first section open by default let openSection = $state('transcription'); + let settingsTextFont = $derived(pretextFontShorthand(prefs.accessibility, 11)); + let settingsPathLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 11)); + let outputFolderPreview = $derived.by(() => { + const text = settings.outputFolder || "Default (app data)"; + if (!text) return ""; + if (outputFolderWidth <= 0) { + return text.length > 80 ? `${text.slice(0, 79)}…` : text; + } + return clampTextLines( + text, + settingsTextFont, + Math.max(120, outputFolderWidth - 24), + settingsPathLineHeight, + 2, + ).text; + }); + + function whisperModelId(size) { + const map = { + Tiny: "whisper-tiny-en", + Base: "whisper-base-en", + Small: "whisper-small-en", + Medium: "whisper-medium-en", + }; + return map[size] || "whisper-base-en"; + } + + function selectedModelId() { + return settings.engine === "parakeet" + ? "parakeet-ctc-0.6b-int8" + : whisperModelId(settings.modelSize); + } + + function engineCapabilities(engineId) { + return runtimeCapabilities?.engines?.find((engine) => engine.id === engineId) || null; + } + + function selectedModelCapabilities() { + return engineCapabilities(settings.engine)?.models?.find((model) => model.id === selectedModelId()) || null; + } + + function whisperModelCapabilities(size) { + return engineCapabilities("whisper")?.models?.find((model) => model.id === whisperModelId(size)) || null; + } + + function currentModelIsEnglishOnly() { + return selectedModelCapabilities()?.languageSupport?.kind === "english-only"; + } + + function hasGpuAcceleration() { + return (runtimeCapabilities?.accelerators || []).some((accelerator) => accelerator !== "cpu"); + } + + async function refreshRuntimeCapabilities() { + runtimeCapabilities = await invoke("get_runtime_capabilities"); + } onMount(async () => { try { + await refreshRuntimeCapabilities(); const loaded = await invoke("check_engine"); engineOk = loaded; engineStatus = loaded ? "Model loaded" : "No model loaded"; @@ -52,6 +127,10 @@ engineStatus = "Engine not ready"; } + // Populate audio device list so the Microphone picker is ready when + // the user opens the Audio section. + refreshAudioDevices(); + try { downloadedModels = await invoke("list_models"); } catch {} @@ -79,23 +158,22 @@ 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; + if (!outputFolderEl) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + outputFolderWidth = entry.contentRect.width; + } + }); + ro.observe(outputFolderEl); + return () => ro.disconnect(); + }); + + // Auto-save on any settings change. + // JSON.stringify subscribes to all properties on the reactive object. + // Safe because `settings` only holds persistent preferences — no ephemeral state. + $effect(() => { + void JSON.stringify(settings); saveSettings(); }); @@ -108,12 +186,24 @@ } }); + $effect(() => { + settings.engine; + settings.modelSize; + if (currentModelIsEnglishOnly() && settings.language !== "en") { + settings.language = "en"; + } + if (!hasGpuAcceleration() && settings.device !== "auto") { + settings.device = "auto"; + } + }); + async function downloadModel(size) { downloadingModel = size; downloadProgress = 0; try { await invoke("download_model", { size }); downloadedModels = await invoke("list_models"); + await refreshRuntimeCapabilities(); downloadingModel = ""; } catch (err) { downloadingModel = ""; @@ -127,6 +217,7 @@ engineOk = false; try { await invoke("load_model", { size }); + await refreshRuntimeCapabilities(); engineOk = true; engineStatus = `${settings.modelSize} model loaded`; } catch (err) { @@ -136,7 +227,21 @@ } function isModelDownloaded(size) { - return downloadedModels.includes(size.toLowerCase()); + const runtimeModel = whisperModelCapabilities(size); + if (runtimeModel) { + return runtimeModel.downloaded; + } + return downloadedModels.some((model) => model.toLowerCase() === size.toLowerCase()); + } + + function isModelLoaded(size) { + const runtimeModel = whisperModelCapabilities(size); + if (runtimeModel) { + return runtimeModel.loaded; + } + return runtimeCapabilities?.engines?.some( + (engine) => engine.id === "whisper" && engine.loadedModelId === whisperModelId(size), + ) || false; } const modelDescriptions = { @@ -151,6 +256,7 @@ parakeetProgress = 0; try { await invoke("download_parakeet_model", { name: "ctc-int8" }); + await refreshRuntimeCapabilities(); parakeetDownloaded = true; parakeetDownloading = false; parakeetStatus = "Downloaded (not loaded)"; @@ -165,6 +271,7 @@ parakeetOk = false; try { await invoke("load_parakeet_model", { name: "ctc-int8" }); + await refreshRuntimeCapabilities(); parakeetOk = true; parakeetStatus = "Model loaded"; } catch (err) { @@ -223,6 +330,56 @@
+ +
+ + {#if openSection === 'audio'} +
+
+

Microphone

+
+ + +
+ {#if audioDevicesError} +

{audioDevicesError}

+ {:else if audioDevices.length === 0} +

No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.

+ {:else} +

+ Auto mode tries the system default first, then any other real input. Speaker-monitor sources (loopback) are skipped because they record system audio rather than the microphone. If dictation is silent, pick the device explicitly here. +

+ {/if} +
+
+ {/if} +
+
@@ -264,7 +422,12 @@

{modelDescriptions[settings.modelSize]}

- {#if isModelDownloaded(settings.modelSize)} + {#if isModelLoaded(settings.modelSize)} + + + Model loaded + + {:else if isModelDownloaded(settings.modelSize)} Downloaded @@ -318,42 +481,68 @@

Compute Device

- -

GPU acceleration requires building with CUDA feature

+ {#if hasGpuAcceleration()} + +

Only accelerators built into this binary are shown here.

+ {:else} +
+ This build is CPU-only. GPU controls appear in GPU-enabled builds. +
+ {/if}

Language

- + {#if currentModelIsEnglishOnly()} + + {:else} + + {/if} {#if settings.language === "en"}
{/if} @@ -466,10 +662,10 @@
{#if editingProfile === i}
-