Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
# Design: `@suaveplan/audio-ir-core`
This file holds the cross-cutting design for the IR family (AGENTS §193). Sibling packages (`audio-ir-loader`, `audio-ir-{mic,speaker,room}-plugin-<name>`) reference this file rather than duplicating its model.
## 1. Family architecture
Three layers, strictly one-way dependencies:
```text
audio-ir-{mic,speaker,room}-plugin-<name> ← content packages
↓ depends on
audio-ir-loader ← URI walk + decode pipeline
↓ depends on
audio-ir-core ← contract + registry + codes
```
`audio-ir-core` is universal-tier with zero `@suaveplan/audio-ir-*` deps. It declares the contract; `audio-ir-loader` declares the I/O pipeline; content packages declare data + a thin `register(registry, resolver)` entrypoint.
The contract is **manifest-first**. A content package contributes data exclusively through its `IrManifest`. No package gets to extend the runtime contract — keeping the registry homogeneous keeps the loader, UIs, and OTel attributes stable as the IR collection grows.
## 2. ID derivation rule
Every `IrManifestEntry.id` is `<packageSlug>-<filename-stem>` where:
- `packageSlug` is the npm package name after `@suaveplan/audio-ir-<family>-plugin-` (`club`, `pool`, `sm57`, `cathedral`, …).
- `filename-stem` is the source file's basename without extension, lowercased, with non-`[a-z0-9-]` characters replaced by `-`.
Examples:
| Package | File | ID |
|---|---|---|
| `audio-ir-room-plugin-club` | `club1.wav` | `club-club1` |
| `audio-ir-room-plugin-pool` | `outdoor-swimming-pool.wav` | `pool-outdoor-swimming-pool` |
| `audio-ir-mic-plugin-sm57` | `axis-on.wav` | `sm57-axis-on` |
The `IrManifest` Zod schema enforces the prefix at validation time (`refine(...)`). Collisions are caught at `registry.register(entry)` time with `AUDIO_IR.DUPLICATE_ID`.
The `family` segment (`mic`/`speaker`/`room`) is **NOT** in the ID — it lives on `manifest.family` and is denormalized onto each entry by the package's `register()` call. This keeps IDs short and lets UIs filter by family by reading the manifest entry, not by parsing strings.
## 3. URI strategy
Every entry has exactly one `uri: string`. Multi-source semantics are encoded as a `fallback://?u=<uri1>&u=<uri2>` URI rather than a manifest array — this leans on the shipped `@suaveplan/storage-uri-plugin-fallback` semantics (ordered walk, transient-vs-semantic classification, `ALL_CANDIDATES_FAILED` per-candidate cause chain) without duplicating them at the IR layer.
For bundled local files, the URI is `bundle://<packageSlug>-<family>-plugin-<name>/<filename>` resolved by the shipped `@suaveplan/storage-uri-plugin-bundle` plugin against `new URL("../files/", import.meta.url)`. The package's `register(registry, resolver)` call constructs the `bundleUriPlugin` and feeds it into the supplied `StorageUriResolver`.
## 4. Registry semantics
`IrRegistry` is a thin `Map<string, IrManifestEntry>` wrapper with:
- `register(entry)` — Zod-validates `entry`, throws `AUDIO_IR.DUPLICATE_ID` on collision (case-sensitive on `id`), `AUDIO_IR.ID_PREFIX_MISMATCH` when the id does not start with the manifest-derived prefix, `AUDIO_IR.FAMILY_MISMATCH` when `entry.family` disagrees with the manifest the entry came from (caller is expected to pass a coherent entry).
- `has(id) / get(id)` — O(1) lookup. `get` throws `AUDIO_IR.NOT_REGISTERED` (callers that prefer optional semantics use `has` first).
- `list({ family? })` — filtered enumeration. Stable insertion order.
- `clear()` — wipes the registry. Provided for tests; production callers don't need it.
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.
## 5. OTel surface
`audio-ir-core` is the layer that knows about the registry's shape, so it owns:
- `audio_ir.registry_size{family}` — observable gauge sampled on read.
- `audio_ir.registrations_total{family, outcome}` — counter incremented in `register()`.
- `audio_ir.lookups_total{family, outcome}` — counter incremented in `get()` / `has()`.
- `audio_ir.errors_total{code}` — counter incremented on every `AUDIO_IR.*` throw.
The loader owns its own `audio_ir_loader.*` metrics (decode duration, fallback hop, cache hit). The two surfaces do not overlap.
## 6. Why not pluggable contracts
A future Q would be "can I store extra fields per IR (mic position, room volume, ITU loudness measurement)?". The answer is **yes, on the manifest entry, but only as opaque metadata** — `IrManifestEntry` will grow a `metadata?: Record<string, unknown>` field in a future MODIFIED-Requirements change. Today's spec keeps the shape minimal so we don't ship a flexibility budget we don't use. AGENTS §0a's "no cut-down replacements" rule applies to migrated packages — this is a genesis-original, and YAGNI applies until a real consumer needs the extension.

View File

@@ -0,0 +1,53 @@
# Add `@suaveplan/audio-ir-core`
**Package**: `@suaveplan/audio-ir-core`
**Status**: PROPOSED
**Type**: new-package
## Why
The IR ecosystem (`audio-ir-loader`, `audio-ir-{mic,speaker,room}-plugin-<name>` packages) needs a single foundation that owns the IR contract, manifest schema, and in-memory registry. Without it, every IR content package would reinvent the entry shape, the registry collision rule, and the ID-derivation convention — guaranteed drift. This package is the foundation every sibling depends on.
## What changes
- ADDED: `ImpulseResponse` type — `{ id: string; channelData: readonly Float32Array[]; channels: number; sampleRate: number; lengthFrames: number; durationSec: number; gainDb: number; family: "mic" | "speaker" | "room"; manifest: IrManifestEntry }`. Frozen, validated.
- ADDED: `IrManifestEntry` Zod schema — `{ id, family, uri, channels, sampleRate, gainDb, license, source }`. `id` matches `/^[a-z][a-z0-9-]*$/` (no consecutive `-`, starts with a letter, length 1–128). `uri` is any non-empty string (deferred validation to `storage-uri` at load time).
- ADDED: `IrManifest` Zod schema — `{ packageSlug: <slug grammar>, family: "mic" | "speaker" | "room", entries: readonly IrManifestEntry[] }`. Every `entry.id` SHALL begin with `<packageSlug>-` (enforced by schema refinement). `entry.family` SHALL equal manifest-level `family`.
- ADDED: `IrRegistry` — in-memory `Map`-backed registry with `register(entry)`, `has(id)`, `get(id)`, `list({ family? })`, `clear()`. `register` throws `AUDIO_IR.DUPLICATE_ID` when an ID is registered twice.
- ADDED: `createIrRegistry()` factory.
- ADDED: `AUDIO_IR.*` codes via `defineCodes`: `DUPLICATE_ID`, `NOT_REGISTERED`, `INVALID_MANIFEST`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`.
- ADDED: OTel — `audio_ir.registry_size{family}` observable gauge; `audio_ir.registrations_total{family, outcome}` counter; `audio_ir.lookups_total{family, outcome}` counter; `audio_ir.errors_total{code}` counter.
## Out of scope
Nothing functional within the foundation's scope. The following are explicitly NOT here, because they belong to siblings:
- Loading + decoding — `audio-ir-loader` owns the resolver-driven walk.
- Concrete IR data — `audio-ir-{mic,speaker,room}-plugin-<name>` packages own the files and the per-package `register(registry, resolver)` call.
## Source mapping
Genesis-original. No upstream `enterprise/audio-ir-core.md` exists.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| every spec requirement | Genesis-original foundation; no upstream baseline |
## Verification approach
- `src/types/manifest-entry.test.ts` — Zod parsing + refinement (id grammar, id-prefix rule, family rule).
- `src/types/manifest.test.ts` — manifest-level Zod validation.
- `src/registry/registry.test.ts` — register / has / get / list / clear semantics; `DUPLICATE_ID` throw.
- `src/registry/registry.spec-conformance.test.ts` — strict-mode behaviors, lookup invariants under concurrency.
- `src/otel/otel.test.ts` — counter + gauge assertions with real in-memory `MeterProvider`.
- `src/error-codes/error-codes.test.ts` — exhaustion.
## Impact
### Affected dependents
No dependents yet (the package is brand new). Verified by `bunx turbo ls --filter=...@suaveplan/audio-ir-core` returning only the package itself.
The first downstream consumers will be `@suaveplan/audio-ir-loader` and every `@suaveplan/audio-ir-{family}-plugin-<name>` package, each landing in its own change.

View File

@@ -0,0 +1,139 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-ir-core (add-audio-ir-core)
**Package**: `@suaveplan/audio-ir-core`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: `ImpulseResponse` and `IrManifestEntry` types are frozen and Zod-validated
The package SHALL export `ImpulseResponse` and `IrManifestEntry` TypeScript types AND companion Zod schemas (`ImpulseResponseSchema`, `IrManifestEntrySchema`) that exhaustively validate every field. Validated objects SHALL be frozen with `Object.freeze` before being returned by any factory or registry method. `IrManifestEntry.id` SHALL match `/^[a-z][a-z0-9-]*$/`, SHALL NOT contain consecutive `-` characters, AND SHALL be `1..128` bytes long.
#### Scenario: valid entry round-trips through Zod
- **WHEN** `IrManifestEntrySchema.parse({ id: "club-club1", family: "room", uri: "bundle://x/y.wav", channels: 2, sampleRate: 44100, gainDb: 0, license: "MIT", source: "demo" })` is called
- **THEN** the result deep-equals the input AND is frozen
#### Scenario: malformed id rejected
- **WHEN** any of `IrManifestEntrySchema.parse({ id: "Club1", ... })`, `IrManifestEntrySchema.parse({ id: "club--1", ... })`, `IrManifestEntrySchema.parse({ id: "1-club", ... })` is called
- **THEN** each call throws a `ZodError`
**Verified by:** `src/types/manifest-entry.test.ts::"valid entry round-trips through Zod and is frozen"` AND `src/types/manifest-entry.test.ts::"rejects uppercase / consecutive-dash / non-letter-start ids"`
---
### Requirement: `IrManifest` enforces id-prefix and family coherence
The `IrManifestSchema` SHALL refine every parsed manifest so that (a) every `entry.id` begins with `<manifest.packageSlug>-` (case-sensitive), AND (b) every `entry.family` equals `manifest.family`. Violations SHALL surface as `AUDIO_IR.ID_PREFIX_MISMATCH` and `AUDIO_IR.FAMILY_MISMATCH` respectively when the validation is invoked through the registry; raw `ZodError` is also acceptable when callers parse the schema directly.
#### Scenario: id without slug prefix is rejected
- **WHEN** the registry is asked to `register({ id: "wrong-1", ... })` from a manifest whose `packageSlug === "club"`
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.ID_PREFIX_MISMATCH"`, `expectedPrefix === "club-"`, `actualId === "wrong-1"`
#### Scenario: entry family disagreeing with manifest family is rejected
- **WHEN** the registry is asked to `register({ id: "club-x", family: "mic", ... })` from a manifest whose `family === "room"`
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.FAMILY_MISMATCH"`, `manifestFamily === "room"`, `entryFamily === "mic"`
**Verified by:** `src/types/manifest.test.ts::"rejects entry whose id lacks the manifest slug prefix"` AND `src/registry/registry.test.ts::"throws FAMILY_MISMATCH when entry family disagrees with manifest"`
---
### Requirement: Registry register / has / get / list / clear semantics
`createIrRegistry()` SHALL return an `IrRegistry` exposing:
1. `register(entry: IrManifestEntry, context: { manifestSlug: string; manifestFamily: IrFamily }): void` — validates `entry` via `IrManifestEntrySchema`, then enforces ID-prefix and family-coherence (per the prior requirement), then inserts into an internal `Map<string, IrManifestEntry>`. Throws `AUDIO_IR.DUPLICATE_ID` when the id is already registered.
2. `has(id: string): boolean` — O(1) lookup.
3. `get(id: string): IrManifestEntry` — O(1) lookup; throws `AUDIO_IR.NOT_REGISTERED` when absent.
4. `list({ family? }: { family?: IrFamily } = {}): readonly IrManifestEntry[]` — stable insertion-order enumeration, filtered when `family` is supplied.
5. `clear(): void` — resets internal state. Intended for test setups.
#### Scenario: register then get returns the registered entry
- **WHEN** `registry.register(entry, { manifestSlug: "club", manifestFamily: "room" })` succeeds AND `registry.get(entry.id)` is called
- **THEN** the returned object is identity-equal (`===`) to the entry passed in, AND it is frozen
#### Scenario: duplicate id throws
- **WHEN** `registry.register` is invoked twice with two distinct entry objects that share the same `id`
- **THEN** the second call throws `AudioIrError` with `code === "AUDIO_IR.DUPLICATE_ID"` AND `id` equal to the duplicated identifier; the first registration remains in place
#### Scenario: list filters by family in stable insertion order
- **WHEN** four entries are registered in order `[r1(room), m1(mic), r2(room), s1(speaker)]` AND `registry.list({ family: "room" })` is called
- **THEN** the result is `[r1, r2]` (insertion order; `m1` and `s1` excluded)
#### Scenario: get against an unknown id throws NOT_REGISTERED
- **WHEN** `registry.get("nope")` is called against an empty registry
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.NOT_REGISTERED"` AND `id === "nope"`
#### Scenario: clear wipes the registry
- **WHEN** entries are registered AND `registry.clear()` is called AND `registry.list()` is invoked
- **THEN** `list()` returns `[]` AND every previously-registered id reports `has(id) === false`
**Verified by:** `src/registry/registry.test.ts::"register then get returns the same frozen entry"` AND `src/registry/registry.test.ts::"throws DUPLICATE_ID on second register of same id"` AND `src/registry/registry.test.ts::"list filters by family in stable insertion order"` AND `src/registry/registry.test.ts::"get throws NOT_REGISTERED for unknown id"` AND `src/registry/registry.test.ts::"clear empties the registry"`
---
### Requirement: Manifest registration helper
The package SHALL export `registerManifest(registry, manifest)` that iterates `manifest.entries` and calls `registry.register(entry, { manifestSlug: manifest.packageSlug, manifestFamily: manifest.family })` for each. On any failure (`DUPLICATE_ID`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`, or Zod parse) the helper SHALL rethrow the original error verbatim WITHOUT rolling back prior registrations — the registry is allowed to be partially populated, since the failure surface is asserted before any IO and a re-run after fixing the manifest will hit `DUPLICATE_ID` on the partial entries, surfacing the conflict immediately. Callers that need atomicity SHALL use a fresh `createIrRegistry()` per attempt.
#### Scenario: well-formed manifest registers every entry
- **WHEN** `registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, eB] })` is called
- **THEN** `registry.has(eA.id)` and `registry.has(eB.id)` are both `true` AND `registry.list({ family: "room" })` includes both
#### Scenario: failure mid-manifest leaves registry partially populated
- **WHEN** `registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, badEntry, eC] })` is called and `badEntry` fails `ID_PREFIX_MISMATCH`
- **THEN** the call throws AND `registry.has(eA.id) === true` AND `registry.has(eC.id) === false`
**Verified by:** `src/registry/register-manifest.test.ts::"registers every entry in a well-formed manifest"` AND `src/registry/register-manifest.test.ts::"leaves registry partially populated on mid-manifest failure"`
---
### Requirement: OpenTelemetry instrumentation
Every public registry method SHALL emit a span on the `@suaveplan/audio-ir-core` tracer. Successful `register` SHALL increment `audio_ir.registrations_total{family, outcome:"success"}`. Successful `get`/`has` SHALL increment `audio_ir.lookups_total{family, outcome:"hit"|"miss"}`. Every `AUDIO_IR.*` throw SHALL increment `audio_ir.errors_total{code}`. The package SHALL register an observable gauge `audio_ir.registry_size{family}` that reports the current count per family on each scrape.
#### Scenario: register success increments registrations_total
- **WHEN** `registry.register` succeeds for a `room`-family entry
- **THEN** `audio_ir.registrations_total{family:"room", outcome:"success"}` is incremented by 1
#### Scenario: error throw increments errors_total with the resolved code
- **WHEN** `registry.get("nope")` throws `NOT_REGISTERED`
- **THEN** `audio_ir.errors_total{code:"AUDIO_IR.NOT_REGISTERED"}` is incremented by 1
#### Scenario: registry_size gauge reports per-family count
- **WHEN** two `room` and one `mic` entries are registered AND the gauge is sampled
- **THEN** the sample yields `audio_ir.registry_size{family:"room"} === 2` AND `audio_ir.registry_size{family:"mic"} === 1`
**Verified by:** `src/otel/otel.test.ts::"increments registrations_total on successful register"` AND `src/otel/otel.test.ts::"increments errors_total on every AUDIO_IR throw"` AND `src/otel/otel.test.ts::"registry_size gauge reports per-family counts"`
---
### Requirement: Error-code registry exhaustion
The `AUDIO_IR_CODES` registry exposed via `defineCodes("AUDIO_IR", [...])` SHALL contain exactly the five codes declared in the proposal: `DUPLICATE_ID`, `NOT_REGISTERED`, `INVALID_MANIFEST`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`. Every code SHALL be reachable from a real call site exercised by the test suite (AGENTS §2c).
#### Scenario: every code is reachable from a real call site
- **WHEN** the test suite enumerates `Object.values(AUDIO_IR_CODES)` AND maps each to a fixture call site
- **THEN** every code maps to at least one `it(...)` assertion that exercises the code from a real call site
**Verified by:** `src/error-codes/error-codes.test.ts::"every AUDIO_IR code reachable from a real call site"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,71 @@
# Tasks — Add `@suaveplan/audio-ir-core`
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/AGENTS.md` §§ 13, 14, 14a, 2c, 6a, 193
- [ ] 1.2 `proposal.md` (this change) — including `## Impact` per §14a
- [ ] 1.3 `design.md` — cross-cutting model for the IR family (referenced by every sibling change)
- [ ] 1.4 `specs/audio-ir-core/spec.md` — every requirement carries `**Verified by:**`
- [ ] 1.5 Working-notes checklist enumerates every `### Requirement` and `#### Scenario`
## 2. Package scaffold
- [ ] 2.1 `mkdir -p packages/audio-ir/audio-ir-core/src/{types,registry,otel,error-codes}`
- [ ] 2.2 `package.json` — `version: 0.0.0`, `sideEffects: false`, deps `@suaveplan/{error,types,opentelemetry}`, `zod`, `tslib`
- [ ] 2.3 `tsconfig.lib.json`, `vite.config.ts`, `biome.json`
- [ ] 2.4 Subpath exports: `.`, `./types`, `./registry`, `./error-codes`
- [ ] 2.5 `README.md` linking every `.md` under `## Modules`
## 3. Implementation
- [ ] 3.1 `src/types/manifest-entry.ts` + `.md` + Zod schema + frozen invariant
- [ ] 3.2 `src/types/manifest.ts` + `.md` + Zod schema with id-prefix & family refinements
- [ ] 3.3 `src/types/impulse-response.ts` + `.md` + Zod schema (used by `audio-ir-loader`)
- [ ] 3.4 `src/error-codes/error-codes.ts` + `.md` — `defineCodes("AUDIO_IR", [...])`
- [ ] 3.5 `src/registry/registry.ts` + `.md` — `createIrRegistry`, all five methods
- [ ] 3.6 `src/registry/register-manifest.ts` + `.md` — `registerManifest` helper
- [ ] 3.7 `src/otel/otel.ts` + `.md` — tracer, counters, observable gauge
- [ ] 3.8 `src/index.ts` — explicit named re-exports (rule 10)
## 4. Tests
- [ ] 4.1 `src/types/manifest-entry.test.ts` — Zod parsing + freeze invariant
- [ ] 4.2 `src/types/manifest.test.ts` — manifest-level refinements
- [ ] 4.3 `src/registry/registry.test.ts` — every scenario from registry requirement
- [ ] 4.4 `src/registry/registry.spec-conformance.test.ts` — strict-mode + insertion-order invariants
- [ ] 4.5 `src/registry/register-manifest.test.ts` — well-formed + partial-failure paths
- [ ] 4.6 `src/otel/otel.test.ts` — real in-memory `MeterProvider`
- [ ] 4.7 `src/error-codes/error-codes.test.ts` — exhaustion
- [ ] 4.8 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
## 5. Documentation
- [ ] 5.1 Sibling `.md` for every `.ts` (≥200 words, basic + advanced examples)
- [ ] 5.2 README `## Modules` section
- [ ] 5.3 Update root `CLAUDE.md` Packages section — add new "Audio IR" subsection
## 6. Pre-archive verification (5-gate triple)
- [ ] 6.1 `bun run check` — 0/0/0/0
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/audio-ir-core` — green
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/audio-ir-core` — 100/100/100/100
- [ ] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/audio-ir-core` — exit 0
- [ ] 6.5 `bunx turbo build --filter=@suaveplan/audio-ir-core` — green
## 7. Pre-archive 7-row checklist (AGENTS §13)
- [ ] 7.1 Generics threaded; `expectTypeOf` assertions on literal-type claims
- [ ] 7.2 Zod schemas wired at every boundary
- [ ] 7.3 Every `AUDIO_IR_CODES` code thrown by at least one production path
- [ ] 7.4 Strict-mode behaviors tested both on and off
- [ ] 7.5 OTel instruments asserted with real in-memory `MeterProvider`
- [ ] 7.6 Lifecycle dedup tested (registry idempotency, freeze invariant)
- [ ] 7.7 No retroactive tick
## 8. Archive
- [ ] 8.1 Fold delta into new `openspec/specs/audio-ir-core/spec.md`
- [ ] 8.2 Move folder to `openspec/archive/<yyyy-mm-dd>-add-audio-ir-core/`
- [ ] 8.3 Refresh `MANIFEST.md`