Initial Commit

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

View File

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

View File

@@ -0,0 +1,90 @@
{
"name": "@suaveplan/audio-ir-core",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Foundation contract for the @suaveplan audio-ir family: ImpulseResponse / IrManifestEntry / IrManifest Zod schemas, in-memory IrRegistry, AUDIO_IR.* error-code registry, and OpenTelemetry surface. Universal; zero @suaveplan/audio-* runtime deps.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/index.js"
}
},
"./registrar": {
"import": {
"types": "./dist/registrar/index.d.ts",
"default": "./dist/registrar/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/storage-uri": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,85 @@
# `error-codes` — `AUDIO_IR.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of every error code the `@suaveplan/audio-ir-core`
runtime is allowed to throw, plus the `AudioIrError` class that carries the
typed discriminants alongside each throw. The registry is built with
`defineCodes("AUDIO_IR", [...] as const)` from `@suaveplan/error`, which
freezes the resulting record and folds the namespace prefix in once so call
sites read as `AUDIO_IR_CODES.DUPLICATE_ID === "AUDIO_IR.DUPLICATE_ID"`.
The codes form the public contract between the registry and its operators:
log pipelines, alerting rules, and OTel attribute filters all key off the
`AUDIO_IR.*` strings. Adding a code is a spec change; renaming is a breaking
change. The exhaustion test in `error-codes.test.ts` guarantees every code
listed here is reachable from a real production call site.
## Features
- Exactly five frozen codes — `DUPLICATE_ID`, `NOT_REGISTERED`,
`INVALID_MANIFEST`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`.
- `AudioIrError` extends `SuaveplanError<AudioIrErrorCode>` so
`instanceof AudioIrError` narrows `code` to the literal union.
- Typed discriminants on the error instance: `id`, `manifestSlug`,
`manifestFamily`, `entryFamily`, `expectedPrefix`, `actualId`. Operators
filter on those properties — never on substring matches of the message.
- Plays nicely with `walkCauses` / `toSpanEvent` from `@suaveplan/error`.
## Usage: Basic
```ts
import { AUDIO_IR_CODES, AudioIrError } from "@suaveplan/audio-ir-core";
throw new AudioIrError(AUDIO_IR_CODES.NOT_REGISTERED, "audio_ir.not_registered", {
id: "club-club1",
});
```
## Usage: Advanced
```ts
import { AUDIO_IR_CODES, AudioIrError } from "@suaveplan/audio-ir-core";
try {
registry.register(entry, { manifestSlug: "club", manifestFamily: "room" });
} catch (err) {
if (err instanceof AudioIrError && err.code === AUDIO_IR_CODES.ID_PREFIX_MISMATCH) {
// err.expectedPrefix and err.actualId are typed `string | undefined`.
console.error({
code: err.code,
expectedPrefix: err.expectedPrefix,
actualId: err.actualId,
});
}
throw err;
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `AUDIO_IR_CODES` | `const` | Frozen `{ DUPLICATE_ID, NOT_REGISTERED, INVALID_MANIFEST, ID_PREFIX_MISMATCH, FAMILY_MISMATCH }` mapped to `"AUDIO_IR.<reason>"`. |
| `AudioIrErrorCode` | `type` | Union of the five fully-qualified code strings. |
| `AudioIrErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with typed discriminants (`id`, `manifestSlug`, `manifestFamily`, `entryFamily`, `expectedPrefix`, `actualId`). |
| `AudioIrError` | `class` | `SuaveplanError<AudioIrErrorCode>` subclass. Read-only typed properties surface every discriminant. |
## Implementation Notes
The registry deliberately omits the namespace inside `defineCodes` because
the helper folds it in; spelling `"AUDIO_IR.DUPLICATE_ID"` here would
double-prefix to `"AUDIO_IR.AUDIO_IR.DUPLICATE_ID"`. The exhaustion test
walks `Object.values(AUDIO_IR_CODES)` and asserts that each code is thrown
from a real production path inside the package — not from a test fixture.
This closes the gap where a code can be registered, leaked into the OTel
attribute vocabulary, and then never actually emitted.
`AudioIrError` does NOT make `id` mandatory because not every throw site has
one (`INVALID_MANIFEST` thrown from a malformed manifest object has no id).
Discriminants are typed `T | undefined` to keep the no-fallback honesty of
the wire-format contract.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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