feat: 语音

This commit is contained in:
电摇小子
2026-08-05 09:45:59 +08:00
committed by Wxw-Gu
parent 69bc6f57e7
commit 7529a67f09
49 changed files with 3013 additions and 154 deletions
+82 -102
View File
@@ -1,55 +1,37 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { createRequire } from 'module'
import { createHash } from 'crypto'
import { Wcdb4Client } from './wcdb4-client'
import { isPackagedRuntime } from './runtime-mode'
import {
createDefaultAudioDecoderRegistry,
type EncodedVoiceSource
} from './voice-pipeline/audio-decoder'
const nodeRequire = createRequire(import.meta.url)
export {
findSilkWasmRuntimeLocation,
getSilkWasmRuntimeLocations,
type SilkWasmRuntimeLocation
} from './voice-pipeline/audio-decoder'
export type SilkWasmRuntimeLocation = {
packagePath: string
wasmPath: string
source: 'unpacked' | 'resources' | 'asar' | 'development'
export interface ResolvedPcmAudio {
pcm: Buffer
sampleRate: number
channels: number
codec: 'silk'
sourceHash: string
}
export function getSilkWasmRuntimeLocations(options?: {
packaged?: boolean
resourcesPath?: string
appPath?: string
}): SilkWasmRuntimeLocation[] {
const packaged = options?.packaged ?? isPackagedRuntime()
const resourcesPath = options?.resourcesPath ?? process.resourcesPath
const appPath = options?.appPath ?? app.getAppPath()
const location = (
packagePath: string,
source: SilkWasmRuntimeLocation['source']
): SilkWasmRuntimeLocation => ({
packagePath,
wasmPath: join(packagePath, 'lib', 'silk.wasm'),
source
})
export type ResolvePcmResult =
| { success: true; audio: ResolvedPcmAudio }
| { success: false; error: string }
if (!packaged) {
return [location(join(appPath, 'node_modules', 'silk-wasm'), 'development')]
}
return [
location(join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm'), 'unpacked'),
location(join(resourcesPath, 'node_modules', 'silk-wasm'), 'resources'),
location(join(appPath, 'node_modules', 'silk-wasm'), 'asar')
]
}
export function findSilkWasmRuntimeLocation(
locations: SilkWasmRuntimeLocation[]
): SilkWasmRuntimeLocation | null {
return locations.find((location) => existsSync(location.wasmPath)) || null
}
export type ResolveSourceResult =
| { success: true; source: EncodedVoiceSource }
| { success: false; error: string }
export class VoiceService {
private wcdb4Client: Wcdb4Client
private voiceCache = new Map<string, string>()
private pcmCache = new Map<string, ResolvedPcmAudio>()
private readonly decoderRegistry = createDefaultAudioDecoderRegistry()
constructor(wcdb4Client: Wcdb4Client) {
this.wcdb4Client = wcdb4Client
@@ -69,39 +51,9 @@ export class VoiceService {
return { success: true, data: cached }
}
const candidates = this.buildCandidates(sessionId)
console.log('[VoiceService] resolving voice:', { sessionId, localId, createTime, candidates })
const voiceResult = await this.wcdb4Client.getVoiceData(
sessionId,
createTime,
candidates,
localId,
svrId || 0
)
if (!voiceResult.success || !voiceResult.hex) {
console.log('[VoiceService] getVoiceData failed:', voiceResult.error)
return { success: false, error: voiceResult.error || '获取语音数据失败' }
}
console.log('[VoiceService] got hex data, length:', voiceResult.hex.length)
const silkData = this.decodeVoiceBlob(voiceResult.hex)
if (!silkData || silkData.length === 0) {
console.log('[VoiceService] decodeVoiceBlob failed, hex:', voiceResult.hex.substring(0, 100))
return { success: false, error: '语音数据为空' }
}
console.log('[VoiceService] silkData length:', silkData.length)
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
if (!pcmData || pcmData.length === 0) {
console.log('[VoiceService] decodeSilkToPcm failed')
return { success: false, error: 'Silk 解码失败' }
}
console.log('[VoiceService] pcmData length:', pcmData.length)
const pcmResult = await this.resolvePcm(sessionId, localId, createTime, svrId)
if (!pcmResult.success) return pcmResult
const pcmData = pcmResult.audio.pcm
const wavData = this.createWavBuffer(pcmData, 24000)
console.log(
@@ -118,6 +70,61 @@ export class VoiceService {
return { success: true, data: base64Data }
}
async resolvePcm(
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
): Promise<ResolvePcmResult> {
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
const cached = this.pcmCache.get(cacheKey)
if (cached) return { success: true, audio: cached }
const sourceResult = await this.resolveSource(sessionId, localId, createTime, svrId)
if (!sourceResult.success) return sourceResult
try {
const decoded = await this.decoderRegistry.decode(sourceResult.source)
const audio: ResolvedPcmAudio = { ...decoded, codec: 'silk' }
this.pcmCache.set(cacheKey, audio)
return { success: true, audio }
} catch (error) {
console.error('[VoiceService] audio decode failed:', error)
return { success: false, error: error instanceof Error ? error.message : 'Silk 解码失败' }
}
}
async resolveSource(
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
): Promise<ResolveSourceResult> {
const candidates = this.buildCandidates(sessionId)
const voiceResult = await this.wcdb4Client.getVoiceData(
sessionId,
createTime,
candidates,
localId,
svrId || 0
)
if (!voiceResult.success || !voiceResult.hex) {
return { success: false, error: voiceResult.error || '获取语音数据失败' }
}
const silkData = this.decodeVoiceBlob(voiceResult.hex)
if (!silkData?.length) return { success: false, error: '语音数据为空' }
return {
success: true,
source: {
data: silkData,
codec: 'silk',
sourceHash: createHash('sha256').update(silkData).digest('hex')
}
}
}
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
return `${sessionId}-${localId}-${createTime}`
}
@@ -142,33 +149,6 @@ export class VoiceService {
}
}
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
const locations = getSilkWasmRuntimeLocations()
const runtime = findSilkWasmRuntimeLocation(locations)
if (!runtime) {
console.error(
'[VoiceService] silk.wasm not found. checked:',
locations.map((location) => location.wasmPath)
)
return null
}
const silkWasm = nodeRequire(runtime.packagePath)
if (!silkWasm || !silkWasm.decode) {
console.error('[VoiceService] silk-wasm module invalid:', runtime.packagePath)
return null
}
console.log('[VoiceService] using silk-wasm runtime:', runtime.source)
const result = await silkWasm.decode(silkData, sampleRate)
return Buffer.from(result.data)
} catch (e) {
console.error('[VoiceService] decodeSilkToPcm error:', e)
return null
}
}
private createWavBuffer(
pcmData: Buffer,
sampleRate: number = 24000,