Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 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`, `audio… |
| [`@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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,124 @@
# `register` — install bundle plugin + register manifest
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single entry point for plugging `@suaveplan/audio-ir-room-plugin-pool` into a
consumer's `IrRegistry` + `StorageUriResolver` composition. Constructs the
package-scoped `bundleUriPlugin` (rooted at the in-package `files/`
directory via `import.meta.url`) and hands it to a `MutableUriPluginRegistrar`,
then calls `registerManifest(registry, manifest)` so the lone
`pool-outdoor-swimming-pool` entry is reachable via `registry.get(id)`.
The `MutableUriPluginRegistrar` shape is the canonical collector interface
shipped by `@suaveplan/audio-ir-core` — see that package's
`registrar/registrar.md` for the type definition and the
`createUriPluginCollector()` factory every consumer should reach for
first. This package only consumes the interface; it does NOT redeclare it.
The helper deliberately does NOT install the `fallback://` plugin or any
`https://` plugin. Both are resolver-composition concerns the consumer
owns once per resolver (the `fallback://` plugin is stateful across
candidates; sharing a single instance keeps the per-candidate counter
attribution consistent across every content package that uses it).
## Features
- One call wires both the bundle adapter and the manifest registration.
- Uses `import.meta.url` to locate `files/` — works in both Node and any
modern bundler that preserves package URLs.
- No fallback plugin registration — single-responsibility wrt content.
- `target` is typed against the foundation's `MutableUriPluginRegistrar`
so a single shape works across every `audio-ir-{family}-plugin-<name>`
content package.
## Usage: Basic
```ts
import {
createIrRegistry,
createUriPluginCollector,
} from "@suaveplan/audio-ir-core";
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { register } from "@suaveplan/audio-ir-room-plugin-pool/register";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
const resolver = createStorageUriResolver(collector.plugins);
```
## Usage: Advanced
Compose multiple content packages into a single resolver, then wrap an
HTTPS-aware plugin and the shared `fallbackUriPlugin` on top:
```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/register";
const registry = createIrRegistry();
const collector = createUriPluginCollector();
register(registry, collector);
// Append the consumer's https://-aware plugin.
const innerPlugins = [...collector.plugins, myHttpsPlugin];
const innerResolver = createStorageUriResolver(innerPlugins);
const outerResolver = createStorageUriResolver([
...innerPlugins,
fallbackUriPlugin({ resolver: innerResolver, policy: { errorClass: "any" } }),
]);
const loader = createIrLoader({
registry,
resolver: outerResolver,
decoders: { wav: decodeWav },
});
const ir = await loader.load("pool-outdoor-swimming-pool");
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `register(registry, target, options?)` | function | Wire the bundle plugin into `target` and register the manifest into `registry`. |
| `RegisterOptions` | interface | Optional `{ fetch?: FetchLike }` override for the bundle adapter (use `nodeFileFetch` under Node). |
| `MutableUriPluginRegistrar` | type | Re-exported from `@suaveplan/audio-ir-core`. Canonical `{ use(plugin: UriPlugin): void }` collector shape. |
## Implementation Notes
`new URL("../../files/", import.meta.url)` is intentional — the
`manifest/manifest.ts` file lives at `src/manifest/manifest.ts` in the
source tree, and at `dist/manifest/manifest.js` after build. In both
cases the relative climb two levels up from the file's URL lands on the
package root, where `files/` lives at the same level as `dist/` and
`src/`. The trailing slash is required by `bundleUriPlugin` — it
rejects `rootUrl` values whose pathname does not end with `/`.
The bundle plugin's `packageSlug` is `"audio-ir-room-plugin-pool"`,
which matches the `bundle://audio-ir-room-plugin-pool/...` authority in
the manifest's URI. Mismatch would surface as `BUNDLE_URI.INVALID_URI`
at first dispatch, not at registration — so the manifest's bundle URI
and `BUNDLE_PACKAGE_SLUG` are both exported from `manifest.ts` as
single-source-of-truth constants.
The helper does NOT clear or re-init the registry. Repeated calls
against the same registry will fail on `AUDIO_IR.DUPLICATE_ID` from
`registerManifest`, which is the desired behaviour — content packages
should install exactly once per process.
`MutableUriPluginRegistrar` is no longer declared locally; the type is
imported from `@suaveplan/audio-ir-core` so every content package
targets exactly one shape. The function body is unchanged across the
type-only switch.