fix(foundry): keep local ASR warm and stream transcripts (#1052)

* fix(foundry): keep local ASR warm and stream transcripts

* fix(foundry): cancel stale release schedules
This commit is contained in:
Chris233 authored and GitHub committed 2026-09-13 12:28:41 +08:00
1 parent c4e8e88782
commit c8bd7aa295
22 files changed
+364 -71

No files matched your search

@@ -319,6 +319,7 @@ pub use settings::*;
pub use shared_types::{
CapsulePayload, CapsuleState, CapsuleStyle, CredentialsStatus, HotkeyMode, HotkeyStatus,
PendingCorrection, PlatformCapabilities, SelectionPolishOutputMode, UserPreferences,
LOCAL_ASR_KEEP_LOADED_FOREVER_SECS,
};
pub use shortcut_types::{
binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap,
@@ -178,6 +178,8 @@ pub trait ModelRuntimeAdapter: Send + Sync {
self.test_model(target, model_dir)
}
fn invalidate_scheduled_release(&self, _runtime: LocalAsrRuntime) {}
fn invalidate_route(&self, _runtime: LocalAsrRuntime) {}
}
@@ -1151,6 +1153,12 @@ impl LocalAsrApi for LocalAsrService {
LocalAsrRuntime::Foundry => preferences.foundry_local_asr_keep_loaded_secs = seconds,
LocalAsrRuntime::SherpaOnnx => preferences.sherpa_onnx_keep_loaded_secs = seconds,
});
if result.is_ok()
&& runtime == LocalAsrRuntime::Foundry
&& seconds == crate::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS
{
self.runtime.invalidate_scheduled_release(runtime);
}
let preferences = Arc::clone(&self.preferences);
let adapter = Arc::clone(&self.runtime);
let model_store = Arc::clone(&self.model_store);
@@ -19,6 +19,9 @@ pub use crate::android_types::{
pub use crate::types::{HistorySource, PolishMode};
/// 本地 ASR 保持加载设置的兼容值:不自动释放,仅由显式操作或进程退出卸载。
pub const LOCAL_ASR_KEEP_LOADED_FOREVER_SECS: u32 = 86_400;
/// 识别管线模式(issue #902):`traditional` = 两段式 ASR + LLM 润色;
/// `multimodal` = 单个多模态模型一步完成「音频 + 提示词 → 最终文本」。
/// 两套配置在凭据库中完全隔离,运行时只读当前模式,切换不删除另一套配置。
@@ -530,7 +533,7 @@ pub struct UserPreferences {
#[serde(default = "default_local_asr_mirror")]
pub local_asr_mirror: String,
/// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
/// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 一天 ≈ 永不释放。
/// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 永不自动释放。
/// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub local_asr_keep_loaded_secs: u32,
@@ -69,6 +69,7 @@ fn public_local_asr_preferences_keep_legacy_normalization_semantics() {
#[derive(Default)]
struct RecordingLocalAsrRuntime {
invalidated: Mutex<Vec<LocalAsrRuntime>>,
invalidated_release_schedules: Mutex<Vec<LocalAsrRuntime>>,
fail_release: std::sync::atomic::AtomicBool,
fail_prepare: std::sync::atomic::AtomicBool,
fail_preload: std::sync::atomic::AtomicBool,
@@ -359,6 +360,13 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime {
fn invalidate_route(&self, runtime: LocalAsrRuntime) {
self.invalidated.lock().unwrap().push(runtime);
}
fn invalidate_scheduled_release(&self, runtime: LocalAsrRuntime) {
self.invalidated_release_schedules
.lock()
.unwrap()
.push(runtime);
}
}
#[derive(Default)]
@@ -1541,6 +1549,8 @@ async fn backend_local_asr_service_owns_preferences_and_change_events() {
assert_eq!(preferences.sherpa_onnx_language_hint, "zh-hans");
assert_eq!(preferences.foundry_local_runtime_source, "ort-nightly");
assert_eq!(preferences.foundry_local_asr_keep_loaded_secs, 42);
assert_eq!(preferences.local_asr_keep_loaded_secs, 300);
assert_eq!(preferences.sherpa_onnx_keep_loaded_secs, 300);
assert_eq!(
runtime.invalidated.lock().unwrap().as_slice(),
[LocalAsrRuntime::Foundry, LocalAsrRuntime::Foundry]
@@ -1554,6 +1564,53 @@ async fn backend_local_asr_service_owns_preferences_and_change_events() {
let _ = std::fs::remove_dir_all(data_dir);
}
#[tokio::test]
async fn foundry_never_release_invalidates_only_the_pending_finite_schedule() {
let (data_dir, runtime, backend) = local_asr_backend();
for seconds in [0, 60, 300, 1_800] {
backend
.services()
.local_asr
.set_keep_loaded_secs(LocalAsrRuntime::Foundry, seconds)
.await
.unwrap();
}
assert!(runtime
.invalidated_release_schedules
.lock()
.unwrap()
.is_empty());
backend
.services()
.local_asr
.set_keep_loaded_secs(
LocalAsrRuntime::Foundry,
openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS,
)
.await
.unwrap();
let preferences = backend.get_preferences();
assert_eq!(
preferences.foundry_local_asr_keep_loaded_secs,
openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS
);
assert_eq!(preferences.local_asr_keep_loaded_secs, 300);
assert_eq!(preferences.sherpa_onnx_keep_loaded_secs, 300);
assert_eq!(
runtime
.invalidated_release_schedules
.lock()
.unwrap()
.as_slice(),
[LocalAsrRuntime::Foundry]
);
let _ = std::fs::remove_dir_all(data_dir);
}
#[tokio::test]
async fn backend_local_asr_storage_change_commits_only_after_runtime_quiesces() {
let (data_dir, runtime, backend) = local_asr_backend();
@@ -81,6 +81,22 @@ for (const contract of [
}
}
for (const contract of [
'setFoundryLocalAsrKeepLoadedSecs',
'foundryStatus?.keepLoadedSecs ?? 300',
]) {
if (!source.includes(contract)) {
throw new Error(`Foundry keep-loaded UI contract is missing: ${contract}`);
}
}
const keepLoadedOptionUses = source.match(/options=\{keepLoadedOptions\}/g) ?? [];
if (keepLoadedOptionUses.length !== 2) {
throw new Error(
`Generic and Foundry keep-loaded selectors must share the options, found ${keepLoadedOptionUses.length}`,
);
}
console.log(
'LocalAsr keeps one refresh poller, pauses it for the download dialog, and preserves stable JSX component types',
);
@@ -130,9 +130,12 @@ mod imp {
}
use anyhow::{Context, Result};
use foundry_local_sdk::{DeviceType, FoundryLocalConfig, FoundryLocalManager, Model};
use foundry_local_sdk::{
AudioTranscriptionResponse, DeviceType, FoundryLocalConfig, FoundryLocalManager, Model,
};
use futures_util::{Stream, StreamExt};
use parking_lot::Mutex;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
use super::{
FoundryCpuFallbackTerminalError, FoundryFallbackNotice, FoundryFallbackNoticeCallback,
@@ -493,6 +496,19 @@ mod imp {
.ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper total timeout exhausted"))
}
async fn collect_foundry_transcription_text<S, E>(
mut stream: S,
) -> std::result::Result<String, E>
where
S: Stream<Item = std::result::Result<AudioTranscriptionResponse, E>> + Unpin,
{
let mut text = String::new();
while let Some(chunk) = stream.next().await {
text.push_str(&chunk?.text);
}
Ok(text)
}
struct FoundrySdkExecution<'a> {
runtime: &'a FoundryLocalRuntime,
manager: &'static FoundryLocalManager,
@@ -546,16 +562,19 @@ mod imp {
client = client.language(language_hint);
}
let model_id = self.loaded.model_id.clone();
let result = tokio::time::timeout(timeout, client.transcribe(audio_path))
.await
.with_context(|| {
format!(
"transcribe audio with Foundry model {model_id} timed out after {} seconds",
timeout.as_secs()
)
})?
.with_context(|| format!("transcribe audio with Foundry model {model_id}"))?;
Ok(result.text)
let result = tokio::time::timeout(timeout, async {
let stream = client.transcribe_streaming(audio_path).await?;
collect_foundry_transcription_text(stream).await
})
.await
.with_context(|| {
format!(
"transcribe audio with Foundry model {model_id} timed out after {} seconds",
timeout.as_secs()
)
})?
.with_context(|| format!("transcribe audio with Foundry model {model_id}"))?;
Ok(result)
}
async fn switch_to_cpu(
@@ -692,6 +711,8 @@ mod imp {
/// 仍可中断(`cancel_prepare` + `check_prepare_cancelled`)。若未来要缩小粒度,
/// 可让下载阶段不持锁、下载完成后重新校验 route epoch 再持锁加载/推理。
lifecycle: AsyncMutex<()>,
/// EP 注册会使 SDK 的模型目录缓存失效;成功后本进程不再重复注册。
execution_providers_ready: OnceCell<()>,
cancel_prepare: Arc<AtomicBool>,
temporary_cpu_fallback_sequence: AtomicU64,
route_epoch: AtomicU64,
@@ -708,6 +729,7 @@ mod imp {
pub fn new() -> Self {
Self {
lifecycle: AsyncMutex::new(()),
execution_providers_ready: OnceCell::new(),
cancel_prepare: Arc::new(AtomicBool::new(false)),
temporary_cpu_fallback_sequence: AtomicU64::new(0),
route_epoch: AtomicU64::new(0),
@@ -1084,24 +1106,33 @@ mod imp {
));
let runtime_progress = Arc::clone(&progress);
let runtime_alias = alias.to_string();
manager
.download_and_register_eps_with_progress(
None,
move |ep_name: &str, percent: f64| {
let label = if ep_name.trim().is_empty() {
"Foundry Local runtime components".to_string()
} else {
format!("Foundry Local runtime component: {ep_name}")
};
runtime_progress.as_ref()(FoundryPrepareProgressPayload::runtime(
runtime_alias.clone(),
label,
percent,
));
},
)
.await
.context("download/register Foundry execution providers")?;
let cancel_prepare = Arc::clone(&self.cancel_prepare);
self.execution_providers_ready
.get_or_try_init(|| async move {
manager
.download_and_register_eps_with_progress(
None,
move |ep_name: &str, percent: f64| {
let label = if ep_name.trim().is_empty() {
"Foundry Local runtime components".to_string()
} else {
format!("Foundry Local runtime component: {ep_name}")
};
runtime_progress.as_ref()(FoundryPrepareProgressPayload::runtime(
runtime_alias.clone(),
label,
percent,
));
},
)
.await
.context("download/register Foundry execution providers")?;
if cancel_prepare.load(Ordering::SeqCst) {
anyhow::bail!("Foundry Local Whisper prepare cancelled");
}
Ok::<(), anyhow::Error>(())
})
.await?;
progress.as_ref()(FoundryPrepareProgressPayload::runtime(
alias,
"Foundry Local runtime components",
@@ -1662,15 +1693,16 @@ mod imp {
}
use super::{
cpu_load_completion, foundry_native_dir_candidates, is_cuda_cudnn_failure,
is_cuda_fallback_candidate, may_reuse_loaded_model, normalized_language_hint,
select_cpu_variant_id, select_foundry_native_dir,
collect_foundry_transcription_text, cpu_load_completion, foundry_native_dir_candidates,
is_cuda_cudnn_failure, is_cuda_fallback_candidate, may_reuse_loaded_model,
normalized_language_hint, select_cpu_variant_id, select_foundry_native_dir,
should_release_temporary_cpu_fallback, transcribe_recording_with_adapter,
FoundryCpuLoadCompletion, FoundryCpuSwitch, FoundryExecutionAdapter,
FoundryExecutionDevice, FoundryFallbackNotice, FoundryFallbackNoticeCallback,
FoundryLocalRuntime, FoundryVariantDescriptor,
};
use anyhow::Result;
use foundry_local_sdk::AudioTranscriptionResponse;
use std::{
collections::VecDeque,
fs,
@@ -1823,6 +1855,45 @@ mod imp {
(callback, received)
}
fn transcription_response(text: &str) -> AudioTranscriptionResponse {
AudioTranscriptionResponse {
text: text.to_string(),
language: None,
duration: None,
segments: None,
words: None,
}
}
#[tokio::test]
async fn foundry_streaming_transcription_concatenates_ordered_responses() {
let stream = futures_util::stream::iter([
Ok::<_, &'static str>(transcription_response("中文")),
Ok(transcription_response("")),
Ok(transcription_response("转写完成")),
]);
assert_eq!(
collect_foundry_transcription_text(stream).await.unwrap(),
"中文转写完成"
);
}
#[tokio::test]
async fn foundry_streaming_transcription_propagates_chunk_errors() {
let stream = futures_util::stream::iter([
Ok(transcription_response("partial")),
Err("stream failed"),
]);
assert_eq!(
collect_foundry_transcription_text(stream)
.await
.unwrap_err(),
"stream failed"
);
}
#[tokio::test]
async fn cuda_failure_retries_the_failed_chunk_once_on_cpu_and_keeps_cpu_for_later_chunks()
{
@@ -38,6 +38,7 @@ pub struct FoundryStatusWire {
pub runtime_source: String,
pub active_model: String,
pub loaded_model_id: Option<String>,
pub keep_loaded_secs: u32,
pub endpoint: Option<String>,
pub error: Option<String>,
}
@@ -51,6 +52,7 @@ impl From<openless_core::LocalAsrRuntimeStatus> for FoundryStatusWire {
runtime_source: status.runtime_source.unwrap_or_default().as_str().into(),
active_model: status.active_model,
loaded_model_id: status.model_id,
keep_loaded_secs: status.keep_loaded_secs,
endpoint: status.endpoint,
error: status.error,
}
@@ -140,6 +142,19 @@ pub async fn foundry_local_asr_set_runtime_source(
.map_err(core_error)
}
#[tauri::command]
pub async fn foundry_local_asr_set_keep_loaded_secs(
backend: CoreState<'_>,
seconds: u32,
) -> Result<(), String> {
backend
.services()
.local_asr
.set_keep_loaded_secs(LocalAsrRuntime::Foundry, seconds)
.await
.map_err(core_error)
}
#[tauri::command]
pub async fn foundry_local_asr_prepare(
backend: CoreState<'_>,
@@ -248,6 +263,7 @@ mod wire_contract_tests {
"runtimeSource": "ort-nightly",
"activeModel": "whisper-small",
"loadedModelId": null,
"keepLoadedSecs": 300,
"endpoint": null,
"error": null,
})
+76 -20
View File
@@ -307,16 +307,6 @@ impl TauriLocalAsrRuntimeAdapter {
foundry_rebind_pending: Arc::new(AtomicBool::new(false)),
}
}
#[cfg(target_os = "windows")]
fn invalidate_release(&self, runtime: openless_core::LocalAsrRuntime) {
match runtime {
openless_core::LocalAsrRuntime::Foundry => &self.native.foundry_generation,
openless_core::LocalAsrRuntime::SherpaOnnx => &self.native.sherpa_generation,
openless_core::LocalAsrRuntime::Generic => return,
}
.fetch_add(1, Ordering::AcqRel);
}
}
impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
@@ -550,7 +540,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
progress: openless_core::ModelPrepareProgressSink,
) -> BoxFuture<'static, Result<String, BackendError>> {
#[cfg(target_os = "windows")]
self.invalidate_release(target.runtime);
self.invalidate_scheduled_release(target.runtime);
let foundry = Arc::clone(&self.native.foundry);
let sherpa = Arc::clone(&self.native.sherpa);
let foundry_rebind_pending = Arc::clone(&self.foundry_rebind_pending);
@@ -692,7 +682,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
runtime: openless_core::LocalAsrRuntime,
) -> BoxFuture<'static, Result<(), BackendError>> {
#[cfg(target_os = "windows")]
self.invalidate_release(runtime);
self.invalidate_scheduled_release(runtime);
let foundry = Arc::clone(&self.native.foundry);
let sherpa = Arc::clone(&self.native.sherpa);
#[cfg(any(target_os = "macos", target_os = "linux"))]
@@ -813,6 +803,16 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
self.native.foundry.invalidate_route();
}
}
#[cfg(target_os = "windows")]
fn invalidate_scheduled_release(&self, runtime: openless_core::LocalAsrRuntime) {
match runtime {
openless_core::LocalAsrRuntime::Foundry => &self.native.foundry_generation,
openless_core::LocalAsrRuntime::SherpaOnnx => &self.native.sherpa_generation,
openless_core::LocalAsrRuntime::Generic => return,
}
.fetch_add(1, Ordering::AcqRel);
}
}
async fn test_model_with_provider(
@@ -2275,6 +2275,12 @@ fn pcm_duration_ms(bytes: &[u8]) -> u64 {
(bytes.len() as u64 / 2).saturating_mul(1_000) / 16_000
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))]
fn local_asr_release_delay(keep_loaded_secs: u32) -> Option<std::time::Duration> {
(keep_loaded_secs != openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS)
.then(|| std::time::Duration::from_secs(keep_loaded_secs as u64))
}
#[cfg(target_os = "windows")]
fn schedule_foundry_release(
runtime: Arc<crate::asr::local::FoundryLocalRuntime>,
@@ -2298,8 +2304,11 @@ fn schedule_foundry_release(
}
}
}
if keep_loaded_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_loaded_secs as u64)).await;
let Some(delay) = local_asr_release_delay(keep_loaded_secs) else {
return;
};
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
if current_generation.load(Ordering::Acquire) != generation {
return;
@@ -2327,9 +2336,12 @@ fn schedule_sherpa_release(
generation: u64,
current_generation: Arc<AtomicU64>,
) {
let Some(delay) = local_asr_release_delay(keep_loaded_secs) else {
return;
};
tauri::async_runtime::spawn(async move {
if keep_loaded_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_loaded_secs as u64)).await;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
if current_generation.load(Ordering::Acquire) == generation {
if let Err(error) = runtime
@@ -2348,8 +2360,10 @@ fn schedule_qwen_release(
engine: std::sync::Weak<crate::asr::local::LocalQwenEngine>,
keep_loaded_secs: u32,
) {
let Some(threshold) = local_asr_release_delay(keep_loaded_secs) else {
return;
};
tauri::async_runtime::spawn(async move {
let threshold = std::time::Duration::from_secs(keep_loaded_secs as u64);
if !threshold.is_zero() {
tokio::time::sleep(threshold).await;
}
@@ -2363,8 +2377,10 @@ fn schedule_whisper_release(
engine: std::sync::Weak<crate::asr::local::WhisperEngine>,
keep_loaded_secs: u32,
) {
let Some(threshold) = local_asr_release_delay(keep_loaded_secs) else {
return;
};
tauri::async_runtime::spawn(async move {
let threshold = std::time::Duration::from_secs(keep_loaded_secs as u64);
if !threshold.is_zero() {
tokio::time::sleep(threshold).await;
}
@@ -3373,6 +3389,22 @@ mod tests {
struct IgnoreTextStreamSink;
#[test]
fn local_asr_keep_loaded_delay_distinguishes_immediate_finite_and_forever() {
assert_eq!(
super::local_asr_release_delay(0),
Some(std::time::Duration::ZERO)
);
assert_eq!(
super::local_asr_release_delay(300),
Some(std::time::Duration::from_secs(300))
);
assert_eq!(
super::local_asr_release_delay(openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS),
None
);
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn windows_preload_requires_the_requested_model_to_be_prepared() {
@@ -3395,10 +3427,34 @@ mod tests {
.unwrap_err();
assert_eq!(error.code, BackendErrorCode::InvalidState);
}
adapter.invalidate_release(LocalAsrRuntime::Foundry);
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn windows_scheduled_release_invalidation_is_runtime_scoped() {
use openless_core::{LocalAsrRuntime, ModelRuntimeAdapter};
let adapter = TauriLocalAsrRuntimeAdapter::new(
TauriNativeAsrDependencies::new(
Arc::new(crate::asr::local::FoundryLocalRuntime::new()),
Arc::new(crate::asr::local::SherpaOnnxRuntime::new()),
),
Arc::new(openless_core::PreferencesStore::in_memory()),
);
let stale_foundry_generation = adapter.native.foundry_generation.load(Ordering::Acquire);
adapter.invalidate_scheduled_release(LocalAsrRuntime::Foundry);
assert_eq!(adapter.native.foundry_generation.load(Ordering::Acquire), 1);
assert_eq!(adapter.native.sherpa_generation.load(Ordering::Acquire), 0);
adapter.invalidate_release(LocalAsrRuntime::SherpaOnnx);
assert!(!adapter
.native
.foundry
.release_if_generation(
adapter.native.foundry_generation.as_ref(),
stale_foundry_generation,
)
.await
.unwrap());
adapter.invalidate_scheduled_release(LocalAsrRuntime::SherpaOnnx);
assert_eq!(adapter.native.foundry_generation.load(Ordering::Acquire), 1);
assert_eq!(adapter.native.sherpa_generation.load(Ordering::Acquire), 1);
}
+1
View File
@@ -368,6 +368,7 @@ macro_rules! app_invoke_handler_desktop {
commands::foundry_local_asr_set_model,
commands::foundry_local_asr_set_language_hint,
commands::foundry_local_asr_set_runtime_source,
commands::foundry_local_asr_set_keep_loaded_secs,
commands::foundry_local_asr_prepare,
commands::foundry_local_asr_cancel_prepare,
commands::foundry_local_asr_release,
+1 -1
View File
@@ -2219,7 +2219,7 @@ export const de: typeof zhCN = {
releaseNow: 'Jetzt entladen',
keepLoadedLabel: 'Geladen halten für',
keepLoadedDesc:
'Wie lange Qwen3-ASR nach der letzten Verwendung im Arbeitsspeicher bleibt, bevor es entladen wird.',
'Wie lange die aktuelle lokale ASR-Engine nach der nächsten Transkription geladen bleibt; „Nie entladen“ gilt bis zum manuellen Entladen oder Beenden.',
keepImmediate: 'Sofort entladen',
keep1min: '1 Minute nach letzter Verwendung',
keep5min: '5 Minuten nach letzter Verwendung (Standard)',
+2 -1
View File
@@ -2151,7 +2151,8 @@ export const en: typeof zhCN = {
loadNow: 'Load now',
releaseNow: 'Release now',
keepLoadedLabel: 'Keep loaded for',
keepLoadedDesc: 'How long Qwen3-ASR stays in memory after the last use, before being freed.',
keepLoadedDesc:
'How long the current local ASR engine stays loaded after the next transcription; Never release keeps it loaded until manual release or exit.',
keepImmediate: 'Release immediately',
keep1min: '1 minute after last use',
keep5min: '5 minutes after last use (default)',
+1 -1
View File
@@ -2200,7 +2200,7 @@ export const es: typeof zhCN = {
releaseNow: 'Liberar ahora',
keepLoadedLabel: 'Mantener cargado durante',
keepLoadedDesc:
'Tiempo que Qwen3-ASR permanece en memoria después del último uso antes de liberarse.',
'Tiempo que el motor ASR local actual permanece cargado tras la siguiente transcripción; «No liberar nunca» se mantiene hasta liberarlo manualmente o salir.',
keepImmediate: 'Liberar inmediatamente',
keep1min: '1 minuto tras el último uso',
keep5min: '5 minutos tras el último uso (predeterminado)',
+1 -1
View File
@@ -2229,7 +2229,7 @@ export const fr: typeof zhCN = {
releaseNow: 'Libérer maintenant',
keepLoadedLabel: 'Conserver en mémoire pendant',
keepLoadedDesc:
'Durée pendant laquelle Qwen3-ASR reste en mémoire après sa dernière utilisation avant d’être libéré.',
'Durée de chargement du moteur ASR local actuel après la prochaine transcription ; « Ne jamais libérer » le conserve jusqu’à une libération manuelle ou la fermeture.',
keepImmediate: 'Libérer immédiatement',
keep1min: '1 minute après la dernière utilisation',
keep5min: '5 minutes après la dernière utilisation (par défaut)',
+1 -1
View File
@@ -2120,7 +2120,7 @@ export const ja: typeof zhCN = {
releaseNow: '今すぐ解放',
keepLoadedLabel: 'ロード保持時間',
keepLoadedDesc:
'ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。',
'現在のローカル ASR を次回の文字起こし後に保持する時間を指定します。「解放しない」は手動解放または終了まで保持します。',
keepImmediate: '使用直後に解放',
keep1min: '最終使用から 1 分',
keep5min: '最終使用から 5 分(既定)',
+1 -1
View File
@@ -2102,7 +2102,7 @@ export const ko: typeof zhCN = {
releaseNow: '지금 해제',
keepLoadedLabel: '로드 유지 시간',
keepLoadedDesc:
'로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.',
'현재 로컬 ASR 엔진을 다음 전사 후 얼마나 유지할지 정합니다. 해제하지 않음은 수동 해제 또는 종료까지 유지합니다.',
keepImmediate: '말하기 직후 해제',
keep1min: '마지막 사용 후 1분',
keep5min: '마지막 사용 후 5분(기본)',
+2 -1
View File
@@ -2030,7 +2030,8 @@ export const zhCN = {
loadNow: '立即加载',
releaseNow: '立即释放',
keepLoadedLabel: '保持加载多久',
keepLoadedDesc: '决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。',
keepLoadedDesc:
'决定当前本地 ASR 引擎在下次转写后保持加载多久;“不释放”会持续驻留至手动释放或退出。',
keepImmediate: '说完话立即释放',
keep1min: '上次使用后 1 分钟',
keep5min: '上次使用后 5 分钟(默认)',
+2 -1
View File
@@ -2016,7 +2016,8 @@ export const zhTW: typeof zhCN = {
loadNow: '立即加載',
releaseNow: '立即釋放',
keepLoadedLabel: '保持加載多久',
keepLoadedDesc: '決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。',
keepLoadedDesc:
'決定目前本地 ASR 引擎在下次轉寫後保持載入多久;「不釋放」會持續駐留至手動釋放或退出。',
keepImmediate: '說完話立即釋放',
keep1min: '上次使用後 1 分鐘',
keep5min: '上次使用後 5 分鐘(默認)',
+6 -1
View File
@@ -1,4 +1,4 @@
import { isLocalAsrModelSupportedOnOs } from './localAsr';
import { LOCAL_ASR_KEEP_LOADED_OPTIONS, isLocalAsrModelSupportedOnOs } from './localAsr';
function assertEqual(actual: boolean, expected: boolean, name: string) {
if (actual !== expected) {
@@ -30,3 +30,8 @@ assertEqual(
true,
'Whisper is available on macOS',
);
const keepLoadedSeconds = LOCAL_ASR_KEEP_LOADED_OPTIONS.map((option) => option.seconds);
if (JSON.stringify(keepLoadedSeconds) !== JSON.stringify([0, 60, 300, 1800, 86400])) {
throw new Error(`unexpected keep-loaded options: ${keepLoadedSeconds.join(', ')}`);
}
+14
View File
@@ -104,10 +104,19 @@ export interface FoundryLocalAsrStatus {
runtimeSource: FoundryRuntimeSource;
activeModel: string;
loadedModelId: string | null;
keepLoadedSecs: number;
endpoint: string | null;
error: string | null;
}
export const LOCAL_ASR_KEEP_LOADED_OPTIONS = [
{ seconds: 0, labelKey: 'localAsr.keepImmediate' },
{ seconds: 60, labelKey: 'localAsr.keep1min' },
{ seconds: 300, labelKey: 'localAsr.keep5min' },
{ seconds: 1800, labelKey: 'localAsr.keep30min' },
{ seconds: 86400, labelKey: 'localAsr.keepForever' },
] as const;
export const FOUNDRY_LOCAL_ASR_MODEL_ALIASES = [
'whisper-small',
'whisper-medium',
@@ -403,6 +412,7 @@ export function getFoundryLocalAsrStatus(): Promise<FoundryLocalAsrStatus> {
runtimeSource: 'auto',
activeModel: 'whisper-small',
loadedModelId: null,
keepLoadedSecs: 300,
endpoint: null,
error: null,
}));
@@ -424,6 +434,10 @@ export function setFoundryLocalRuntimeSource(source: string): Promise<void> {
return invokeOrMock('foundry_local_asr_set_runtime_source', { source }, () => undefined);
}
export function setFoundryLocalAsrKeepLoadedSecs(seconds: number): Promise<void> {
return invokeOrMock('foundry_local_asr_set_keep_loaded_secs', { seconds }, () => undefined);
}
export function prepareFoundryLocalAsr(modelAlias: string): Promise<string> {
return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`);
}
+1 -1
View File
@@ -445,7 +445,7 @@ export interface UserPreferences {
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
localAsrMirror: string;
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
* 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */
* 300 = 默认 5 分钟;86400 = 不自动释放(保持加载)。 */
localAsrKeepLoadedSecs: number;
/** Windows Foundry Local Whisper 当前激活的模型 alias。 */
foundryLocalAsrModel: string;
+48 -7
View File
@@ -15,6 +15,7 @@ import { emitSaved } from '../../lib/savedEvent';
import { useLayoutStack } from '../../lib/useMobileLayout';
import {
FOUNDRY_LOCAL_ASR_MODELS,
LOCAL_ASR_KEEP_LOADED_OPTIONS,
SHERPA_ONNX_ASR_MODELS,
activateLocalAsr,
cancelFoundryLocalAsrPrepare,
@@ -49,6 +50,7 @@ import {
revealSherpaOnnxAsrModelDir,
setLocalAsrModelsBaseDir,
setFoundryLocalAsrLanguageHint,
setFoundryLocalAsrKeepLoadedSecs,
setFoundryLocalRuntimeSource,
setLocalAsrKeepLoadedSecs,
setLocalAsrMirror,
@@ -161,6 +163,10 @@ type RefreshGuard = () => boolean;
export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const { t } = useTranslation();
const keepLoadedOptions = LOCAL_ASR_KEEP_LOADED_OPTIONS.map(({ seconds, labelKey }) => ({
value: String(seconds),
label: t(labelKey),
}));
const stackLayout = useLayoutStack(1000);
const { prefs, updatePrefs } = useHotkeySettings();
const [settings, setSettings] = useState<LocalAsrSettings | null>(null);
@@ -351,6 +357,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
runtimeSource: selectedFoundryRuntimeSource,
activeModel: selectedFoundryAlias,
loadedModelId: null,
keepLoadedSecs: 300,
endpoint: null,
error: message,
});
@@ -937,6 +944,18 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
};
const handleFoundryKeepLoadedChange = async (seconds: number, restoreScroll?: () => void) => {
try {
setError(null);
await setFoundryLocalAsrKeepLoadedSecs(seconds);
await refreshFoundryStatus();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
restoreScroll?.();
}
};
const handleEnableFoundry = async (aliasOverride?: FoundryLocalAsrModelAlias) => {
if (!foundryAvailable) return;
const alias = aliasOverride ?? selectedFoundryAlias;
@@ -2167,13 +2186,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
value={String(engineStatus?.keepLoadedSecs ?? 300)}
onChange={(v) => void handleKeepLoadedChange(Number(v))}
ariaLabel={t('localAsr.keepLoadedLabel')}
options={[
{ value: '0', label: t('localAsr.keepImmediate') },
{ value: '60', label: t('localAsr.keep1min') },
{ value: '300', label: t('localAsr.keep5min') },
{ value: '1800', label: t('localAsr.keep30min') },
{ value: '86400', label: t('localAsr.keepForever') },
]}
options={keepLoadedOptions}
style={{ fontSize: 13, height: 31, minWidth: 200 }}
/>
</div>
@@ -2527,6 +2540,33 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
/>
</label>
<label
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
fontSize: 11,
color: 'var(--ol-ink-4)',
}}
>
{t('localAsr.keepLoadedLabel')}
<SelectLite
value={String(foundryStatus?.keepLoadedSecs ?? 300)}
onChange={(v) => {
const restoreScroll = preserveEmbeddedScroll(foundryControlsRef.current);
void handleFoundryKeepLoadedChange(Number(v), restoreScroll);
}}
disabled={foundryBusy !== null}
ariaLabel={t('localAsr.keepLoadedLabel')}
options={keepLoadedOptions}
style={{
fontSize: 13,
height: 31,
minWidth: 200,
}}
/>
</label>
</div>
</div>
@@ -2586,6 +2626,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
</span>
{foundryStatus?.loadedModelId ?? t('localAsr.foundryNotLoaded')}
</div>
<div>{t('localAsr.keepLoadedDesc')}</div>
{foundryStatus?.error && (
<div style={{ color: '#9b2c2c' }}>
<span>{t('localAsr.foundryError')}: </span>