Skip to content
Merged
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
60 changes: 60 additions & 0 deletions openless-all/app/src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
64 changes: 64 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
52 changes: 52 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src-tauri/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ export const ja: typeof zhCN = {
audioLoading: '読み込み中…',
exportRecording: '録音をエクスポート',
exportFailed: 'エクスポート失敗:{{err}}',
retranscribe: '再認識',
retranscribing: '認識中…',
retranscribeFailed: '再認識に失敗:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左側から 1 件選択して詳細を表示。',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ export const ko: typeof zhCN = {
audioLoading: '로딩 중…',
exportRecording: '녹음 내보내기',
exportFailed: '내보내기 실패: {{err}}',
retranscribe: '다시 인식',
retranscribing: '인식 중…',
retranscribeFailed: '다시 인식 실패: {{err}}',
rawLabel: '원문',
rawEmpty: '(비어 있음)',
selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export const zhCN = {
audioLoading: '加载中…',
exportRecording: '导出录音',
exportFailed: '导出失败:{{err}}',
retranscribe: '重新转录',
retranscribing: '转录中…',
retranscribeFailed: '重新转录失败:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左侧选一条查看详情。',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ export const zhTW: typeof zhCN = {
audioLoading: '載入中…',
exportRecording: '匯出錄音',
exportFailed: '匯出失敗:{{err}}',
retranscribe: '重新轉錄',
retranscribing: '轉錄中…',
retranscribeFailed: '重新轉錄失敗:{{err}}',
rawLabel: '原文',
rawEmpty: '(空)',
selectHint: '左側選一條查看詳情。',
Expand Down
11 changes: 11 additions & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading