Initial Commit
This commit is contained in:
84
openspec/specs/audio-ir-room-plugin-club/spec.md
Normal file
84
openspec/specs/audio-ir-room-plugin-club/spec.md
Normal file
@@ -0,0 +1,84 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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: <decoded>, sampleRate: <decoded>, gainDb: 0, license: <see README>, 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: <decoded>, sampleRate: <decoded>, gainDb: 0, license: <see README>, 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user