Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# @suaveplan/audio-ir-loader - Pipeline package for the @suaveplan audio-ir family: composes IrRegistry → StorageUriResolver → AudioDecoder → manifest validation into a single createIrLoader(). Universal.
Pipeline package for the `@suaveplan/audio-ir-*` family. Composes
`IrRegistry` (from `@suaveplan/audio-ir-core`), a `StorageUriResolver`
(from `@suaveplan/storage-uri`), and a map of `AudioDecoder`s into a
single `IrLoader` with `load`, `preload`, `evict`, and `cacheStats`.
<!-- begin:auto-docs -->
## Documentation tree
- [cache](./src/cache/cache.md) — This module implements the in-process LRU that backs the loader's optional cache. The cache is keyed by IR id and bound…
- [cache](./src/cache/cache.md) — This module implements the in-process LRU that backs the loader's optional cache. The cache is keyed by IR id and bound…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code raised by `@suaveplan/audio-ir-loader`. It uses `defineC…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code raised by `@suaveplan/audio-ir-loader`. It uses `defineC…
- [loader](./src/loader/loader.md) — This module is the entry point for the package. `createIrLoader(options)` returns a typed `IrLoader` that composes the…
- [extension](./src/loader/extension.md) — The loader dispatches decoders by file extension. This module owns the canonicalisation step: given a URI string (which…
- [loader](./src/loader/loader.md) — This module is the entry point for the package. `createIrLoader(options)` returns a typed `IrLoader` that composes the…
- [manifest-validation](./src/loader/manifest-validation.md) — This module is the bridge between the `DecodedAudio` shape emitted by an `AudioDecoder` and the `ImpulseResponse` shape…
- [preload](./src/loader/preload.md) — This module implements the bounded-concurrency `preload` algorithm backing `IrLoader.preload`. It launches up to `concu…
- [otel](./src/otel/otel.md) — This module owns the loader's OpenTelemetry instrumentation. It lazily builds a single, cached `AudioIrLoaderInstrument…
- [otel](./src/otel/otel.md) — This module owns the loader's OpenTelemetry instrumentation. It lazily builds a single, cached `AudioIrLoaderInstrument…
<!-- end:auto-docs -->
## What ships
- `createIrLoader(options): IrLoader` — Zod-validated factory.
- Bounded-concurrency `preload(ids, { concurrency })` with aggregate
failure reporting.
- Optional in-process byte-bounded LRU cache.
- `AUDIO_IR_LOADER.*` error-code registry — exactly six codes
(`DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
`CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`).
- OpenTelemetry surface — `audio_ir_loader.load_duration_ms`,
`audio_ir_loader.bytes_read_total`, `audio_ir_loader.frames_out_total`,
`audio_ir_loader.cache_hits_total`, `audio_ir_loader.cache_misses_total`,
`audio_ir_loader.errors_total`.
## Modules
| Module | Subpath | Docs |
| --- | --- | --- |
| Error codes | `./error-codes` | [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) |
| OTel surface | (root) | [`src/otel/otel.md`](./src/otel/otel.md) |
| LRU cache | (root) | [`src/cache/cache.md`](./src/cache/cache.md) |
| `createIrLoader` | (root) | [`src/loader/loader.md`](./src/loader/loader.md) |
| `extractExtension` | (root) | [`src/loader/extension.md`](./src/loader/extension.md) |
| Manifest validation | (root) | [`src/loader/manifest-validation.md`](./src/loader/manifest-validation.md) |
| Bounded `preload` | (root) | [`src/loader/preload.md`](./src/loader/preload.md) |
## Design
The pipeline + dispatch + cache decisions live at
the cross-cutting IR-family model lives at
## Quick install
```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 },
cache: { maxBytes: 64 * 1024 * 1024 },
});
const ir = await loader.load("club-club1");
```

View File

@@ -0,0 +1,70 @@
{
"name": "@suaveplan/audio-ir-loader",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Pipeline package for the @suaveplan audio-ir family: composes IrRegistry → StorageUriResolver → AudioDecoder → manifest validation into a single createIrLoader(). Universal.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/audio-ir-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/storage-uri": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/audio-decode-wav": "workspace:^",
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
"@suaveplan/storage-uri-plugin-fallback": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,101 @@
# `cache.ts` — byte-bounded LRU for decoded `ImpulseResponse`s
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module implements the in-process LRU that backs the loader's optional
cache. The cache is keyed by IR id and bounded by total decoded bytes —
not entry count — because IRs vary wildly in size (a 1-second mic IR vs.
a 10-second cathedral room IR can differ by 100x) and a count-based budget
gives unpredictable memory behaviour. Eviction is oldest-by-insertion, not
oldest-by-access, because IRs are typically loaded once at app start and
then held for the session: insertion-time eviction is both simpler and
gives more predictable memory occupancy than the LRU-by-touch variant.
## Features
- Byte-bounded budget (`maxBytes`) with strict enforcement.
- Single-IR `CACHE_OVERFLOW` throw rather than thrashing the rest of the
cache to fit one oversized IR.
- Hit / miss counters surfaced via `stats()` for `IrLoader.cacheStats()`.
- Updates over an existing id replace in-place (and re-budget).
## Usage: Basic
```ts
import { ImpulseResponseCache } from "@suaveplan/audio-ir-loader";
const cache = new ImpulseResponseCache(1024 * 1024);
cache.put("club-club1", ir);
const hit = cache.get("club-club1"); // === ir
console.log(cache.stats()); // { entries: 1, bytes: ..., hits: 1, misses: 0 }
```
## Usage: Advanced
The eviction loop is deterministic: `Map.keys()` enumerates insertion
order, so callers can reason about exactly which entry will be evicted
when budget is exceeded. The pattern below demonstrates the
`CACHE_OVERFLOW` boundary:
```ts
import {
AUDIO_IR_LOADER_CODES,
AudioIrLoaderError,
ImpulseResponseCache,
} from "@suaveplan/audio-ir-loader";
const cache = new ImpulseResponseCache(1024);
try {
cache.put("big", giantIr); // channelData totals 4096 bytes
} catch (err) {
if (
err instanceof AudioIrLoaderError &&
err.code === AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW
) {
console.error(
`IR ${err.id} needs ${err.decodedBytes} bytes (budget ${err.maxBytes})`
);
}
}
```
## API Reference
### `class ImpulseResponseCache`
- `new ImpulseResponseCache(maxBytes: number)` — `maxBytes` must be a
positive integer; otherwise throws `CACHE_OVERFLOW` at construction.
- `get(id: string): ImpulseResponse | undefined` — increments hits on
success, misses on absence.
- `put(id: string, ir: ImpulseResponse): void` — throws `CACHE_OVERFLOW`
if `ir`'s decoded bytes exceed `maxBytes`; otherwise evicts oldest
entries until the new IR fits.
- `evict(id: string): boolean` — drops the entry; returns whether
anything was removed.
- `stats(): CacheStats` — snapshot of `{ entries, bytes, hits, misses }`.
- `maxBytes` getter — the configured byte budget.
### `CacheStats`
`{ entries: number; bytes: number; hits: number; misses: number }`.
### `impulseResponseBytes(ir): number`
Sum of `channelData[i].byteLength` over all channels — the canonical
budgeting metric used by the cache.
## Implementation Notes
`Map` preserves insertion order in JavaScript, so the eviction iterator
walks entries in the order they were `put()` — re-inserting an existing
id moves it to the most-recent slot (via `delete` then `set`). The
`stats()` snapshot is freshly built on every call (no caching) because
callers expect a point-in-time view; the cost is bounded by the size of
the returned object (four numbers) and dominated by the V8 allocation
itself. The constructor's positive-integer check protects against the
`new ImpulseResponseCache(0)` foot-gun, which would otherwise let every
single `put` throw and obscure the misconfiguration.

View File

@@ -0,0 +1,142 @@
import type { ImpulseResponse } 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 { ImpulseResponseCache, impulseResponseBytes } from "./cache.js";
const ENTRY = (id: string) => ({
id,
family: "room" as const,
uri: `bundle://x/${id}.wav`,
channels: 1,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
});
function makeIR(id: string, bytesPerChannel: number): ImpulseResponse {
const samples = bytesPerChannel / 4;
const buf = new Float32Array(samples);
return Object.freeze({
id,
channelData: Object.freeze([buf]),
channels: 1,
sampleRate: 44100,
lengthFrames: samples,
durationSec: samples / 44100,
gainDb: 0,
family: "room",
manifest: Object.freeze(ENTRY(id)),
}) as ImpulseResponse;
}
describe("impulseResponseBytes", () => {
it("sums byteLength across channels", { timeout: 5000 }, () => {
const ir = makeIR("club-x", 1024);
expect(impulseResponseBytes(ir)).toBe(1024);
});
});
describe("ImpulseResponseCache", () => {
it("rejects non-positive maxBytes at construction", {
timeout: 5000,
}, () => {
expect(() => new ImpulseResponseCache(0)).toThrow(AudioIrLoaderError);
expect(() => new ImpulseResponseCache(-1)).toThrow(AudioIrLoaderError);
expect(() => new ImpulseResponseCache(1.5)).toThrow(AudioIrLoaderError);
});
it("get returns undefined and counts misses for absent keys", {
timeout: 5000,
}, () => {
const c = new ImpulseResponseCache(4096);
expect(c.get("nope")).toBeUndefined();
expect(c.stats()).toEqual({
entries: 0,
bytes: 0,
hits: 0,
misses: 1,
});
});
it("second load returns cached reference without re-decode", {
timeout: 5000,
}, () => {
const c = new ImpulseResponseCache(4096);
const ir = makeIR("club-a", 1024);
c.put("club-a", ir);
const first = c.get("club-a");
const second = c.get("club-a");
expect(first).toBe(ir);
expect(second).toBe(ir);
expect(c.stats().hits).toBe(2);
});
it("LRU evicts oldest by insertion when maxBytes exceeded", {
timeout: 5000,
}, () => {
// maxBytes sized to hold exactly two 1024-byte IRs.
const c = new ImpulseResponseCache(2048);
c.put("a", makeIR("a", 1024));
c.put("b", makeIR("b", 1024));
c.put("c", makeIR("c", 1024));
expect(c.stats().entries).toBe(2);
expect(c.get("a")).toBeUndefined();
expect(c.get("b")).toBeDefined();
expect(c.get("c")).toBeDefined();
});
it("oversize IR throws CACHE_OVERFLOW", { timeout: 5000 }, () => {
const c = new ImpulseResponseCache(1024);
const big = makeIR("big", 4096);
let captured: AudioIrLoaderError | undefined;
try {
c.put("big", big);
} catch (err) {
if (err instanceof AudioIrLoaderError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW);
expect(captured?.maxBytes).toBe(1024);
expect(captured?.decodedBytes).toBe(4096);
expect(captured?.id).toBe("big");
});
it("evict returns true on hit and false on miss", {
timeout: 5000,
}, () => {
const c = new ImpulseResponseCache(2048);
c.put("x", makeIR("x", 512));
expect(c.evict("x")).toBe(true);
expect(c.evict("x")).toBe(false);
expect(c.stats().entries).toBe(0);
expect(c.stats().bytes).toBe(0);
});
it("put over an existing id replaces the entry in place", {
timeout: 5000,
}, () => {
const c = new ImpulseResponseCache(4096);
c.put("x", makeIR("x", 1024));
c.put("x", makeIR("x", 2048));
expect(c.stats().entries).toBe(1);
expect(c.stats().bytes).toBe(2048);
});
it("maxBytes getter reports the configured budget", {
timeout: 5000,
}, () => {
const c = new ImpulseResponseCache(8192);
expect(c.maxBytes).toBe(8192);
});
it("stats reflects bytes after eviction", { timeout: 5000 }, () => {
const c = new ImpulseResponseCache(2048);
c.put("a", makeIR("a", 1024));
c.put("b", makeIR("b", 1024));
c.put("c", makeIR("c", 1024));
expect(c.stats().bytes).toBe(2048);
});
});

View File

@@ -0,0 +1,132 @@
import type { ImpulseResponse } from "@suaveplan/audio-ir-core";
import {
AUDIO_IR_LOADER_CODES,
AudioIrLoaderError,
} from "../error-codes/error-codes.js";
/** Snapshot of cache state surfaced via `IrLoader.cacheStats()`. */
export interface CacheStats {
readonly entries: number;
readonly bytes: number;
readonly hits: number;
readonly misses: number;
}
/**
* Compute the total decoded byte-length of an `ImpulseResponse`'s
* channelData. Used both for cache accounting and for the `CACHE_OVERFLOW`
* pre-check.
*/
export function impulseResponseBytes(ir: ImpulseResponse): number {
let total = 0;
for (const channel of ir.channelData) {
total += channel.byteLength;
}
return total;
}
interface CacheEntry {
readonly ir: ImpulseResponse;
readonly bytes: number;
}
/**
* In-process LRU keyed by IR id, bounded by total decoded bytes.
*
* Eviction is oldest-by-insertion (not access-time): IRs are typically
* loaded once and held, so insertion-time eviction is both simpler and
* better-behaved than access-time LRU at this scale. A single IR larger
* than the configured `maxBytes` throws `CACHE_OVERFLOW` instead of
* evicting every other entry — better to fail loud than to thrash.
*/
export class ImpulseResponseCache {
readonly #maxBytes: number;
readonly #entries = new Map<string, CacheEntry>();
#bytes = 0;
#hits = 0;
#misses = 0;
public constructor(maxBytes: number) {
if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
throw new AudioIrLoaderError(
AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW,
"cache maxBytes must be a positive integer",
{ maxBytes }
);
}
this.#maxBytes = maxBytes;
}
/** Configured byte budget. */
public get maxBytes(): number {
return this.#maxBytes;
}
/** `get(id)` — increments hits on success, misses on absence. */
public get(id: string): ImpulseResponse | undefined {
const entry = this.#entries.get(id);
if (entry === undefined) {
this.#misses += 1;
return undefined;
}
this.#hits += 1;
return entry.ir;
}
/**
* Insert `ir` into the cache. Throws `CACHE_OVERFLOW` if `ir` alone
* exceeds `maxBytes`. Otherwise evicts oldest-by-insertion entries
* until total bytes ≤ `maxBytes - irBytes`, then inserts.
*/
public put(id: string, ir: ImpulseResponse): void {
const irBytes = impulseResponseBytes(ir);
if (irBytes > this.#maxBytes) {
throw new AudioIrLoaderError(
AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW,
`IR ${id} decodes to ${irBytes} bytes; cache budget is ${this.#maxBytes}`,
{
id,
maxBytes: this.#maxBytes,
decodedBytes: irBytes,
}
);
}
const existing = this.#entries.get(id);
if (existing !== undefined) {
this.#bytes -= existing.bytes;
this.#entries.delete(id);
}
// Evict oldest until insertion fits. The constructor enforces
// `irBytes <= maxBytes`, so the loop is guaranteed to terminate
// before draining every entry — the `Map.keys()` iterator and
// its corresponding `get()` are always defined for live entries.
const iter = this.#entries.keys();
while (this.#bytes + irBytes > this.#maxBytes) {
const evictId = iter.next().value as string;
const evictEntry = this.#entries.get(evictId) as CacheEntry;
this.#bytes -= evictEntry.bytes;
this.#entries.delete(evictId);
}
this.#entries.set(id, { ir, bytes: irBytes });
this.#bytes += irBytes;
}
/** Drop the entry for `id`. Returns whether anything was removed. */
public evict(id: string): boolean {
const entry = this.#entries.get(id);
if (entry === undefined) return false;
this.#bytes -= entry.bytes;
this.#entries.delete(id);
return true;
}
/** Snapshot of current cache state. */
public stats(): CacheStats {
return {
entries: this.#entries.size,
bytes: this.#bytes,
hits: this.#hits,
misses: this.#misses,
};
}
}

View File

@@ -0,0 +1,5 @@
export type { CacheStats } from "./cache.js";
export {
ImpulseResponseCache,
impulseResponseBytes,
} from "./cache.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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";
}
}

View File

@@ -0,0 +1,9 @@
export type {
AggregatedLoadFailure,
AudioIrLoaderErrorCode,
AudioIrLoaderErrorOptions,
} from "./error-codes.js";
export {
AUDIO_IR_LOADER_CODES,
AudioIrLoaderError,
} from "./error-codes.js";

View File

@@ -0,0 +1,42 @@
// Side-effect import: ensures the AUDIO_IR_LOADER.* code registry registers
// even when subpath consumers never reach this barrel.
import "./error-codes/error-codes.js";
export {
type CacheStats,
ImpulseResponseCache,
impulseResponseBytes,
} from "./cache/cache.js";
export {
type AggregatedLoadFailure,
AUDIO_IR_LOADER_CODES,
AudioIrLoaderError,
type AudioIrLoaderErrorCode,
type AudioIrLoaderErrorOptions,
} from "./error-codes/error-codes.js";
export { extractExtension } from "./loader/extension.js";
export {
type AudioDecoder,
type CreateIrLoaderOptions,
createIrLoader,
type IrLoader,
type LoadOptions,
type PreloadOptions,
} from "./loader/loader.js";
export {
type DecodedAudioLike,
validateAndFinalize,
} from "./loader/manifest-validation.js";
export { runPreload } from "./loader/preload.js";
export {
type AudioIrLoaderInstruments,
type AudioIrLoaderLoadOutcome,
getInstruments,
recordCacheHit,
recordCacheMiss,
recordError,
recordSuccess,
resetInstruments,
setAudioIrLoaderMeterProvider,
uriKindOf,
} from "./otel/otel.js";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View 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";

View 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.

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View 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();
},
};
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View 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));
}

View File

@@ -0,0 +1,14 @@
export type {
AudioIrLoaderInstruments,
AudioIrLoaderLoadOutcome,
} from "./otel.js";
export {
getInstruments,
recordCacheHit,
recordCacheMiss,
recordError,
recordSuccess,
resetInstruments,
setAudioIrLoaderMeterProvider,
uriKindOf,
} from "./otel.js";

View File

@@ -0,0 +1,114 @@
# `otel.ts` — OpenTelemetry surface for `audio-ir-loader`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the loader's OpenTelemetry instrumentation. It lazily
builds a single, cached `AudioIrLoaderInstruments` bag bound to either the
global `MeterProvider` or a per-process override installed via
`setAudioIrLoaderMeterProvider`. The bag carries every counter and
histogram the spec requires (`bytes_read_total`, `frames_out_total`,
`cache_hits_total`, `cache_misses_total`, `errors_total`, plus the
`load_duration_ms` histogram).
The split between this module and the loader/cache modules is deliberate:
all metric definitions live here so dashboards can grep for instrument
names in one place, and the loader/cache modules call recorder helpers
(`recordSuccess`, `recordCacheHit`, `recordCacheMiss`, `recordError`)
that close over the cached bag.
## Features
- Real `MeterProvider` override for tests — no mocks; tests install an
in-memory recorder via `setAudioIrLoaderMeterProvider`.
- Histogram + five counters covering success / cache-hit / cache-miss /
error outcomes.
- Span-attribute helper `uriKindOf(uri)` resolves the `audio_ir.uri_kind`
dimension from a URI's scheme without re-parsing the whole URI.
## Usage: Basic
```ts
import {
recordSuccess,
setAudioIrLoaderMeterProvider,
} from "@suaveplan/audio-ir-loader";
import { metrics } from "@opentelemetry/api";
// In an app bootstrap:
setAudioIrLoaderMeterProvider(metrics.getMeterProvider());
recordSuccess("room", 100000, 25000, 12.5);
```
## Usage: Advanced
Tests install a real in-memory provider that records every observation:
```ts
import {
getInstruments,
setAudioIrLoaderMeterProvider,
} from "@suaveplan/audio-ir-loader";
import type { MeterProvider } from "@opentelemetry/api";
const observed: { name: string; value: number; attrs: Record<string, unknown> }[] = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (v: number, a?: Record<string, unknown>) =>
observed.push({ name, value: v, attrs: a ?? {} }),
}),
createHistogram: (name: string) => ({
record: (v: number, a?: Record<string, unknown>) =>
observed.push({ name, value: v, attrs: a ?? {} }),
}),
}),
} as unknown as MeterProvider;
setAudioIrLoaderMeterProvider(provider);
getInstruments(true);
```
## API Reference
### `setAudioIrLoaderMeterProvider(provider | undefined): void`
Install a meter provider override. Pass `undefined` to revert.
### `getInstruments(force?: boolean): AudioIrLoaderInstruments`
Lazily builds (and caches) the instrument bag.
### `resetInstruments(): void`
Drops the cache — used between test provider swaps.
### `recordSuccess(family, bytes, frames, durationMs): void`
Increments `bytes_read_total{family}` and `frames_out_total{family}`, then
records `load_duration_ms{family, outcome:"success"}`.
### `recordCacheHit(family, durationMs): void` / `recordCacheMiss(family): void`
Counter + duration bookkeeping for cache outcomes.
### `recordError(code, family, durationMs): void`
Increments `errors_total{code}` and records the duration with
`outcome:"error"`.
### `uriKindOf(uri): "bundle" | "fallback" | "https" | "drive" | "other"`
Maps a URI's leading scheme to the span-attribute dimension.
## Implementation Notes
The cached instrument bag is rebuilt every time `setAudioIrLoaderMeterProvider`
swaps the provider — otherwise tests would observe stale instruments
bound to the previous provider's meter. The `force` parameter on
`getInstruments` exists exclusively to let tests immediately rebind
after `setAudioIrLoaderMeterProvider` to avoid a wasted check on the
next call. `uriKindOf` deliberately treats `http` and `https` identically
because callers care about the on-the-wire transport, not the TLS bit.

View File

@@ -0,0 +1,319 @@
import type { MeterProvider } from "@opentelemetry/api";
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 { AudioIrLoaderError } from "../error-codes/error-codes.js";
import { type AudioDecoder, createIrLoader } from "../loader/loader.js";
import {
getInstruments,
recordCacheHit,
recordCacheMiss,
recordError,
recordSuccess,
resetInstruments,
setAudioIrLoaderMeterProvider,
uriKindOf,
} from "./otel.js";
interface CounterObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface HistogramObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface Recorder {
readonly counters: CounterObs[];
readonly histograms: HistogramObs[];
}
function installRecorder(): Recorder {
const counters: CounterObs[] = [];
const histograms: HistogramObs[] = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({ name, value, attributes: attrs ?? {} });
},
}),
createHistogram: (name: string) => ({
record: (value: number, attrs?: Record<string, unknown>) => {
histograms.push({ name, value, attributes: attrs ?? {} });
},
}),
}),
} as unknown as MeterProvider;
setAudioIrLoaderMeterProvider(provider);
getInstruments(true);
return { counters, histograms };
}
function uninstall(): void {
setAudioIrLoaderMeterProvider(undefined);
resetInstruments();
}
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;
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();
return { resolver, registry, reads };
}
describe("audio-ir-loader OTel surface (helpers)", () => {
afterEach(() => uninstall());
it("recordSuccess records bytes_read_total + frames_out_total + duration", {
timeout: 5000,
}, () => {
const rec = installRecorder();
recordSuccess("room", 100000, 25000, 12.5);
const bytes = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.bytes_read_total" &&
c.attributes["family"] === "room"
);
const frames = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.frames_out_total" &&
c.attributes["family"] === "room"
);
const duration = rec.histograms.find(
(h) =>
h.name === "audio_ir_loader.load_duration_ms" &&
h.attributes["family"] === "room" &&
h.attributes["outcome"] === "success"
);
expect(bytes?.value).toBe(100000);
expect(frames?.value).toBe(25000);
expect(duration?.value).toBe(12.5);
});
it("recordCacheHit increments cache_hits_total + cache_hit duration", {
timeout: 5000,
}, () => {
const rec = installRecorder();
recordCacheHit("room", 0.5);
const hit = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.cache_hits_total" &&
c.attributes["family"] === "room"
);
const dur = rec.histograms.find(
(h) => h.attributes["outcome"] === "cache_hit"
);
expect(hit?.value).toBe(1);
expect(dur?.value).toBe(0.5);
});
it("recordCacheMiss increments cache_misses_total", {
timeout: 5000,
}, () => {
const rec = installRecorder();
recordCacheMiss("speaker");
const miss = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.cache_misses_total" &&
c.attributes["family"] === "speaker"
);
expect(miss?.value).toBe(1);
});
it("recordError increments errors_total{code} + error duration", {
timeout: 5000,
}, () => {
const rec = installRecorder();
recordError("AUDIO_IR.NOT_REGISTERED", "unknown", 7.0);
const err = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.errors_total" &&
c.attributes["code"] === "AUDIO_IR.NOT_REGISTERED"
);
expect(err?.value).toBe(1);
});
it("getInstruments returns cached instance without force", {
timeout: 5000,
}, () => {
installRecorder();
const a = getInstruments();
const b = getInstruments();
expect(a).toBe(b);
});
it("setAudioIrLoaderMeterProvider(undefined) reverts to global provider", {
timeout: 5000,
}, () => {
installRecorder();
setAudioIrLoaderMeterProvider(undefined);
// Subsequent recordSuccess still works (no-op meter).
recordSuccess("room", 1, 1, 1);
});
it("uriKindOf classifies known schemes", { timeout: 5000 }, () => {
expect(uriKindOf("bundle://x/y.wav")).toBe("bundle");
expect(uriKindOf("fallback://?u=x")).toBe("fallback");
expect(uriKindOf("https://h/x")).toBe("https");
expect(uriKindOf("http://h/x")).toBe("https");
expect(uriKindOf("drive://share/x")).toBe("drive");
expect(uriKindOf("file:/path")).toBe("other");
expect(uriKindOf("garbage")).toBe("other");
});
});
describe("audio-ir-loader OTel surface (end-to-end)", () => {
afterEach(() => uninstall());
it("records load_duration_ms and bytes_read_total on success", {
timeout: 5000,
}, async () => {
const rec = installRecorder();
const { resolver, registry, reads } = harness();
registry.register(ENTRY(), {
manifestSlug: "club",
manifestFamily: "room",
});
reads.set("x/club1.wav", new Uint8Array(100));
const decoder: AudioDecoder = async () => ({
channelData: [new Float32Array(25)],
channels: 1,
sampleRate: 44100,
});
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decoder },
});
await loader.load("club-club1");
const bytes = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.bytes_read_total" &&
c.attributes["family"] === "room"
);
const frames = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.frames_out_total" &&
c.attributes["family"] === "room"
);
const dur = rec.histograms.find(
(h) =>
h.name === "audio_ir_loader.load_duration_ms" &&
h.attributes["outcome"] === "success"
);
expect(bytes?.value).toBe(100);
expect(frames?.value).toBe(25);
expect(dur).toBeDefined();
});
it("increments cache_hits_total on cache hit", {
timeout: 5000,
}, async () => {
const rec = installRecorder();
const { resolver, registry, reads } = harness();
registry.register(ENTRY(), {
manifestSlug: "club",
manifestFamily: "room",
});
reads.set("x/club1.wav", new Uint8Array([1]));
const decoder: AudioDecoder = async () => ({
channelData: [new Float32Array([0])],
channels: 1,
sampleRate: 44100,
});
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decoder },
cache: { maxBytes: 4096 },
});
await loader.load("club-club1");
await loader.load("club-club1");
const hits = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.cache_hits_total" &&
c.attributes["family"] === "room"
);
expect(hits?.value).toBe(1);
});
it("increments errors_total with the resolved code from any namespace", {
timeout: 5000,
}, async () => {
const rec = installRecorder();
const { resolver, registry } = harness();
const loader = createIrLoader({
registry,
resolver,
decoders: {
wav: (async () => ({
channelData: [new Float32Array([0])],
channels: 1,
sampleRate: 44100,
})) as AudioDecoder,
},
});
let captured: AudioIrLoaderError | undefined;
try {
await loader.load("nope");
} catch (err) {
if (err instanceof AudioIrLoaderError) captured = err;
}
// The throw is AUDIO_IR.NOT_REGISTERED (an AudioIrError), not a loader error.
// No need to assert captured here — we're checking the counter only.
void captured;
const err = rec.counters.find(
(c) =>
c.name === "audio_ir_loader.errors_total" &&
c.attributes["code"] === "AUDIO_IR.NOT_REGISTERED"
);
expect(err).toBeDefined();
});
});

View File

@@ -0,0 +1,146 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
import type { IrFamily } from "@suaveplan/audio-ir-core";
const METER_NAME = "@suaveplan/audio-ir-loader";
const TRACER_NAME = "@suaveplan/audio-ir-loader";
let overrideMeterProvider: MeterProvider | undefined;
/**
* Override the meter provider used to acquire instruments. Pass
* `undefined` to revert to the global provider. Resets the cached
* instrument bag so the next access binds to the new provider.
*/
export function setAudioIrLoaderMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
}
/** Load-outcome label used on `audio_ir_loader.load_duration_ms`. */
export type AudioIrLoaderLoadOutcome = "success" | "cache_hit" | "error";
export interface AudioIrLoaderInstruments {
readonly tracer: Tracer;
readonly meter: Meter;
readonly loadDurationMs: Histogram;
readonly bytesReadTotal: Counter;
readonly framesOutTotal: Counter;
readonly cacheHitsTotal: Counter;
readonly cacheMissesTotal: Counter;
readonly errorsTotal: Counter;
}
let cachedInstruments: AudioIrLoaderInstruments | undefined;
/** Lazily build (or return cached) instruments bound to the active provider. */
export function getInstruments(force = false): AudioIrLoaderInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
loadDurationMs: meter.createHistogram(
"audio_ir_loader.load_duration_ms",
{
description:
"End-to-end load duration in ms; labels: family, outcome",
unit: "ms",
}
),
bytesReadTotal: meter.createCounter(
"audio_ir_loader.bytes_read_total",
{
description: "Raw bytes read from the resolver; labels: family",
}
),
framesOutTotal: meter.createCounter(
"audio_ir_loader.frames_out_total",
{
description: "Decoded frame count per channel; labels: family",
}
),
cacheHitsTotal: meter.createCounter(
"audio_ir_loader.cache_hits_total",
{
description: "LRU cache hits; labels: family",
}
),
cacheMissesTotal: meter.createCounter(
"audio_ir_loader.cache_misses_total",
{
description: "LRU cache misses; labels: family",
}
),
errorsTotal: meter.createCounter("audio_ir_loader.errors_total", {
description:
"Every error thrown by the loader; labels: code (fully-qualified)",
}),
};
return cachedInstruments;
}
/** Reset cached instruments — used by tests between provider swaps. */
export function resetInstruments(): void {
cachedInstruments = undefined;
}
/** Increment `bytes_read_total{family}` by `bytes` and `frames_out_total{family}` by `frames`. */
export function recordSuccess(
family: IrFamily,
bytes: number,
frames: number,
durationMs: number
): void {
const inst = getInstruments();
inst.bytesReadTotal.add(bytes, { family });
inst.framesOutTotal.add(frames, { family });
inst.loadDurationMs.record(durationMs, { family, outcome: "success" });
}
/** Record a cache hit + the (very short) duration the hit cost. */
export function recordCacheHit(family: IrFamily, durationMs: number): void {
const inst = getInstruments();
inst.cacheHitsTotal.add(1, { family });
inst.loadDurationMs.record(durationMs, { family, outcome: "cache_hit" });
}
/** Record a cache miss. */
export function recordCacheMiss(family: IrFamily): void {
getInstruments().cacheMissesTotal.add(1, { family });
}
/** Increment `errors_total{code}` and record duration with `outcome=error`. */
export function recordError(
code: string,
family: IrFamily | "unknown",
durationMs: number
): void {
const inst = getInstruments();
inst.errorsTotal.add(1, { code });
inst.loadDurationMs.record(durationMs, { family, outcome: "error" });
}
/** Derive `audio_ir.uri_kind` span attribute from a URI's leading scheme. */
export function uriKindOf(
uri: string
): "bundle" | "fallback" | "https" | "drive" | "other" {
const colon = uri.indexOf(":");
if (colon <= 0) return "other";
const scheme = uri.slice(0, colon).toLowerCase();
if (scheme === "bundle") return "bundle";
if (scheme === "fallback") return "fallback";
if (scheme === "https" || scheme === "http") return "https";
if (scheme === "drive") return "drive";
return "other";
}

View File

@@ -0,0 +1,280 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MeterProvider } from "@opentelemetry/api";
import { decodeWav } from "@suaveplan/audio-decode-wav";
import {
createIrRegistry,
type IrManifestEntry,
} from "@suaveplan/audio-ir-core";
import {
createStorageUriResolver,
STORAGE_URI_CODES,
StorageUriError,
type UriPlugin,
} from "@suaveplan/storage-uri";
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
import {
buildFallbackUri,
fallbackUriPlugin,
resetFallbackInstruments,
setFallbackMeterProvider,
} from "@suaveplan/storage-uri-plugin-fallback";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createIrLoader } from "../../src/index.js";
import {
getInstruments,
resetInstruments,
setAudioIrLoaderMeterProvider,
} from "../../src/otel/otel.js";
interface CounterObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface HistogramObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
/**
* In-test minimal `http`/`https` `UriPlugin` adapter wrapping
* `globalThis.fetch`. Lives in the test file by design — there is no
* shipped storage-uri plugin for HTTPS yet, and the integration test
* must not introduce a new published package.
*/
function makeHttpPlugin(scheme: "http" | "https"): UriPlugin {
return {
scheme,
adapter: {
async get(key: string) {
const url = `${scheme}://${key}`;
const res = await fetch(url);
if (res.status === 404) return undefined;
if (!res.ok) {
throw new StorageUriError(
STORAGE_URI_CODES.ADAPTER_ERROR,
`HTTP ${res.status} from ${url}`,
{ uri: url, scheme }
);
}
return new Uint8Array(await res.arrayBuffer());
},
async set() {
return;
},
async delete() {
return true;
},
async exists(key: string) {
const url = `${scheme}://${key}`;
const res = await fetch(url, { method: "HEAD" });
return res.ok;
},
async list() {
return { keys: [], total: 0, hasMore: false };
},
async clear() {
return;
},
},
parse(uri: string) {
const noScheme = uri.replace(new RegExp(`^${scheme}://`), "");
return { key: noScheme };
},
};
}
/** Minimal RIFF/WAVE 16-bit mono PCM builder for the integration fixture. */
function buildSilentWav(
sampleRate: number,
samplesPerChannel: number
): Uint8Array {
const dataBytes = samplesPerChannel * 2;
const buf = new Uint8Array(44 + dataBytes);
const dv = new DataView(buf.buffer);
const writeAscii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
writeAscii(0, "RIFF");
dv.setUint32(4, 36 + dataBytes, true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
writeAscii(36, "data");
dv.setUint32(40, dataBytes, true);
// body left as silence (zeros) — sufficient for the decode happy path.
return buf;
}
function installCombinedRecorder(): {
counters: CounterObs[];
histograms: HistogramObs[];
} {
const counters: CounterObs[] = [];
const histograms: HistogramObs[] = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({ name, value, attributes: attrs ?? {} });
},
}),
createHistogram: (name: string) => ({
record: (value: number, attrs?: Record<string, unknown>) => {
histograms.push({ name, value, attributes: attrs ?? {} });
},
}),
createObservableGauge: () => ({
addCallback: () => {},
}),
}),
} as unknown as MeterProvider;
setAudioIrLoaderMeterProvider(provider);
setFallbackMeterProvider(provider);
getInstruments(true);
return { counters, histograms };
}
interface Harness {
server: Server;
port: number;
tempDir: string;
}
let harness: Harness | undefined;
beforeAll(async () => {
const tempDir = await mkdtemp(join(tmpdir(), "audio-ir-loader-test-"));
// Create a club/ subdirectory but DON'T put missing.wav in it — so the
// bundle adapter's GET for `bundle://club/missing.wav` will surface a
// local-file 404 from the file:// URL.
await mkdir(join(tempDir, "club"), { recursive: true });
const wavBytes = buildSilentWav(44100, 32);
// Stand up a node:http server that serves /club1.wav with the WAV bytes
// and 404 for everything else.
const server = createServer((req, res) => {
if (req.url === "/club1.wav") {
res.writeHead(200, { "Content-Type": "audio/wav" });
res.end(Buffer.from(wavBytes));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const port = (server.address() as AddressInfo).port;
harness = { server, port, tempDir };
}, 30000);
afterAll(async () => {
if (harness !== undefined) {
await new Promise<void>((resolve, reject) =>
harness!.server.close((err) => (err ? reject(err) : resolve()))
);
await rm(harness.tempDir, { recursive: true, force: true });
}
setAudioIrLoaderMeterProvider(undefined);
setFallbackMeterProvider(undefined);
resetInstruments();
resetFallbackInstruments();
}, 30000);
describe("integration: bundle-then-http fallback walk", () => {
it("bundle misses fall back to HTTPS and load succeeds end-to-end", {
timeout: 30000,
}, async () => {
if (harness === undefined) throw new Error("harness not initialised");
const rec = installCombinedRecorder();
// Build a UriPlugin set: bundle:// rooted at the tempDir (no
// missing.wav exists), http:// via the in-test plugin.
// The fallback plugin builds a resolver-of-resolvers chain; the
// inner resolver must include bundle + http.
const innerBundle = bundleUriPlugin({
packageSlug: "club",
rootUrl: new URL(`file://${harness.tempDir}/`),
});
const innerHttp = makeHttpPlugin("http");
const innerResolver = createStorageUriResolver([
innerBundle,
innerHttp,
]);
const fbPlugin = fallbackUriPlugin({
resolver: innerResolver,
policy: { errorClass: "any" },
});
const outerResolver = createStorageUriResolver([
innerBundle,
innerHttp,
fbPlugin,
]);
const bundleUri = "bundle://club/missing.wav";
const httpUri = `http://127.0.0.1:${harness.port}/club1.wav`;
const fallbackUri = buildFallbackUri([bundleUri, httpUri]);
const entry: IrManifestEntry = {
id: "club-pool",
family: "room",
uri: fallbackUri,
channels: 1,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "fixture",
} as IrManifestEntry;
const registry = createIrRegistry();
registry.register(entry, {
manifestSlug: "club",
manifestFamily: "room",
});
const loader = createIrLoader({
registry,
resolver: outerResolver,
decoders: { wav: decodeWav },
});
const ir = await loader.load("club-pool");
expect(ir.id).toBe("club-pool");
expect(ir.channels).toBe(1);
expect(ir.sampleRate).toBe(44100);
expect(ir.channelData.length).toBe(1);
// Loader bytes_read_total should have ticked exactly once with
// family=room.
const bytesRead = rec.counters.filter(
(c) =>
c.name === "audio_ir_loader.bytes_read_total" &&
c.attributes["family"] === "room"
);
expect(bytesRead.length).toBe(1);
// The fallback plugin's per-candidate counter should have a
// candidate_index=0 transient sample (the missing bundle URL).
const fbTransient = rec.counters.filter(
(c) =>
c.name === "storage_uri.fallback_total" &&
c.attributes["candidate_index"] === 0 &&
c.attributes["outcome"] === "transient"
);
expect(fbTransient.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-ir-loader",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/audio-ir-core",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/storage-uri",
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,5 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30000,
});