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,16 @@
# `audio-ir`
Impulse-response capture, loading, and room-acoustic plugins.
<!-- begin:auto-docs -->
## Packages in `audio-ir`
4 packages.
| Package | Description |
|---|---|
| [`@suaveplan/audio-ir-core`](./audio-ir-core/README.md) | Foundation contract for the `@suaveplan/audio-ir-*` family. Universal; zero `@suaveplan/audio-*` runtime deps. Every sibling package (`audio-ir-loader`, `… |
| [`@suaveplan/audio-ir-loader`](./audio-ir-loader/README.md) | Pipeline package for the `@suaveplan/audio-ir-*` family. Composes `IrRegistry` (from `@suaveplan/audio-ir-core`), a `StorageUriResolver` (from `@suaveplan/stor… |
| [`@suaveplan/audio-ir-room-plugin-club`](./audio-ir-room-plugin-club/README.md) | Original audio-ir content package. Ships two bundled room impulse responses (`club1.wav`, `club2.wav`) wired through the `@suaveplan/audio-ir-*` family. |
| [`@suaveplan/audio-ir-room-plugin-pool`](./audio-ir-room-plugin-pool/README.md) | Original audio-ir content package. Ships a single `room`-family impulse response — `pool-outdoor-swimming-pool` — sourced from the outdoor-swimming-pool captur… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Impulse-response capture, loading, and room-acoustic plugins.

View File

@@ -0,0 +1,110 @@
# @suaveplan/audio-ir-core - Foundation contract for the @suaveplan audio-ir family: ImpulseResponse / IrManifestEntry / IrManifest Zod schemas, in-memory IrRegistry, AUDIO_IR.* error-code registry, and OpenTelemetry surface. Universal; zero @suaveplan/audio-* runtime deps.
Foundation contract for the `@suaveplan/audio-ir-*` family. Universal;
zero `@suaveplan/audio-*` runtime deps. Every sibling package
(`audio-ir-loader`, `audio-ir-{mic,speaker,room}-plugin-<name>`) consumes
this package.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of every error code the `@suaveplan/audio-ir-core` runtime is allowed to throw, plus the `A…
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of every error code the `@suaveplan/audio-ir-core` runtime is allowed to throw, plus the `A…
- [otel](./src/otel/otel.md) — Owns every span, counter, and observable gauge the registry emits. The foundation is the layer that knows about the reg…
- [otel](./src/otel/otel.md) — Owns every span, counter, and observable gauge the registry emits. The foundation is the layer that knows about the reg…
- [registrar](./src/registrar/registrar.md) — `MutableUriPluginRegistrar` is the canonical collector interface every `audio-ir-{family}-plugin-<name>` content packag…
- [registrar](./src/registrar/registrar.md) — `MutableUriPluginRegistrar` is the canonical collector interface every `audio-ir-{family}-plugin-<name>` content packag…
- [registry](./src/registry/registry.md) — In-memory `Map<string, IrManifestEntry>` registry — the central seam between content packages (which register entries)…
- [register-manifest](./src/registry/register-manifest.md) — Thin iterator over `manifest.entries` that calls `registry.register(entry, { manifestSlug: manifest.packageSlug, manife…
- [registry](./src/registry/registry.md) — In-memory `Map<string, IrManifestEntry>` registry — the central seam between content packages (which register entries)…
- [impulse-response](./src/types/impulse-response.md) — Defines the in-memory shape of a fully-decoded impulse response — the value `@suaveplan/audio-ir-loader.load(id)` retur…
- [manifest-entry](./src/types/manifest-entry.md) — Defines the on-the-wire shape of a single entry inside an `IrManifest`. An entry is the smallest unit a content package…
- [manifest](./src/types/manifest.md) — Defines the on-the-wire shape of a complete IR manifest — the package-level contract a content package (e.g. `audio-ir-…
<!-- end:auto-docs -->
## What ships
- `ImpulseResponse` / `IrManifestEntry` / `IrManifest` Zod schemas — all
frozen on parse, with cross-field refinements (ID-prefix coherence,
family coherence, channel-count + duration coherence).
- `createIrRegistry()` → `IrRegistry` with `register`, `has`, `get`,
`list`, `clear`.
- `registerManifest(registry, manifest)` bulk helper.
- `AUDIO_IR.*` error-code registry — exactly five codes, every one
reachable from a real production call site.
- OpenTelemetry surface — `audio_ir.registrations_total`,
`audio_ir.lookups_total`, `audio_ir.errors_total`,
`audio_ir.registry_size{family}`.
## Modules
| Module | Subpath | Docs |
| --- | --- | --- |
| Manifest entry schema | `./types` | [`src/types/manifest-entry.md`](./src/types/manifest-entry.md) |
| Manifest schema | `./types` | [`src/types/manifest.md`](./src/types/manifest.md) |
| `ImpulseResponse` schema | `./types` | [`src/types/impulse-response.md`](./src/types/impulse-response.md) |
| `AUDIO_IR.*` codes | `./error-codes` | [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) |
| In-memory registry | `./registry` | [`src/registry/registry.md`](./src/registry/registry.md) |
| `registerManifest` helper | `./registry` | [`src/registry/register-manifest.md`](./src/registry/register-manifest.md) |
| OTel instruments | `./otel` | [`src/otel/otel.md`](./src/otel/otel.md) |
| URI plugin registrar | `./registrar` | [`src/registrar/registrar.md`](./src/registrar/registrar.md) |
## Design
The cross-cutting design for the IR family lives in
That file is the source of truth for the family layering, ID derivation
rule, URI strategy, and OTel split.
## Quick install
This package is wave-0 foundation — depended on, not depending on, the
rest of the audio-ir family. Pull it directly:
```ts
import {
createIrRegistry,
IrManifestSchema,
registerManifest,
} from "@suaveplan/audio-ir-core";
const registry = createIrRegistry();
const manifest = IrManifestSchema.parse(rawManifestFromContentPackage);
registerManifest(registry, manifest);
```
## Composing IR content packages
Every `audio-ir-{family}-plugin-<name>` content package exposes a
`register(registry, collector, options?)` function. `collector` is a
`MutableUriPluginRegistrar` — a one-method object `{ use(plugin) }`. Pre-
construct a collector, call every content package's `register(...)`, then
hand the gathered `plugins` snapshot to `createStorageUriResolver` once:
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { register as registerClub } from "@suaveplan/audio-ir-room-plugin-club";
import { register as registerPool } from "@suaveplan/audio-ir-room-plugin-pool";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
// Each content package calls collector.use(plugin) for every bundle plugin.
registerPool(registry, collector);
registerClub(registry, collector);
// `collector.plugins` is a frozen snapshot — safe to hand off directly.
const resolver = createStorageUriResolver({ plugins: collector.plugins });
```
The collector is order-preserving and snapshot-frozen, so consumers can
compose multiple content packages — or fan out to multiple resolvers
(parent + inner, sharded, future builder) — without each content package
needing to agree on resolver shape. See
[`src/registrar/registrar.md`](./src/registrar/registrar.md) for the full
API reference.

View File

@@ -0,0 +1,90 @@
{
"name": "@suaveplan/audio-ir-core",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Foundation contract for the @suaveplan audio-ir family: ImpulseResponse / IrManifestEntry / IrManifest Zod schemas, in-memory IrRegistry, AUDIO_IR.* error-code registry, and OpenTelemetry surface. Universal; zero @suaveplan/audio-* runtime deps.",
"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"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/index.js"
}
},
"./registrar": {
"import": {
"types": "./dist/registrar/index.d.ts",
"default": "./dist/registrar/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/storage-uri": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@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,85 @@
# `error-codes` — `AUDIO_IR.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of every error code the `@suaveplan/audio-ir-core`
runtime is allowed to throw, plus the `AudioIrError` class that carries the
typed discriminants alongside each throw. The registry is built with
`defineCodes("AUDIO_IR", [...] as const)` from `@suaveplan/error`, which
freezes the resulting record and folds the namespace prefix in once so call
sites read as `AUDIO_IR_CODES.DUPLICATE_ID === "AUDIO_IR.DUPLICATE_ID"`.
The codes form the public contract between the registry and its operators:
log pipelines, alerting rules, and OTel attribute filters all key off the
`AUDIO_IR.*` strings. Adding a code is a spec change; renaming is a breaking
change. The exhaustion test in `error-codes.test.ts` guarantees every code
listed here is reachable from a real production call site.
## Features
- Exactly five frozen codes — `DUPLICATE_ID`, `NOT_REGISTERED`,
`INVALID_MANIFEST`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`.
- `AudioIrError` extends `SuaveplanError<AudioIrErrorCode>` so
`instanceof AudioIrError` narrows `code` to the literal union.
- Typed discriminants on the error instance: `id`, `manifestSlug`,
`manifestFamily`, `entryFamily`, `expectedPrefix`, `actualId`. Operators
filter on those properties — never on substring matches of the message.
- Plays nicely with `walkCauses` / `toSpanEvent` from `@suaveplan/error`.
## Usage: Basic
```ts
import { AUDIO_IR_CODES, AudioIrError } from "@suaveplan/audio-ir-core";
throw new AudioIrError(AUDIO_IR_CODES.NOT_REGISTERED, "audio_ir.not_registered", {
id: "club-club1",
});
```
## Usage: Advanced
```ts
import { AUDIO_IR_CODES, AudioIrError } from "@suaveplan/audio-ir-core";
try {
registry.register(entry, { manifestSlug: "club", manifestFamily: "room" });
} catch (err) {
if (err instanceof AudioIrError && err.code === AUDIO_IR_CODES.ID_PREFIX_MISMATCH) {
// err.expectedPrefix and err.actualId are typed `string | undefined`.
console.error({
code: err.code,
expectedPrefix: err.expectedPrefix,
actualId: err.actualId,
});
}
throw err;
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `AUDIO_IR_CODES` | `const` | Frozen `{ DUPLICATE_ID, NOT_REGISTERED, INVALID_MANIFEST, ID_PREFIX_MISMATCH, FAMILY_MISMATCH }` mapped to `"AUDIO_IR.<reason>"`. |
| `AudioIrErrorCode` | `type` | Union of the five fully-qualified code strings. |
| `AudioIrErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with typed discriminants (`id`, `manifestSlug`, `manifestFamily`, `entryFamily`, `expectedPrefix`, `actualId`). |
| `AudioIrError` | `class` | `SuaveplanError<AudioIrErrorCode>` subclass. Read-only typed properties surface every discriminant. |
## Implementation Notes
The registry deliberately omits the namespace inside `defineCodes` because
the helper folds it in; spelling `"AUDIO_IR.DUPLICATE_ID"` here would
double-prefix to `"AUDIO_IR.AUDIO_IR.DUPLICATE_ID"`. The exhaustion test
walks `Object.values(AUDIO_IR_CODES)` and asserts that each code is thrown
from a real production path inside the package — not from a test fixture.
This closes the gap where a code can be registered, leaked into the OTel
attribute vocabulary, and then never actually emitted.
`AudioIrError` does NOT make `id` mandatory because not every throw site has
one (`INVALID_MANIFEST` thrown from a malformed manifest object has no id).
Discriminants are typed `T | undefined` to keep the no-fallback honesty of
the wire-format contract.

View File

@@ -0,0 +1,178 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createIrRegistry, registerManifest } from "../registry/index.js";
import { IrManifestSchema } from "../types/manifest.js";
import {
AUDIO_IR_CODES,
AudioIrError,
type AudioIrErrorCode,
} from "./error-codes.js";
const VALID_ENTRY = {
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
};
function tryRegister(): AudioIrError | undefined {
const reg = createIrRegistry();
try {
reg.register(
{ ...VALID_ENTRY, id: "club--bad" },
{ manifestSlug: "club", manifestFamily: "room" }
);
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
function fixtureCallSite(code: AudioIrErrorCode): AudioIrError | undefined {
const reg = createIrRegistry();
if (code === AUDIO_IR_CODES.DUPLICATE_ID) {
reg.register(VALID_ENTRY, {
manifestSlug: "club",
manifestFamily: "room",
});
try {
reg.register(
{ ...VALID_ENTRY },
{ manifestSlug: "club", manifestFamily: "room" }
);
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
if (code === AUDIO_IR_CODES.NOT_REGISTERED) {
try {
reg.get("missing");
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
if (code === AUDIO_IR_CODES.INVALID_MANIFEST) {
try {
reg.register(
{
...VALID_ENTRY,
channels: -1,
} as unknown as typeof VALID_ENTRY,
{ manifestSlug: "club", manifestFamily: "room" }
);
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
if (code === AUDIO_IR_CODES.ID_PREFIX_MISMATCH) {
try {
reg.register(
{ ...VALID_ENTRY, id: "other-x" },
{ manifestSlug: "club", manifestFamily: "room" }
);
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
if (code === AUDIO_IR_CODES.FAMILY_MISMATCH) {
try {
reg.register(
{ ...VALID_ENTRY, family: "mic" },
{ manifestSlug: "club", manifestFamily: "room" }
);
} catch (err) {
if (err instanceof AudioIrError) return err;
}
return undefined;
}
return undefined;
}
describe("AUDIO_IR_CODES exhaustion", () => {
it("every AUDIO_IR code reachable from a real call site", {
timeout: 30000,
}, () => {
const all = Object.values(AUDIO_IR_CODES);
expect(all.length).toBe(5);
for (const code of all) {
const err = fixtureCallSite(code);
expect(err, `no real call site exercises ${code}`).toBeDefined();
expect(err?.code).toBe(code);
}
});
it("registry exposes exactly five codes", { timeout: 5000 }, () => {
const names = Object.keys(AUDIO_IR_CODES).sort();
expect(names).toEqual([
"DUPLICATE_ID",
"FAMILY_MISMATCH",
"ID_PREFIX_MISMATCH",
"INVALID_MANIFEST",
"NOT_REGISTERED",
]);
});
it("AUDIO_IR_CODES registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUDIO_IR_CODES)).toBe(true);
});
it("AudioIrError surfaces typed discriminants", { timeout: 5000 }, () => {
const err = tryRegister();
expect(err).toBeInstanceOf(AudioIrError);
expect(err?.code).toBe(AUDIO_IR_CODES.INVALID_MANIFEST);
expect(err?.name).toBe("AudioIrError");
expect(err?.manifestSlug).toBe("club");
expect(err?.manifestFamily).toBe("room");
});
it("AudioIrError with no options leaves discriminants undefined", {
timeout: 5000,
}, () => {
const err = new AudioIrError(
AUDIO_IR_CODES.NOT_REGISTERED,
"audio_ir.not_registered"
);
expect(err.id).toBeUndefined();
expect(err.manifestSlug).toBeUndefined();
expect(err.manifestFamily).toBeUndefined();
expect(err.entryFamily).toBeUndefined();
expect(err.expectedPrefix).toBeUndefined();
expect(err.actualId).toBeUndefined();
});
it("manifest path also surfaces ID_PREFIX_MISMATCH through registerManifest", {
timeout: 5000,
}, () => {
// First confirm IrManifestSchema rejects bad-prefix manifests
// at parse-time (the manifest layer's own refinement).
expect(() =>
IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [{ ...VALID_ENTRY, id: "other-1" }],
})
).toThrow();
// A manifest crafted by hand (skipping schema parse) reaches
// registerManifest -> registry.register -> ID_PREFIX_MISMATCH
// throw, exercising the same code from a second call site.
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
registerManifest(reg, {
packageSlug: "club",
family: "room",
entries: [{ ...VALID_ENTRY, id: "other-1" }],
} as unknown as Parameters<typeof registerManifest>[1]);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.ID_PREFIX_MISMATCH);
});
});

View File

@@ -0,0 +1,72 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of every error code raised by `@suaveplan/audio-ir-core`.
*
* Five codes total — `DUPLICATE_ID`, `NOT_REGISTERED`, `INVALID_MANIFEST`,
* `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`. Every code is reachable from a
* real production call site exercised by the test suite (see
* `error-codes.test.ts` for the enumeration). The exhaustion test
* guarantees we do not register a code that is never thrown.
*/
export const AUDIO_IR_CODES = defineCodes("AUDIO_IR", [
"DUPLICATE_ID",
"NOT_REGISTERED",
"INVALID_MANIFEST",
"ID_PREFIX_MISMATCH",
"FAMILY_MISMATCH",
] as const);
export type AudioIrErrorCode =
(typeof AUDIO_IR_CODES)[keyof typeof AUDIO_IR_CODES];
export interface AudioIrErrorOptions extends SuaveplanErrorOptions {
/** The IR identifier the operation targeted. */
id?: string;
/** The package slug the manifest declared. */
manifestSlug?: string;
/** The IR family the manifest declared. */
manifestFamily?: "mic" | "speaker" | "room";
/** The IR family the offending entry declared. */
entryFamily?: "mic" | "speaker" | "room";
/** The slug-prefix the registry expected (e.g. `"club-"`). */
expectedPrefix?: string;
/** The actual id the entry carried. */
actualId?: string;
}
/**
* Structured error for every `AUDIO_IR.*` throw.
*
* Every dimension an operator filters on is a typed discriminant — never
* parsed from message text. `id`, `manifestSlug`, `manifestFamily`,
* `entryFamily`, `expectedPrefix`, `actualId` are all surfaced as
* read-only properties.
*/
export class AudioIrError extends SuaveplanError<AudioIrErrorCode> {
public readonly id: string | undefined;
public readonly manifestSlug: string | undefined;
public readonly manifestFamily: "mic" | "speaker" | "room" | undefined;
public readonly entryFamily: "mic" | "speaker" | "room" | undefined;
public readonly expectedPrefix: string | undefined;
public readonly actualId: string | undefined;
public constructor(
code: AudioIrErrorCode,
message: string,
options?: AudioIrErrorOptions
) {
super(code, message, options);
this.id = options?.id;
this.manifestSlug = options?.manifestSlug;
this.manifestFamily = options?.manifestFamily;
this.entryFamily = options?.entryFamily;
this.expectedPrefix = options?.expectedPrefix;
this.actualId = options?.actualId;
this.name = "AudioIrError";
}
}

View File

@@ -0,0 +1,8 @@
export type {
AudioIrErrorCode,
AudioIrErrorOptions,
} from "./error-codes.js";
export {
AUDIO_IR_CODES,
AudioIrError,
} from "./error-codes.js";

View File

@@ -0,0 +1,46 @@
export {
AUDIO_IR_CODES,
AudioIrError,
type AudioIrErrorCode,
type AudioIrErrorOptions,
} from "./error-codes/error-codes.js";
export {
type AudioIrInstruments,
type AudioIrLookupOutcome,
type AudioIrRegisterOutcome,
getInstruments,
recordError,
recordLookup,
recordRegister,
resetInstruments,
setAudioIrMeterProvider,
setRegistrySizeSampler,
} from "./otel/otel.js";
export {
createUriPluginCollector,
type MutableUriPluginRegistrar,
type UriPluginCollector,
} from "./registrar/registrar.js";
export { registerManifest } from "./registry/register-manifest.js";
export {
createIrRegistry,
type IrListOptions,
type IrRegisterContext,
type IrRegistry,
} from "./registry/registry.js";
export {
type ImpulseResponse,
ImpulseResponseSchema,
} from "./types/impulse-response.js";
export {
type IrManifest,
IrManifestSchema,
IrManifestSlugSchema,
} from "./types/manifest.js";
export {
IrEntryIdSchema,
type IrFamily,
IrFamilySchema,
type IrManifestEntry,
IrManifestEntrySchema,
} from "./types/manifest-entry.js";

View File

@@ -0,0 +1,14 @@
export type {
AudioIrInstruments,
AudioIrLookupOutcome,
AudioIrRegisterOutcome,
} from "./otel.js";
export {
getInstruments,
recordError,
recordLookup,
recordRegister,
resetInstruments,
setAudioIrMeterProvider,
setRegistrySizeSampler,
} from "./otel.js";

View File

@@ -0,0 +1,107 @@
# `otel` — OpenTelemetry surface for `audio-ir-core`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns every span, counter, and observable gauge the registry emits. The
foundation is the layer that knows about the registry's shape, so it also
owns the OTel instruments that describe that shape — counters for the
write/read traffic (`registrations_total`, `lookups_total`), counter for
every typed throw (`errors_total{code}`), and an observable gauge for the
per-family registry size sampled on every scrape (`registry_size{family}`).
The loader and content packages own their own metric surfaces
(`audio_ir_loader.*` for decode duration, fallback hop, cache hit). The
two surfaces deliberately do not overlap.
## Features
- Lazy instrument construction — bound on first `getInstruments()` call,
cached thereafter.
- Injectable `MeterProvider` via `setAudioIrMeterProvider(provider)` —
tests install an in-memory recorder; production leaves it undefined and
the global `metrics` is used.
- Observable gauge with a pluggable sampler — the registry installs a
sampler that reports per-family counts on every scrape.
- `recordRegister` / `recordLookup` also start a span with stable
attribute keys (`audio_ir.family`, `audio_ir.outcome`).
- `recordError(code)` increments `audio_ir.errors_total{code}` for every
`AUDIO_IR.*` throw.
## Usage: Basic
```ts
import { recordRegister } from "@suaveplan/audio-ir-core/otel";
recordRegister("room", "success");
```
## Usage: Advanced
```ts
import {
type MeterProvider,
metrics,
} from "@opentelemetry/api";
import {
getInstruments,
setAudioIrMeterProvider,
setRegistrySizeSampler,
} from "@suaveplan/audio-ir-core/otel";
// In tests: install an in-memory recorder MeterProvider so spec
// assertions can see exact counter values.
const recorded: Array<{ name: string; value: number; attributes: unknown }> = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attributes?: Record<string, unknown>) => {
recorded.push({ name, value, attributes });
},
}),
createObservableGauge: (name: string) => ({
addCallback: () => undefined,
}),
}),
} as unknown as MeterProvider;
setAudioIrMeterProvider(provider);
setRegistrySizeSampler(() => new Map([["room", 2]]));
getInstruments(true);
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `setAudioIrMeterProvider(provider)` | function | Override the provider used to build instruments. Resets the cache. |
| `setRegistrySizeSampler(sampler)` | function | Install the sampler the observable gauge calls on collection. |
| `getInstruments(force?)` | function | Lazily build (or return cached) the full instrument bag. |
| `resetInstruments()` | function | Drop the cache — used by tests between provider swaps. |
| `recordRegister(family, outcome)` | function | Increment `audio_ir.registrations_total` + start a `audio_ir.register` span. |
| `recordLookup(family, outcome, op)` | function | Increment `audio_ir.lookups_total` + start a `audio_ir.get` or `audio_ir.has` span. |
| `recordError(code)` | function | Increment `audio_ir.errors_total{code}` for every typed throw. |
| `AudioIrInstruments` | interface | Read-only struct of every instrument the package emits. |
| `AudioIrRegisterOutcome` | type | `"success" \| "error"`. |
| `AudioIrLookupOutcome` | type | `"hit" \| "miss"`. |
## Implementation Notes
The observable gauge does not retain a snapshot of the registry — it calls
the installed sampler on every scrape. The registry installs a sampler
that wraps a live counter `Map<IrFamily, number>` it maintains on
`register` / `clear`. That keeps the sampler O(1) and avoids the gauge
ever lagging behind the actual registry state.
`setAudioIrMeterProvider(undefined)` reverts to the global `metrics`
provider AND clears the cached instrument bag, so subsequent calls
rebuild against the global. Tests must call this in cleanup or the next
test inherits the stub.
The injected provider in tests IS a real `MeterProvider` implementation
(it satisfies the public interface from `@opentelemetry/api`) — it is not
a mock function. The recorder bookkeeping is the implementation. This
honours the no-mocks rule while keeping the assertion surface narrow
enough to write maintainable tests.

View File

@@ -0,0 +1,272 @@
import type { MeterProvider } from "@opentelemetry/api";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_IR_CODES, AudioIrError } from "../error-codes/error-codes.js";
import { createIrRegistry } from "../registry/registry.js";
import {
getInstruments,
recordError,
recordLookup,
recordRegister,
resetInstruments,
setAudioIrMeterProvider,
setRegistrySizeSampler,
} from "./otel.js";
interface CounterObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface GaugeObs {
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface Recorder {
readonly counters: CounterObs[];
readonly gauges: GaugeObs[];
sampleGauge(): void;
}
/**
* Real in-memory `MeterProvider` implementation. No mocks — every method
* on the public OTel API surface is implemented with deterministic
* in-memory bookkeeping. Tests install this with
* `setAudioIrMeterProvider`, then call `sampleGauge()` to fire the
* observable-gauge callback explicitly.
*/
function installRecorder(): Recorder {
const counters: CounterObs[] = [];
const gauges: GaugeObs[] = [];
type GaugeCallback = (result: {
observe: (value: number, attrs?: Record<string, unknown>) => void;
}) => void;
const gaugeCallbacks: GaugeCallback[] = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({ name, value, attributes: attrs ?? {} });
},
}),
createObservableGauge: (_name: string) => ({
addCallback: (cb: GaugeCallback) => {
gaugeCallbacks.push(cb);
},
}),
}),
} as unknown as MeterProvider;
setAudioIrMeterProvider(provider);
getInstruments(true);
return {
counters,
gauges,
sampleGauge() {
for (const cb of gaugeCallbacks) {
cb({
observe: (value, attrs) =>
gauges.push({ value, attributes: attrs ?? {} }),
});
}
},
};
}
function uninstallRecorder(): void {
setAudioIrMeterProvider(undefined);
resetInstruments();
setRegistrySizeSampler(undefined);
}
describe("audio-ir-core OTel surface", () => {
afterEach(() => {
uninstallRecorder();
});
it("increments registrations_total on successful register", {
timeout: 5000,
}, () => {
const rec = installRecorder();
const reg = createIrRegistry();
reg.register(
{
id: "club-c1",
family: "room",
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "club", manifestFamily: "room" }
);
const hit = rec.counters.find(
(c) =>
c.name === "audio_ir.registrations_total" &&
c.attributes["family"] === "room" &&
c.attributes["outcome"] === "success"
);
expect(hit).toBeDefined();
expect(hit?.value).toBe(1);
});
it("increments errors_total on every AUDIO_IR throw", {
timeout: 5000,
}, () => {
const rec = installRecorder();
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
reg.get("nope");
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.NOT_REGISTERED);
const hit = rec.counters.find(
(c) =>
c.name === "audio_ir.errors_total" &&
c.attributes["code"] === AUDIO_IR_CODES.NOT_REGISTERED
);
expect(hit).toBeDefined();
});
it("registry_size gauge reports per-family counts", {
timeout: 5000,
}, () => {
const rec = installRecorder();
const reg = createIrRegistry();
reg.register(
{
id: "club-r1",
family: "room",
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "club", manifestFamily: "room" }
);
reg.register(
{
id: "club-r2",
family: "room",
uri: "bundle://x/z.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "club", manifestFamily: "room" }
);
reg.register(
{
id: "sm57-m1",
family: "mic",
uri: "bundle://x/m.wav",
channels: 1,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "sm57", manifestFamily: "mic" }
);
rec.sampleGauge();
const room = rec.gauges.find((g) => g.attributes["family"] === "room");
const mic = rec.gauges.find((g) => g.attributes["family"] === "mic");
expect(room?.value).toBe(2);
expect(mic?.value).toBe(1);
});
it("lookup hit increments lookups_total with family + outcome=hit", {
timeout: 5000,
}, () => {
const rec = installRecorder();
const reg = createIrRegistry();
reg.register(
{
id: "club-r1",
family: "room",
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "club", manifestFamily: "room" }
);
reg.get("club-r1");
reg.has("club-r1");
const hits = rec.counters.filter(
(c) =>
c.name === "audio_ir.lookups_total" &&
c.attributes["outcome"] === "hit" &&
c.attributes["family"] === "room"
);
// Both has() and get() emit a hit.
expect(hits.length).toBeGreaterThanOrEqual(2);
});
it("lookup miss increments lookups_total with family=unknown, outcome=miss", {
timeout: 5000,
}, () => {
const rec = installRecorder();
const reg = createIrRegistry();
expect(reg.has("nope")).toBe(false);
const miss = rec.counters.find(
(c) =>
c.name === "audio_ir.lookups_total" &&
c.attributes["outcome"] === "miss" &&
c.attributes["family"] === "unknown"
);
expect(miss).toBeDefined();
});
it("gauge with no sampler installed emits no observations", {
timeout: 5000,
}, () => {
const rec = installRecorder();
setRegistrySizeSampler(undefined);
rec.sampleGauge();
expect(rec.gauges).toEqual([]);
});
it("getInstruments returns cached instance without force", {
timeout: 5000,
}, () => {
installRecorder();
const a = getInstruments();
const b = getInstruments();
expect(a).toBe(b);
});
it("setAudioIrMeterProvider(undefined) reverts to global provider", {
timeout: 5000,
}, () => {
installRecorder();
setAudioIrMeterProvider(undefined);
// Subsequent recordRegister still works (no-op meter).
recordRegister("room", "success");
recordLookup("room", "hit", "get");
recordError(AUDIO_IR_CODES.NOT_REGISTERED);
});
it("recordRegister with outcome=error increments the counter", {
timeout: 5000,
}, () => {
const rec = installRecorder();
recordRegister("speaker", "error");
const hit = rec.counters.find(
(c) =>
c.name === "audio_ir.registrations_total" &&
c.attributes["family"] === "speaker" &&
c.attributes["outcome"] === "error"
);
expect(hit).toBeDefined();
});
});

View File

@@ -0,0 +1,138 @@
import {
type Counter,
type Meter,
type MeterProvider,
metrics,
type ObservableGauge,
type Tracer,
trace,
} from "@opentelemetry/api";
import type { IrFamily } from "../types/manifest-entry.js";
const METER_NAME = "@suaveplan/audio-ir-core";
const TRACER_NAME = "@suaveplan/audio-ir-core";
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 + gauge sampler bindings so the next access binds to
* the new provider.
*/
export function setAudioIrMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
gaugeSampler = undefined;
}
export type AudioIrRegisterOutcome = "success" | "error";
export type AudioIrLookupOutcome = "hit" | "miss";
export interface AudioIrInstruments {
readonly tracer: Tracer;
readonly meter: Meter;
readonly registrationsTotal: Counter;
readonly lookupsTotal: Counter;
readonly errorsTotal: Counter;
readonly registrySize: ObservableGauge;
}
let cachedInstruments: AudioIrInstruments | undefined;
let gaugeSampler: (() => ReadonlyMap<IrFamily, number>) | undefined;
/**
* Register the per-family size sampler the observable gauge calls on
* collection. The registry installs this when it builds its instrument
* bag; tests may also install a fixed-value sampler.
*/
export function setRegistrySizeSampler(
sampler: (() => ReadonlyMap<IrFamily, number>) | undefined
): void {
gaugeSampler = sampler;
}
function buildGauge(meter: Meter): ObservableGauge {
const gauge = meter.createObservableGauge("audio_ir.registry_size", {
description: "Current registry size per family (mic|speaker|room)",
});
gauge.addCallback((result) => {
if (gaugeSampler === undefined) {
// No sampler installed yet — emit a single zero observation so
// the gauge always has a sample on scrape.
return;
}
const counts = gaugeSampler();
for (const [family, count] of counts) {
result.observe(count, { family });
}
});
return gauge;
}
/** Lazily build (or return cached) instruments bound to the active provider. */
export function getInstruments(force = false): AudioIrInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
registrationsTotal: meter.createCounter(
"audio_ir.registrations_total",
{
description:
"IrRegistry.register calls; labels: family, outcome",
}
),
lookupsTotal: meter.createCounter("audio_ir.lookups_total", {
description:
"IrRegistry.get / IrRegistry.has calls; labels: family, outcome",
}),
errorsTotal: meter.createCounter("audio_ir.errors_total", {
description:
"Every AUDIO_IR.* throw; labels: code (fully-qualified AUDIO_IR.<reason>)",
}),
registrySize: buildGauge(meter),
};
return cachedInstruments;
}
/** Reset cached instruments — used by tests between provider swaps. */
export function resetInstruments(): void {
cachedInstruments = undefined;
}
/** Increment `audio_ir.registrations_total{family, outcome}`. */
export function recordRegister(
family: IrFamily,
outcome: AudioIrRegisterOutcome
): void {
const inst = getInstruments();
inst.registrationsTotal.add(1, { family, outcome });
const span = inst.tracer.startSpan("audio_ir.register", {
attributes: { "audio_ir.family": family, "audio_ir.outcome": outcome },
});
span.end();
}
/** Increment `audio_ir.lookups_total{family, outcome}`. */
export function recordLookup(
family: IrFamily | "unknown",
outcome: AudioIrLookupOutcome,
op: "get" | "has"
): void {
const inst = getInstruments();
inst.lookupsTotal.add(1, { family, outcome });
const span = inst.tracer.startSpan(`audio_ir.${op}`, {
attributes: { "audio_ir.family": family, "audio_ir.outcome": outcome },
});
span.end();
}
/** Increment `audio_ir.errors_total{code}`. */
export function recordError(code: string): void {
getInstruments().errorsTotal.add(1, { code });
}

View File

@@ -0,0 +1,5 @@
export type {
MutableUriPluginRegistrar,
UriPluginCollector,
} from "./registrar.js";
export { createUriPluginCollector } from "./registrar.js";

View File

@@ -0,0 +1,110 @@
# `registrar` — `MutableUriPluginRegistrar` + `createUriPluginCollector()`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MutableUriPluginRegistrar` is the canonical collector interface every
`audio-ir-{family}-plugin-<name>` content package targets when registering
its bundled URI plugins. Stage-3 ship of `audio-ir-room-plugin-pool`
discovered that `StorageUriResolver` is frozen at construction — there is
no `resolver.use(plugin)` after construction — so content packages need a
mutable seam to deposit URI plugins during their `register(...)` call. This
module promotes that seam from the pool's local copy into the
`audio-ir-core` foundation so every content package, present and future,
targets exactly one shape.
The companion `createUriPluginCollector()` factory wraps an internal
ordered array and exposes a frozen snapshot via the `plugins` getter, which
plugs directly into `createStorageUriResolver({ plugins })`. The snapshot
is rebuilt on every read so a stale read remains immutable even after
additional `use(...)` calls.
## Features
- `MutableUriPluginRegistrar` — single-method interface
(`use(plugin: UriPlugin): void`) decoupled from `StorageUriResolver` so
consumers may compose plugin lists across multiple content packages
before building one resolver — or a parent + inner resolver, or a future
builder.
- `createUriPluginCollector()` — factory returning
`MutableUriPluginRegistrar & { readonly plugins: readonly UriPlugin[] }`.
`use()` pushes (insertion-order preserving); `plugins` returns a fresh
`Object.freeze([...store])` snapshot on every read.
- Zero runtime dependencies beyond the `UriPlugin` TYPE import from
`@suaveplan/storage-uri`. Universal; no browser or Node surface.
## Usage: Basic
```ts
import {
createIrRegistry,
createUriPluginCollector,
registerManifest,
} from "@suaveplan/audio-ir-core";
import { register as registerPoolBundle } from "@suaveplan/audio-ir-room-plugin-pool";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
registerPoolBundle(registry, collector);
const resolver = createStorageUriResolver({ plugins: collector.plugins });
```
## Usage: Advanced
Compose multiple content packages, validate snapshot immutability, and
keep an audit copy of plugins gathered at each stage:
```ts
import {
createUriPluginCollector,
type MutableUriPluginRegistrar,
} from "@suaveplan/audio-ir-core/registrar";
import { register as registerClub } from "@suaveplan/audio-ir-room-plugin-club";
import { register as registerPool } from "@suaveplan/audio-ir-room-plugin-pool";
const collector = createUriPluginCollector();
registerPool(registry, collector);
const afterPool = collector.plugins; // frozen snapshot — survives further use()
registerClub(registry, collector);
const afterClub = collector.plugins; // new frozen snapshot, contains both bundles
// Adapt an arbitrary sink:
const externalSink: MutableUriPluginRegistrar = {
use: (p) => myObservableArray.push(p),
};
registerPool(registry, externalSink);
```
## API Reference
- `interface MutableUriPluginRegistrar`
- `use(plugin: UriPlugin): void` — append a URI plugin to the underlying
collection. Implementations MUST preserve call order.
- `interface UriPluginCollector extends MutableUriPluginRegistrar`
- `readonly plugins: readonly UriPlugin[]` — frozen snapshot of plugins
pushed via `use`. A new frozen array is allocated on every read.
- `function createUriPluginCollector(): UriPluginCollector` — fresh
collector backed by a private `UriPlugin[]`. `use()` pushes;
`plugins` returns `Object.freeze([...store])`.
## Implementation Notes
- The `plugins` accessor returns a defensive copy. Strict-mode callers
attempting `(c.plugins as UriPlugin[]).push(extra)` will receive a
`TypeError` because the snapshot is frozen.
- A previously-read snapshot does NOT reflect plugins added after the
read — that is the whole point of returning a new frozen array each
time. Subsequent `use(...)` calls show up only in subsequent `plugins`
reads.
- The interface does NOT extend `StorageUriResolver` and does NOT touch
any resolver-shaped type. Keeping the collector resolver-free is what
allows multi-resolver composition (parent + inner, multiple sinks,
future builders) without forcing one shape on every consumer.
- See the package `README.md` `## Composing IR content packages` section
for the canonical recipe.

View File

@@ -0,0 +1,98 @@
import type { ParsedUri, UriPlugin } from "@suaveplan/storage-uri";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { StorageLike } from "@suaveplan/types";
import * as rootBarrel from "../index.js";
import * as registrarSubpath from "./index.js";
import {
createUriPluginCollector,
type MutableUriPluginRegistrar,
} from "./registrar.js";
const noopAdapter: StorageLike = {
get: () => Promise.resolve(undefined),
set: () => Promise.resolve(),
delete: () => Promise.resolve(false),
exists: () => Promise.resolve(false),
list: () => Promise.resolve({ keys: [], total: 0, hasMore: false }),
clear: () => Promise.resolve(),
};
function plugin(scheme: string): UriPlugin {
return {
scheme,
adapter: noopAdapter,
parse: (uri: string): ParsedUri => ({ key: uri }),
};
}
describe("MutableUriPluginRegistrar spec-conformance", () => {
it("MutableUriPluginRegistrar accepts a single-method collector object", {
timeout: 5000,
}, () => {
const pluginArray: UriPlugin[] = [];
const target: MutableUriPluginRegistrar = {
use: (p) => {
pluginArray.push(p);
},
};
// Simulate a content-package register() that invokes target.use once
// per bundled plugin.
const bundle = [plugin("a"), plugin("b")];
for (const p of bundle) {
target.use(p);
}
expect(pluginArray).toEqual(bundle);
// Type-level: the interface shape is the one-method object we expect.
expectTypeOf<MutableUriPluginRegistrar>().toEqualTypeOf<{
use(plugin: UriPlugin): void;
}>();
expectTypeOf<MutableUriPluginRegistrar["use"]>()
.parameter(0)
.toEqualTypeOf<UriPlugin>();
expectTypeOf<
MutableUriPluginRegistrar["use"]
>().returns.toEqualTypeOf<void>();
});
it("MutableUriPluginRegistrar is exported from the root barrel", {
timeout: 5000,
}, () => {
// Type-level: importing the type from the root barrel yields the same
// shape as the local import.
expectTypeOf<rootBarrel.MutableUriPluginRegistrar>().toEqualTypeOf<MutableUriPluginRegistrar>();
// Value-level: createUriPluginCollector returns an object that the
// interface accepts.
const c = rootBarrel.createUriPluginCollector();
const asTarget: MutableUriPluginRegistrar = c;
asTarget.use(plugin("x"));
expect(c.plugins).toHaveLength(1);
});
it("root barrel re-exports MutableUriPluginRegistrar and createUriPluginCollector", {
timeout: 5000,
}, () => {
expect(typeof rootBarrel.createUriPluginCollector).toBe("function");
// Same reference as the feature-local export.
expect(rootBarrel.createUriPluginCollector).toBe(
createUriPluginCollector
);
// Type-level: both symbols compile from the root barrel.
expectTypeOf(rootBarrel.createUriPluginCollector).toEqualTypeOf<
typeof createUriPluginCollector
>();
expectTypeOf<rootBarrel.MutableUriPluginRegistrar>().toEqualTypeOf<MutableUriPluginRegistrar>();
});
it("./registrar subpath resolves to the same references as the root barrel", {
timeout: 5000,
}, () => {
expect(registrarSubpath.createUriPluginCollector).toBe(
rootBarrel.createUriPluginCollector
);
expect(registrarSubpath.createUriPluginCollector).toBe(
createUriPluginCollector
);
expectTypeOf<registrarSubpath.MutableUriPluginRegistrar>().toEqualTypeOf<MutableUriPluginRegistrar>();
expectTypeOf<registrarSubpath.MutableUriPluginRegistrar>().toEqualTypeOf<rootBarrel.MutableUriPluginRegistrar>();
});
});

View File

@@ -0,0 +1,93 @@
import type { ParsedUri, UriPlugin } from "@suaveplan/storage-uri";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { StorageLike } from "@suaveplan/types";
import { createUriPluginCollector } from "./registrar.js";
const noopAdapter: StorageLike = {
get: () => Promise.resolve(undefined),
set: () => Promise.resolve(),
delete: () => Promise.resolve(false),
exists: () => Promise.resolve(false),
list: () => Promise.resolve({ keys: [], total: 0, hasMore: false }),
clear: () => Promise.resolve(),
};
function plugin(scheme: string): UriPlugin {
return {
scheme,
adapter: noopAdapter,
parse: (uri: string): ParsedUri => ({ key: uri }),
};
}
describe("createUriPluginCollector()", () => {
it("collector preserves insertion order across use() calls", {
timeout: 5000,
}, () => {
const c = createUriPluginCollector();
const pA = plugin("a");
const pB = plugin("b");
const pC = plugin("c");
c.use(pA);
c.use(pB);
c.use(pC);
expect(c.plugins).toEqual([pA, pB, pC]);
// Referential equality on each entry.
expect(c.plugins[0]).toBe(pA);
expect(c.plugins[1]).toBe(pB);
expect(c.plugins[2]).toBe(pC);
});
it("plugins snapshot is frozen and rejects mutation", {
timeout: 5000,
}, () => {
const c = createUriPluginCollector();
c.use(plugin("a"));
const snapshot = c.plugins;
expect(Object.isFrozen(snapshot)).toBe(true);
expect(() => {
(snapshot as UriPlugin[]).push(plugin("x"));
}).toThrow(TypeError);
});
it("use() after plugins read reflects in the next snapshot", {
timeout: 5000,
}, () => {
const c = createUriPluginCollector();
const pA = plugin("a");
const pB = plugin("b");
const pD = plugin("d");
c.use(pA);
c.use(pB);
const first = c.plugins;
c.use(pD);
const second = c.plugins;
// First snapshot is unaffected (frozen + defensive copy).
expect(first).toEqual([pA, pB]);
expect(Object.isFrozen(first)).toBe(true);
// Second snapshot reflects the new push.
expect(second).toEqual([pA, pB, pD]);
expect(second).not.toBe(first);
});
it("returns a fresh frozen array on every plugins read", {
timeout: 5000,
}, () => {
const c = createUriPluginCollector();
c.use(plugin("a"));
const r1 = c.plugins;
const r2 = c.plugins;
expect(r1).not.toBe(r2);
expect(r1).toEqual(r2);
expect(Object.isFrozen(r1)).toBe(true);
expect(Object.isFrozen(r2)).toBe(true);
});
it("empty collector exposes an empty frozen snapshot", {
timeout: 5000,
}, () => {
const c = createUriPluginCollector();
expect(c.plugins).toEqual([]);
expect(Object.isFrozen(c.plugins)).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import type { UriPlugin } from "@suaveplan/storage-uri";
/**
* Canonical collector interface for IR content packages.
*
* `audio-ir-{family}-plugin-<name>` content packages expose a
* `register(registry, target, options?)` function that calls
* `target.use(plugin)` once per bundled URI plugin. The collector pattern is
* deliberately decoupled from `StorageUriResolver` construction so callers
* may compose plugin lists across multiple content packages before building
* a single resolver (or a parent + inner resolver, or a future builder).
*/
export interface MutableUriPluginRegistrar {
/**
* Append a URI plugin to the underlying collection. Implementations MUST
* preserve call order.
*/
use(plugin: UriPlugin): void;
}
/**
* Collector returned by {@link createUriPluginCollector}. Adds a read-only
* `plugins` accessor that returns a defensive frozen snapshot of the current
* list — suitable for passing directly to `createStorageUriResolver({ plugins })`.
*/
export interface UriPluginCollector extends MutableUriPluginRegistrar {
/** Frozen snapshot of plugins pushed via {@link MutableUriPluginRegistrar.use}. */
readonly plugins: readonly UriPlugin[];
}
/**
* Build a fresh ordered collector for URI plugins.
*
* Internal storage is a private array. {@link MutableUriPluginRegistrar.use}
* pushes onto it (preserving insertion order). The `plugins` getter returns a
* NEW frozen snapshot on every read, so callers cannot mutate the collector's
* internal state after reading and a stale snapshot remains immutable even if
* additional plugins are appended later.
*
* @example
* ```ts
* const collector = createUriPluginCollector();
* registerPoolBundle(registry, collector);
* registerClubBundle(registry, collector);
* const resolver = createStorageUriResolver({ plugins: collector.plugins });
* ```
*/
export function createUriPluginCollector(): UriPluginCollector {
const store: UriPlugin[] = [];
return {
use(plugin: UriPlugin): void {
store.push(plugin);
},
get plugins(): readonly UriPlugin[] {
return Object.freeze([...store]);
},
};
}

View File

@@ -0,0 +1,7 @@
export { registerManifest } from "./register-manifest.js";
export {
createIrRegistry,
type IrListOptions,
type IrRegisterContext,
type IrRegistry,
} from "./registry.js";

View File

@@ -0,0 +1,86 @@
# `registry/register-manifest` — bulk-register helper
## Purpose
Thin iterator over `manifest.entries` that calls `registry.register(entry,
{ manifestSlug: manifest.packageSlug, manifestFamily: manifest.family })`
for each one. The helper exists so content packages don't have to repeat
the context wiring at every call site — they parse a manifest, hand it +
the registry to `registerManifest`, and trust the throw surface.
The helper deliberately does NOT wrap the iteration in a try/rollback
block. If entry N fails, entries 0..N-1 remain registered. Callers that
need atomicity should run against a fresh `createIrRegistry()` per
attempt and only swap it in once registration finishes. This is the
intended workflow for content packages — install once at module load,
fail fast if the manifest is broken — and means a re-run after fixing a
broken manifest hits `AUDIO_IR.DUPLICATE_ID` on the partial entries,
surfacing the conflict immediately.
## Features
- Single-pass iteration in manifest order.
- Re-throws the original `AudioIrError` verbatim — no wrapping, no loss
of typed discriminants.
- No rollback on partial failure — fail-fast semantics.
## Usage: Basic
```ts
import { createIrRegistry, registerManifest } from "@suaveplan/audio-ir-core";
const registry = createIrRegistry();
const manifest = {
packageSlug: "club",
family: "room" as const,
entries: [/* ... two or more entries ... */],
};
registerManifest(registry, manifest);
```
## Usage: Advanced
```ts
import {
AUDIO_IR_CODES,
AudioIrError,
createIrRegistry,
registerManifest,
} from "@suaveplan/audio-ir-core";
const stagedRegistry = createIrRegistry();
try {
registerManifest(stagedRegistry, untrustedManifest);
} catch (err) {
if (err instanceof AudioIrError && err.code === AUDIO_IR_CODES.DUPLICATE_ID) {
console.error(`duplicate id while staging: ${err.id}`);
}
throw err;
}
// Only swap into production after the staged registry has fully accepted.
publishRegistry(stagedRegistry);
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `registerManifest(registry, manifest)` | function | Iterate `manifest.entries` and `register` each one with manifest-derived context. |
## Implementation Notes
The helper is one for-loop. Resist the temptation to add a "validate
first, register second" pre-pass — `IrManifestSchema.parse` already
runs ID-prefix and family-coherence refinements at parse time, so
manifests that round-tripped through `IrManifestSchema.parse` cannot
fail those checks here. The only failure mode left for parsed
manifests is `DUPLICATE_ID`, and that one is intrinsically per-entry.
For manifests passed in without going through the schema (e.g. a
fixture literal in a test), `registry.register` re-runs the
entry-level schema and the registry-level checks, so the throw
surface is still the typed `AUDIO_IR.*` vocabulary.
The helper does not call `setRegistrySizeSampler` itself — that
happens once in `createIrRegistry`. The gauge therefore reflects the
post-registration counts at every scrape after this call.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_IR_CODES, AudioIrError } from "../error-codes/error-codes.js";
import type { IrManifest } from "../types/manifest.js";
import { registerManifest } from "./register-manifest.js";
import { createIrRegistry } from "./registry.js";
const ENTRY = (overrides: Record<string, unknown> = {}) => ({
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
...overrides,
});
describe("registerManifest", () => {
it("registers every entry in a well-formed manifest", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const eA = ENTRY({ id: "club-a" });
const eB = ENTRY({ id: "club-b" });
registerManifest(reg, {
packageSlug: "club",
family: "room",
entries: [eA, eB],
} as IrManifest);
expect(reg.has(eA.id)).toBe(true);
expect(reg.has(eB.id)).toBe(true);
const rooms = reg.list({ family: "room" });
expect(rooms.map((e) => e.id).sort()).toEqual(["club-a", "club-b"]);
});
it("leaves registry partially populated on mid-manifest failure", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const eA = ENTRY({ id: "club-a" });
const badEntry = ENTRY({ id: "other-1" });
const eC = ENTRY({ id: "club-c" });
let captured: AudioIrError | undefined;
try {
registerManifest(reg, {
packageSlug: "club",
family: "room",
entries: [eA, badEntry, eC],
} as IrManifest);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.ID_PREFIX_MISMATCH);
expect(reg.has(eA.id)).toBe(true);
expect(reg.has(eC.id)).toBe(false);
});
it("an empty manifest leaves the registry empty", { timeout: 5000 }, () => {
const reg = createIrRegistry();
registerManifest(reg, {
packageSlug: "club",
family: "room",
entries: [],
} as unknown as IrManifest);
expect(reg.list()).toEqual([]);
});
});

View File

@@ -0,0 +1,24 @@
import type { IrManifest } from "../types/manifest.js";
import type { IrRegistry } from "./registry.js";
/**
* Register every entry in `manifest` against `registry`.
*
* On any failure (`DUPLICATE_ID`, `ID_PREFIX_MISMATCH`,
* `FAMILY_MISMATCH`, or `INVALID_MANIFEST`) the helper rethrows the
* original `AudioIrError` verbatim WITHOUT rolling back prior
* registrations — the registry is allowed to be partially populated.
* Callers that need atomicity SHOULD use a fresh `createIrRegistry()`
* per attempt.
*/
export function registerManifest(
registry: IrRegistry,
manifest: IrManifest
): void {
for (const entry of manifest.entries) {
registry.register(entry, {
manifestSlug: manifest.packageSlug,
manifestFamily: manifest.family,
});
}
}

View File

@@ -0,0 +1,115 @@
# `registry` — `createIrRegistry()` + `IrRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory `Map<string, IrManifestEntry>` registry — the central seam
between content packages (which register entries) and the loader (which
looks them up). The registry is the only place that enforces the
runtime invariants the schema cannot: duplicate-id detection, ID-prefix
coherence against the registering manifest, family coherence between
entry and manifest, and the typed `AUDIO_IR.*` throw vocabulary. Every
mutation drives the OTel surface (counters + observable gauge).
The registry holds **manifest entries only**, not decoded
`ImpulseResponse` values. Decoding happens lazily in
`audio-ir-loader.load(id)` — keeping the registry data-only means
content packages can be tree-shaken at the manifest level and decoders
are pulled in only on demand. See `design.md` of the
`add-audio-ir-core` change for the cross-cutting model.
## Features
- `createIrRegistry()` returns a fresh `IrRegistry` and installs a
per-family size sampler with the OTel module — the
`audio_ir.registry_size{family}` gauge reflects this registry's counts.
- `register(entry, { manifestSlug, manifestFamily })` — validates the
entry, enforces ID-prefix + family-coherence, then inserts. Throws
`AUDIO_IR.INVALID_MANIFEST`, `AUDIO_IR.ID_PREFIX_MISMATCH`,
`AUDIO_IR.FAMILY_MISMATCH`, or `AUDIO_IR.DUPLICATE_ID` with typed
discriminants.
- `has(id) / get(id)` — O(1) lookup. `get` throws
`AUDIO_IR.NOT_REGISTERED` when absent.
- `list({ family? })` — stable insertion-order enumeration.
- `clear()` — wipes state. Provided for tests.
- Every throw increments `audio_ir.errors_total{code}` via
`recordError`. Every success increments
`audio_ir.registrations_total` / `audio_ir.lookups_total`.
## Usage: Basic
```ts
import { createIrRegistry } from "@suaveplan/audio-ir-core";
const registry = createIrRegistry();
registry.register(
{
id: "club-club1",
family: "room",
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
{ manifestSlug: "club", manifestFamily: "room" }
);
console.log(registry.has("club-club1")); // true
console.log(registry.get("club-club1").id); // "club-club1"
```
## Usage: Advanced
```ts
import { AUDIO_IR_CODES, AudioIrError, createIrRegistry } from "@suaveplan/audio-ir-core";
const registry = createIrRegistry();
function safeRegister(entry: unknown, ctx: { manifestSlug: string; manifestFamily: "room" | "mic" | "speaker" }): "ok" | "duplicate" | "rejected" {
try {
registry.register(entry as Parameters<typeof registry.register>[0], ctx);
return "ok";
} catch (err) {
if (err instanceof AudioIrError) {
if (err.code === AUDIO_IR_CODES.DUPLICATE_ID) return "duplicate";
return "rejected";
}
throw err;
}
}
const rooms = registry.list({ family: "room" });
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `createIrRegistry()` | function | Build a fresh `IrRegistry` and install its size sampler. |
| `IrRegistry` | interface | `register`, `has`, `get`, `list`, `clear` methods. |
| `IrRegisterContext` | interface | `{ manifestSlug: string; manifestFamily: IrFamily }` — required at register time. |
| `IrListOptions` | interface | `{ family?: IrFamily }` — optional list filter. |
## Implementation Notes
`register` re-runs `IrManifestEntrySchema.parse` on every input that
isn't already frozen. This means content packages that hand-craft a
`IrManifestEntry` literal still get the full grammar guarantees — the
schema is the single source of truth. Frozen entries (already produced
by `IrManifestSchema.parse` upstream) are inserted by reference so the
spec's `registry.get(id) === entry` identity invariant holds.
The registry maintains a private `familyCounts: Map<IrFamily, number>`
on every `register` / `clear` and exposes it to the OTel observable
gauge via the sampler hook installed in `createIrRegistry`. That keeps
the gauge O(1) per scrape and impossible to drift from the actual
registry state.
`register` failures do NOT short-circuit on the first issue — Zod issues
are walked, the family-coherence and id-prefix checks happen even when
the schema accepted the entry (callers wiring through
`IrManifestSchema.parse` already hit those refinements), and the duplicate
check fires only when the entry would otherwise have been accepted.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_IR_CODES, AudioIrError } from "../error-codes/error-codes.js";
import { createIrRegistry } from "./registry.js";
const E = (overrides: Record<string, unknown> = {}) => ({
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
...overrides,
});
const CTX = { manifestSlug: "club", manifestFamily: "room" as const };
describe("IrRegistry spec-conformance", () => {
it("preserves insertion order under interleaved register / list", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const ids = ["club-a", "club-b", "club-c", "club-d", "club-e"];
for (const id of ids) {
reg.register(E({ id }), CTX);
}
expect(reg.list().map((e) => e.id)).toEqual(ids);
// Interleave: a partial list mid-insert.
const reg2 = createIrRegistry();
reg2.register(E({ id: "club-a" }), CTX);
const snapshot = reg2.list().map((e) => e.id);
reg2.register(E({ id: "club-b" }), CTX);
expect(snapshot).toEqual(["club-a"]);
expect(reg2.list().map((e) => e.id)).toEqual(["club-a", "club-b"]);
});
it("is idempotent under repeated has() without mutating order", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
reg.register(E({ id: "club-a" }), CTX);
reg.register(E({ id: "club-b" }), CTX);
for (let i = 0; i < 10; i++) {
expect(reg.has("club-a")).toBe(true);
expect(reg.has("club-b")).toBe(true);
expect(reg.has("missing")).toBe(false);
}
expect(reg.list().map((e) => e.id)).toEqual(["club-a", "club-b"]);
});
it("strict-mode: re-registering after DUPLICATE_ID throw leaves first registration intact", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const first = E({ id: "club-a" });
reg.register(first, CTX);
for (let i = 0; i < 3; i++) {
let captured: AudioIrError | undefined;
try {
reg.register(
E({ id: "club-a", uri: `bundle://x/${i}.wav` }),
CTX
);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.DUPLICATE_ID);
}
expect(reg.get("club-a")).toBe(first);
});
it("list({ family }) is empty for a family with zero registrations", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
reg.register(E({ id: "club-a" }), CTX);
expect(reg.list({ family: "mic" })).toEqual([]);
expect(reg.list({ family: "speaker" })).toEqual([]);
});
it("clear is repeatable", { timeout: 5000 }, () => {
const reg = createIrRegistry();
reg.register(E({ id: "club-a" }), CTX);
reg.clear();
reg.clear();
expect(reg.list()).toEqual([]);
});
it("registering after clear works and resets per-family counts", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
reg.register(E({ id: "club-a" }), CTX);
reg.clear();
reg.register(E({ id: "club-a" }), CTX);
expect(reg.has("club-a")).toBe(true);
});
});

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_IR_CODES, AudioIrError } from "../error-codes/error-codes.js";
import { IrManifestEntrySchema } from "../types/manifest-entry.js";
import { createIrRegistry } from "./registry.js";
const E = (overrides: Record<string, unknown> = {}) => ({
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
...overrides,
});
const CTX = { manifestSlug: "club", manifestFamily: "room" as const };
describe("IrRegistry", () => {
it("register then get returns the same frozen entry", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const entry = E();
reg.register(entry, CTX);
const got = reg.get(entry.id);
expect(got).toBe(entry);
expect(Object.isFrozen(got)).toBe(true);
});
it("register preserves identity for entries already parsed via the schema", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const entry = IrManifestEntrySchema.parse(E());
reg.register(entry, CTX);
expect(reg.get(entry.id)).toBe(entry);
});
it("throws DUPLICATE_ID on second register of same id", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const first = E();
reg.register(first, CTX);
const second = E({ uri: "bundle://x/other.wav" });
let captured: AudioIrError | undefined;
try {
reg.register(second, CTX);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.DUPLICATE_ID);
expect(captured?.id).toBe("club-club1");
// First registration must remain.
expect(reg.get("club-club1")).toBe(first);
});
it("list filters by family in stable insertion order", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
const r1 = E({ id: "club-r1", family: "room" });
const m1 = E({
id: "sm57-m1",
family: "mic",
});
const r2 = E({ id: "club-r2", family: "room" });
const s1 = E({
id: "ns10-s1",
family: "speaker",
});
reg.register(r1, CTX);
reg.register(m1, {
manifestSlug: "sm57",
manifestFamily: "mic",
});
reg.register(r2, CTX);
reg.register(s1, {
manifestSlug: "ns10",
manifestFamily: "speaker",
});
const rooms = reg.list({ family: "room" });
expect(rooms.map((e) => e.id)).toEqual(["club-r1", "club-r2"]);
const all = reg.list();
expect(all.map((e) => e.id)).toEqual([
"club-r1",
"sm57-m1",
"club-r2",
"ns10-s1",
]);
});
it("get throws NOT_REGISTERED for unknown id", { timeout: 5000 }, () => {
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
reg.get("nope");
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.NOT_REGISTERED);
expect(captured?.id).toBe("nope");
});
it("clear empties the registry", { timeout: 5000 }, () => {
const reg = createIrRegistry();
const e = E();
reg.register(e, CTX);
expect(reg.has(e.id)).toBe(true);
reg.clear();
expect(reg.list()).toEqual([]);
expect(reg.has(e.id)).toBe(false);
});
it("has returns false for unknown id", { timeout: 5000 }, () => {
const reg = createIrRegistry();
expect(reg.has("nope")).toBe(false);
});
it("has returns true after register", { timeout: 5000 }, () => {
const reg = createIrRegistry();
const e = E();
reg.register(e, CTX);
expect(reg.has(e.id)).toBe(true);
});
it("throws ID_PREFIX_MISMATCH when entry id lacks slug prefix", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
reg.register(E({ id: "wrong-1" }), CTX);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.ID_PREFIX_MISMATCH);
expect(captured?.expectedPrefix).toBe("club-");
expect(captured?.actualId).toBe("wrong-1");
});
it("throws FAMILY_MISMATCH when entry family disagrees with manifest", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
reg.register(E({ id: "club-x", family: "mic" }), CTX);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.FAMILY_MISMATCH);
expect(captured?.manifestFamily).toBe("room");
expect(captured?.entryFamily).toBe("mic");
});
it("throws INVALID_MANIFEST when the entry shape is malformed", {
timeout: 5000,
}, () => {
const reg = createIrRegistry();
let captured: AudioIrError | undefined;
try {
reg.register(
E({ channels: -1 }) as unknown as Parameters<
typeof reg.register
>[0],
CTX
);
} catch (err) {
if (err instanceof AudioIrError) captured = err;
}
expect(captured?.code).toBe(AUDIO_IR_CODES.INVALID_MANIFEST);
expect(captured?.cause).toBeDefined();
});
it("validates the register context shape", { timeout: 5000 }, () => {
const reg = createIrRegistry();
expect(() =>
reg.register(E(), {
manifestSlug: "",
manifestFamily: "room",
} as unknown as typeof CTX)
).toThrow();
expect(() =>
reg.register(E(), {
manifestSlug: "club",
manifestFamily: "bus",
} as unknown as typeof CTX)
).toThrow();
});
});

View File

@@ -0,0 +1,210 @@
import z from "zod";
import { AUDIO_IR_CODES, AudioIrError } from "../error-codes/error-codes.js";
import {
recordError,
recordLookup,
recordRegister,
setRegistrySizeSampler,
} from "../otel/otel.js";
import {
type IrFamily,
type IrManifestEntry,
IrManifestEntrySchema,
} from "../types/manifest-entry.js";
/**
* Cross-entry validation context the registry needs to enforce the
* id-prefix and family-coherence invariants when an entry is registered
* one-at-a-time (i.e. without going through `IrManifestSchema.parse`).
*/
export interface IrRegisterContext {
readonly manifestSlug: string;
readonly manifestFamily: IrFamily;
}
/** Optional filter for `IrRegistry.list({ family? })`. */
export interface IrListOptions {
readonly family?: IrFamily;
}
/**
* In-memory `Map`-backed registry of `IrManifestEntry` values.
*
* Created via `createIrRegistry()`. See `design.md` of the
* `add-audio-ir-core` change for the cross-cutting model that explains
* why the registry holds manifest entries (not decoded IRs).
*/
export interface IrRegistry {
/**
* Validate `entry`, enforce ID-prefix + family coherence against
* `context`, then insert. Throws on every failure mode with a typed
* `AudioIrError`.
*/
register(entry: IrManifestEntry, context: IrRegisterContext): void;
/** O(1) lookup. */
has(id: string): boolean;
/** O(1) lookup; throws `AUDIO_IR.NOT_REGISTERED` when absent. */
get(id: string): IrManifestEntry;
/** Stable insertion-order enumeration, optionally filtered by family. */
list(options?: IrListOptions): readonly IrManifestEntry[];
/** Reset internal state. Intended for test setups. */
clear(): void;
}
function throwAndRecord(err: AudioIrError): never {
recordError(err.code);
throw err;
}
/**
* Build a fresh `IrRegistry`. Every registry installs its own per-family
* size sampler with the OTel module so the `audio_ir.registry_size` gauge
* always reflects the most recently constructed registry's counts.
*/
export function createIrRegistry(): IrRegistry {
const store = new Map<string, IrManifestEntry>();
const familyCounts = new Map<IrFamily, number>([
["mic", 0],
["speaker", 0],
["room", 0],
]);
setRegistrySizeSampler(() => familyCounts);
function bumpFamily(family: IrFamily, delta: number): void {
// familyCounts is pre-seeded with all three families, so the
// get() is always defined — non-null assertion is safe here.
const current = familyCounts.get(family) as number;
familyCounts.set(family, current + delta);
}
function registerImpl(
entry: IrManifestEntry,
context: IrRegisterContext
): void {
// Validate the entry shape first — re-runs the schema so callers
// that hand-craft an `IrManifestEntry` literal still get the same
// grammar guarantees as `IrManifestSchema.parse`. The validated
// entry reference is frozen in-place (Zod's `.transform` returns
// a new object, but `Object.freeze` is idempotent + identity-
// preserving) so the registry's `get(id) === entry` invariant
// holds across the parse + register round-trip.
let parsed: IrManifestEntry;
try {
if (!Object.isFrozen(entry)) {
IrManifestEntrySchema.parse(entry);
Object.freeze(entry);
}
parsed = entry;
} catch (cause) {
const err = new AudioIrError(
AUDIO_IR_CODES.INVALID_MANIFEST,
"audio_ir.invalid_manifest",
{
cause,
manifestSlug: context.manifestSlug,
manifestFamily: context.manifestFamily,
}
);
throwAndRecord(err);
}
const prefix = `${context.manifestSlug}-`;
if (!parsed.id.startsWith(prefix)) {
throwAndRecord(
new AudioIrError(
AUDIO_IR_CODES.ID_PREFIX_MISMATCH,
"audio_ir.id_prefix_mismatch",
{
manifestSlug: context.manifestSlug,
manifestFamily: context.manifestFamily,
expectedPrefix: prefix,
actualId: parsed.id,
id: parsed.id,
}
)
);
}
if (parsed.family !== context.manifestFamily) {
throwAndRecord(
new AudioIrError(
AUDIO_IR_CODES.FAMILY_MISMATCH,
"audio_ir.family_mismatch",
{
manifestSlug: context.manifestSlug,
manifestFamily: context.manifestFamily,
entryFamily: parsed.family,
id: parsed.id,
}
)
);
}
if (store.has(parsed.id)) {
throwAndRecord(
new AudioIrError(
AUDIO_IR_CODES.DUPLICATE_ID,
"audio_ir.duplicate_id",
{
id: parsed.id,
manifestSlug: context.manifestSlug,
manifestFamily: context.manifestFamily,
}
)
);
}
store.set(parsed.id, parsed);
bumpFamily(parsed.family, 1);
recordRegister(parsed.family, "success");
}
return {
register(entry, context) {
// Light validation of the context shape — the registry must
// refuse to bind an entry into an arbitrary context.
const ctx = z
.object({
manifestSlug: z.string().min(1),
manifestFamily: z.enum(["mic", "speaker", "room"]),
})
.parse(context);
registerImpl(entry, ctx);
},
has(id) {
const entry = store.get(id);
if (entry === undefined) {
recordLookup("unknown", "miss", "has");
return false;
}
recordLookup(entry.family, "hit", "has");
return true;
},
get(id) {
const entry = store.get(id);
if (entry === undefined) {
recordLookup("unknown", "miss", "get");
const err = new AudioIrError(
AUDIO_IR_CODES.NOT_REGISTERED,
"audio_ir.not_registered",
{ id }
);
throwAndRecord(err);
}
recordLookup(entry.family, "hit", "get");
return entry;
},
list(options) {
const family = options?.family;
if (family === undefined) {
return Array.from(store.values());
}
return Array.from(store.values()).filter(
(entry) => entry.family === family
);
},
clear() {
store.clear();
familyCounts.set("mic", 0);
familyCounts.set("speaker", 0);
familyCounts.set("room", 0);
},
};
}

View File

@@ -0,0 +1,98 @@
# `types/impulse-response` — decoded `ImpulseResponse` schema
## Purpose
Defines the in-memory shape of a fully-decoded impulse response — the value
`@suaveplan/audio-ir-loader.load(id)` returns and the convolver consumes.
Decoded means there is no URI here, no I/O state, just channel buffers and
the metadata needed to wire the IR into a Web Audio convolver or a Bun
offline-render pipeline.
The schema enforces three coherence rules a typed `interface` cannot:
1. `channelData.length === channels`.
2. Every `channelData[i].length === lengthFrames`.
3. `durationSec === lengthFrames / sampleRate` (1 ms tolerance for FP).
These rules are the contract between the loader (which decodes WAV/AIFF
bytes into `Float32Array` channels) and the registry's `manifest`
reference. Violating any of them is a loader bug, not a content bug, and
the schema catches it at the seam.
## Features
- Custom `Float32Array` Zod brand — exhaustively validates the prototype.
- Cross-field coherence via `.superRefine()` — channel count + frame count
- duration.
- 1 ms tolerance on `durationSec` so floating-point IEEE-754 round-tripping
is safe.
- `.transform(Object.freeze)` on the entire object (channelData included).
- Every parsed value carries its source `manifest` entry, so consumers
walking a list of decoded IRs can still reach the provenance (license,
source) without a second registry lookup.
## Usage: Basic
```ts
import { ImpulseResponseSchema } from "@suaveplan/audio-ir-core/types";
const ir = ImpulseResponseSchema.parse({
id: "club-club1",
channelData: [new Float32Array([0.1, 0.2, 0.3])],
channels: 1,
sampleRate: 44100,
lengthFrames: 3,
durationSec: 3 / 44100,
gainDb: 0,
family: "room",
manifest: {
id: "club-club1",
family: "room",
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
channels: 1,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
});
```
## Usage: Advanced
```ts
import { ImpulseResponseSchema } from "@suaveplan/audio-ir-core/types";
function assertConvolverCompatible(ir: unknown): void {
const parsed = ImpulseResponseSchema.parse(ir);
if (parsed.channels > 2) {
throw new Error(
`convolver supports 1..2 channels, got ${parsed.channels} for ${parsed.id}`
);
}
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `ImpulseResponseSchema` | `ZodObject` | Strict, frozen, cross-field-coherent. |
| `ImpulseResponse` | `type` | `z.infer<typeof ImpulseResponseSchema>`. |
## Implementation Notes
The schema lives in `audio-ir-core` rather than `audio-ir-loader` because
the registry's `IrRegistry.get(id)` returns a `IrManifestEntry`, not an
`ImpulseResponse` — the registry is intentionally data-only — but the
loader's return-type contract is part of the foundation. Putting the
schema here means content packages, test fixtures, and downstream
convolver wrappers all parse through the same validator.
`Float32Array` is brought in via `z.custom` rather than `z.instanceof`
because the structural Zod check is cheaper and forward-compatible with
SharedArrayBuffer-backed typed arrays.
Channel buffers are NOT frozen individually (typed arrays are mutable by
spec; freezing them yields a hard error in some runtimes). Freezing the
top-level object is enough to prevent accidental wiring drift.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { ImpulseResponseSchema } from "./impulse-response.js";
const validManifest = {
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 1,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
};
const buf = (n: number) => Float32Array.from({ length: n }, (_, i) => i / n);
const validIr = (overrides: Record<string, unknown> = {}) => ({
id: "club-club1",
channelData: [buf(441)],
channels: 1,
sampleRate: 44100,
lengthFrames: 441,
durationSec: 441 / 44100,
gainDb: 0,
family: "room" as const,
manifest: validManifest,
...overrides,
});
describe("ImpulseResponseSchema", () => {
it("accepts a well-formed IR and freezes it", { timeout: 5000 }, () => {
const parsed = ImpulseResponseSchema.parse(validIr());
expect(parsed.lengthFrames).toBe(441);
expect(Object.isFrozen(parsed)).toBe(true);
});
it("rejects when channelData.length !== channels", {
timeout: 5000,
}, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({
channels: 2,
channelData: [buf(441)],
})
)
).toThrow(z.ZodError);
});
it("rejects when a channel buffer length !== lengthFrames", {
timeout: 5000,
}, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({
channelData: [buf(100)],
})
)
).toThrow(z.ZodError);
});
it("rejects when durationSec disagrees with lengthFrames/sampleRate", {
timeout: 5000,
}, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({
durationSec: 1.0,
})
)
).toThrow(z.ZodError);
});
it("rejects a non-Float32Array channel buffer", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({
channelData: [Array.from({ length: 441 }, () => 0)],
})
)
).toThrow(z.ZodError);
});
it("rejects an empty channel buffer", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({
channelData: [new Float32Array(0)],
lengthFrames: 1,
})
)
).toThrow(z.ZodError);
});
it("rejects non-positive durationSec", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(validIr({ durationSec: 0 }))
).toThrow(z.ZodError);
});
it("rejects non-integer sampleRate", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(validIr({ sampleRate: 44100.5 }))
).toThrow(z.ZodError);
});
it("rejects non-integer lengthFrames", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(validIr({ lengthFrames: 0.5 }))
).toThrow(z.ZodError);
});
it("rejects empty id", { timeout: 5000 }, () => {
expect(() => ImpulseResponseSchema.parse(validIr({ id: "" }))).toThrow(
z.ZodError
);
});
it("rejects non-finite gainDb", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse(
validIr({ gainDb: Number.POSITIVE_INFINITY })
)
).toThrow(z.ZodError);
});
it("rejects extra unknown properties (strict)", { timeout: 5000 }, () => {
expect(() =>
ImpulseResponseSchema.parse({ ...validIr(), bonus: true })
).toThrow(z.ZodError);
});
it("accepts a stereo IR with matching channel buffers", {
timeout: 5000,
}, () => {
const parsed = ImpulseResponseSchema.parse(
validIr({
channels: 2,
channelData: [buf(441), buf(441)],
})
);
expect(parsed.channels).toBe(2);
expect(parsed.channelData.length).toBe(2);
});
});

View File

@@ -0,0 +1,79 @@
import z from "zod";
import { IrFamilySchema, IrManifestEntrySchema } from "./manifest-entry.js";
/** Zod schema for a single channel's Float32 PCM buffer. */
const Float32ArraySchema = z
.custom<Float32Array>((v) => v instanceof Float32Array, {
message: "expected a Float32Array",
})
.refine((v) => v.length > 0, {
message: "channelData buffers must be non-empty",
});
/**
* Zod schema for a fully-decoded impulse response.
*
* This is the value `audio-ir-loader.load(id)` returns and the value the
* convolver consumes. Decoded — no URI here, no manifest fields beyond the
* provenance `manifest` reference — so the schema is enough to validate
* any in-memory IR regardless of where it came from.
*
* The schema enforces channel-count coherence (`channelData.length ===
* channels`) and length coherence (`durationSec === lengthFrames /
* sampleRate` within a 1ms tolerance). Every parsed value is
* `Object.freeze`d.
*/
export const ImpulseResponseSchema = z
.object({
id: z.string().min(1),
channelData: z.array(Float32ArraySchema).readonly(),
channels: z
.number()
.int("channels must be an integer")
.min(1, "channels must be >= 1"),
sampleRate: z
.number()
.int("sampleRate must be an integer")
.min(1, "sampleRate must be >= 1"),
lengthFrames: z
.number()
.int("lengthFrames must be an integer")
.min(1, "lengthFrames must be >= 1"),
durationSec: z.number().positive("durationSec must be > 0"),
gainDb: z.number().finite("gainDb must be a finite number"),
family: IrFamilySchema,
manifest: IrManifestEntrySchema,
})
.strict()
.superRefine((ir, ctx) => {
if (ir.channelData.length !== ir.channels) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["channelData"],
message: `channelData.length (${ir.channelData.length}) must equal channels (${ir.channels})`,
});
}
for (let i = 0; i < ir.channelData.length; i++) {
const buf = ir.channelData[i] as Float32Array;
if (buf.length !== ir.lengthFrames) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["channelData", i],
message: `channelData[${i}].length (${buf.length}) must equal lengthFrames (${ir.lengthFrames})`,
});
}
}
const expected = ir.lengthFrames / ir.sampleRate;
// 1ms tolerance — floating-point round-trip safety, not a soft cap.
if (Math.abs(expected - ir.durationSec) > 1e-3) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["durationSec"],
message: `durationSec (${ir.durationSec}) must equal lengthFrames/sampleRate (${expected})`,
});
}
})
.transform((ir) => Object.freeze(ir));
/** Static `ImpulseResponse` shape (frozen at runtime). */
export type ImpulseResponse = z.infer<typeof ImpulseResponseSchema>;

View File

@@ -0,0 +1,16 @@
export {
type ImpulseResponse,
ImpulseResponseSchema,
} from "./impulse-response.js";
export {
type IrManifest,
IrManifestSchema,
IrManifestSlugSchema,
} from "./manifest.js";
export {
IrEntryIdSchema,
type IrFamily,
IrFamilySchema,
type IrManifestEntry,
IrManifestEntrySchema,
} from "./manifest-entry.js";

View File

@@ -0,0 +1,91 @@
# `types/manifest-entry` — `IrManifestEntry` Zod schema
## Purpose
Defines the on-the-wire shape of a single entry inside an `IrManifest`. An
entry is the smallest unit a content package contributes through the
manifest-first contract: one IR file's identity (`id`), classification
(`family`), location (`uri`), audio shape (`channels`, `sampleRate`),
loudness offset (`gainDb`), and provenance (`license`, `source`). The
schema is the canonical validator at every boundary; every other module
parses through it before trusting the data.
The `id` grammar is the foundation of the registry's collision discipline
and the OTel attribute vocabulary. Lower-kebab, letter-led, no consecutive
dashes, 1..128 bytes — that bound keeps ids safe as Map keys, span
attributes, file-system path segments, and CSS class names. The schema
also freezes every parsed entry, so callers cannot mutate the registry
view by mutating the entry they got back.
## Features
- Exported `IrFamilySchema` (Zod enum) + `IrFamily` static type — the
closed `"mic" | "speaker" | "room"` union the entire family uses.
- Exported `IrEntryIdSchema` as a standalone primitive, reusable wherever
an id needs to be validated outside an entry context.
- `IrManifestEntrySchema` is `.strict()` — extra properties are rejected,
which prevents accidental schema drift between content packages.
- Every parsed entry is `Object.freeze`d via `.transform()`.
## Usage: Basic
```ts
import { IrManifestEntrySchema } from "@suaveplan/audio-ir-core/types";
const entry = IrManifestEntrySchema.parse({
id: "club-club1",
family: "room",
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
});
// `entry` is frozen and deep-equal to the input.
```
## Usage: Advanced
```ts
import { IrEntryIdSchema, IrFamilySchema } from "@suaveplan/audio-ir-core/types";
// Use the primitive standalone where only the id grammar is relevant —
// e.g. CLI option parsing for the loader.
function parseIdArg(raw: string): string {
return IrEntryIdSchema.parse(raw);
}
// Family enum is reusable for filter UIs.
function buildFamilyFilter(): readonly string[] {
return IrFamilySchema.options;
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `IrFamilySchema` | `ZodEnum` | `["mic", "speaker", "room"]`. |
| `IrFamily` | `type` | `"mic" \| "speaker" \| "room"`. |
| `IrEntryIdSchema` | `ZodString` | Refined string: 1..128 bytes, lower-kebab, letter-led, no `--`. |
| `IrManifestEntrySchema` | `ZodObject` | Strict object with `.transform(Object.freeze)`. |
| `IrManifestEntry` | `type` | `z.infer<typeof IrManifestEntrySchema>`. |
## Implementation Notes
The id grammar is enforced with two `.refine()` passes (regex shape, then
consecutive-dash check) rather than a single fused regex so each violation
maps to its own Zod issue with a specific message. That matters for the
DX when manifests are malformed at scale — operators get a precise
finger-pointing message instead of a single opaque regex failure.
`.strict()` is intentional: a future MODIFIED-Requirements change can grow
the schema (e.g. `metadata?: Record<string, unknown>`) but until that ships
we reject unknown fields outright. That keeps content packages from
encoding bespoke extensions that bypass the manifest-first discipline.
`gainDb` uses `.finite()` (rejecting `NaN`/`Infinity`) but not `.safe()` —
loudness deltas can legitimately be negative double-digits and we want to
preserve their numeric semantics. The loader downstream is responsible for
clipping into a sensible playback range.

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import {
IrEntryIdSchema,
IrFamilySchema,
IrManifestEntrySchema,
} from "./manifest-entry.js";
const baseEntry = {
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
};
describe("IrManifestEntrySchema", () => {
it("valid entry round-trips through Zod and is frozen", {
timeout: 5000,
}, () => {
const parsed = IrManifestEntrySchema.parse(baseEntry);
expect(parsed).toEqual(baseEntry);
expect(Object.isFrozen(parsed)).toBe(true);
});
it("rejects uppercase / consecutive-dash / non-letter-start ids", {
timeout: 5000,
}, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, id: "Club1" })
).toThrow(z.ZodError);
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, id: "club--1" })
).toThrow(z.ZodError);
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, id: "1-club" })
).toThrow(z.ZodError);
});
it("rejects an empty id", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, id: "" })
).toThrow(z.ZodError);
});
it("rejects an id longer than 128 bytes", { timeout: 5000 }, () => {
const tooLong = `a${"b".repeat(128)}`;
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, id: tooLong })
).toThrow(z.ZodError);
});
it("rejects an empty uri", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, uri: "" })
).toThrow(z.ZodError);
});
it("rejects non-integer channels", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, channels: 1.5 })
).toThrow(z.ZodError);
});
it("rejects channels < 1", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, channels: 0 })
).toThrow(z.ZodError);
});
it("rejects non-integer sampleRate", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, sampleRate: 44.1 })
).toThrow(z.ZodError);
});
it("rejects sampleRate < 1", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, sampleRate: 0 })
).toThrow(z.ZodError);
});
it("rejects non-finite gainDb", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({
...baseEntry,
gainDb: Number.POSITIVE_INFINITY,
})
).toThrow(z.ZodError);
expect(() =>
IrManifestEntrySchema.parse({
...baseEntry,
gainDb: Number.NaN,
})
).toThrow(z.ZodError);
});
it("rejects empty license / source", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, license: "" })
).toThrow(z.ZodError);
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, source: "" })
).toThrow(z.ZodError);
});
it("rejects extra unknown properties (strict)", { timeout: 5000 }, () => {
expect(() =>
IrManifestEntrySchema.parse({ ...baseEntry, extraField: "nope" })
).toThrow(z.ZodError);
});
it("accepts the boundary-length 128-byte id", { timeout: 5000 }, () => {
const longId = `a${"b".repeat(127)}`;
expect(longId.length).toBe(128);
const parsed = IrManifestEntrySchema.parse({
...baseEntry,
id: longId,
});
expect(parsed.id).toBe(longId);
});
});
describe("IrEntryIdSchema standalone", () => {
it("accepts a single letter", { timeout: 5000 }, () => {
expect(IrEntryIdSchema.parse("a")).toBe("a");
});
it("rejects raw uppercase", { timeout: 5000 }, () => {
expect(() => IrEntryIdSchema.parse("A")).toThrow(z.ZodError);
});
});
describe("IrFamilySchema enum", () => {
it("accepts the three known families", { timeout: 5000 }, () => {
expect(IrFamilySchema.parse("mic")).toBe("mic");
expect(IrFamilySchema.parse("speaker")).toBe("speaker");
expect(IrFamilySchema.parse("room")).toBe("room");
});
it("rejects unknown families", { timeout: 5000 }, () => {
expect(() => IrFamilySchema.parse("bus")).toThrow(z.ZodError);
});
});

View File

@@ -0,0 +1,60 @@
import z from "zod";
/** IR family discriminator — `mic`, `speaker`, or `room`. */
export const IrFamilySchema = z.enum(["mic", "speaker", "room"]);
/** Static IR family union. */
export type IrFamily = z.infer<typeof IrFamilySchema>;
/**
* Zod schema for `IrManifestEntry.id`.
*
* Grammar:
*
* - SHALL match `/^[a-z][a-z0-9-]*$/` (lower-kebab, letter-led).
* - SHALL NOT contain consecutive `-` characters.
* - SHALL be 1..128 bytes long.
*
* The grammar is enforced as a top-level refinement so we can return a
* specific issue per violation rather than a single opaque regex error.
*/
export const IrEntryIdSchema = z
.string()
.min(1, "id must be at least 1 character")
.max(128, "id must be at most 128 characters")
.refine((s) => /^[a-z][a-z0-9-]*$/.test(s), {
message:
"id must match /^[a-z][a-z0-9-]*$/ — lowercase, letter-led, kebab",
})
.refine((s) => !s.includes("--"), {
message: "id must not contain consecutive `-`",
});
/**
* Zod schema for an entry inside an `IrManifest`.
*
* Every parsed entry is `Object.freeze`d before being returned so callers
* cannot mutate the registry view by mutating the entry they got back.
*/
export const IrManifestEntrySchema = z
.object({
id: IrEntryIdSchema,
family: IrFamilySchema,
uri: z.string().min(1, "uri must be a non-empty string"),
channels: z
.number()
.int("channels must be an integer")
.min(1, "channels must be >= 1"),
sampleRate: z
.number()
.int("sampleRate must be an integer")
.min(1, "sampleRate must be >= 1"),
gainDb: z.number().finite("gainDb must be a finite number"),
license: z.string().min(1, "license must be a non-empty string"),
source: z.string().min(1, "source must be a non-empty string"),
})
.strict()
.transform((parsed) => Object.freeze(parsed));
/** Static `IrManifestEntry` shape (frozen at runtime). */
export type IrManifestEntry = z.infer<typeof IrManifestEntrySchema>;

View File

@@ -0,0 +1,97 @@
# `types/manifest` — `IrManifest` Zod schema
## Purpose
Defines the on-the-wire shape of a complete IR manifest — the package-level
contract a content package (e.g. `audio-ir-room-plugin-club`) ships so the
registry knows which entries belong together, which slug-prefix to enforce,
and which family to denormalise onto each entry. Every other component in
the IR pipeline (loader, registry helper, UI filters) parses through this
schema before trusting the data.
The schema enforces two cross-entry invariants that the entry-level schema
cannot express by itself: id-prefix coherence and family coherence. Both
violations surface as `ZodError` issues with a machine-readable `params`
payload (`{ kind: "ID_PREFIX_MISMATCH" | "FAMILY_MISMATCH", ... }`) so the
registry layer can rewrap them as typed `AudioIrError` throws without
parsing the message string. The cross-cutting design lives in `design.md`
of the spec change.
## Features
- Exported `IrManifestSlugSchema` standalone — reusable wherever a slug
needs validating (CLI args, plugin manifests).
- `.strict()` object — extra properties on the manifest rejected outright.
- Cross-entry refinements via `.superRefine()` so multiple issues from one
parse run surface as multiple Zod issues, not the first-failure-wins.
- `.transform(Object.freeze)` — every parsed manifest is frozen.
## Usage: Basic
```ts
import { IrManifestSchema } from "@suaveplan/audio-ir-core/types";
const manifest = IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [
{
id: "club-club1",
family: "room",
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
},
],
});
```
## Usage: Advanced
```ts
import { IrManifestSchema } from "@suaveplan/audio-ir-core/types";
const result = IrManifestSchema.safeParse(untrustedInput);
if (!result.success) {
for (const issue of result.error.issues) {
const kind = (issue.params as { kind?: string } | undefined)?.kind;
if (kind === "ID_PREFIX_MISMATCH") {
const params = issue.params as {
expectedPrefix: string;
actualId: string;
};
console.error(
`entry id "${params.actualId}" should start with "${params.expectedPrefix}"`
);
}
}
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `IrManifestSlugSchema` | `ZodString` | Lower-kebab, letter-led, no `--`, 1..64 bytes. |
| `IrManifestSchema` | `ZodObject` | Strict manifest with `superRefine` + freeze transform. |
| `IrManifest` | `type` | `z.infer<typeof IrManifestSchema>`. |
## Implementation Notes
The slug grammar is capped at 64 bytes — half the entry id cap — because
the slug + dash + filename stem all live inside a single 128-byte id and
the filename stem deserves at least 63 bytes of headroom for descriptive
names like `outdoor-swimming-pool`.
`superRefine` is used over `refine` because we want every entry's
violations reported in the same `ZodError`, not just the first. That makes
authoring a content-package manifest much faster — fix the whole batch in
one round-trip instead of one issue at a time.
The custom-issue `params.kind` discriminant (`"ID_PREFIX_MISMATCH"` /
`"FAMILY_MISMATCH"`) is the contract the registry layer parses to decide
which `AudioIrError` code to throw. The keys are intentionally identical
to the `AUDIO_IR.*` reason names so the wiring is mechanical.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { IrManifestSchema, IrManifestSlugSchema } from "./manifest.js";
const entry = (overrides: Record<string, unknown> = {}) => ({
id: "club-club1",
family: "room" as const,
uri: "bundle://x/y.wav",
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: "MIT",
source: "demo",
...overrides,
});
describe("IrManifestSchema", () => {
it("accepts a well-formed manifest and freezes it", {
timeout: 5000,
}, () => {
const manifest = IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [entry(), entry({ id: "club-club2" })],
});
expect(manifest.entries.length).toBe(2);
expect(Object.isFrozen(manifest)).toBe(true);
});
it("rejects entry whose id lacks the manifest slug prefix", {
timeout: 5000,
}, () => {
let captured: z.ZodError | undefined;
try {
IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [entry({ id: "wrong-1" })],
});
} catch (err) {
captured = err as z.ZodError;
}
expect(captured).toBeInstanceOf(z.ZodError);
const issue = captured?.issues.find(
(i) =>
(i.params as { kind?: string } | undefined)?.kind ===
"ID_PREFIX_MISMATCH"
);
expect(issue).toBeDefined();
const params = issue?.params as {
expectedPrefix: string;
actualId: string;
};
expect(params.expectedPrefix).toBe("club-");
expect(params.actualId).toBe("wrong-1");
});
it("rejects entry whose family disagrees with manifest family", {
timeout: 5000,
}, () => {
let captured: z.ZodError | undefined;
try {
IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [entry({ id: "club-club1", family: "mic" })],
});
} catch (err) {
captured = err as z.ZodError;
}
expect(captured).toBeInstanceOf(z.ZodError);
const issue = captured?.issues.find(
(i) =>
(i.params as { kind?: string } | undefined)?.kind ===
"FAMILY_MISMATCH"
);
expect(issue).toBeDefined();
const params = issue?.params as {
manifestFamily: string;
entryFamily: string;
};
expect(params.manifestFamily).toBe("room");
expect(params.entryFamily).toBe("mic");
});
it("rejects an unknown top-level property", { timeout: 5000 }, () => {
expect(() =>
IrManifestSchema.parse({
packageSlug: "club",
family: "room",
entries: [entry()],
rogue: "field",
})
).toThrow(z.ZodError);
});
it("rejects empty packageSlug", { timeout: 5000 }, () => {
expect(() =>
IrManifestSchema.parse({
packageSlug: "",
family: "room",
entries: [],
})
).toThrow(z.ZodError);
});
});
describe("IrManifestSlugSchema", () => {
it("accepts a kebab slug", { timeout: 5000 }, () => {
expect(IrManifestSlugSchema.parse("audio-ir-club")).toBe(
"audio-ir-club"
);
});
it("rejects uppercase", { timeout: 5000 }, () => {
expect(() => IrManifestSlugSchema.parse("Club")).toThrow(z.ZodError);
});
it("rejects consecutive dashes", { timeout: 5000 }, () => {
expect(() => IrManifestSlugSchema.parse("a--b")).toThrow(z.ZodError);
});
it("rejects slug longer than 64 bytes", { timeout: 5000 }, () => {
const tooLong = `a${"b".repeat(64)}`;
expect(() => IrManifestSlugSchema.parse(tooLong)).toThrow(z.ZodError);
});
});

View File

@@ -0,0 +1,90 @@
import z from "zod";
import {
type IrFamily,
IrFamilySchema,
IrManifestEntrySchema,
} from "./manifest-entry.js";
/**
* Zod schema for `IrManifest.packageSlug`.
*
* Grammar mirrors `IrEntryIdSchema` (lower-kebab, letter-led, no
* consecutive `-`, 1..64 bytes). 64 bytes is the upper bound because the
* slug is concatenated as `<slug>-<rest>` into the entry id, which is
* itself capped at 128.
*/
export const IrManifestSlugSchema = z
.string()
.min(1, "packageSlug must be at least 1 character")
.max(64, "packageSlug must be at most 64 characters")
.refine((s) => /^[a-z][a-z0-9-]*$/.test(s), {
message:
"packageSlug must match /^[a-z][a-z0-9-]*$/ — lowercase, letter-led, kebab",
})
.refine((s) => !s.includes("--"), {
message: "packageSlug must not contain consecutive `-`",
});
interface ManifestShape {
readonly packageSlug: string;
readonly family: IrFamily;
readonly entries: ReadonlyArray<z.infer<typeof IrManifestEntrySchema>>;
}
/**
* Zod schema for a complete `IrManifest`.
*
* The schema refines the parsed value with two invariants:
*
* 1. Every `entry.id` SHALL begin with `<packageSlug>-` (case-sensitive).
* 2. Every `entry.family` SHALL equal `manifest.family`.
*
* Violations surface as `ZodError` issues with `code: "custom"` and a
* machine-readable `params` payload so the registry can rewrap them as
* `AUDIO_IR.ID_PREFIX_MISMATCH` / `AUDIO_IR.FAMILY_MISMATCH` without
* regex-parsing the message text.
*/
export const IrManifestSchema = z
.object({
packageSlug: IrManifestSlugSchema,
family: IrFamilySchema,
entries: z.array(IrManifestEntrySchema).readonly(),
})
.strict()
.superRefine((manifest, ctx) => {
const prefix = `${manifest.packageSlug}-`;
for (let i = 0; i < manifest.entries.length; i++) {
const entry = manifest.entries[i] as z.infer<
typeof IrManifestEntrySchema
>;
if (!entry.id.startsWith(prefix)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["entries", i, "id"],
message: `id "${entry.id}" must start with manifest slug prefix "${prefix}"`,
params: {
kind: "ID_PREFIX_MISMATCH",
expectedPrefix: prefix,
actualId: entry.id,
},
});
}
if (entry.family !== manifest.family) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["entries", i, "family"],
message: `entry.family "${entry.family}" must equal manifest.family "${manifest.family}"`,
params: {
kind: "FAMILY_MISMATCH",
manifestFamily: manifest.family,
entryFamily: entry.family,
id: entry.id,
},
});
}
}
})
.transform((manifest): ManifestShape => Object.freeze(manifest));
/** Static `IrManifest` shape (frozen at runtime). */
export type IrManifest = z.infer<typeof IrManifestSchema>;

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"],
"exclude": ["dist", "node_modules"]
}

View File

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

View File

@@ -0,0 +1,44 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-ir-core",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"registrar/index": "src/registrar/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@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,
});

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

View File

@@ -0,0 +1,86 @@
# @suaveplan/audio-ir-room-plugin-club - Original audio-ir content package: ships the bundled `club1.wav` and `club2.wav` room impulse responses, registered through @suaveplan/storage-uri-plugin-bundle and @suaveplan/audio-ir-core. Universal; bundle-only content package.
Original audio-ir content package. Ships two bundled room impulse responses (`club1.wav`, `club2.wav`) wired through the `@suaveplan/audio-ir-*` family.
This package is the canonical example of the **bundle-only** content-package pattern in the IR family — every future `audio-ir-{family}-plugin-<name>` package that ships only local files follows this shape.
<!-- begin:auto-docs -->
## Documentation tree
- [manifest](./src/manifest/manifest.md) — The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `…
- [manifest](./src/manifest/manifest.md) — The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `…
- [register](./src/register/register.md) — This module is the only side-effecting entrypoint a consumer ever calls from `@suaveplan/audio-ir-room-plugin-club`. It…
- [register](./src/register/register.md) — This module is the only side-effecting entrypoint a consumer ever calls from `@suaveplan/audio-ir-room-plugin-club`. It…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-ir-room-plugin-club
```
Peer-installed alongside `@suaveplan/audio-ir-core`, `@suaveplan/storage-uri`, `@suaveplan/storage-uri-plugin-bundle`.
## Quick start
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { register } from "@suaveplan/audio-ir-room-plugin-club";
import { createIrLoader } from "@suaveplan/audio-ir-loader";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";
import { decodeWav } from "@suaveplan/audio-decode-wav";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector, { fetch: nodeFileFetch }); // omit `fetch` in the browser
const resolver = createStorageUriResolver(collector.plugins);
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decodeWav },
});
const club1 = await loader.load("club-club1");
const club2 = await loader.load("club-club2");
```
## Manifest
| `id` | URI | Channels | Sample rate | Gain |
|---|---|---|---|---|
| `club-club1` | `bundle://audio-ir-room-plugin-club/club1.wav` | 2 | 44 100 Hz | 0 dB |
| `club-club2` | `bundle://audio-ir-room-plugin-club/club2.wav` | 2 | 44 100 Hz | 0 dB |
Channels and sample rate are decoded at manifest-authoring time; the loader re-validates and throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on disagreement.
## Modules
- `./manifest` — `manifest: IrManifest`, `BUNDLE_PACKAGE_SLUG`, `MANIFEST_SLUG`.
- `./register` — `register(registry, collector, options?)` (collector pattern).
The root barrel re-exports every public binding so `import { register } from "@suaveplan/audio-ir-room-plugin-club"` is equivalent to the subpath form.
## Browser vs Node
- **Browser**: omit `options.fetch` in `register` — `globalThis.fetch` reads the bundled URL directly.
- **Node**: pass `register(registry, collector, { fetch: nodeFileFetch })` from `@suaveplan/storage-uri-plugin-bundle/node-fetch` — Node's undici-backed global `fetch` refuses `file:` URLs.
## License & sources
This package is published under the MIT license. Both IR captures (`club1.wav`, `club2.wav`) originate from the operator's Mountain Duck media library at `/media/_/audio/ir/room/club{1,2}.wav` and are redistributed inside Suaveplan under the same MIT license. The IR samples themselves are room captures with no upstream proprietary attribution requirement.
The `IrManifestEntry.source` field on each entry records the Mountain Duck origin path so a future operator can reproduce the import end-to-end.
## Pre-publish gate
```bash
bunx turbo lint typecheck test --filter=@suaveplan/audio-ir-room-plugin-club
bunx turbo test:coverage --filter=@suaveplan/audio-ir-room-plugin-club # 100/100/100/100
bunx turbo build --filter=@suaveplan/audio-ir-room-plugin-club
```

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/audio-ir-room-plugin-club",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Genesis-original audio-ir content package: ships the bundled `club1.wav` and `club2.wav` room impulse responses, registered through @suaveplan/storage-uri-plugin-bundle and @suaveplan/audio-ir-core. Universal; bundle-only content package.",
"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"
}
},
"./manifest": {
"import": {
"types": "./dist/manifest/manifest.d.ts",
"default": "./dist/manifest/manifest.js"
}
},
"./register": {
"import": {
"types": "./dist/register/register.d.ts",
"default": "./dist/register/register.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": {
"@suaveplan/audio-ir-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage-uri": "workspace:^",
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/audio-decode-wav": "workspace:^",
"@suaveplan/audio-ir-loader": "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",
"files",
"README.md"
]
}

View File

@@ -0,0 +1,9 @@
export {
BUNDLE_PACKAGE_SLUG,
MANIFEST_SLUG,
manifest,
} from "./manifest/manifest.js";
export {
type RegisterOptions,
register,
} from "./register/register.js";

View File

@@ -0,0 +1,65 @@
# `manifest` — frozen room-IR manifest for the club package
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `IrManifest` parsed once through `IrManifestSchema` at module load time, frozen by the schema's `.transform`, and exported for two consumers: (1) `register(registry, resolver)` (the sibling `src/register` module) which walks every entry into the supplied `IrRegistry`, and (2) tooling that wants to enumerate available IRs without touching the storage resolver (catalog UIs, registry warm-up loaders, build-time docs).
Every value in the manifest is a literal at authoring time. Both `channels` and `sampleRate` were captured by decoding the bundled WAVs through `@suaveplan/audio-decode-wav` and recorded as constants so the manifest can never drift from the on-disk shape. The IR loader (`@suaveplan/audio-ir-loader`) re-validates these fields at decode time and throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` if they disagree — but for a bundle-only package the on-disk file is also part of the tarball, so the only way the values can disagree is a manual edit, which the manifest-validation gate catches before publish.
## Features
- Frozen at parse time — every consumer reads the same `Object.freeze`d value.
- Schema-validated id-prefix refinement (`club-*`) and family coherence (`room` on every entry).
- Pre-computed bundle URIs that match the package's slug verbatim.
- Stable insertion order: `club-club1` before `club-club2`.
## Usage: Basic
```ts
import { manifest } from "@suaveplan/audio-ir-room-plugin-club/manifest";
console.log(manifest.entries.map((e) => e.id));
// → ["club-club1", "club-club2"]
```
## Usage: Advanced
```ts
import { manifest, BUNDLE_PACKAGE_SLUG } from "@suaveplan/audio-ir-room-plugin-club/manifest";
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
import { parseUri } from "@suaveplan/storage-uri";
// Re-validate (no-op — already parsed at load time, but useful as a smoke test).
const reparsed = IrManifestSchema.parse(manifest);
console.assert(reparsed.packageSlug === "club");
// Verify every entry resolves to a well-formed bundle:// URI bound to
// THIS package's slug.
for (const entry of manifest.entries) {
const parts = parseUri(entry.uri);
console.assert(parts.scheme === "bundle");
console.assert(parts.authority === BUNDLE_PACKAGE_SLUG);
}
```
## API Reference
### `manifest: IrManifest`
The frozen, parsed manifest. Two entries, both `family: "room"`, both `gainDb: 0`, both 44.1 kHz stereo.
### `BUNDLE_PACKAGE_SLUG: "audio-ir-room-plugin-club"`
The full npm-style slug used as the `bundle://<slug>/<rel>` authority. Exported so tests and the sibling `register` module can reference it without repeating the literal.
### `MANIFEST_SLUG: "club"`
The short slug stamped onto every `entry.id` as `<MANIFEST_SLUG>-<filename-stem>`.
## Implementation Notes
The two slugs (`MANIFEST_SLUG` and `BUNDLE_PACKAGE_SLUG`) intentionally differ: the manifest slug follows the design's "short id" rule (`design.md` §2) while the bundle slug must match the npm package name so a single resolver can route multiple content packages without collision. The bundle plugin enforces the longer form on `parse`; the registry enforces the shorter form on the entry id. Re-running `IrManifestSchema.parse(manifest)` is idempotent and free of side effects — the schema's `.transform` only freezes, it does not clone.

View File

@@ -0,0 +1,46 @@
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
import { parseUri } from "@suaveplan/storage-uri";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BUNDLE_PACKAGE_SLUG, manifest } from "./manifest.js";
describe("manifest", () => {
it("manifest parses through IrManifestSchema", { timeout: 5000 }, () => {
const parsed = IrManifestSchema.parse(manifest);
expect(parsed).toEqual(manifest);
expect(parsed.packageSlug).toBe("club");
expect(parsed.family).toBe("room");
for (const entry of parsed.entries) {
expect(entry.id.startsWith("club-")).toBe(true);
expect(entry.family).toBe("room");
}
});
it("declares exactly club-club1 and club-club2", { timeout: 5000 }, () => {
const ids = manifest.entries.map((e) => e.id);
expect(ids).toEqual(["club-club1", "club-club2"]);
expect(new Set(ids).size).toBe(ids.length);
});
it("every URI parses as a well-formed bundle URI", {
timeout: 5000,
}, () => {
for (const entry of manifest.entries) {
const parts = parseUri(entry.uri);
expect(parts.scheme).toBe("bundle");
expect(parts.authority).toBe(BUNDLE_PACKAGE_SLUG);
expect(parts.path.length).toBeGreaterThan(0);
}
});
it("every entry declares positive channels, sampleRate, and finite gainDb", {
timeout: 5000,
}, () => {
for (const entry of manifest.entries) {
expect(entry.channels).toBeGreaterThanOrEqual(1);
expect(entry.sampleRate).toBeGreaterThan(0);
expect(Number.isFinite(entry.gainDb)).toBe(true);
expect(entry.license.length).toBeGreaterThan(0);
expect(entry.source.length).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,75 @@
import { type IrManifest, IrManifestSchema } from "@suaveplan/audio-ir-core";
/**
* npm-name slug used as the bundle URI authority. MUST match the package
* directory name and the `bundle://<slug>/...` authority in every
* `IrManifestEntry.uri` below — the bundle plugin's `parse` rejects any
* URI whose authority differs from this string verbatim.
*/
export const BUNDLE_PACKAGE_SLUG = "audio-ir-room-plugin-club" as const;
/**
* Short slug embedded into every entry id under the
* `<manifestSlug>-<filename-stem>` rule from `audio-ir-core/design.md` §2.
* The id-prefix `IrManifestSchema` refinement requires every entry id to
* start with `<manifestSlug>-`.
*/
export const MANIFEST_SLUG = "club" as const;
/**
* License declared on every entry. Both IRs originate from the operator's
* Mountain Duck media library and are redistributed inside the genesis
* monorepo under the package's MIT license — the IR samples themselves are
* room captures with no upstream proprietary attribution requirement.
*/
const LICENSE = "MIT (Suaveplan-original room capture)" as const;
/**
* Source attribution for `club1.wav`. Identifies the file by its full
* Mountain Duck mount path so an operator can reproduce the import.
*/
const SOURCE_CLUB1 =
"Mountain Duck media library: /media/_/audio/ir/room/club1.wav" as const;
/**
* Source attribution for `club2.wav`.
*/
const SOURCE_CLUB2 =
"Mountain Duck media library: /media/_/audio/ir/room/club2.wav" as const;
/**
* Frozen, schema-validated `IrManifest` describing the two club room IRs
* shipped by this package. `channels` and `sampleRate` were measured by
* decoding the bundled WAVs at manifest-authoring time (both are 44.1 kHz
* stereo). `gainDb: 0` means the loader applies no gain adjustment.
*
* The manifest is parsed through `IrManifestSchema` exactly once at module
* load — every consumer sees the frozen, validated value. Re-parsing is
* unnecessary; mutating it is impossible.
*/
export const manifest: IrManifest = IrManifestSchema.parse({
packageSlug: MANIFEST_SLUG,
family: "room",
entries: [
{
id: "club-club1",
family: "room",
uri: `bundle://${BUNDLE_PACKAGE_SLUG}/club1.wav`,
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: LICENSE,
source: SOURCE_CLUB1,
},
{
id: "club-club2",
family: "room",
uri: `bundle://${BUNDLE_PACKAGE_SLUG}/club2.wav`,
channels: 2,
sampleRate: 44100,
gainDb: 0,
license: LICENSE,
source: SOURCE_CLUB2,
},
],
});

View File

@@ -0,0 +1,130 @@
# `register` — collector-pattern wiring entrypoint
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the only side-effecting entrypoint a consumer ever calls from
`@suaveplan/audio-ir-room-plugin-club`. It bridges three pieces that exist in
three separate packages: the in-memory `IrRegistry` from
`@suaveplan/audio-ir-core` (which holds manifest entries), a
`MutableUriPluginRegistrar` collector — typically built via
`createUriPluginCollector()` — that gathers `UriPlugin`s before resolver
construction, and the `bundleUriPlugin` from
`@suaveplan/storage-uri-plugin-bundle` (which knows how to read this
package's bundled WAVs).
The package never auto-registers on import — every consumer calls
`register(registry, collector)` explicitly. Importing the package's barrel
without calling `register` is a no-op aside from loading the frozen
manifest constant. This matches the side-effect-free contract on all
`@suaveplan/audio-ir-*` content packages and matches the family-canon
shape established by `audio-ir-room-plugin-pool`.
## Features
- Single call wires both the bundle adapter (via `target.use(plugin)`) and
the manifest registration (via `registerManifest`).
- `RegisterOptions.fetch?: FetchLike` lets Node-tier callers pass
`nodeFileFetch` so `file://` reads work under undici's global `fetch`.
- Default `rootUrl` resolves to the in-tarball `files/` directory via
`import.meta.url` — works in both Node and any modern bundler that
preserves package URLs.
- No `createPlugin()` separate step; no `resolver.hasScheme("bundle")`
structural check; the collector pattern makes both unnecessary.
- Errors from `registerManifest` (`AUDIO_IR.DUPLICATE_ID`,
`AUDIO_IR.ID_PREFIX_MISMATCH`, …) pass through verbatim.
## Usage: Basic
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { register } from "@suaveplan/audio-ir-room-plugin-club";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
const resolver = createStorageUriResolver(collector.plugins);
// registry.has("club-club1") === true
// registry.has("club-club2") === true
```
## Usage: Advanced
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { createIrLoader } from "@suaveplan/audio-ir-loader";
import { decodeWav } from "@suaveplan/audio-decode-wav";
import { register } from "@suaveplan/audio-ir-room-plugin-club";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";
// Node.js: globalThis.fetch refuses file:// — wire nodeFileFetch.
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector, { fetch: nodeFileFetch });
const resolver = createStorageUriResolver(collector.plugins);
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decodeWav },
});
const club1 = await loader.load("club-club1");
console.log(club1.channelData[0].length, "samples @", club1.sampleRate, "Hz");
```
## API Reference
### `register(registry: IrRegistry, target: MutableUriPluginRegistrar, options?: RegisterOptions): void`
Builds the package-scoped `bundleUriPlugin`, hands it to `target.use(plugin)`,
then calls `registerManifest(registry, manifest)`.
- `registry` — an `IrRegistry` from `@suaveplan/audio-ir-core`.
- `target` — any object satisfying
`MutableUriPluginRegistrar` (`use(plugin: UriPlugin): void`). Typically
built via `createUriPluginCollector()`.
- `options.fetch?: FetchLike` — overrides the runtime fetch (pass
`nodeFileFetch` on Node so `file://` URLs resolve).
Throws `AUDIO_IR.DUPLICATE_ID` if any manifest entry is already in the
registry.
### `interface RegisterOptions`
- `readonly fetch?: FetchLike` — see above.
## Implementation Notes
`new URL("../../files/", import.meta.url)` is intentional — the
`register/register.ts` file lives at `src/register/register.ts` in the
source tree, and at `dist/register/register.js` after build. In both
cases two levels up lands on the package root, where `files/` sits at
the same level as `dist/` and `src/`. The trailing slash is required by
`bundleUriPlugin`'s `rootUrl` validator.
The `target.use(plugin)` call happens before `registerManifest(...)` so
that even when `registerManifest` rejects (for example with
`AUDIO_IR.DUPLICATE_ID` on a double-register), the bundle plugin is
already in the caller's collector. This matches the family-canon
ordering established by `audio-ir-room-plugin-pool` and keeps the
collector observable when tests inspect it after a thrown error.
The double-register test relies on `registerManifest` calling
`registry.register(entry, ctx)` for each entry in declaration order. The
registry throws `AUDIO_IR.DUPLICATE_ID` on the first colliding id — for
this package, `club-club1` — so the rethrown error is deterministic.

View File

@@ -0,0 +1,90 @@
import {
AUDIO_IR_CODES,
AudioIrError,
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { register } from "./register.js";
describe("register", () => {
it("register calls target.use exactly once with a bundle plugin", {
timeout: 5000,
}, () => {
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
expect(collector.plugins.length).toBe(1);
const installed = collector.plugins[0];
expect(installed).toBeDefined();
if (installed === undefined) throw new Error("unreachable");
expect(installed.scheme).toBe("bundle");
// Parse smoke-tests that the plugin is bound to this package's
// slug — the bundle plugin's `parse` accepts URIs whose authority
// equals `audio-ir-room-plugin-club`.
const parsed = installed.parse(
"bundle://audio-ir-room-plugin-club/club1.wav"
);
expect(parsed.key).toBe("club1.wav");
expect(parsed.meta?.packageSlug).toBe("audio-ir-room-plugin-club");
});
it("register populates the registry with both club entries", {
timeout: 5000,
}, () => {
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
expect(registry.has("club-club1")).toBe(true);
expect(registry.has("club-club2")).toBe(true);
const rooms = registry.list({ family: "room" });
const ids = rooms.map((e) => e.id);
expect(ids).toContain("club-club1");
expect(ids).toContain("club-club2");
});
it("double register against same registry surfaces DUPLICATE_ID", {
timeout: 5000,
}, () => {
const registry = createIrRegistry();
const collectorA = createUriPluginCollector();
const collectorB = createUriPluginCollector();
register(registry, collectorA);
let caught: unknown;
try {
register(registry, collectorB);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AudioIrError);
expect((caught as AudioIrError).code).toBe(AUDIO_IR_CODES.DUPLICATE_ID);
// Even though the second register threw, its `target.use(plugin)`
// ran BEFORE `registerManifest` rejected — so collectorB still
// received the bundle plugin. This matches family-canon ordering.
expect(collectorB.plugins.length).toBe(1);
expect(collectorB.plugins[0]?.scheme).toBe("bundle");
});
it("register accepts an injected fetch via options", {
timeout: 5000,
}, () => {
const registry = createIrRegistry();
const collector = createUriPluginCollector();
const fakeFetch = async () => new Response(new Uint8Array());
register(registry, collector, { fetch: fakeFetch });
expect(collector.plugins.length).toBe(1);
expect(collector.plugins[0]?.scheme).toBe("bundle");
expect(registry.has("club-club1")).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import type {
IrRegistry,
MutableUriPluginRegistrar,
} from "@suaveplan/audio-ir-core";
import { registerManifest } from "@suaveplan/audio-ir-core";
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
import type { FetchLike } from "@suaveplan/types";
import { BUNDLE_PACKAGE_SLUG, manifest } from "../manifest/manifest.js";
/** Optional overrides for `register`. */
export interface RegisterOptions {
/**
* Override the `fetch` implementation passed to `bundleUriPlugin`. Useful
* under Node (which does NOT support `fetch('file://...')`) — pass
* `nodeFileFetch` from `@suaveplan/storage-uri-plugin-bundle/node-fetch`.
* Defaults to `globalThis.fetch`.
*/
readonly fetch?: FetchLike;
}
/**
* Install this content package's bundle plugin onto `target` and register
* every entry from `manifest` into `registry`.
*
* `register` does NOT install the shipped `fallback://` plugin or any
* `https://` plugin — both are resolver-composition concerns the consumer
* owns once per resolver. The collector pattern decouples plugin
* construction from resolver construction so a single
* `createStorageUriResolver(collector.plugins)` call composes every
* content package's bundle plugin into one frozen resolver.
*
* `rootUrl` is computed from `import.meta.url` so the package's
* `files/` directory is located deterministically regardless of where
* the consumer installs the published tarball.
*/
export function register(
registry: IrRegistry,
target: MutableUriPluginRegistrar,
options?: RegisterOptions
): void {
const plugin = bundleUriPlugin({
packageSlug: BUNDLE_PACKAGE_SLUG,
rootUrl: new URL("../../files/", import.meta.url),
...(options?.fetch !== undefined ? { fetch: options.fetch } : {}),
});
target.use(plugin);
registerManifest(registry, manifest);
}

View File

@@ -0,0 +1,74 @@
import { readFile, stat } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { decodeWav } from "@suaveplan/audio-decode-wav";
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { createIrLoader } from "@suaveplan/audio-ir-loader";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { register } from "../../src/register/register.js";
/**
* Minimal `file://` `FetchLike` for Node — undici's global `fetch` refuses
* `file:` URLs. Sufficient for the GET requests the bundle plugin issues.
*/
const nodeFileFetch: typeof globalThis.fetch = async (
input,
init
): Promise<Response> => {
const url =
input instanceof URL
? input
: input instanceof Request
? new URL(input.url)
: new URL(input);
if (url.protocol !== "file:") {
return globalThis.fetch(input, init);
}
const path = fileURLToPath(url);
const st = await stat(path);
if (!st.isFile()) return new Response(null, { status: 404 });
const buf = await readFile(path);
return new Response(buf, {
status: 200,
headers: {
"content-length": String(st.size),
"last-modified": st.mtime.toUTCString(),
},
});
};
describe("end-to-end", () => {
it("collector wiring loads both club IRs", {
timeout: 30000,
}, async () => {
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector, { fetch: nodeFileFetch });
const resolver = createStorageUriResolver(collector.plugins);
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decodeWav },
});
for (const id of ["club-club1", "club-club2"] as const) {
const ir = await loader.load(id);
expect(Object.isFrozen(ir)).toBe(true);
expect(ir.id).toBe(id);
expect(ir.family).toBe("room");
expect(ir.channels).toBe(2);
expect(ir.sampleRate).toBe(44100);
expect(ir.channelData.length).toBe(2);
expect(ir.channelData[0]!.length).toBeGreaterThan(0);
expect(ir.channelData[1]!.length).toBe(ir.channelData[0]!.length);
expect(ir.lengthFrames).toBe(ir.channelData[0]!.length);
expect(ir.durationSec).toBeGreaterThan(0);
expect(ir.gainDb).toBe(0);
}
});
});

View File

@@ -0,0 +1,23 @@
import { readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
const FILES_ROOT = new URL("../../files/", import.meta.url);
describe("file-presence", () => {
it("club1.wav and club2.wav exist on disk and are non-empty", {
timeout: 5000,
}, () => {
for (const name of ["club1.wav", "club2.wav"]) {
const path = fileURLToPath(new URL(name, FILES_ROOT));
const stat = statSync(path);
expect(stat.isFile()).toBe(true);
expect(stat.size).toBeGreaterThan(0);
const bytes = readFileSync(path);
expect(bytes.byteLength).toBe(stat.size);
expect(
String.fromCharCode(bytes[0]!, bytes[1]!, bytes[2]!, bytes[3]!)
).toBe("RIFF");
}
});
});

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": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-ir-room-plugin-club",
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: [
"@suaveplan/audio-ir-core",
"@suaveplan/error",
"@suaveplan/storage-uri",
"@suaveplan/storage-uri-plugin-bundle",
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

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

View File

@@ -0,0 +1,74 @@
# @suaveplan/audio-ir-room-plugin-pool - Original audio-ir content package: ships the outdoor-swimming-pool room impulse response paired with a https CDN fallback. Universal.
Original audio-ir content package. Ships a single `room`-family
impulse response — `pool-outdoor-swimming-pool` — sourced from the
outdoor-swimming-pool capture in the Suaveplan internal media library,
paired with a CDN fallback at `https://swch.b-cdn.net/_/audio/ir/room/roompool.wav`.
Universal; no browser, DOM, or Node-specific APIs in the public
surface. The shipped bundle plugin uses `import.meta.url` to locate the
in-tarball `files/` directory at runtime.
<!-- begin:auto-docs -->
## Documentation tree
- [manifest](./src/manifest/manifest.md) — Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`. The entry's `uri` is a `fallback…
- [manifest](./src/manifest/manifest.md) — Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`. The entry's `uri` is a `fallback…
- [register](./src/register/register.md) — Single entry point for plugging `@suaveplan/audio-ir-room-plugin-pool` into a consumer's `IrRegistry` + `StorageUriReso…
- [register](./src/register/register.md) — Single entry point for plugging `@suaveplan/audio-ir-room-plugin-pool` into a consumer's `IrRegistry` + `StorageUriReso…
<!-- end:auto-docs -->
## Modules
- `./manifest` — `manifest: IrManifest`, plus the canonical constants
(`BUNDLE_PACKAGE_SLUG`, `BUNDLE_URI`, `CDN_URI`, `ENTRY_ID`,
`CHANNELS`, `SAMPLE_RATE`).
- `./register` — `register(registry, target, options?)` and the
re-exported `MutableUriPluginRegistrar` type (sourced from
`@suaveplan/audio-ir-core`). Wires the bundle plugin onto a
consumer-supplied registrar and calls `registerManifest`.
## Quick start
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { createIrLoader } from "@suaveplan/audio-ir-loader";
import { decodeWav } from "@suaveplan/audio-decode-wav";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { fallbackUriPlugin } from "@suaveplan/storage-uri-plugin-fallback";
import { register } from "@suaveplan/audio-ir-room-plugin-pool";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
// Append the consumer's https://-aware plugin via collector.use(...).
const innerPlugins = [...collector.plugins];
const innerResolver = createStorageUriResolver(innerPlugins);
const fb = fallbackUriPlugin({
resolver: innerResolver,
policy: { errorClass: "any" },
});
const resolver = createStorageUriResolver([...innerPlugins, fb]);
const loader = createIrLoader({
registry,
resolver,
decoders: { wav: decodeWav },
});
const ir = await loader.load("pool-outdoor-swimming-pool");
```
## License & sources
The bundled WAV (`files/outdoor-swimming-pool.wav`) was copied from the
Suaveplan internal media library at
`/mnt/c/Users/Administrator/Mountain Duck/74a17879-9780-426d-8bd9-7ed805f53d41/media/_/audio/ir/room/outdoor-swimming-pool.wav`.
The CDN copy lives at `https://swch.b-cdn.net/_/audio/ir/room/roompool.wav`.
License: **All Rights Reserved — Suaveplan internal media library**. Do
not redistribute the bundled WAV outside Suaveplan distributions.

View File

@@ -0,0 +1,76 @@
{
"name": "@suaveplan/audio-ir-room-plugin-pool",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Genesis-original audio-ir content package: ships the outdoor-swimming-pool room impulse response paired with a https CDN fallback. 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"
}
},
"./manifest": {
"import": {
"types": "./dist/manifest/index.d.ts",
"default": "./dist/manifest/index.js"
}
},
"./register": {
"import": {
"types": "./dist/register/index.d.ts",
"default": "./dist/register/index.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": {
"@suaveplan/audio-ir-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage-uri": "workspace:^",
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
"@suaveplan/storage-uri-plugin-fallback": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/audio-decode-wav": "workspace:^",
"@suaveplan/audio-ir-loader": "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",
"files",
"README.md"
]
}

View File

@@ -0,0 +1,11 @@
export type { MutableUriPluginRegistrar } from "@suaveplan/audio-ir-core";
export {
BUNDLE_PACKAGE_SLUG,
BUNDLE_URI,
CDN_URI,
CHANNELS,
ENTRY_ID,
manifest,
SAMPLE_RATE,
} from "./manifest/manifest.js";
export { type RegisterOptions, register } from "./register/register.js";

View File

@@ -0,0 +1,9 @@
export {
BUNDLE_PACKAGE_SLUG,
BUNDLE_URI,
CDN_URI,
CHANNELS,
ENTRY_ID,
manifest,
SAMPLE_RATE,
} from "./manifest.js";

View File

@@ -0,0 +1,89 @@
# `manifest` — single-entry `IrManifest` for the outdoor-swimming-pool IR
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`.
The entry's `uri` is a `fallback://?u=<bundle>&u=<https>` URI built at module
load by `buildFallbackUri` from `@suaveplan/storage-uri-plugin-fallback` —
when the consuming resolver has both a `bundle://` plugin and an `https://`
plugin wired, the shipped `fallback://` walk hits the in-package WAV first
and only reaches for the CDN copy when the local file is absent.
The manifest also pins the **decoded** `channels` and `sampleRate` of the
bundled WAV (`channels = 1`, `sampleRate = 48000`) so the loader's
`validateAndFinalize` step can reject any subsequent re-encode that drifts
from those values. Both numbers were obtained by feeding
`files/outdoor-swimming-pool.wav` through `decodeWav` once at
manifest-authoring time.
## Features
- Round-trips through `IrManifestSchema` at module load — any drift in the
schema fails the build before `dist/` is emitted.
- `BUNDLE_PACKAGE_SLUG`, `BUNDLE_URI`, `CDN_URI`, `ENTRY_ID`, `CHANNELS`,
`SAMPLE_RATE` exported as named `const` literals so consumers can refer
to them without re-deriving from `manifest.entries[0]`.
- Frozen at runtime (`IrManifestSchema` applies `Object.freeze` to the
parsed manifest + every entry).
## Usage: Basic
```ts
import { manifest } from "@suaveplan/audio-ir-room-plugin-pool/manifest";
console.log(manifest.packageSlug); // "pool"
console.log(manifest.entries[0].id); // "pool-outdoor-swimming-pool"
```
## Usage: Advanced
```ts
import {
BUNDLE_URI,
CDN_URI,
ENTRY_ID,
manifest,
} from "@suaveplan/audio-ir-room-plugin-pool/manifest";
import { parseFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
const entry = manifest.entries[0];
const parsed = parseFallbackUri(entry.uri);
console.log(parsed.candidates); // [BUNDLE_URI, CDN_URI]
console.log(entry.id === ENTRY_ID); // true
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `manifest` | `IrManifest` | Frozen single-entry manifest. |
| `BUNDLE_PACKAGE_SLUG` | `"audio-ir-room-plugin-pool"` | Slug expected by the bundle plugin. |
| `BUNDLE_URI` | string literal | Bundle-local URI for the WAV. |
| `CDN_URI` | string literal | HTTPS CDN URI for the WAV. |
| `ENTRY_ID` | `"pool-outdoor-swimming-pool"` | Entry id within the registry. |
| `CHANNELS` | `1` | Decoded channel count of the WAV. |
| `SAMPLE_RATE` | `48000` | Decoded sample rate of the WAV (Hz). |
## Implementation Notes
The manifest constants are NOT optional — the spec for this package
requires them to equal the values measured by decoding
`files/outdoor-swimming-pool.wav` at authoring time. If the bundled WAV is
ever re-mastered, both `CHANNELS` and `SAMPLE_RATE` MUST be updated to
match the new decode result, and the file-presence integration test
SHOULD re-run as part of that change to assert the new constants.
`buildFallbackUri([BUNDLE_URI, CDN_URI])` runs at module load. The
ordering of candidates IS the failover policy — bundle wins when present,
CDN wins when bundle reports `BUNDLE_URI.NOT_FOUND`. Reversing the array
would silently flip that policy without a separate spec change, so the
constants are kept verbose and explicit rather than inlined.
The `license` field intentionally says "All Rights Reserved — Suaveplan
internal media library" because the source media library has no public
license attached. Consumers SHOULD NOT redistribute the bundled WAV
outside the Suaveplan distribution.

View File

@@ -0,0 +1,63 @@
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
import { parseFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BUNDLE_PACKAGE_SLUG,
BUNDLE_URI,
CDN_URI,
CHANNELS,
ENTRY_ID,
manifest,
SAMPLE_RATE,
} from "./manifest.js";
describe("manifest", () => {
it("manifest parses through IrManifestSchema", { timeout: 5000 }, () => {
const reparsed = IrManifestSchema.parse(manifest);
expect(reparsed).toEqual(manifest);
expect(manifest.packageSlug).toBe("pool");
expect(manifest.family).toBe("room");
expect(manifest.entries.length).toBe(1);
const entry = manifest.entries[0];
expect(entry).toBeDefined();
if (entry === undefined) throw new Error("unreachable");
expect(entry.id).toBe(ENTRY_ID);
expect(entry.id).toBe("pool-outdoor-swimming-pool");
expect(entry.family).toBe("room");
expect(entry.channels).toBe(CHANNELS);
expect(entry.sampleRate).toBe(SAMPLE_RATE);
expect(entry.gainDb).toBe(0);
expect(entry.license.length).toBeGreaterThan(0);
expect(entry.source.length).toBeGreaterThan(0);
});
it("URI parses through parseFallbackUri with two ordered candidates", {
timeout: 5000,
}, () => {
const entry = manifest.entries[0];
if (entry === undefined) throw new Error("unreachable");
const parsed = parseFallbackUri(entry.uri);
expect(parsed.candidates.length).toBe(2);
const first = parsed.candidates[0];
const second = parsed.candidates[1];
expect(first).toBeDefined();
expect(second).toBeDefined();
if (first === undefined || second === undefined) {
throw new Error("unreachable");
}
expect(first.startsWith("bundle://audio-ir-room-plugin-pool/")).toBe(
true
);
expect(first).toBe(BUNDLE_URI);
expect(second).toBe(CDN_URI);
expect(second).toBe(
"https://swch.b-cdn.net/_/audio/ir/room/roompool.wav"
);
});
it("exports the canonical constants", { timeout: 5000 }, () => {
expect(BUNDLE_PACKAGE_SLUG).toBe("audio-ir-room-plugin-pool");
expect(CHANNELS).toBe(1);
expect(SAMPLE_RATE).toBe(48_000);
});
});

View File

@@ -0,0 +1,67 @@
import type { IrManifest } from "@suaveplan/audio-ir-core";
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
import { buildFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
/**
* Slug embedded in the bundle URL. MUST match the `packageSlug` passed to
* `bundleUriPlugin({ packageSlug })` inside `register` — the bundle plugin
* uses this to validate that the URL authority matches the consuming
* package.
*/
export const BUNDLE_PACKAGE_SLUG = "audio-ir-room-plugin-pool" as const;
/**
* Bundle-local URI for the shipped WAV. Resolves against the package's
* `files/` directory at runtime via `bundleUriPlugin`.
*/
export const BUNDLE_URI =
"bundle://audio-ir-room-plugin-pool/outdoor-swimming-pool.wav" as const;
/**
* CDN fallback URI. The shipped `fallback://` walk targets this when the
* bundle adapter reports `BUNDLE_URI.NOT_FOUND`.
*/
export const CDN_URI =
"https://swch.b-cdn.net/_/audio/ir/room/roompool.wav" as const;
/**
* Entry id for the lone IR shipped by this package.
*/
export const ENTRY_ID = "pool-outdoor-swimming-pool" as const;
/**
* Decoded channel count of `files/outdoor-swimming-pool.wav` — measured by
* decoding the bundled file at manifest-authoring time with
* `@suaveplan/audio-decode-wav`.
*/
export const CHANNELS = 1 as const;
/**
* Decoded sample rate of `files/outdoor-swimming-pool.wav` (Hz) — measured by
* decoding the bundled file at manifest-authoring time.
*/
export const SAMPLE_RATE = 48_000 as const;
/**
* Single-entry `IrManifest` validated through `IrManifestSchema`.
*
* The lone entry's `uri` is a `fallback://?u=<bundle>&u=<https>` URI in
* declared order — bundle first, CDN second. The shipped `fallback://` plugin
* handles the ordered walk + transient classification.
*/
export const manifest: IrManifest = IrManifestSchema.parse({
packageSlug: "pool",
family: "room",
entries: [
{
id: ENTRY_ID,
family: "room",
uri: buildFallbackUri([BUNDLE_URI, CDN_URI]),
channels: CHANNELS,
sampleRate: SAMPLE_RATE,
gainDb: 0,
license: "All Rights Reserved — Suaveplan internal media library",
source: "Mountain Duck media library + https://swch.b-cdn.net CDN",
},
],
});

View File

@@ -0,0 +1,2 @@
export type { MutableUriPluginRegistrar } from "@suaveplan/audio-ir-core";
export { type RegisterOptions, register } from "./register.js";

Some files were not shown because too many files have changed in this diff Show More