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
+14 -1
View File
@@ -1,5 +1,6 @@
type ScheduledTask<T> = {
key: string
priority: number
run: (signal: AbortSignal) => Promise<T>
controller: AbortController
resolve: (value: T) => void
@@ -10,15 +11,27 @@ export class VoiceTaskScheduler {
private readonly queue: ScheduledTask<unknown>[] = []
private active: ScheduledTask<unknown> | null = null
schedule<T>(key: string, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
schedule<T>(
key: string,
run: (signal: AbortSignal) => Promise<T>,
options?: { priority?: 'interactive' | 'background' }
): Promise<T> {
return new Promise<T>((resolve, reject) => {
// A batch task is deliberately interruptible. The caller can resume its
// next item after cancellation, while an explicit chat-bubble request
// never waits behind a long background transcription.
if (options?.priority !== 'background' && this.active?.priority === 0) {
this.active.controller.abort()
}
this.queue.push({
key,
priority: options?.priority === 'background' ? 0 : 1,
run,
controller: new AbortController(),
resolve: resolve as (value: unknown) => void,
reject
})
this.queue.sort((left, right) => right.priority - left.priority)
this.pump()
})
}
@@ -1,7 +1,11 @@
import { dirname } from 'path'
import { mkdirSync } from 'fs'
import { DatabaseSync } from 'node:sqlite'
import type { TranscriptRecord, TranscriptRepository } from './types'
import type {
TranscriptMessageStatus,
TranscriptRecord,
TranscriptRepository
} from './types'
type TranscriptKey = Omit<
TranscriptRecord,
@@ -34,6 +38,14 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
recognizer_id, model_version, model_fingerprint
)
) STRICT;
CREATE TABLE IF NOT EXISTS voice_transcript_message_states (
account_id TEXT NOT NULL,
message_identity TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'transcribed', 'failed')),
error TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, message_identity)
) STRICT;
`)
}
@@ -74,6 +86,52 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
}
}
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null {
const row = this.database
.prepare(
`SELECT account_id, message_identity, audio_hash, processor_version,
recognizer_id, model_version, model_fingerprint, transcript,
language, duration_ms, created_at, updated_at
FROM voice_transcripts
WHERE account_id = ? AND message_identity = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(accountId, messageIdentity) as Record<string, unknown> | undefined
if (!row) return null
return {
accountId: String(row.account_id),
messageIdentity: String(row.message_identity),
audioHash: String(row.audio_hash),
processorVersion: String(row.processor_version),
recognizerId: String(row.recognizer_id),
modelVersion: String(row.model_version),
modelFingerprint: String(row.model_fingerprint),
transcript: String(row.transcript),
language: row.language ? String(row.language) : undefined,
durationMs: Number(row.duration_ms),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at)
}
}
getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus {
const row = this.database
.prepare(
`SELECT state, error, updated_at
FROM voice_transcript_message_states
WHERE account_id = ? AND message_identity = ?`
)
.get(accountId, messageIdentity) as Record<string, unknown> | undefined
return {
accountId,
messageIdentity,
state: row ? (String(row.state) as TranscriptMessageStatus['state']) : 'pending',
updatedAt: row ? Number(row.updated_at) : 0,
error: row?.error ? String(row.error) : undefined
}
}
save(record: TranscriptRecord): void {
this.database
.prepare(
@@ -105,6 +163,31 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
record.createdAt,
record.updatedAt
)
this.database
.prepare(
`INSERT INTO voice_transcript_message_states (
account_id, message_identity, state, error, updated_at
) VALUES (?, ?, 'transcribed', NULL, ?)
ON CONFLICT (account_id, message_identity) DO UPDATE SET
state = excluded.state,
error = NULL,
updated_at = excluded.updated_at`
)
.run(record.accountId, record.messageIdentity, record.updatedAt)
}
markFailure(accountId: string, messageIdentity: string, error: string): void {
this.database
.prepare(
`INSERT INTO voice_transcript_message_states (
account_id, message_identity, state, error, updated_at
) VALUES (?, ?, 'failed', ?, ?)
ON CONFLICT (account_id, message_identity) DO UPDATE SET
state = excluded.state,
error = excluded.error,
updated_at = excluded.updated_at`
)
.run(accountId, messageIdentity, error.slice(0, 500), Date.now())
}
close(): void {
+13
View File
@@ -69,6 +69,16 @@ export interface TranscriptRecord extends RecognitionMetadata {
updatedAt: number
}
export type TranscriptMessageState = 'pending' | 'transcribed' | 'failed'
export interface TranscriptMessageStatus {
accountId: string
messageIdentity: string
state: TranscriptMessageState
updatedAt: number
error?: string
}
export interface TranscriptRepository {
find(
key: Omit<
@@ -76,6 +86,9 @@ export interface TranscriptRepository {
'transcript' | 'language' | 'durationMs' | 'createdAt' | 'updatedAt'
>
): TranscriptRecord | null
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null
getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus
save(record: TranscriptRecord): void
markFailure(accountId: string, messageIdentity: string, error: string): void
close(): void
}
@@ -0,0 +1,369 @@
import type {
VoiceBatchConversationSummary,
VoiceBatchPreflight,
VoiceBatchProgress,
VoiceBatchRequest,
VoiceMessageReference
} from '../../shared/voice-recognition'
import * as chat from '../services/chat-service'
import { voiceMessageIdentity } from './voice-message-identity'
import { VoiceRecognitionUseCase } from './voice-recognition-use-case'
type VoiceBatchItem = {
conversationId: string
reference: VoiceMessageReference
}
type ActiveTask = {
accountIdentity: string
controller: AbortController
startedAt: number
items: VoiceBatchItem[]
failures: VoiceBatchItem[]
progress: VoiceBatchProgress
}
type VoiceBatchListener = (progress: VoiceBatchProgress) => void
type PreparedBatch = {
accountIdentity: string
requestKey: string
items: VoiceBatchItem[]
preflight: VoiceBatchPreflight
}
function rangeStart(range: VoiceBatchRequest['range']): number | undefined {
if (range === 'selected_history') return undefined
const now = new Date()
if (range === 'current_year')
return Math.floor(new Date(now.getFullYear(), 0, 1).getTime() / 1000)
return Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60
}
function voiceReference(message: chat.FormattedMessage): VoiceMessageReference | undefined {
if (
message.type !== '语音' ||
!message.sessionId ||
message.localId === undefined ||
!message.createTime
) {
return undefined
}
return {
sessionId: message.sessionId,
localId: message.localId,
createTime: message.createTime,
svrId: message.serverId
}
}
/**
* Main-process coordinator for one account-local batch. It only chooses work
* items; recognition, cache de-duplication and knowledge updates remain in
* VoiceRecognitionUseCase.
*/
export class VoiceBatchService {
private active: ActiveTask | null = null
private lastProgress: VoiceBatchProgress | null = null
private lastFailures: { accountIdentity: string; items: VoiceBatchItem[] } | null = null
private prepared: PreparedBatch | null = null
private readonly listeners = new Set<VoiceBatchListener>()
constructor(private readonly recognition: VoiceRecognitionUseCase) {}
onProgress(listener: VoiceBatchListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
async preflight(request: VoiceBatchRequest): Promise<VoiceBatchPreflight> {
const accountIdentity = this.recognition.accountIdentity
const contacts = await chat.listContactsAsync()
const items = await this.collect(request, contacts)
const preflight = await this.summarize(accountIdentity, items)
this.prepared = {
accountIdentity,
requestKey: this.requestKey(request),
items,
preflight
}
return preflight
}
async conversationSummaries(
request: VoiceBatchRequest
): Promise<VoiceBatchConversationSummary[]> {
const requested = Array.from(new Set(request.conversationIds.filter(Boolean)))
if (!requested.length) return []
const contacts = await chat.listContactsAsync()
const selected = contacts.filter((contact) => requested.includes(contact.md5))
if (selected.length !== requested.length) throw new Error('选择的会话已不可用,请重新选择')
const startTime = rangeStart(request.range)
const summaries: VoiceBatchConversationSummary[] = []
for (let index = 0; index < selected.length; index += 1) {
const contact = selected[index]
summaries.push({
conversationId: contact.md5,
voiceMessageCount: await chat.countVoiceMessagesAsync(contact.md5, startTime)
})
// Keep a long contact list responsive while each count runs on WCDB's
// asynchronous SQL channel.
if (index > 0 && index % 4 === 0) await new Promise<void>((resolve) => setImmediate(resolve))
}
return summaries
}
private async summarize(
accountIdentity: string,
items: VoiceBatchItem[]
): Promise<VoiceBatchPreflight> {
const status = await this.recognition.getModelStatus()
let cachedCount = 0
let failedCount = 0
for (const [index, item] of items.entries()) {
const snapshot = this.recognition.getTranscriptSnapshot(item.reference)
if (snapshot.state === 'transcribed') cachedCount += 1
if (snapshot.state === 'failed') failedCount += 1
if (index > 0 && index % 100 === 0)
await new Promise<void>((resolve) => setImmediate(resolve))
}
return {
accountIdentity,
conversationCount: new Set(items.map((item) => item.conversationId)).size,
voiceMessageCount: items.length,
cachedCount,
pendingCount: Math.max(0, items.length - cachedCount - failedCount),
failedCount,
estimatedDurationMs: null,
modelReady: status.state === 'ready'
}
}
getProgress(): VoiceBatchProgress {
if (this.active) return { ...this.active.progress }
if (this.lastProgress?.accountIdentity === this.recognition.accountIdentity) {
return { ...this.lastProgress }
}
return {
accountIdentity: this.recognition.accountIdentity,
state: 'idle',
total: 0,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}
}
async start(request: VoiceBatchRequest): Promise<VoiceBatchProgress> {
if (this.active) throw new Error('当前账号已有语音转写任务正在执行')
const preflight = await this.preflight(request)
if (!preflight.accountIdentity) throw new Error('请先连接微信数据')
if (preflight.accountIdentity !== this.recognition.accountIdentity) {
throw new Error('当前账号已切换,请重新选择会话')
}
if (!preflight.modelReady) throw new Error('请先在设置中准备离线语音模型')
const prepared = this.prepared
const items =
prepared?.accountIdentity === preflight.accountIdentity &&
prepared.requestKey === this.requestKey(request)
? prepared.items
: await this.collect(request)
const task: ActiveTask = {
accountIdentity: preflight.accountIdentity,
controller: new AbortController(),
startedAt: Date.now(),
items,
failures: [],
progress: {
accountIdentity: preflight.accountIdentity,
state: items.length ? 'pending' : 'completed',
total: items.length,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}
}
this.active = task
this.publish(task)
if (!items.length) {
this.active = null
return task.progress
}
void this.run(task)
return { ...task.progress }
}
cancel(): boolean {
if (!this.active) return false
this.active.controller.abort()
return true
}
async retryFailed(): Promise<VoiceBatchProgress> {
if (this.active) throw new Error('当前账号已有语音转写任务正在执行')
const lastFailures = this.lastFailures
if (
!lastFailures?.items.length ||
lastFailures.accountIdentity !== this.recognition.accountIdentity
) {
throw new Error('当前账号没有可重试的失败语音')
}
const status = await this.recognition.getModelStatus()
if (status.state !== 'ready') throw new Error('请先在设置中准备离线语音模型')
const task: ActiveTask = {
accountIdentity: lastFailures.accountIdentity,
controller: new AbortController(),
startedAt: Date.now(),
items: lastFailures.items,
failures: [],
progress: {
accountIdentity: lastFailures.accountIdentity,
state: 'pending',
total: lastFailures.items.length,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}
}
this.active = task
this.publish(task)
void this.run(task)
return { ...task.progress }
}
private async run(task: ActiveTask): Promise<void> {
const conversationsNeedingIndex = new Map<string, VoiceMessageReference>()
task.progress.state = 'processing'
this.publish(task)
for (const item of task.items) {
if (
task.controller.signal.aborted ||
task.accountIdentity !== this.recognition.accountIdentity
)
break
task.progress.currentConversationId = item.conversationId
task.progress.currentMessageIdentity = voiceMessageIdentity(item.reference)
task.progress.elapsedMs = Date.now() - task.startedAt
this.publish(task)
const result = await this.recognition.recognize(item.reference, {
priority: 'background',
publishTranscriptUpdate: false
})
if (
task.controller.signal.aborted ||
task.accountIdentity !== this.recognition.accountIdentity
)
break
if (!result.success && result.code === 'CANCELLED') {
// An interactive chat-bubble request preempted this background item.
// Put it at the tail instead of treating it as a completed or failed
// transcription, then continue after the foreground request.
task.items.push(item)
continue
}
task.progress.processed += 1
if (result.success) {
if (result.cached) task.progress.cached += 1
else task.progress.succeeded += 1
conversationsNeedingIndex.set(item.conversationId, item.reference)
} else {
task.progress.failed += 1
task.failures.push(item)
}
task.progress.elapsedMs = Date.now() - task.startedAt
this.publish(task)
}
task.progress.elapsedMs = Date.now() - task.startedAt
task.progress.currentConversationId = undefined
task.progress.currentMessageIdentity = undefined
// A complete conversation snapshot sees every transcript written by this
// batch, so refresh Knowledge once per affected conversation after the
// recognition loop rather than rebuilding after every voice message.
if (
!task.controller.signal.aborted &&
task.accountIdentity === this.recognition.accountIdentity
) {
for (const reference of conversationsNeedingIndex.values()) {
try {
await this.recognition.publishTranscriptSnapshot(reference)
} catch (error) {
console.warn('[Voice] batch transcript index update failed:', error)
}
}
}
task.progress.elapsedMs = Date.now() - task.startedAt
if (
task.controller.signal.aborted ||
task.accountIdentity !== this.recognition.accountIdentity
) {
task.progress.state = 'cancelled'
} else if (task.progress.failed) {
task.progress.state = 'partially_failed'
} else {
task.progress.state = 'completed'
}
this.lastFailures = task.failures.length
? { accountIdentity: task.accountIdentity, items: task.failures }
: null
this.publish(task)
if (this.active === task) this.active = null
}
private async collect(
request: VoiceBatchRequest,
contactsOverride?: chat.FormattedContact[]
): Promise<VoiceBatchItem[]> {
const requested = Array.from(new Set(request.conversationIds.filter(Boolean)))
if (!requested.length) return []
const contacts = contactsOverride || (await chat.listContactsAsync())
const selected = contacts.filter((contact) => requested.includes(contact.md5))
if (selected.length !== requested.length) throw new Error('选择的会话已不可用,请重新选择')
const startTime = rangeStart(request.range)
const items: VoiceBatchItem[] = []
const seen = new Set<string>()
for (const contact of selected) {
const messages = await chat.listMessagesAsync(contact.md5, startTime)
for (const message of messages) {
const reference = voiceReference(message)
if (!reference) continue
const identity = voiceMessageIdentity(reference)
if (seen.has(identity)) continue
seen.add(identity)
items.push({ conversationId: contact.md5, reference })
}
}
return items
}
private requestKey(request: VoiceBatchRequest): string {
return `${request.range}:${Array.from(new Set(request.conversationIds.filter(Boolean)))
.sort()
.join('|')}`
}
private publish(task: ActiveTask): void {
const elapsedMs = Date.now() - task.startedAt
const estimatedRemainingMs =
task.progress.processed > 0 && task.progress.processed < task.progress.total
? Math.round(
(elapsedMs / task.progress.processed) * (task.progress.total - task.progress.processed)
)
: task.progress.processed >= task.progress.total
? 0
: null
const progress = { ...task.progress, elapsedMs, estimatedRemainingMs }
task.progress = progress
this.lastProgress = progress
for (const listener of this.listeners) listener(progress)
}
}
@@ -0,0 +1,25 @@
import { createHash } from 'crypto'
import type { VoiceMessageReference } from '../../shared/voice-recognition'
/**
* Stable, account-local identity for a source voice message. This is separate
* from scheduler keys and is shared by every transcription entry point.
*/
export function voiceMessageIdentity(reference: VoiceMessageReference): string {
return createHash('sha256')
.update(
`${reference.sessionId}|${reference.localId}|${reference.createTime}|${reference.svrId ?? ''}`
)
.digest('hex')
}
export function voiceAccountIdentity(accountRoot: string): string {
return createHash('sha256')
.update(
accountRoot
.trim()
.replace(/[\\/]+$/, '')
.toLowerCase()
)
.digest('hex')
}
+8 -10
View File
@@ -1,4 +1,3 @@
import { createHash } from 'crypto'
import type { VoiceMessageReference } from '../../shared/voice-recognition'
import type { VoiceService } from '../voice-service'
import type { AudioDecoderRegistry, EncodedVoiceSource } from './audio-decoder'
@@ -9,6 +8,7 @@ import type {
TranscriptRecord,
TranscriptRepository
} from './types'
import { voiceMessageIdentity } from './voice-message-identity'
export class VoiceSourceResolver implements SourceResolver {
constructor(private readonly voiceService: VoiceService) {}
@@ -45,11 +45,7 @@ export class VoicePipeline {
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
const audio = this.audioProcessor.process(decoded)
if (audio.samples.length === 0) throw new Error('Voice audio is empty after processing')
const messageIdentity = createHash('sha256')
.update(
`${reference.sessionId}|${reference.localId}|${reference.createTime}|${reference.svrId ?? ''}`
)
.digest('hex')
const messageIdentity = voiceMessageIdentity(reference)
const key = {
accountId,
messageIdentity,
@@ -58,9 +54,9 @@ export class VoicePipeline {
...this.recognizer.metadata
}
const cached = this.transcripts.find(key)
if (cached) {
if (cached?.transcript.trim()) {
return {
transcript: cached.transcript,
transcript: cached.transcript.trim(),
language: cached.language,
durationMs: cached.durationMs,
cached: true
@@ -68,10 +64,12 @@ export class VoicePipeline {
}
const output = await this.recognizer.recognize(audio, signal)
const transcript = output.text.trim()
if (!transcript) throw new Error('Voice recognition produced an empty transcript')
const now = Date.now()
const record: TranscriptRecord = {
...key,
transcript: output.text,
transcript,
language: output.language,
durationMs: audio.durationMs,
createdAt: now,
@@ -79,7 +77,7 @@ export class VoicePipeline {
}
this.transcripts.save(record)
return {
transcript: output.text,
transcript,
language: output.language,
durationMs: audio.durationMs,
cached: false
@@ -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)
}
}