#165: get_record gains an optional `out_format` (mp3/amr/wma/m4a/spx/ogg/ wav/flac, mirroring NapCat). When set, the QQ voice (SILK/AMR) is transcoded server-side and the result is attached as `data.base64`; the original info (file/url/file_size) is preserved. Absent ⇒ behaviour unchanged. This spares ASR consumers from shipping a SILK decoder. - protocol: convertAudioBytes(bytes, format, deps?) drives the bundled SILK-capable ffmpeg addon's convertFile through temp files (always cleaned up), with a format allowlist and an output-size cap. Pure/ injectable, unit-tested with a fake addon (6 cases). - core: ExtrasApi.convertRecord(source, format) = loadBinarySource (64 MiB cap) → convertAudioBytes. - onebot: get_record wires out_format; transcode failure (incl. an addon lacking SILK decode) degrades to ACTION_FAILED, never crashes. Adversarially reviewed: no path traversal (format allowlisted), no SSRF (source is the cached CDN url, not user input), cleanup covers every path, graceful degradation confirmed. Catalog regenerated. Real-machine e2e (does the shipped .node actually decode SILK?) is Phase 2.
68 lines
3.1 KiB
TypeScript
68 lines
3.1 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest';
|
|
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { AUDIO_OUT_FORMATS, convertAudioBytes, isAudioOutFormat } from '@snowluma/protocol/highway/ffmpeg-addon';
|
|
|
|
// convertAudioBytes is the pure (#165) transcode seam: write input → addon
|
|
// convertFile → read output as base64 → always clean up temp files. The native
|
|
// addon is file-based and can't run in CI, so a fake addon stands in.
|
|
|
|
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sl-audio-test-'));
|
|
afterAll(() => rmSync(tmpDir, { recursive: true, force: true }));
|
|
|
|
/** Fake addon: writes deterministic bytes to the output path and reports ok. */
|
|
const fakeOk = {
|
|
convertFile: async (_in: string, out: string, _fmt: string) => {
|
|
writeFileSync(out, Buffer.from('WAVDATA')); // stand-in converted payload
|
|
return { success: true };
|
|
},
|
|
};
|
|
|
|
describe('isAudioOutFormat / AUDIO_OUT_FORMATS', () => {
|
|
it('accepts exactly NapCat\'s allowlist', () => {
|
|
expect([...AUDIO_OUT_FORMATS]).toEqual(['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac']);
|
|
expect(isAudioOutFormat('wav')).toBe(true);
|
|
expect(isAudioOutFormat('WAV')).toBe(false); // case-sensitive
|
|
expect(isAudioOutFormat('opus')).toBe(false);
|
|
expect(isAudioOutFormat('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('convertAudioBytes', () => {
|
|
it('transcodes and returns base64 + size of the addon output', async () => {
|
|
const r = await convertAudioBytes(new Uint8Array([1, 2, 3]), 'wav', { addon: fakeOk, tmpDir });
|
|
expect(Buffer.from(r.base64, 'base64').toString()).toBe('WAVDATA');
|
|
expect(r.size).toBe('WAVDATA'.length);
|
|
});
|
|
|
|
it('rejects an unsupported format before touching the addon', async () => {
|
|
let called = false;
|
|
const spy = { convertFile: async () => { called = true; return { success: true }; } };
|
|
await expect(convertAudioBytes(new Uint8Array([1]), 'opus', { addon: spy, tmpDir })).rejects.toThrow(/unsupported out_format/);
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it('throws and cleans up when the addon reports failure', async () => {
|
|
const fakeFail = { convertFile: async () => ({ success: false }) };
|
|
await expect(convertAudioBytes(new Uint8Array([9]), 'mp3', { addon: fakeFail, tmpDir })).rejects.toThrow(/conversion failed/);
|
|
// no leftover temp files for this run
|
|
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.mp3') || n.endsWith('.in'));
|
|
expect(leftover).toEqual([]);
|
|
});
|
|
|
|
it('rejects (and cleans up) when the output exceeds the cap', async () => {
|
|
await expect(
|
|
convertAudioBytes(new Uint8Array([1]), 'wav', { addon: fakeOk, tmpDir, maxOutputBytes: 3 }),
|
|
).rejects.toThrow(/too large/);
|
|
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.wav') || n.endsWith('.in'));
|
|
expect(leftover).toEqual([]);
|
|
});
|
|
|
|
it('cleans up the temp input + output on success too', async () => {
|
|
await convertAudioBytes(new Uint8Array([4, 5]), 'flac', { addon: fakeOk, tmpDir });
|
|
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.flac') || n.endsWith('.in'));
|
|
expect(leftover).toEqual([]);
|
|
});
|
|
});
|