Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 90 additions & 71 deletions openless-all/app/src-tauri/src/asr/local/foundry_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use std::io::Write;
#[cfg(target_os = "windows")]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(target_os = "windows")]
use std::sync::Arc;

#[cfg(target_os = "windows")]
Expand All @@ -22,11 +21,18 @@ use crate::asr::RawTranscript;

#[cfg(target_os = "windows")]
use super::foundry_runtime::FoundryLocalRuntime;
use super::foundry_runtime::{FoundryFallbackNoticeCallback, FoundryPrimaryRecoveryToken};

/// Foundry Local Whisper 属于 Whisper 系模型,原生解码窗口约 30s。每次 SDK
/// 请求保持在窗口内,再由 OpenLess 合并分片文本,避免长听写只返回第一段。
const FOUNDRY_WHISPER_CHUNK_LIMIT_MS: u64 = 30_000;

pub(crate) struct FoundryProviderTranscription {
pub raw: RawTranscript,
pub used_cpu_fallback: bool,
pub primary_recovery: Option<FoundryPrimaryRecoveryToken>,
}

pub struct FoundryLocalWhisperAsr {
#[cfg(target_os = "windows")]
runtime: Arc<FoundryLocalRuntime>,
Expand Down Expand Up @@ -81,16 +87,34 @@ impl FoundryLocalWhisperAsr {
}

pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result<RawTranscript> {
Ok(self
.transcribe_with_fallback_notice(audio_timeout, Arc::new(|_| {}))
.await?
.raw)
}

/// 转写当前录音,并在 Foundry 的一次性 GPU→CPU 回退期间同步最小 UI 提示。
///
/// 普通转写与历史重新转录继续调用 `transcribe`,因此不会创建新的 UI 协议或提示。
pub(crate) async fn transcribe_with_fallback_notice(
&self,
audio_timeout: std::time::Duration,
notices: FoundryFallbackNoticeCallback,
) -> Result<FoundryProviderTranscription> {
let cancel_generation = self.cancel_generation.load(Ordering::SeqCst);
let pcm = self.buffer.lock().clone();
if pcm.is_empty() {
return Ok(RawTranscript {
text: String::new(),
duration_ms: 0,
return Ok(FoundryProviderTranscription {
raw: RawTranscript {
text: String::new(),
duration_ms: 0,
},
used_cpu_fallback: false,
primary_recovery: None,
});
}

let result = self.transcribe_inner(&pcm, audio_timeout).await;
let result = self.transcribe_inner(&pcm, audio_timeout, notices).await;
if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation {
anyhow::bail!("Foundry Local Whisper transcription cancelled");
}
Expand All @@ -104,12 +128,14 @@ impl FoundryLocalWhisperAsr {
&self,
pcm: &[u8],
audio_timeout: std::time::Duration,
) -> Result<RawTranscript> {
notices: FoundryFallbackNoticeCallback,
) -> Result<FoundryProviderTranscription> {
let duration_ms = pcm_duration_ms(pcm);

#[cfg(not(target_os = "windows"))]
{
let _ = pcm;
let _ = notices;
anyhow::bail!(
"Foundry Local Whisper is only available on Windows: {}",
self.model_alias
Expand All @@ -122,9 +148,6 @@ impl FoundryLocalWhisperAsr {
pcm,
Some(FOUNDRY_WHISPER_CHUNK_LIMIT_MS),
);
let deadline = std::time::Instant::now()
.checked_add(audio_timeout)
.ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper timeout is too large"))?;
if chunks.len() > 1 {
log::info!(
"[foundry-asr] splitting {:.2}s audio into {} chunks (limit={}ms)",
Expand All @@ -134,50 +157,72 @@ impl FoundryLocalWhisperAsr {
);
}

let mut texts = Vec::with_capacity(chunks.len());
for (index, chunk) in chunks.iter().enumerate() {
let wav_file = TempWavFile::create(chunk)?;
let remaining_timeout =
remaining_transcribe_timeout(deadline, std::time::Instant::now())
.with_context(|| {
format!(
"Foundry Local Whisper total timeout exhausted before chunk {}/{}",
index + 1,
chunks.len()
)
})?;
let text = self
.runtime
.transcribe_audio_file(
&self.model_alias,
&self.runtime_source,
self.language_hint(),
wav_file.path(),
remaining_timeout,
// 所有临时 WAV 必须在单次 runtime 调用结束后才释放:GPU 失败时,runtime 才能让
// CPU 重试失败分片并继续后续分片,保持整段录音的一致执行路线。
let wav_files = chunks
.iter()
.map(|chunk| TempWavFile::create(chunk))
.collect::<Result<Vec<_>>>()?;
let audio_paths = wav_files
.iter()
.map(|wav_file| wav_file.path().to_path_buf())
.collect::<Vec<_>>();
let outcome = self
.runtime
.transcribe_audio_files(
&self.model_alias,
&self.runtime_source,
self.language_hint(),
&audio_paths,
audio_timeout,
notices,
)
.await
.with_context(|| {
format!(
"transcribe Foundry Local Whisper recording ({} chunks) with model {}",
chunks.len(),
self.model_alias
)
.await
.with_context(|| {
format!(
"transcribe Foundry Local Whisper chunk {}/{} with model {}",
index + 1,
chunks.len(),
self.model_alias
)
})?;
texts.push(trim_transcript_text(&text));
}

Ok(RawTranscript {
text: crate::asr::whisper::join_transcript_chunks(&texts),
duration_ms,
})?;
let texts = outcome
.texts
.iter()
.map(|text| trim_transcript_text(text))
.collect::<Vec<_>>();

Ok(FoundryProviderTranscription {
raw: RawTranscript {
text: crate::asr::whisper::join_transcript_chunks(&texts),
duration_ms,
},
used_cpu_fallback: outcome.used_cpu_fallback,
primary_recovery: outcome.primary_recovery,
})
}
}

pub fn cancel(&self) {
self.cancel_generation.fetch_add(1, Ordering::SeqCst);
#[cfg(target_os = "windows")]
self.runtime.request_cancel_prepare();
{
self.runtime.request_cancel_prepare();
// `end_session` 会 drop 在途 future;若此时已切到临时 CPU,不能等待普通模型
// 保活计时器才释放。lease 上界将清理限定到当前录音,避免旧取消影响下一段录音。
if let Some(cancelled_through) = self.runtime.cancellation_cleanup_lease() {
let runtime = Arc::clone(&self.runtime);
tauri::async_runtime::spawn(async move {
if let Err(error) = runtime
.release_temporary_cpu_fallback(cancelled_through)
.await
{
log::warn!(
"[foundry-asr] cancel cleanup for temporary CPU fallback failed: {error:#}"
);
}
});
}
}
self.buffer.lock().clear();
}
}
Expand All @@ -192,16 +237,6 @@ fn pcm_duration_ms(pcm: &[u8]) -> u64 {
crate::asr::pcm::pcm_duration_ms(pcm)
}

fn remaining_transcribe_timeout(
deadline: std::time::Instant,
now: std::time::Instant,
) -> Result<std::time::Duration> {
deadline
.checked_duration_since(now)
.filter(|duration| !duration.is_zero())
.ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper total timeout exhausted"))
}

fn pcm_to_wav(pcm: &[u8]) -> Vec<u8> {
let samples: Vec<i16> = pcm
.chunks_exact(2)
Expand Down Expand Up @@ -358,22 +393,6 @@ mod tests {
assert_eq!(chunks[2].len(), 32_000 * 5);
}

#[test]
fn foundry_chunk_timeout_uses_remaining_total_budget() {
let started = std::time::Instant::now();
let deadline = started + std::time::Duration::from_secs(85);

assert_eq!(
super::remaining_transcribe_timeout(
deadline,
started + std::time::Duration::from_secs(30),
)
.unwrap(),
std::time::Duration::from_secs(55)
);
assert!(super::remaining_transcribe_timeout(deadline, deadline).is_err());
}

#[test]
fn foundry_provider_reports_buffer_duration_without_consuming() {
#[cfg(target_os = "windows")]
Expand Down
Loading
Loading