# Capability Spec: audio-ir-room-plugin-club **Package**: `@suaveplan/audio-ir-room-plugin-club` **Status**: SHIPPED > Every requirement carries a `**Verified by:**` line. ## Requirements ### Requirement: Manifest declares both club room IRs with bundle URIs The package SHALL export `manifest: IrManifest` with `packageSlug: "club"`, `family: "room"`, AND `entries: readonly IrManifestEntry[]` containing exactly two entries: 1. `{ id: "club-club1", family: "room", uri: "bundle://audio-ir-room-plugin-club/club1.wav", channels: , sampleRate: , gainDb: 0, license: , source: "Mountain Duck media library: /media/_/audio/ir/room/club1.wav" }`. 2. `{ id: "club-club2", family: "room", uri: "bundle://audio-ir-room-plugin-club/club2.wav", channels: , sampleRate: , gainDb: 0, license: , source: "Mountain Duck media library: /media/_/audio/ir/room/club2.wav" }`. `channels` AND `sampleRate` SHALL be the actual values measured by decoding the bundled files at manifest-authoring time (so the manifest never disagrees with the file). Manifest SHALL parse successfully through `IrManifestSchema` (id-prefix, family coherence, uri non-empty). #### Scenario: manifest parses through IrManifestSchema - **WHEN** `IrManifestSchema.parse(manifest)` is called - **THEN** the result deep-equals the export AND every `entry.id` starts with `"club-"` AND every `entry.family === "room"` #### Scenario: both ids are unique and well-formed - **WHEN** the test enumerates `manifest.entries.map(e => e.id)` - **THEN** the result is `["club-club1", "club-club2"]` (order matches declaration; no duplicates) #### Scenario: every URI parses as a bundle:// URI - **WHEN** the test parses every `entry.uri` through `parseUri` (from `@suaveplan/storage-uri`) AND the bundle plugin's `parse` - **THEN** each parse succeeds AND the parsed `meta.packageSlug === "audio-ir-room-plugin-club"` **Verified by:** `src/manifest/manifest.test.ts::"manifest parses through IrManifestSchema"` AND `src/manifest/manifest.test.ts::"declares exactly club-club1 and club-club2"` AND `src/manifest/manifest.test.ts::"every URI parses as a well-formed bundle URI"` --- ### Requirement: `register(registry, target, options?)` collector-pattern signature The package SHALL export `register(registry: IrRegistry, target: MutableUriPluginRegistrar, options?: RegisterOptions): void` where `MutableUriPluginRegistrar` is imported from `@suaveplan/audio-ir-core` (per the family canon — see `openspec/changes/add-mutable-uri-plugin-registrar-to-audio-ir-core/`). The function SHALL: 1. Construct `bundleUriPlugin({ packageSlug: "audio-ir-room-plugin-club", rootUrl: new URL("../../files/", import.meta.url), ...(options?.fetch !== undefined ? { fetch: options.fetch } : {}) })` exactly once. 2. Invoke `target.use(plugin)` to hand the plugin to the caller's collector. 3. Invoke `registerManifest(registry, manifest)`. The function SHALL NOT consult any resolver — the collector pattern decouples plugin construction from resolver construction. Errors from `registerManifest` (`AUDIO_IR.DUPLICATE_ID`, `AUDIO_IR.ID_PREFIX_MISMATCH`, etc.) SHALL pass through verbatim. This requirement supersedes the prior `register(registry, resolver: StorageUriResolver)` requirement shipped in `add-audio-ir-room-plugin-club` and removes the `resolver.hasScheme("bundle")` structural check. #### Scenario: register calls target.use exactly once - **WHEN** `register(registry, collector)` is called against `collector = createUriPluginCollector()` - **THEN** `collector.plugins.length === 1` AND `collector.plugins[0].scheme === "bundle"` (verified by reading the collected plugin's `scheme` property) #### Scenario: register populates the registry with both entries - **WHEN** `register(registry, collector)` is called - **THEN** `registry.has("club-club1") === true` AND `registry.has("club-club2") === true` AND `registry.list({ family: "room" })` contains both #### Scenario: double register against the same registry surfaces DUPLICATE_ID - **WHEN** `register(registry, collectorA)` is called AND `register(registry, collectorB)` is called immediately afterwards (different collectors, same registry) - **THEN** the second call rejects with `AudioIrError` whose `code === "AUDIO_IR.DUPLICATE_ID"` AND the `id` is `"club-club1"` (the first colliding entry) **Verified by:** `src/register/register.test.ts::"register calls target.use exactly once with a bundle plugin"` AND `src/register/register.test.ts::"register populates the registry with both club entries"` AND `src/register/register.test.ts::"double register against same registry surfaces DUPLICATE_ID"` --- ### Requirement: End-to-end load through collector + resolver composition The package SHALL include an integration test that wires the new collector pattern end-to-end: construct `collector = createUriPluginCollector()`, call `register(registry, collector, { fetch: nodeFileFetch })` (or the in-test `file://` shim under Node), construct `resolver = createStorageUriResolver(collector.plugins)`, construct `loader = createIrLoader({ registry, resolver, decoders: { wav: decodeWav } })`, and assert `await loader.load("club-club1")` and `await loader.load("club-club2")` both resolve to frozen `ImpulseResponse` objects whose `channels === 2`, `sampleRate === 44100`, and `channelData[0].length > 0`. This requirement supersedes the prior end-to-end test that wired `createPlugin()` + `createStorageUriResolver([createPlugin()])` directly. The decoded `ImpulseResponse` is byte-for-byte identical under both patterns; only the wiring changes. #### Scenario: collector wiring loads both club IRs - **WHEN** the test composes collector + resolver + loader per the recipe above AND calls `loader.load("club-club1")` and `loader.load("club-club2")` - **THEN** both calls resolve to frozen `ImpulseResponse` with manifest-declared `channels`/`sampleRate` AND non-empty `channelData[0]` **Verified by:** `tests/integration/end-to-end.test.ts::"collector wiring loads both club IRs"`