Merge pull request #637 from Open-Less/feat/issue-613-retain-recording-retranscribe

feat(history): 转录失败保留录音 + 历史「重新转录」(#613)
This commit is contained in:
Chris233 authored and GitHub committed 2026-06-11 16:57:45 +08:00
commit 67447e6232
12 files changed
+254 -1

No files matched your search

@@ -48,3 +48,63 @@ pub async fn read_audio_recording(session_id: String) -> Result<Vec<u8>, String>
}
})
}
/// 对一条「转录失败」历史条目的归档录音用**当前** ASR provider 重新转录(issue #613)。
///
/// 流程:读 `recordings/<id>.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<DictationSession, String> {
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)
}
@@ -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<u8>) -> Result<String, String> {
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
}
@@ -46,6 +46,7 @@ pub(crate) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> Result<(), String> {
Ok(())
}
/// ASR 转录失败时,若本次录音已成功归档到 `recordings/<session_id>.wav`,写一条
/// `transcribeFailed` 历史记录,让用户能在历史页回放原始录音并「重新转录」(issue #613)。
///
/// 关键:history id 用 coordinator 的 `session_id`(而非新 UUID),与 recorder
/// 旁路写盘的 wav 文件名对齐 —— 这样前端凭 id 就能找到录音,否则播放/重转会 404。
///
/// 未归档录音时(用户没开「保留原始录音」或写盘失败)不写历史:没有可回放/重转的
/// 内容,写一条空壳记录反而污染历史。沿用 empty-transcript 分支「以实际归档状态为准」
/// 的语义。
fn write_transcribe_failed_history(inner: &Arc<Inner>, 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}");
}
}
+1
View File
@@ -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,
@@ -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<bool> {
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::<DictationSession>::new())
+3
View File
@@ -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.',
+3
View File
@@ -279,6 +279,9 @@ export const ja: typeof zhCN = {
audioLoading: '読み込み中…',
exportRecording: '録音をエクスポート',
exportFailed: 'エクスポート失敗:{{err}}',
retranscribe: '再認識',
retranscribing: '認識中…',
retranscribeFailed: '再認識に失敗:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左側から 1 件選択して詳細を表示。',
+3
View File
@@ -279,6 +279,9 @@ export const ko: typeof zhCN = {
audioLoading: '로딩 중…',
exportRecording: '녹음 내보내기',
exportFailed: '내보내기 실패: {{err}}',
retranscribe: '다시 인식',
retranscribing: '인식 중…',
retranscribeFailed: '다시 인식 실패: {{err}}',
rawLabel: '원문',
rawEmpty: '(비어 있음)',
selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.',
+3
View File
@@ -275,6 +275,9 @@ export const zhCN = {
audioLoading: '加载中…',
exportRecording: '导出录音',
exportFailed: '导出失败:{{err}}',
retranscribe: '重新转录',
retranscribing: '转录中…',
retranscribeFailed: '重新转录失败:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左侧选一条查看详情。',
+3
View File
@@ -277,6 +277,9 @@ export const zhTW: typeof zhCN = {
audioLoading: '載入中…',
exportRecording: '匯出錄音',
exportFailed: '匯出失敗:{{err}}',
retranscribe: '重新轉錄',
retranscribing: '轉錄中…',
retranscribeFailed: '重新轉錄失敗:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左側選一條查看詳情。',
+11
View File
@@ -706,6 +706,17 @@ export function readAudioRecording(sessionId: string): Promise<Uint8Array> {
})
}
/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。
* 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。
* 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 */
export function retranscribeRecording(sessionId: string): Promise<DictationSession> {
return invokeOrMock(
"retranscribe_recording",
{ sessionId },
() => mockHistory[0],
) as Promise<DictationSession>
}
// ── Vocab ──────────────────────────────────────────────────────────────
export function listVocab(): Promise<DictionaryEntry[]> {
return invokeOrMock("list_vocab", undefined, () => mockVocab)
+37 -1
View File
@@ -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<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [justCopied, setJustCopied] = useState(false);
// issue #613:「重新转录」进行中的条目 id(按钮转 loading、防重复点击)。
const [retranscribingId, setRetranscribingId] = useState<string | null>(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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<PageHeader
@@ -303,6 +327,18 @@ export function History() {
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon={justCopied ? 'check' : 'copy'} variant="ghost" size="sm" onClick={() => void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')}</Btn>
{/* issue #613:失败条目(有错误码)且录音仍在时,提供「重新转录」。 */}
{item.errorCode && item.hasAudioRecording && !audioMissingIds.has(item.id) && (
<Btn
icon="refresh"
variant="ghost"
size="sm"
disabled={retranscribingId === item.id}
onClick={() => void onRetranscribe()}
>
{retranscribingId === item.id ? t('history.retranscribing') : t('history.retranscribe')}
</Btn>
)}
{item.hasAudioRecording && !audioMissingIds.has(item.id) && (
<Btn icon="download" variant="ghost" size="sm" onClick={() => void onExportAudio()}>{t('history.exportRecording')}</Btn>
)}