import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { convertAudioBytes, encodeSilk } from '@snowluma/protocol/highway/ffmpeg-addon'; interface NativeAudioAddon { convertToNTSilkTct(inputFile: string, outputFile: string): Promise; decodeAudioToFmt( inputFile: string, outputFile: string, format: string, ): Promise<{ result: boolean; sampleRate: number; channels: number; format: string }>; getDuration(filePath: string): Promise; } const __dirname = path.dirname(fileURLToPath(import.meta.url)); function makeMonoWav(sampleRate = 24_000, durationSeconds = 1): Buffer { const samples = sampleRate * durationSeconds; const bytes = Buffer.alloc(44 + samples * 2); bytes.write('RIFF', 0); bytes.writeUInt32LE(36 + samples * 2, 4); bytes.write('WAVE', 8); bytes.write('fmt ', 12); bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22); bytes.writeUInt32LE(sampleRate, 24); bytes.writeUInt32LE(sampleRate * 2, 28); bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34); bytes.write('data', 36); bytes.writeUInt32LE(samples * 2, 40); for (let i = 0; i < samples; i += 1) { bytes.writeInt16LE( Math.round(Math.sin((2 * Math.PI * 440 * i) / sampleRate) * 12_000), 44 + i * 2, ); } return bytes; } function loadBundledAddon(): NativeAudioAddon { const addonPath = path.resolve( __dirname, `../../../runtime/native/ffmpeg/ffmpegAddon.${process.platform}.${process.arch}.node`, ); const mod = { exports: {} as Record }; process.dlopen(mod, addonPath); return mod.exports as unknown as NativeAudioAddon; } function hasMp3Signature(bytes: Buffer): boolean { const hasId3Tag = bytes.length >= 3 && bytes.subarray(0, 3).toString('ascii') === 'ID3'; const hasFrameSync = bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0; return hasId3Tag || hasFrameSync; } const isReleaseTarget = (process.platform === 'win32' && process.arch === 'x64') || (process.platform === 'linux' && (process.arch === 'x64' || process.arch === 'arm64')); const describeBundledAddon = isReleaseTarget ? describe : describe.skip; describeBundledAddon('bundled native ffmpeg addon', () => { let tmpDir: string; beforeAll(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sl-native-audio-test-')); }); afterAll(() => rmSync(tmpDir, { recursive: true, force: true })); describe.each(['silk', 'ntsilk', 'extensionless'] as const)('%s duration', (format) => { it.each([0.02, 1, 2.5])('measures %s seconds from the encoded frames', async (seconds) => { const addon = loadBundledAddon(); const wavPath = path.join(tmpDir, `duration-${format}-${seconds}.wav`); const recordPath = path.join( tmpDir, `duration-${format}-${seconds}${format === 'extensionless' ? '' : `.${format}`}`, ); writeFileSync(wavPath, makeMonoWav(24_000, seconds)); await addon.convertToNTSilkTct(wavPath, recordPath); if (format === 'silk') { // Standard SILK has the same packets without the QQ container prefix. const encoded = readFileSync(recordPath); expect(encoded.subarray(0, 10)).toEqual( Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]), ); writeFileSync(recordPath, encoded.subarray(1)); } expect(await addon.getDuration(recordPath)).toBeCloseTo(seconds, 6); }); it('rejects a truncated packet instead of reporting a duration', async () => { const addon = loadBundledAddon(); const wavPath = path.join(tmpDir, `truncated-${format}.wav`); const recordPath = path.join( tmpDir, `truncated-${format}${format === 'extensionless' ? '' : `.${format}`}`, ); writeFileSync(wavPath, makeMonoWav()); await addon.convertToNTSilkTct(wavPath, recordPath); const encoded = readFileSync(recordPath); writeFileSync( recordPath, encoded.subarray(format === 'silk' ? 1 : 0, encoded.length - 1), ); await expect(addon.getDuration(recordPath)).rejects.toThrow(/Invalid data/); await expect(encodeSilk(recordPath, tmpDir)).rejects.toThrow(/Invalid data/); }); }); it('encodes a WAV with the runtime addon when started from the protocol package', async () => { const wavPath = path.join(tmpDir, 'runtime-input.wav'); writeFileSync(wavPath, makeMonoWav()); const result = await encodeSilk(wavPath, tmpDir); expect(result.converted).toBe(true); expect(result.duration).toBe(1); expect(readFileSync(result.path).subarray(1, 10).toString('ascii')).toBe('#!SILK_V3'); expect(await loadBundledAddon().getDuration(result.path)).toBeCloseTo(1, 6); }); it('converts a mono QQ-style SILK record to a decodable MP3 through the public transcode seam', async () => { const addon = loadBundledAddon(); const wavPath = path.join(tmpDir, 'mono-input.wav'); const silkPath = path.join(tmpDir, 'mono-input.ntsilk'); writeFileSync(wavPath, makeMonoWav()); await addon.convertToNTSilkTct(wavPath, silkPath); const result = await convertAudioBytes(readFileSync(silkPath), 'mp3', { addon, tmpDir }); const mp3 = Buffer.from(result.base64, 'base64'); const outputPath = path.join(tmpDir, 'mono-output.mp3'); writeFileSync(outputPath, mp3); expect(result.size).toBe(mp3.length); expect(hasMp3Signature(mp3)).toBe(true); const duration = await addon.getDuration(outputPath); expect(duration).toBeGreaterThan(0.9); expect(duration).toBeLessThan(1.2); }); it('converts the QQ AI SILK container variant through the public transcode seam', async () => { const addon = loadBundledAddon(); const wavPath = path.join(tmpDir, 'ai-input.wav'); const silkPath = path.join(tmpDir, 'ai-input.ntsilk'); writeFileSync(wavPath, makeMonoWav()); await addon.convertToNTSilkTct(wavPath, silkPath); const aiSilk = readFileSync(silkPath); expect(aiSilk.subarray(0, 10)).toEqual( Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]), ); aiSilk[0] = 0x03; const result = await convertAudioBytes(aiSilk, 'mp3', { addon, tmpDir }); const mp3 = Buffer.from(result.base64, 'base64'); const outputPath = path.join(tmpDir, 'ai-output.mp3'); writeFileSync(outputPath, mp3); expect(hasMp3Signature(mp3)).toBe(true); const duration = await addon.getDuration(outputPath); expect(duration).toBeGreaterThan(0.9); expect(duration).toBeLessThan(1.2); }); });