Initial Commit
This commit is contained in:
110
packages/media/audio-ir/audio-ir-core/README.md
Normal file
110
packages/media/audio-ir/audio-ir-core/README.md
Normal 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.
|
||||
|
||||
90
packages/media/audio-ir/audio-ir-core/package.json
Normal file
90
packages/media/audio-ir/audio-ir-core/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
AudioIrErrorCode,
|
||||
AudioIrErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_IR_CODES,
|
||||
AudioIrError,
|
||||
} from "./error-codes.js";
|
||||
46
packages/media/audio-ir/audio-ir-core/src/index.ts
Normal file
46
packages/media/audio-ir/audio-ir-core/src/index.ts
Normal 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";
|
||||
14
packages/media/audio-ir/audio-ir-core/src/otel/index.ts
Normal file
14
packages/media/audio-ir/audio-ir-core/src/otel/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AudioIrInstruments,
|
||||
AudioIrLookupOutcome,
|
||||
AudioIrRegisterOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordError,
|
||||
recordLookup,
|
||||
recordRegister,
|
||||
resetInstruments,
|
||||
setAudioIrMeterProvider,
|
||||
setRegistrySizeSampler,
|
||||
} from "./otel.js";
|
||||
107
packages/media/audio-ir/audio-ir-core/src/otel/otel.md
Normal file
107
packages/media/audio-ir/audio-ir-core/src/otel/otel.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# `otel` — OpenTelemetry surface for `audio-ir-core`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns every span, counter, and observable gauge the registry emits. The
|
||||
foundation is the layer that knows about the registry's shape, so it also
|
||||
owns the OTel instruments that describe that shape — counters for the
|
||||
write/read traffic (`registrations_total`, `lookups_total`), counter for
|
||||
every typed throw (`errors_total{code}`), and an observable gauge for the
|
||||
per-family registry size sampled on every scrape (`registry_size{family}`).
|
||||
|
||||
The loader and content packages own their own metric surfaces
|
||||
(`audio_ir_loader.*` for decode duration, fallback hop, cache hit). The
|
||||
two surfaces deliberately do not overlap.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument construction — bound on first `getInstruments()` call,
|
||||
cached thereafter.
|
||||
- Injectable `MeterProvider` via `setAudioIrMeterProvider(provider)` —
|
||||
tests install an in-memory recorder; production leaves it undefined and
|
||||
the global `metrics` is used.
|
||||
- Observable gauge with a pluggable sampler — the registry installs a
|
||||
sampler that reports per-family counts on every scrape.
|
||||
- `recordRegister` / `recordLookup` also start a span with stable
|
||||
attribute keys (`audio_ir.family`, `audio_ir.outcome`).
|
||||
- `recordError(code)` increments `audio_ir.errors_total{code}` for every
|
||||
`AUDIO_IR.*` throw.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordRegister } from "@suaveplan/audio-ir-core/otel";
|
||||
|
||||
recordRegister("room", "success");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
getInstruments,
|
||||
setAudioIrMeterProvider,
|
||||
setRegistrySizeSampler,
|
||||
} from "@suaveplan/audio-ir-core/otel";
|
||||
|
||||
// In tests: install an in-memory recorder MeterProvider so spec
|
||||
// assertions can see exact counter values.
|
||||
const recorded: Array<{ name: string; value: number; attributes: unknown }> = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (value: number, attributes?: Record<string, unknown>) => {
|
||||
recorded.push({ name, value, attributes });
|
||||
},
|
||||
}),
|
||||
createObservableGauge: (name: string) => ({
|
||||
addCallback: () => undefined,
|
||||
}),
|
||||
}),
|
||||
} as unknown as MeterProvider;
|
||||
setAudioIrMeterProvider(provider);
|
||||
setRegistrySizeSampler(() => new Map([["room", 2]]));
|
||||
getInstruments(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `setAudioIrMeterProvider(provider)` | function | Override the provider used to build instruments. Resets the cache. |
|
||||
| `setRegistrySizeSampler(sampler)` | function | Install the sampler the observable gauge calls on collection. |
|
||||
| `getInstruments(force?)` | function | Lazily build (or return cached) the full instrument bag. |
|
||||
| `resetInstruments()` | function | Drop the cache — used by tests between provider swaps. |
|
||||
| `recordRegister(family, outcome)` | function | Increment `audio_ir.registrations_total` + start a `audio_ir.register` span. |
|
||||
| `recordLookup(family, outcome, op)` | function | Increment `audio_ir.lookups_total` + start a `audio_ir.get` or `audio_ir.has` span. |
|
||||
| `recordError(code)` | function | Increment `audio_ir.errors_total{code}` for every typed throw. |
|
||||
| `AudioIrInstruments` | interface | Read-only struct of every instrument the package emits. |
|
||||
| `AudioIrRegisterOutcome` | type | `"success" \| "error"`. |
|
||||
| `AudioIrLookupOutcome` | type | `"hit" \| "miss"`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The observable gauge does not retain a snapshot of the registry — it calls
|
||||
the installed sampler on every scrape. The registry installs a sampler
|
||||
that wraps a live counter `Map<IrFamily, number>` it maintains on
|
||||
`register` / `clear`. That keeps the sampler O(1) and avoids the gauge
|
||||
ever lagging behind the actual registry state.
|
||||
|
||||
`setAudioIrMeterProvider(undefined)` reverts to the global `metrics`
|
||||
provider AND clears the cached instrument bag, so subsequent calls
|
||||
rebuild against the global. Tests must call this in cleanup or the next
|
||||
test inherits the stub.
|
||||
|
||||
The injected provider in tests IS a real `MeterProvider` implementation
|
||||
(it satisfies the public interface from `@opentelemetry/api`) — it is not
|
||||
a mock function. The recorder bookkeeping is the implementation. This
|
||||
honours the no-mocks rule while keeping the assertion surface narrow
|
||||
enough to write maintainable tests.
|
||||
272
packages/media/audio-ir/audio-ir-core/src/otel/otel.test.ts
Normal file
272
packages/media/audio-ir/audio-ir-core/src/otel/otel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
138
packages/media/audio-ir/audio-ir-core/src/otel/otel.ts
Normal file
138
packages/media/audio-ir/audio-ir-core/src/otel/otel.ts
Normal 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 });
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
MutableUriPluginRegistrar,
|
||||
UriPluginCollector,
|
||||
} from "./registrar.js";
|
||||
export { createUriPluginCollector } from "./registrar.js";
|
||||
110
packages/media/audio-ir/audio-ir-core/src/registrar/registrar.md
Normal file
110
packages/media/audio-ir/audio-ir-core/src/registrar/registrar.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `registrar` — `MutableUriPluginRegistrar` + `createUriPluginCollector()`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MutableUriPluginRegistrar` is the canonical collector interface every
|
||||
`audio-ir-{family}-plugin-<name>` content package targets when registering
|
||||
its bundled URI plugins. Stage-3 ship of `audio-ir-room-plugin-pool`
|
||||
discovered that `StorageUriResolver` is frozen at construction — there is
|
||||
no `resolver.use(plugin)` after construction — so content packages need a
|
||||
mutable seam to deposit URI plugins during their `register(...)` call. This
|
||||
module promotes that seam from the pool's local copy into the
|
||||
`audio-ir-core` foundation so every content package, present and future,
|
||||
targets exactly one shape.
|
||||
|
||||
The companion `createUriPluginCollector()` factory wraps an internal
|
||||
ordered array and exposes a frozen snapshot via the `plugins` getter, which
|
||||
plugs directly into `createStorageUriResolver({ plugins })`. The snapshot
|
||||
is rebuilt on every read so a stale read remains immutable even after
|
||||
additional `use(...)` calls.
|
||||
|
||||
## Features
|
||||
|
||||
- `MutableUriPluginRegistrar` — single-method interface
|
||||
(`use(plugin: UriPlugin): void`) decoupled from `StorageUriResolver` so
|
||||
consumers may compose plugin lists across multiple content packages
|
||||
before building one resolver — or a parent + inner resolver, or a future
|
||||
builder.
|
||||
- `createUriPluginCollector()` — factory returning
|
||||
`MutableUriPluginRegistrar & { readonly plugins: readonly UriPlugin[] }`.
|
||||
`use()` pushes (insertion-order preserving); `plugins` returns a fresh
|
||||
`Object.freeze([...store])` snapshot on every read.
|
||||
- Zero runtime dependencies beyond the `UriPlugin` TYPE import from
|
||||
`@suaveplan/storage-uri`. Universal; no browser or Node surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
registerManifest,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { register as registerPoolBundle } from "@suaveplan/audio-ir-room-plugin-pool";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
|
||||
registerPoolBundle(registry, collector);
|
||||
|
||||
const resolver = createStorageUriResolver({ plugins: collector.plugins });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose multiple content packages, validate snapshot immutability, and
|
||||
keep an audit copy of plugins gathered at each stage:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createUriPluginCollector,
|
||||
type MutableUriPluginRegistrar,
|
||||
} from "@suaveplan/audio-ir-core/registrar";
|
||||
import { register as registerClub } from "@suaveplan/audio-ir-room-plugin-club";
|
||||
import { register as registerPool } from "@suaveplan/audio-ir-room-plugin-pool";
|
||||
|
||||
const collector = createUriPluginCollector();
|
||||
registerPool(registry, collector);
|
||||
const afterPool = collector.plugins; // frozen snapshot — survives further use()
|
||||
registerClub(registry, collector);
|
||||
const afterClub = collector.plugins; // new frozen snapshot, contains both bundles
|
||||
|
||||
// Adapt an arbitrary sink:
|
||||
const externalSink: MutableUriPluginRegistrar = {
|
||||
use: (p) => myObservableArray.push(p),
|
||||
};
|
||||
registerPool(registry, externalSink);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface MutableUriPluginRegistrar`
|
||||
- `use(plugin: UriPlugin): void` — append a URI plugin to the underlying
|
||||
collection. Implementations MUST preserve call order.
|
||||
- `interface UriPluginCollector extends MutableUriPluginRegistrar`
|
||||
- `readonly plugins: readonly UriPlugin[]` — frozen snapshot of plugins
|
||||
pushed via `use`. A new frozen array is allocated on every read.
|
||||
- `function createUriPluginCollector(): UriPluginCollector` — fresh
|
||||
collector backed by a private `UriPlugin[]`. `use()` pushes;
|
||||
`plugins` returns `Object.freeze([...store])`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `plugins` accessor returns a defensive copy. Strict-mode callers
|
||||
attempting `(c.plugins as UriPlugin[]).push(extra)` will receive a
|
||||
`TypeError` because the snapshot is frozen.
|
||||
- A previously-read snapshot does NOT reflect plugins added after the
|
||||
read — that is the whole point of returning a new frozen array each
|
||||
time. Subsequent `use(...)` calls show up only in subsequent `plugins`
|
||||
reads.
|
||||
- The interface does NOT extend `StorageUriResolver` and does NOT touch
|
||||
any resolver-shaped type. Keeping the collector resolver-free is what
|
||||
allows multi-resolver composition (parent + inner, multiple sinks,
|
||||
future builders) without forcing one shape on every consumer.
|
||||
- See the package `README.md` `## Composing IR content packages` section
|
||||
for the canonical recipe.
|
||||
@@ -0,0 +1,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>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { registerManifest } from "./register-manifest.js";
|
||||
export {
|
||||
createIrRegistry,
|
||||
type IrListOptions,
|
||||
type IrRegisterContext,
|
||||
type IrRegistry,
|
||||
} from "./registry.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
115
packages/media/audio-ir/audio-ir-core/src/registry/registry.md
Normal file
115
packages/media/audio-ir/audio-ir-core/src/registry/registry.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `registry` — `createIrRegistry()` + `IrRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
In-memory `Map<string, IrManifestEntry>` registry — the central seam
|
||||
between content packages (which register entries) and the loader (which
|
||||
looks them up). The registry is the only place that enforces the
|
||||
runtime invariants the schema cannot: duplicate-id detection, ID-prefix
|
||||
coherence against the registering manifest, family coherence between
|
||||
entry and manifest, and the typed `AUDIO_IR.*` throw vocabulary. Every
|
||||
mutation drives the OTel surface (counters + observable gauge).
|
||||
|
||||
The registry holds **manifest entries only**, not decoded
|
||||
`ImpulseResponse` values. Decoding happens lazily in
|
||||
`audio-ir-loader.load(id)` — keeping the registry data-only means
|
||||
content packages can be tree-shaken at the manifest level and decoders
|
||||
are pulled in only on demand. See `design.md` of the
|
||||
`add-audio-ir-core` change for the cross-cutting model.
|
||||
|
||||
## Features
|
||||
|
||||
- `createIrRegistry()` returns a fresh `IrRegistry` and installs a
|
||||
per-family size sampler with the OTel module — the
|
||||
`audio_ir.registry_size{family}` gauge reflects this registry's counts.
|
||||
- `register(entry, { manifestSlug, manifestFamily })` — validates the
|
||||
entry, enforces ID-prefix + family-coherence, then inserts. Throws
|
||||
`AUDIO_IR.INVALID_MANIFEST`, `AUDIO_IR.ID_PREFIX_MISMATCH`,
|
||||
`AUDIO_IR.FAMILY_MISMATCH`, or `AUDIO_IR.DUPLICATE_ID` with typed
|
||||
discriminants.
|
||||
- `has(id) / get(id)` — O(1) lookup. `get` throws
|
||||
`AUDIO_IR.NOT_REGISTERED` when absent.
|
||||
- `list({ family? })` — stable insertion-order enumeration.
|
||||
- `clear()` — wipes state. Provided for tests.
|
||||
- Every throw increments `audio_ir.errors_total{code}` via
|
||||
`recordError`. Every success increments
|
||||
`audio_ir.registrations_total` / `audio_ir.lookups_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
registry.register(
|
||||
{
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
},
|
||||
{ manifestSlug: "club", manifestFamily: "room" }
|
||||
);
|
||||
console.log(registry.has("club-club1")); // true
|
||||
console.log(registry.get("club-club1").id); // "club-club1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AUDIO_IR_CODES, AudioIrError, createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
function safeRegister(entry: unknown, ctx: { manifestSlug: string; manifestFamily: "room" | "mic" | "speaker" }): "ok" | "duplicate" | "rejected" {
|
||||
try {
|
||||
registry.register(entry as Parameters<typeof registry.register>[0], ctx);
|
||||
return "ok";
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrError) {
|
||||
if (err.code === AUDIO_IR_CODES.DUPLICATE_ID) return "duplicate";
|
||||
return "rejected";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const rooms = registry.list({ family: "room" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `createIrRegistry()` | function | Build a fresh `IrRegistry` and install its size sampler. |
|
||||
| `IrRegistry` | interface | `register`, `has`, `get`, `list`, `clear` methods. |
|
||||
| `IrRegisterContext` | interface | `{ manifestSlug: string; manifestFamily: IrFamily }` — required at register time. |
|
||||
| `IrListOptions` | interface | `{ family?: IrFamily }` — optional list filter. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`register` re-runs `IrManifestEntrySchema.parse` on every input that
|
||||
isn't already frozen. This means content packages that hand-craft a
|
||||
`IrManifestEntry` literal still get the full grammar guarantees — the
|
||||
schema is the single source of truth. Frozen entries (already produced
|
||||
by `IrManifestSchema.parse` upstream) are inserted by reference so the
|
||||
spec's `registry.get(id) === entry` identity invariant holds.
|
||||
|
||||
The registry maintains a private `familyCounts: Map<IrFamily, number>`
|
||||
on every `register` / `clear` and exposes it to the OTel observable
|
||||
gauge via the sampler hook installed in `createIrRegistry`. That keeps
|
||||
the gauge O(1) per scrape and impossible to drift from the actual
|
||||
registry state.
|
||||
|
||||
`register` failures do NOT short-circuit on the first issue — Zod issues
|
||||
are walked, the family-coherence and id-prefix checks happen even when
|
||||
the schema accepted the entry (callers wiring through
|
||||
`IrManifestSchema.parse` already hit those refinements), and the duplicate
|
||||
check fires only when the entry would otherwise have been accepted.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
210
packages/media/audio-ir/audio-ir-core/src/registry/registry.ts
Normal file
210
packages/media/audio-ir/audio-ir-core/src/registry/registry.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `types/impulse-response` — decoded `ImpulseResponse` schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the in-memory shape of a fully-decoded impulse response — the value
|
||||
`@suaveplan/audio-ir-loader.load(id)` returns and the convolver consumes.
|
||||
Decoded means there is no URI here, no I/O state, just channel buffers and
|
||||
the metadata needed to wire the IR into a Web Audio convolver or a Bun
|
||||
offline-render pipeline.
|
||||
|
||||
The schema enforces three coherence rules a typed `interface` cannot:
|
||||
|
||||
1. `channelData.length === channels`.
|
||||
2. Every `channelData[i].length === lengthFrames`.
|
||||
3. `durationSec === lengthFrames / sampleRate` (1 ms tolerance for FP).
|
||||
|
||||
These rules are the contract between the loader (which decodes WAV/AIFF
|
||||
bytes into `Float32Array` channels) and the registry's `manifest`
|
||||
reference. Violating any of them is a loader bug, not a content bug, and
|
||||
the schema catches it at the seam.
|
||||
|
||||
## Features
|
||||
|
||||
- Custom `Float32Array` Zod brand — exhaustively validates the prototype.
|
||||
- Cross-field coherence via `.superRefine()` — channel count + frame count
|
||||
- duration.
|
||||
- 1 ms tolerance on `durationSec` so floating-point IEEE-754 round-tripping
|
||||
is safe.
|
||||
- `.transform(Object.freeze)` on the entire object (channelData included).
|
||||
- Every parsed value carries its source `manifest` entry, so consumers
|
||||
walking a list of decoded IRs can still reach the provenance (license,
|
||||
source) without a second registry lookup.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ImpulseResponseSchema } from "@suaveplan/audio-ir-core/types";
|
||||
|
||||
const ir = ImpulseResponseSchema.parse({
|
||||
id: "club-club1",
|
||||
channelData: [new Float32Array([0.1, 0.2, 0.3])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
lengthFrames: 3,
|
||||
durationSec: 3 / 44100,
|
||||
gainDb: 0,
|
||||
family: "room",
|
||||
manifest: {
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ImpulseResponseSchema } from "@suaveplan/audio-ir-core/types";
|
||||
|
||||
function assertConvolverCompatible(ir: unknown): void {
|
||||
const parsed = ImpulseResponseSchema.parse(ir);
|
||||
if (parsed.channels > 2) {
|
||||
throw new Error(
|
||||
`convolver supports 1..2 channels, got ${parsed.channels} for ${parsed.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `ImpulseResponseSchema` | `ZodObject` | Strict, frozen, cross-field-coherent. |
|
||||
| `ImpulseResponse` | `type` | `z.infer<typeof ImpulseResponseSchema>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema lives in `audio-ir-core` rather than `audio-ir-loader` because
|
||||
the registry's `IrRegistry.get(id)` returns a `IrManifestEntry`, not an
|
||||
`ImpulseResponse` — the registry is intentionally data-only — but the
|
||||
loader's return-type contract is part of the foundation. Putting the
|
||||
schema here means content packages, test fixtures, and downstream
|
||||
convolver wrappers all parse through the same validator.
|
||||
|
||||
`Float32Array` is brought in via `z.custom` rather than `z.instanceof`
|
||||
because the structural Zod check is cheaper and forward-compatible with
|
||||
SharedArrayBuffer-backed typed arrays.
|
||||
|
||||
Channel buffers are NOT frozen individually (typed arrays are mutable by
|
||||
spec; freezing them yields a hard error in some runtimes). Freezing the
|
||||
top-level object is enough to prevent accidental wiring drift.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
16
packages/media/audio-ir/audio-ir-core/src/types/index.ts
Normal file
16
packages/media/audio-ir/audio-ir-core/src/types/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
97
packages/media/audio-ir/audio-ir-core/src/types/manifest.md
Normal file
97
packages/media/audio-ir/audio-ir-core/src/types/manifest.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# `types/manifest` — `IrManifest` Zod schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the on-the-wire shape of a complete IR manifest — the package-level
|
||||
contract a content package (e.g. `audio-ir-room-plugin-club`) ships so the
|
||||
registry knows which entries belong together, which slug-prefix to enforce,
|
||||
and which family to denormalise onto each entry. Every other component in
|
||||
the IR pipeline (loader, registry helper, UI filters) parses through this
|
||||
schema before trusting the data.
|
||||
|
||||
The schema enforces two cross-entry invariants that the entry-level schema
|
||||
cannot express by itself: id-prefix coherence and family coherence. Both
|
||||
violations surface as `ZodError` issues with a machine-readable `params`
|
||||
payload (`{ kind: "ID_PREFIX_MISMATCH" | "FAMILY_MISMATCH", ... }`) so the
|
||||
registry layer can rewrap them as typed `AudioIrError` throws without
|
||||
parsing the message string. The cross-cutting design lives in `design.md`
|
||||
of the spec change.
|
||||
|
||||
## Features
|
||||
|
||||
- Exported `IrManifestSlugSchema` standalone — reusable wherever a slug
|
||||
needs validating (CLI args, plugin manifests).
|
||||
- `.strict()` object — extra properties on the manifest rejected outright.
|
||||
- Cross-entry refinements via `.superRefine()` so multiple issues from one
|
||||
parse run surface as multiple Zod issues, not the first-failure-wins.
|
||||
- `.transform(Object.freeze)` — every parsed manifest is frozen.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core/types";
|
||||
|
||||
const manifest = IrManifestSchema.parse({
|
||||
packageSlug: "club",
|
||||
family: "room",
|
||||
entries: [
|
||||
{
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://audio-ir-room-plugin-club/club1.wav",
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core/types";
|
||||
|
||||
const result = IrManifestSchema.safeParse(untrustedInput);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const kind = (issue.params as { kind?: string } | undefined)?.kind;
|
||||
if (kind === "ID_PREFIX_MISMATCH") {
|
||||
const params = issue.params as {
|
||||
expectedPrefix: string;
|
||||
actualId: string;
|
||||
};
|
||||
console.error(
|
||||
`entry id "${params.actualId}" should start with "${params.expectedPrefix}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `IrManifestSlugSchema` | `ZodString` | Lower-kebab, letter-led, no `--`, 1..64 bytes. |
|
||||
| `IrManifestSchema` | `ZodObject` | Strict manifest with `superRefine` + freeze transform. |
|
||||
| `IrManifest` | `type` | `z.infer<typeof IrManifestSchema>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The slug grammar is capped at 64 bytes — half the entry id cap — because
|
||||
the slug + dash + filename stem all live inside a single 128-byte id and
|
||||
the filename stem deserves at least 63 bytes of headroom for descriptive
|
||||
names like `outdoor-swimming-pool`.
|
||||
|
||||
`superRefine` is used over `refine` because we want every entry's
|
||||
violations reported in the same `ZodError`, not just the first. That makes
|
||||
authoring a content-package manifest much faster — fix the whole batch in
|
||||
one round-trip instead of one issue at a time.
|
||||
|
||||
The custom-issue `params.kind` discriminant (`"ID_PREFIX_MISMATCH"` /
|
||||
`"FAMILY_MISMATCH"`) is the contract the registry layer parses to decide
|
||||
which `AudioIrError` code to throw. The keys are intentionally identical
|
||||
to the `AUDIO_IR.*` reason names so the wiring is mechanical.
|
||||
124
packages/media/audio-ir/audio-ir-core/src/types/manifest.test.ts
Normal file
124
packages/media/audio-ir/audio-ir-core/src/types/manifest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
packages/media/audio-ir/audio-ir-core/src/types/manifest.ts
Normal file
90
packages/media/audio-ir/audio-ir-core/src/types/manifest.ts
Normal 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>;
|
||||
12
packages/media/audio-ir/audio-ir-core/tsconfig.json
Normal file
12
packages/media/audio-ir/audio-ir-core/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/media/audio-ir/audio-ir-core/tsconfig.lib.json
Normal file
12
packages/media/audio-ir/audio-ir-core/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
44
packages/media/audio-ir/audio-ir-core/vite.config.ts
Normal file
44
packages/media/audio-ir/audio-ir-core/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
5
packages/media/audio-ir/audio-ir-core/vitest.config.ts
Normal file
5
packages/media/audio-ir/audio-ir-core/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30000,
|
||||
});
|
||||
Reference in New Issue
Block a user