Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
```
|
||||
|
||||
101
plugins/document-navigator/docs/media/audio-ir/audio-ir-loader/src/cache/cache.md
vendored
Normal file
101
plugins/document-navigator/docs/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.
|
||||
@@ -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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user