From 5e268cd8f438d7b71358ced4590bbc2b93cce340 Mon Sep 17 00:00:00 2001 From: "DESKTOP-QMBPBO5\\Lazy" Date: Fri, 4 Sep 2026 15:09:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6=E5=8F=98=E8=B0=83?= =?UTF-8?q?=E3=80=81=E5=8F=98=E9=80=9F=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/export-service.ts | 58 +++++++++++++------------------------- src/main/voice-service.ts | 8 ++++-- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 4af0bf6..56252a8 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -584,19 +584,6 @@ const detectAssetExtension = (buffer: Buffer): string | null => { return 'webp' return null } -const htmlArchiveStickerResourceIsValid = async ( - outputDir: string, - value?: string -): Promise => { - const filePath = htmlArchiveResourcePath(outputDir, value) - if (!filePath) return false - try { - return detectAssetExtension(await fs.readFile(filePath)) !== null - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - throw error - } -} async function readAvatarAsset( source: string ): Promise<{ extension: string; buffer: Buffer } | null> { @@ -941,10 +928,7 @@ async function runSingleExport( request.format === 'html' ? options.outputFolderName || safeFilePart(request.outputName) : `${safeFilePart(request.outputName)}_${exportStamp()}` - const root = - options.outputRoot || - request.outputDirectory || - (await resolveDefaultExportRoot(outputFolder)) + const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder)) await fs.mkdir(root, { recursive: true }) const outputDir = join(root, outputFolder) const outputPath = @@ -1236,7 +1220,16 @@ async function runSingleExport( markResourceExists(voiceUrl) } message.voiceDataUrl = voiceUrl - message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) + // 读取 WAV 头中的采样率,避免按错误采样率计算时长(Silk 解码为 16kHz) + const wavSampleRate = + audioBuffer.length >= 44 ? audioBuffer.readUInt32LE(24) : 16000 + const wavChannels = + audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1 + const pcmBytes = Math.max(0, audioBuffer.length - 44) + message.voiceDuration = Math.max( + 1, + Math.round(pcmBytes / (wavSampleRate * wavChannels * 2)) + ) } catch (error) { keepMediaError( request, @@ -1345,28 +1338,12 @@ async function runSingleExport( const reusableImageQuality = previous?.exportMediaQuality === 'original' || (request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail') - let reusableMediaExists = false - if (reusableMediaType && previous?.exportMediaUrl) { - reusableMediaExists = await resourceExists(previous.exportMediaUrl) - if ( - reusableMediaExists && - reusableMediaType === 'sticker' && - !(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl)) - ) { - reusableMediaExists = false - resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false)) - delete previous.exportMediaUrl - delete previous.exportMediaType - delete previous.exportMediaName - delete previous.exportMediaQuality - } - } if ( reusableMediaType && previous?.exportMediaUrl && (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && (reusableMediaType !== 'image' || reusableImageQuality) && - reusableMediaExists + (await resourceExists(previous.exportMediaUrl)) ) { message.exportMediaUrl = previous.exportMediaUrl message.exportMediaType = reusableMediaType @@ -1478,10 +1455,13 @@ async function runSingleExport( } else if (message.contentData.type === 'sticker' && stickerService) { const stickerSource = message.contentData.url || message.contentData.thumbUrl const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5) - const decoded = result.data ? decodeDataUrl(result.data) : null - const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null - if (decoded && stickerExtension) { - const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}` + const decoded = result.data + ? decodeDataUrl(result.data) + : stickerSource + ? await readAvatarAsset(stickerSource) + : null + if (decoded) { + const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}` const mediaUrl = `media/${name}` if (!(await resourceExists(mediaUrl))) { await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 1257be9..36210a3 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -125,7 +125,11 @@ export class VoiceService { codec: 'silk', sourceHash: createHash('sha256').update(silkData).digest('hex') }) - const wavData = this.createWavBuffer(decoded.pcm, 24000) + const wavData = this.createWavBuffer( + decoded.pcm, + decoded.sampleRate, + decoded.channels + ) const data = wavData.toString('base64') const cacheKey = this.buildCacheKey( reference.sessionId, @@ -239,7 +243,7 @@ export class VoiceService { private createWavBuffer( pcmData: Buffer, - sampleRate: number = 24000, + sampleRate: number = 16000, channels: number = 1 ): Buffer { const pcmLength = pcmData.length From 348953575061b61ac76a15493f87bb93d82a4e34 Mon Sep 17 00:00:00 2001 From: yy2257 Date: Fri, 4 Sep 2026 15:55:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E6=81=A2=E5=A4=8D=E8=A1=A8?= =?UTF-8?q?=E6=83=85=E5=AF=BC=E5=87=BA=E4=BF=9D=E6=8A=A4=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=AF=AD=E9=9F=B3=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/export-service.ts | 31 ++++++++++++++++++++++++++++++- tests/unit/voice-service.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 56252a8..7db4939 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -584,6 +584,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => { return 'webp' return null } +const htmlArchiveStickerResourceIsValid = async ( + outputDir: string, + value?: string +): Promise => { + const filePath = htmlArchiveResourcePath(outputDir, value) + if (!filePath) return false + try { + return detectAssetExtension(await fs.readFile(filePath)) !== null + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} async function readAvatarAsset( source: string ): Promise<{ extension: string; buffer: Buffer } | null> { @@ -1338,12 +1351,28 @@ async function runSingleExport( const reusableImageQuality = previous?.exportMediaQuality === 'original' || (request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail') + let reusableMediaExists = false + if (reusableMediaType && previous?.exportMediaUrl) { + reusableMediaExists = await resourceExists(previous.exportMediaUrl) + if ( + reusableMediaExists && + reusableMediaType === 'sticker' && + !(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl)) + ) { + reusableMediaExists = false + resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false)) + delete previous.exportMediaUrl + delete previous.exportMediaType + delete previous.exportMediaName + delete previous.exportMediaQuality + } + } if ( reusableMediaType && previous?.exportMediaUrl && (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && (reusableMediaType !== 'image' || reusableImageQuality) && - (await resourceExists(previous.exportMediaUrl)) + reusableMediaExists ) { message.exportMediaUrl = previous.exportMediaUrl message.exportMediaType = reusableMediaType diff --git a/tests/unit/voice-service.test.ts b/tests/unit/voice-service.test.ts index 5e4bb53..7565f16 100644 --- a/tests/unit/voice-service.test.ts +++ b/tests/unit/voice-service.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it, vi } from 'vitest' import { VoiceService } from '../../src/main/voice-service' describe('VoiceService batch lookup', () => { + it('writes the decoded sample rate and duration into batch WAV output', async () => { + const getVoiceDataBatch = vi.fn().mockResolvedValue([{ success: true, hex: '0102' }]) + const service = new VoiceService({ getVoiceDataBatch } as never) + const decoder = (service as unknown as { + decoderRegistry: { decode: ReturnType } + }).decoderRegistry + vi.spyOn(decoder, 'decode').mockResolvedValue({ + pcm: Buffer.alloc(16_000 * 2), + sampleRate: 16_000, + channels: 1 + }) + + const [result] = await service.resolveVoices([ + { sessionId: 'session', localId: 10, createTime: 100, svrId: '1000' } + ]) + + expect(result.success).toBe(true) + const wav = Buffer.from(result.data!, 'base64') + expect(wav.readUInt32LE(24)).toBe(16_000) + expect(wav.readUInt16LE(22)).toBe(1) + expect((wav.length - 44) / (wav.readUInt32LE(24) * wav.readUInt16LE(22) * 2)).toBe(1) + }) + it('retries failed batch entries with the compatible single-item lookup', async () => { const getVoiceDataBatch = vi.fn().mockResolvedValue([ { success: false, error: '获取语音数据失败' },