'custom words' everywhere instead of 'correct words'
This commit is contained in:
parent
a252655b2f
commit
c2b3815f56
10 changed files with 180 additions and 135 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
115
src/components/settings/CustomWords.tsx
Normal file
115
src/components/settings/CustomWords.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue