Skip to content

Commit e04b119

Browse files
committed
fix: harden voice downloads and dictation
1 parent bd1c4bb commit e04b119

11 files changed

Lines changed: 665 additions & 98 deletions

File tree

‎src-tauri/Cargo.lock‎

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/src/voice.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,14 @@ pub async fn download_voice_asset(
6565
app: tauri::AppHandle,
6666
state: tauri::State<'_, Arc<VoiceRuntime>>,
6767
kind: String,
68+
github_mirror: Option<String>,
6869
) -> Result<VoiceRuntimeStatus, String> {
6970
let kind = VoiceAssetKind::parse(&kind)?;
7071
let runtime = state.inner().clone();
7172
tauri::async_runtime::spawn_blocking(move || {
7273
runtime
7374
.assets
74-
.install(kind, |progress: VoiceAssetProgress| {
75+
.install(kind, github_mirror.as_deref(), |progress: VoiceAssetProgress| {
7576
let _ = app.emit("voice-asset-progress", progress);
7677
})?;
7778
Ok::<VoiceRuntimeStatus, String>(status(&runtime))

‎src/components/settings/VoiceInputSettings.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ export function VoiceInputSettings() {
143143
setBusy('transcribing')
144144
try {
145145
const result = await stopDictation()
146-
setTranscript(result.trim() || text.empty)
147-
const next = await markDictationTestPassed()
148-
setStatus(next)
149-
publishVoiceInputStatus(next)
146+
const cleaned = result.trim()
147+
setTranscript(cleaned || text.empty)
148+
if (cleaned) {
149+
const next = await markDictationTestPassed()
150+
setStatus(next)
151+
publishVoiceInputStatus(next)
152+
}
150153
} catch (reason) {
151154
setError(String(reason))
152155
await refresh().catch(() => undefined)

‎src/components/settings/VoiceRuntimeSettings.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
X,
1111
} from 'lucide-react'
1212
import i18n from '../../i18n'
13+
import { useAppStore } from '../../stores/appStore'
1314
import {
1415
cancelVoiceAssetDownload,
1516
deleteVoiceAsset,
@@ -79,6 +80,7 @@ const formatBytes = (bytes: number) => {
7980

8081
export function VoiceRuntimeSettings() {
8182
const copy = i18n.resolvedLanguage?.startsWith('zh') ? text.zh : text.en
83+
const githubMirror = useAppStore((state) => state.githubMirror)
8284
const [status, setStatus] = useState<VoiceRuntimeStatus | null>(null)
8385
const [progress, setProgress] = useState<VoiceAssetProgress | null>(null)
8486
const [busy, setBusy] = useState<VoiceAssetKind | null>(null)
@@ -123,7 +125,7 @@ export function VoiceRuntimeSettings() {
123125
setProgress(null)
124126
setError(null)
125127
try {
126-
setStatus(await downloadVoiceAsset(kind))
128+
setStatus(await downloadVoiceAsset(kind, githubMirror))
127129
} catch (reason) {
128130
setError(String(reason))
129131
await refresh().catch(() => undefined)

‎src/utils/voiceRuntime.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export interface RealtimeVoiceEvent {
5454
export const getVoiceRuntimeStatus = () =>
5555
invoke<VoiceRuntimeStatus>('get_voice_runtime_status')
5656

57-
export const downloadVoiceAsset = (kind: VoiceAssetKind) =>
58-
invoke<VoiceRuntimeStatus>('download_voice_asset', { kind })
57+
export const downloadVoiceAsset = (kind: VoiceAssetKind, githubMirror?: string) =>
58+
invoke<VoiceRuntimeStatus>('download_voice_asset', { kind, githubMirror })
5959

6060
export const cancelVoiceAssetDownload = () =>
6161
invoke<void>('cancel_voice_asset_download')

‎stt/Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ sonora = "0.2.0"
1616
tar = "0.4"
1717
ureq = "2.12"
1818
whisper-rs = "0.16"
19+
20+
[target.'cfg(windows)'.dependencies]
21+
winreg = "0.56"
22+
23+
[dev-dependencies]
24+
serde_json = "1"

‎stt/src/download.rs‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use std::{env, time::Duration};
2+
3+
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
4+
const READ_TIMEOUT: Duration = Duration::from_secs(90);
5+
6+
pub(crate) fn agent() -> ureq::Agent {
7+
let mut builder = base_builder();
8+
if let Some(proxy_url) = configured_proxy() {
9+
if let Ok(proxy) = ureq::Proxy::new(proxy_url) {
10+
builder = builder.proxy(proxy);
11+
}
12+
}
13+
builder.build()
14+
}
15+
16+
pub(crate) fn direct_agent() -> ureq::Agent {
17+
base_builder().build()
18+
}
19+
20+
fn base_builder() -> ureq::AgentBuilder {
21+
ureq::AgentBuilder::new()
22+
.timeout_connect(CONNECT_TIMEOUT)
23+
.timeout_read(READ_TIMEOUT)
24+
.user_agent("S-Loop voice model downloader")
25+
}
26+
27+
fn configured_proxy() -> Option<String> {
28+
[
29+
"ALL_PROXY",
30+
"all_proxy",
31+
"HTTPS_PROXY",
32+
"https_proxy",
33+
"HTTP_PROXY",
34+
"http_proxy",
35+
]
36+
.into_iter()
37+
.find_map(|name| {
38+
env::var(name)
39+
.ok()
40+
.and_then(|value| normalize_proxy(&value))
41+
})
42+
.or_else(windows_user_proxy)
43+
}
44+
45+
fn normalize_proxy(value: &str) -> Option<String> {
46+
let value = value.trim();
47+
if value.is_empty() {
48+
return None;
49+
}
50+
if value.contains("://") {
51+
Some(value.to_owned())
52+
} else {
53+
Some(format!("http://{value}"))
54+
}
55+
}
56+
57+
#[cfg(windows)]
58+
fn windows_user_proxy() -> Option<String> {
59+
use winreg::{enums::HKEY_CURRENT_USER, RegKey};
60+
61+
let settings = RegKey::predef(HKEY_CURRENT_USER)
62+
.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings")
63+
.ok()?;
64+
let enabled = settings.get_value::<u32, _>("ProxyEnable").unwrap_or(0);
65+
if enabled == 0 {
66+
return None;
67+
}
68+
let value = settings.get_value::<String, _>("ProxyServer").ok()?;
69+
parse_windows_proxy(&value)
70+
}
71+
72+
#[cfg(not(windows))]
73+
fn windows_user_proxy() -> Option<String> {
74+
None
75+
}
76+
77+
fn parse_windows_proxy(value: &str) -> Option<String> {
78+
let value = value.trim();
79+
if value.is_empty() {
80+
return None;
81+
}
82+
if !value.contains('=') {
83+
return normalize_proxy(value);
84+
}
85+
86+
let entries: Vec<(&str, &str)> = value
87+
.split(';')
88+
.filter_map(|entry| entry.split_once('='))
89+
.map(|(protocol, address)| (protocol.trim(), address.trim()))
90+
.collect();
91+
["https", "http", "socks"].into_iter().find_map(|protocol| {
92+
entries
93+
.iter()
94+
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(protocol))
95+
.and_then(|(_, address)| {
96+
if protocol == "socks" && !address.contains("://") {
97+
Some(format!("socks5://{address}"))
98+
} else {
99+
normalize_proxy(address)
100+
}
101+
})
102+
})
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::{agent, direct_agent, normalize_proxy, parse_windows_proxy};
108+
109+
#[test]
110+
fn normalizes_proxy_without_a_scheme() {
111+
assert_eq!(
112+
normalize_proxy("127.0.0.1:7890").as_deref(),
113+
Some("http://127.0.0.1:7890")
114+
);
115+
}
116+
117+
#[test]
118+
fn keeps_an_explicit_proxy_scheme() {
119+
assert_eq!(
120+
normalize_proxy("http://127.0.0.1:7890").as_deref(),
121+
Some("http://127.0.0.1:7890")
122+
);
123+
}
124+
125+
#[test]
126+
fn selects_https_from_a_windows_protocol_map() {
127+
assert_eq!(
128+
parse_windows_proxy("http=127.0.0.1:7890;https=127.0.0.1:7891").as_deref(),
129+
Some("http://127.0.0.1:7891")
130+
);
131+
}
132+
133+
#[test]
134+
fn recognizes_a_windows_socks_proxy() {
135+
assert_eq!(
136+
parse_windows_proxy("socks=127.0.0.1:7891").as_deref(),
137+
Some("socks5://127.0.0.1:7891")
138+
);
139+
}
140+
141+
#[test]
142+
#[ignore = "manual external-network diagnostic"]
143+
fn reaches_modelscope_directly_and_github_through_the_configured_proxy() {
144+
let modelscope = direct_agent()
145+
.get("https://www.modelscope.cn/models/budaoshou/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/resolve/658a5257f1342768b148d8b51c87e52a4e012262/tokens.txt")
146+
.set("Range", "bytes=0-0")
147+
.call()
148+
.expect("ModelScope should be reachable without a proxy");
149+
assert!(matches!(modelscope.status(), 200 | 206));
150+
151+
let github = agent()
152+
.get("https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx")
153+
.set("Range", "bytes=0-0")
154+
.call()
155+
.expect("GitHub should be reachable through the configured proxy");
156+
assert!(matches!(github.status(), 200 | 206));
157+
}
158+
}

0 commit comments

Comments
 (0)