Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "thoth-tauri",
"version": "2026.2.7",
"version": "2026.2.16",
"private": true,
"description": "Privacy-first, offline-capable voice transcription application",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "thoth"
version = "2026.2.7"
version = "2026.2.16"
description = "Privacy-first, offline-capable voice transcription application"
authors = ["Paul Roberts"]
license = "MIT"
Expand Down
39 changes: 39 additions & 0 deletions src-tauri/src/audio/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,50 @@ pub fn start_recording_metering(app: AppHandle, device_id: Option<&str>) -> Resu
// Spawn emitter thread to send levels to the recording-indicator window
let emit_stop_flag = stop_flag.clone();
let emit_handle = std::thread::spawn(move || {
// Track silence for no-input warning
// After NO_INPUT_GRACE_SECS seconds with RMS below threshold, emit warning
const NO_INPUT_GRACE_SECS: f64 = 3.0;
const NO_INPUT_RMS_THRESHOLD: f32 = 0.001; // effectively silence
let mut silence_since: Option<std::time::Instant> = None;
let mut no_input_warning_sent = false;
let start_time = std::time::Instant::now();

while !emit_stop_flag.load(Ordering::Relaxed) {
while let Ok(samples) = rx.try_recv() {
let mut meter = meter.lock();
let level = meter.process(&samples);

// Track silence window for no-input detection
// Only start counting after grace period from recording start
let elapsed_secs = start_time.elapsed().as_secs_f64();
if elapsed_secs >= NO_INPUT_GRACE_SECS {
if level.rms < NO_INPUT_RMS_THRESHOLD {
let now = std::time::Instant::now();
let silence_duration = silence_since
.get_or_insert(now)
.elapsed()
.as_secs_f64();
if silence_duration >= NO_INPUT_GRACE_SECS && !no_input_warning_sent {
// Emit no-input warning
let _ = app.emit("recording-no-input", true);
if let Some(w) = app.get_webview_window("recording-indicator") {
let _ = w.emit("recording-no-input", true);
}
no_input_warning_sent = true;
}
} else {
// Voice detected — clear silence tracker and reset warning
if no_input_warning_sent {
let _ = app.emit("recording-no-input", false);
if let Some(w) = app.get_webview_window("recording-indicator") {
let _ = w.emit("recording-no-input", false);
}
no_input_warning_sent = false;
}
silence_since = None;
}
}

let event = AudioLevelEvent {
rms: level.rms,
peak: level.peak,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ pub struct ShortcutConfig {
pub toggle_recording_alt: Option<String>,
/// Copy last transcription shortcut
pub copy_last: Option<String>,
/// Quick-add selected text to dictionary shortcut
#[serde(default)]
pub add_to_dictionary: Option<String>,
/// Recording mode: toggle or push-to-talk
pub recording_mode: RecordingMode,
}
Expand All @@ -132,6 +135,7 @@ impl Default for ShortcutConfig {
toggle_recording: "F13".to_string(),
toggle_recording_alt: Some("ShiftRight".to_string()),
copy_last: Some("F14".to_string()),
add_to_dictionary: None,
recording_mode: RecordingMode::default(),
}
}
Expand Down Expand Up @@ -196,6 +200,9 @@ pub struct GeneralConfig {
pub show_recording_indicator: bool,
/// Visual style for the recording indicator
pub indicator_style: IndicatorStyle,
/// Override the update check endpoint (None = use built-in default)
#[serde(default)]
pub update_endpoint_override: Option<String>,
}

impl Default for GeneralConfig {
Expand All @@ -207,6 +214,7 @@ impl Default for GeneralConfig {
check_for_updates: true,
show_recording_indicator: true,
indicator_style: IndicatorStyle::default(),
update_endpoint_override: None,
}
}
}
Expand Down Expand Up @@ -748,6 +756,7 @@ mod tests {
check_for_updates: true,
show_recording_indicator: true,
indicator_style: IndicatorStyle::CursorDot,
update_endpoint_override: None,
},
recorder: RecorderConfig {
position: RecorderPosition::Centre,
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ fn register_shortcuts_from_config(app: &tauri::AppHandle, cfg: &config::Config)
"Copy last transcription",
)
}),
cfg.shortcuts.add_to_dictionary.as_deref().map(|accel| {
(
shortcut_ids::ADD_TO_DICTIONARY,
accel,
"Quick-add selected word to dictionary",
)
}),
]
.into_iter()
.flatten()
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/shortcuts/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod shortcut_ids {
pub const TOGGLE_RECORDING: &str = "toggle_recording";
pub const TOGGLE_RECORDING_ALT: &str = "toggle_recording_alt";
pub const COPY_LAST_TRANSCRIPTION: &str = "copy_last_transcription";
pub const ADD_TO_DICTIONARY: &str = "add_to_dictionary";
}

/// Global shortcut manager instance
Expand Down Expand Up @@ -81,6 +82,12 @@ pub fn get_defaults() -> Vec<ShortcutInfo> {
description: "Toggle recording (alternative)".to_string(),
is_enabled: false,
},
ShortcutInfo {
id: shortcut_ids::ADD_TO_DICTIONARY.to_string(),
accelerator: String::new(), // No default — user sets their own key
description: "Quick-add selected text to dictionary".to_string(),
is_enabled: false,
},
]
}

Expand Down
15 changes: 10 additions & 5 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Thoth",
"version": "2026.2.7",
"identifier": "com.poodle64.thoth",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down Expand Up @@ -65,8 +64,10 @@
"requireLiteralLeadingDot": false
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEExOEY5RURGQUJBNkQzRjkKUldUNTA2YXIzNTZQb1lNSGQ3WVlkbDY3VVlUQlJYSzJzQW5ieUhhci9NVnVDUms5T0NJa21acnEK",
"endpoints": ["https://github.com/poodle64/thoth/releases/latest/download/latest.json"]
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU0NzMwMTlBQzJDQzEyOUMKUldTY0VzekNtZ0Z6VkxOQlZZSzlmczdBby92bnZnSmlVamRWODJEU3lvajJYcXJ3b1hJODRRUTQK",
"endpoints": [
"https://github.com/sk8ersquare/thoth/releases/latest/download/latest.json"
]
}
},
"bundle": {
Expand Down Expand Up @@ -106,11 +107,15 @@
"bundleMediaFramework": true
},
"deb": {
"depends": ["libasound2", "libwebkit2gtk-4.1-0", "libgtk-3-0"],
"depends": [
"libasound2",
"libwebkit2gtk-4.1-0",
"libgtk-3-0"
],
"section": "sound",
"priority": "optional",
"desktopTemplate": "linux/thoth.desktop"
}
}
}
}
}
23 changes: 20 additions & 3 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { onMount, onDestroy } from 'svelte';
import Settings from './lib/windows/Settings.svelte';
import DictionaryAddModal from './lib/components/DictionaryAddModal.svelte';
import { configStore } from './lib/stores/config.svelte';
import { pipelineStore } from './lib/stores/pipeline.svelte';
import { settingsStore } from './lib/stores/settings.svelte';
Expand All @@ -26,10 +27,15 @@
: () => {};

let indicatorLogUnlisten: UnlistenFn | null = null;
let dictAddUnlisten: UnlistenFn | null = null;

let isInitialising = $state(true);
let initError = $state<string | null>(null);

// Dictionary quick-add modal state
let dictModalOpen = $state(false);
let dictModalWord = $state('');

async function initialise() {
try {
debug('Starting initialisation...');
Expand Down Expand Up @@ -105,14 +111,19 @@
debug(event.payload.message);
});

// Listen for dictionary quick-add events (from shortcut handler in pipeline store)
dictAddUnlisten = await listen<{ word: string }>('show-dictionary-add', (event) => {
dictModalWord = event.payload.word ?? '';
dictModalOpen = true;
});

initialise();
});

onDestroy(() => {
pipelineStore.cleanup();
if (indicatorLogUnlisten) {
indicatorLogUnlisten();
}
if (indicatorLogUnlisten) indicatorLogUnlisten();
if (dictAddUnlisten) dictAddUnlisten();
});
</script>

Expand All @@ -130,6 +141,12 @@
<Settings />
{/if}

<DictionaryAddModal
open={dictModalOpen}
word={dictModalWord}
onclose={() => (dictModalOpen = false)}
/>

<style>
.loading-container {
display: flex;
Expand Down
13 changes: 8 additions & 5 deletions src/lib/components/AIEnhancementSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -199,27 +199,30 @@
}

/** Delete a custom prompt */
async function deletePrompt(promptId: string): Promise<void> {
async function deletePrompt(promptId: string, promptName: string): Promise<void> {
if (!confirm(`Delete prompt "${promptName}"?`)) return;
try {
await invoke('delete_custom_prompt_cmd', { prompt_id: promptId });
await invoke('delete_custom_prompt_cmd', { promptId: promptId });
await loadPrompts();

// If the deleted prompt was selected, reset to default
if (configStore.enhancement.promptId === promptId) {
if (configStore.config.enhancement.promptId === promptId) {
configStore.updateEnhancement('promptId', 'fix-grammar');
await saveSettings();
}

toastStore.success(`Deleted "${promptName}"`);
// Rebuild tray so the prompt submenu stays in sync
invoke('refresh_tray_menu').catch(() => {});
} catch (e) {
console.error('Failed to delete prompt:', e);
toastStore.error(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
}
}

/** Get the currently selected prompt */
function getSelectedPrompt(): PromptTemplate | undefined {
return prompts.find((p) => p.id === configStore.enhancement.promptId);
return prompts.find((p) => p.id === configStore.config.enhancement.promptId);
}

/** Open the prompt writing guide window */
Expand Down Expand Up @@ -439,7 +442,7 @@
</button>
<button
class="delete-btn-small"
onclick={() => deletePrompt(prompt.id)}
onclick={() => deletePrompt(prompt.id, prompt.name)}
title="Delete prompt"
>
Delete
Expand Down
Loading