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:
1 parent
c4e8e88782
commit
c8bd7aa295
22 files changed
+364
-71
No files matched your search
@@ -63,6 +63,7 @@
|
||||
"foundry_local_asr_release",
|
||||
"foundry_local_asr_reveal_model_dir",
|
||||
"foundry_local_asr_set_language_hint",
|
||||
"foundry_local_asr_set_keep_loaded_secs",
|
||||
"foundry_local_asr_set_model",
|
||||
"foundry_local_asr_set_runtime_source",
|
||||
"foundry_local_asr_status",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -2120,7 +2120,7 @@ export const ja: typeof zhCN = {
|
||||
releaseNow: '今すぐ解放',
|
||||
keepLoadedLabel: 'ロード保持時間',
|
||||
keepLoadedDesc:
|
||||
'ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。',
|
||||
'現在のローカル ASR を次回の文字起こし後に保持する時間を指定します。「解放しない」は手動解放または終了まで保持します。',
|
||||
keepImmediate: '使用直後に解放',
|
||||
keep1min: '最終使用から 1 分',
|
||||
keep5min: '最終使用から 5 分(既定)',
|
||||
|
||||
@@ -2102,7 +2102,7 @@ export const ko: typeof zhCN = {
|
||||
releaseNow: '지금 해제',
|
||||
keepLoadedLabel: '로드 유지 시간',
|
||||
keepLoadedDesc:
|
||||
'로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.',
|
||||
'현재 로컬 ASR 엔진을 다음 전사 후 얼마나 유지할지 정합니다. 해제하지 않음은 수동 해제 또는 종료까지 유지합니다.',
|
||||
keepImmediate: '말하기 직후 해제',
|
||||
keep1min: '마지막 사용 후 1분',
|
||||
keep5min: '마지막 사용 후 5분(기본)',
|
||||
|
||||
@@ -2030,7 +2030,8 @@ export const zhCN = {
|
||||
loadNow: '立即加载',
|
||||
releaseNow: '立即释放',
|
||||
keepLoadedLabel: '保持加载多久',
|
||||
keepLoadedDesc: '决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。',
|
||||
keepLoadedDesc:
|
||||
'决定当前本地 ASR 引擎在下次转写后保持加载多久;“不释放”会持续驻留至手动释放或退出。',
|
||||
keepImmediate: '说完话立即释放',
|
||||
keep1min: '上次使用后 1 分钟',
|
||||
keep5min: '上次使用后 5 分钟(默认)',
|
||||
|
||||
@@ -2016,7 +2016,8 @@ export const zhTW: typeof zhCN = {
|
||||
loadNow: '立即加載',
|
||||
releaseNow: '立即釋放',
|
||||
keepLoadedLabel: '保持加載多久',
|
||||
keepLoadedDesc: '決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。',
|
||||
keepLoadedDesc:
|
||||
'決定目前本地 ASR 引擎在下次轉寫後保持載入多久;「不釋放」會持續駐留至手動釋放或退出。',
|
||||
keepImmediate: '說完話立即釋放',
|
||||
keep1min: '上次使用後 1 分鐘',
|
||||
keep5min: '上次使用後 5 分鐘(默認)',
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user