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:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View 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");
```

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.