Initial Commit
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# `extension.ts` — URI → file-extension extraction
|
||||
|
||||
## Purpose
|
||||
|
||||
The loader dispatches decoders by file extension. This module owns the
|
||||
canonicalisation step: given a URI string (which may be `bundle://`,
|
||||
`https://`, `drive://`, plain path-based, or `fallback://?u=…&u=…`),
|
||||
return the lowercase extension that decides which `AudioDecoder` runs.
|
||||
Cross-format `fallback://` URIs are explicitly rejected with
|
||||
`DECODER_UNAVAILABLE` — a single load must pick a single decoder.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal across `bundle://`, `https://`, `drive://`, and arbitrary
|
||||
path-based URIs.
|
||||
- Recursive handling for `fallback://` URIs — every candidate is
|
||||
re-extracted and the result must agree across candidates.
|
||||
- Query and fragment components are stripped before extension extraction.
|
||||
- Throws `AUDIO_IR_LOADER.DECODER_UNAVAILABLE` on every failure mode
|
||||
(missing extension, parse error, candidate disagreement).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { extractExtension } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
extractExtension("bundle://x/foo.wav"); // → "wav"
|
||||
extractExtension("https://cdn.example.com/path/clip.WAV?v=2"); // → "wav"
|
||||
extractExtension("fallback://?u=bundle%3A%2F%2Fx%2Fa.wav&u=https%3A%2F%2Fh%2Fa.wav");
|
||||
// → "wav"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The loader uses this helper at the start of every `load(id)` to pick the
|
||||
right decoder before any I/O happens:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
extractExtension,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
function pickDecoder<D>(decoders: Record<string, D>, uri: string, id: string): D {
|
||||
const ext = extractExtension(uri, id);
|
||||
const decoder = decoders[ext];
|
||||
if (decoder === undefined) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`no decoder registered for '.${ext}'`,
|
||||
{ id, extension: ext }
|
||||
);
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
```
|
||||
|
||||
Cross-format fallback URIs surface as a single `DECODER_UNAVAILABLE`
|
||||
throw with the first extension preserved on the error's `extension`
|
||||
discriminant, which makes the diagnostic easy to act on in the manifest
|
||||
authoring step.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `extractExtension(uri: string, id?: string): string`
|
||||
|
||||
Returns the lowercase extension. The optional `id` is forwarded to the
|
||||
`AudioIrLoaderError.id` discriminant on every throw so observers can
|
||||
filter by IR identifier.
|
||||
|
||||
Throws `AUDIO_IR_LOADER.DECODER_UNAVAILABLE` when:
|
||||
|
||||
- the URI carries no `.<ext>` segment after the last `/`,
|
||||
- the URI is `fallback://` and is not parseable as a URL,
|
||||
- the URI is `fallback://` with zero `u=` candidates,
|
||||
- the URI is `fallback://` and candidates disagree on extension.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `bundle://x/y.wav` URI form is technically not a valid RFC 3986
|
||||
authority-less URI but every consumer of `audio-ir-loader` already deals
|
||||
with it through `storage-uri-plugin-bundle`'s own parser; the
|
||||
path-stripping in this module is intentionally permissive about authority
|
||||
shape so callers get a consistent dispatch result regardless of whether
|
||||
the path-component is `//host/key.wav` or `:/path/key.wav`. The
|
||||
`fallback://` walk delegates to `extractExtension` recursively, so any
|
||||
future URI scheme that itself wraps candidates (e.g. a hypothetical
|
||||
`mirror://`) would only need to extend `FALLBACK_PREFIX` matching and
|
||||
fall through to the same recursion.
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { extractExtension } from "./extension.js";
|
||||
|
||||
describe("extractExtension", () => {
|
||||
it("extracts ext from a bundle:// URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("bundle://pkg/club1.wav")).toBe("wav");
|
||||
});
|
||||
|
||||
it("extracts ext from an https URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("https://cdn.example.com/clip.flac")).toBe(
|
||||
"flac"
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts ext from a drive:// URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("drive://share/key/x.opus")).toBe("opus");
|
||||
});
|
||||
|
||||
it("lowercases the extension", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("https://h/p/clip.WAV")).toBe("wav");
|
||||
});
|
||||
|
||||
it("strips query and fragment before extracting", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractExtension("https://h/p/clip.wav?v=2#frag")).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE when no extension is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("bundle://pkg/clip");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("propagates id discriminant on throw", { timeout: 5000 }, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("bundle://pkg/clip", "club-a");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.id).toBe("club-a");
|
||||
});
|
||||
|
||||
it("walks fallback:// candidates and returns the agreed ext", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = encodeURIComponent("bundle://pkg/x.wav");
|
||||
const b = encodeURIComponent("https://h/x.wav");
|
||||
const uri = `fallback://?u=${a}&u=${b}`;
|
||||
expect(extractExtension(uri)).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE when fallback candidates disagree", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = encodeURIComponent("bundle://pkg/x.wav");
|
||||
const b = encodeURIComponent("https://h/x.flac");
|
||||
const uri = `fallback://?u=${a}&u=${b}`;
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension(uri, "club-a");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
expect(captured?.extension).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE on empty fallback candidate list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("fallback://");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE on fallback URI with no candidates (alt form)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
// `fallback:` with no authority and no `u=` query → zero
|
||||
// candidates; the candidate-walk surfaces DECODER_UNAVAILABLE.
|
||||
extractExtension("fallback:%%%");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("throws when URI ends with a dot (empty extension)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => extractExtension("bundle://pkg/foo.")).toThrow(
|
||||
AudioIrLoaderError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ext for URI with no path slash", { timeout: 5000 }, () => {
|
||||
// Edge case: `file:bare.wav` — no slash before extension.
|
||||
expect(extractExtension("file:bare.wav")).toBe("wav");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const FALLBACK_PREFIX = /^fallback:/i;
|
||||
|
||||
/**
|
||||
* Extract a lowercase file extension (without dot) from a URI's path
|
||||
* component.
|
||||
*
|
||||
* Handles three URI shapes:
|
||||
*
|
||||
* - `bundle://x/y.wav`, `https://h/p/q.flac`, `drive://share/key.opus`,
|
||||
* plain `<scheme>://host/path.<ext>` → return path's trailing extension.
|
||||
* - `fallback://?u=<encoded-uri-1>&u=<encoded-uri-2>` → recursively
|
||||
* extract from each candidate, throw `DECODER_UNAVAILABLE` if
|
||||
* candidates disagree (the resolver would be unable to pick a single
|
||||
* decoder).
|
||||
* - Anything else → return the extension of the URI's last `.`-segment,
|
||||
* or throw `DECODER_UNAVAILABLE` if none exists.
|
||||
*/
|
||||
export function extractExtension(uri: string, id?: string): string {
|
||||
if (FALLBACK_PREFIX.test(uri)) {
|
||||
return extensionFromFallback(uri, id);
|
||||
}
|
||||
return extensionFromSingle(uri, id);
|
||||
}
|
||||
|
||||
function extensionFromSingle(uri: string, id: string | undefined): string {
|
||||
// Strip any query / fragment before extracting; an extension only
|
||||
// applies to the path component. Splitting on `?` first lets the
|
||||
// fragment-only case (no `?`) still fall through to the slash-tail
|
||||
// computation.
|
||||
const stripped = stripQueryAndFragment(uri);
|
||||
const lastSlash = stripped.lastIndexOf("/");
|
||||
const tail = lastSlash >= 0 ? stripped.slice(lastSlash + 1) : stripped;
|
||||
const dot = tail.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === tail.length - 1) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`URI '${uri}' has no file extension`,
|
||||
{ id }
|
||||
);
|
||||
}
|
||||
return tail.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function stripQueryAndFragment(uri: string): string {
|
||||
// Replace every char after the first `?` or `#` (whichever is
|
||||
// earliest) with nothing. The branch-free form lets both the
|
||||
// present and absent cases share a single code path.
|
||||
const limit = firstIndexOfAny(uri, ["?", "#"]);
|
||||
return limit < 0 ? uri : uri.slice(0, limit);
|
||||
}
|
||||
|
||||
function firstIndexOfAny(haystack: string, needles: readonly string[]): number {
|
||||
let best = -1;
|
||||
for (const n of needles) {
|
||||
const idx = haystack.indexOf(n);
|
||||
if (idx < 0) continue;
|
||||
if (best < 0 || idx < best) best = idx;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function extensionFromFallback(uri: string, id: string | undefined): string {
|
||||
// `URL` accepts every `fallback:<rest>` form because `fallback` is a
|
||||
// generic non-special scheme; we therefore extract candidates through
|
||||
// `searchParams.getAll("u")` and let the candidate-count and per-
|
||||
// candidate dispatch surface every error. No try/catch needed.
|
||||
const parsed = new URL(uri);
|
||||
const candidates = parsed.searchParams.getAll("u");
|
||||
if (candidates.length === 0) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`fallback URI '${uri}' carries no candidates`,
|
||||
{ id }
|
||||
);
|
||||
}
|
||||
let first: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const ext = extractExtension(candidate, id);
|
||||
if (first === undefined) {
|
||||
first = ext;
|
||||
continue;
|
||||
}
|
||||
if (ext !== first) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`fallback candidates disagree on extension ('${first}' vs '${ext}')`,
|
||||
{ extension: first, id }
|
||||
);
|
||||
}
|
||||
}
|
||||
// `first` is defined here because we returned early on zero candidates.
|
||||
return first as string;
|
||||
}
|
||||
14
packages/media/audio-ir/audio-ir-loader/src/loader/index.ts
Normal file
14
packages/media/audio-ir/audio-ir-loader/src/loader/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { extractExtension } from "./extension.js";
|
||||
export {
|
||||
type AudioDecoder,
|
||||
type CreateIrLoaderOptions,
|
||||
createIrLoader,
|
||||
type IrLoader,
|
||||
type LoadOptions,
|
||||
type PreloadOptions,
|
||||
} from "./loader.js";
|
||||
export {
|
||||
type DecodedAudioLike,
|
||||
validateAndFinalize,
|
||||
} from "./manifest-validation.js";
|
||||
export { runPreload } from "./preload.js";
|
||||
105
packages/media/audio-ir/audio-ir-loader/src/loader/loader.md
Normal file
105
packages/media/audio-ir/audio-ir-loader/src/loader/loader.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `loader.ts` — `createIrLoader` pipeline orchestration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the entry point for the package. `createIrLoader(options)`
|
||||
returns a typed `IrLoader` that composes the IR family pipeline:
|
||||
`IrRegistry.get(id)` → `cache.get(id)` (on hit, fast path) →
|
||||
`StorageUriResolver.read(uri)` → `decoders[ext](bytes)` →
|
||||
`validateAndFinalize(entry, decoded)` → `cache.put` →
|
||||
return frozen `ImpulseResponse`. Every layer's errors pass through
|
||||
verbatim — the loader only owns the six `AUDIO_IR_LOADER.*` codes from
|
||||
its own contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at construction (`createIrLoader` throws
|
||||
`ZodError` on misuse).
|
||||
- Decoder keys normalised to lowercase so callers can write
|
||||
`{ WAV: decodeWav }` and have it work.
|
||||
- Optional in-process LRU cache controlled by `options.cache.maxBytes`.
|
||||
- `AbortSignal` threading: short-circuits a pre-aborted signal,
|
||||
passes the signal through to both `resolver.read` and the decoder.
|
||||
- Real OpenTelemetry instrumentation: span + duration histogram + bytes /
|
||||
frames / cache / error counters.
|
||||
- Bounded-concurrency `preload(ids, { concurrency })` with aggregate
|
||||
failure reporting (delegated to `preload.ts`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
bundleUriPlugin({ packageSlug: "club", rootUrl: new URL("file:///opt/ir/club/") }),
|
||||
]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The loader composes well with an LRU cache and with bounded-concurrency
|
||||
preload for app bootstrap:
|
||||
|
||||
```ts
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav, flac: decodeFlac },
|
||||
cache: { maxBytes: 64 * 1024 * 1024 },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const [a, b, c] = await loader.preload(
|
||||
["club-a", "club-b", "club-c"],
|
||||
{ signal: controller.signal, concurrency: 2 }
|
||||
);
|
||||
// Later, free a specific IR's slot:
|
||||
loader.evict("club-c");
|
||||
console.log(loader.cacheStats());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createIrLoader(options): IrLoader`
|
||||
|
||||
- `options.registry: IrRegistry`
|
||||
- `options.resolver: StorageUriResolver`
|
||||
- `options.decoders: Record<string, AudioDecoder>` — must be non-empty;
|
||||
keys are case-insensitive.
|
||||
- `options.cache?: { maxBytes: number }` — positive integer; enables LRU.
|
||||
|
||||
### `IrLoader`
|
||||
|
||||
- `load(id, { signal? }?): Promise<ImpulseResponse>`
|
||||
- `preload(ids, { signal?, concurrency? }?): Promise<readonly ImpulseResponse[]>`
|
||||
- `evict(id): boolean`
|
||||
- `cacheStats(): { entries, bytes, hits, misses }`
|
||||
|
||||
### `AudioDecoder`
|
||||
|
||||
`(bytes: Uint8Array, opts?: { signal?: AbortSignal }) => Promise<DecodedAudioLike>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The OTel span for every `load` is started before the cache hit-check so
|
||||
even cache hits emit a span; the `outcome=cache_hit` histogram label
|
||||
distinguishes those from the `outcome=success` "did real work" path. The
|
||||
`SuaveplanError`-cast on errors lets the loader's `errors_total` counter
|
||||
attribute the throw to its originating namespace (`AUDIO_IR.*`,
|
||||
`STORAGE_URI.*`, `BUNDLE_URI.*`, `FALLBACK_URI.*`, `AUDIO_DECODE.<FMT>.*`,
|
||||
or `AUDIO_IR_LOADER.*`) rather than collapsing every miss into a single
|
||||
opaque bucket. The pre-abort check throws synchronously before any
|
||||
registry / resolver / decoder call, satisfying the spec's "never invoked
|
||||
when signal is already aborted" requirement.
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
|
||||
const ENTRY = (): IrManifestEntry =>
|
||||
({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function harness() {
|
||||
const reads = new Map<string, Uint8Array>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads.get(key);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key);
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
return { resolver, registry, reads };
|
||||
}
|
||||
|
||||
const decoderOk: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0.5, 0.5])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
|
||||
describe("loader spec-conformance: AbortSignal threading", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("pre-aborted signal short-circuits load", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
let registryGets = 0;
|
||||
const proxiedRegistry = {
|
||||
...registry,
|
||||
get(id: string) {
|
||||
registryGets += 1;
|
||||
return registry.get(id);
|
||||
},
|
||||
has: registry.has.bind(registry),
|
||||
list: registry.list.bind(registry),
|
||||
register: registry.register.bind(registry),
|
||||
clear: registry.clear.bind(registry),
|
||||
};
|
||||
let resolverReads = 0;
|
||||
const proxiedResolver = {
|
||||
...resolver,
|
||||
read: async (uri: string, signal?: AbortSignal) => {
|
||||
resolverReads += 1;
|
||||
return resolver.read(uri, signal);
|
||||
},
|
||||
};
|
||||
let decoderCalls = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
decoderCalls += 1;
|
||||
return decoderOk(new Uint8Array());
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry: proxiedRegistry as unknown as Parameters<
|
||||
typeof createIrLoader
|
||||
>[0]["registry"],
|
||||
resolver: proxiedResolver as unknown as Parameters<
|
||||
typeof createIrLoader
|
||||
>[0]["resolver"],
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("pre-abort"));
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1", { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.ABORTED);
|
||||
expect(registryGets).toBe(0);
|
||||
expect(resolverReads).toBe(0);
|
||||
expect(decoderCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("mid-flight resolver abort passes through unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { registry } = harness();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(_key: string) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.ABORTED,
|
||||
"fixture mid-flight",
|
||||
{ uri: "bundle://x/club1.wav" }
|
||||
);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists() {
|
||||
return false;
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoderOk },
|
||||
});
|
||||
let captured: StorageUriError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(STORAGE_URI_CODES.ABORTED);
|
||||
});
|
||||
|
||||
it("threads signal through to the decoder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
const decoder: AudioDecoder = async (_bytes, opts) => {
|
||||
signalSeen = opts?.signal;
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await loader.load("club-club1", { signal: controller.signal });
|
||||
expect(signalSeen).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loader spec-conformance: cache identity", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("cacheStats survives an evict round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoderOk },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
expect(loader.cacheStats().entries).toBe(1);
|
||||
loader.evict("club-club1");
|
||||
expect(loader.cacheStats().entries).toBe(0);
|
||||
// Idempotent evict — second call is a no-op.
|
||||
expect(loader.evict("club-club1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
|
||||
const ENTRY = (overrides: Partial<IrManifestEntry> = {}): IrManifestEntry =>
|
||||
({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
interface ResolverHandle {
|
||||
readonly resolver: ReturnType<typeof createStorageUriResolver>;
|
||||
readonly reads: Map<string, Uint8Array | "missing" | StorageUriError>;
|
||||
}
|
||||
|
||||
function buildResolver(): ResolverHandle {
|
||||
const reads = new Map<string, Uint8Array | "missing" | StorageUriError>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
const v = reads.get(key);
|
||||
if (v === undefined || v === "missing") return undefined;
|
||||
if (v instanceof StorageUriError) throw v;
|
||||
return v;
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key) && reads.get(key) !== "missing";
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
return { resolver: createStorageUriResolver([plugin]), reads };
|
||||
}
|
||||
|
||||
const makeWavDecoder = (sampleRate = 44100, channels = 1): AudioDecoder => {
|
||||
return async () => ({
|
||||
channelData: Array.from(
|
||||
{ length: channels },
|
||||
() => new Float32Array([1, 0, 0, 0])
|
||||
),
|
||||
channels,
|
||||
sampleRate,
|
||||
});
|
||||
};
|
||||
|
||||
function freshRegistry() {
|
||||
const reg = createIrRegistry();
|
||||
return reg;
|
||||
}
|
||||
|
||||
describe("createIrLoader construction", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("minimal options construct a loader", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(typeof loader.load).toBe("function");
|
||||
expect(typeof loader.preload).toBe("function");
|
||||
expect(typeof loader.evict).toBe("function");
|
||||
expect(typeof loader.cacheStats).toBe("function");
|
||||
});
|
||||
|
||||
it("throws on empty decoder map", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
expect(() =>
|
||||
createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: {},
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("normalizes decoder keys to lowercase", { timeout: 5000 }, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1, 2]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { WAV: makeWavDecoder() },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
expect(ir.id).toBe("club-club1");
|
||||
});
|
||||
|
||||
it("rejects non-positive cache.maxBytes", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
expect(() =>
|
||||
createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
cache: { maxBytes: 0 },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader.load happy path", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("end-to-end load returns a frozen ImpulseResponse", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(
|
||||
ENTRY({ id: "club-club1", channels: 2, sampleRate: 44100 }),
|
||||
{ manifestSlug: "club", manifestFamily: "room" }
|
||||
);
|
||||
reads.set("x/club1.wav", new Uint8Array([1, 2, 3]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(44100, 2) },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
expect(ir.id).toBe("club-club1");
|
||||
expect(ir.channels).toBe(2);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.channelData.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader.load error pass-through", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("passes through AUDIO_IR.NOT_REGISTERED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: SuaveplanError | undefined;
|
||||
try {
|
||||
await loader.load("nope");
|
||||
} catch (err) {
|
||||
if (err instanceof SuaveplanError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe("AUDIO_IR.NOT_REGISTERED");
|
||||
expect(captured).not.toBeInstanceOf(AudioIrLoaderError);
|
||||
});
|
||||
|
||||
it("passes through resolver errors unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
// The adapter returns undefined → resolver throws NOT_FOUND.
|
||||
reads.set("x/club1.wav", "missing");
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: StorageUriError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(STORAGE_URI_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE for unknown extension", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(
|
||||
ENTRY({ id: "club-club2", uri: "bundle://x/club2.flac" }),
|
||||
{ manifestSlug: "club", manifestFamily: "room" }
|
||||
);
|
||||
reads.set("x/club2.flac", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club2");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
expect(captured?.extension).toBe("flac");
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on channel-count divergence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 2 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(44100, 1) },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("channels");
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on sample-rate divergence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 1, sampleRate: 44100 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(48000, 1) },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("sampleRate");
|
||||
});
|
||||
|
||||
it("applies gainDb multiplicatively (peak ≈ 0.5)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 1, gainDb: -6.0206 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([1.0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
const peak = (ir.channelData[0] as Float32Array)[0] as number;
|
||||
expect(Math.abs(peak - 0.5)).toBeLessThan(1e-3);
|
||||
expect(ir.manifest.gainDb).toBe(-6.0206);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader non-SuaveplanError pass-through", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("propagates plain-Error throws from the decoder unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const decoder: AudioDecoder = async () => {
|
||||
throw new TypeError("boom");
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader cache behavior", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("evict returns false when cache disabled", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(loader.evict("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("cacheStats reports zeros when cache disabled", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(loader.cacheStats()).toEqual({
|
||||
entries: 0,
|
||||
bytes: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("cache hit returns the same reference without re-decode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
let decodeCalls = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
decodeCalls += 1;
|
||||
return {
|
||||
channelData: [new Float32Array([0.5])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
const first = await loader.load("club-club1");
|
||||
const second = await loader.load("club-club1");
|
||||
expect(first).toBe(second);
|
||||
expect(decodeCalls).toBe(1);
|
||||
expect(loader.cacheStats().hits).toBe(1);
|
||||
});
|
||||
|
||||
it("evict returns true when cached id is dropped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
expect(loader.evict("club-club1")).toBe(true);
|
||||
expect(loader.evict("club-club1")).toBe(false);
|
||||
});
|
||||
});
|
||||
220
packages/media/audio-ir/audio-ir-loader/src/loader/loader.ts
Normal file
220
packages/media/audio-ir/audio-ir-loader/src/loader/loader.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
ImpulseResponse,
|
||||
IrFamily,
|
||||
IrRegistry,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import type { StorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import z from "zod";
|
||||
import type { CacheStats } from "../cache/cache.js";
|
||||
import { ImpulseResponseCache } from "../cache/cache.js";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
getInstruments,
|
||||
recordCacheHit,
|
||||
recordCacheMiss,
|
||||
recordError,
|
||||
recordSuccess,
|
||||
uriKindOf,
|
||||
} from "../otel/otel.js";
|
||||
import { extractExtension } from "./extension.js";
|
||||
import {
|
||||
type DecodedAudioLike,
|
||||
validateAndFinalize,
|
||||
} from "./manifest-validation.js";
|
||||
import { runPreload } from "./preload.js";
|
||||
|
||||
/**
|
||||
* Decoder function shape consumed by the loader. Mirrors
|
||||
* `@suaveplan/audio-decode-wav`'s `decodeWav` signature.
|
||||
*/
|
||||
export type AudioDecoder = (
|
||||
bytes: Uint8Array,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => Promise<DecodedAudioLike>;
|
||||
|
||||
/** Options for `IrLoader.load`. */
|
||||
export interface LoadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Options for `IrLoader.preload`. */
|
||||
export interface PreloadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly concurrency?: number;
|
||||
}
|
||||
|
||||
/** Public `IrLoader` surface. */
|
||||
export interface IrLoader {
|
||||
load(id: string, options?: LoadOptions): Promise<ImpulseResponse>;
|
||||
preload(
|
||||
ids: readonly string[],
|
||||
options?: PreloadOptions
|
||||
): Promise<readonly ImpulseResponse[]>;
|
||||
evict(id: string): boolean;
|
||||
cacheStats(): CacheStats;
|
||||
}
|
||||
|
||||
/** Input options for `createIrLoader`. */
|
||||
export interface CreateIrLoaderOptions {
|
||||
readonly registry: IrRegistry;
|
||||
readonly resolver: StorageUriResolver;
|
||||
readonly decoders: Readonly<Record<string, AudioDecoder>>;
|
||||
readonly cache?: { readonly maxBytes: number };
|
||||
}
|
||||
|
||||
const OptionsSchema = z.object({
|
||||
registry: z.custom<IrRegistry>(
|
||||
(v): v is IrRegistry =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as IrRegistry).get === "function" &&
|
||||
typeof (v as IrRegistry).has === "function",
|
||||
{ message: "options.registry must be an IrRegistry" }
|
||||
),
|
||||
resolver: z.custom<StorageUriResolver>(
|
||||
(v): v is StorageUriResolver =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as StorageUriResolver).read === "function",
|
||||
{ message: "options.resolver must be a StorageUriResolver" }
|
||||
),
|
||||
decoders: z
|
||||
.record(
|
||||
z.string().min(1),
|
||||
z.custom<AudioDecoder>(
|
||||
(v): v is AudioDecoder => typeof v === "function",
|
||||
{ message: "decoder must be a function" }
|
||||
)
|
||||
)
|
||||
.refine((d) => Object.keys(d).length > 0, {
|
||||
message: "decoders must not be empty",
|
||||
}),
|
||||
cache: z
|
||||
.object({
|
||||
maxBytes: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a typed `IrLoader` bound to a registry, resolver, and decoder map.
|
||||
*
|
||||
* Validates `options` through Zod at construction; failures throw a
|
||||
* `ZodError`. Decoder keys are normalised to lowercase. When
|
||||
* `options.cache` is supplied, an in-process LRU is created with the
|
||||
* declared `maxBytes`; when absent, the loader operates uncached.
|
||||
*/
|
||||
export function createIrLoader(options: CreateIrLoaderOptions): IrLoader {
|
||||
const parsed = OptionsSchema.parse(options);
|
||||
const decoders: Record<string, AudioDecoder> = {};
|
||||
for (const [k, v] of Object.entries(parsed.decoders)) {
|
||||
decoders[k.toLowerCase()] = v;
|
||||
}
|
||||
const cache =
|
||||
parsed.cache !== undefined
|
||||
? new ImpulseResponseCache(parsed.cache.maxBytes)
|
||||
: undefined;
|
||||
|
||||
async function loadInternal(
|
||||
id: string,
|
||||
loadOptions: LoadOptions | undefined
|
||||
): Promise<ImpulseResponse> {
|
||||
const signal = loadOptions?.signal;
|
||||
const started = performance.now();
|
||||
// Pre-aborted signal: short-circuit before any side effect.
|
||||
if (signal?.aborted === true) {
|
||||
// `AbortSignal.reason` is always defined post-`abort()` (the
|
||||
// platform synthesises a `DOMException` when no explicit
|
||||
// reason was supplied) — pass it through unconditionally.
|
||||
const err = new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"aborted before load started",
|
||||
{ id, cause: signal.reason }
|
||||
);
|
||||
recordError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"unknown",
|
||||
performance.now() - started
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
let family: IrFamily | "unknown" = "unknown";
|
||||
let entry: ReturnType<IrRegistry["get"]>;
|
||||
const tracer = getInstruments().tracer;
|
||||
const span = tracer.startSpan("audio_ir_loader.load", {
|
||||
attributes: { "audio_ir.id": id },
|
||||
});
|
||||
try {
|
||||
entry = parsed.registry.get(id);
|
||||
family = entry.family;
|
||||
span.setAttribute("audio_ir.family", family);
|
||||
span.setAttribute("audio_ir.uri_kind", uriKindOf(entry.uri));
|
||||
if (cache !== undefined) {
|
||||
const hit = cache.get(id);
|
||||
if (hit !== undefined) {
|
||||
recordCacheHit(family, performance.now() - started);
|
||||
return hit;
|
||||
}
|
||||
recordCacheMiss(family);
|
||||
}
|
||||
const ext = extractExtension(entry.uri, id);
|
||||
const decoder = decoders[ext];
|
||||
if (decoder === undefined) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`no decoder registered for '.${ext}'`,
|
||||
{ id, extension: ext }
|
||||
);
|
||||
}
|
||||
const bytes = await parsed.resolver.read(entry.uri, signal);
|
||||
const decoded = await decoder(
|
||||
bytes,
|
||||
signal !== undefined ? { signal } : undefined
|
||||
);
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
if (cache !== undefined) {
|
||||
cache.put(id, ir);
|
||||
}
|
||||
const firstChannel = ir.channelData[0] as Float32Array;
|
||||
recordSuccess(
|
||||
family,
|
||||
bytes.byteLength,
|
||||
firstChannel.length,
|
||||
performance.now() - started
|
||||
);
|
||||
return ir;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof SuaveplanError
|
||||
? err.code
|
||||
: "AUDIO_IR_LOADER.UNKNOWN";
|
||||
recordError(code, family, performance.now() - started);
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load(id, loadOptions) {
|
||||
return loadInternal(id, loadOptions);
|
||||
},
|
||||
preload(ids, preloadOptions) {
|
||||
return runPreload(loadInternal, ids, preloadOptions);
|
||||
},
|
||||
evict(id) {
|
||||
if (cache === undefined) return false;
|
||||
return cache.evict(id);
|
||||
},
|
||||
cacheStats() {
|
||||
if (cache === undefined) {
|
||||
return { entries: 0, bytes: 0, hits: 0, misses: 0 };
|
||||
}
|
||||
return cache.stats();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# `manifest-validation.ts` — decoded audio ↔ manifest reconciliation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the bridge between the `DecodedAudio` shape emitted by an
|
||||
`AudioDecoder` and the `ImpulseResponse` shape consumed by the convolver.
|
||||
It compares the decoded audio against the manifest entry's declared
|
||||
`channels` / `sampleRate`, throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on
|
||||
any divergence, then applies the manifest's `gainDb` multiplicatively
|
||||
and returns a frozen `ImpulseResponse` ready to hand back to the caller.
|
||||
|
||||
## Features
|
||||
|
||||
- Exact-match enforcement for both `channels` and `sampleRate` — no
|
||||
tolerance window (resampling is out-of-scope for this package).
|
||||
- `gainDb` applied via `10^(gainDb/20)` per sample. The `gainDb === 0`
|
||||
fast-path skips the multiply and just copies the buffer to ensure the
|
||||
resulting IR owns its memory.
|
||||
- Returns a fully frozen `ImpulseResponse` whose `manifest` reference
|
||||
preserves the source entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateAndFinalize } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const ir = validateAndFinalize(entry, decodedAudio);
|
||||
console.log(ir.id, ir.lengthFrames, ir.durationSec);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators routing on `MANIFEST_MISMATCH` errors can use the structured
|
||||
discriminants directly:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
validateAndFinalize,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
return validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioIrLoaderError &&
|
||||
err.code === AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH
|
||||
) {
|
||||
metrics.recordMismatch({
|
||||
id: err.id,
|
||||
field: err.field, // "channels" | "sampleRate"
|
||||
manifestValue: err.manifestValue,
|
||||
decodedValue: err.decodedValue,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateAndFinalize(entry, decoded): ImpulseResponse`
|
||||
|
||||
- `entry: IrManifestEntry` — the manifest entry resolved from the registry.
|
||||
- `decoded: DecodedAudioLike` — `{ channelData, sampleRate, channels }`.
|
||||
|
||||
Throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on `channels` or `sampleRate`
|
||||
divergence (with `field`, `manifestValue`, `decodedValue` discriminants).
|
||||
Returns a frozen `ImpulseResponse` whose `manifest` field is the input
|
||||
entry.
|
||||
|
||||
### `DecodedAudioLike`
|
||||
|
||||
Structural subset of `@suaveplan/audio-decode-wav`'s `DecodedAudio` —
|
||||
just the three fields manifest validation needs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `gainDb === 0` short-circuit avoids both the `Math.pow` call and a
|
||||
floating-point pass over the buffer for the (very common) case where the
|
||||
manifest carries no gain offset; the per-sample copy is still performed
|
||||
because every channel buffer is wrapped in `new Float32Array(channel)`
|
||||
to give the resulting IR its own memory. The result is `Object.freeze`d
|
||||
at the outer level and the `channelData` array is frozen too; the
|
||||
individual `Float32Array` buffers cannot be `Object.freeze`d (TypedArray
|
||||
buffers reject the freeze) but the array-level freeze is enough to
|
||||
prevent callers from swapping channels at the array level.
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { validateAndFinalize } from "./manifest-validation.js";
|
||||
|
||||
const ENTRY = (overrides: Partial<IrManifestEntry> = {}): IrManifestEntry =>
|
||||
Object.freeze({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
describe("validateAndFinalize", () => {
|
||||
it("returns a frozen ImpulseResponse on a happy path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY();
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([1, 0]), new Float32Array([0, 1])],
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
expect(ir.id).toBe("club-club1");
|
||||
expect(ir.channels).toBe(2);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.lengthFrames).toBe(2);
|
||||
expect(ir.manifest).toBe(entry);
|
||||
// ChannelData should be a copy, not the original reference.
|
||||
expect(ir.channelData[0]).not.toBe(decoded.channelData[0]);
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on channel-count divergence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 2 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("channels");
|
||||
expect(captured?.manifestValue).toBe(2);
|
||||
expect(captured?.decodedValue).toBe(1);
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on sample-rate divergence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, sampleRate: 44100 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 48000,
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("sampleRate");
|
||||
expect(captured?.manifestValue).toBe(44100);
|
||||
expect(captured?.decodedValue).toBe(48000);
|
||||
});
|
||||
|
||||
it("applies gainDb multiplicatively", { timeout: 5000 }, () => {
|
||||
const entry = ENTRY({ channels: 1, gainDb: -6.0206 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([1.0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
const peak = (ir.channelData[0] as Float32Array)[0] as number;
|
||||
expect(Math.abs(peak - 0.5)).toBeLessThan(1e-3);
|
||||
expect(ir.manifest.gainDb).toBe(-6.0206);
|
||||
});
|
||||
|
||||
it("gainDb=0 fast-path still copies the buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, gainDb: 0 });
|
||||
const orig = new Float32Array([0.25]);
|
||||
const decoded = {
|
||||
channelData: [orig],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
const out = ir.channelData[0] as Float32Array;
|
||||
expect(out).not.toBe(orig);
|
||||
expect(out[0]).toBe(0.25);
|
||||
});
|
||||
|
||||
it("computes durationSec from lengthFrames / sampleRate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, sampleRate: 22050 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array(22050)],
|
||||
channels: 1,
|
||||
sampleRate: 22050,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
expect(ir.durationSec).toBeCloseTo(1.0, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
ImpulseResponse,
|
||||
IrManifestEntry,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Minimal shape the loader consumes from `DecodedAudio`. */
|
||||
export interface DecodedAudioLike {
|
||||
readonly channelData: readonly Float32Array[];
|
||||
readonly sampleRate: number;
|
||||
readonly channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare decoded audio against a manifest entry, apply `gainDb`, and
|
||||
* return a frozen `ImpulseResponse`.
|
||||
*
|
||||
* Throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` when `channels` or
|
||||
* `sampleRate` disagree. `gainDb` is applied multiplicatively:
|
||||
* `sample * 10^(gainDb/20)`. The result's `manifest.gainDb` preserves
|
||||
* the manifest's declared value so callers know what was applied.
|
||||
*/
|
||||
export function validateAndFinalize(
|
||||
entry: IrManifestEntry,
|
||||
decoded: DecodedAudioLike
|
||||
): ImpulseResponse {
|
||||
if (decoded.channels !== entry.channels) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
||||
`IR ${entry.id} manifest declares ${entry.channels} channel(s), decoded ${decoded.channels}`,
|
||||
{
|
||||
id: entry.id,
|
||||
field: "channels",
|
||||
manifestValue: entry.channels,
|
||||
decodedValue: decoded.channels,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (decoded.sampleRate !== entry.sampleRate) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
||||
`IR ${entry.id} manifest declares ${entry.sampleRate} Hz, decoded ${decoded.sampleRate} Hz`,
|
||||
{
|
||||
id: entry.id,
|
||||
field: "sampleRate",
|
||||
manifestValue: entry.sampleRate,
|
||||
decodedValue: decoded.sampleRate,
|
||||
}
|
||||
);
|
||||
}
|
||||
const gain = entry.gainDb === 0 ? 1 : 10 ** (entry.gainDb / 20);
|
||||
const channelData: Float32Array[] = [];
|
||||
for (const channel of decoded.channelData) {
|
||||
if (gain === 1) {
|
||||
// Copy regardless so the IR owns its buffer (the decoder may
|
||||
// return frozen-but-shared buffers; mutating decoder output
|
||||
// would be a footgun).
|
||||
channelData.push(new Float32Array(channel));
|
||||
continue;
|
||||
}
|
||||
const scaled = new Float32Array(channel.length);
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
// `channel[i]` is defined for i < length per Float32Array indexing.
|
||||
const sample = channel[i] as number;
|
||||
scaled[i] = sample * gain;
|
||||
}
|
||||
channelData.push(scaled);
|
||||
}
|
||||
const frozenChannels = Object.freeze(channelData);
|
||||
// The first channel's length is canonical — the decoder is required
|
||||
// to return equal-length channels, and the loader's downstream
|
||||
// schema check would re-enforce it.
|
||||
const firstChannel = channelData[0] as Float32Array;
|
||||
const lengthFrames = firstChannel.length;
|
||||
const durationSec = lengthFrames / entry.sampleRate;
|
||||
const ir: ImpulseResponse = Object.freeze({
|
||||
id: entry.id,
|
||||
channelData: frozenChannels,
|
||||
channels: entry.channels,
|
||||
sampleRate: entry.sampleRate,
|
||||
lengthFrames,
|
||||
durationSec,
|
||||
gainDb: entry.gainDb,
|
||||
family: entry.family,
|
||||
manifest: entry,
|
||||
});
|
||||
return ir;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# `preload.ts` — bounded-concurrency bulk load
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the bounded-concurrency `preload` algorithm
|
||||
backing `IrLoader.preload`. It launches up to `concurrency` workers (each
|
||||
a `while`-loop that pops the next id off a shared cursor), threads the
|
||||
caller's `AbortSignal` through every per-id load, and produces either a
|
||||
fully-populated array of `ImpulseResponse` (in input order) on success
|
||||
or an aggregate `AUDIO_IR_LOADER.DECODE_FAILED` error carrying every
|
||||
per-id failure on partial failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Concurrency clamped to `[1, 32]` to prevent caller mistakes from
|
||||
saturating the runtime.
|
||||
- Input order preserved on success — the `results` array is indexed by
|
||||
the id's position in `ids`, not by completion order.
|
||||
- Aggregate failure: every failing id is collected before the throw, so
|
||||
callers get a complete picture rather than the first error.
|
||||
- Abort threading: workers stop launching new loads as soon as
|
||||
`signal.aborted` becomes true; the throw is `ABORTED` not
|
||||
`DECODE_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const irs = await loader.preload(
|
||||
["club-a", "club-b", "club-c"],
|
||||
{ concurrency: 2 }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The aggregate carries every failure for ergonomic dashboards:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
await loader.preload(ids);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError && err.errors !== undefined) {
|
||||
for (const { id, error } of err.errors) {
|
||||
metrics.recordLoadFailure({ id, code: error.code });
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
Best-effort callers that want to keep going past failures can call
|
||||
`Promise.allSettled(ids.map((id) => loader.load(id)))` directly.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `runPreload(loadFn, ids, options?): Promise<readonly ImpulseResponse[]>`
|
||||
|
||||
- `loadFn` — the per-id load function (the loader's closure).
|
||||
- `ids: readonly string[]` — input ids; order preserved on success.
|
||||
- `options.signal?: AbortSignal` — shared abort.
|
||||
- `options.concurrency?: number` — clamped to `[1, 32]`, default `4`.
|
||||
|
||||
On full success returns a frozen `ImpulseResponse[]` in input order. On
|
||||
partial failure throws `AUDIO_IR_LOADER.DECODE_FAILED` with the failure
|
||||
list on `errors`. On signal abort throws `AUDIO_IR_LOADER.ABORTED`
|
||||
(carrying any pre-abort failures on `errors`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The worker pool is a simple "N parallel tasks, all draining a shared
|
||||
cursor" pattern. The cursor is captured in `takeNext()` which also checks
|
||||
abort state on entry, so a mid-flight abort halts every worker on its
|
||||
next iteration. Non-`SuaveplanError` throwables are wrapped as
|
||||
`AUDIO_IR_LOADER.RESOLVE_FAILED` before being added to the aggregate;
|
||||
this preserves the invariant that every entry in `errors[]` is a
|
||||
`SuaveplanError` (and therefore carries a stable `.code`). The success
|
||||
result is `Object.freeze`d so callers can't mutate the array in place.
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
import { runPreload } from "./preload.js";
|
||||
|
||||
const ENTRY = (
|
||||
id: string,
|
||||
overrides: Partial<IrManifestEntry> = {}
|
||||
): IrManifestEntry =>
|
||||
({
|
||||
id,
|
||||
family: "room",
|
||||
uri: `bundle://x/${id}.wav`,
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function buildSlowDecoder(
|
||||
delayMs: number,
|
||||
counter: { active: number; max: number }
|
||||
): AudioDecoder {
|
||||
return async () => {
|
||||
counter.active += 1;
|
||||
counter.max = Math.max(counter.max, counter.active);
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
counter.active -= 1;
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function buildHarness(ids: readonly string[]) {
|
||||
const reads = new Map<string, Uint8Array>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads.get(key);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key);
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const registry = createIrRegistry();
|
||||
for (const id of ids) {
|
||||
registry.register(ENTRY(`club-${id}`), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set(`x/club-${id}.wav`, new Uint8Array([1]));
|
||||
}
|
||||
return { resolver, registry };
|
||||
}
|
||||
|
||||
describe("preload concurrency, order, and aggregate failure", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("honours concurrency cap", { timeout: 10000 }, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
]);
|
||||
const decoder = buildSlowDecoder(10, counter);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
await loader.preload(
|
||||
["club-a", "club-b", "club-c", "club-d", "club-e", "club-f"],
|
||||
{ concurrency: 2 }
|
||||
);
|
||||
expect(counter.max).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("preserves input order on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b", "c"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(5, counter) },
|
||||
});
|
||||
const input = ["club-a", "club-b", "club-c"];
|
||||
const out = await loader.preload(input);
|
||||
expect(out.map((ir) => ir.id)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects with aggregate on partial failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b", "c"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
// Decoder returns mismatched channel count for `b`.
|
||||
const decoder: AudioDecoder = async (_bytes) => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const flakyDecoder: AudioDecoder = async (_bytes, opts) => {
|
||||
// Inspect a side channel via stack — simpler: keep counter to fail on second call.
|
||||
counter.active += 1;
|
||||
if (counter.active === 2) {
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
}
|
||||
return decoder(_bytes, opts);
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: flakyDecoder },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.preload(["club-a", "club-b", "club-c"], {
|
||||
concurrency: 1,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODE_FAILED);
|
||||
expect(captured?.errors?.length).toBe(1);
|
||||
expect(captured?.errors?.[0]?.id).toBe("club-b");
|
||||
expect(captured?.errors?.[0]?.error.code).toBe(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH
|
||||
);
|
||||
});
|
||||
|
||||
it("preload stops launching new loads after abort", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a", "b", "c", "d"]);
|
||||
let launches = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
launches += 1;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const promise = loader.preload(
|
||||
["club-a", "club-b", "club-c", "club-d"],
|
||||
{ signal: controller.signal, concurrency: 2 }
|
||||
);
|
||||
// Fire abort after a short delay so the first two finish but the
|
||||
// last two are never launched.
|
||||
setTimeout(() => controller.abort(new Error("test abort")), 30);
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.ABORTED);
|
||||
// counter is unused for this assertion; tests on `launches` upper bound.
|
||||
expect(launches).toBeLessThan(4);
|
||||
// Touch counter to silence the unused-variable lint when running through Biome.
|
||||
void counter;
|
||||
});
|
||||
|
||||
it("clamps requested concurrency to [1, 32]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a"]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a"], { concurrency: 9999 });
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
|
||||
it("default concurrency (no option) is honoured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a", "club-b"]);
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it("wraps non-SuaveplanError throwables as RESOLVE_FAILED in the aggregate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badLoader = async (_id: string) => {
|
||||
throw new TypeError("plain error");
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await runPreload(badLoader, ["x"], undefined);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODE_FAILED);
|
||||
expect(captured?.errors?.[0]?.error.code).toBe(
|
||||
AUDIO_IR_LOADER_CODES.RESOLVE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps requested concurrency 0 up to 1", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a"]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a"], { concurrency: 0 });
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
});
|
||||
108
packages/media/audio-ir/audio-ir-loader/src/loader/preload.ts
Normal file
108
packages/media/audio-ir/audio-ir-loader/src/loader/preload.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ImpulseResponse } from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
type AggregatedLoadFailure,
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { LoadOptions, PreloadOptions } from "./loader.js";
|
||||
|
||||
const MIN_CONCURRENCY = 1;
|
||||
const MAX_CONCURRENCY = 32;
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
type LoadFn = (
|
||||
id: string,
|
||||
options: LoadOptions | undefined
|
||||
) => Promise<ImpulseResponse>;
|
||||
|
||||
/**
|
||||
* Execute `loadFn` against every id in `ids` with at most `concurrency`
|
||||
* in-flight at any time. On full success returns the resulting array in
|
||||
* input order; on any failure rejects with an aggregate
|
||||
* `AUDIO_IR_LOADER.DECODE_FAILED` carrying every per-id error.
|
||||
*
|
||||
* If the supplied signal is aborted mid-flight, the function stops
|
||||
* launching new loads and rejects with `AUDIO_IR_LOADER.ABORTED`
|
||||
* (preserving any per-id errors already captured).
|
||||
*/
|
||||
export async function runPreload(
|
||||
loadFn: LoadFn,
|
||||
ids: readonly string[],
|
||||
options: PreloadOptions | undefined
|
||||
): Promise<readonly ImpulseResponse[]> {
|
||||
const requested = options?.concurrency ?? DEFAULT_CONCURRENCY;
|
||||
const concurrency = Math.min(
|
||||
Math.max(Math.floor(requested), MIN_CONCURRENCY),
|
||||
MAX_CONCURRENCY
|
||||
);
|
||||
const signal = options?.signal;
|
||||
const results: (ImpulseResponse | undefined)[] = new Array(ids.length);
|
||||
const failures: AggregatedLoadFailure[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
function takeNext(): { index: number; id: string } | undefined {
|
||||
if (signal?.aborted === true) return undefined;
|
||||
if (nextIndex >= ids.length) return undefined;
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const id = ids[index] as string;
|
||||
return { index, id };
|
||||
}
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const next = takeNext();
|
||||
if (next === undefined) return;
|
||||
try {
|
||||
const ir = await loadFn(
|
||||
next.id,
|
||||
signal !== undefined ? { signal } : undefined
|
||||
);
|
||||
results[next.index] = ir;
|
||||
} catch (err) {
|
||||
if (err instanceof SuaveplanError) {
|
||||
failures.push({ id: next.id, error: err });
|
||||
} else {
|
||||
// Non-namespaced throwables are wrapped so the
|
||||
// aggregate carries a SuaveplanError on every entry.
|
||||
// Always carry the offending throwable on `cause` —
|
||||
// the SuaveplanError options bag accepts any value
|
||||
// and preserving it loses no information.
|
||||
const wrapped = new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.RESOLVE_FAILED,
|
||||
`preload of ${next.id} threw a non-SuaveplanError`,
|
||||
{ id: next.id, cause: err }
|
||||
);
|
||||
failures.push({ id: next.id, error: wrapped });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers: Promise<void>[] = [];
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
// `AbortSignal.reason` is always defined after an `abort()` call
|
||||
// (the platform synthesises a `DOMException` when no explicit
|
||||
// reason was supplied), so pass it through unconditionally.
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"preload aborted",
|
||||
{ cause: signal.reason, errors: failures }
|
||||
);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODE_FAILED,
|
||||
`preload failed for ${failures.length} of ${ids.length} ids`,
|
||||
{ errors: failures }
|
||||
);
|
||||
}
|
||||
// All slots populated — narrow to `ImpulseResponse[]` for the caller.
|
||||
return Object.freeze(results.map((r) => r as ImpulseResponse));
|
||||
}
|
||||
Reference in New Issue
Block a user