diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 6f43042b..ce6998d5 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -48,3 +48,63 @@ pub async fn read_audio_recording(session_id: String) -> Result, String> } }) } + +/// 对一条「转录失败」历史条目的归档录音用**当前** ASR provider 重新转录(issue #613)。 +/// +/// 流程:读 `recordings/.wav` → 取 PCM(跳过 44 字节 WAV 头)→ 现 provider 重转 +/// → 成功则原地回写该条历史的 rawTranscript / finalText、清除 error_code,返回新文本。 +/// +/// 仅做 ASR,不自动二次润色(润色依赖 LLM 凭据且 issue 标为待定,留作后续)。失败时 +/// 不动历史、不删录音,把错误返回给前端提示,用户可重试。返回更新后的整条记录给前端 +/// 局部刷新。 +#[tauri::command] +pub async fn retranscribe_recording( + coord: CoordinatorState<'_>, + session_id: String, +) -> Result { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".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| { + if e.kind() == std::io::ErrorKind::NotFound { + "recording not found".into() + } else { + format!("read wav failed: {e}") + } + })?; + // 归档 wav 是 16k/mono/16-bit、固定 44 字节标准头(见 asr::wav::encode_wav_16k_mono)。 + if wav.len() <= 44 { + return Err("recording is empty or corrupt".into()); + } + let pcm = wav[44..].to_vec(); + + let text = coord.retranscribe_pcm(pcm).await?; + if text.trim().is_empty() { + return Err("重新转录仍未识别到语音".into()); + } + + // 找到原条目,保留其它字段,只更新转写结果 + 清错误码。 + let mut entry = coord + .history() + .list() + .map_err(|e| e.to_string())? + .into_iter() + .find(|s| s.id == session_id) + .ok_or_else(|| "history entry not found".to_string())?; + // 只更新转写结果并清除失败标记。insert_status 保持原值(重新转录不向光标落字, + // 没有可表达「已转写未落字」的状态,清掉 error_code 即足以标记不再是失败条目)。 + entry.raw_transcript = text.clone(); + entry.final_text = text; + entry.error_code = None; + + let updated = coord + .history() + .update_entry(entry.clone()) + .map_err(|e| e.to_string())?; + if !updated { + return Err("history entry not found".into()); + } + Ok(entry) +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1579dc3f..66087007 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -827,6 +827,70 @@ impl Coordinator { pub fn history(&self) -> &HistoryStore { &self.inner.history } + + /// 用**当前配置的** ASR provider 对一段已归档的 16k/mono/16-bit PCM 重新转录 + /// (issue #613「重新转录」)。复用 `build_qa_asr_start`,对所有 provider 统一: + /// 流式 provider 先 open_session 再灌音并取 final,批处理 provider 直接灌音后 + /// transcribe。整段超时走 COORDINATOR_GLOBAL_TIMEOUT_SECS 兜底,防止挂死。 + /// + /// 只做 ASR,不做润色/落字/写历史 —— 回写历史由 command 层完成,保持本方法纯粹。 + pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { + let inner = &self.inner; + let active_asr = CredentialsVault::get_active_asr(); + let start = build_qa_asr_start(inner, &active_asr).await?; + start.open_streaming_session().await?; + let consumer = start.recorder_consumer(); + consumer.consume_pcm_chunk(&pcm); + let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + let raw = match start.active_asr() { + ActiveAsr::Volcengine(asr) => { + asr.send_last_frame().await.map_err(|e| e.to_string())?; + tokio::time::timeout(timeout, asr.await_final_result()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } + ActiveAsr::Bailian(asr) => { + asr.send_last_frame().await.map_err(|e| e.to_string())?; + tokio::time::timeout(timeout, asr.await_final_result()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } + ActiveAsr::Whisper(w) => tokio::time::timeout(timeout, w.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?, + ActiveAsr::Mimo(m) => tokio::time::timeout(timeout, m.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?, + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => local + .transcribe(asr_setup::foundry_audio_transcribe_timeout_duration()) + .await + .map_err(|e| e.to_string())?, + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => local + .transcribe(asr_setup::sherpa_audio_transcribe_timeout_duration()) + .await + .map_err(|e| e.to_string())?, + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + let dur = asr_setup::local_qwen_transcribe_timeout( + (local.buffer_duration_ms() as f64) / 1000.0, + ); + inner.local_asr_cache.touch(); + let out = tokio::time::timeout(dur, local.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?; + asr_setup::schedule_local_asr_release(inner); + out + } + }; + Ok(raw.text) + } pub fn prefs(&self) -> &PreferencesStore { &self.inner.prefs } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index 09439d9b..0c178087 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -46,6 +46,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] await final failed: {e}"); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -67,6 +68,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { ); // 清理 ASR session,避免资源泄漏 asr.cancel(); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -90,6 +92,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] whisper transcribe failed: {e}"); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -108,6 +111,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] whisper 全局超时 {} 秒", COORDINATOR_GLOBAL_TIMEOUT_SECS ); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -130,6 +134,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] MiMo ASR transcribe failed: {e}"); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -148,6 +153,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] MiMo ASR 全局超时 {} 秒", COORDINATOR_GLOBAL_TIMEOUT_SECS ); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -173,6 +179,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] Bailian await final failed: {e}"); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -192,6 +199,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { COORDINATOR_GLOBAL_TIMEOUT_SECS ); asr.cancel(); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -239,6 +247,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { inner, AsrReleaseSession::Dictation(current_session_id), ); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -288,6 +297,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { inner, AsrReleaseSession::Dictation(current_session_id), ); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -325,6 +335,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}"); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -344,6 +355,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { timeout_duration.as_secs(), audio_secs ); + write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( inner, CapsuleState::Error, @@ -784,3 +796,43 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(()) } + +/// ASR 转录失败时,若本次录音已成功归档到 `recordings/.wav`,写一条 +/// `transcribeFailed` 历史记录,让用户能在历史页回放原始录音并「重新转录」(issue #613)。 +/// +/// 关键:history id 用 coordinator 的 `session_id`(而非新 UUID),与 recorder +/// 旁路写盘的 wav 文件名对齐 —— 这样前端凭 id 就能找到录音,否则播放/重转会 404。 +/// +/// 未归档录音时(用户没开「保留原始录音」或写盘失败)不写历史:没有可回放/重转的 +/// 内容,写一条空壳记录反而污染历史。沿用 empty-transcript 分支「以实际归档状态为准」 +/// 的语义。 +fn write_transcribe_failed_history(inner: &Arc, session_id: SessionId, duration_ms: u64) { + if !inner.audio_archive_active.load(Ordering::Relaxed) { + return; + } + let prefs_snapshot = inner.prefs.get(); + let session = DictationSession { + id: session_id.to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: String::new(), + final_text: String::new(), + mode: prefs_snapshot.default_mode, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Failed, + error_code: Some("transcribeFailed".to_string()), + duration_ms: Some(duration_ms), + dictionary_entry_count: None, + has_audio_recording: Some(true), + }; + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] transcribeFailed history append failed: {e}"); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 567d5a8b..713636f6 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -365,6 +365,7 @@ pub fn run() { commands::delete_history_entry, commands::clear_history, commands::read_audio_recording, + commands::retranscribe_recording, commands::marketplace_list, commands::marketplace_detail, commands::marketplace_install, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index cd4b735f..15157ca2 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1411,6 +1411,20 @@ impl HistoryStore { self.write_locked(&sessions) } + /// 原地替换 id 匹配的历史条目(保持原位置)。用于「重新转录」成功后回写 + /// rawTranscript / finalText / error_code(issue #613)。找不到对应 id 时返回 + /// `Ok(false)`,调用方据此提示「历史条目已不存在」。 + pub fn update_entry(&self, updated: DictationSession) -> Result { + let _guard = self.lock.lock(); + let mut sessions = self.read_locked()?; + let Some(slot) = sessions.iter_mut().find(|s| s.id == updated.id) else { + return Ok(false); + }; + *slot = updated; + self.write_locked(&sessions)?; + Ok(true) + } + pub fn clear(&self) -> Result<()> { let _guard = self.lock.lock(); self.write_locked(&Vec::::new()) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 326e2c07..b186baf1 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -277,6 +277,9 @@ export const en: typeof zhCN = { audioLoading: 'Loading…', exportRecording: 'Export recording', exportFailed: 'Failed to export: {{err}}', + retranscribe: 'Retranscribe', + retranscribing: 'Transcribing…', + retranscribeFailed: 'Retranscribe failed: {{err}}', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c3d695c0..9bb81aa7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -279,6 +279,9 @@ export const ja: typeof zhCN = { audioLoading: '読み込み中…', exportRecording: '録音をエクスポート', exportFailed: 'エクスポート失敗:{{err}}', + retranscribe: '再認識', + retranscribing: '認識中…', + retranscribeFailed: '再認識に失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0633be29..80a20a48 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -279,6 +279,9 @@ export const ko: typeof zhCN = { audioLoading: '로딩 중…', exportRecording: '녹음 내보내기', exportFailed: '내보내기 실패: {{err}}', + retranscribe: '다시 인식', + retranscribing: '인식 중…', + retranscribeFailed: '다시 인식 실패: {{err}}', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d7844014..bd0d6be7 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -275,6 +275,9 @@ export const zhCN = { audioLoading: '加载中…', exportRecording: '导出录音', exportFailed: '导出失败:{{err}}', + retranscribe: '重新转录', + retranscribing: '转录中…', + retranscribeFailed: '重新转录失败:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f01e6be2..ecdc1f66 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -277,6 +277,9 @@ export const zhTW: typeof zhCN = { audioLoading: '載入中…', exportRecording: '匯出錄音', exportFailed: '匯出失敗:{{err}}', + retranscribe: '重新轉錄', + retranscribing: '轉錄中…', + retranscribeFailed: '重新轉錄失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8f8d246b..e43aa375 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -706,6 +706,17 @@ export function readAudioRecording(sessionId: string): Promise { }) } +/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。 + * 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。 + * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 */ +export function retranscribeRecording(sessionId: string): Promise { + return invokeOrMock( + "retranscribe_recording", + { sessionId }, + () => mockHistory[0], + ) as Promise +} + // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { return invokeOrMock("list_vocab", undefined, () => mockVocab) diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index d3c7d0a4..cf821862 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -47,6 +47,8 @@ export function History() { const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); const [justCopied, setJustCopied] = useState(false); + // issue #613:「重新转录」进行中的条目 id(按钮转 loading、防重复点击)。 + const [retranscribingId, setRetranscribingId] = useState(null); // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, @@ -185,6 +187,28 @@ export function History() { } }; + // issue #613:对失败条目的归档录音用当前 provider 重新转录,成功后局部刷新该条。 + const onRetranscribe = async () => { + if (!item || retranscribingId) return; + const targetId = item.id; + setRetranscribingId(targetId); + setActionError(null); + try { + const updated = await retranscribeRecording(targetId); + setItems(prev => prev.map(s => (s.id === targetId ? updated : s))); + } catch (error) { + console.error('[history] failed to retranscribe recording', error); + const msg = errorMessage(error); + // wav 已被清理:隐藏录音相关操作,不当作用户错误。 + if (msg.includes('recording not found') || msg.includes('not found')) { + markAudioMissing(targetId); + } + setActionError(t('history.retranscribeFailed', { err: msg })); + } finally { + setRetranscribingId(null); + } + }; + return (
void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} + {/* issue #613:失败条目(有错误码)且录音仍在时,提供「重新转录」。 */} + {item.errorCode && item.hasAudioRecording && !audioMissingIds.has(item.id) && ( + void onRetranscribe()} + > + {retranscribingId === item.id ? t('history.retranscribing') : t('history.retranscribe')} + + )} {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( void onExportAudio()}>{t('history.exportRecording')} )}