332 lines
9.9 KiB
TypeScript
332 lines
9.9 KiB
TypeScript
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
|
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
|
import {
|
|
createStorageUriResolver,
|
|
type UriPlugin,
|
|
} from "@suaveplan/storage-uri";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { ImpulseResponseCache } from "../cache/cache.js";
|
|
import { type AudioDecoder, createIrLoader } from "../loader/loader.js";
|
|
import { runPreload } from "../loader/preload.js";
|
|
import { resetInstruments } from "../otel/otel.js";
|
|
import {
|
|
AUDIO_IR_LOADER_CODES,
|
|
AudioIrLoaderError,
|
|
type AudioIrLoaderErrorCode,
|
|
} from "./error-codes.js";
|
|
|
|
const ENTRY: IrManifestEntry = Object.freeze({
|
|
id: "club-club1",
|
|
family: "room",
|
|
uri: "bundle://x/club1.wav",
|
|
channels: 1,
|
|
sampleRate: 44100,
|
|
gainDb: 0,
|
|
license: "MIT",
|
|
source: "demo",
|
|
}) as IrManifestEntry;
|
|
|
|
const FLAC_ENTRY: IrManifestEntry = Object.freeze({
|
|
...ENTRY,
|
|
id: "club-club2",
|
|
uri: "bundle://x/club2.flac",
|
|
}) as IrManifestEntry;
|
|
|
|
function buildResolver(reads: Record<string, Uint8Array>): {
|
|
resolver: ReturnType<typeof createStorageUriResolver>;
|
|
plugin: UriPlugin;
|
|
} {
|
|
const plugin: UriPlugin = {
|
|
scheme: "bundle",
|
|
adapter: {
|
|
async get(key: string) {
|
|
return reads[key];
|
|
},
|
|
async set() {
|
|
return;
|
|
},
|
|
async delete() {
|
|
return true;
|
|
},
|
|
async exists(key) {
|
|
return key in reads;
|
|
},
|
|
async list() {
|
|
return { keys: [], total: 0, hasMore: false };
|
|
},
|
|
async clear() {
|
|
return;
|
|
},
|
|
},
|
|
parse(uri: string) {
|
|
const noScheme = uri.replace(/^bundle:\/\//, "");
|
|
return { key: noScheme };
|
|
},
|
|
};
|
|
return { resolver: createStorageUriResolver([plugin]), plugin };
|
|
}
|
|
|
|
const noopWavBytes = new Uint8Array([1, 2, 3]);
|
|
function decoderThrowing(code: string): AudioDecoder {
|
|
return async () => {
|
|
const err = new Error(`fixture decoder threw ${code}`);
|
|
throw err;
|
|
};
|
|
}
|
|
|
|
async function exerciseDecoderUnavailable(): Promise<
|
|
AudioIrLoaderError | undefined
|
|
> {
|
|
resetInstruments();
|
|
const registry = createIrRegistry();
|
|
registry.register(FLAC_ENTRY, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const { resolver } = buildResolver({ "x/club2.flac": noopWavBytes });
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver,
|
|
decoders: { wav: decoderThrowing("nope") },
|
|
});
|
|
try {
|
|
await loader.load("club-club2");
|
|
} catch (err) {
|
|
if (err instanceof AudioIrLoaderError) return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function exerciseManifestMismatch(): Promise<
|
|
AudioIrLoaderError | undefined
|
|
> {
|
|
resetInstruments();
|
|
const registry = createIrRegistry();
|
|
registry.register(ENTRY, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
|
const decoder: AudioDecoder = async () => ({
|
|
channelData: [new Float32Array([0.1])],
|
|
channels: 1,
|
|
sampleRate: 22050,
|
|
});
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver,
|
|
decoders: { wav: decoder },
|
|
});
|
|
try {
|
|
await loader.load("club-club1");
|
|
} catch (err) {
|
|
if (err instanceof AudioIrLoaderError) return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function exerciseAborted(): Promise<AudioIrLoaderError | undefined> {
|
|
resetInstruments();
|
|
const registry = createIrRegistry();
|
|
registry.register(ENTRY, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
|
const decoder: AudioDecoder = async () => ({
|
|
channelData: [new Float32Array([0])],
|
|
channels: 1,
|
|
sampleRate: 44100,
|
|
});
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver,
|
|
decoders: { wav: decoder },
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("preflight"));
|
|
try {
|
|
await loader.load("club-club1", { signal: controller.signal });
|
|
} catch (err) {
|
|
if (err instanceof AudioIrLoaderError) return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function exerciseCacheOverflow(): Promise<
|
|
AudioIrLoaderError | undefined
|
|
> {
|
|
const cache = new ImpulseResponseCache(64);
|
|
const big = Object.freeze({
|
|
id: "big",
|
|
channelData: Object.freeze([new Float32Array(1024)]),
|
|
channels: 1,
|
|
sampleRate: 44100,
|
|
lengthFrames: 1024,
|
|
durationSec: 1024 / 44100,
|
|
gainDb: 0,
|
|
family: "room" as const,
|
|
manifest: ENTRY,
|
|
});
|
|
try {
|
|
cache.put("big", big as unknown as Parameters<typeof cache.put>[1]);
|
|
} catch (err) {
|
|
if (err instanceof AudioIrLoaderError) return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function exerciseDecodeFailed(): Promise<AudioIrLoaderError | undefined> {
|
|
resetInstruments();
|
|
const registry = createIrRegistry();
|
|
registry.register(ENTRY, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
|
const decoder: AudioDecoder = async () => ({
|
|
channelData: [new Float32Array([0])],
|
|
channels: 99,
|
|
sampleRate: 44100,
|
|
});
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver,
|
|
decoders: { wav: decoder },
|
|
});
|
|
try {
|
|
await runPreload(
|
|
(id, opts) => loader.load(id, opts),
|
|
["club-club1"],
|
|
undefined
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof AudioIrLoaderError) return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function exerciseResolveFailed(): Promise<
|
|
AudioIrLoaderError | undefined
|
|
> {
|
|
resetInstruments();
|
|
const registry = createIrRegistry();
|
|
registry.register(ENTRY, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
|
const decoder: AudioDecoder = async () => ({
|
|
channelData: [new Float32Array([0])],
|
|
channels: 1,
|
|
sampleRate: 44100,
|
|
});
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver,
|
|
decoders: { wav: decoder },
|
|
});
|
|
// Build a fake load fn that throws a non-SuaveplanError to trigger
|
|
// the RESOLVE_FAILED wrapper inside runPreload.
|
|
const badLoader = async () => {
|
|
throw new TypeError("plain error");
|
|
};
|
|
try {
|
|
await runPreload(
|
|
badLoader as unknown as (id: string) => Promise<never>,
|
|
["x"],
|
|
undefined
|
|
);
|
|
} catch (err) {
|
|
// top-level err is DECODE_FAILED whose `errors[0].error` is the RESOLVE_FAILED
|
|
if (err instanceof AudioIrLoaderError && err.errors !== undefined) {
|
|
const first = err.errors[0]?.error;
|
|
if (first instanceof AudioIrLoaderError) return first;
|
|
}
|
|
}
|
|
// touch loader to avoid unused warning
|
|
void loader;
|
|
return undefined;
|
|
}
|
|
|
|
function siteFor(
|
|
code: AudioIrLoaderErrorCode
|
|
): () => Promise<AudioIrLoaderError | undefined> {
|
|
if (code === AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE)
|
|
return exerciseDecoderUnavailable;
|
|
if (code === AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH)
|
|
return exerciseManifestMismatch;
|
|
if (code === AUDIO_IR_LOADER_CODES.ABORTED) return exerciseAborted;
|
|
if (code === AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW)
|
|
return exerciseCacheOverflow;
|
|
if (code === AUDIO_IR_LOADER_CODES.DECODE_FAILED)
|
|
return exerciseDecodeFailed;
|
|
if (code === AUDIO_IR_LOADER_CODES.RESOLVE_FAILED)
|
|
return exerciseResolveFailed;
|
|
return async () => undefined;
|
|
}
|
|
|
|
describe("AUDIO_IR_LOADER_CODES exhaustion", () => {
|
|
it("every AUDIO_IR_LOADER code reachable from a real call site", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const all = Object.values(AUDIO_IR_LOADER_CODES);
|
|
expect(all.length).toBe(6);
|
|
for (const code of all) {
|
|
const err = await siteFor(code)();
|
|
expect(err, `no real call site exercises ${code}`).toBeDefined();
|
|
expect(err?.code).toBe(code);
|
|
}
|
|
});
|
|
|
|
it("registry exposes exactly six codes", { timeout: 5000 }, () => {
|
|
const names = Object.keys(AUDIO_IR_LOADER_CODES).sort();
|
|
expect(names).toEqual([
|
|
"ABORTED",
|
|
"CACHE_OVERFLOW",
|
|
"DECODER_UNAVAILABLE",
|
|
"DECODE_FAILED",
|
|
"MANIFEST_MISMATCH",
|
|
"RESOLVE_FAILED",
|
|
]);
|
|
});
|
|
|
|
it("AUDIO_IR_LOADER_CODES registry is frozen", { timeout: 5000 }, () => {
|
|
expect(Object.isFrozen(AUDIO_IR_LOADER_CODES)).toBe(true);
|
|
});
|
|
|
|
it("AudioIrLoaderError surfaces typed discriminants", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const err = new AudioIrLoaderError(
|
|
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
|
"x",
|
|
{
|
|
id: "club-a",
|
|
field: "channels",
|
|
manifestValue: 2,
|
|
decodedValue: 1,
|
|
}
|
|
);
|
|
expect(err.name).toBe("AudioIrLoaderError");
|
|
expect(err.id).toBe("club-a");
|
|
expect(err.field).toBe("channels");
|
|
expect(err.manifestValue).toBe(2);
|
|
expect(err.decodedValue).toBe(1);
|
|
});
|
|
|
|
it("AudioIrLoaderError with no options leaves discriminants undefined", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const err = new AudioIrLoaderError(AUDIO_IR_LOADER_CODES.ABORTED, "x");
|
|
expect(err.id).toBeUndefined();
|
|
expect(err.extension).toBeUndefined();
|
|
expect(err.field).toBeUndefined();
|
|
expect(err.manifestValue).toBeUndefined();
|
|
expect(err.decodedValue).toBeUndefined();
|
|
expect(err.maxBytes).toBeUndefined();
|
|
expect(err.decodedBytes).toBeUndefined();
|
|
expect(err.errors).toBeUndefined();
|
|
});
|
|
});
|