first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

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.