Initial Commit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# `error-codes.ts` — `AUDIO_IR_LOADER.*` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single source of truth for every error code raised by
|
||||
`@suaveplan/audio-ir-loader`. It uses `defineCodes` from `@suaveplan/error`
|
||||
to register an immutable, byte-stable mapping of short keys (`MANIFEST_MISMATCH`)
|
||||
to fully-qualified strings (`AUDIO_IR_LOADER.MANIFEST_MISMATCH`). It also
|
||||
declares the `AudioIrLoaderError` class, which exposes every dimension an
|
||||
operator might filter on (`id`, `extension`, `field`, `manifestValue`,
|
||||
`decodedValue`, `maxBytes`, `decodedBytes`, `errors`) as typed read-only
|
||||
properties so dashboards and routing layers never have to parse message
|
||||
text to know what happened.
|
||||
|
||||
## Features
|
||||
|
||||
- Six codes — `DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
|
||||
`CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`.
|
||||
- Frozen registry object — `Object.isFrozen(AUDIO_IR_LOADER_CODES)` is true.
|
||||
- Typed error class with discriminants pre-typed on the class.
|
||||
- Aggregate `errors[]` for the `DECODE_FAILED` partial-failure path from
|
||||
`preload`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
"no decoder registered for .flac",
|
||||
{ id: "club-club1", extension: "flac" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The aggregate `errors` discriminant powers the `preload` partial-failure
|
||||
contract:
|
||||
|
||||
```ts
|
||||
import { AudioIrLoaderError, AUDIO_IR_LOADER_CODES } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
await loader.preload(["a", "b", "c"]);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioIrLoaderError &&
|
||||
err.code === AUDIO_IR_LOADER_CODES.DECODE_FAILED
|
||||
) {
|
||||
for (const { id, error } of err.errors ?? []) {
|
||||
console.error(`load ${id} failed:`, error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Operators routing on the structured discriminants can ship dashboards that
|
||||
break down failures by `extension` for `DECODER_UNAVAILABLE`, by `field` for
|
||||
`MANIFEST_MISMATCH`, and by the size pair (`maxBytes` / `decodedBytes`) for
|
||||
`CACHE_OVERFLOW`, with zero message-text regexes.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUDIO_IR_LOADER_CODES`
|
||||
|
||||
Frozen `Readonly<Record<Key, "AUDIO_IR_LOADER.<Key>">>` with keys
|
||||
`DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`, `CACHE_OVERFLOW`,
|
||||
`DECODE_FAILED`, `RESOLVE_FAILED`.
|
||||
|
||||
### `AudioIrLoaderError extends SuaveplanError`
|
||||
|
||||
Constructor: `(code, message, options?)`. Discriminants on the instance:
|
||||
`id`, `extension`, `field`, `manifestValue`, `decodedValue`, `maxBytes`,
|
||||
`decodedBytes`, `errors`. `name === "AudioIrLoaderError"`.
|
||||
|
||||
### `AudioIrLoaderErrorOptions`
|
||||
|
||||
Extends `SuaveplanErrorOptions` with the seven optional discriminants
|
||||
listed on the class plus `errors: readonly AggregatedLoadFailure[]`.
|
||||
|
||||
### `AggregatedLoadFailure`
|
||||
|
||||
`{ id: string; error: SuaveplanError }` — one entry per failed id in a
|
||||
`preload` aggregate.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object; the registry guarantees byte-stable
|
||||
code strings across builds. The discriminants on `AudioIrLoaderError` are
|
||||
declared as `T | undefined` rather than via `exactOptionalPropertyTypes`
|
||||
gymnastics so callers reading the property always get an unambiguous
|
||||
"absent" sentinel. Every code in this registry is exercised by a real
|
||||
call site under test (see `error-codes.test.ts`) — the exhaustion test
|
||||
guarantees we cannot register a code that has no production thrower.
|
||||
@@ -0,0 +1,331 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every error code raised by `@suaveplan/audio-ir-loader`.
|
||||
*
|
||||
* Six codes total — `DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
|
||||
* `CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`. Every code is
|
||||
* reachable from a real production call site exercised by the test suite.
|
||||
*
|
||||
* Errors from upstream packages (`AUDIO_IR.*`, `STORAGE_URI.*`,
|
||||
* `BUNDLE_URI.*`, `FALLBACK_URI.*`, `DRIVE_URI.*`, `AUDIO_DECODE.<FMT>.*`)
|
||||
* pass through verbatim — the loader does NOT re-wrap them.
|
||||
*/
|
||||
export const AUDIO_IR_LOADER_CODES = defineCodes("AUDIO_IR_LOADER", [
|
||||
"DECODER_UNAVAILABLE",
|
||||
"MANIFEST_MISMATCH",
|
||||
"ABORTED",
|
||||
"CACHE_OVERFLOW",
|
||||
"DECODE_FAILED",
|
||||
"RESOLVE_FAILED",
|
||||
] as const);
|
||||
|
||||
export type AudioIrLoaderErrorCode =
|
||||
(typeof AUDIO_IR_LOADER_CODES)[keyof typeof AUDIO_IR_LOADER_CODES];
|
||||
|
||||
/** Per-id failure entry carried by aggregate `DECODE_FAILED` errors. */
|
||||
export interface AggregatedLoadFailure {
|
||||
readonly id: string;
|
||||
readonly error: SuaveplanError;
|
||||
}
|
||||
|
||||
export interface AudioIrLoaderErrorOptions extends SuaveplanErrorOptions {
|
||||
/** The IR identifier the operation targeted. */
|
||||
id?: string | undefined;
|
||||
/** File extension extracted from the URI for DECODER_UNAVAILABLE. */
|
||||
extension?: string | undefined;
|
||||
/** Field on which the manifest diverged from the decoded audio. */
|
||||
field?: "channels" | "sampleRate" | undefined;
|
||||
/** Manifest-declared value for MANIFEST_MISMATCH. */
|
||||
manifestValue?: number | undefined;
|
||||
/** Decoded value for MANIFEST_MISMATCH. */
|
||||
decodedValue?: number | undefined;
|
||||
/** Configured cache budget for CACHE_OVERFLOW. */
|
||||
maxBytes?: number | undefined;
|
||||
/** Decoded byte total for CACHE_OVERFLOW. */
|
||||
decodedBytes?: number | undefined;
|
||||
/** Per-id error list for aggregate DECODE_FAILED throws. */
|
||||
errors?: readonly AggregatedLoadFailure[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for every `AUDIO_IR_LOADER.*` throw.
|
||||
*
|
||||
* Every dimension an operator filters on is a typed discriminant — never
|
||||
* parsed from message text. All fields are exposed as readonly properties.
|
||||
*/
|
||||
export class AudioIrLoaderError extends SuaveplanError<AudioIrLoaderErrorCode> {
|
||||
public readonly id: string | undefined;
|
||||
public readonly extension: string | undefined;
|
||||
public readonly field: "channels" | "sampleRate" | undefined;
|
||||
public readonly manifestValue: number | undefined;
|
||||
public readonly decodedValue: number | undefined;
|
||||
public readonly maxBytes: number | undefined;
|
||||
public readonly decodedBytes: number | undefined;
|
||||
public readonly errors: readonly AggregatedLoadFailure[] | undefined;
|
||||
|
||||
public constructor(
|
||||
code: AudioIrLoaderErrorCode,
|
||||
message: string,
|
||||
options?: AudioIrLoaderErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.id = options?.id;
|
||||
this.extension = options?.extension;
|
||||
this.field = options?.field;
|
||||
this.manifestValue = options?.manifestValue;
|
||||
this.decodedValue = options?.decodedValue;
|
||||
this.maxBytes = options?.maxBytes;
|
||||
this.decodedBytes = options?.decodedBytes;
|
||||
this.errors = options?.errors;
|
||||
this.name = "AudioIrLoaderError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
AggregatedLoadFailure,
|
||||
AudioIrLoaderErrorCode,
|
||||
AudioIrLoaderErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "./error-codes.js";
|
||||
Reference in New Issue
Block a user