mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 05:26:57 +08:00
feat: 知识库更新,优化批量语音转写 - 合并索引 - 增加会话级批量转写处理 - 补充语音转写回归测试
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user