From c038676edd7a6edd0a7ef138362ef82a3298d04c Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Tue, 15 Sep 2026 13:02:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(history):=20=E6=94=AF=E6=8C=81=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9D=A1=E7=9B=AE=E9=87=8D=E6=96=B0=E8=BD=AC?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- memory-graph.md | 41 +++++++++++++++ .../app/src-tauri/src/commands/history.rs | 2 +- .../app/src/lib/history-retranscribe.test.ts | 52 +++++++++++++++++++ .../app/src/lib/history-retranscribe.ts | 14 +++++ openless-all/app/src/lib/ipc/history.ts | 5 +- openless-all/app/src/pages/History.tsx | 19 +++---- 6 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 memory-graph.md create mode 100644 openless-all/app/src/lib/history-retranscribe.test.ts create mode 100644 openless-all/app/src/lib/history-retranscribe.ts diff --git a/memory-graph.md b/memory-graph.md new file mode 100644 index 000000000..26ab74465 --- /dev/null +++ b/memory-graph.md @@ -0,0 +1,41 @@ +# Memory Graph — openless + +- slug: openless +- path: f:/编程/openless +- updated: 2026-09-15 + +## Summary + +OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openless,维护者克隆 HKLHaoBin/openless。 + +## Entities + +- SelectionVoice (Feature): 选区语音问答/编辑,EditPlan 结构化改写 +- EditPlan (Module): XML/JSON 操作计划,本地确定性 apply +- StylePack (Entity): 含 prompt / selectionPrompt / voiceEditPrompt +- Issue1076 (Issue): EditPlan 解析失败(模型输出正文) +- Issue1046 (Issue): 历史页对已完成转录的录音提供重新转录与试听 + +## Relations + +- SelectionVoice --uses--> EditPlan: 编辑意图生成方案后替换选区 +- SelectionVoice --reads--> StylePack.voiceEditPrompt: 空则 prefs 自定义,再则默认 XML/JSON prompt +- EditPlan --parses-with-priority--> Xml|Json: 用户选择优先格式,另一种兜底 +- QA Panel --shows--> model_output: 解析失败时展示原始模型输出 +- History --retranscribes--> archived_recording: 有归档 WAV 的传统 ASR 条目可用当前 provider 重转 + +## Facts + +- 2026-09-14 开分支 fix/selection-voice-editplan-prompt-format(基于 upstream/beta) +- Issue: https://github.com/Open-Less/openless/issues/1076(完全解决前不提 PR) +- PR: https://github.com/Open-Less/openless/pull/1077(目标 beta,关联并关闭 #1076) +- 根因:听写润色 user framing「只输出正文」与 EditPlan system prompt 冲突 +- folia-major 参考:OUTPUT CONTRACT + 剥围栏/平衡括号候选解析 +- 2026-09-15 基于 upstream/beta 创建 fix/1046-history-retranscription;历史页将重新转录入口从失败状态扩展到所有有归档录音的传统 ASR 条目,继续保留多模态能力边界 + +## Decisions + +- 用户可选 EditPlan 输出优先 XML 或 JSON;解析双向兜底 +- 提示词:设置自定义 > 风格包 voiceEditPrompt > 内置默认 +- 失败错误保留 ---model_output--- 供 QA 面板展示 +- 重新转录按钮不改变录音归档隐私策略:只有 `hasAudioRecording` 为 true 且非多模态条目展示,成功/润色失败/转录失败均可使用 diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 268297aa5..dc69b3aec 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -203,7 +203,7 @@ fn copy_recording_to_mobile_url( Ok(()) } -/// 对一条「转录失败」历史条目的归档录音用**当前** ASR provider 重新转录(issue #613)。 +/// 对一条有归档录音的历史条目用**当前** ASR provider 重新转录(issue #613 / #1046)。 /// /// 流程:读 `recordings/.wav` → 取 PCM(跳过 44 字节 WAV 头)→ 现 provider 重转 /// → 成功则原地回写该条历史的 rawTranscript / finalText、清除 error_code,返回新文本。 diff --git a/openless-all/app/src/lib/history-retranscribe.test.ts b/openless-all/app/src/lib/history-retranscribe.test.ts new file mode 100644 index 000000000..49ec97ac2 --- /dev/null +++ b/openless-all/app/src/lib/history-retranscribe.test.ts @@ -0,0 +1,52 @@ +import { canRetranscribeHistoryEntry } from './history-retranscribe'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const archivedEntry = { + hasAudioRecording: true, + pipelineMode: 'traditional', +} as const; + +assert( + canRetranscribeHistoryEntry({ ...archivedEntry, errorCode: null }), + 'completed entries with an archived recording should be retranscribable', +); +assert( + canRetranscribeHistoryEntry({ ...archivedEntry, errorCode: 'polishFailed' }), + 'entries whose polishing failed should still be retranscribable', +); +assert( + canRetranscribeHistoryEntry({ + ...archivedEntry, + errorCode: 'transcribeFailed', + }), + 'entries whose transcription failed should be retranscribable', +); +assert( + !canRetranscribeHistoryEntry({ + hasAudioRecording: false, + pipelineMode: 'traditional', + errorCode: null, + }), + 'entries without an archived recording should not show retranscription', +); +assert( + !canRetranscribeHistoryEntry({ + hasAudioRecording: null, + pipelineMode: undefined, + errorCode: null, + }), + 'legacy entries without recording metadata should not show retranscription', +); +assert( + !canRetranscribeHistoryEntry({ + ...archivedEntry, + pipelineMode: 'multimodal', + errorCode: null, + }), + 'multimodal entries should not show an unsupported retranscription action', +); + +console.log('history-retranscribe: all assertions passed'); diff --git a/openless-all/app/src/lib/history-retranscribe.ts b/openless-all/app/src/lib/history-retranscribe.ts new file mode 100644 index 000000000..b13a9b952 --- /dev/null +++ b/openless-all/app/src/lib/history-retranscribe.ts @@ -0,0 +1,14 @@ +import type { DictationSession } from './types'; + +/** + * 重新转录需要一份仍存在的 WAV 归档。多模态历史目前没有对应的重转录 + * provider 通道,避免展示一个点击后必然失败的按钮。 + * + * errorCode 不参与判断:成功转录、润色失败和转录失败的条目都可能有可用录音, + * 用户都应能用同一份音频重新验证当前 ASR provider。 + */ +export function canRetranscribeHistoryEntry( + session: Pick, +): boolean { + return session.hasAudioRecording === true && session.pipelineMode !== 'multimodal'; +} diff --git a/openless-all/app/src/lib/ipc/history.ts b/openless-all/app/src/lib/ipc/history.ts index f854c791b..1058bc039 100644 --- a/openless-all/app/src/lib/ipc/history.ts +++ b/openless-all/app/src/lib/ipc/history.ts @@ -26,9 +26,10 @@ export function readAudioRecording(sessionId: string): Promise { return invokeOrMock('read_audio_recording', { sessionId }, () => 'data:audio/wav;base64,'); } -/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。 +/** 用当前 ASR provider 对一条有归档录音的历史条目重新转录(issue #613 / #1046)。 * 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。 - * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 */ + * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 + * 成功、润色失败和转录失败的条目都可调用,前端负责隐藏没有录音或不支持的条目。 */ export function retranscribeRecording(sessionId: string): Promise { return invokeOrMock( 'retranscribe_recording', diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index c9fc8368f..981b7513f 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -22,6 +22,7 @@ import { packDisplayName, resolveRepolishRetryPackIdWithFallback, } from '../lib/history-repolish'; +import { canRetranscribeHistoryEntry } from '../lib/history-retranscribe'; import { useMobileLayout } from '../lib/useMobileLayout'; import type { DictationSession, PolishMode, StylePack } from '../lib/types'; import { countCodePoints } from '../lib/unicode'; @@ -300,12 +301,12 @@ export function History() { } }; - // 对一条「转录失败 / 没识别到语音」的历史用当前 ASR provider 重新转录(issue #613)。 + // 对一条有归档录音的历史用当前 ASR provider 重新转录(issue #613 / #1046)。 // 后端读 recordings/.wav → 重转 → 原地回写该条 rawTranscript/finalText、清 errorCode, - // 返回整条记录;前端据此局部刷新。失败保留 + 自动重试已让这些条目的录音留得住,这里给 - // 持久失败(重试也没救回来)一个手动重转入口。 + // 返回整条记录;前端据此局部刷新。无论上次转录成功、润色失败还是转录失败,只要录音仍在, + // 用户都可以用同一份音频重新验证当前 provider 的结果。 const onRetranscribe = async () => { - if (!item || !item.hasAudioRecording) return; + if (!item || !canRetranscribeHistoryEntry(item)) return; setRetranscribing(true); setActionError(null); try { @@ -604,11 +605,7 @@ export function History() { {t('history.exportRecording')} )} - {item.hasAudioRecording && - !audioMissingIds.has(item.id) && - item.pipelineMode !== 'multimodal' && - (item.errorCode === 'transcribeFailed' || - item.errorCode === 'emptyTranscript') && ( + {canRetranscribeHistoryEntry(item) && !audioMissingIds.has(item.id) && ( Date: Tue, 15 Sep 2026 13:42:00 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Issue=201046?= =?UTF-8?q?=20=E4=BA=A4=E4=BB=98=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- memory-graph.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/memory-graph.md b/memory-graph.md index 26ab74465..185a97161 100644 --- a/memory-graph.md +++ b/memory-graph.md @@ -32,6 +32,8 @@ OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openl - 根因:听写润色 user framing「只输出正文」与 EditPlan system prompt 冲突 - folia-major 参考:OUTPUT CONTRACT + 剥围栏/平衡括号候选解析 - 2026-09-15 基于 upstream/beta 创建 fix/1046-history-retranscription;历史页将重新转录入口从失败状态扩展到所有有归档录音的传统 ASR 条目,继续保留多模态能力边界 +- 2026-09-15 提交 c038676e 并推送至 origin/fix/1046-history-retranscription;review-bugbot 复审结论为无 bug,CI、Android APK 与跨平台桌面发布构建均成功 +- 2026-09-15 已下载 Android 四架构 APK 与 Windows/macOS 桌面产物;APK ZIP、Updater JSON、macOS updater tar.gz 及文件完整性静态校验全部通过,因无连接 ADB 设备未执行真机安装 ## Decisions From 0e271b797d3cc36baaddf945990f5d2df4766106 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Tue, 15 Sep 2026 21:32:25 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20Issue=201046=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memory-graph.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/memory-graph.md b/memory-graph.md index 185a97161..5442059fb 100644 --- a/memory-graph.md +++ b/memory-graph.md @@ -1,7 +1,7 @@ # Memory Graph — openless - slug: openless -- path: f:/编程/openless +- path: `F:/编程/openless` - updated: 2026-09-15 ## Summary @@ -26,6 +26,7 @@ OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openl ## Facts +- 2026-09-15:扫描确认该项目包含 `.cursor`,已纳入本次全局图谱一致性更新。 - 2026-09-14 开分支 fix/selection-voice-editplan-prompt-format(基于 upstream/beta) - Issue: https://github.com/Open-Less/openless/issues/1076(完全解决前不提 PR) - PR: https://github.com/Open-Less/openless/pull/1077(目标 beta,关联并关闭 #1076) @@ -34,6 +35,8 @@ OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openl - 2026-09-15 基于 upstream/beta 创建 fix/1046-history-retranscription;历史页将重新转录入口从失败状态扩展到所有有归档录音的传统 ASR 条目,继续保留多模态能力边界 - 2026-09-15 提交 c038676e 并推送至 origin/fix/1046-history-retranscription;review-bugbot 复审结论为无 bug,CI、Android APK 与跨平台桌面发布构建均成功 - 2026-09-15 已下载 Android 四架构 APK 与 Windows/macOS 桌面产物;APK ZIP、Updater JSON、macOS updater tar.gz 及文件完整性静态校验全部通过,因无连接 ADB 设备未执行真机安装 +- 2026-09-15 全局扫描确认项目根目录已有 `memory-graph.md`,纳入按项目名路由表;当前工作区仍有未提交的 vendor 修改 +- 2026-09-15 向上游 `Open-Less/openless` 的 `beta` 提交 PR #1079;克隆仓库误建的 PR #6 已关闭,源分支仍为 `HKLHaoBin:fix/1046-history-retranscription` ## Decisions From 0bab0defc3e78fb2b8143b296677dfadf85d5518 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 16 Sep 2026 21:42:30 +0800 Subject: [PATCH 4/4] fix(history): preserve records and split long Qwen audio --- memory-graph.md | 46 ---------- .../app/crates/openless-core/src/api.rs | 45 +++++++--- .../src-tauri/src/asr/local/sherpa_runtime.rs | 84 ++++++++++++++----- .../app/src-tauri/src/commands/history.rs | 62 ++++++++++++-- .../app/src/lib/history-retranscribe.test.ts | 14 ++-- .../app/src/lib/history-retranscribe.ts | 6 +- openless-all/app/src/lib/ipc/history.ts | 20 +++-- openless-all/app/src/pages/History.tsx | 59 ++++++++----- 8 files changed, 214 insertions(+), 122 deletions(-) delete mode 100644 memory-graph.md diff --git a/memory-graph.md b/memory-graph.md deleted file mode 100644 index 5442059fb..000000000 --- a/memory-graph.md +++ /dev/null @@ -1,46 +0,0 @@ -# Memory Graph — openless - -- slug: openless -- path: `F:/编程/openless` -- updated: 2026-09-15 - -## Summary - -OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openless,维护者克隆 HKLHaoBin/openless。 - -## Entities - -- SelectionVoice (Feature): 选区语音问答/编辑,EditPlan 结构化改写 -- EditPlan (Module): XML/JSON 操作计划,本地确定性 apply -- StylePack (Entity): 含 prompt / selectionPrompt / voiceEditPrompt -- Issue1076 (Issue): EditPlan 解析失败(模型输出正文) -- Issue1046 (Issue): 历史页对已完成转录的录音提供重新转录与试听 - -## Relations - -- SelectionVoice --uses--> EditPlan: 编辑意图生成方案后替换选区 -- SelectionVoice --reads--> StylePack.voiceEditPrompt: 空则 prefs 自定义,再则默认 XML/JSON prompt -- EditPlan --parses-with-priority--> Xml|Json: 用户选择优先格式,另一种兜底 -- QA Panel --shows--> model_output: 解析失败时展示原始模型输出 -- History --retranscribes--> archived_recording: 有归档 WAV 的传统 ASR 条目可用当前 provider 重转 - -## Facts - -- 2026-09-15:扫描确认该项目包含 `.cursor`,已纳入本次全局图谱一致性更新。 -- 2026-09-14 开分支 fix/selection-voice-editplan-prompt-format(基于 upstream/beta) -- Issue: https://github.com/Open-Less/openless/issues/1076(完全解决前不提 PR) -- PR: https://github.com/Open-Less/openless/pull/1077(目标 beta,关联并关闭 #1076) -- 根因:听写润色 user framing「只输出正文」与 EditPlan system prompt 冲突 -- folia-major 参考:OUTPUT CONTRACT + 剥围栏/平衡括号候选解析 -- 2026-09-15 基于 upstream/beta 创建 fix/1046-history-retranscription;历史页将重新转录入口从失败状态扩展到所有有归档录音的传统 ASR 条目,继续保留多模态能力边界 -- 2026-09-15 提交 c038676e 并推送至 origin/fix/1046-history-retranscription;review-bugbot 复审结论为无 bug,CI、Android APK 与跨平台桌面发布构建均成功 -- 2026-09-15 已下载 Android 四架构 APK 与 Windows/macOS 桌面产物;APK ZIP、Updater JSON、macOS updater tar.gz 及文件完整性静态校验全部通过,因无连接 ADB 设备未执行真机安装 -- 2026-09-15 全局扫描确认项目根目录已有 `memory-graph.md`,纳入按项目名路由表;当前工作区仍有未提交的 vendor 修改 -- 2026-09-15 向上游 `Open-Less/openless` 的 `beta` 提交 PR #1079;克隆仓库误建的 PR #6 已关闭,源分支仍为 `HKLHaoBin:fix/1046-history-retranscription` - -## Decisions - -- 用户可选 EditPlan 输出优先 XML 或 JSON;解析双向兜底 -- 提示词:设置自定义 > 风格包 voiceEditPrompt > 内置默认 -- 失败错误保留 ---model_output--- 供 QA 面板展示 -- 重新转录按钮不改变录音归档隐私策略:只有 `hasAudioRecording` 为 true 且非多模态条目展示,成功/润色失败/转录失败均可使用 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 499436b6a..2bb89c078 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -4275,6 +4275,15 @@ impl OpenLessBackend { .ok_or_else(|| { BackendError::new(BackendErrorCode::InvalidArgument, "history entry not found") })?; + if !matches!( + entry.error_code.as_deref(), + Some("transcribeFailed" | "emptyTranscript") + ) { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "history entry is not a failed transcription", + )); + } entry.raw_transcript = text.clone(); entry.final_text = text; entry.error_code = None; @@ -7588,21 +7597,35 @@ mod tests { let mut entry = history_session("one"); backend.append_history(entry.clone(), 30, Some(20)).unwrap(); + let asr_call = crate::auxiliary::AsrCallLabel { + provider: "channel-b".into(), + model: Some("model-b".into()), + }; + let completed_error = backend + .apply_history_retranscription( + &entry.id, + "must not replace history".into(), + &asr_call, + 1, + ) + .unwrap_err(); + assert_eq!(completed_error.code, BackendErrorCode::InvalidState); + entry.final_text = "updated".to_string(); + entry.error_code = Some("polishFailed".to_string()); + assert!(backend.update_history_entry(entry.clone()).unwrap()); + let polish_error = backend + .apply_history_retranscription(&entry.id, "must remain a preview".into(), &asr_call, 1) + .unwrap_err(); + assert_eq!(polish_error.code, BackendErrorCode::InvalidState); + + entry.error_code = Some("transcribeFailed".to_string()); assert!(backend.update_history_entry(entry.clone()).unwrap()); assert!(!backend .update_history_entry(history_session("missing")) .unwrap()); let retranscribed = backend - .apply_history_retranscription( - &entry.id, - "retranscribed".into(), - &crate::auxiliary::AsrCallLabel { - provider: "channel-b".into(), - model: Some("model-b".into()), - }, - 480, - ) + .apply_history_retranscription(&entry.id, "retranscribed".into(), &asr_call, 480) .unwrap(); assert_eq!(retranscribed.raw_transcript, "retranscribed"); assert_eq!(retranscribed.final_text, "retranscribed"); @@ -7619,8 +7642,8 @@ mod tests { assert!(backend.list_history().unwrap().is_empty()); assert_eq!(backend.list_activity().unwrap()[0].chars, 42); - assert_eq!(backend.snapshot().history_revision, 6); - for expected_revision in 1..=6 { + assert_eq!(backend.snapshot().history_revision, 7); + for expected_revision in 1..=7 { assert_eq!( events.try_recv().unwrap().kind, BackendEventKind::HistoryChanged(HistoryChange { diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs index f9dad6de2..4de373d35 100644 --- a/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs @@ -22,6 +22,9 @@ use crate::asr::local::sherpa::{ SherpaPreparePhase, SherpaPrepareProgressPayload, SherpaRuntimeStatus, PROVIDER_ID, }; +const QWEN3_ASR_CHUNK_LIMIT_MS: u64 = 30_000; +const QWEN3_ASR_MAX_NEW_TOKENS: i32 = 256; + #[cfg(target_os = "windows")] use sherpa_onnx::{ OfflineParaformerModelConfig, OfflineQwen3ASRModelConfig, OfflineRecognizer, @@ -225,12 +228,16 @@ impl SherpaOnnxRuntime { if pcm.is_empty() { return Ok(String::new()); } - if sherpa_target(alias)?.sherpa_execution_mode() - != Some(openless_core::LocalAsrExecutionMode::Offline) - { + let target = sherpa_target(alias)?; + if target.sherpa_execution_mode() != Some(openless_core::LocalAsrExecutionMode::Offline) { anyhow::bail!("sherpa-onnx model {alias} is online-only; use streaming API"); } let audio_ms = pcm_duration_ms(pcm); + let pcm_chunks = sherpa_offline_chunks(&target, pcm) + .into_iter() + .map(|chunk| chunk.to_vec()) + .collect::>(); + let chunk_count = pcm_chunks.len(); let loaded_alias = self.ensure_loaded(alias, model_dir).await?; let loaded = self .state @@ -242,7 +249,7 @@ impl SherpaOnnxRuntime { let started = Instant::now(); let result = transcribe_loaded_model( loaded, - pcm.to_vec(), + pcm_chunks, language_hint.map(str::to_string), audio_timeout, ) @@ -251,9 +258,10 @@ impl SherpaOnnxRuntime { match &result { Ok(text) => { log::info!( - "[sherpa-asr] transcribe finished model={} audio_ms={} elapsed_ms={} text_chars={}", + "[sherpa-asr] transcribe finished model={} audio_ms={} chunks={} elapsed_ms={} text_chars={}", alias, audio_ms, + chunk_count, elapsed_ms, text.chars().count() ); @@ -262,9 +270,10 @@ impl SherpaOnnxRuntime { Err(error) => { let message = format!("{error:#}"); log::warn!( - "[sherpa-asr] transcribe failed model={} audio_ms={} elapsed_ms={} error={}", + "[sherpa-asr] transcribe failed model={} audio_ms={} chunks={} elapsed_ms={} error={}", alias, audio_ms, + chunk_count, elapsed_ms, message ); @@ -426,6 +435,15 @@ fn pcm_duration_ms(pcm: &[u8]) -> u64 { crate::asr::pcm::pcm_duration_ms(pcm) } +fn sherpa_offline_chunks<'a>( + target: &openless_core::LocalAsrTarget, + pcm: &'a [u8], +) -> Vec<&'a [u8]> { + let limit = (target.sherpa_family() == Some(openless_core::SherpaModelFamily::Qwen3Asr)) + .then_some(QWEN3_ASR_CHUNK_LIMIT_MS); + openless_core::asr::whisper::split_pcm_by_duration(pcm, limit) +} + enum LoadedModel { Offline(LoadedOfflineModel), Online(LoadedOnlineModel), @@ -515,6 +533,7 @@ fn create_offline_recognizer(alias: &str, dir: &Path) -> Result Result { #[cfg(target_os = "windows")] async fn transcribe_loaded_model( loaded: LoadedOfflineModel, - pcm: Vec, + pcm_chunks: Vec>, language_hint: Option, audio_timeout: std::time::Duration, ) -> Result { tokio::time::timeout(audio_timeout, async move { tokio::task::spawn_blocking(move || { - let samples = pcm_s16le_to_f32(&pcm)?; - let stream = loaded.recognizer.create_stream(); - if let Some(language) = language_hint.as_deref().filter(|value| !value.is_empty()) { - if stream.has_option("language") { - stream.set_option("language", language); + let mut texts = Vec::with_capacity(pcm_chunks.len()); + for pcm in pcm_chunks { + let samples = pcm_s16le_to_f32(&pcm)?; + let stream = loaded.recognizer.create_stream(); + if let Some(language) = language_hint.as_deref().filter(|value| !value.is_empty()) { + if stream.has_option("language") { + stream.set_option("language", language); + } } + stream.accept_waveform(16_000, &samples); + loaded.recognizer.decode(&stream); + let result = stream + .get_result() + .ok_or_else(|| anyhow::anyhow!("sherpa-onnx returned no result"))?; + texts.push(result.text); } - stream.accept_waveform(16_000, &samples); - loaded.recognizer.decode(&stream); - let result = stream - .get_result() - .ok_or_else(|| anyhow::anyhow!("sherpa-onnx returned no result"))?; - Ok(result.text) + Ok(openless_core::asr::whisper::join_transcript_chunks(&texts)) }) .await .map_err(|e| anyhow::anyhow!("sherpa-onnx transcribe join failed: {e:#}"))? @@ -599,7 +622,7 @@ async fn transcribe_loaded_model( #[cfg(not(target_os = "windows"))] async fn transcribe_loaded_model( _loaded: LoadedOfflineModel, - _pcm: Vec, + _pcm_chunks: Vec>, _language_hint: Option, _audio_timeout: std::time::Duration, ) -> Result { @@ -936,6 +959,29 @@ mod tests { assert!(format!("{:#}", result.unwrap_err()).contains("online-only")); } + #[test] + fn only_qwen3_offline_audio_is_split_at_thirty_seconds() { + let qwen = sherpa_target("qwen3-asr-0.6b-int8").unwrap(); + let thirty_seconds = vec![0; 32_000 * 30]; + assert_eq!( + sherpa_offline_chunks(&qwen, &thirty_seconds), + vec![thirty_seconds.as_slice()] + ); + + let pcm = vec![0; 32_000 * 65]; + let chunks = sherpa_offline_chunks(&qwen, &pcm); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].len(), 32_000 * 30); + assert_eq!(chunks[1].len(), 32_000 * 30); + assert_eq!(chunks[2].len(), 32_000 * 5); + + let sense_voice = sherpa_target("sense-voice-small-zh").unwrap(); + assert_eq!( + sherpa_offline_chunks(&sense_voice, &pcm), + vec![pcm.as_slice()] + ); + } + #[tokio::test] async fn create_online_session_rejects_offline_model_alias() { let runtime = SherpaOnnxRuntime::new(); diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index dc69b3aec..eeb88f6d5 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -203,22 +203,44 @@ fn copy_recording_to_mobile_url( Ok(()) } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryRetranscriptionResult { + text: String, + updated_entry: Option, +} + +fn should_replace_failed_history(error_code: Option<&str>) -> bool { + matches!(error_code, Some("transcribeFailed" | "emptyTranscript")) +} + /// 对一条有归档录音的历史条目用**当前** ASR provider 重新转录(issue #613 / #1046)。 /// /// 流程:读 `recordings/.wav` → 取 PCM(跳过 44 字节 WAV 头)→ 现 provider 重转 -/// → 成功则原地回写该条历史的 rawTranscript / finalText、清除 error_code,返回新文本。 +/// → 失败记录原地修复;已完成 / 润色失败记录仅返回临时结果,不覆盖历史事实。 /// /// 仅重新转写音频,不调用 LLM 润色。失败时 -/// 不动历史、不删录音,把错误返回给前端提示,用户可重试。返回更新后的整条记录给前端 -/// 局部刷新。 +/// 不动历史、不删录音,把错误返回给前端提示,用户可重试。 #[tauri::command] pub async fn retranscribe_recording( core: CoreState<'_>, session_id: String, -) -> Result { +) -> Result { if !is_valid_session_id(&session_id) { return Err("invalid session id".into()); } + let entry = core + .list_history() + .map_err(|e| e.to_string())? + .into_iter() + .find(|entry| entry.id == session_id) + .ok_or_else(|| "history entry not found".to_string())?; + if entry.has_audio_recording != Some(true) { + return Err("history entry has no archived recording".into()); + } + if entry.pipeline_mode.as_deref() == Some("multimodal") { + return Err("multimodal history does not support retranscription".into()); + } let path = crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; let wav = tokio::fs::read(&path).await.map_err(|e| { @@ -248,6 +270,34 @@ pub async fn retranscribe_recording( } let retranscribe_ms = retranscribe_started.elapsed().as_millis() as u64; - core.apply_history_retranscription(&session_id, text, &asr_call_label, retranscribe_ms) - .map_err(|e| e.to_string()) + let updated_entry = if should_replace_failed_history(entry.error_code.as_deref()) { + Some( + core.apply_history_retranscription( + &session_id, + text.clone(), + &asr_call_label, + retranscribe_ms, + ) + .map_err(|e| e.to_string())?, + ) + } else { + None + }; + Ok(HistoryRetranscriptionResult { + text, + updated_entry, + }) +} + +#[cfg(test)] +mod retranscription_tests { + use super::should_replace_failed_history; + + #[test] + fn only_failed_transcriptions_are_replaced() { + assert!(should_replace_failed_history(Some("transcribeFailed"))); + assert!(should_replace_failed_history(Some("emptyTranscript"))); + assert!(!should_replace_failed_history(Some("polishFailed"))); + assert!(!should_replace_failed_history(None)); + } } diff --git a/openless-all/app/src/lib/history-retranscribe.test.ts b/openless-all/app/src/lib/history-retranscribe.test.ts index 49ec97ac2..8c2a4a19f 100644 --- a/openless-all/app/src/lib/history-retranscribe.test.ts +++ b/openless-all/app/src/lib/history-retranscribe.test.ts @@ -9,26 +9,24 @@ const archivedEntry = { pipelineMode: 'traditional', } as const; +const entryWithError = (errorCode: string | null) => ({ ...archivedEntry, errorCode }); + assert( - canRetranscribeHistoryEntry({ ...archivedEntry, errorCode: null }), + canRetranscribeHistoryEntry(entryWithError(null)), 'completed entries with an archived recording should be retranscribable', ); assert( - canRetranscribeHistoryEntry({ ...archivedEntry, errorCode: 'polishFailed' }), + canRetranscribeHistoryEntry(entryWithError('polishFailed')), 'entries whose polishing failed should still be retranscribable', ); assert( - canRetranscribeHistoryEntry({ - ...archivedEntry, - errorCode: 'transcribeFailed', - }), + canRetranscribeHistoryEntry(entryWithError('transcribeFailed')), 'entries whose transcription failed should be retranscribable', ); assert( !canRetranscribeHistoryEntry({ hasAudioRecording: false, pipelineMode: 'traditional', - errorCode: null, }), 'entries without an archived recording should not show retranscription', ); @@ -36,7 +34,6 @@ assert( !canRetranscribeHistoryEntry({ hasAudioRecording: null, pipelineMode: undefined, - errorCode: null, }), 'legacy entries without recording metadata should not show retranscription', ); @@ -44,7 +41,6 @@ assert( !canRetranscribeHistoryEntry({ ...archivedEntry, pipelineMode: 'multimodal', - errorCode: null, }), 'multimodal entries should not show an unsupported retranscription action', ); diff --git a/openless-all/app/src/lib/history-retranscribe.ts b/openless-all/app/src/lib/history-retranscribe.ts index b13a9b952..2bd5ee872 100644 --- a/openless-all/app/src/lib/history-retranscribe.ts +++ b/openless-all/app/src/lib/history-retranscribe.ts @@ -4,11 +4,11 @@ import type { DictationSession } from './types'; * 重新转录需要一份仍存在的 WAV 归档。多模态历史目前没有对应的重转录 * provider 通道,避免展示一个点击后必然失败的按钮。 * - * errorCode 不参与判断:成功转录、润色失败和转录失败的条目都可能有可用录音, - * 用户都应能用同一份音频重新验证当前 ASR provider。 + * 成功转录、润色失败和转录失败的条目都可能有可用录音,用户都应能用同一份 + * 音频重新验证当前 ASR provider。是否回写失败记录由后端决定。 */ export function canRetranscribeHistoryEntry( - session: Pick, + session: Pick, ): boolean { return session.hasAudioRecording === true && session.pipelineMode !== 'multimodal'; } diff --git a/openless-all/app/src/lib/ipc/history.ts b/openless-all/app/src/lib/ipc/history.ts index 1058bc039..ac1f869b0 100644 --- a/openless-all/app/src/lib/ipc/history.ts +++ b/openless-all/app/src/lib/ipc/history.ts @@ -26,14 +26,18 @@ export function readAudioRecording(sessionId: string): Promise { return invokeOrMock('read_audio_recording', { sessionId }, () => 'data:audio/wav;base64,'); } +export interface HistoryRetranscriptionResult { + text: string; + updatedEntry: DictationSession | null; +} + /** 用当前 ASR provider 对一条有归档录音的历史条目重新转录(issue #613 / #1046)。 - * 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。 + * 转录失败记录会被修复;已完成 / 润色失败记录只返回临时结果,不覆盖原历史。 * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 - * 成功、润色失败和转录失败的条目都可调用,前端负责隐藏没有录音或不支持的条目。 */ -export function retranscribeRecording(sessionId: string): Promise { - return invokeOrMock( - 'retranscribe_recording', - { sessionId }, - () => mockHistory[0], - ) as Promise; + * 成功、润色失败和转录失败的条目都可调用,后端再次校验录音与能力边界。 */ +export function retranscribeRecording(sessionId: string): Promise { + return invokeOrMock('retranscribe_recording', { sessionId }, () => ({ + text: mockHistory[0].rawTranscript, + updatedEntry: null, + })) as Promise; } diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 981b7513f..f91c7d20f 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -84,6 +84,10 @@ export function History() { const [justCopiedRaw, setJustCopiedRaw] = useState(false); // 「重新转录」进行中:禁用按钮 + 显示「转录中…」,避免重复点击发起多次 ASR。 const [retranscribing, setRetranscribing] = useState(false); + const [retranscriptionResult, setRetranscriptionResult] = useState<{ + sessionId: string; + text: string; + } | null>(null); // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, @@ -301,23 +305,30 @@ export function History() { } }; - // 对一条有归档录音的历史用当前 ASR provider 重新转录(issue #613 / #1046)。 - // 后端读 recordings/.wav → 重转 → 原地回写该条 rawTranscript/finalText、清 errorCode, - // 返回整条记录;前端据此局部刷新。无论上次转录成功、润色失败还是转录失败,只要录音仍在, - // 用户都可以用同一份音频重新验证当前 provider 的结果。 + // 失败记录沿用 #613 的原地修复;已经插入过文字的完成 / 润色失败记录只显示临时结果, + // 避免把事后重转文本伪装成当时实际插入的历史事实。 const onRetranscribe = async () => { if (!item || !canRetranscribeHistoryEntry(item)) return; + const sessionId = item.id; setRetranscribing(true); + setRetranscriptionResult(null); setActionError(null); try { - const updated = await retranscribeRecording(item.id); - setItems((prev) => prev.map((s) => (s.id === updated.id ? updated : s))); + const result = await retranscribeRecording(sessionId); + if (result.updatedEntry) { + const updatedEntry = result.updatedEntry; + setItems((prev) => + prev.map((entry) => (entry.id === updatedEntry.id ? updatedEntry : entry)), + ); + } else { + setRetranscriptionResult({ sessionId, text: result.text }); + } } catch (error) { console.error('[history] retranscribe failed', error); const msg = errorMessage(error); // wav 已被 retention / 条数 cap 清理:隐藏入口,不报错(用户没干错事)。 if (msg.includes('recording not found') || msg.includes('not found')) { - markAudioMissing(item.id); + markAudioMissing(sessionId); return; } setActionError(t('history.retranscribeFailed', { err: msg })); @@ -606,16 +617,16 @@ export function History() { )} {canRetranscribeHistoryEntry(item) && !audioMissingIds.has(item.id) && ( - void onRetranscribe()} - > - {retranscribing ? t('history.retranscribing') : t('history.retranscribe')} - - )} + void onRetranscribe()} + > + {retranscribing ? t('history.retranscribing') : t('history.retranscribe')} + + )} {t('common.delete')} @@ -632,6 +643,14 @@ export function History() { key={`audio-${item.id}`} /> )} + {retranscriptionResult?.sessionId === item.id && ( +
+ +
+ )} {/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列 provider·model(或插入目标),右列该步耗时/状态。旧历史没有模型与 耗时字段时对应行自动隐藏,只剩插入行 = 改版前的信息量。 */} @@ -1098,7 +1117,7 @@ function RepolishPanel({ {results.length > 0 && (
{results.map((result) => ( - + ))}
)} @@ -1106,7 +1125,7 @@ function RepolishPanel({ ); } -function RepolishResultCard({ title, text }: { title: string; text: string }) { +function HistoryResultCard({ title, text }: { title: string; text: string }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -1117,7 +1136,7 @@ function RepolishResultCard({ title, text }: { title: string; text: string }) { setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch (error) { - console.error('[history] failed to copy repolish result', error); + console.error('[history] failed to copy result', error); } };