fix: 录音结束即落盘,连接断开与超时不再静默
此前音频要等整个识别流程走完才保存,中途出岔子就跟着一起丢;录音期间掉线也没有 任何提示,只是空等到超时后回到待机。现在停止录音就写盘,掉线当场结束本次录音, 历史里没出文字的记录会区分「没识别到声音」「连接已断开」「等待超时」三种原因。
This commit is contained in:
1 parent
b0495dc1c3
commit
70b4204fc3
6 files changed
+515
-202
No files matched your search
@@ -73,6 +73,8 @@ const HISTORY_FAILURE_KEYS: Record<HistoryFailReasonCode, TranslationKey> = {
|
||||
provider_rate_limit: 'err.provider.rateLimit',
|
||||
provider_no_model: 'err.provider.noModel',
|
||||
provider_failed: 'record.providerFailed',
|
||||
processing_timeout: 'recorder.processingTimeout',
|
||||
connection_lost: 'recorder.connectionLost',
|
||||
}
|
||||
|
||||
/** 内置模式名由稳定 id 翻译;用户自建名称永远原样显示。 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as bridge from '../bridge'
|
||||
import { startCapture, stopCapture } from '../audio'
|
||||
import { getProvider, type TranscriptionProvider, type TranscriptionCallbacks, type FinalResult } from '../transcription'
|
||||
import { getProvider, MID_SESSION_DISCONNECT_ERROR, type TranscriptionProvider, type TranscriptionCallbacks, type FinalResult } from '../transcription'
|
||||
import { isStreamingDisplayReady } from '@/lib/asrModels'
|
||||
import {
|
||||
addHistory,
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
getPromptPresets,
|
||||
getSetting,
|
||||
setActivePresetId,
|
||||
updateHistoryRecord,
|
||||
type HistoryFailReasonCode,
|
||||
type HistoryRecord,
|
||||
type PromptPreset,
|
||||
} from '../store'
|
||||
import { setActivePresetKnown } from '../../stores/activePreset'
|
||||
@@ -85,10 +87,24 @@ const VALID_TRANSITIONS: StateTransition[] = [
|
||||
]
|
||||
|
||||
const LATE_FINAL_GRACE_MS = 15000
|
||||
/**
|
||||
* 等音频落盘的上限。
|
||||
*
|
||||
* 存档是为了兜底,不该反过来把结果处理挂死:写盘走一次大 payload IPC(长录音的 PCM 有
|
||||
* 好几 MB),一旦它不返回,等它的那条链路就永远出不来,界面停在「处理中」什么也不说。
|
||||
* 超时后先按无音频写记录,落盘真的完成时再把路径补回去。
|
||||
*/
|
||||
const AUDIO_ARCHIVE_WAIT_MS = 30_000
|
||||
/** 文本插入还没收尾时,超时定时器每次顺延多久。 */
|
||||
const INSERTION_TIMEOUT_EXTENSION_MS = 5000
|
||||
/** 顺延次数上限;用尽就强制把界面从「处理中」放出来。 */
|
||||
const MAX_INSERTION_TIMEOUT_EXTENSIONS = 3
|
||||
const MODIFIER_PTT_RELEASE_GUARD_MS = 200
|
||||
const MIC_MUTED_AUTO_CANCEL_MS = 3000
|
||||
|
||||
function classifyHistoryProviderFailure(message: string): HistoryFailReasonCode {
|
||||
// 录音中掉线要单列:这段语音从未送达服务端,跟「服务端处理失败」的排查方向完全不同。
|
||||
if (message === MID_SESSION_DISCONNECT_ERROR) return 'connection_lost'
|
||||
const code = describeProviderError(message).code
|
||||
switch (code) {
|
||||
case 'provider_timeout':
|
||||
@@ -106,6 +122,15 @@ function classifyHistoryProviderFailure(message: string): HistoryFailReasonCode
|
||||
interface TimedOutProcessingContext {
|
||||
runId: number
|
||||
timedOutAt: number
|
||||
/**
|
||||
* 这一代是否已经收尾(迟到 final 已被消费并落库)。
|
||||
*
|
||||
* 宽限期兜底定时器**只认这个标志**,不再看 `timedOutProcessingContext` 还指着自己:
|
||||
* 新一次录音会在 startRecording 里把那个字段清空,于是兜底定时器直接 return,
|
||||
* 上一段录音的音频与历史一起消失(实测丢过 60 秒录音)。用户又按了一次热键,
|
||||
* 不等于他放弃了上一段。
|
||||
*/
|
||||
settled: boolean
|
||||
audioDurationSec: number
|
||||
wallTimeSec: number
|
||||
promptResolution: PromptResolution | null
|
||||
@@ -119,6 +144,38 @@ interface ResetToIdleOptions {
|
||||
preserveLateFinalContext?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次录音的音频存档。
|
||||
*
|
||||
* 关键点:写盘在**停止录音时立刻**开始,不等 ASR/AI 结果。以前音频只在某个终态分支里
|
||||
* 才落盘,于是任何一条没走到终态的路(超时后被新录音顶掉、连接断了在假等待、进程退出)
|
||||
* 都会让整段录音消失得无声无息。现在结果只决定历史记录里写什么,不再决定音频是否存在。
|
||||
*/
|
||||
/** buildHistoryMetadata 的产物:录音时的应用/窗口/Prompt 上下文。 */
|
||||
type HistoryMetadata = Pick<
|
||||
HistoryRecord,
|
||||
| 'appId'
|
||||
| 'appName'
|
||||
| 'windowTitle'
|
||||
| 'processName'
|
||||
| 'windowClass'
|
||||
| 'promptPresetId'
|
||||
| 'promptPresetName'
|
||||
| 'promptRuleId'
|
||||
| 'promptSummary'
|
||||
| 'workMode'
|
||||
>
|
||||
|
||||
interface AudioArchive {
|
||||
runId: number
|
||||
/** 本代唯一的历史记录 id。所有终态路径必须复用它,保证一次录音只有一条记录、一份文件。 */
|
||||
recordId: string
|
||||
/** 落盘任务;resolve 为 WAV 路径,null = 未保存(关了音频保留 / 无数据 / 写失败)。 */
|
||||
promise: Promise<string | null>
|
||||
/** 用户显式取消后置位;写盘可能还没结束,所以要等 promise 出来才知道删哪个文件。 */
|
||||
discarded: boolean
|
||||
}
|
||||
|
||||
export class RecorderOrchestrator {
|
||||
private state: RecorderState = 'idle'
|
||||
private onStateChange: ((s: RecorderState) => void) | null = null
|
||||
@@ -147,6 +204,23 @@ export class RecorderOrchestrator {
|
||||
private finalReceivedAt = 0
|
||||
private timedOutProcessingContext: TimedOutProcessingContext | null = null
|
||||
private pendingHistoryArtifact: { runId: number; recordId: string; audioFilePath?: string } | null = null
|
||||
/**
|
||||
* 各代录音的音频存档任务,按 runId 索引。停止录音后立刻开始写盘,不等识别结果。
|
||||
*
|
||||
* 必须按代保存、不能只留一份:超时兜底要等 15 秒宽限,这期间用户完全可能已经录完了
|
||||
* 下一段。只留一份的话,旧代来取存档时拿到的是新代的,只能放弃 —— 音频又成了孤儿文件、
|
||||
* 历史里依旧什么都没有。
|
||||
*/
|
||||
private audioArchives = new Map<number, AudioArchive>()
|
||||
/**
|
||||
* 被用户**显式**取消的代次(Esc / 取消录音)。
|
||||
*
|
||||
* 与「run 失效」不是一回事:用户在超时后立刻再按热键,新 run 会让旧 runId 失效,
|
||||
* 但上一段录音的音频和历史必须留下。以前这两种情况共用 isRunCurrent 判据,于是
|
||||
* 2026-09-07 实测丢掉了一整段 60 秒录音(超时兜底回调在 0.75 秒后被新录音判为失效,
|
||||
* 直接 return,音频、记录、日志三样都没有)。
|
||||
*/
|
||||
private canceledRuns = new Set<number>()
|
||||
|
||||
private handsFreeMode = false
|
||||
private pttSuppressed = false
|
||||
@@ -259,6 +333,254 @@ export class RecorderOrchestrator {
|
||||
return runId !== 0 && this.activeRunId === runId
|
||||
}
|
||||
|
||||
/** 标记这一代是被用户主动取消的(只有 Esc / 取消录音走这里)。 */
|
||||
private markRunCanceled(runId: number) {
|
||||
if (runId === 0) return
|
||||
this.canceledRuns.add(runId)
|
||||
// 长期运行下别无界增长;只保留最近若干代,足够覆盖仍在飞的异步收尾。
|
||||
if (this.canceledRuns.size > 64) {
|
||||
for (const id of this.canceledRuns) {
|
||||
if (this.canceledRuns.size <= 32) break
|
||||
this.canceledRuns.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一代的历史存档该不该丢弃。
|
||||
*
|
||||
* 判据是「用户是否主动取消」,**不是** isRunCurrent。用户在上一段还没收尾时又按了热键,
|
||||
* 旧 run 会失效但他并没有要求丢掉上一段——那段音频是他刚说完的,必须留下。
|
||||
*/
|
||||
private isRunCanceled(runId: number) {
|
||||
return runId === 0 || this.canceledRuns.has(runId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音后立刻把音频写盘,不等识别结果。
|
||||
*
|
||||
* 返回本代的 recordId:所有终态路径(正常 final / 空识别 / 报错 / 超时兜底)都必须用
|
||||
* takeArchivedAudio 取回它,绝不再自己生成 id、自己保存一遍。
|
||||
*/
|
||||
private beginAudioArchive(runId: number, chunks: ArrayBuffer[]): string {
|
||||
const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
if (chunks.length === 0) return null
|
||||
const [historyEnabled, retentionEnabled] = await Promise.all([
|
||||
getSetting('historyEnabled', true),
|
||||
getSetting('audioRetentionEnabled', true),
|
||||
])
|
||||
if (!historyEnabled || !retentionEnabled) return null
|
||||
const savedPath = await saveRecordingAudio(recordId, chunks)
|
||||
addRuntimeEvent('info', 'recorder', 'Recording audio archived', {
|
||||
runId,
|
||||
recordId,
|
||||
saved: Boolean(savedPath),
|
||||
chunks: chunks.length,
|
||||
})
|
||||
return savedPath
|
||||
} catch (error) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to archive recording audio', {
|
||||
runId,
|
||||
recordId,
|
||||
error: String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
})()
|
||||
this.audioArchives.set(runId, { runId, recordId, promise, discarded: false })
|
||||
// 正常情况下每条存档都会被某个终态取走;关掉历史等分支不会取,这里兜一下上限。
|
||||
if (this.audioArchives.size > 8) {
|
||||
const oldest = this.audioArchives.keys().next()
|
||||
if (!oldest.done) this.audioArchives.delete(oldest.value)
|
||||
}
|
||||
return recordId
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保本代音频已经在落盘。
|
||||
*
|
||||
* 正常路径由 stopRecording 发起;错误可能在**录音进行中**就到达(连接断开),那时还没
|
||||
* 走到 stopRecording,必须在这里补一次,否则这段音频没人保存。
|
||||
*/
|
||||
private ensureAudioArchive(runId: number, chunks: ArrayBuffer[]): void {
|
||||
if (this.audioArchives.has(runId)) return
|
||||
this.beginAudioArchive(runId, chunks)
|
||||
}
|
||||
|
||||
/** 取回本代已存档的音频;返回 null 表示这一代没有存档(例如时长不足没进 processing)。 */
|
||||
private async takeArchivedAudio(
|
||||
runId: number,
|
||||
): Promise<{ recordId: string; audioFilePath?: string } | null> {
|
||||
const archive = this.audioArchives.get(runId)
|
||||
if (!archive) return null
|
||||
// 取走即移交:一次录音只有一条终态路径会走到这里(finalHandled / settled 互斥保证)。
|
||||
this.audioArchives.delete(runId)
|
||||
let timedOutWaiting = false
|
||||
const savedPath = await Promise.race([
|
||||
archive.promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => {
|
||||
timedOutWaiting = true
|
||||
resolve(null)
|
||||
}, AUDIO_ARCHIVE_WAIT_MS)),
|
||||
])
|
||||
if (archive.discarded) return null
|
||||
if (timedOutWaiting) {
|
||||
// 落盘 IPC 卡住时绝不能把整条结果处理一起挂死(那正是「悬浮条无限转圈、什么都不说」
|
||||
// 的成因之一)。记录先写出去,音频路径等落盘真的完成再补上。
|
||||
addRuntimeEvent('warn', 'recorder', 'Audio archive did not finish in time; writing history without the audio path', {
|
||||
runId,
|
||||
recordId: archive.recordId,
|
||||
waitedMs: AUDIO_ARCHIVE_WAIT_MS,
|
||||
})
|
||||
void archive.promise.then(async (latePath) => {
|
||||
if (!latePath || archive.discarded) return
|
||||
try {
|
||||
await updateHistoryRecord(archive.recordId, { audioFilePath: latePath })
|
||||
void bridge.emit('history-updated')
|
||||
addRuntimeEvent('info', 'recorder', 'Attached late audio archive to its history record', {
|
||||
recordId: archive.recordId,
|
||||
})
|
||||
} catch (error) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to attach late audio archive', {
|
||||
recordId: archive.recordId,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
})
|
||||
return { recordId: archive.recordId }
|
||||
}
|
||||
return { recordId: archive.recordId, audioFilePath: savedPath ?? undefined }
|
||||
}
|
||||
|
||||
/** 用户主动取消:把这份存档标为废弃,写盘完成后删掉文件。 */
|
||||
private discardAudioArchive(runId: number) {
|
||||
const archive = this.audioArchives.get(runId)
|
||||
if (!archive || archive.discarded) return
|
||||
archive.discarded = true
|
||||
this.audioArchives.delete(runId)
|
||||
void archive.promise.then((savedPath) => {
|
||||
if (!savedPath) return
|
||||
void bridge.deleteAudioFile(savedPath).catch(() => { /* 孤儿文件删除失败不影响状态复位 */ })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「有音频但没有可用文本」的一次录音写进历史,供回放与重新识别。
|
||||
*
|
||||
* 空识别、供应商报错、处理超时、连接断开四条路共用它:音频已经在 beginAudioArchive 里
|
||||
* 存好了,这里只负责补一条带明确原因的记录。返回是否真的写入。
|
||||
*/
|
||||
private async archiveFailedRun(params: {
|
||||
runId: number
|
||||
audioDurationSec: number
|
||||
wallTimeSec: number
|
||||
asrMs?: number
|
||||
asrDurationSec?: number
|
||||
failReason?: string
|
||||
failReasonCode?: HistoryFailReasonCode
|
||||
historyMeta: HistoryMetadata
|
||||
aiSource?: HistoryRecord['aiSource']
|
||||
aiStatus?: HistoryRecord['aiStatus']
|
||||
}): Promise<boolean> {
|
||||
const { runId } = params
|
||||
let artifact: { recordId: string; audioFilePath?: string } | null = null
|
||||
try {
|
||||
const historyEnabled = await getSetting('historyEnabled', true)
|
||||
if (!historyEnabled) return false
|
||||
artifact = await this.takeArchivedAudio(runId)
|
||||
if (!artifact) return false
|
||||
if (this.isRunCanceled(runId)) {
|
||||
await this.discardCanceledHistory(artifact)
|
||||
return false
|
||||
}
|
||||
this.pendingHistoryArtifact = { runId, ...artifact }
|
||||
await addHistory({
|
||||
...params.historyMeta,
|
||||
id: artifact.recordId,
|
||||
timestamp: Date.now(),
|
||||
asrText: '',
|
||||
llmText: '',
|
||||
asrMs: params.asrMs ?? 0,
|
||||
llmMs: 0,
|
||||
durationSec: params.wallTimeSec,
|
||||
audioDurationSec: params.audioDurationSec > 0 ? params.audioDurationSec : undefined,
|
||||
asrDurationSec: params.asrDurationSec,
|
||||
charCount: 0,
|
||||
isEmpty: true,
|
||||
failReason: params.failReason,
|
||||
failReasonCode: params.failReasonCode,
|
||||
aiSource: params.aiSource,
|
||||
aiStatus: params.aiStatus,
|
||||
audioFilePath: artifact.audioFilePath,
|
||||
})
|
||||
// 写入后再验一次:取消可能发生在 addHistory 期间。
|
||||
if (this.isRunCanceled(runId)) {
|
||||
await this.discardCanceledHistory(artifact)
|
||||
return false
|
||||
}
|
||||
void bridge.emit('history-updated')
|
||||
addRuntimeEvent('info', 'recorder', 'Saved recording to history without text', {
|
||||
runId,
|
||||
recordId: artifact.recordId,
|
||||
audioSaved: Boolean(artifact.audioFilePath),
|
||||
audioSec: params.audioDurationSec,
|
||||
failReasonCode: params.failReasonCode,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
if (artifact) await this.discardCanceledHistory(artifact)
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save recording to history', {
|
||||
runId,
|
||||
error: String(error),
|
||||
failReasonCode: params.failReasonCode,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 已经确定不会有结果了:存历史 + 给出可读提示 + 立刻回 idle。
|
||||
*
|
||||
* 用于「请求根本没送出去」这类当场就能判定的失败。关键是**不进等待** —— 让悬浮条转满
|
||||
* 一分钟再什么都不说,是最糟的处理方式,用户既不知道发生了什么,也不知道录音还在不在。
|
||||
*/
|
||||
private async failRunWithoutResult(runId: number, params: {
|
||||
audioDurationSec: number
|
||||
wallTimeSec: number
|
||||
failReason: string
|
||||
failReasonCode: HistoryFailReasonCode
|
||||
toast: string
|
||||
}): Promise<void> {
|
||||
this.clearProcessingTimeout()
|
||||
this.processingCancelable = false
|
||||
const historyMeta = this.buildHistoryMetadata(
|
||||
this.currentPromptResolution,
|
||||
this.currentActiveAppContext,
|
||||
)
|
||||
if (this.isRunCurrent(runId)) this.overlayService.showError(params.toast)
|
||||
addRuntimeEvent('warn', 'recorder', 'Run failed without any result', {
|
||||
runId,
|
||||
audioSec: params.audioDurationSec,
|
||||
failReasonCode: params.failReasonCode,
|
||||
mode: this.provider.mode,
|
||||
})
|
||||
this.provider.cancel()
|
||||
if (this.provider.mode === 'server') this.ensureConnection()
|
||||
await this.archiveFailedRun({
|
||||
runId,
|
||||
audioDurationSec: params.audioDurationSec,
|
||||
wallTimeSec: params.wallTimeSec,
|
||||
failReason: params.failReason,
|
||||
failReasonCode: params.failReasonCode,
|
||||
historyMeta,
|
||||
})
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
this.finishRun(runId)
|
||||
this.resetToIdle({ keepOverlay: true })
|
||||
}
|
||||
|
||||
/** 幂等结束指定代次;所有清理都带 runId 条件,绝不清除随后创建的新代次。 */
|
||||
private finishRun(runId: number) {
|
||||
if (runId === 0) return
|
||||
@@ -827,6 +1149,7 @@ export class RecorderOrchestrator {
|
||||
// 在任何 await 之前离开 recording 并让 run 失效。这样同时到达的 PTT up 不会
|
||||
// 再进入正常 stopRecording,Provider 的迟到 partial/error 也会被 run guard 丢弃。
|
||||
if (!this.transition('processing')) return
|
||||
this.markRunCanceled(canceledRunId)
|
||||
this.finalHandledInCurrentRun = true
|
||||
this.processingCancelable = false
|
||||
this.overlayService.stopListeningTicker()
|
||||
@@ -875,6 +1198,12 @@ export class RecorderOrchestrator {
|
||||
const canceledRunId = this.activeRunId
|
||||
const historyArtifact = this.pendingHistoryArtifact
|
||||
this.clearProcessingTimeout()
|
||||
// 用户明确按了 Esc:这一代的音频存档和历史都该消失。这是唯一允许丢弃录音的入口。
|
||||
this.markRunCanceled(canceledRunId)
|
||||
this.discardAudioArchive(canceledRunId)
|
||||
if (this.timedOutProcessingContext?.runId === canceledRunId) {
|
||||
this.timedOutProcessingContext.settled = true
|
||||
}
|
||||
this.timedOutProcessingContext = null
|
||||
this.processingCancelable = false
|
||||
this.provider.cancel()
|
||||
@@ -919,68 +1248,26 @@ export class RecorderOrchestrator {
|
||||
|
||||
const audioDur = this.getAudioDurationSec()
|
||||
const wallSec = this.wallTimeAtStopSec > 0 ? this.wallTimeAtStopSec : audioDur
|
||||
const audioChunks = this.recordedChunks.slice()
|
||||
const audioChunkCount = this.recordedChunks.length
|
||||
const historyMeta = this.buildHistoryMetadata(
|
||||
this.currentPromptResolution,
|
||||
this.currentActiveAppContext,
|
||||
)
|
||||
void (async () => {
|
||||
let historyArtifact: { runId: number; recordId: string; audioFilePath?: string } | null = null
|
||||
try {
|
||||
const historyEnabled = await getSetting('historyEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (historyEnabled) {
|
||||
const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
historyArtifact = { runId, recordId }
|
||||
this.pendingHistoryArtifact = historyArtifact
|
||||
const saveAudioEnabled = await getSetting('audioRetentionEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (saveAudioEnabled && audioChunks.length > 0) {
|
||||
try {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for empty result', { error: String(err) })
|
||||
}
|
||||
// 保存文件后必须重新验代;失效时同时清文件和可能已存在的记录。
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
}
|
||||
await addHistory({
|
||||
id: recordId,
|
||||
timestamp: Date.now(),
|
||||
asrText: '',
|
||||
llmText: '',
|
||||
asrMs: result.asrMs || 0,
|
||||
llmMs: 0,
|
||||
durationSec: wallSec,
|
||||
audioDurationSec: audioDur > 0 ? audioDur : undefined,
|
||||
asrDurationSec: result.durationSec > 0 ? result.durationSec : undefined,
|
||||
charCount: 0,
|
||||
isEmpty: true,
|
||||
// 调用本身成功,只是没出字。这一句和上面的报错要能区分开:
|
||||
// 前者多半真没说话,后者是调用失败
|
||||
failReason: t('recorder.noTranscript'),
|
||||
failReasonCode: 'no_transcript',
|
||||
aiSource: result.aiSource,
|
||||
aiStatus: result.aiStatus,
|
||||
audioFilePath: historyArtifact.audioFilePath,
|
||||
...historyMeta,
|
||||
})
|
||||
// 写记录后同样验代;取消/新 run 会清掉刚写入的文件与记录。
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
void bridge.emit('history-updated')
|
||||
}
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write empty result to history', { error: String(err) })
|
||||
}
|
||||
// 调用本身成功,只是没出字。failReason 要能和「调用失败」区分开:
|
||||
// 前者多半真没说话,后者是供应商/连接的问题。
|
||||
await this.archiveFailedRun({
|
||||
runId,
|
||||
audioDurationSec: audioDur,
|
||||
wallTimeSec: wallSec,
|
||||
asrMs: result.asrMs || 0,
|
||||
asrDurationSec: result.durationSec > 0 ? result.durationSec : undefined,
|
||||
failReason: t('recorder.noTranscript'),
|
||||
failReasonCode: 'no_transcript',
|
||||
historyMeta,
|
||||
aiSource: result.aiSource,
|
||||
aiStatus: result.aiStatus,
|
||||
})
|
||||
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
this.overlayService.showNoSpeech({
|
||||
@@ -988,7 +1275,7 @@ export class RecorderOrchestrator {
|
||||
mode: this.provider.mode,
|
||||
audioSec: Number(audioDur.toFixed(1)),
|
||||
asrMs: result.asrMs || 0,
|
||||
audioChunks: audioChunks.length,
|
||||
audioChunks: audioChunkCount,
|
||||
runId,
|
||||
})
|
||||
this.finishRun(runId)
|
||||
@@ -1042,6 +1329,7 @@ export class RecorderOrchestrator {
|
||||
void this.processFinalResult(result, {
|
||||
runId: this.activeRunId,
|
||||
timedOutAt: 0,
|
||||
settled: true,
|
||||
audioDurationSec: this.getAudioDurationSec(),
|
||||
wallTimeSec: this.wallTimeAtStopSec > 0 ? this.wallTimeAtStopSec : this.getAudioDurationSec(),
|
||||
promptResolution: this.currentPromptResolution ? { ...this.currentPromptResolution } : null,
|
||||
@@ -1060,6 +1348,38 @@ export class RecorderOrchestrator {
|
||||
if (this.textInsertionInFlight) return
|
||||
const runId = this.activeRunId
|
||||
this.clearProcessingTimeout()
|
||||
|
||||
// Provider 说本轮结束了,却一个结果都没给(协议异常:done 先于 final 到达、
|
||||
// 或服务端只发了 done)。音频已经存档,别让它变成没人认领的孤儿文件 ——
|
||||
// 补一条带原因的记录,用户至少还能回放和重新识别。
|
||||
if (this.audioArchives.has(runId)) {
|
||||
const audioDur = this.getAudioDurationSec()
|
||||
const wallSec = this.wallTimeAtStopSec > 0 ? this.wallTimeAtStopSec : audioDur
|
||||
const historyMeta = this.buildHistoryMetadata(
|
||||
this.currentPromptResolution,
|
||||
this.currentActiveAppContext,
|
||||
)
|
||||
addRuntimeEvent('warn', 'recorder', 'Provider finished without any result; saving audio to history', {
|
||||
runId,
|
||||
mode: this.provider.mode,
|
||||
audioSec: audioDur,
|
||||
})
|
||||
this.overlayService.showNoSpeech({ reason: 'done_without_result', mode: this.provider.mode, runId })
|
||||
this.resetToIdle({ keepOverlay: true })
|
||||
void (async () => {
|
||||
await this.archiveFailedRun({
|
||||
runId,
|
||||
audioDurationSec: audioDur,
|
||||
wallTimeSec: wallSec,
|
||||
failReason: t('record.providerFailed'),
|
||||
failReasonCode: 'provider_failed',
|
||||
historyMeta,
|
||||
})
|
||||
this.finishRun(runId)
|
||||
})()
|
||||
return
|
||||
}
|
||||
|
||||
this.finishRun(runId)
|
||||
this.resetToIdle()
|
||||
},
|
||||
@@ -1078,79 +1398,44 @@ export class RecorderOrchestrator {
|
||||
this.finalHandledInCurrentRun = true
|
||||
|
||||
// 停止音频采集,并进入不可再触发 stopRecording 的收尾状态。
|
||||
if (this.state === 'recording') {
|
||||
const failedWhileRecording = this.state === 'recording'
|
||||
if (failedWhileRecording) {
|
||||
this.overlayService.stopListeningTicker()
|
||||
void stopCapture().catch(() => { })
|
||||
this.restoreSystemMuteIfNeeded()
|
||||
this.transition('processing')
|
||||
}
|
||||
|
||||
// 快照本代音频和元数据;每个异步 artifact 边界后均验代并清理失效产物。
|
||||
const audioDur = this.getAudioDurationSec()
|
||||
const wallSec = this.wallTimeAtStopSec > 0 ? this.wallTimeAtStopSec : audioDur
|
||||
const audioChunks = this.recordedChunks.slice()
|
||||
const historyMeta = this.buildHistoryMetadata(
|
||||
this.currentPromptResolution,
|
||||
this.currentActiveAppContext,
|
||||
)
|
||||
// 错误在录音进行中到达时还没走过 stopRecording,音频尚未开始落盘 —— 在这里补上,
|
||||
// 否则「说到一半连接断了」这段语音谁都没保存。
|
||||
if (audioDur >= 0.5 && this.recordedChunks.length > 0) {
|
||||
this.ensureAudioArchive(runId, this.recordedChunks.slice())
|
||||
}
|
||||
// 错误发生在录音中时,用户看到的悬浮条还停在「聆听」;给一句明确提示。
|
||||
if (failedWhileRecording) this.overlayService.showError(friendlyFailure.message)
|
||||
void (async () => {
|
||||
let historyArtifact: { runId: number; recordId: string; audioFilePath?: string } | null = null
|
||||
try {
|
||||
if (audioDur >= 0.5 && audioChunks.length > 0) {
|
||||
const historyEnabled = await getSetting('historyEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (historyEnabled) {
|
||||
const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
historyArtifact = { runId, recordId }
|
||||
this.pendingHistoryArtifact = historyArtifact
|
||||
const saveAudioEnabled = await getSetting('audioRetentionEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (saveAudioEnabled) {
|
||||
try {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio during error recovery', { error: String(err) })
|
||||
}
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
}
|
||||
await addHistory({
|
||||
id: recordId,
|
||||
timestamp: Date.now(),
|
||||
asrText: '',
|
||||
llmText: '',
|
||||
asrMs: 0,
|
||||
llmMs: 0,
|
||||
durationSec: wallSec,
|
||||
audioDurationSec: audioDur > 0 ? audioDur : undefined,
|
||||
charCount: 0,
|
||||
isEmpty: true,
|
||||
// 供应商/后端给的原话(额度、资源未开通、连接被断都在这里),
|
||||
// 以前只进日志,用户看到的仍是「无有效声音」
|
||||
failReason: friendlyFailure.detail,
|
||||
failReasonCode: classifyHistoryProviderFailure(msg),
|
||||
audioFilePath: historyArtifact.audioFilePath,
|
||||
...historyMeta,
|
||||
})
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
void bridge.emit('history-updated')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write error history entry', { error: String(err) })
|
||||
if (audioDur >= 0.5) {
|
||||
// 供应商/后端给的原话(额度、资源未开通、连接被断都在这里),
|
||||
// 以前只进日志,用户看到的仍是「无有效声音」
|
||||
await this.archiveFailedRun({
|
||||
runId,
|
||||
audioDurationSec: audioDur,
|
||||
wallTimeSec: wallSec,
|
||||
failReason: friendlyFailure.detail,
|
||||
failReasonCode: classifyHistoryProviderFailure(msg),
|
||||
historyMeta,
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
this.finishRun(runId)
|
||||
this.resetToIdle()
|
||||
this.resetToIdle({ keepOverlay: failedWhileRecording })
|
||||
})()
|
||||
},
|
||||
}
|
||||
@@ -1876,7 +2161,7 @@ export class RecorderOrchestrator {
|
||||
const skipAiForShortSpeech = this.cachedAiMinDurationSec > 0
|
||||
&& audioDur < this.cachedAiMinDurationSec
|
||||
|
||||
this.provider.stop({
|
||||
const stopAccepted = this.provider.stop({
|
||||
pttHoldMs,
|
||||
disableAi: skipAiForShortSpeech || undefined,
|
||||
audioStats: this.audioStatsTotalFrames > 0 ? {
|
||||
@@ -1892,6 +2177,7 @@ export class RecorderOrchestrator {
|
||||
pttHoldMs: Math.round(pttHoldMs),
|
||||
aiMinDurationSec: this.cachedAiMinDurationSec || undefined,
|
||||
skipAiForShortSpeech: skipAiForShortSpeech || undefined,
|
||||
stopAccepted,
|
||||
})
|
||||
|
||||
if (!this.transition('processing')) {
|
||||
@@ -1901,21 +2187,61 @@ export class RecorderOrchestrator {
|
||||
}
|
||||
this.processingCancelable = true
|
||||
|
||||
// 音频先落盘,不等识别结果。此后无论超时、断连、还是进程被杀,这段录音都还在磁盘上。
|
||||
this.beginAudioArchive(runId, this.recordedChunks.slice())
|
||||
|
||||
if (!stopAccepted) {
|
||||
// 请求根本没送出去(server 模式连接已断、或 provider 会话已失效)。以前这里照样
|
||||
// 进 45 秒超时等待 + 15 秒宽限:悬浮条转整整一分钟「处理中」,而根本没有任何请求
|
||||
// 在飞。实测 2026-09-07 两次长录音都是这条路(日志:Failed to send stop)。
|
||||
void this.failRunWithoutResult(runId, {
|
||||
audioDurationSec: audioDur,
|
||||
wallTimeSec,
|
||||
failReason: t('recorder.connectionLost'),
|
||||
failReasonCode: 'connection_lost',
|
||||
toast: t('recorder.connectionLostToast'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const processingTimeoutMs = this.computeProcessingTimeoutMs(audioDur)
|
||||
addRuntimeEvent('info', 'recorder', 'Entered processing', {
|
||||
audioSec: audioDur,
|
||||
timeoutMs: processingTimeoutMs,
|
||||
})
|
||||
this.overlayService.showThinking(audioDur, runId)
|
||||
this.processingTimeoutId = setTimeout(() => {
|
||||
let insertionExtensions = 0
|
||||
const onProcessingTimeout = () => {
|
||||
if (this.state !== 'processing' || !this.isRunCurrent(runId)) return
|
||||
if (this.textInsertionInFlight) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Processing timed out while text insertion is active; extending wait')
|
||||
if (insertionExtensions < MAX_INSERTION_TIMEOUT_EXTENSIONS) {
|
||||
insertionExtensions++
|
||||
// 插入已经在飞,给它一点时间;但**必须重新排一次定时器**。以前这里直接 return,
|
||||
// 于是插入一旦卡住(Rust SendInput 停在目标进程上)就再没有人来收尾,
|
||||
// 悬浮条无限停在「处理中」。
|
||||
addRuntimeEvent('warn', 'recorder', 'Processing timed out while text insertion is active; extending wait', {
|
||||
runId,
|
||||
extension: insertionExtensions,
|
||||
extendByMs: INSERTION_TIMEOUT_EXTENSION_MS,
|
||||
})
|
||||
this.processingTimeoutId = setTimeout(onProcessingTimeout, INSERTION_TIMEOUT_EXTENSION_MS)
|
||||
return
|
||||
}
|
||||
// 顺延用尽:插入大概率卡死了。历史与音频在 final 阶段已经落库,这里只把界面从
|
||||
// 「处理中」放出来,绝不再写一条空记录去覆盖已经有文本的那条。
|
||||
addRuntimeEvent('error', 'recorder', 'Text insertion never finished; forcing recorder back to idle', {
|
||||
runId,
|
||||
waitedMs: processingTimeoutMs + insertionExtensions * INSERTION_TIMEOUT_EXTENSION_MS,
|
||||
})
|
||||
this.textInsertionInFlight = false
|
||||
this.finishRun(runId)
|
||||
this.resetToIdle()
|
||||
return
|
||||
}
|
||||
const timedOutCtx: TimedOutProcessingContext = {
|
||||
runId,
|
||||
timedOutAt: Date.now(),
|
||||
settled: false,
|
||||
audioDurationSec: audioDur,
|
||||
wallTimeSec,
|
||||
promptResolution: this.currentPromptResolution ? { ...this.currentPromptResolution } : null,
|
||||
@@ -1930,79 +2256,37 @@ export class RecorderOrchestrator {
|
||||
lateFinalGraceMs: LATE_FINAL_GRACE_MS,
|
||||
})
|
||||
|
||||
// 安全网:快照本次录音音频与元数据。若宽限期内没等到迟到的 final,就把音频存入历史
|
||||
// (标记为空结果),用户可在历史里“重新识别”,避免超时丢失整段录音。
|
||||
// resetToIdle 不会清空 recordedChunks,这里再 slice 一份,防止后续录音替换缓冲。
|
||||
const audioChunks = timedOutCtx.audioChunks
|
||||
// 安全网:宽限期内没等到迟到的 final,就把这段录音写进历史(空结果 + 明确原因),
|
||||
// 用户可以在历史里回放和「重新识别」。音频本身已在 stopRecording 时落盘。
|
||||
const historyMeta = this.buildHistoryMetadata(timedOutCtx.promptResolution, timedOutCtx.appContext)
|
||||
window.setTimeout(() => {
|
||||
// onFinal 若在宽限期内消费了 context(迟到 final 已落历史)→ 跳过,避免重复记录。
|
||||
if (this.timedOutProcessingContext !== timedOutCtx || !this.isRunCurrent(timedOutCtx.runId)) return
|
||||
// 宽限期已结束,不再接受迟到结果。先废弃 Provider 旧会话,再异步保存兜底历史;
|
||||
// Server 必须换 socket,避免旧 final/done/error 串到下一代。
|
||||
this.timedOutProcessingContext = null
|
||||
this.provider.cancel()
|
||||
if (this.provider.mode === 'server') this.ensureConnection()
|
||||
// 只认 settled:迟到 final 已接手收尾时让位,避免同一段录音写出两条记录。
|
||||
// 绝不再判 isRunCurrent —— 用户又按了一次热键不等于放弃上一段(见 settled 注释)。
|
||||
if (timedOutCtx.settled) return
|
||||
timedOutCtx.settled = true
|
||||
// 仍是当前代时才动 Provider:新录音已在 startRecording 里换过会话,这里再 cancel
|
||||
// 会把用户正在录的这一段打断。
|
||||
if (this.timedOutProcessingContext === timedOutCtx) {
|
||||
this.timedOutProcessingContext = null
|
||||
this.provider.cancel()
|
||||
if (this.provider.mode === 'server') this.ensureConnection()
|
||||
}
|
||||
void (async () => {
|
||||
let historyArtifact: { recordId: string; audioFilePath?: string } | null = null
|
||||
try {
|
||||
const historyEnabled = await getSetting('historyEnabled', true)
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) return
|
||||
if (!historyEnabled) {
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
return
|
||||
}
|
||||
|
||||
let audioFilePath: string | undefined
|
||||
const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
const saveAudioEnabled = await getSetting('audioRetentionEnabled', true)
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) return
|
||||
if (saveAudioEnabled && audioChunks.length > 0) {
|
||||
try {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for timeout recovery', { error: String(err) })
|
||||
}
|
||||
}
|
||||
historyArtifact = { recordId, audioFilePath }
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
|
||||
await addHistory({
|
||||
id: recordId,
|
||||
timestamp: Date.now(),
|
||||
asrText: '',
|
||||
llmText: '',
|
||||
asrMs: 0,
|
||||
llmMs: 0,
|
||||
durationSec: timedOutCtx.wallTimeSec,
|
||||
audioDurationSec: timedOutCtx.audioDurationSec > 0 ? timedOutCtx.audioDurationSec : undefined,
|
||||
charCount: 0,
|
||||
isEmpty: true,
|
||||
audioFilePath,
|
||||
...historyMeta,
|
||||
})
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
void bridge.emit('history-updated')
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
addRuntimeEvent('info', 'recorder', 'Timed-out recording saved to history for retry', { audioSec: timedOutCtx.audioDurationSec })
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) return
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write timeout recovery history entry', { error: String(err) })
|
||||
}
|
||||
await this.archiveFailedRun({
|
||||
runId: timedOutCtx.runId,
|
||||
audioDurationSec: timedOutCtx.audioDurationSec,
|
||||
wallTimeSec: timedOutCtx.wallTimeSec,
|
||||
failReason: t('recorder.processingTimeout'),
|
||||
failReasonCode: 'processing_timeout',
|
||||
historyMeta,
|
||||
})
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
})()
|
||||
}, LATE_FINAL_GRACE_MS)
|
||||
|
||||
this.resetToIdle({ preserveLateFinalContext: true })
|
||||
}, processingTimeoutMs)
|
||||
}
|
||||
this.processingTimeoutId = setTimeout(onProcessingTimeout, processingTimeoutMs)
|
||||
}
|
||||
|
||||
// ── Toggle / hands-free ──
|
||||
@@ -2226,6 +2510,8 @@ export class RecorderOrchestrator {
|
||||
// 超过 15 秒时把 context 留给已排队的宽限期兜底定时器,避免双方都放弃收尾。
|
||||
if (Date.now() - context.timedOutAt > LATE_FINAL_GRACE_MS) return null
|
||||
this.timedOutProcessingContext = null
|
||||
// 迟到 final 接手收尾,兜底定时器必须让位,否则同一段录音会写出两条记录。
|
||||
context.settled = true
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -2297,25 +2583,18 @@ export class RecorderOrchestrator {
|
||||
const historyEnabled = await getSetting('historyEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (historyEnabled) {
|
||||
const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
historyArtifact = { runId, recordId }
|
||||
const saveAudioEnabled = await getSetting('audioRetentionEnabled', true)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (saveAudioEnabled && context.audioChunks.length > 0) {
|
||||
try {
|
||||
const savedPath = await saveRecordingAudio(recordId, context.audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio file', { error: String(err) })
|
||||
}
|
||||
}
|
||||
// 音频在 stopRecording 时就开始写盘了;这里只等它落定,不再写第二份。
|
||||
const archived = await this.takeArchivedAudio(runId)
|
||||
const recordId = archived?.recordId
|
||||
?? (Date.now().toString(36) + Math.random().toString(36).slice(2, 6))
|
||||
historyArtifact = { runId, recordId, audioFilePath: archived?.audioFilePath }
|
||||
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
if (this.isRunCanceled(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
const providerMeta = await this.buildProviderMetadata(result)
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
if (this.isRunCanceled(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
@@ -2349,7 +2628,7 @@ export class RecorderOrchestrator {
|
||||
...this.buildHistoryMetadata(promptResolution, appContext),
|
||||
...providerMeta,
|
||||
})
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
if (this.isRunCanceled(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
return
|
||||
}
|
||||
@@ -2357,12 +2636,14 @@ export class RecorderOrchestrator {
|
||||
}
|
||||
} catch (error) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write history entry', { error: String(error) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write history entry', { error: String(error), runId })
|
||||
}
|
||||
|
||||
// 历史已经落定;下面是 UI 与文本插入,那些必须归当前代所有。
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (this.isRunCanceled(runId) && historyArtifact) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export type HistoryFailReasonCode =
|
||||
| 'provider_rate_limit'
|
||||
| 'provider_no_model'
|
||||
| 'provider_failed'
|
||||
/** 等结果等到超时,一个字都没回来(音频已存档,可重新识别)。 */
|
||||
| 'processing_timeout'
|
||||
/** 录音期间或发送时连接已断开,这段语音从未送达服务端。 */
|
||||
| 'connection_lost'
|
||||
|
||||
export interface Stats {
|
||||
totalDurationSec: number
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loadServerAiSource,
|
||||
type ServerAiSource,
|
||||
} from './serverAiSource'
|
||||
import { MID_SESSION_DISCONNECT_ERROR } from './types'
|
||||
import type {
|
||||
FinalResult,
|
||||
TranscriptionProvider,
|
||||
@@ -47,6 +48,20 @@ export class ServerProvider implements TranscriptionProvider {
|
||||
await ws.connect({
|
||||
onStateChange: (state) => {
|
||||
callbacks.onStateChange?.(state)
|
||||
// 连接在一次录音进行中掉了:必须当场报出去。
|
||||
//
|
||||
// 以前这里只是转发状态、没人处理,于是录音继续跑(音频被 sendAudio 静静丢掉),
|
||||
// 停止时 sendStop 也发不出去,录音器照旧进入 45 秒超时等待 —— 用户看着悬浮条转
|
||||
// 一分钟,最后什么提示都没有。实测 2026-09-07 两段长录音都是这么没的。
|
||||
if (state !== 'disconnected' && state !== 'error') return
|
||||
const runId = this.activeRunId
|
||||
if (runId === 0) return
|
||||
addRuntimeEvent('error', 'server', 'Connection dropped during an active recording', {
|
||||
runId,
|
||||
state,
|
||||
})
|
||||
this.resetRun()
|
||||
callbacks.onError?.(MID_SESSION_DISCONNECT_ERROR)
|
||||
},
|
||||
onReady: (data) => {
|
||||
callbacks.onReady?.({
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
AiExecutionSource,
|
||||
AiExecutionStatus,
|
||||
} from './types'
|
||||
export { MID_SESSION_DISCONNECT_ERROR } from './types'
|
||||
|
||||
let currentProvider: TranscriptionProvider | null = null
|
||||
let currentMode: WorkMode = 'server'
|
||||
|
||||
@@ -8,6 +8,16 @@ export type WorkMode = 'server' | 'cloud_api' | 'local'
|
||||
|
||||
export type ProviderState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
/**
|
||||
* 一次录音进行中连接掉线的哨兵错误。
|
||||
*
|
||||
* 走 onError 的统一失败通道(这样音频存档/历史/提示都复用同一条路),但录音器要能把它
|
||||
* 单独归类成 connection_lost —— 这段语音是**从未送达**服务端,跟「服务端处理失败」
|
||||
* 不是一回事,用户看到的解释也不该相同。
|
||||
*/
|
||||
export const MID_SESSION_DISCONNECT_ERROR
|
||||
= 'sayit_error:server_unreachable:websocket disconnected mid-session'
|
||||
|
||||
export type AiExecutionSource = 'server' | 'custom' | 'none'
|
||||
export type AiExecutionStatus = 'applied' | 'skipped' | 'unavailable' | 'failed'
|
||||
|
||||
|
||||
Reference in new issue
Block a user