'custom words' everywhere instead of 'correct words'

This commit is contained in:
CJ Pais 2025-08-11 15:19:48 -07:00
parent a252655b2f
commit c2b3815f56
10 changed files with 180 additions and 135 deletions

View file

@ -187,7 +187,7 @@ pub fn run() {
shortcut::change_selected_language_setting,
shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting,
shortcut::update_correct_words,
shortcut::update_custom_words,
shortcut::suspend_binding,
shortcut::resume_binding,
trigger_update_check,

View file

@ -27,22 +27,22 @@ pub struct TranscriptionManager {
current_model_id: Mutex<Option<String>>,
}
fn correct_words(text: &str, correct_words: &[String]) -> String {
if correct_words.is_empty() {
fn apply_custom_words(text: &str, custom_words: &[String]) -> String {
if custom_words.is_empty() {
return text.to_string();
}
// Pre-compute lowercase versions to avoid repeated allocations
let correct_words_lower: Vec<String> = correct_words.iter()
.map(|w| w.to_lowercase())
.collect();
let custom_words_lower: Vec<String> = custom_words.iter().map(|w| w.to_lowercase()).collect();
let words: Vec<&str> = text.split_whitespace().collect();
let mut corrected_words = Vec::new();
for word in words {
let cleaned_word = word.trim_matches(|c: char| !c.is_alphabetic()).to_lowercase();
let cleaned_word = word
.trim_matches(|c: char| !c.is_alphabetic())
.to_lowercase();
if cleaned_word.is_empty() {
corrected_words.push(word.to_string());
continue;
@ -57,31 +57,35 @@ fn correct_words(text: &str, correct_words: &[String]) -> String {
let mut best_match: Option<&String> = None;
let mut best_score = f64::MAX;
for (i, correct_word_lower) in correct_words_lower.iter().enumerate() {
for (i, custom_word_lower) in custom_words_lower.iter().enumerate() {
// Skip if lengths are too different (optimization)
let len_diff = (cleaned_word.len() as i32 - correct_word_lower.len() as i32).abs();
let len_diff = (cleaned_word.len() as i32 - custom_word_lower.len() as i32).abs();
if len_diff > 5 {
continue;
}
// Calculate Levenshtein distance (normalized by length)
let levenshtein_dist = levenshtein(&cleaned_word, correct_word_lower);
let max_len = cleaned_word.len().max(correct_word_lower.len()) as f64;
let levenshtein_score = if max_len > 0.0 { levenshtein_dist as f64 / max_len } else { 1.0 };
let levenshtein_dist = levenshtein(&cleaned_word, custom_word_lower);
let max_len = cleaned_word.len().max(custom_word_lower.len()) as f64;
let levenshtein_score = if max_len > 0.0 {
levenshtein_dist as f64 / max_len
} else {
1.0
};
// Calculate phonetic similarity using Soundex
let phonetic_match = soundex(&cleaned_word, correct_word_lower);
let phonetic_match = soundex(&cleaned_word, custom_word_lower);
// Combine scores: favor phonetic matches, but also consider string similarity
let combined_score = if phonetic_match {
levenshtein_score * 0.3 // Give significant boost to phonetic matches
levenshtein_score * 0.3 // Give significant boost to phonetic matches
} else {
levenshtein_score
};
// Accept if the score is good enough (threshold: 0.4 for good matches)
if combined_score < 0.4 && combined_score < best_score {
best_match = Some(&correct_words[i]);
best_match = Some(&custom_words[i]);
best_score = combined_score;
}
}
@ -99,15 +103,30 @@ fn correct_words(text: &str, correct_words: &[String]) -> String {
} else {
replacement.clone()
};
// Preserve punctuation from original word - optimized version
let prefix_end = word.chars().take_while(|c| !c.is_alphabetic()).count();
let suffix_start = word.char_indices().rev().take_while(|(_, c)| !c.is_alphabetic()).count();
let original_prefix = if prefix_end > 0 { &word[..prefix_end] } else { "" };
let original_suffix = if suffix_start > 0 { &word[word.len() - suffix_start..] } else { "" };
corrected_words.push(format!("{}{}{}", original_prefix, corrected, original_suffix));
let suffix_start = word
.char_indices()
.rev()
.take_while(|(_, c)| !c.is_alphabetic())
.count();
let original_prefix = if prefix_end > 0 {
&word[..prefix_end]
} else {
""
};
let original_suffix = if suffix_start > 0 {
&word[word.len() - suffix_start..]
} else {
""
};
corrected_words.push(format!(
"{}{}{}",
original_prefix, corrected, original_suffix
));
} else {
corrected_words.push(word.to_string());
}
@ -298,9 +317,9 @@ impl TranscriptionManager {
result.push_str(&segment);
}
// Apply word correction if correct words are configured
let corrected_result = if !settings.correct_words.is_empty() {
correct_words(&result, &settings.correct_words)
// Apply word correction if custom words are configured
let corrected_result = if !settings.custom_words.is_empty() {
apply_custom_words(&result, &settings.custom_words)
} else {
result
};

View file

@ -43,7 +43,7 @@ pub struct AppSettings {
#[serde(default = "default_debug_mode")]
pub debug_mode: bool,
#[serde(default)]
pub correct_words: Vec<String>,
pub custom_words: Vec<String>,
}
fn default_model() -> String {
@ -106,7 +106,7 @@ pub fn get_default_settings() -> AppSettings {
selected_language: "auto".to_string(),
overlay_position: OverlayPosition::Bottom,
debug_mode: false,
correct_words: Vec::new(),
custom_words: Vec::new(),
}
}

View file

@ -148,10 +148,10 @@ pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Resu
};
settings.overlay_position = parsed;
settings::write_settings(&app, settings);
// Update overlay position without recreating window
crate::utils::update_overlay_position(&app);
Ok(())
}
@ -164,9 +164,9 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
}
#[tauri::command]
pub fn update_correct_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {
pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.correct_words = words;
settings.custom_words = words;
settings::write_settings(&app, settings);
Ok(())
}

View file

@ -1,91 +0,0 @@
import React, { useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { SettingContainer } from "../ui/SettingContainer";
interface CorrectWordsProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const CorrectWords: React.FC<CorrectWordsProps> = React.memo(({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const [newWord, setNewWord] = useState("");
const correctWords = getSetting("correct_words") || [];
const handleAddWord = () => {
const trimmedWord = newWord.trim();
const sanitizedWord = trimmedWord.replace(/[<>"'&]/g, '');
if (sanitizedWord && !sanitizedWord.includes(' ') && sanitizedWord.length <= 50 && !correctWords.includes(sanitizedWord)) {
updateSetting("correct_words", [...correctWords, sanitizedWord]);
setNewWord("");
}
};
const handleRemoveWord = (wordToRemove: string) => {
updateSetting("correct_words", correctWords.filter((word) => word !== wordToRemove));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddWord();
}
};
return (
<>
<SettingContainer
title="Custom Words"
description="Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list."
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center gap-2">
<Input
type="text"
className="max-w-[128px]"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Add a word"
variant="compact"
disabled={isUpdating("correct_words")}
/>
<Button
onClick={handleAddWord}
disabled={!newWord.trim() || newWord.includes(' ') || newWord.trim().length > 50 || isUpdating("correct_words")}
variant="primary"
size="md"
>
Add
</Button>
</div>
</SettingContainer>
{correctWords.length > 0 && (
<div className={`px-4 p-2 ${grouped ? "" : "rounded-lg border border-mid-gray/20"} flex flex-wrap gap-1`}>
{correctWords.map((word) => (
<Button
key={word}
onClick={() => handleRemoveWord(word)}
disabled={isUpdating("correct_words")}
variant="secondary"
size="sm"
className="inline-flex items-center gap-1 cursor-pointer"
aria-label={`Remove ${word}`}
>
<span>{word}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
))}
</div>
)}
</>
);
});

View file

@ -0,0 +1,115 @@
import React, { useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { SettingContainer } from "../ui/SettingContainer";
interface CustomWordsProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const CustomWords: React.FC<CustomWordsProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const [newWord, setNewWord] = useState("");
const customWords = getSetting("custom_words") || [];
const handleAddWord = () => {
const trimmedWord = newWord.trim();
const sanitizedWord = trimmedWord.replace(/[<>"'&]/g, "");
if (
sanitizedWord &&
!sanitizedWord.includes(" ") &&
sanitizedWord.length <= 50 &&
!customWords.includes(sanitizedWord)
) {
updateSetting("custom_words", [...customWords, sanitizedWord]);
setNewWord("");
}
};
const handleRemoveWord = (wordToRemove: string) => {
updateSetting(
"custom_words",
customWords.filter((word) => word !== wordToRemove),
);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddWord();
}
};
return (
<>
<SettingContainer
title="Custom Words"
description="Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list."
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center gap-2">
<Input
type="text"
className="max-w-[128px]"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Add a word"
variant="compact"
disabled={isUpdating("custom_words")}
/>
<Button
onClick={handleAddWord}
disabled={
!newWord.trim() ||
newWord.includes(" ") ||
newWord.trim().length > 50 ||
isUpdating("custom_words")
}
variant="primary"
size="md"
>
Add
</Button>
</div>
</SettingContainer>
{customWords.length > 0 && (
<div
className={`px-4 p-2 ${grouped ? "" : "rounded-lg border border-mid-gray/20"} flex flex-wrap gap-1`}
>
{customWords.map((word) => (
<Button
key={word}
onClick={() => handleRemoveWord(word)}
disabled={isUpdating("custom_words")}
variant="secondary"
size="sm"
className="inline-flex items-center gap-1 cursor-pointer"
aria-label={`Remove ${word}`}
>
<span>{word}</span>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
))}
</div>
)}
</>
);
},
);

View file

@ -8,7 +8,7 @@ import { ShowOverlay } from "./ShowOverlay";
import { HandyShortcut } from "./HandyShortcut";
import { TranslateToEnglish } from "./TranslateToEnglish";
import { LanguageSelector } from "./LanguageSelector";
import { CorrectWords } from "./CorrectWords";
import { CustomWords } from "./CustomWords";
import { SettingsGroup } from "../ui/SettingsGroup";
import { DebugSettings } from "./debug";
import { useSettings } from "../../hooks/useSettings";
@ -55,7 +55,7 @@ export const Settings: React.FC = () => {
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<CorrectWords descriptionMode="tooltip" grouped={true} />
<CustomWords descriptionMode="tooltip" grouped />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>

View file

@ -7,4 +7,4 @@ export { AudioFeedback } from "./AudioFeedback";
export { ShowOverlay } from "./ShowOverlay";
export { HandyShortcut } from "./HandyShortcut";
export { TranslateToEnglish } from "./TranslateToEnglish";
export { CorrectWords } from "./CorrectWords";
export { CustomWords } from "./CustomWords";

View file

@ -200,13 +200,15 @@ export const useSettings = (): UseSettingsReturn => {
});
break;
case "overlay_position":
await invoke("change_overlay_position_setting", { position: value });
await invoke("change_overlay_position_setting", {
position: value,
});
break;
case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value });
break;
case "correct_words":
await invoke("update_correct_words", { words: value });
case "custom_words":
await invoke("update_custom_words", { words: value });
break;
case "bindings":
// Handle bindings separately - they use their own invoke methods
@ -254,7 +256,7 @@ export const useSettings = (): UseSettingsReturn => {
selected_language: "auto",
overlay_position: "bottom",
debug_mode: false,
correct_words: [],
custom_words: [],
};
const defaultValue = defaults[key];

View file

@ -34,7 +34,7 @@ export const SettingsSchema = z.object({
selected_language: z.string(),
overlay_position: OverlayPositionSchema,
debug_mode: z.boolean(),
correct_words: z.array(z.string()).optional().default([]),
custom_words: z.array(z.string()).optional().default([]),
});
export const BindingResponseSchema = z.object({