first commit
This commit is contained in:
@@ -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.
|
||||
107
docs/media/audio-ir/audio-ir-core/src/otel/otel.md
Normal file
107
docs/media/audio-ir/audio-ir-core/src/otel/otel.md
Normal 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.
|
||||
110
docs/media/audio-ir/audio-ir-core/src/registrar/registrar.md
Normal file
110
docs/media/audio-ir/audio-ir-core/src/registrar/registrar.md
Normal 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.
|
||||
@@ -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.
|
||||
115
docs/media/audio-ir/audio-ir-core/src/registry/registry.md
Normal file
115
docs/media/audio-ir/audio-ir-core/src/registry/registry.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
97
docs/media/audio-ir/audio-ir-core/src/types/manifest.md
Normal file
97
docs/media/audio-ir/audio-ir-core/src/types/manifest.md
Normal 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.
|
||||
Reference in New Issue
Block a user