feat: 知识库更新,优化批量语音转写 - 合并索引 - 增加会话级批量转写处理 - 补充语音转写回归测试

This commit is contained in:
电摇小子
2026-08-06 20:31:08 +08:00
parent a0e8ab278f
commit 0ec2e6a0be
34 changed files with 2589 additions and 53 deletions
@@ -1,9 +1,11 @@
import { createHash } from 'crypto'
import type {
VoiceMessageReference,
VoiceModelDownloadResult,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionPriority,
VoiceRecognitionResult,
VoiceTranscriptSnapshot,
VoiceTranscriptUpdate
} from '../../shared/voice-recognition'
import type { VoiceService } from '../voice-service'
import { PcmAudioProcessor } from './audio-processor'
@@ -14,6 +16,14 @@ import { VoiceTaskScheduler } from './task-scheduler'
import { SqliteTranscriptRepository } from './transcript-repository'
import { VoicePipeline, VoiceSourceResolver } from './voice-pipeline'
import { SpeechRecognizerRegistry } from './types'
import { voiceAccountIdentity, voiceMessageIdentity } from './voice-message-identity'
type TranscriptUpdateListener = (update: VoiceTranscriptUpdate) => Promise<void> | void
type RecognitionOptions = {
priority?: VoiceRecognitionPriority
publishTranscriptUpdate?: boolean
}
export class VoiceRecognitionUseCase {
readonly modelManager: VoiceModelManager
@@ -23,6 +33,8 @@ export class VoiceRecognitionUseCase {
private readonly recognizers = new SpeechRecognizerRegistry()
private pipeline: VoicePipeline | null = null
private accountId = ''
private accountGeneration = 0
private readonly transcriptUpdateListeners = new Set<TranscriptUpdateListener>()
constructor(options: { modelRoot: string; databasePath: string; workerPath: string }) {
this.modelManager = new VoiceModelManager(options.modelRoot)
@@ -36,14 +48,8 @@ export class VoiceRecognitionUseCase {
connect(voiceService: VoiceService, accountRoot: string): void {
this.scheduler.cancelAll()
this.accountId = createHash('sha256')
.update(
accountRoot
.trim()
.replace(/[\\/]+$/, '')
.toLowerCase()
)
.digest('hex')
this.accountGeneration += 1
this.accountId = voiceAccountIdentity(accountRoot)
this.pipeline = new VoicePipeline(
new VoiceSourceResolver(voiceService),
createDefaultAudioDecoderRegistry(),
@@ -55,6 +61,7 @@ export class VoiceRecognitionUseCase {
disconnect(): void {
this.scheduler.cancelAll()
this.accountGeneration += 1
this.pipeline = null
this.accountId = ''
}
@@ -77,13 +84,18 @@ export class VoiceRecognitionUseCase {
return this.modelManager.remove()
}
recognize(reference: VoiceMessageReference): Promise<VoiceRecognitionResult> {
recognize(
reference: VoiceMessageReference,
options?: RecognitionOptions
): Promise<VoiceRecognitionResult> {
const pipeline = this.pipeline
const accountId = this.accountId
if (!pipeline || !accountId) {
return Promise.resolve({ success: false, code: 'NOT_CONNECTED', error: '请先连接微信数据库' })
}
const key = this.taskKey(reference)
const generation = this.accountGeneration
const accountIdentity = this.accountId
return this.scheduler
.schedule(key, async (signal) => {
const status = await this.modelManager.getStatus()
@@ -91,18 +103,95 @@ export class VoiceRecognitionUseCase {
return { success: false, code: 'MODEL_NOT_READY', error: '请先下载语音识别模型' } as const
}
const result = await pipeline.run(accountId, reference, signal)
return { success: true, ...result } as const
})
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
throw new DOMException('Recognition cancelled', 'AbortError')
}
const transcript = result.transcript.trim()
if (
transcript &&
options?.publishTranscriptUpdate !== false &&
this.isCurrentAccount(accountId, generation)
) {
try {
await this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'transcribed',
transcript,
cached: result.cached
})
} catch (error) {
console.warn('[Voice] transcript indexed asynchronously failed:', error)
}
}
return { success: true, ...result, transcript } as const
}, { priority: options?.priority })
.catch((error): VoiceRecognitionResult => {
if (error instanceof DOMException && error.name === 'AbortError') {
return { success: false, code: 'CANCELLED', error: '语音识别已取消' }
}
const message = error instanceof Error ? error.message : String(error)
const code = message.toLowerCase().includes('timed out') ? 'TIMEOUT' : 'RECOGNITION_FAILED'
if (this.isCurrentAccount(accountId, generation)) {
this.transcripts.markFailure(accountId, voiceMessageIdentity(reference), message)
if (options?.publishTranscriptUpdate !== false) {
void this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'failed',
error: message,
cached: false
}).catch((publishError) => {
console.warn('[Voice] failed transcript state update failed:', publishError)
})
}
}
return { success: false, code, error: message }
})
}
onTranscriptUpdate(listener: TranscriptUpdateListener): () => void {
this.transcriptUpdateListeners.add(listener)
return () => this.transcriptUpdateListeners.delete(listener)
}
getTranscriptSnapshot(reference: VoiceMessageReference): VoiceTranscriptSnapshot {
if (!this.accountId) return { state: 'pending' }
const messageIdentity = voiceMessageIdentity(reference)
const record = this.transcripts.findLatest(this.accountId, messageIdentity)
if (record?.transcript.trim()) {
return { state: 'transcribed', transcript: record.transcript, updatedAt: record.updatedAt }
}
const status = this.transcripts.getMessageStatus(this.accountId, messageIdentity)
return {
state: status.state === 'transcribed' ? 'pending' : status.state,
error: status.error,
updatedAt: status.updatedAt || undefined
}
}
async publishTranscriptSnapshot(reference: VoiceMessageReference): Promise<void> {
const accountIdentity = this.accountId
if (!accountIdentity) return
const snapshot = this.getTranscriptSnapshot(reference)
if (snapshot.state === 'pending') return
await this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: snapshot.state,
transcript: snapshot.transcript,
error: snapshot.error,
cached: snapshot.state === 'transcribed'
})
}
get accountIdentity(): string {
return this.accountId
}
cancelRecognition(reference: VoiceMessageReference): { success: boolean } {
return { success: this.scheduler.cancel(this.taskKey(reference)) }
}
@@ -114,6 +203,14 @@ export class VoiceRecognitionUseCase {
}
private taskKey(reference: VoiceMessageReference): string {
return `${this.accountId}:${reference.sessionId}:${reference.localId}:${reference.createTime}`
return `${this.accountId}:${voiceMessageIdentity(reference)}`
}
private isCurrentAccount(accountId: string, generation: number): boolean {
return this.accountId === accountId && this.accountGeneration === generation
}
private async publishTranscriptUpdate(update: VoiceTranscriptUpdate): Promise<void> {
for (const listener of this.transcriptUpdateListeners) await listener(update)
}
}