Initial Commit
This commit is contained in:
16
packages/media/audio-ir/README.md
Normal file
16
packages/media/audio-ir/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# `audio-ir`
|
||||
|
||||
Impulse-response capture, loading, and room-acoustic plugins.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `audio-ir`
|
||||
|
||||
4 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/audio-ir-core`](./audio-ir-core/README.md) | Foundation contract for the `@suaveplan/audio-ir-*` family. Universal; zero `@suaveplan/audio-*` runtime deps. Every sibling package (`audio-ir-loader`, `… |
|
||||
| [`@suaveplan/audio-ir-loader`](./audio-ir-loader/README.md) | Pipeline package for the `@suaveplan/audio-ir-*` family. Composes `IrRegistry` (from `@suaveplan/audio-ir-core`), a `StorageUriResolver` (from `@suaveplan/stor… |
|
||||
| [`@suaveplan/audio-ir-room-plugin-club`](./audio-ir-room-plugin-club/README.md) | Original audio-ir content package. Ships two bundled room impulse responses (`club1.wav`, `club2.wav`) wired through the `@suaveplan/audio-ir-*` family. |
|
||||
| [`@suaveplan/audio-ir-room-plugin-pool`](./audio-ir-room-plugin-pool/README.md) | Original audio-ir content package. Ships a single `room`-family impulse response — `pool-outdoor-swimming-pool` — sourced from the outdoor-swimming-pool captur… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/media/audio-ir/_intro.md
Normal file
1
packages/media/audio-ir/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Impulse-response capture, loading, and room-acoustic plugins.
|
||||
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,
|
||||
});
|
||||
79
packages/media/audio-ir/audio-ir-loader/README.md
Normal file
79
packages/media/audio-ir/audio-ir-loader/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @suaveplan/audio-ir-loader - Pipeline package for the @suaveplan audio-ir family: composes IrRegistry → StorageUriResolver → AudioDecoder → manifest validation into a single createIrLoader(). Universal.
|
||||
|
||||
Pipeline package for the `@suaveplan/audio-ir-*` family. Composes
|
||||
`IrRegistry` (from `@suaveplan/audio-ir-core`), a `StorageUriResolver`
|
||||
(from `@suaveplan/storage-uri`), and a map of `AudioDecoder`s into a
|
||||
single `IrLoader` with `load`, `preload`, `evict`, and `cacheStats`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [cache](./src/cache/cache.md) — This module implements the in-process LRU that backs the loader's optional cache. The cache is keyed by IR id and bound…
|
||||
- [cache](./src/cache/cache.md) — This module implements the in-process LRU that backs the loader's optional cache. The cache is keyed by IR id and bound…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code raised by `@suaveplan/audio-ir-loader`. It uses `defineC…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code raised by `@suaveplan/audio-ir-loader`. It uses `defineC…
|
||||
- [loader](./src/loader/loader.md) — This module is the entry point for the package. `createIrLoader(options)` returns a typed `IrLoader` that composes the…
|
||||
- [extension](./src/loader/extension.md) — The loader dispatches decoders by file extension. This module owns the canonicalisation step: given a URI string (which…
|
||||
- [loader](./src/loader/loader.md) — This module is the entry point for the package. `createIrLoader(options)` returns a typed `IrLoader` that composes the…
|
||||
- [manifest-validation](./src/loader/manifest-validation.md) — This module is the bridge between the `DecodedAudio` shape emitted by an `AudioDecoder` and the `ImpulseResponse` shape…
|
||||
- [preload](./src/loader/preload.md) — This module implements the bounded-concurrency `preload` algorithm backing `IrLoader.preload`. It launches up to `concu…
|
||||
- [otel](./src/otel/otel.md) — This module owns the loader's OpenTelemetry instrumentation. It lazily builds a single, cached `AudioIrLoaderInstrument…
|
||||
- [otel](./src/otel/otel.md) — This module owns the loader's OpenTelemetry instrumentation. It lazily builds a single, cached `AudioIrLoaderInstrument…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
- `createIrLoader(options): IrLoader` — Zod-validated factory.
|
||||
- Bounded-concurrency `preload(ids, { concurrency })` with aggregate
|
||||
failure reporting.
|
||||
- Optional in-process byte-bounded LRU cache.
|
||||
- `AUDIO_IR_LOADER.*` error-code registry — exactly six codes
|
||||
(`DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
|
||||
`CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`).
|
||||
- OpenTelemetry surface — `audio_ir_loader.load_duration_ms`,
|
||||
`audio_ir_loader.bytes_read_total`, `audio_ir_loader.frames_out_total`,
|
||||
`audio_ir_loader.cache_hits_total`, `audio_ir_loader.cache_misses_total`,
|
||||
`audio_ir_loader.errors_total`.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Subpath | Docs |
|
||||
| --- | --- | --- |
|
||||
| Error codes | `./error-codes` | [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) |
|
||||
| OTel surface | (root) | [`src/otel/otel.md`](./src/otel/otel.md) |
|
||||
| LRU cache | (root) | [`src/cache/cache.md`](./src/cache/cache.md) |
|
||||
| `createIrLoader` | (root) | [`src/loader/loader.md`](./src/loader/loader.md) |
|
||||
| `extractExtension` | (root) | [`src/loader/extension.md`](./src/loader/extension.md) |
|
||||
| Manifest validation | (root) | [`src/loader/manifest-validation.md`](./src/loader/manifest-validation.md) |
|
||||
| Bounded `preload` | (root) | [`src/loader/preload.md`](./src/loader/preload.md) |
|
||||
|
||||
## Design
|
||||
|
||||
The pipeline + dispatch + cache decisions live at
|
||||
|
||||
the cross-cutting IR-family model lives at
|
||||
|
||||
|
||||
## Quick install
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
bundleUriPlugin({
|
||||
packageSlug: "club",
|
||||
rootUrl: new URL("file:///opt/ir/club/"),
|
||||
}),
|
||||
]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
cache: { maxBytes: 64 * 1024 * 1024 },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
```
|
||||
|
||||
70
packages/media/audio-ir/audio-ir-loader/package.json
Normal file
70
packages/media/audio-ir/audio-ir-loader/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-ir-loader",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Pipeline package for the @suaveplan audio-ir family: composes IrRegistry → StorageUriResolver → AudioDecoder → manifest validation into a single createIrLoader(). Universal.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/audio-ir-core": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/storage-uri": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-decode-wav": "workspace:^",
|
||||
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
|
||||
"@suaveplan/storage-uri-plugin-fallback": "workspace:^",
|
||||
"@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"
|
||||
]
|
||||
}
|
||||
101
packages/media/audio-ir/audio-ir-loader/src/cache/cache.md
vendored
Normal file
101
packages/media/audio-ir/audio-ir-loader/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# `cache.ts` — byte-bounded LRU for decoded `ImpulseResponse`s
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the in-process LRU that backs the loader's optional
|
||||
cache. The cache is keyed by IR id and bounded by total decoded bytes —
|
||||
not entry count — because IRs vary wildly in size (a 1-second mic IR vs.
|
||||
a 10-second cathedral room IR can differ by 100x) and a count-based budget
|
||||
gives unpredictable memory behaviour. Eviction is oldest-by-insertion, not
|
||||
oldest-by-access, because IRs are typically loaded once at app start and
|
||||
then held for the session: insertion-time eviction is both simpler and
|
||||
gives more predictable memory occupancy than the LRU-by-touch variant.
|
||||
|
||||
## Features
|
||||
|
||||
- Byte-bounded budget (`maxBytes`) with strict enforcement.
|
||||
- Single-IR `CACHE_OVERFLOW` throw rather than thrashing the rest of the
|
||||
cache to fit one oversized IR.
|
||||
- Hit / miss counters surfaced via `stats()` for `IrLoader.cacheStats()`.
|
||||
- Updates over an existing id replace in-place (and re-budget).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ImpulseResponseCache } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const cache = new ImpulseResponseCache(1024 * 1024);
|
||||
cache.put("club-club1", ir);
|
||||
const hit = cache.get("club-club1"); // === ir
|
||||
console.log(cache.stats()); // { entries: 1, bytes: ..., hits: 1, misses: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The eviction loop is deterministic: `Map.keys()` enumerates insertion
|
||||
order, so callers can reason about exactly which entry will be evicted
|
||||
when budget is exceeded. The pattern below demonstrates the
|
||||
`CACHE_OVERFLOW` boundary:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
ImpulseResponseCache,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const cache = new ImpulseResponseCache(1024);
|
||||
try {
|
||||
cache.put("big", giantIr); // channelData totals 4096 bytes
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioIrLoaderError &&
|
||||
err.code === AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW
|
||||
) {
|
||||
console.error(
|
||||
`IR ${err.id} needs ${err.decodedBytes} bytes (budget ${err.maxBytes})`
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ImpulseResponseCache`
|
||||
|
||||
- `new ImpulseResponseCache(maxBytes: number)` — `maxBytes` must be a
|
||||
positive integer; otherwise throws `CACHE_OVERFLOW` at construction.
|
||||
- `get(id: string): ImpulseResponse | undefined` — increments hits on
|
||||
success, misses on absence.
|
||||
- `put(id: string, ir: ImpulseResponse): void` — throws `CACHE_OVERFLOW`
|
||||
if `ir`'s decoded bytes exceed `maxBytes`; otherwise evicts oldest
|
||||
entries until the new IR fits.
|
||||
- `evict(id: string): boolean` — drops the entry; returns whether
|
||||
anything was removed.
|
||||
- `stats(): CacheStats` — snapshot of `{ entries, bytes, hits, misses }`.
|
||||
- `maxBytes` getter — the configured byte budget.
|
||||
|
||||
### `CacheStats`
|
||||
|
||||
`{ entries: number; bytes: number; hits: number; misses: number }`.
|
||||
|
||||
### `impulseResponseBytes(ir): number`
|
||||
|
||||
Sum of `channelData[i].byteLength` over all channels — the canonical
|
||||
budgeting metric used by the cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Map` preserves insertion order in JavaScript, so the eviction iterator
|
||||
walks entries in the order they were `put()` — re-inserting an existing
|
||||
id moves it to the most-recent slot (via `delete` then `set`). The
|
||||
`stats()` snapshot is freshly built on every call (no caching) because
|
||||
callers expect a point-in-time view; the cost is bounded by the size of
|
||||
the returned object (four numbers) and dominated by the V8 allocation
|
||||
itself. The constructor's positive-integer check protects against the
|
||||
`new ImpulseResponseCache(0)` foot-gun, which would otherwise let every
|
||||
single `put` throw and obscure the misconfiguration.
|
||||
142
packages/media/audio-ir/audio-ir-loader/src/cache/cache.test.ts
vendored
Normal file
142
packages/media/audio-ir/audio-ir-loader/src/cache/cache.test.ts
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { ImpulseResponse } from "@suaveplan/audio-ir-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { ImpulseResponseCache, impulseResponseBytes } from "./cache.js";
|
||||
|
||||
const ENTRY = (id: string) => ({
|
||||
id,
|
||||
family: "room" as const,
|
||||
uri: `bundle://x/${id}.wav`,
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
});
|
||||
|
||||
function makeIR(id: string, bytesPerChannel: number): ImpulseResponse {
|
||||
const samples = bytesPerChannel / 4;
|
||||
const buf = new Float32Array(samples);
|
||||
return Object.freeze({
|
||||
id,
|
||||
channelData: Object.freeze([buf]),
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
lengthFrames: samples,
|
||||
durationSec: samples / 44100,
|
||||
gainDb: 0,
|
||||
family: "room",
|
||||
manifest: Object.freeze(ENTRY(id)),
|
||||
}) as ImpulseResponse;
|
||||
}
|
||||
|
||||
describe("impulseResponseBytes", () => {
|
||||
it("sums byteLength across channels", { timeout: 5000 }, () => {
|
||||
const ir = makeIR("club-x", 1024);
|
||||
expect(impulseResponseBytes(ir)).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImpulseResponseCache", () => {
|
||||
it("rejects non-positive maxBytes at construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new ImpulseResponseCache(0)).toThrow(AudioIrLoaderError);
|
||||
expect(() => new ImpulseResponseCache(-1)).toThrow(AudioIrLoaderError);
|
||||
expect(() => new ImpulseResponseCache(1.5)).toThrow(AudioIrLoaderError);
|
||||
});
|
||||
|
||||
it("get returns undefined and counts misses for absent keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new ImpulseResponseCache(4096);
|
||||
expect(c.get("nope")).toBeUndefined();
|
||||
expect(c.stats()).toEqual({
|
||||
entries: 0,
|
||||
bytes: 0,
|
||||
hits: 0,
|
||||
misses: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("second load returns cached reference without re-decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new ImpulseResponseCache(4096);
|
||||
const ir = makeIR("club-a", 1024);
|
||||
c.put("club-a", ir);
|
||||
const first = c.get("club-a");
|
||||
const second = c.get("club-a");
|
||||
expect(first).toBe(ir);
|
||||
expect(second).toBe(ir);
|
||||
expect(c.stats().hits).toBe(2);
|
||||
});
|
||||
|
||||
it("LRU evicts oldest by insertion when maxBytes exceeded", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// maxBytes sized to hold exactly two 1024-byte IRs.
|
||||
const c = new ImpulseResponseCache(2048);
|
||||
c.put("a", makeIR("a", 1024));
|
||||
c.put("b", makeIR("b", 1024));
|
||||
c.put("c", makeIR("c", 1024));
|
||||
expect(c.stats().entries).toBe(2);
|
||||
expect(c.get("a")).toBeUndefined();
|
||||
expect(c.get("b")).toBeDefined();
|
||||
expect(c.get("c")).toBeDefined();
|
||||
});
|
||||
|
||||
it("oversize IR throws CACHE_OVERFLOW", { timeout: 5000 }, () => {
|
||||
const c = new ImpulseResponseCache(1024);
|
||||
const big = makeIR("big", 4096);
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
c.put("big", big);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW);
|
||||
expect(captured?.maxBytes).toBe(1024);
|
||||
expect(captured?.decodedBytes).toBe(4096);
|
||||
expect(captured?.id).toBe("big");
|
||||
});
|
||||
|
||||
it("evict returns true on hit and false on miss", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new ImpulseResponseCache(2048);
|
||||
c.put("x", makeIR("x", 512));
|
||||
expect(c.evict("x")).toBe(true);
|
||||
expect(c.evict("x")).toBe(false);
|
||||
expect(c.stats().entries).toBe(0);
|
||||
expect(c.stats().bytes).toBe(0);
|
||||
});
|
||||
|
||||
it("put over an existing id replaces the entry in place", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new ImpulseResponseCache(4096);
|
||||
c.put("x", makeIR("x", 1024));
|
||||
c.put("x", makeIR("x", 2048));
|
||||
expect(c.stats().entries).toBe(1);
|
||||
expect(c.stats().bytes).toBe(2048);
|
||||
});
|
||||
|
||||
it("maxBytes getter reports the configured budget", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new ImpulseResponseCache(8192);
|
||||
expect(c.maxBytes).toBe(8192);
|
||||
});
|
||||
|
||||
it("stats reflects bytes after eviction", { timeout: 5000 }, () => {
|
||||
const c = new ImpulseResponseCache(2048);
|
||||
c.put("a", makeIR("a", 1024));
|
||||
c.put("b", makeIR("b", 1024));
|
||||
c.put("c", makeIR("c", 1024));
|
||||
expect(c.stats().bytes).toBe(2048);
|
||||
});
|
||||
});
|
||||
132
packages/media/audio-ir/audio-ir-loader/src/cache/cache.ts
vendored
Normal file
132
packages/media/audio-ir/audio-ir-loader/src/cache/cache.ts
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { ImpulseResponse } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Snapshot of cache state surfaced via `IrLoader.cacheStats()`. */
|
||||
export interface CacheStats {
|
||||
readonly entries: number;
|
||||
readonly bytes: number;
|
||||
readonly hits: number;
|
||||
readonly misses: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the total decoded byte-length of an `ImpulseResponse`'s
|
||||
* channelData. Used both for cache accounting and for the `CACHE_OVERFLOW`
|
||||
* pre-check.
|
||||
*/
|
||||
export function impulseResponseBytes(ir: ImpulseResponse): number {
|
||||
let total = 0;
|
||||
for (const channel of ir.channelData) {
|
||||
total += channel.byteLength;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
readonly ir: ImpulseResponse;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process LRU keyed by IR id, bounded by total decoded bytes.
|
||||
*
|
||||
* Eviction is oldest-by-insertion (not access-time): IRs are typically
|
||||
* loaded once and held, so insertion-time eviction is both simpler and
|
||||
* better-behaved than access-time LRU at this scale. A single IR larger
|
||||
* than the configured `maxBytes` throws `CACHE_OVERFLOW` instead of
|
||||
* evicting every other entry — better to fail loud than to thrash.
|
||||
*/
|
||||
export class ImpulseResponseCache {
|
||||
readonly #maxBytes: number;
|
||||
readonly #entries = new Map<string, CacheEntry>();
|
||||
#bytes = 0;
|
||||
#hits = 0;
|
||||
#misses = 0;
|
||||
|
||||
public constructor(maxBytes: number) {
|
||||
if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW,
|
||||
"cache maxBytes must be a positive integer",
|
||||
{ maxBytes }
|
||||
);
|
||||
}
|
||||
this.#maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
/** Configured byte budget. */
|
||||
public get maxBytes(): number {
|
||||
return this.#maxBytes;
|
||||
}
|
||||
|
||||
/** `get(id)` — increments hits on success, misses on absence. */
|
||||
public get(id: string): ImpulseResponse | undefined {
|
||||
const entry = this.#entries.get(id);
|
||||
if (entry === undefined) {
|
||||
this.#misses += 1;
|
||||
return undefined;
|
||||
}
|
||||
this.#hits += 1;
|
||||
return entry.ir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `ir` into the cache. Throws `CACHE_OVERFLOW` if `ir` alone
|
||||
* exceeds `maxBytes`. Otherwise evicts oldest-by-insertion entries
|
||||
* until total bytes ≤ `maxBytes - irBytes`, then inserts.
|
||||
*/
|
||||
public put(id: string, ir: ImpulseResponse): void {
|
||||
const irBytes = impulseResponseBytes(ir);
|
||||
if (irBytes > this.#maxBytes) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW,
|
||||
`IR ${id} decodes to ${irBytes} bytes; cache budget is ${this.#maxBytes}`,
|
||||
{
|
||||
id,
|
||||
maxBytes: this.#maxBytes,
|
||||
decodedBytes: irBytes,
|
||||
}
|
||||
);
|
||||
}
|
||||
const existing = this.#entries.get(id);
|
||||
if (existing !== undefined) {
|
||||
this.#bytes -= existing.bytes;
|
||||
this.#entries.delete(id);
|
||||
}
|
||||
// Evict oldest until insertion fits. The constructor enforces
|
||||
// `irBytes <= maxBytes`, so the loop is guaranteed to terminate
|
||||
// before draining every entry — the `Map.keys()` iterator and
|
||||
// its corresponding `get()` are always defined for live entries.
|
||||
const iter = this.#entries.keys();
|
||||
while (this.#bytes + irBytes > this.#maxBytes) {
|
||||
const evictId = iter.next().value as string;
|
||||
const evictEntry = this.#entries.get(evictId) as CacheEntry;
|
||||
this.#bytes -= evictEntry.bytes;
|
||||
this.#entries.delete(evictId);
|
||||
}
|
||||
this.#entries.set(id, { ir, bytes: irBytes });
|
||||
this.#bytes += irBytes;
|
||||
}
|
||||
|
||||
/** Drop the entry for `id`. Returns whether anything was removed. */
|
||||
public evict(id: string): boolean {
|
||||
const entry = this.#entries.get(id);
|
||||
if (entry === undefined) return false;
|
||||
this.#bytes -= entry.bytes;
|
||||
this.#entries.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snapshot of current cache state. */
|
||||
public stats(): CacheStats {
|
||||
return {
|
||||
entries: this.#entries.size,
|
||||
bytes: this.#bytes,
|
||||
hits: this.#hits,
|
||||
misses: this.#misses,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
packages/media/audio-ir/audio-ir-loader/src/cache/index.ts
vendored
Normal file
5
packages/media/audio-ir/audio-ir-loader/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { CacheStats } from "./cache.js";
|
||||
export {
|
||||
ImpulseResponseCache,
|
||||
impulseResponseBytes,
|
||||
} from "./cache.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# `error-codes.ts` — `AUDIO_IR_LOADER.*` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single source of truth for every error code raised by
|
||||
`@suaveplan/audio-ir-loader`. It uses `defineCodes` from `@suaveplan/error`
|
||||
to register an immutable, byte-stable mapping of short keys (`MANIFEST_MISMATCH`)
|
||||
to fully-qualified strings (`AUDIO_IR_LOADER.MANIFEST_MISMATCH`). It also
|
||||
declares the `AudioIrLoaderError` class, which exposes every dimension an
|
||||
operator might filter on (`id`, `extension`, `field`, `manifestValue`,
|
||||
`decodedValue`, `maxBytes`, `decodedBytes`, `errors`) as typed read-only
|
||||
properties so dashboards and routing layers never have to parse message
|
||||
text to know what happened.
|
||||
|
||||
## Features
|
||||
|
||||
- Six codes — `DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
|
||||
`CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`.
|
||||
- Frozen registry object — `Object.isFrozen(AUDIO_IR_LOADER_CODES)` is true.
|
||||
- Typed error class with discriminants pre-typed on the class.
|
||||
- Aggregate `errors[]` for the `DECODE_FAILED` partial-failure path from
|
||||
`preload`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
"no decoder registered for .flac",
|
||||
{ id: "club-club1", extension: "flac" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The aggregate `errors` discriminant powers the `preload` partial-failure
|
||||
contract:
|
||||
|
||||
```ts
|
||||
import { AudioIrLoaderError, AUDIO_IR_LOADER_CODES } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
await loader.preload(["a", "b", "c"]);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioIrLoaderError &&
|
||||
err.code === AUDIO_IR_LOADER_CODES.DECODE_FAILED
|
||||
) {
|
||||
for (const { id, error } of err.errors ?? []) {
|
||||
console.error(`load ${id} failed:`, error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Operators routing on the structured discriminants can ship dashboards that
|
||||
break down failures by `extension` for `DECODER_UNAVAILABLE`, by `field` for
|
||||
`MANIFEST_MISMATCH`, and by the size pair (`maxBytes` / `decodedBytes`) for
|
||||
`CACHE_OVERFLOW`, with zero message-text regexes.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUDIO_IR_LOADER_CODES`
|
||||
|
||||
Frozen `Readonly<Record<Key, "AUDIO_IR_LOADER.<Key>">>` with keys
|
||||
`DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`, `CACHE_OVERFLOW`,
|
||||
`DECODE_FAILED`, `RESOLVE_FAILED`.
|
||||
|
||||
### `AudioIrLoaderError extends SuaveplanError`
|
||||
|
||||
Constructor: `(code, message, options?)`. Discriminants on the instance:
|
||||
`id`, `extension`, `field`, `manifestValue`, `decodedValue`, `maxBytes`,
|
||||
`decodedBytes`, `errors`. `name === "AudioIrLoaderError"`.
|
||||
|
||||
### `AudioIrLoaderErrorOptions`
|
||||
|
||||
Extends `SuaveplanErrorOptions` with the seven optional discriminants
|
||||
listed on the class plus `errors: readonly AggregatedLoadFailure[]`.
|
||||
|
||||
### `AggregatedLoadFailure`
|
||||
|
||||
`{ id: string; error: SuaveplanError }` — one entry per failed id in a
|
||||
`preload` aggregate.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object; the registry guarantees byte-stable
|
||||
code strings across builds. The discriminants on `AudioIrLoaderError` are
|
||||
declared as `T | undefined` rather than via `exactOptionalPropertyTypes`
|
||||
gymnastics so callers reading the property always get an unambiguous
|
||||
"absent" sentinel. Every code in this registry is exercised by a real
|
||||
call site under test (see `error-codes.test.ts`) — the exhaustion test
|
||||
guarantees we cannot register a code that has no production thrower.
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ImpulseResponseCache } from "../cache/cache.js";
|
||||
import { type AudioDecoder, createIrLoader } from "../loader/loader.js";
|
||||
import { runPreload } from "../loader/preload.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
type AudioIrLoaderErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
const ENTRY: IrManifestEntry = Object.freeze({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
}) as IrManifestEntry;
|
||||
|
||||
const FLAC_ENTRY: IrManifestEntry = Object.freeze({
|
||||
...ENTRY,
|
||||
id: "club-club2",
|
||||
uri: "bundle://x/club2.flac",
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function buildResolver(reads: Record<string, Uint8Array>): {
|
||||
resolver: ReturnType<typeof createStorageUriResolver>;
|
||||
plugin: UriPlugin;
|
||||
} {
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads[key];
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return key in reads;
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
const noScheme = uri.replace(/^bundle:\/\//, "");
|
||||
return { key: noScheme };
|
||||
},
|
||||
};
|
||||
return { resolver: createStorageUriResolver([plugin]), plugin };
|
||||
}
|
||||
|
||||
const noopWavBytes = new Uint8Array([1, 2, 3]);
|
||||
function decoderThrowing(code: string): AudioDecoder {
|
||||
return async () => {
|
||||
const err = new Error(`fixture decoder threw ${code}`);
|
||||
throw err;
|
||||
};
|
||||
}
|
||||
|
||||
async function exerciseDecoderUnavailable(): Promise<
|
||||
AudioIrLoaderError | undefined
|
||||
> {
|
||||
resetInstruments();
|
||||
const registry = createIrRegistry();
|
||||
registry.register(FLAC_ENTRY, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const { resolver } = buildResolver({ "x/club2.flac": noopWavBytes });
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoderThrowing("nope") },
|
||||
});
|
||||
try {
|
||||
await loader.load("club-club2");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) return err;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exerciseManifestMismatch(): Promise<
|
||||
AudioIrLoaderError | undefined
|
||||
> {
|
||||
resetInstruments();
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0.1])],
|
||||
channels: 1,
|
||||
sampleRate: 22050,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) return err;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exerciseAborted(): Promise<AudioIrLoaderError | undefined> {
|
||||
resetInstruments();
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("preflight"));
|
||||
try {
|
||||
await loader.load("club-club1", { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) return err;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exerciseCacheOverflow(): Promise<
|
||||
AudioIrLoaderError | undefined
|
||||
> {
|
||||
const cache = new ImpulseResponseCache(64);
|
||||
const big = Object.freeze({
|
||||
id: "big",
|
||||
channelData: Object.freeze([new Float32Array(1024)]),
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
lengthFrames: 1024,
|
||||
durationSec: 1024 / 44100,
|
||||
gainDb: 0,
|
||||
family: "room" as const,
|
||||
manifest: ENTRY,
|
||||
});
|
||||
try {
|
||||
cache.put("big", big as unknown as Parameters<typeof cache.put>[1]);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) return err;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exerciseDecodeFailed(): Promise<AudioIrLoaderError | undefined> {
|
||||
resetInstruments();
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 99,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
try {
|
||||
await runPreload(
|
||||
(id, opts) => loader.load(id, opts),
|
||||
["club-club1"],
|
||||
undefined
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) return err;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exerciseResolveFailed(): Promise<
|
||||
AudioIrLoaderError | undefined
|
||||
> {
|
||||
resetInstruments();
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const { resolver } = buildResolver({ "x/club1.wav": noopWavBytes });
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
// Build a fake load fn that throws a non-SuaveplanError to trigger
|
||||
// the RESOLVE_FAILED wrapper inside runPreload.
|
||||
const badLoader = async () => {
|
||||
throw new TypeError("plain error");
|
||||
};
|
||||
try {
|
||||
await runPreload(
|
||||
badLoader as unknown as (id: string) => Promise<never>,
|
||||
["x"],
|
||||
undefined
|
||||
);
|
||||
} catch (err) {
|
||||
// top-level err is DECODE_FAILED whose `errors[0].error` is the RESOLVE_FAILED
|
||||
if (err instanceof AudioIrLoaderError && err.errors !== undefined) {
|
||||
const first = err.errors[0]?.error;
|
||||
if (first instanceof AudioIrLoaderError) return first;
|
||||
}
|
||||
}
|
||||
// touch loader to avoid unused warning
|
||||
void loader;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function siteFor(
|
||||
code: AudioIrLoaderErrorCode
|
||||
): () => Promise<AudioIrLoaderError | undefined> {
|
||||
if (code === AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE)
|
||||
return exerciseDecoderUnavailable;
|
||||
if (code === AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH)
|
||||
return exerciseManifestMismatch;
|
||||
if (code === AUDIO_IR_LOADER_CODES.ABORTED) return exerciseAborted;
|
||||
if (code === AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW)
|
||||
return exerciseCacheOverflow;
|
||||
if (code === AUDIO_IR_LOADER_CODES.DECODE_FAILED)
|
||||
return exerciseDecodeFailed;
|
||||
if (code === AUDIO_IR_LOADER_CODES.RESOLVE_FAILED)
|
||||
return exerciseResolveFailed;
|
||||
return async () => undefined;
|
||||
}
|
||||
|
||||
describe("AUDIO_IR_LOADER_CODES exhaustion", () => {
|
||||
it("every AUDIO_IR_LOADER code reachable from a real call site", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const all = Object.values(AUDIO_IR_LOADER_CODES);
|
||||
expect(all.length).toBe(6);
|
||||
for (const code of all) {
|
||||
const err = await siteFor(code)();
|
||||
expect(err, `no real call site exercises ${code}`).toBeDefined();
|
||||
expect(err?.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry exposes exactly six codes", { timeout: 5000 }, () => {
|
||||
const names = Object.keys(AUDIO_IR_LOADER_CODES).sort();
|
||||
expect(names).toEqual([
|
||||
"ABORTED",
|
||||
"CACHE_OVERFLOW",
|
||||
"DECODER_UNAVAILABLE",
|
||||
"DECODE_FAILED",
|
||||
"MANIFEST_MISMATCH",
|
||||
"RESOLVE_FAILED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("AUDIO_IR_LOADER_CODES registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(AUDIO_IR_LOADER_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("AudioIrLoaderError surfaces typed discriminants", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
||||
"x",
|
||||
{
|
||||
id: "club-a",
|
||||
field: "channels",
|
||||
manifestValue: 2,
|
||||
decodedValue: 1,
|
||||
}
|
||||
);
|
||||
expect(err.name).toBe("AudioIrLoaderError");
|
||||
expect(err.id).toBe("club-a");
|
||||
expect(err.field).toBe("channels");
|
||||
expect(err.manifestValue).toBe(2);
|
||||
expect(err.decodedValue).toBe(1);
|
||||
});
|
||||
|
||||
it("AudioIrLoaderError with no options leaves discriminants undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AudioIrLoaderError(AUDIO_IR_LOADER_CODES.ABORTED, "x");
|
||||
expect(err.id).toBeUndefined();
|
||||
expect(err.extension).toBeUndefined();
|
||||
expect(err.field).toBeUndefined();
|
||||
expect(err.manifestValue).toBeUndefined();
|
||||
expect(err.decodedValue).toBeUndefined();
|
||||
expect(err.maxBytes).toBeUndefined();
|
||||
expect(err.decodedBytes).toBeUndefined();
|
||||
expect(err.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every error code raised by `@suaveplan/audio-ir-loader`.
|
||||
*
|
||||
* Six codes total — `DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`,
|
||||
* `CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`. Every code is
|
||||
* reachable from a real production call site exercised by the test suite.
|
||||
*
|
||||
* Errors from upstream packages (`AUDIO_IR.*`, `STORAGE_URI.*`,
|
||||
* `BUNDLE_URI.*`, `FALLBACK_URI.*`, `DRIVE_URI.*`, `AUDIO_DECODE.<FMT>.*`)
|
||||
* pass through verbatim — the loader does NOT re-wrap them.
|
||||
*/
|
||||
export const AUDIO_IR_LOADER_CODES = defineCodes("AUDIO_IR_LOADER", [
|
||||
"DECODER_UNAVAILABLE",
|
||||
"MANIFEST_MISMATCH",
|
||||
"ABORTED",
|
||||
"CACHE_OVERFLOW",
|
||||
"DECODE_FAILED",
|
||||
"RESOLVE_FAILED",
|
||||
] as const);
|
||||
|
||||
export type AudioIrLoaderErrorCode =
|
||||
(typeof AUDIO_IR_LOADER_CODES)[keyof typeof AUDIO_IR_LOADER_CODES];
|
||||
|
||||
/** Per-id failure entry carried by aggregate `DECODE_FAILED` errors. */
|
||||
export interface AggregatedLoadFailure {
|
||||
readonly id: string;
|
||||
readonly error: SuaveplanError;
|
||||
}
|
||||
|
||||
export interface AudioIrLoaderErrorOptions extends SuaveplanErrorOptions {
|
||||
/** The IR identifier the operation targeted. */
|
||||
id?: string | undefined;
|
||||
/** File extension extracted from the URI for DECODER_UNAVAILABLE. */
|
||||
extension?: string | undefined;
|
||||
/** Field on which the manifest diverged from the decoded audio. */
|
||||
field?: "channels" | "sampleRate" | undefined;
|
||||
/** Manifest-declared value for MANIFEST_MISMATCH. */
|
||||
manifestValue?: number | undefined;
|
||||
/** Decoded value for MANIFEST_MISMATCH. */
|
||||
decodedValue?: number | undefined;
|
||||
/** Configured cache budget for CACHE_OVERFLOW. */
|
||||
maxBytes?: number | undefined;
|
||||
/** Decoded byte total for CACHE_OVERFLOW. */
|
||||
decodedBytes?: number | undefined;
|
||||
/** Per-id error list for aggregate DECODE_FAILED throws. */
|
||||
errors?: readonly AggregatedLoadFailure[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for every `AUDIO_IR_LOADER.*` throw.
|
||||
*
|
||||
* Every dimension an operator filters on is a typed discriminant — never
|
||||
* parsed from message text. All fields are exposed as readonly properties.
|
||||
*/
|
||||
export class AudioIrLoaderError extends SuaveplanError<AudioIrLoaderErrorCode> {
|
||||
public readonly id: string | undefined;
|
||||
public readonly extension: string | undefined;
|
||||
public readonly field: "channels" | "sampleRate" | undefined;
|
||||
public readonly manifestValue: number | undefined;
|
||||
public readonly decodedValue: number | undefined;
|
||||
public readonly maxBytes: number | undefined;
|
||||
public readonly decodedBytes: number | undefined;
|
||||
public readonly errors: readonly AggregatedLoadFailure[] | undefined;
|
||||
|
||||
public constructor(
|
||||
code: AudioIrLoaderErrorCode,
|
||||
message: string,
|
||||
options?: AudioIrLoaderErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.id = options?.id;
|
||||
this.extension = options?.extension;
|
||||
this.field = options?.field;
|
||||
this.manifestValue = options?.manifestValue;
|
||||
this.decodedValue = options?.decodedValue;
|
||||
this.maxBytes = options?.maxBytes;
|
||||
this.decodedBytes = options?.decodedBytes;
|
||||
this.errors = options?.errors;
|
||||
this.name = "AudioIrLoaderError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
AggregatedLoadFailure,
|
||||
AudioIrLoaderErrorCode,
|
||||
AudioIrLoaderErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "./error-codes.js";
|
||||
42
packages/media/audio-ir/audio-ir-loader/src/index.ts
Normal file
42
packages/media/audio-ir/audio-ir-loader/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Side-effect import: ensures the AUDIO_IR_LOADER.* code registry registers
|
||||
// even when subpath consumers never reach this barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
type CacheStats,
|
||||
ImpulseResponseCache,
|
||||
impulseResponseBytes,
|
||||
} from "./cache/cache.js";
|
||||
export {
|
||||
type AggregatedLoadFailure,
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
type AudioIrLoaderErrorCode,
|
||||
type AudioIrLoaderErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { extractExtension } from "./loader/extension.js";
|
||||
export {
|
||||
type AudioDecoder,
|
||||
type CreateIrLoaderOptions,
|
||||
createIrLoader,
|
||||
type IrLoader,
|
||||
type LoadOptions,
|
||||
type PreloadOptions,
|
||||
} from "./loader/loader.js";
|
||||
export {
|
||||
type DecodedAudioLike,
|
||||
validateAndFinalize,
|
||||
} from "./loader/manifest-validation.js";
|
||||
export { runPreload } from "./loader/preload.js";
|
||||
export {
|
||||
type AudioIrLoaderInstruments,
|
||||
type AudioIrLoaderLoadOutcome,
|
||||
getInstruments,
|
||||
recordCacheHit,
|
||||
recordCacheMiss,
|
||||
recordError,
|
||||
recordSuccess,
|
||||
resetInstruments,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
uriKindOf,
|
||||
} from "./otel/otel.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `extension.ts` — URI → file-extension extraction
|
||||
|
||||
## Purpose
|
||||
|
||||
The loader dispatches decoders by file extension. This module owns the
|
||||
canonicalisation step: given a URI string (which may be `bundle://`,
|
||||
`https://`, `drive://`, plain path-based, or `fallback://?u=…&u=…`),
|
||||
return the lowercase extension that decides which `AudioDecoder` runs.
|
||||
Cross-format `fallback://` URIs are explicitly rejected with
|
||||
`DECODER_UNAVAILABLE` — a single load must pick a single decoder.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal across `bundle://`, `https://`, `drive://`, and arbitrary
|
||||
path-based URIs.
|
||||
- Recursive handling for `fallback://` URIs — every candidate is
|
||||
re-extracted and the result must agree across candidates.
|
||||
- Query and fragment components are stripped before extension extraction.
|
||||
- Throws `AUDIO_IR_LOADER.DECODER_UNAVAILABLE` on every failure mode
|
||||
(missing extension, parse error, candidate disagreement).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { extractExtension } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
extractExtension("bundle://x/foo.wav"); // → "wav"
|
||||
extractExtension("https://cdn.example.com/path/clip.WAV?v=2"); // → "wav"
|
||||
extractExtension("fallback://?u=bundle%3A%2F%2Fx%2Fa.wav&u=https%3A%2F%2Fh%2Fa.wav");
|
||||
// → "wav"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The loader uses this helper at the start of every `load(id)` to pick the
|
||||
right decoder before any I/O happens:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
extractExtension,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
function pickDecoder<D>(decoders: Record<string, D>, uri: string, id: string): D {
|
||||
const ext = extractExtension(uri, id);
|
||||
const decoder = decoders[ext];
|
||||
if (decoder === undefined) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`no decoder registered for '.${ext}'`,
|
||||
{ id, extension: ext }
|
||||
);
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
```
|
||||
|
||||
Cross-format fallback URIs surface as a single `DECODER_UNAVAILABLE`
|
||||
throw with the first extension preserved on the error's `extension`
|
||||
discriminant, which makes the diagnostic easy to act on in the manifest
|
||||
authoring step.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `extractExtension(uri: string, id?: string): string`
|
||||
|
||||
Returns the lowercase extension. The optional `id` is forwarded to the
|
||||
`AudioIrLoaderError.id` discriminant on every throw so observers can
|
||||
filter by IR identifier.
|
||||
|
||||
Throws `AUDIO_IR_LOADER.DECODER_UNAVAILABLE` when:
|
||||
|
||||
- the URI carries no `.<ext>` segment after the last `/`,
|
||||
- the URI is `fallback://` and is not parseable as a URL,
|
||||
- the URI is `fallback://` with zero `u=` candidates,
|
||||
- the URI is `fallback://` and candidates disagree on extension.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `bundle://x/y.wav` URI form is technically not a valid RFC 3986
|
||||
authority-less URI but every consumer of `audio-ir-loader` already deals
|
||||
with it through `storage-uri-plugin-bundle`'s own parser; the
|
||||
path-stripping in this module is intentionally permissive about authority
|
||||
shape so callers get a consistent dispatch result regardless of whether
|
||||
the path-component is `//host/key.wav` or `:/path/key.wav`. The
|
||||
`fallback://` walk delegates to `extractExtension` recursively, so any
|
||||
future URI scheme that itself wraps candidates (e.g. a hypothetical
|
||||
`mirror://`) would only need to extend `FALLBACK_PREFIX` matching and
|
||||
fall through to the same recursion.
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { extractExtension } from "./extension.js";
|
||||
|
||||
describe("extractExtension", () => {
|
||||
it("extracts ext from a bundle:// URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("bundle://pkg/club1.wav")).toBe("wav");
|
||||
});
|
||||
|
||||
it("extracts ext from an https URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("https://cdn.example.com/clip.flac")).toBe(
|
||||
"flac"
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts ext from a drive:// URI", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("drive://share/key/x.opus")).toBe("opus");
|
||||
});
|
||||
|
||||
it("lowercases the extension", { timeout: 5000 }, () => {
|
||||
expect(extractExtension("https://h/p/clip.WAV")).toBe("wav");
|
||||
});
|
||||
|
||||
it("strips query and fragment before extracting", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractExtension("https://h/p/clip.wav?v=2#frag")).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE when no extension is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("bundle://pkg/clip");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("propagates id discriminant on throw", { timeout: 5000 }, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("bundle://pkg/clip", "club-a");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.id).toBe("club-a");
|
||||
});
|
||||
|
||||
it("walks fallback:// candidates and returns the agreed ext", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = encodeURIComponent("bundle://pkg/x.wav");
|
||||
const b = encodeURIComponent("https://h/x.wav");
|
||||
const uri = `fallback://?u=${a}&u=${b}`;
|
||||
expect(extractExtension(uri)).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE when fallback candidates disagree", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = encodeURIComponent("bundle://pkg/x.wav");
|
||||
const b = encodeURIComponent("https://h/x.flac");
|
||||
const uri = `fallback://?u=${a}&u=${b}`;
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension(uri, "club-a");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
expect(captured?.extension).toBe("wav");
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE on empty fallback candidate list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
extractExtension("fallback://");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE on fallback URI with no candidates (alt form)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
// `fallback:` with no authority and no `u=` query → zero
|
||||
// candidates; the candidate-walk surfaces DECODER_UNAVAILABLE.
|
||||
extractExtension("fallback:%%%");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("throws when URI ends with a dot (empty extension)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => extractExtension("bundle://pkg/foo.")).toThrow(
|
||||
AudioIrLoaderError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ext for URI with no path slash", { timeout: 5000 }, () => {
|
||||
// Edge case: `file:bare.wav` — no slash before extension.
|
||||
expect(extractExtension("file:bare.wav")).toBe("wav");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const FALLBACK_PREFIX = /^fallback:/i;
|
||||
|
||||
/**
|
||||
* Extract a lowercase file extension (without dot) from a URI's path
|
||||
* component.
|
||||
*
|
||||
* Handles three URI shapes:
|
||||
*
|
||||
* - `bundle://x/y.wav`, `https://h/p/q.flac`, `drive://share/key.opus`,
|
||||
* plain `<scheme>://host/path.<ext>` → return path's trailing extension.
|
||||
* - `fallback://?u=<encoded-uri-1>&u=<encoded-uri-2>` → recursively
|
||||
* extract from each candidate, throw `DECODER_UNAVAILABLE` if
|
||||
* candidates disagree (the resolver would be unable to pick a single
|
||||
* decoder).
|
||||
* - Anything else → return the extension of the URI's last `.`-segment,
|
||||
* or throw `DECODER_UNAVAILABLE` if none exists.
|
||||
*/
|
||||
export function extractExtension(uri: string, id?: string): string {
|
||||
if (FALLBACK_PREFIX.test(uri)) {
|
||||
return extensionFromFallback(uri, id);
|
||||
}
|
||||
return extensionFromSingle(uri, id);
|
||||
}
|
||||
|
||||
function extensionFromSingle(uri: string, id: string | undefined): string {
|
||||
// Strip any query / fragment before extracting; an extension only
|
||||
// applies to the path component. Splitting on `?` first lets the
|
||||
// fragment-only case (no `?`) still fall through to the slash-tail
|
||||
// computation.
|
||||
const stripped = stripQueryAndFragment(uri);
|
||||
const lastSlash = stripped.lastIndexOf("/");
|
||||
const tail = lastSlash >= 0 ? stripped.slice(lastSlash + 1) : stripped;
|
||||
const dot = tail.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === tail.length - 1) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`URI '${uri}' has no file extension`,
|
||||
{ id }
|
||||
);
|
||||
}
|
||||
return tail.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function stripQueryAndFragment(uri: string): string {
|
||||
// Replace every char after the first `?` or `#` (whichever is
|
||||
// earliest) with nothing. The branch-free form lets both the
|
||||
// present and absent cases share a single code path.
|
||||
const limit = firstIndexOfAny(uri, ["?", "#"]);
|
||||
return limit < 0 ? uri : uri.slice(0, limit);
|
||||
}
|
||||
|
||||
function firstIndexOfAny(haystack: string, needles: readonly string[]): number {
|
||||
let best = -1;
|
||||
for (const n of needles) {
|
||||
const idx = haystack.indexOf(n);
|
||||
if (idx < 0) continue;
|
||||
if (best < 0 || idx < best) best = idx;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function extensionFromFallback(uri: string, id: string | undefined): string {
|
||||
// `URL` accepts every `fallback:<rest>` form because `fallback` is a
|
||||
// generic non-special scheme; we therefore extract candidates through
|
||||
// `searchParams.getAll("u")` and let the candidate-count and per-
|
||||
// candidate dispatch surface every error. No try/catch needed.
|
||||
const parsed = new URL(uri);
|
||||
const candidates = parsed.searchParams.getAll("u");
|
||||
if (candidates.length === 0) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`fallback URI '${uri}' carries no candidates`,
|
||||
{ id }
|
||||
);
|
||||
}
|
||||
let first: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const ext = extractExtension(candidate, id);
|
||||
if (first === undefined) {
|
||||
first = ext;
|
||||
continue;
|
||||
}
|
||||
if (ext !== first) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`fallback candidates disagree on extension ('${first}' vs '${ext}')`,
|
||||
{ extension: first, id }
|
||||
);
|
||||
}
|
||||
}
|
||||
// `first` is defined here because we returned early on zero candidates.
|
||||
return first as string;
|
||||
}
|
||||
14
packages/media/audio-ir/audio-ir-loader/src/loader/index.ts
Normal file
14
packages/media/audio-ir/audio-ir-loader/src/loader/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { extractExtension } from "./extension.js";
|
||||
export {
|
||||
type AudioDecoder,
|
||||
type CreateIrLoaderOptions,
|
||||
createIrLoader,
|
||||
type IrLoader,
|
||||
type LoadOptions,
|
||||
type PreloadOptions,
|
||||
} from "./loader.js";
|
||||
export {
|
||||
type DecodedAudioLike,
|
||||
validateAndFinalize,
|
||||
} from "./manifest-validation.js";
|
||||
export { runPreload } from "./preload.js";
|
||||
105
packages/media/audio-ir/audio-ir-loader/src/loader/loader.md
Normal file
105
packages/media/audio-ir/audio-ir-loader/src/loader/loader.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `loader.ts` — `createIrLoader` pipeline orchestration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the entry point for the package. `createIrLoader(options)`
|
||||
returns a typed `IrLoader` that composes the IR family pipeline:
|
||||
`IrRegistry.get(id)` → `cache.get(id)` (on hit, fast path) →
|
||||
`StorageUriResolver.read(uri)` → `decoders[ext](bytes)` →
|
||||
`validateAndFinalize(entry, decoded)` → `cache.put` →
|
||||
return frozen `ImpulseResponse`. Every layer's errors pass through
|
||||
verbatim — the loader only owns the six `AUDIO_IR_LOADER.*` codes from
|
||||
its own contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at construction (`createIrLoader` throws
|
||||
`ZodError` on misuse).
|
||||
- Decoder keys normalised to lowercase so callers can write
|
||||
`{ WAV: decodeWav }` and have it work.
|
||||
- Optional in-process LRU cache controlled by `options.cache.maxBytes`.
|
||||
- `AbortSignal` threading: short-circuits a pre-aborted signal,
|
||||
passes the signal through to both `resolver.read` and the decoder.
|
||||
- Real OpenTelemetry instrumentation: span + duration histogram + bytes /
|
||||
frames / cache / error counters.
|
||||
- Bounded-concurrency `preload(ids, { concurrency })` with aggregate
|
||||
failure reporting (delegated to `preload.ts`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
bundleUriPlugin({ packageSlug: "club", rootUrl: new URL("file:///opt/ir/club/") }),
|
||||
]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The loader composes well with an LRU cache and with bounded-concurrency
|
||||
preload for app bootstrap:
|
||||
|
||||
```ts
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav, flac: decodeFlac },
|
||||
cache: { maxBytes: 64 * 1024 * 1024 },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const [a, b, c] = await loader.preload(
|
||||
["club-a", "club-b", "club-c"],
|
||||
{ signal: controller.signal, concurrency: 2 }
|
||||
);
|
||||
// Later, free a specific IR's slot:
|
||||
loader.evict("club-c");
|
||||
console.log(loader.cacheStats());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createIrLoader(options): IrLoader`
|
||||
|
||||
- `options.registry: IrRegistry`
|
||||
- `options.resolver: StorageUriResolver`
|
||||
- `options.decoders: Record<string, AudioDecoder>` — must be non-empty;
|
||||
keys are case-insensitive.
|
||||
- `options.cache?: { maxBytes: number }` — positive integer; enables LRU.
|
||||
|
||||
### `IrLoader`
|
||||
|
||||
- `load(id, { signal? }?): Promise<ImpulseResponse>`
|
||||
- `preload(ids, { signal?, concurrency? }?): Promise<readonly ImpulseResponse[]>`
|
||||
- `evict(id): boolean`
|
||||
- `cacheStats(): { entries, bytes, hits, misses }`
|
||||
|
||||
### `AudioDecoder`
|
||||
|
||||
`(bytes: Uint8Array, opts?: { signal?: AbortSignal }) => Promise<DecodedAudioLike>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The OTel span for every `load` is started before the cache hit-check so
|
||||
even cache hits emit a span; the `outcome=cache_hit` histogram label
|
||||
distinguishes those from the `outcome=success` "did real work" path. The
|
||||
`SuaveplanError`-cast on errors lets the loader's `errors_total` counter
|
||||
attribute the throw to its originating namespace (`AUDIO_IR.*`,
|
||||
`STORAGE_URI.*`, `BUNDLE_URI.*`, `FALLBACK_URI.*`, `AUDIO_DECODE.<FMT>.*`,
|
||||
or `AUDIO_IR_LOADER.*`) rather than collapsing every miss into a single
|
||||
opaque bucket. The pre-abort check throws synchronously before any
|
||||
registry / resolver / decoder call, satisfying the spec's "never invoked
|
||||
when signal is already aborted" requirement.
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
|
||||
const ENTRY = (): IrManifestEntry =>
|
||||
({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function harness() {
|
||||
const reads = new Map<string, Uint8Array>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads.get(key);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key);
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const registry = createIrRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
return { resolver, registry, reads };
|
||||
}
|
||||
|
||||
const decoderOk: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0.5, 0.5])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
|
||||
describe("loader spec-conformance: AbortSignal threading", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("pre-aborted signal short-circuits load", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
let registryGets = 0;
|
||||
const proxiedRegistry = {
|
||||
...registry,
|
||||
get(id: string) {
|
||||
registryGets += 1;
|
||||
return registry.get(id);
|
||||
},
|
||||
has: registry.has.bind(registry),
|
||||
list: registry.list.bind(registry),
|
||||
register: registry.register.bind(registry),
|
||||
clear: registry.clear.bind(registry),
|
||||
};
|
||||
let resolverReads = 0;
|
||||
const proxiedResolver = {
|
||||
...resolver,
|
||||
read: async (uri: string, signal?: AbortSignal) => {
|
||||
resolverReads += 1;
|
||||
return resolver.read(uri, signal);
|
||||
},
|
||||
};
|
||||
let decoderCalls = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
decoderCalls += 1;
|
||||
return decoderOk(new Uint8Array());
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry: proxiedRegistry as unknown as Parameters<
|
||||
typeof createIrLoader
|
||||
>[0]["registry"],
|
||||
resolver: proxiedResolver as unknown as Parameters<
|
||||
typeof createIrLoader
|
||||
>[0]["resolver"],
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("pre-abort"));
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1", { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.ABORTED);
|
||||
expect(registryGets).toBe(0);
|
||||
expect(resolverReads).toBe(0);
|
||||
expect(decoderCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("mid-flight resolver abort passes through unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { registry } = harness();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(_key: string) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.ABORTED,
|
||||
"fixture mid-flight",
|
||||
{ uri: "bundle://x/club1.wav" }
|
||||
);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists() {
|
||||
return false;
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoderOk },
|
||||
});
|
||||
let captured: StorageUriError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(STORAGE_URI_CODES.ABORTED);
|
||||
});
|
||||
|
||||
it("threads signal through to the decoder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
const decoder: AudioDecoder = async (_bytes, opts) => {
|
||||
signalSeen = opts?.signal;
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await loader.load("club-club1", { signal: controller.signal });
|
||||
expect(signalSeen).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loader spec-conformance: cache identity", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("cacheStats survives an evict round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, registry } = harness();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoderOk },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
expect(loader.cacheStats().entries).toBe(1);
|
||||
loader.evict("club-club1");
|
||||
expect(loader.cacheStats().entries).toBe(0);
|
||||
// Idempotent evict — second call is a no-op.
|
||||
expect(loader.evict("club-club1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
|
||||
const ENTRY = (overrides: Partial<IrManifestEntry> = {}): IrManifestEntry =>
|
||||
({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
interface ResolverHandle {
|
||||
readonly resolver: ReturnType<typeof createStorageUriResolver>;
|
||||
readonly reads: Map<string, Uint8Array | "missing" | StorageUriError>;
|
||||
}
|
||||
|
||||
function buildResolver(): ResolverHandle {
|
||||
const reads = new Map<string, Uint8Array | "missing" | StorageUriError>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
const v = reads.get(key);
|
||||
if (v === undefined || v === "missing") return undefined;
|
||||
if (v instanceof StorageUriError) throw v;
|
||||
return v;
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key) && reads.get(key) !== "missing";
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
return { resolver: createStorageUriResolver([plugin]), reads };
|
||||
}
|
||||
|
||||
const makeWavDecoder = (sampleRate = 44100, channels = 1): AudioDecoder => {
|
||||
return async () => ({
|
||||
channelData: Array.from(
|
||||
{ length: channels },
|
||||
() => new Float32Array([1, 0, 0, 0])
|
||||
),
|
||||
channels,
|
||||
sampleRate,
|
||||
});
|
||||
};
|
||||
|
||||
function freshRegistry() {
|
||||
const reg = createIrRegistry();
|
||||
return reg;
|
||||
}
|
||||
|
||||
describe("createIrLoader construction", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("minimal options construct a loader", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(typeof loader.load).toBe("function");
|
||||
expect(typeof loader.preload).toBe("function");
|
||||
expect(typeof loader.evict).toBe("function");
|
||||
expect(typeof loader.cacheStats).toBe("function");
|
||||
});
|
||||
|
||||
it("throws on empty decoder map", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
expect(() =>
|
||||
createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: {},
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("normalizes decoder keys to lowercase", { timeout: 5000 }, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1, 2]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { WAV: makeWavDecoder() },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
expect(ir.id).toBe("club-club1");
|
||||
});
|
||||
|
||||
it("rejects non-positive cache.maxBytes", { timeout: 5000 }, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
expect(() =>
|
||||
createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
cache: { maxBytes: 0 },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader.load happy path", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("end-to-end load returns a frozen ImpulseResponse", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(
|
||||
ENTRY({ id: "club-club1", channels: 2, sampleRate: 44100 }),
|
||||
{ manifestSlug: "club", manifestFamily: "room" }
|
||||
);
|
||||
reads.set("x/club1.wav", new Uint8Array([1, 2, 3]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(44100, 2) },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
expect(ir.id).toBe("club-club1");
|
||||
expect(ir.channels).toBe(2);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.channelData.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader.load error pass-through", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("passes through AUDIO_IR.NOT_REGISTERED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: SuaveplanError | undefined;
|
||||
try {
|
||||
await loader.load("nope");
|
||||
} catch (err) {
|
||||
if (err instanceof SuaveplanError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe("AUDIO_IR.NOT_REGISTERED");
|
||||
expect(captured).not.toBeInstanceOf(AudioIrLoaderError);
|
||||
});
|
||||
|
||||
it("passes through resolver errors unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
// The adapter returns undefined → resolver throws NOT_FOUND.
|
||||
reads.set("x/club1.wav", "missing");
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: StorageUriError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(STORAGE_URI_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("throws DECODER_UNAVAILABLE for unknown extension", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(
|
||||
ENTRY({ id: "club-club2", uri: "bundle://x/club2.flac" }),
|
||||
{ manifestSlug: "club", manifestFamily: "room" }
|
||||
);
|
||||
reads.set("x/club2.flac", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club2");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE);
|
||||
expect(captured?.extension).toBe("flac");
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on channel-count divergence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 2 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(44100, 1) },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("channels");
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on sample-rate divergence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 1, sampleRate: 44100 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder(48000, 1) },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("sampleRate");
|
||||
});
|
||||
|
||||
it("applies gainDb multiplicatively (peak ≈ 0.5)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY({ channels: 1, gainDb: -6.0206 }), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([1.0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const ir = await loader.load("club-club1");
|
||||
const peak = (ir.channelData[0] as Float32Array)[0] as number;
|
||||
expect(Math.abs(peak - 0.5)).toBeLessThan(1e-3);
|
||||
expect(ir.manifest.gainDb).toBe(-6.0206);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader non-SuaveplanError pass-through", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("propagates plain-Error throws from the decoder unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const decoder: AudioDecoder = async () => {
|
||||
throw new TypeError("boom");
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await loader.load("club-club1");
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IrLoader cache behavior", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("evict returns false when cache disabled", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(loader.evict("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("cacheStats reports zeros when cache disabled", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { resolver } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
});
|
||||
expect(loader.cacheStats()).toEqual({
|
||||
entries: 0,
|
||||
bytes: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("cache hit returns the same reference without re-decode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
let decodeCalls = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
decodeCalls += 1;
|
||||
return {
|
||||
channelData: [new Float32Array([0.5])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
const first = await loader.load("club-club1");
|
||||
const second = await loader.load("club-club1");
|
||||
expect(first).toBe(second);
|
||||
expect(decodeCalls).toBe(1);
|
||||
expect(loader.cacheStats().hits).toBe(1);
|
||||
});
|
||||
|
||||
it("evict returns true when cached id is dropped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolver, reads } = buildResolver();
|
||||
const registry = freshRegistry();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: makeWavDecoder() },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
expect(loader.evict("club-club1")).toBe(true);
|
||||
expect(loader.evict("club-club1")).toBe(false);
|
||||
});
|
||||
});
|
||||
220
packages/media/audio-ir/audio-ir-loader/src/loader/loader.ts
Normal file
220
packages/media/audio-ir/audio-ir-loader/src/loader/loader.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
ImpulseResponse,
|
||||
IrFamily,
|
||||
IrRegistry,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import type { StorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import z from "zod";
|
||||
import type { CacheStats } from "../cache/cache.js";
|
||||
import { ImpulseResponseCache } from "../cache/cache.js";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
getInstruments,
|
||||
recordCacheHit,
|
||||
recordCacheMiss,
|
||||
recordError,
|
||||
recordSuccess,
|
||||
uriKindOf,
|
||||
} from "../otel/otel.js";
|
||||
import { extractExtension } from "./extension.js";
|
||||
import {
|
||||
type DecodedAudioLike,
|
||||
validateAndFinalize,
|
||||
} from "./manifest-validation.js";
|
||||
import { runPreload } from "./preload.js";
|
||||
|
||||
/**
|
||||
* Decoder function shape consumed by the loader. Mirrors
|
||||
* `@suaveplan/audio-decode-wav`'s `decodeWav` signature.
|
||||
*/
|
||||
export type AudioDecoder = (
|
||||
bytes: Uint8Array,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => Promise<DecodedAudioLike>;
|
||||
|
||||
/** Options for `IrLoader.load`. */
|
||||
export interface LoadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Options for `IrLoader.preload`. */
|
||||
export interface PreloadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly concurrency?: number;
|
||||
}
|
||||
|
||||
/** Public `IrLoader` surface. */
|
||||
export interface IrLoader {
|
||||
load(id: string, options?: LoadOptions): Promise<ImpulseResponse>;
|
||||
preload(
|
||||
ids: readonly string[],
|
||||
options?: PreloadOptions
|
||||
): Promise<readonly ImpulseResponse[]>;
|
||||
evict(id: string): boolean;
|
||||
cacheStats(): CacheStats;
|
||||
}
|
||||
|
||||
/** Input options for `createIrLoader`. */
|
||||
export interface CreateIrLoaderOptions {
|
||||
readonly registry: IrRegistry;
|
||||
readonly resolver: StorageUriResolver;
|
||||
readonly decoders: Readonly<Record<string, AudioDecoder>>;
|
||||
readonly cache?: { readonly maxBytes: number };
|
||||
}
|
||||
|
||||
const OptionsSchema = z.object({
|
||||
registry: z.custom<IrRegistry>(
|
||||
(v): v is IrRegistry =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as IrRegistry).get === "function" &&
|
||||
typeof (v as IrRegistry).has === "function",
|
||||
{ message: "options.registry must be an IrRegistry" }
|
||||
),
|
||||
resolver: z.custom<StorageUriResolver>(
|
||||
(v): v is StorageUriResolver =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as StorageUriResolver).read === "function",
|
||||
{ message: "options.resolver must be a StorageUriResolver" }
|
||||
),
|
||||
decoders: z
|
||||
.record(
|
||||
z.string().min(1),
|
||||
z.custom<AudioDecoder>(
|
||||
(v): v is AudioDecoder => typeof v === "function",
|
||||
{ message: "decoder must be a function" }
|
||||
)
|
||||
)
|
||||
.refine((d) => Object.keys(d).length > 0, {
|
||||
message: "decoders must not be empty",
|
||||
}),
|
||||
cache: z
|
||||
.object({
|
||||
maxBytes: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a typed `IrLoader` bound to a registry, resolver, and decoder map.
|
||||
*
|
||||
* Validates `options` through Zod at construction; failures throw a
|
||||
* `ZodError`. Decoder keys are normalised to lowercase. When
|
||||
* `options.cache` is supplied, an in-process LRU is created with the
|
||||
* declared `maxBytes`; when absent, the loader operates uncached.
|
||||
*/
|
||||
export function createIrLoader(options: CreateIrLoaderOptions): IrLoader {
|
||||
const parsed = OptionsSchema.parse(options);
|
||||
const decoders: Record<string, AudioDecoder> = {};
|
||||
for (const [k, v] of Object.entries(parsed.decoders)) {
|
||||
decoders[k.toLowerCase()] = v;
|
||||
}
|
||||
const cache =
|
||||
parsed.cache !== undefined
|
||||
? new ImpulseResponseCache(parsed.cache.maxBytes)
|
||||
: undefined;
|
||||
|
||||
async function loadInternal(
|
||||
id: string,
|
||||
loadOptions: LoadOptions | undefined
|
||||
): Promise<ImpulseResponse> {
|
||||
const signal = loadOptions?.signal;
|
||||
const started = performance.now();
|
||||
// Pre-aborted signal: short-circuit before any side effect.
|
||||
if (signal?.aborted === true) {
|
||||
// `AbortSignal.reason` is always defined post-`abort()` (the
|
||||
// platform synthesises a `DOMException` when no explicit
|
||||
// reason was supplied) — pass it through unconditionally.
|
||||
const err = new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"aborted before load started",
|
||||
{ id, cause: signal.reason }
|
||||
);
|
||||
recordError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"unknown",
|
||||
performance.now() - started
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
let family: IrFamily | "unknown" = "unknown";
|
||||
let entry: ReturnType<IrRegistry["get"]>;
|
||||
const tracer = getInstruments().tracer;
|
||||
const span = tracer.startSpan("audio_ir_loader.load", {
|
||||
attributes: { "audio_ir.id": id },
|
||||
});
|
||||
try {
|
||||
entry = parsed.registry.get(id);
|
||||
family = entry.family;
|
||||
span.setAttribute("audio_ir.family", family);
|
||||
span.setAttribute("audio_ir.uri_kind", uriKindOf(entry.uri));
|
||||
if (cache !== undefined) {
|
||||
const hit = cache.get(id);
|
||||
if (hit !== undefined) {
|
||||
recordCacheHit(family, performance.now() - started);
|
||||
return hit;
|
||||
}
|
||||
recordCacheMiss(family);
|
||||
}
|
||||
const ext = extractExtension(entry.uri, id);
|
||||
const decoder = decoders[ext];
|
||||
if (decoder === undefined) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODER_UNAVAILABLE,
|
||||
`no decoder registered for '.${ext}'`,
|
||||
{ id, extension: ext }
|
||||
);
|
||||
}
|
||||
const bytes = await parsed.resolver.read(entry.uri, signal);
|
||||
const decoded = await decoder(
|
||||
bytes,
|
||||
signal !== undefined ? { signal } : undefined
|
||||
);
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
if (cache !== undefined) {
|
||||
cache.put(id, ir);
|
||||
}
|
||||
const firstChannel = ir.channelData[0] as Float32Array;
|
||||
recordSuccess(
|
||||
family,
|
||||
bytes.byteLength,
|
||||
firstChannel.length,
|
||||
performance.now() - started
|
||||
);
|
||||
return ir;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof SuaveplanError
|
||||
? err.code
|
||||
: "AUDIO_IR_LOADER.UNKNOWN";
|
||||
recordError(code, family, performance.now() - started);
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load(id, loadOptions) {
|
||||
return loadInternal(id, loadOptions);
|
||||
},
|
||||
preload(ids, preloadOptions) {
|
||||
return runPreload(loadInternal, ids, preloadOptions);
|
||||
},
|
||||
evict(id) {
|
||||
if (cache === undefined) return false;
|
||||
return cache.evict(id);
|
||||
},
|
||||
cacheStats() {
|
||||
if (cache === undefined) {
|
||||
return { entries: 0, bytes: 0, hits: 0, misses: 0 };
|
||||
}
|
||||
return cache.stats();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# `manifest-validation.ts` — decoded audio ↔ manifest reconciliation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the bridge between the `DecodedAudio` shape emitted by an
|
||||
`AudioDecoder` and the `ImpulseResponse` shape consumed by the convolver.
|
||||
It compares the decoded audio against the manifest entry's declared
|
||||
`channels` / `sampleRate`, throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on
|
||||
any divergence, then applies the manifest's `gainDb` multiplicatively
|
||||
and returns a frozen `ImpulseResponse` ready to hand back to the caller.
|
||||
|
||||
## Features
|
||||
|
||||
- Exact-match enforcement for both `channels` and `sampleRate` — no
|
||||
tolerance window (resampling is out-of-scope for this package).
|
||||
- `gainDb` applied via `10^(gainDb/20)` per sample. The `gainDb === 0`
|
||||
fast-path skips the multiply and just copies the buffer to ensure the
|
||||
resulting IR owns its memory.
|
||||
- Returns a fully frozen `ImpulseResponse` whose `manifest` reference
|
||||
preserves the source entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateAndFinalize } from "@suaveplan/audio-ir-loader";
|
||||
|
||||
const ir = validateAndFinalize(entry, decodedAudio);
|
||||
console.log(ir.id, ir.lengthFrames, ir.durationSec);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators routing on `MANIFEST_MISMATCH` errors can use the structured
|
||||
discriminants directly:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
validateAndFinalize,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
return validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioIrLoaderError &&
|
||||
err.code === AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH
|
||||
) {
|
||||
metrics.recordMismatch({
|
||||
id: err.id,
|
||||
field: err.field, // "channels" | "sampleRate"
|
||||
manifestValue: err.manifestValue,
|
||||
decodedValue: err.decodedValue,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateAndFinalize(entry, decoded): ImpulseResponse`
|
||||
|
||||
- `entry: IrManifestEntry` — the manifest entry resolved from the registry.
|
||||
- `decoded: DecodedAudioLike` — `{ channelData, sampleRate, channels }`.
|
||||
|
||||
Throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on `channels` or `sampleRate`
|
||||
divergence (with `field`, `manifestValue`, `decodedValue` discriminants).
|
||||
Returns a frozen `ImpulseResponse` whose `manifest` field is the input
|
||||
entry.
|
||||
|
||||
### `DecodedAudioLike`
|
||||
|
||||
Structural subset of `@suaveplan/audio-decode-wav`'s `DecodedAudio` —
|
||||
just the three fields manifest validation needs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `gainDb === 0` short-circuit avoids both the `Math.pow` call and a
|
||||
floating-point pass over the buffer for the (very common) case where the
|
||||
manifest carries no gain offset; the per-sample copy is still performed
|
||||
because every channel buffer is wrapped in `new Float32Array(channel)`
|
||||
to give the resulting IR its own memory. The result is `Object.freeze`d
|
||||
at the outer level and the `channelData` array is frozen too; the
|
||||
individual `Float32Array` buffers cannot be `Object.freeze`d (TypedArray
|
||||
buffers reject the freeze) but the array-level freeze is enough to
|
||||
prevent callers from swapping channels at the array level.
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { validateAndFinalize } from "./manifest-validation.js";
|
||||
|
||||
const ENTRY = (overrides: Partial<IrManifestEntry> = {}): IrManifestEntry =>
|
||||
Object.freeze({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
describe("validateAndFinalize", () => {
|
||||
it("returns a frozen ImpulseResponse on a happy path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY();
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([1, 0]), new Float32Array([0, 1])],
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
expect(ir.id).toBe("club-club1");
|
||||
expect(ir.channels).toBe(2);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.lengthFrames).toBe(2);
|
||||
expect(ir.manifest).toBe(entry);
|
||||
// ChannelData should be a copy, not the original reference.
|
||||
expect(ir.channelData[0]).not.toBe(decoded.channelData[0]);
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on channel-count divergence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 2 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("channels");
|
||||
expect(captured?.manifestValue).toBe(2);
|
||||
expect(captured?.decodedValue).toBe(1);
|
||||
});
|
||||
|
||||
it("throws MANIFEST_MISMATCH on sample-rate divergence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, sampleRate: 44100 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 48000,
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
validateAndFinalize(entry, decoded);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH);
|
||||
expect(captured?.field).toBe("sampleRate");
|
||||
expect(captured?.manifestValue).toBe(44100);
|
||||
expect(captured?.decodedValue).toBe(48000);
|
||||
});
|
||||
|
||||
it("applies gainDb multiplicatively", { timeout: 5000 }, () => {
|
||||
const entry = ENTRY({ channels: 1, gainDb: -6.0206 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array([1.0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
const peak = (ir.channelData[0] as Float32Array)[0] as number;
|
||||
expect(Math.abs(peak - 0.5)).toBeLessThan(1e-3);
|
||||
expect(ir.manifest.gainDb).toBe(-6.0206);
|
||||
});
|
||||
|
||||
it("gainDb=0 fast-path still copies the buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, gainDb: 0 });
|
||||
const orig = new Float32Array([0.25]);
|
||||
const decoded = {
|
||||
channelData: [orig],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
const out = ir.channelData[0] as Float32Array;
|
||||
expect(out).not.toBe(orig);
|
||||
expect(out[0]).toBe(0.25);
|
||||
});
|
||||
|
||||
it("computes durationSec from lengthFrames / sampleRate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = ENTRY({ channels: 1, sampleRate: 22050 });
|
||||
const decoded = {
|
||||
channelData: [new Float32Array(22050)],
|
||||
channels: 1,
|
||||
sampleRate: 22050,
|
||||
};
|
||||
const ir = validateAndFinalize(entry, decoded);
|
||||
expect(ir.durationSec).toBeCloseTo(1.0, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
ImpulseResponse,
|
||||
IrManifestEntry,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Minimal shape the loader consumes from `DecodedAudio`. */
|
||||
export interface DecodedAudioLike {
|
||||
readonly channelData: readonly Float32Array[];
|
||||
readonly sampleRate: number;
|
||||
readonly channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare decoded audio against a manifest entry, apply `gainDb`, and
|
||||
* return a frozen `ImpulseResponse`.
|
||||
*
|
||||
* Throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` when `channels` or
|
||||
* `sampleRate` disagree. `gainDb` is applied multiplicatively:
|
||||
* `sample * 10^(gainDb/20)`. The result's `manifest.gainDb` preserves
|
||||
* the manifest's declared value so callers know what was applied.
|
||||
*/
|
||||
export function validateAndFinalize(
|
||||
entry: IrManifestEntry,
|
||||
decoded: DecodedAudioLike
|
||||
): ImpulseResponse {
|
||||
if (decoded.channels !== entry.channels) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
||||
`IR ${entry.id} manifest declares ${entry.channels} channel(s), decoded ${decoded.channels}`,
|
||||
{
|
||||
id: entry.id,
|
||||
field: "channels",
|
||||
manifestValue: entry.channels,
|
||||
decodedValue: decoded.channels,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (decoded.sampleRate !== entry.sampleRate) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH,
|
||||
`IR ${entry.id} manifest declares ${entry.sampleRate} Hz, decoded ${decoded.sampleRate} Hz`,
|
||||
{
|
||||
id: entry.id,
|
||||
field: "sampleRate",
|
||||
manifestValue: entry.sampleRate,
|
||||
decodedValue: decoded.sampleRate,
|
||||
}
|
||||
);
|
||||
}
|
||||
const gain = entry.gainDb === 0 ? 1 : 10 ** (entry.gainDb / 20);
|
||||
const channelData: Float32Array[] = [];
|
||||
for (const channel of decoded.channelData) {
|
||||
if (gain === 1) {
|
||||
// Copy regardless so the IR owns its buffer (the decoder may
|
||||
// return frozen-but-shared buffers; mutating decoder output
|
||||
// would be a footgun).
|
||||
channelData.push(new Float32Array(channel));
|
||||
continue;
|
||||
}
|
||||
const scaled = new Float32Array(channel.length);
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
// `channel[i]` is defined for i < length per Float32Array indexing.
|
||||
const sample = channel[i] as number;
|
||||
scaled[i] = sample * gain;
|
||||
}
|
||||
channelData.push(scaled);
|
||||
}
|
||||
const frozenChannels = Object.freeze(channelData);
|
||||
// The first channel's length is canonical — the decoder is required
|
||||
// to return equal-length channels, and the loader's downstream
|
||||
// schema check would re-enforce it.
|
||||
const firstChannel = channelData[0] as Float32Array;
|
||||
const lengthFrames = firstChannel.length;
|
||||
const durationSec = lengthFrames / entry.sampleRate;
|
||||
const ir: ImpulseResponse = Object.freeze({
|
||||
id: entry.id,
|
||||
channelData: frozenChannels,
|
||||
channels: entry.channels,
|
||||
sampleRate: entry.sampleRate,
|
||||
lengthFrames,
|
||||
durationSec,
|
||||
gainDb: entry.gainDb,
|
||||
family: entry.family,
|
||||
manifest: entry,
|
||||
});
|
||||
return ir;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# `preload.ts` — bounded-concurrency bulk load
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the bounded-concurrency `preload` algorithm
|
||||
backing `IrLoader.preload`. It launches up to `concurrency` workers (each
|
||||
a `while`-loop that pops the next id off a shared cursor), threads the
|
||||
caller's `AbortSignal` through every per-id load, and produces either a
|
||||
fully-populated array of `ImpulseResponse` (in input order) on success
|
||||
or an aggregate `AUDIO_IR_LOADER.DECODE_FAILED` error carrying every
|
||||
per-id failure on partial failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Concurrency clamped to `[1, 32]` to prevent caller mistakes from
|
||||
saturating the runtime.
|
||||
- Input order preserved on success — the `results` array is indexed by
|
||||
the id's position in `ids`, not by completion order.
|
||||
- Aggregate failure: every failing id is collected before the throw, so
|
||||
callers get a complete picture rather than the first error.
|
||||
- Abort threading: workers stop launching new loads as soon as
|
||||
`signal.aborted` becomes true; the throw is `ABORTED` not
|
||||
`DECODE_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const irs = await loader.preload(
|
||||
["club-a", "club-b", "club-c"],
|
||||
{ concurrency: 2 }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The aggregate carries every failure for ergonomic dashboards:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
|
||||
try {
|
||||
await loader.preload(ids);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError && err.errors !== undefined) {
|
||||
for (const { id, error } of err.errors) {
|
||||
metrics.recordLoadFailure({ id, code: error.code });
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
Best-effort callers that want to keep going past failures can call
|
||||
`Promise.allSettled(ids.map((id) => loader.load(id)))` directly.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `runPreload(loadFn, ids, options?): Promise<readonly ImpulseResponse[]>`
|
||||
|
||||
- `loadFn` — the per-id load function (the loader's closure).
|
||||
- `ids: readonly string[]` — input ids; order preserved on success.
|
||||
- `options.signal?: AbortSignal` — shared abort.
|
||||
- `options.concurrency?: number` — clamped to `[1, 32]`, default `4`.
|
||||
|
||||
On full success returns a frozen `ImpulseResponse[]` in input order. On
|
||||
partial failure throws `AUDIO_IR_LOADER.DECODE_FAILED` with the failure
|
||||
list on `errors`. On signal abort throws `AUDIO_IR_LOADER.ABORTED`
|
||||
(carrying any pre-abort failures on `errors`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The worker pool is a simple "N parallel tasks, all draining a shared
|
||||
cursor" pattern. The cursor is captured in `takeNext()` which also checks
|
||||
abort state on entry, so a mid-flight abort halts every worker on its
|
||||
next iteration. Non-`SuaveplanError` throwables are wrapped as
|
||||
`AUDIO_IR_LOADER.RESOLVE_FAILED` before being added to the aggregate;
|
||||
this preserves the invariant that every entry in `errors[]` is a
|
||||
`SuaveplanError` (and therefore carries a stable `.code`). The success
|
||||
result is `Object.freeze`d so callers can't mutate the array in place.
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import { type AudioDecoder, createIrLoader } from "./loader.js";
|
||||
import { runPreload } from "./preload.js";
|
||||
|
||||
const ENTRY = (
|
||||
id: string,
|
||||
overrides: Partial<IrManifestEntry> = {}
|
||||
): IrManifestEntry =>
|
||||
({
|
||||
id,
|
||||
family: "room",
|
||||
uri: `bundle://x/${id}.wav`,
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function buildSlowDecoder(
|
||||
delayMs: number,
|
||||
counter: { active: number; max: number }
|
||||
): AudioDecoder {
|
||||
return async () => {
|
||||
counter.active += 1;
|
||||
counter.max = Math.max(counter.max, counter.active);
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
counter.active -= 1;
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function buildHarness(ids: readonly string[]) {
|
||||
const reads = new Map<string, Uint8Array>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads.get(key);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key);
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const registry = createIrRegistry();
|
||||
for (const id of ids) {
|
||||
registry.register(ENTRY(`club-${id}`), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set(`x/club-${id}.wav`, new Uint8Array([1]));
|
||||
}
|
||||
return { resolver, registry };
|
||||
}
|
||||
|
||||
describe("preload concurrency, order, and aggregate failure", () => {
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("honours concurrency cap", { timeout: 10000 }, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
]);
|
||||
const decoder = buildSlowDecoder(10, counter);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
await loader.preload(
|
||||
["club-a", "club-b", "club-c", "club-d", "club-e", "club-f"],
|
||||
{ concurrency: 2 }
|
||||
);
|
||||
expect(counter.max).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("preserves input order on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b", "c"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(5, counter) },
|
||||
});
|
||||
const input = ["club-a", "club-b", "club-c"];
|
||||
const out = await loader.preload(input);
|
||||
expect(out.map((ir) => ir.id)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects with aggregate on partial failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b", "c"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
// Decoder returns mismatched channel count for `b`.
|
||||
const decoder: AudioDecoder = async (_bytes) => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const flakyDecoder: AudioDecoder = async (_bytes, opts) => {
|
||||
// Inspect a side channel via stack — simpler: keep counter to fail on second call.
|
||||
counter.active += 1;
|
||||
if (counter.active === 2) {
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
}
|
||||
return decoder(_bytes, opts);
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: flakyDecoder },
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.preload(["club-a", "club-b", "club-c"], {
|
||||
concurrency: 1,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODE_FAILED);
|
||||
expect(captured?.errors?.length).toBe(1);
|
||||
expect(captured?.errors?.[0]?.id).toBe("club-b");
|
||||
expect(captured?.errors?.[0]?.error.code).toBe(
|
||||
AUDIO_IR_LOADER_CODES.MANIFEST_MISMATCH
|
||||
);
|
||||
});
|
||||
|
||||
it("preload stops launching new loads after abort", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a", "b", "c", "d"]);
|
||||
let launches = 0;
|
||||
const decoder: AudioDecoder = async () => {
|
||||
launches += 1;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return {
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
};
|
||||
};
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const promise = loader.preload(
|
||||
["club-a", "club-b", "club-c", "club-d"],
|
||||
{ signal: controller.signal, concurrency: 2 }
|
||||
);
|
||||
// Fire abort after a short delay so the first two finish but the
|
||||
// last two are never launched.
|
||||
setTimeout(() => controller.abort(new Error("test abort")), 30);
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.ABORTED);
|
||||
// counter is unused for this assertion; tests on `launches` upper bound.
|
||||
expect(launches).toBeLessThan(4);
|
||||
// Touch counter to silence the unused-variable lint when running through Biome.
|
||||
void counter;
|
||||
});
|
||||
|
||||
it("clamps requested concurrency to [1, 32]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a"]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a"], { concurrency: 9999 });
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
|
||||
it("default concurrency (no option) is honoured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const ids = ["a", "b"];
|
||||
const { resolver, registry } = buildHarness(ids);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a", "club-b"]);
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it("wraps non-SuaveplanError throwables as RESOLVE_FAILED in the aggregate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badLoader = async (_id: string) => {
|
||||
throw new TypeError("plain error");
|
||||
};
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await runPreload(badLoader, ["x"], undefined);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(AUDIO_IR_LOADER_CODES.DECODE_FAILED);
|
||||
expect(captured?.errors?.[0]?.error.code).toBe(
|
||||
AUDIO_IR_LOADER_CODES.RESOLVE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps requested concurrency 0 up to 1", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counter = { active: 0, max: 0 };
|
||||
const { resolver, registry } = buildHarness(["a"]);
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: buildSlowDecoder(1, counter) },
|
||||
});
|
||||
const out = await loader.preload(["club-a"], { concurrency: 0 });
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
});
|
||||
108
packages/media/audio-ir/audio-ir-loader/src/loader/preload.ts
Normal file
108
packages/media/audio-ir/audio-ir-loader/src/loader/preload.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ImpulseResponse } from "@suaveplan/audio-ir-core";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
type AggregatedLoadFailure,
|
||||
AUDIO_IR_LOADER_CODES,
|
||||
AudioIrLoaderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { LoadOptions, PreloadOptions } from "./loader.js";
|
||||
|
||||
const MIN_CONCURRENCY = 1;
|
||||
const MAX_CONCURRENCY = 32;
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
type LoadFn = (
|
||||
id: string,
|
||||
options: LoadOptions | undefined
|
||||
) => Promise<ImpulseResponse>;
|
||||
|
||||
/**
|
||||
* Execute `loadFn` against every id in `ids` with at most `concurrency`
|
||||
* in-flight at any time. On full success returns the resulting array in
|
||||
* input order; on any failure rejects with an aggregate
|
||||
* `AUDIO_IR_LOADER.DECODE_FAILED` carrying every per-id error.
|
||||
*
|
||||
* If the supplied signal is aborted mid-flight, the function stops
|
||||
* launching new loads and rejects with `AUDIO_IR_LOADER.ABORTED`
|
||||
* (preserving any per-id errors already captured).
|
||||
*/
|
||||
export async function runPreload(
|
||||
loadFn: LoadFn,
|
||||
ids: readonly string[],
|
||||
options: PreloadOptions | undefined
|
||||
): Promise<readonly ImpulseResponse[]> {
|
||||
const requested = options?.concurrency ?? DEFAULT_CONCURRENCY;
|
||||
const concurrency = Math.min(
|
||||
Math.max(Math.floor(requested), MIN_CONCURRENCY),
|
||||
MAX_CONCURRENCY
|
||||
);
|
||||
const signal = options?.signal;
|
||||
const results: (ImpulseResponse | undefined)[] = new Array(ids.length);
|
||||
const failures: AggregatedLoadFailure[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
function takeNext(): { index: number; id: string } | undefined {
|
||||
if (signal?.aborted === true) return undefined;
|
||||
if (nextIndex >= ids.length) return undefined;
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const id = ids[index] as string;
|
||||
return { index, id };
|
||||
}
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const next = takeNext();
|
||||
if (next === undefined) return;
|
||||
try {
|
||||
const ir = await loadFn(
|
||||
next.id,
|
||||
signal !== undefined ? { signal } : undefined
|
||||
);
|
||||
results[next.index] = ir;
|
||||
} catch (err) {
|
||||
if (err instanceof SuaveplanError) {
|
||||
failures.push({ id: next.id, error: err });
|
||||
} else {
|
||||
// Non-namespaced throwables are wrapped so the
|
||||
// aggregate carries a SuaveplanError on every entry.
|
||||
// Always carry the offending throwable on `cause` —
|
||||
// the SuaveplanError options bag accepts any value
|
||||
// and preserving it loses no information.
|
||||
const wrapped = new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.RESOLVE_FAILED,
|
||||
`preload of ${next.id} threw a non-SuaveplanError`,
|
||||
{ id: next.id, cause: err }
|
||||
);
|
||||
failures.push({ id: next.id, error: wrapped });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers: Promise<void>[] = [];
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
// `AbortSignal.reason` is always defined after an `abort()` call
|
||||
// (the platform synthesises a `DOMException` when no explicit
|
||||
// reason was supplied), so pass it through unconditionally.
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.ABORTED,
|
||||
"preload aborted",
|
||||
{ cause: signal.reason, errors: failures }
|
||||
);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AudioIrLoaderError(
|
||||
AUDIO_IR_LOADER_CODES.DECODE_FAILED,
|
||||
`preload failed for ${failures.length} of ${ids.length} ids`,
|
||||
{ errors: failures }
|
||||
);
|
||||
}
|
||||
// All slots populated — narrow to `ImpulseResponse[]` for the caller.
|
||||
return Object.freeze(results.map((r) => r as ImpulseResponse));
|
||||
}
|
||||
14
packages/media/audio-ir/audio-ir-loader/src/otel/index.ts
Normal file
14
packages/media/audio-ir/audio-ir-loader/src/otel/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AudioIrLoaderInstruments,
|
||||
AudioIrLoaderLoadOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordCacheHit,
|
||||
recordCacheMiss,
|
||||
recordError,
|
||||
recordSuccess,
|
||||
resetInstruments,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
uriKindOf,
|
||||
} from "./otel.js";
|
||||
114
packages/media/audio-ir/audio-ir-loader/src/otel/otel.md
Normal file
114
packages/media/audio-ir/audio-ir-loader/src/otel/otel.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# `otel.ts` — OpenTelemetry surface for `audio-ir-loader`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the loader's OpenTelemetry instrumentation. It lazily
|
||||
builds a single, cached `AudioIrLoaderInstruments` bag bound to either the
|
||||
global `MeterProvider` or a per-process override installed via
|
||||
`setAudioIrLoaderMeterProvider`. The bag carries every counter and
|
||||
histogram the spec requires (`bytes_read_total`, `frames_out_total`,
|
||||
`cache_hits_total`, `cache_misses_total`, `errors_total`, plus the
|
||||
`load_duration_ms` histogram).
|
||||
|
||||
The split between this module and the loader/cache modules is deliberate:
|
||||
all metric definitions live here so dashboards can grep for instrument
|
||||
names in one place, and the loader/cache modules call recorder helpers
|
||||
(`recordSuccess`, `recordCacheHit`, `recordCacheMiss`, `recordError`)
|
||||
that close over the cached bag.
|
||||
|
||||
## Features
|
||||
|
||||
- Real `MeterProvider` override for tests — no mocks; tests install an
|
||||
in-memory recorder via `setAudioIrLoaderMeterProvider`.
|
||||
- Histogram + five counters covering success / cache-hit / cache-miss /
|
||||
error outcomes.
|
||||
- Span-attribute helper `uriKindOf(uri)` resolves the `audio_ir.uri_kind`
|
||||
dimension from a URI's scheme without re-parsing the whole URI.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSuccess,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
// In an app bootstrap:
|
||||
setAudioIrLoaderMeterProvider(metrics.getMeterProvider());
|
||||
recordSuccess("room", 100000, 25000, 12.5);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Tests install a real in-memory provider that records every observation:
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstruments,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
} from "@suaveplan/audio-ir-loader";
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
|
||||
const observed: { name: string; value: number; attrs: Record<string, unknown> }[] = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number, a?: Record<string, unknown>) =>
|
||||
observed.push({ name, value: v, attrs: a ?? {} }),
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (v: number, a?: Record<string, unknown>) =>
|
||||
observed.push({ name, value: v, attrs: a ?? {} }),
|
||||
}),
|
||||
}),
|
||||
} as unknown as MeterProvider;
|
||||
setAudioIrLoaderMeterProvider(provider);
|
||||
getInstruments(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setAudioIrLoaderMeterProvider(provider | undefined): void`
|
||||
|
||||
Install a meter provider override. Pass `undefined` to revert.
|
||||
|
||||
### `getInstruments(force?: boolean): AudioIrLoaderInstruments`
|
||||
|
||||
Lazily builds (and caches) the instrument bag.
|
||||
|
||||
### `resetInstruments(): void`
|
||||
|
||||
Drops the cache — used between test provider swaps.
|
||||
|
||||
### `recordSuccess(family, bytes, frames, durationMs): void`
|
||||
|
||||
Increments `bytes_read_total{family}` and `frames_out_total{family}`, then
|
||||
records `load_duration_ms{family, outcome:"success"}`.
|
||||
|
||||
### `recordCacheHit(family, durationMs): void` / `recordCacheMiss(family): void`
|
||||
|
||||
Counter + duration bookkeeping for cache outcomes.
|
||||
|
||||
### `recordError(code, family, durationMs): void`
|
||||
|
||||
Increments `errors_total{code}` and records the duration with
|
||||
`outcome:"error"`.
|
||||
|
||||
### `uriKindOf(uri): "bundle" | "fallback" | "https" | "drive" | "other"`
|
||||
|
||||
Maps a URI's leading scheme to the span-attribute dimension.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cached instrument bag is rebuilt every time `setAudioIrLoaderMeterProvider`
|
||||
swaps the provider — otherwise tests would observe stale instruments
|
||||
bound to the previous provider's meter. The `force` parameter on
|
||||
`getInstruments` exists exclusively to let tests immediately rebind
|
||||
after `setAudioIrLoaderMeterProvider` to avoid a wasted check on the
|
||||
next call. `uriKindOf` deliberately treats `http` and `https` identically
|
||||
because callers care about the on-the-wire transport, not the TLS bit.
|
||||
319
packages/media/audio-ir/audio-ir-loader/src/otel/otel.test.ts
Normal file
319
packages/media/audio-ir/audio-ir-loader/src/otel/otel.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import type { IrManifestEntry } from "@suaveplan/audio-ir-core";
|
||||
import { createIrRegistry } from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AudioIrLoaderError } from "../error-codes/error-codes.js";
|
||||
import { type AudioDecoder, createIrLoader } from "../loader/loader.js";
|
||||
import {
|
||||
getInstruments,
|
||||
recordCacheHit,
|
||||
recordCacheMiss,
|
||||
recordError,
|
||||
recordSuccess,
|
||||
resetInstruments,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
uriKindOf,
|
||||
} from "./otel.js";
|
||||
|
||||
interface CounterObs {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
interface HistogramObs {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
readonly counters: CounterObs[];
|
||||
readonly histograms: HistogramObs[];
|
||||
}
|
||||
|
||||
function installRecorder(): Recorder {
|
||||
const counters: CounterObs[] = [];
|
||||
const histograms: HistogramObs[] = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (value: number, attrs?: Record<string, unknown>) => {
|
||||
counters.push({ name, value, attributes: attrs ?? {} });
|
||||
},
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (value: number, attrs?: Record<string, unknown>) => {
|
||||
histograms.push({ name, value, attributes: attrs ?? {} });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as unknown as MeterProvider;
|
||||
setAudioIrLoaderMeterProvider(provider);
|
||||
getInstruments(true);
|
||||
return { counters, histograms };
|
||||
}
|
||||
|
||||
function uninstall(): void {
|
||||
setAudioIrLoaderMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
}
|
||||
|
||||
const ENTRY = (overrides: Partial<IrManifestEntry> = {}): IrManifestEntry =>
|
||||
({
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: "bundle://x/club1.wav",
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "demo",
|
||||
...overrides,
|
||||
}) as IrManifestEntry;
|
||||
|
||||
function harness() {
|
||||
const reads = new Map<string, Uint8Array>();
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "bundle",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
return reads.get(key);
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key) {
|
||||
return reads.has(key);
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
return { key: uri.replace(/^bundle:\/\//, "") };
|
||||
},
|
||||
};
|
||||
const resolver = createStorageUriResolver([plugin]);
|
||||
const registry = createIrRegistry();
|
||||
return { resolver, registry, reads };
|
||||
}
|
||||
|
||||
describe("audio-ir-loader OTel surface (helpers)", () => {
|
||||
afterEach(() => uninstall());
|
||||
|
||||
it("recordSuccess records bytes_read_total + frames_out_total + duration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rec = installRecorder();
|
||||
recordSuccess("room", 100000, 25000, 12.5);
|
||||
const bytes = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.bytes_read_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
const frames = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.frames_out_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
const duration = rec.histograms.find(
|
||||
(h) =>
|
||||
h.name === "audio_ir_loader.load_duration_ms" &&
|
||||
h.attributes["family"] === "room" &&
|
||||
h.attributes["outcome"] === "success"
|
||||
);
|
||||
expect(bytes?.value).toBe(100000);
|
||||
expect(frames?.value).toBe(25000);
|
||||
expect(duration?.value).toBe(12.5);
|
||||
});
|
||||
|
||||
it("recordCacheHit increments cache_hits_total + cache_hit duration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rec = installRecorder();
|
||||
recordCacheHit("room", 0.5);
|
||||
const hit = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.cache_hits_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
const dur = rec.histograms.find(
|
||||
(h) => h.attributes["outcome"] === "cache_hit"
|
||||
);
|
||||
expect(hit?.value).toBe(1);
|
||||
expect(dur?.value).toBe(0.5);
|
||||
});
|
||||
|
||||
it("recordCacheMiss increments cache_misses_total", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rec = installRecorder();
|
||||
recordCacheMiss("speaker");
|
||||
const miss = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.cache_misses_total" &&
|
||||
c.attributes["family"] === "speaker"
|
||||
);
|
||||
expect(miss?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("recordError increments errors_total{code} + error duration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rec = installRecorder();
|
||||
recordError("AUDIO_IR.NOT_REGISTERED", "unknown", 7.0);
|
||||
const err = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.errors_total" &&
|
||||
c.attributes["code"] === "AUDIO_IR.NOT_REGISTERED"
|
||||
);
|
||||
expect(err?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("getInstruments returns cached instance without force", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
installRecorder();
|
||||
const a = getInstruments();
|
||||
const b = getInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("setAudioIrLoaderMeterProvider(undefined) reverts to global provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
installRecorder();
|
||||
setAudioIrLoaderMeterProvider(undefined);
|
||||
// Subsequent recordSuccess still works (no-op meter).
|
||||
recordSuccess("room", 1, 1, 1);
|
||||
});
|
||||
|
||||
it("uriKindOf classifies known schemes", { timeout: 5000 }, () => {
|
||||
expect(uriKindOf("bundle://x/y.wav")).toBe("bundle");
|
||||
expect(uriKindOf("fallback://?u=x")).toBe("fallback");
|
||||
expect(uriKindOf("https://h/x")).toBe("https");
|
||||
expect(uriKindOf("http://h/x")).toBe("https");
|
||||
expect(uriKindOf("drive://share/x")).toBe("drive");
|
||||
expect(uriKindOf("file:/path")).toBe("other");
|
||||
expect(uriKindOf("garbage")).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio-ir-loader OTel surface (end-to-end)", () => {
|
||||
afterEach(() => uninstall());
|
||||
|
||||
it("records load_duration_ms and bytes_read_total on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = installRecorder();
|
||||
const { resolver, registry, reads } = harness();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array(100));
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array(25)],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
const bytes = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.bytes_read_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
const frames = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.frames_out_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
const dur = rec.histograms.find(
|
||||
(h) =>
|
||||
h.name === "audio_ir_loader.load_duration_ms" &&
|
||||
h.attributes["outcome"] === "success"
|
||||
);
|
||||
expect(bytes?.value).toBe(100);
|
||||
expect(frames?.value).toBe(25);
|
||||
expect(dur).toBeDefined();
|
||||
});
|
||||
|
||||
it("increments cache_hits_total on cache hit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = installRecorder();
|
||||
const { resolver, registry, reads } = harness();
|
||||
registry.register(ENTRY(), {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
reads.set("x/club1.wav", new Uint8Array([1]));
|
||||
const decoder: AudioDecoder = async () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decoder },
|
||||
cache: { maxBytes: 4096 },
|
||||
});
|
||||
await loader.load("club-club1");
|
||||
await loader.load("club-club1");
|
||||
const hits = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.cache_hits_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
expect(hits?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("increments errors_total with the resolved code from any namespace", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = installRecorder();
|
||||
const { resolver, registry } = harness();
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: {
|
||||
wav: (async () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
})) as AudioDecoder,
|
||||
},
|
||||
});
|
||||
let captured: AudioIrLoaderError | undefined;
|
||||
try {
|
||||
await loader.load("nope");
|
||||
} catch (err) {
|
||||
if (err instanceof AudioIrLoaderError) captured = err;
|
||||
}
|
||||
// The throw is AUDIO_IR.NOT_REGISTERED (an AudioIrError), not a loader error.
|
||||
// No need to assert captured here — we're checking the counter only.
|
||||
void captured;
|
||||
const err = rec.counters.find(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.errors_total" &&
|
||||
c.attributes["code"] === "AUDIO_IR.NOT_REGISTERED"
|
||||
);
|
||||
expect(err).toBeDefined();
|
||||
});
|
||||
});
|
||||
146
packages/media/audio-ir/audio-ir-loader/src/otel/otel.ts
Normal file
146
packages/media/audio-ir/audio-ir-loader/src/otel/otel.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
import type { IrFamily } from "@suaveplan/audio-ir-core";
|
||||
|
||||
const METER_NAME = "@suaveplan/audio-ir-loader";
|
||||
const TRACER_NAME = "@suaveplan/audio-ir-loader";
|
||||
|
||||
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 so the next access binds to the new provider.
|
||||
*/
|
||||
export function setAudioIrLoaderMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
overrideMeterProvider = provider;
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
/** Load-outcome label used on `audio_ir_loader.load_duration_ms`. */
|
||||
export type AudioIrLoaderLoadOutcome = "success" | "cache_hit" | "error";
|
||||
|
||||
export interface AudioIrLoaderInstruments {
|
||||
readonly tracer: Tracer;
|
||||
readonly meter: Meter;
|
||||
readonly loadDurationMs: Histogram;
|
||||
readonly bytesReadTotal: Counter;
|
||||
readonly framesOutTotal: Counter;
|
||||
readonly cacheHitsTotal: Counter;
|
||||
readonly cacheMissesTotal: Counter;
|
||||
readonly errorsTotal: Counter;
|
||||
}
|
||||
|
||||
let cachedInstruments: AudioIrLoaderInstruments | undefined;
|
||||
|
||||
/** Lazily build (or return cached) instruments bound to the active provider. */
|
||||
export function getInstruments(force = false): AudioIrLoaderInstruments {
|
||||
if (cachedInstruments !== undefined && !force) return cachedInstruments;
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
|
||||
cachedInstruments = {
|
||||
tracer,
|
||||
meter,
|
||||
loadDurationMs: meter.createHistogram(
|
||||
"audio_ir_loader.load_duration_ms",
|
||||
{
|
||||
description:
|
||||
"End-to-end load duration in ms; labels: family, outcome",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
bytesReadTotal: meter.createCounter(
|
||||
"audio_ir_loader.bytes_read_total",
|
||||
{
|
||||
description: "Raw bytes read from the resolver; labels: family",
|
||||
}
|
||||
),
|
||||
framesOutTotal: meter.createCounter(
|
||||
"audio_ir_loader.frames_out_total",
|
||||
{
|
||||
description: "Decoded frame count per channel; labels: family",
|
||||
}
|
||||
),
|
||||
cacheHitsTotal: meter.createCounter(
|
||||
"audio_ir_loader.cache_hits_total",
|
||||
{
|
||||
description: "LRU cache hits; labels: family",
|
||||
}
|
||||
),
|
||||
cacheMissesTotal: meter.createCounter(
|
||||
"audio_ir_loader.cache_misses_total",
|
||||
{
|
||||
description: "LRU cache misses; labels: family",
|
||||
}
|
||||
),
|
||||
errorsTotal: meter.createCounter("audio_ir_loader.errors_total", {
|
||||
description:
|
||||
"Every error thrown by the loader; labels: code (fully-qualified)",
|
||||
}),
|
||||
};
|
||||
return cachedInstruments;
|
||||
}
|
||||
|
||||
/** Reset cached instruments — used by tests between provider swaps. */
|
||||
export function resetInstruments(): void {
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
/** Increment `bytes_read_total{family}` by `bytes` and `frames_out_total{family}` by `frames`. */
|
||||
export function recordSuccess(
|
||||
family: IrFamily,
|
||||
bytes: number,
|
||||
frames: number,
|
||||
durationMs: number
|
||||
): void {
|
||||
const inst = getInstruments();
|
||||
inst.bytesReadTotal.add(bytes, { family });
|
||||
inst.framesOutTotal.add(frames, { family });
|
||||
inst.loadDurationMs.record(durationMs, { family, outcome: "success" });
|
||||
}
|
||||
|
||||
/** Record a cache hit + the (very short) duration the hit cost. */
|
||||
export function recordCacheHit(family: IrFamily, durationMs: number): void {
|
||||
const inst = getInstruments();
|
||||
inst.cacheHitsTotal.add(1, { family });
|
||||
inst.loadDurationMs.record(durationMs, { family, outcome: "cache_hit" });
|
||||
}
|
||||
|
||||
/** Record a cache miss. */
|
||||
export function recordCacheMiss(family: IrFamily): void {
|
||||
getInstruments().cacheMissesTotal.add(1, { family });
|
||||
}
|
||||
|
||||
/** Increment `errors_total{code}` and record duration with `outcome=error`. */
|
||||
export function recordError(
|
||||
code: string,
|
||||
family: IrFamily | "unknown",
|
||||
durationMs: number
|
||||
): void {
|
||||
const inst = getInstruments();
|
||||
inst.errorsTotal.add(1, { code });
|
||||
inst.loadDurationMs.record(durationMs, { family, outcome: "error" });
|
||||
}
|
||||
|
||||
/** Derive `audio_ir.uri_kind` span attribute from a URI's leading scheme. */
|
||||
export function uriKindOf(
|
||||
uri: string
|
||||
): "bundle" | "fallback" | "https" | "drive" | "other" {
|
||||
const colon = uri.indexOf(":");
|
||||
if (colon <= 0) return "other";
|
||||
const scheme = uri.slice(0, colon).toLowerCase();
|
||||
if (scheme === "bundle") return "bundle";
|
||||
if (scheme === "fallback") return "fallback";
|
||||
if (scheme === "https" || scheme === "http") return "https";
|
||||
if (scheme === "drive") return "drive";
|
||||
return "other";
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import {
|
||||
createIrRegistry,
|
||||
type IrManifestEntry,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
import {
|
||||
buildFallbackUri,
|
||||
fallbackUriPlugin,
|
||||
resetFallbackInstruments,
|
||||
setFallbackMeterProvider,
|
||||
} from "@suaveplan/storage-uri-plugin-fallback";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createIrLoader } from "../../src/index.js";
|
||||
import {
|
||||
getInstruments,
|
||||
resetInstruments,
|
||||
setAudioIrLoaderMeterProvider,
|
||||
} from "../../src/otel/otel.js";
|
||||
|
||||
interface CounterObs {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
interface HistogramObs {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-test minimal `http`/`https` `UriPlugin` adapter wrapping
|
||||
* `globalThis.fetch`. Lives in the test file by design — there is no
|
||||
* shipped storage-uri plugin for HTTPS yet, and the integration test
|
||||
* must not introduce a new published package.
|
||||
*/
|
||||
function makeHttpPlugin(scheme: "http" | "https"): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
const url = `${scheme}://${key}`;
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.ADAPTER_ERROR,
|
||||
`HTTP ${res.status} from ${url}`,
|
||||
{ uri: url, scheme }
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists(key: string) {
|
||||
const url = `${scheme}://${key}`;
|
||||
const res = await fetch(url, { method: "HEAD" });
|
||||
return res.ok;
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
const noScheme = uri.replace(new RegExp(`^${scheme}://`), "");
|
||||
return { key: noScheme };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal RIFF/WAVE 16-bit mono PCM builder for the integration fixture. */
|
||||
function buildSilentWav(
|
||||
sampleRate: number,
|
||||
samplesPerChannel: number
|
||||
): Uint8Array {
|
||||
const dataBytes = samplesPerChannel * 2;
|
||||
const buf = new Uint8Array(44 + dataBytes);
|
||||
const dv = new DataView(buf.buffer);
|
||||
const writeAscii = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||
};
|
||||
writeAscii(0, "RIFF");
|
||||
dv.setUint32(4, 36 + dataBytes, true);
|
||||
writeAscii(8, "WAVE");
|
||||
writeAscii(12, "fmt ");
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true); // mono
|
||||
dv.setUint32(24, sampleRate, true);
|
||||
dv.setUint32(28, sampleRate * 2, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
writeAscii(36, "data");
|
||||
dv.setUint32(40, dataBytes, true);
|
||||
// body left as silence (zeros) — sufficient for the decode happy path.
|
||||
return buf;
|
||||
}
|
||||
|
||||
function installCombinedRecorder(): {
|
||||
counters: CounterObs[];
|
||||
histograms: HistogramObs[];
|
||||
} {
|
||||
const counters: CounterObs[] = [];
|
||||
const histograms: HistogramObs[] = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (value: number, attrs?: Record<string, unknown>) => {
|
||||
counters.push({ name, value, attributes: attrs ?? {} });
|
||||
},
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (value: number, attrs?: Record<string, unknown>) => {
|
||||
histograms.push({ name, value, attributes: attrs ?? {} });
|
||||
},
|
||||
}),
|
||||
createObservableGauge: () => ({
|
||||
addCallback: () => {},
|
||||
}),
|
||||
}),
|
||||
} as unknown as MeterProvider;
|
||||
setAudioIrLoaderMeterProvider(provider);
|
||||
setFallbackMeterProvider(provider);
|
||||
getInstruments(true);
|
||||
return { counters, histograms };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
server: Server;
|
||||
port: number;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
let harness: Harness | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "audio-ir-loader-test-"));
|
||||
// Create a club/ subdirectory but DON'T put missing.wav in it — so the
|
||||
// bundle adapter's GET for `bundle://club/missing.wav` will surface a
|
||||
// local-file 404 from the file:// URL.
|
||||
await mkdir(join(tempDir, "club"), { recursive: true });
|
||||
const wavBytes = buildSilentWav(44100, 32);
|
||||
// Stand up a node:http server that serves /club1.wav with the WAV bytes
|
||||
// and 404 for everything else.
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/club1.wav") {
|
||||
res.writeHead(200, { "Content-Type": "audio/wav" });
|
||||
res.end(Buffer.from(wavBytes));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve)
|
||||
);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
harness = { server, port, tempDir };
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (harness !== undefined) {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
harness!.server.close((err) => (err ? reject(err) : resolve()))
|
||||
);
|
||||
await rm(harness.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
setAudioIrLoaderMeterProvider(undefined);
|
||||
setFallbackMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
resetFallbackInstruments();
|
||||
}, 30000);
|
||||
|
||||
describe("integration: bundle-then-http fallback walk", () => {
|
||||
it("bundle misses fall back to HTTPS and load succeeds end-to-end", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
if (harness === undefined) throw new Error("harness not initialised");
|
||||
const rec = installCombinedRecorder();
|
||||
|
||||
// Build a UriPlugin set: bundle:// rooted at the tempDir (no
|
||||
// missing.wav exists), http:// via the in-test plugin.
|
||||
// The fallback plugin builds a resolver-of-resolvers chain; the
|
||||
// inner resolver must include bundle + http.
|
||||
const innerBundle = bundleUriPlugin({
|
||||
packageSlug: "club",
|
||||
rootUrl: new URL(`file://${harness.tempDir}/`),
|
||||
});
|
||||
const innerHttp = makeHttpPlugin("http");
|
||||
const innerResolver = createStorageUriResolver([
|
||||
innerBundle,
|
||||
innerHttp,
|
||||
]);
|
||||
const fbPlugin = fallbackUriPlugin({
|
||||
resolver: innerResolver,
|
||||
policy: { errorClass: "any" },
|
||||
});
|
||||
const outerResolver = createStorageUriResolver([
|
||||
innerBundle,
|
||||
innerHttp,
|
||||
fbPlugin,
|
||||
]);
|
||||
|
||||
const bundleUri = "bundle://club/missing.wav";
|
||||
const httpUri = `http://127.0.0.1:${harness.port}/club1.wav`;
|
||||
const fallbackUri = buildFallbackUri([bundleUri, httpUri]);
|
||||
|
||||
const entry: IrManifestEntry = {
|
||||
id: "club-pool",
|
||||
family: "room",
|
||||
uri: fallbackUri,
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: "MIT",
|
||||
source: "fixture",
|
||||
} as IrManifestEntry;
|
||||
const registry = createIrRegistry();
|
||||
registry.register(entry, {
|
||||
manifestSlug: "club",
|
||||
manifestFamily: "room",
|
||||
});
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver: outerResolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
const ir = await loader.load("club-pool");
|
||||
expect(ir.id).toBe("club-pool");
|
||||
expect(ir.channels).toBe(1);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.channelData.length).toBe(1);
|
||||
|
||||
// Loader bytes_read_total should have ticked exactly once with
|
||||
// family=room.
|
||||
const bytesRead = rec.counters.filter(
|
||||
(c) =>
|
||||
c.name === "audio_ir_loader.bytes_read_total" &&
|
||||
c.attributes["family"] === "room"
|
||||
);
|
||||
expect(bytesRead.length).toBe(1);
|
||||
|
||||
// The fallback plugin's per-candidate counter should have a
|
||||
// candidate_index=0 transient sample (the missing bundle URL).
|
||||
const fbTransient = rec.counters.filter(
|
||||
(c) =>
|
||||
c.name === "storage_uri.fallback_total" &&
|
||||
c.attributes["candidate_index"] === 0 &&
|
||||
c.attributes["outcome"] === "transient"
|
||||
);
|
||||
expect(fbTransient.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio-ir/audio-ir-loader/tsconfig.json
Normal file
12
packages/media/audio-ir/audio-ir-loader/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", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/media/audio-ir/audio-ir-loader/tsconfig.lib.json
Normal file
12
packages/media/audio-ir/audio-ir-loader/tsconfig.lib.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", "tests/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/media/audio-ir/audio-ir-loader/vite.config.ts
Normal file
39
packages/media/audio-ir/audio-ir-loader/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-ir-loader",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/audio-ir-core",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/storage-uri",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
5
packages/media/audio-ir/audio-ir-loader/vitest.config.ts
Normal file
5
packages/media/audio-ir/audio-ir-loader/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30000,
|
||||
});
|
||||
86
packages/media/audio-ir/audio-ir-room-plugin-club/README.md
Normal file
86
packages/media/audio-ir/audio-ir-room-plugin-club/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# @suaveplan/audio-ir-room-plugin-club - Original audio-ir content package: ships the bundled `club1.wav` and `club2.wav` room impulse responses, registered through @suaveplan/storage-uri-plugin-bundle and @suaveplan/audio-ir-core. Universal; bundle-only content package.
|
||||
|
||||
Original audio-ir content package. Ships two bundled room impulse responses (`club1.wav`, `club2.wav`) wired through the `@suaveplan/audio-ir-*` family.
|
||||
|
||||
This package is the canonical example of the **bundle-only** content-package pattern in the IR family — every future `audio-ir-{family}-plugin-<name>` package that ships only local files follows this shape.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [manifest](./src/manifest/manifest.md) — The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `…
|
||||
- [manifest](./src/manifest/manifest.md) — The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `…
|
||||
- [register](./src/register/register.md) — This module is the only side-effecting entrypoint a consumer ever calls from `@suaveplan/audio-ir-room-plugin-club`. It…
|
||||
- [register](./src/register/register.md) — This module is the only side-effecting entrypoint a consumer ever calls from `@suaveplan/audio-ir-room-plugin-club`. It…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audio-ir-room-plugin-club
|
||||
```
|
||||
|
||||
Peer-installed alongside `@suaveplan/audio-ir-core`, `@suaveplan/storage-uri`, `@suaveplan/storage-uri-plugin-bundle`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { register } from "@suaveplan/audio-ir-room-plugin-club";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
register(registry, collector, { fetch: nodeFileFetch }); // omit `fetch` in the browser
|
||||
const resolver = createStorageUriResolver(collector.plugins);
|
||||
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
|
||||
const club1 = await loader.load("club-club1");
|
||||
const club2 = await loader.load("club-club2");
|
||||
```
|
||||
|
||||
## Manifest
|
||||
|
||||
| `id` | URI | Channels | Sample rate | Gain |
|
||||
|---|---|---|---|---|
|
||||
| `club-club1` | `bundle://audio-ir-room-plugin-club/club1.wav` | 2 | 44 100 Hz | 0 dB |
|
||||
| `club-club2` | `bundle://audio-ir-room-plugin-club/club2.wav` | 2 | 44 100 Hz | 0 dB |
|
||||
|
||||
Channels and sample rate are decoded at manifest-authoring time; the loader re-validates and throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` on disagreement.
|
||||
|
||||
## Modules
|
||||
|
||||
- `./manifest` — `manifest: IrManifest`, `BUNDLE_PACKAGE_SLUG`, `MANIFEST_SLUG`.
|
||||
- `./register` — `register(registry, collector, options?)` (collector pattern).
|
||||
|
||||
The root barrel re-exports every public binding so `import { register } from "@suaveplan/audio-ir-room-plugin-club"` is equivalent to the subpath form.
|
||||
|
||||
## Browser vs Node
|
||||
|
||||
- **Browser**: omit `options.fetch` in `register` — `globalThis.fetch` reads the bundled URL directly.
|
||||
- **Node**: pass `register(registry, collector, { fetch: nodeFileFetch })` from `@suaveplan/storage-uri-plugin-bundle/node-fetch` — Node's undici-backed global `fetch` refuses `file:` URLs.
|
||||
|
||||
## License & sources
|
||||
|
||||
This package is published under the MIT license. Both IR captures (`club1.wav`, `club2.wav`) originate from the operator's Mountain Duck media library at `/media/_/audio/ir/room/club{1,2}.wav` and are redistributed inside Suaveplan under the same MIT license. The IR samples themselves are room captures with no upstream proprietary attribution requirement.
|
||||
|
||||
The `IrManifestEntry.source` field on each entry records the Mountain Duck origin path so a future operator can reproduce the import end-to-end.
|
||||
|
||||
## Pre-publish gate
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/audio-ir-room-plugin-club
|
||||
bunx turbo test:coverage --filter=@suaveplan/audio-ir-room-plugin-club # 100/100/100/100
|
||||
bunx turbo build --filter=@suaveplan/audio-ir-room-plugin-club
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-ir-room-plugin-club",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Genesis-original audio-ir content package: ships the bundled `club1.wav` and `club2.wav` room impulse responses, registered through @suaveplan/storage-uri-plugin-bundle and @suaveplan/audio-ir-core. Universal; bundle-only content package.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./manifest": {
|
||||
"import": {
|
||||
"types": "./dist/manifest/manifest.d.ts",
|
||||
"default": "./dist/manifest/manifest.js"
|
||||
}
|
||||
},
|
||||
"./register": {
|
||||
"import": {
|
||||
"types": "./dist/register/register.d.ts",
|
||||
"default": "./dist/register/register.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-ir-core": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/storage-uri": "workspace:^",
|
||||
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-decode-wav": "workspace:^",
|
||||
"@suaveplan/audio-ir-loader": "workspace:^",
|
||||
"@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",
|
||||
"files",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BUNDLE_PACKAGE_SLUG,
|
||||
MANIFEST_SLUG,
|
||||
manifest,
|
||||
} from "./manifest/manifest.js";
|
||||
export {
|
||||
type RegisterOptions,
|
||||
register,
|
||||
} from "./register/register.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# `manifest` — frozen room-IR manifest for the club package
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `manifest` constant is the single source of truth for which impulse responses this content package ships. It is a `IrManifest` parsed once through `IrManifestSchema` at module load time, frozen by the schema's `.transform`, and exported for two consumers: (1) `register(registry, resolver)` (the sibling `src/register` module) which walks every entry into the supplied `IrRegistry`, and (2) tooling that wants to enumerate available IRs without touching the storage resolver (catalog UIs, registry warm-up loaders, build-time docs).
|
||||
|
||||
Every value in the manifest is a literal at authoring time. Both `channels` and `sampleRate` were captured by decoding the bundled WAVs through `@suaveplan/audio-decode-wav` and recorded as constants so the manifest can never drift from the on-disk shape. The IR loader (`@suaveplan/audio-ir-loader`) re-validates these fields at decode time and throws `AUDIO_IR_LOADER.MANIFEST_MISMATCH` if they disagree — but for a bundle-only package the on-disk file is also part of the tarball, so the only way the values can disagree is a manual edit, which the manifest-validation gate catches before publish.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen at parse time — every consumer reads the same `Object.freeze`d value.
|
||||
- Schema-validated id-prefix refinement (`club-*`) and family coherence (`room` on every entry).
|
||||
- Pre-computed bundle URIs that match the package's slug verbatim.
|
||||
- Stable insertion order: `club-club1` before `club-club2`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { manifest } from "@suaveplan/audio-ir-room-plugin-club/manifest";
|
||||
|
||||
console.log(manifest.entries.map((e) => e.id));
|
||||
// → ["club-club1", "club-club2"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { manifest, BUNDLE_PACKAGE_SLUG } from "@suaveplan/audio-ir-room-plugin-club/manifest";
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
|
||||
import { parseUri } from "@suaveplan/storage-uri";
|
||||
|
||||
// Re-validate (no-op — already parsed at load time, but useful as a smoke test).
|
||||
const reparsed = IrManifestSchema.parse(manifest);
|
||||
console.assert(reparsed.packageSlug === "club");
|
||||
|
||||
// Verify every entry resolves to a well-formed bundle:// URI bound to
|
||||
// THIS package's slug.
|
||||
for (const entry of manifest.entries) {
|
||||
const parts = parseUri(entry.uri);
|
||||
console.assert(parts.scheme === "bundle");
|
||||
console.assert(parts.authority === BUNDLE_PACKAGE_SLUG);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `manifest: IrManifest`
|
||||
|
||||
The frozen, parsed manifest. Two entries, both `family: "room"`, both `gainDb: 0`, both 44.1 kHz stereo.
|
||||
|
||||
### `BUNDLE_PACKAGE_SLUG: "audio-ir-room-plugin-club"`
|
||||
|
||||
The full npm-style slug used as the `bundle://<slug>/<rel>` authority. Exported so tests and the sibling `register` module can reference it without repeating the literal.
|
||||
|
||||
### `MANIFEST_SLUG: "club"`
|
||||
|
||||
The short slug stamped onto every `entry.id` as `<MANIFEST_SLUG>-<filename-stem>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The two slugs (`MANIFEST_SLUG` and `BUNDLE_PACKAGE_SLUG`) intentionally differ: the manifest slug follows the design's "short id" rule (`design.md` §2) while the bundle slug must match the npm package name so a single resolver can route multiple content packages without collision. The bundle plugin enforces the longer form on `parse`; the registry enforces the shorter form on the entry id. Re-running `IrManifestSchema.parse(manifest)` is idempotent and free of side effects — the schema's `.transform` only freezes, it does not clone.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
|
||||
import { parseUri } from "@suaveplan/storage-uri";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BUNDLE_PACKAGE_SLUG, manifest } from "./manifest.js";
|
||||
|
||||
describe("manifest", () => {
|
||||
it("manifest parses through IrManifestSchema", { timeout: 5000 }, () => {
|
||||
const parsed = IrManifestSchema.parse(manifest);
|
||||
expect(parsed).toEqual(manifest);
|
||||
expect(parsed.packageSlug).toBe("club");
|
||||
expect(parsed.family).toBe("room");
|
||||
for (const entry of parsed.entries) {
|
||||
expect(entry.id.startsWith("club-")).toBe(true);
|
||||
expect(entry.family).toBe("room");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares exactly club-club1 and club-club2", { timeout: 5000 }, () => {
|
||||
const ids = manifest.entries.map((e) => e.id);
|
||||
expect(ids).toEqual(["club-club1", "club-club2"]);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("every URI parses as a well-formed bundle URI", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const entry of manifest.entries) {
|
||||
const parts = parseUri(entry.uri);
|
||||
expect(parts.scheme).toBe("bundle");
|
||||
expect(parts.authority).toBe(BUNDLE_PACKAGE_SLUG);
|
||||
expect(parts.path.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every entry declares positive channels, sampleRate, and finite gainDb", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const entry of manifest.entries) {
|
||||
expect(entry.channels).toBeGreaterThanOrEqual(1);
|
||||
expect(entry.sampleRate).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(entry.gainDb)).toBe(true);
|
||||
expect(entry.license.length).toBeGreaterThan(0);
|
||||
expect(entry.source.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type IrManifest, IrManifestSchema } from "@suaveplan/audio-ir-core";
|
||||
|
||||
/**
|
||||
* npm-name slug used as the bundle URI authority. MUST match the package
|
||||
* directory name and the `bundle://<slug>/...` authority in every
|
||||
* `IrManifestEntry.uri` below — the bundle plugin's `parse` rejects any
|
||||
* URI whose authority differs from this string verbatim.
|
||||
*/
|
||||
export const BUNDLE_PACKAGE_SLUG = "audio-ir-room-plugin-club" as const;
|
||||
|
||||
/**
|
||||
* Short slug embedded into every entry id under the
|
||||
* `<manifestSlug>-<filename-stem>` rule from `audio-ir-core/design.md` §2.
|
||||
* The id-prefix `IrManifestSchema` refinement requires every entry id to
|
||||
* start with `<manifestSlug>-`.
|
||||
*/
|
||||
export const MANIFEST_SLUG = "club" as const;
|
||||
|
||||
/**
|
||||
* License declared on every entry. Both IRs originate from the operator's
|
||||
* Mountain Duck media library and are redistributed inside the genesis
|
||||
* monorepo under the package's MIT license — the IR samples themselves are
|
||||
* room captures with no upstream proprietary attribution requirement.
|
||||
*/
|
||||
const LICENSE = "MIT (Suaveplan-original room capture)" as const;
|
||||
|
||||
/**
|
||||
* Source attribution for `club1.wav`. Identifies the file by its full
|
||||
* Mountain Duck mount path so an operator can reproduce the import.
|
||||
*/
|
||||
const SOURCE_CLUB1 =
|
||||
"Mountain Duck media library: /media/_/audio/ir/room/club1.wav" as const;
|
||||
|
||||
/**
|
||||
* Source attribution for `club2.wav`.
|
||||
*/
|
||||
const SOURCE_CLUB2 =
|
||||
"Mountain Duck media library: /media/_/audio/ir/room/club2.wav" as const;
|
||||
|
||||
/**
|
||||
* Frozen, schema-validated `IrManifest` describing the two club room IRs
|
||||
* shipped by this package. `channels` and `sampleRate` were measured by
|
||||
* decoding the bundled WAVs at manifest-authoring time (both are 44.1 kHz
|
||||
* stereo). `gainDb: 0` means the loader applies no gain adjustment.
|
||||
*
|
||||
* The manifest is parsed through `IrManifestSchema` exactly once at module
|
||||
* load — every consumer sees the frozen, validated value. Re-parsing is
|
||||
* unnecessary; mutating it is impossible.
|
||||
*/
|
||||
export const manifest: IrManifest = IrManifestSchema.parse({
|
||||
packageSlug: MANIFEST_SLUG,
|
||||
family: "room",
|
||||
entries: [
|
||||
{
|
||||
id: "club-club1",
|
||||
family: "room",
|
||||
uri: `bundle://${BUNDLE_PACKAGE_SLUG}/club1.wav`,
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: LICENSE,
|
||||
source: SOURCE_CLUB1,
|
||||
},
|
||||
{
|
||||
id: "club-club2",
|
||||
family: "room",
|
||||
uri: `bundle://${BUNDLE_PACKAGE_SLUG}/club2.wav`,
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
gainDb: 0,
|
||||
license: LICENSE,
|
||||
source: SOURCE_CLUB2,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
# `register` — collector-pattern wiring entrypoint
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the only side-effecting entrypoint a consumer ever calls from
|
||||
`@suaveplan/audio-ir-room-plugin-club`. It bridges three pieces that exist in
|
||||
three separate packages: the in-memory `IrRegistry` from
|
||||
`@suaveplan/audio-ir-core` (which holds manifest entries), a
|
||||
`MutableUriPluginRegistrar` collector — typically built via
|
||||
`createUriPluginCollector()` — that gathers `UriPlugin`s before resolver
|
||||
construction, and the `bundleUriPlugin` from
|
||||
`@suaveplan/storage-uri-plugin-bundle` (which knows how to read this
|
||||
package's bundled WAVs).
|
||||
|
||||
The package never auto-registers on import — every consumer calls
|
||||
`register(registry, collector)` explicitly. Importing the package's barrel
|
||||
without calling `register` is a no-op aside from loading the frozen
|
||||
manifest constant. This matches the side-effect-free contract on all
|
||||
`@suaveplan/audio-ir-*` content packages and matches the family-canon
|
||||
shape established by `audio-ir-room-plugin-pool`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single call wires both the bundle adapter (via `target.use(plugin)`) and
|
||||
the manifest registration (via `registerManifest`).
|
||||
- `RegisterOptions.fetch?: FetchLike` lets Node-tier callers pass
|
||||
`nodeFileFetch` so `file://` reads work under undici's global `fetch`.
|
||||
- Default `rootUrl` resolves to the in-tarball `files/` directory via
|
||||
`import.meta.url` — works in both Node and any modern bundler that
|
||||
preserves package URLs.
|
||||
- No `createPlugin()` separate step; no `resolver.hasScheme("bundle")`
|
||||
structural check; the collector pattern makes both unnecessary.
|
||||
- Errors from `registerManifest` (`AUDIO_IR.DUPLICATE_ID`,
|
||||
`AUDIO_IR.ID_PREFIX_MISMATCH`, …) pass through verbatim.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { register } from "@suaveplan/audio-ir-room-plugin-club";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
|
||||
register(registry, collector);
|
||||
|
||||
const resolver = createStorageUriResolver(collector.plugins);
|
||||
// registry.has("club-club1") === true
|
||||
// registry.has("club-club2") === true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { register } from "@suaveplan/audio-ir-room-plugin-club";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";
|
||||
|
||||
// Node.js: globalThis.fetch refuses file:// — wire nodeFileFetch.
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
register(registry, collector, { fetch: nodeFileFetch });
|
||||
|
||||
const resolver = createStorageUriResolver(collector.plugins);
|
||||
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
|
||||
const club1 = await loader.load("club-club1");
|
||||
console.log(club1.channelData[0].length, "samples @", club1.sampleRate, "Hz");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `register(registry: IrRegistry, target: MutableUriPluginRegistrar, options?: RegisterOptions): void`
|
||||
|
||||
Builds the package-scoped `bundleUriPlugin`, hands it to `target.use(plugin)`,
|
||||
then calls `registerManifest(registry, manifest)`.
|
||||
|
||||
- `registry` — an `IrRegistry` from `@suaveplan/audio-ir-core`.
|
||||
- `target` — any object satisfying
|
||||
`MutableUriPluginRegistrar` (`use(plugin: UriPlugin): void`). Typically
|
||||
built via `createUriPluginCollector()`.
|
||||
- `options.fetch?: FetchLike` — overrides the runtime fetch (pass
|
||||
`nodeFileFetch` on Node so `file://` URLs resolve).
|
||||
|
||||
Throws `AUDIO_IR.DUPLICATE_ID` if any manifest entry is already in the
|
||||
registry.
|
||||
|
||||
### `interface RegisterOptions`
|
||||
|
||||
- `readonly fetch?: FetchLike` — see above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`new URL("../../files/", import.meta.url)` is intentional — the
|
||||
`register/register.ts` file lives at `src/register/register.ts` in the
|
||||
source tree, and at `dist/register/register.js` after build. In both
|
||||
cases two levels up lands on the package root, where `files/` sits at
|
||||
the same level as `dist/` and `src/`. The trailing slash is required by
|
||||
`bundleUriPlugin`'s `rootUrl` validator.
|
||||
|
||||
The `target.use(plugin)` call happens before `registerManifest(...)` so
|
||||
that even when `registerManifest` rejects (for example with
|
||||
`AUDIO_IR.DUPLICATE_ID` on a double-register), the bundle plugin is
|
||||
already in the caller's collector. This matches the family-canon
|
||||
ordering established by `audio-ir-room-plugin-pool` and keeps the
|
||||
collector observable when tests inspect it after a thrown error.
|
||||
|
||||
The double-register test relies on `registerManifest` calling
|
||||
`registry.register(entry, ctx)` for each entry in declaration order. The
|
||||
registry throws `AUDIO_IR.DUPLICATE_ID` on the first colliding id — for
|
||||
this package, `club-club1` — so the rethrown error is deterministic.
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AUDIO_IR_CODES,
|
||||
AudioIrError,
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { register } from "./register.js";
|
||||
|
||||
describe("register", () => {
|
||||
it("register calls target.use exactly once with a bundle plugin", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
|
||||
register(registry, collector);
|
||||
|
||||
expect(collector.plugins.length).toBe(1);
|
||||
const installed = collector.plugins[0];
|
||||
expect(installed).toBeDefined();
|
||||
if (installed === undefined) throw new Error("unreachable");
|
||||
expect(installed.scheme).toBe("bundle");
|
||||
|
||||
// Parse smoke-tests that the plugin is bound to this package's
|
||||
// slug — the bundle plugin's `parse` accepts URIs whose authority
|
||||
// equals `audio-ir-room-plugin-club`.
|
||||
const parsed = installed.parse(
|
||||
"bundle://audio-ir-room-plugin-club/club1.wav"
|
||||
);
|
||||
expect(parsed.key).toBe("club1.wav");
|
||||
expect(parsed.meta?.packageSlug).toBe("audio-ir-room-plugin-club");
|
||||
});
|
||||
|
||||
it("register populates the registry with both club entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
|
||||
register(registry, collector);
|
||||
|
||||
expect(registry.has("club-club1")).toBe(true);
|
||||
expect(registry.has("club-club2")).toBe(true);
|
||||
|
||||
const rooms = registry.list({ family: "room" });
|
||||
const ids = rooms.map((e) => e.id);
|
||||
expect(ids).toContain("club-club1");
|
||||
expect(ids).toContain("club-club2");
|
||||
});
|
||||
|
||||
it("double register against same registry surfaces DUPLICATE_ID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createIrRegistry();
|
||||
const collectorA = createUriPluginCollector();
|
||||
const collectorB = createUriPluginCollector();
|
||||
|
||||
register(registry, collectorA);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
register(registry, collectorB);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioIrError);
|
||||
expect((caught as AudioIrError).code).toBe(AUDIO_IR_CODES.DUPLICATE_ID);
|
||||
|
||||
// Even though the second register threw, its `target.use(plugin)`
|
||||
// ran BEFORE `registerManifest` rejected — so collectorB still
|
||||
// received the bundle plugin. This matches family-canon ordering.
|
||||
expect(collectorB.plugins.length).toBe(1);
|
||||
expect(collectorB.plugins[0]?.scheme).toBe("bundle");
|
||||
});
|
||||
|
||||
it("register accepts an injected fetch via options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
const fakeFetch = async () => new Response(new Uint8Array());
|
||||
|
||||
register(registry, collector, { fetch: fakeFetch });
|
||||
|
||||
expect(collector.plugins.length).toBe(1);
|
||||
expect(collector.plugins[0]?.scheme).toBe("bundle");
|
||||
expect(registry.has("club-club1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
IrRegistry,
|
||||
MutableUriPluginRegistrar,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { registerManifest } from "@suaveplan/audio-ir-core";
|
||||
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { BUNDLE_PACKAGE_SLUG, manifest } from "../manifest/manifest.js";
|
||||
|
||||
/** Optional overrides for `register`. */
|
||||
export interface RegisterOptions {
|
||||
/**
|
||||
* Override the `fetch` implementation passed to `bundleUriPlugin`. Useful
|
||||
* under Node (which does NOT support `fetch('file://...')`) — pass
|
||||
* `nodeFileFetch` from `@suaveplan/storage-uri-plugin-bundle/node-fetch`.
|
||||
* Defaults to `globalThis.fetch`.
|
||||
*/
|
||||
readonly fetch?: FetchLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install this content package's bundle plugin onto `target` and register
|
||||
* every entry from `manifest` into `registry`.
|
||||
*
|
||||
* `register` does NOT install the shipped `fallback://` plugin or any
|
||||
* `https://` plugin — both are resolver-composition concerns the consumer
|
||||
* owns once per resolver. The collector pattern decouples plugin
|
||||
* construction from resolver construction so a single
|
||||
* `createStorageUriResolver(collector.plugins)` call composes every
|
||||
* content package's bundle plugin into one frozen resolver.
|
||||
*
|
||||
* `rootUrl` is computed from `import.meta.url` so the package's
|
||||
* `files/` directory is located deterministically regardless of where
|
||||
* the consumer installs the published tarball.
|
||||
*/
|
||||
export function register(
|
||||
registry: IrRegistry,
|
||||
target: MutableUriPluginRegistrar,
|
||||
options?: RegisterOptions
|
||||
): void {
|
||||
const plugin = bundleUriPlugin({
|
||||
packageSlug: BUNDLE_PACKAGE_SLUG,
|
||||
rootUrl: new URL("../../files/", import.meta.url),
|
||||
...(options?.fetch !== undefined ? { fetch: options.fetch } : {}),
|
||||
});
|
||||
target.use(plugin);
|
||||
registerManifest(registry, manifest);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { register } from "../../src/register/register.js";
|
||||
|
||||
/**
|
||||
* Minimal `file://` `FetchLike` for Node — undici's global `fetch` refuses
|
||||
* `file:` URLs. Sufficient for the GET requests the bundle plugin issues.
|
||||
*/
|
||||
const nodeFileFetch: typeof globalThis.fetch = async (
|
||||
input,
|
||||
init
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
input instanceof URL
|
||||
? input
|
||||
: input instanceof Request
|
||||
? new URL(input.url)
|
||||
: new URL(input);
|
||||
if (url.protocol !== "file:") {
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
const path = fileURLToPath(url);
|
||||
const st = await stat(path);
|
||||
if (!st.isFile()) return new Response(null, { status: 404 });
|
||||
const buf = await readFile(path);
|
||||
return new Response(buf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-length": String(st.size),
|
||||
"last-modified": st.mtime.toUTCString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("end-to-end", () => {
|
||||
it("collector wiring loads both club IRs", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
register(registry, collector, { fetch: nodeFileFetch });
|
||||
|
||||
const resolver = createStorageUriResolver(collector.plugins);
|
||||
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
|
||||
for (const id of ["club-club1", "club-club2"] as const) {
|
||||
const ir = await loader.load(id);
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
expect(ir.id).toBe(id);
|
||||
expect(ir.family).toBe("room");
|
||||
expect(ir.channels).toBe(2);
|
||||
expect(ir.sampleRate).toBe(44100);
|
||||
expect(ir.channelData.length).toBe(2);
|
||||
expect(ir.channelData[0]!.length).toBeGreaterThan(0);
|
||||
expect(ir.channelData[1]!.length).toBe(ir.channelData[0]!.length);
|
||||
expect(ir.lengthFrames).toBe(ir.channelData[0]!.length);
|
||||
expect(ir.durationSec).toBeGreaterThan(0);
|
||||
expect(ir.gainDb).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const FILES_ROOT = new URL("../../files/", import.meta.url);
|
||||
|
||||
describe("file-presence", () => {
|
||||
it("club1.wav and club2.wav exist on disk and are non-empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const name of ["club1.wav", "club2.wav"]) {
|
||||
const path = fileURLToPath(new URL(name, FILES_ROOT));
|
||||
const stat = statSync(path);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
expect(stat.size).toBeGreaterThan(0);
|
||||
const bytes = readFileSync(path);
|
||||
expect(bytes.byteLength).toBe(stat.size);
|
||||
expect(
|
||||
String.fromCharCode(bytes[0]!, bytes[1]!, bytes[2]!, bytes[3]!)
|
||||
).toBe("RIFF");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-ir-room-plugin-club",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/audio-ir-core",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/storage-uri",
|
||||
"@suaveplan/storage-uri-plugin-bundle",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30000,
|
||||
});
|
||||
74
packages/media/audio-ir/audio-ir-room-plugin-pool/README.md
Normal file
74
packages/media/audio-ir/audio-ir-room-plugin-pool/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# @suaveplan/audio-ir-room-plugin-pool - Original audio-ir content package: ships the outdoor-swimming-pool room impulse response paired with a https CDN fallback. Universal.
|
||||
|
||||
Original audio-ir content package. Ships a single `room`-family
|
||||
impulse response — `pool-outdoor-swimming-pool` — sourced from the
|
||||
outdoor-swimming-pool capture in the Suaveplan internal media library,
|
||||
paired with a CDN fallback at `https://swch.b-cdn.net/_/audio/ir/room/roompool.wav`.
|
||||
|
||||
Universal; no browser, DOM, or Node-specific APIs in the public
|
||||
surface. The shipped bundle plugin uses `import.meta.url` to locate the
|
||||
in-tarball `files/` directory at runtime.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [manifest](./src/manifest/manifest.md) — Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`. The entry's `uri` is a `fallback…
|
||||
- [manifest](./src/manifest/manifest.md) — Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`. The entry's `uri` is a `fallback…
|
||||
- [register](./src/register/register.md) — Single entry point for plugging `@suaveplan/audio-ir-room-plugin-pool` into a consumer's `IrRegistry` + `StorageUriReso…
|
||||
- [register](./src/register/register.md) — Single entry point for plugging `@suaveplan/audio-ir-room-plugin-pool` into a consumer's `IrRegistry` + `StorageUriReso…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- `./manifest` — `manifest: IrManifest`, plus the canonical constants
|
||||
(`BUNDLE_PACKAGE_SLUG`, `BUNDLE_URI`, `CDN_URI`, `ENTRY_ID`,
|
||||
`CHANNELS`, `SAMPLE_RATE`).
|
||||
- `./register` — `register(registry, target, options?)` and the
|
||||
re-exported `MutableUriPluginRegistrar` type (sourced from
|
||||
`@suaveplan/audio-ir-core`). Wires the bundle plugin onto a
|
||||
consumer-supplied registrar and calls `registerManifest`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { fallbackUriPlugin } from "@suaveplan/storage-uri-plugin-fallback";
|
||||
import { register } from "@suaveplan/audio-ir-room-plugin-pool";
|
||||
|
||||
const registry = createIrRegistry();
|
||||
const collector = createUriPluginCollector();
|
||||
register(registry, collector);
|
||||
|
||||
// Append the consumer's https://-aware plugin via collector.use(...).
|
||||
const innerPlugins = [...collector.plugins];
|
||||
const innerResolver = createStorageUriResolver(innerPlugins);
|
||||
const fb = fallbackUriPlugin({
|
||||
resolver: innerResolver,
|
||||
policy: { errorClass: "any" },
|
||||
});
|
||||
const resolver = createStorageUriResolver([...innerPlugins, fb]);
|
||||
|
||||
const loader = createIrLoader({
|
||||
registry,
|
||||
resolver,
|
||||
decoders: { wav: decodeWav },
|
||||
});
|
||||
const ir = await loader.load("pool-outdoor-swimming-pool");
|
||||
```
|
||||
|
||||
## License & sources
|
||||
|
||||
The bundled WAV (`files/outdoor-swimming-pool.wav`) was copied from the
|
||||
Suaveplan internal media library at
|
||||
`/mnt/c/Users/Administrator/Mountain Duck/74a17879-9780-426d-8bd9-7ed805f53d41/media/_/audio/ir/room/outdoor-swimming-pool.wav`.
|
||||
The CDN copy lives at `https://swch.b-cdn.net/_/audio/ir/room/roompool.wav`.
|
||||
|
||||
License: **All Rights Reserved — Suaveplan internal media library**. Do
|
||||
not redistribute the bundled WAV outside Suaveplan distributions.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-ir-room-plugin-pool",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Genesis-original audio-ir content package: ships the outdoor-swimming-pool room impulse response paired with a https CDN fallback. Universal.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./manifest": {
|
||||
"import": {
|
||||
"types": "./dist/manifest/index.d.ts",
|
||||
"default": "./dist/manifest/index.js"
|
||||
}
|
||||
},
|
||||
"./register": {
|
||||
"import": {
|
||||
"types": "./dist/register/index.d.ts",
|
||||
"default": "./dist/register/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-ir-core": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/storage-uri": "workspace:^",
|
||||
"@suaveplan/storage-uri-plugin-bundle": "workspace:^",
|
||||
"@suaveplan/storage-uri-plugin-fallback": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/audio-decode-wav": "workspace:^",
|
||||
"@suaveplan/audio-ir-loader": "workspace:^",
|
||||
"@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",
|
||||
"files",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { MutableUriPluginRegistrar } from "@suaveplan/audio-ir-core";
|
||||
export {
|
||||
BUNDLE_PACKAGE_SLUG,
|
||||
BUNDLE_URI,
|
||||
CDN_URI,
|
||||
CHANNELS,
|
||||
ENTRY_ID,
|
||||
manifest,
|
||||
SAMPLE_RATE,
|
||||
} from "./manifest/manifest.js";
|
||||
export { type RegisterOptions, register } from "./register/register.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BUNDLE_PACKAGE_SLUG,
|
||||
BUNDLE_URI,
|
||||
CDN_URI,
|
||||
CHANNELS,
|
||||
ENTRY_ID,
|
||||
manifest,
|
||||
SAMPLE_RATE,
|
||||
} from "./manifest.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `manifest` — single-entry `IrManifest` for the outdoor-swimming-pool IR
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the lone `IrManifestEntry` shipped by `@suaveplan/audio-ir-room-plugin-pool`.
|
||||
The entry's `uri` is a `fallback://?u=<bundle>&u=<https>` URI built at module
|
||||
load by `buildFallbackUri` from `@suaveplan/storage-uri-plugin-fallback` —
|
||||
when the consuming resolver has both a `bundle://` plugin and an `https://`
|
||||
plugin wired, the shipped `fallback://` walk hits the in-package WAV first
|
||||
and only reaches for the CDN copy when the local file is absent.
|
||||
|
||||
The manifest also pins the **decoded** `channels` and `sampleRate` of the
|
||||
bundled WAV (`channels = 1`, `sampleRate = 48000`) so the loader's
|
||||
`validateAndFinalize` step can reject any subsequent re-encode that drifts
|
||||
from those values. Both numbers were obtained by feeding
|
||||
`files/outdoor-swimming-pool.wav` through `decodeWav` once at
|
||||
manifest-authoring time.
|
||||
|
||||
## Features
|
||||
|
||||
- Round-trips through `IrManifestSchema` at module load — any drift in the
|
||||
schema fails the build before `dist/` is emitted.
|
||||
- `BUNDLE_PACKAGE_SLUG`, `BUNDLE_URI`, `CDN_URI`, `ENTRY_ID`, `CHANNELS`,
|
||||
`SAMPLE_RATE` exported as named `const` literals so consumers can refer
|
||||
to them without re-deriving from `manifest.entries[0]`.
|
||||
- Frozen at runtime (`IrManifestSchema` applies `Object.freeze` to the
|
||||
parsed manifest + every entry).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { manifest } from "@suaveplan/audio-ir-room-plugin-pool/manifest";
|
||||
|
||||
console.log(manifest.packageSlug); // "pool"
|
||||
console.log(manifest.entries[0].id); // "pool-outdoor-swimming-pool"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BUNDLE_URI,
|
||||
CDN_URI,
|
||||
ENTRY_ID,
|
||||
manifest,
|
||||
} from "@suaveplan/audio-ir-room-plugin-pool/manifest";
|
||||
import { parseFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
|
||||
|
||||
const entry = manifest.entries[0];
|
||||
const parsed = parseFallbackUri(entry.uri);
|
||||
console.log(parsed.candidates); // [BUNDLE_URI, CDN_URI]
|
||||
console.log(entry.id === ENTRY_ID); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `manifest` | `IrManifest` | Frozen single-entry manifest. |
|
||||
| `BUNDLE_PACKAGE_SLUG` | `"audio-ir-room-plugin-pool"` | Slug expected by the bundle plugin. |
|
||||
| `BUNDLE_URI` | string literal | Bundle-local URI for the WAV. |
|
||||
| `CDN_URI` | string literal | HTTPS CDN URI for the WAV. |
|
||||
| `ENTRY_ID` | `"pool-outdoor-swimming-pool"` | Entry id within the registry. |
|
||||
| `CHANNELS` | `1` | Decoded channel count of the WAV. |
|
||||
| `SAMPLE_RATE` | `48000` | Decoded sample rate of the WAV (Hz). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manifest constants are NOT optional — the spec for this package
|
||||
requires them to equal the values measured by decoding
|
||||
`files/outdoor-swimming-pool.wav` at authoring time. If the bundled WAV is
|
||||
ever re-mastered, both `CHANNELS` and `SAMPLE_RATE` MUST be updated to
|
||||
match the new decode result, and the file-presence integration test
|
||||
SHOULD re-run as part of that change to assert the new constants.
|
||||
|
||||
`buildFallbackUri([BUNDLE_URI, CDN_URI])` runs at module load. The
|
||||
ordering of candidates IS the failover policy — bundle wins when present,
|
||||
CDN wins when bundle reports `BUNDLE_URI.NOT_FOUND`. Reversing the array
|
||||
would silently flip that policy without a separate spec change, so the
|
||||
constants are kept verbose and explicit rather than inlined.
|
||||
|
||||
The `license` field intentionally says "All Rights Reserved — Suaveplan
|
||||
internal media library" because the source media library has no public
|
||||
license attached. Consumers SHOULD NOT redistribute the bundled WAV
|
||||
outside the Suaveplan distribution.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
|
||||
import { parseFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BUNDLE_PACKAGE_SLUG,
|
||||
BUNDLE_URI,
|
||||
CDN_URI,
|
||||
CHANNELS,
|
||||
ENTRY_ID,
|
||||
manifest,
|
||||
SAMPLE_RATE,
|
||||
} from "./manifest.js";
|
||||
|
||||
describe("manifest", () => {
|
||||
it("manifest parses through IrManifestSchema", { timeout: 5000 }, () => {
|
||||
const reparsed = IrManifestSchema.parse(manifest);
|
||||
expect(reparsed).toEqual(manifest);
|
||||
expect(manifest.packageSlug).toBe("pool");
|
||||
expect(manifest.family).toBe("room");
|
||||
expect(manifest.entries.length).toBe(1);
|
||||
const entry = manifest.entries[0];
|
||||
expect(entry).toBeDefined();
|
||||
if (entry === undefined) throw new Error("unreachable");
|
||||
expect(entry.id).toBe(ENTRY_ID);
|
||||
expect(entry.id).toBe("pool-outdoor-swimming-pool");
|
||||
expect(entry.family).toBe("room");
|
||||
expect(entry.channels).toBe(CHANNELS);
|
||||
expect(entry.sampleRate).toBe(SAMPLE_RATE);
|
||||
expect(entry.gainDb).toBe(0);
|
||||
expect(entry.license.length).toBeGreaterThan(0);
|
||||
expect(entry.source.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("URI parses through parseFallbackUri with two ordered candidates", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entry = manifest.entries[0];
|
||||
if (entry === undefined) throw new Error("unreachable");
|
||||
const parsed = parseFallbackUri(entry.uri);
|
||||
expect(parsed.candidates.length).toBe(2);
|
||||
const first = parsed.candidates[0];
|
||||
const second = parsed.candidates[1];
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
if (first === undefined || second === undefined) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
expect(first.startsWith("bundle://audio-ir-room-plugin-pool/")).toBe(
|
||||
true
|
||||
);
|
||||
expect(first).toBe(BUNDLE_URI);
|
||||
expect(second).toBe(CDN_URI);
|
||||
expect(second).toBe(
|
||||
"https://swch.b-cdn.net/_/audio/ir/room/roompool.wav"
|
||||
);
|
||||
});
|
||||
|
||||
it("exports the canonical constants", { timeout: 5000 }, () => {
|
||||
expect(BUNDLE_PACKAGE_SLUG).toBe("audio-ir-room-plugin-pool");
|
||||
expect(CHANNELS).toBe(1);
|
||||
expect(SAMPLE_RATE).toBe(48_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IrManifest } from "@suaveplan/audio-ir-core";
|
||||
import { IrManifestSchema } from "@suaveplan/audio-ir-core";
|
||||
import { buildFallbackUri } from "@suaveplan/storage-uri-plugin-fallback";
|
||||
|
||||
/**
|
||||
* Slug embedded in the bundle URL. MUST match the `packageSlug` passed to
|
||||
* `bundleUriPlugin({ packageSlug })` inside `register` — the bundle plugin
|
||||
* uses this to validate that the URL authority matches the consuming
|
||||
* package.
|
||||
*/
|
||||
export const BUNDLE_PACKAGE_SLUG = "audio-ir-room-plugin-pool" as const;
|
||||
|
||||
/**
|
||||
* Bundle-local URI for the shipped WAV. Resolves against the package's
|
||||
* `files/` directory at runtime via `bundleUriPlugin`.
|
||||
*/
|
||||
export const BUNDLE_URI =
|
||||
"bundle://audio-ir-room-plugin-pool/outdoor-swimming-pool.wav" as const;
|
||||
|
||||
/**
|
||||
* CDN fallback URI. The shipped `fallback://` walk targets this when the
|
||||
* bundle adapter reports `BUNDLE_URI.NOT_FOUND`.
|
||||
*/
|
||||
export const CDN_URI =
|
||||
"https://swch.b-cdn.net/_/audio/ir/room/roompool.wav" as const;
|
||||
|
||||
/**
|
||||
* Entry id for the lone IR shipped by this package.
|
||||
*/
|
||||
export const ENTRY_ID = "pool-outdoor-swimming-pool" as const;
|
||||
|
||||
/**
|
||||
* Decoded channel count of `files/outdoor-swimming-pool.wav` — measured by
|
||||
* decoding the bundled file at manifest-authoring time with
|
||||
* `@suaveplan/audio-decode-wav`.
|
||||
*/
|
||||
export const CHANNELS = 1 as const;
|
||||
|
||||
/**
|
||||
* Decoded sample rate of `files/outdoor-swimming-pool.wav` (Hz) — measured by
|
||||
* decoding the bundled file at manifest-authoring time.
|
||||
*/
|
||||
export const SAMPLE_RATE = 48_000 as const;
|
||||
|
||||
/**
|
||||
* Single-entry `IrManifest` validated through `IrManifestSchema`.
|
||||
*
|
||||
* The lone entry's `uri` is a `fallback://?u=<bundle>&u=<https>` URI in
|
||||
* declared order — bundle first, CDN second. The shipped `fallback://` plugin
|
||||
* handles the ordered walk + transient classification.
|
||||
*/
|
||||
export const manifest: IrManifest = IrManifestSchema.parse({
|
||||
packageSlug: "pool",
|
||||
family: "room",
|
||||
entries: [
|
||||
{
|
||||
id: ENTRY_ID,
|
||||
family: "room",
|
||||
uri: buildFallbackUri([BUNDLE_URI, CDN_URI]),
|
||||
channels: CHANNELS,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
gainDb: 0,
|
||||
license: "All Rights Reserved — Suaveplan internal media library",
|
||||
source: "Mountain Duck media library + https://swch.b-cdn.net CDN",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { MutableUriPluginRegistrar } from "@suaveplan/audio-ir-core";
|
||||
export { type RegisterOptions, register } from "./register.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user