Files
suaveplan/openspec/specs/voice/spec.md
2026-05-27 23:29:53 +01:00

509 lines
20 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: voice
**Package**: `@suaveplan/voice`
**Status**: STABLE
**Tier**: universal
> Every requirement below carries a `**Verified by:**` line anchored to a real test in
> `packages/media/audio/voice/`. No exceptions.
`@suaveplan/voice` is the provider-agnostic voice core: abstract engine interfaces, shared
domain types, Zod schemas, and structured errors for text-to-speech, speech-to-text, voice
management, sound-effect generation, and voice isolation. The package owns **no** transport,
auth, retry, or vendor-specific DTOs — those live in provider adapter packages
(`@suaveplan/voice-elevenlabs`, future `@suaveplan/voice-azure`, etc.).
## Requirements
### Requirement: Universal-tier purity and minimal dependency set
The package SHALL import zero browser-only APIs (`window`, `document`, `navigator`,
`AudioContext`, `MediaStream`) and zero Node-only APIs (`node:fs`, `node:path`, `process`,
`Buffer`). All audio bytes SHALL flow through `Uint8Array` or `ReadableStream<Uint8Array>`.
The runtime `dependencies` field SHALL be limited to `@suaveplan/error`, `@suaveplan/types`,
`tslib`, and `zod`. No other `@suaveplan/*` package SHALL appear in `dependencies`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd for `window`, `document`, `node:fs`, `node:path`,
`process`, `AudioContext`, `Buffer`
- **THEN** the count is zero
#### Scenario: Dependency surface is exactly the four allowed packages
- **WHEN** `package.json` `dependencies` is inspected
- **THEN** keys are exactly `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`
**Verified by:** `tests/dep-graph.test.ts` AND `tests/tree-shake.test.ts`
---
### Requirement: Stable error code registry under VOICE.* namespace
The package SHALL declare exactly one error registry via `defineCodes("VOICE", [...])`
exported as `VOICE_CODES`. The registry SHALL contain exactly these twelve codes in this order:
`TTS.SYNTHESIS_FAILED`, `TTS.STREAM_INTERRUPTED`, `STT.TRANSCRIPTION_FAILED`,
`STT.UNSUPPORTED_FORMAT`, `VOICE.NOT_FOUND`, `VOICE.CLONE_REJECTED`,
`EFFECTS.GENERATION_FAILED`, `ISOLATION.FAILED`, `PROVIDER.NOT_CONFIGURED`,
`PROVIDER.RATE_LIMITED`, `VALIDATION.INVALID_REQUEST`, `AUDIO.FORMAT_UNSUPPORTED`.
No hand-typed code-string literal SHALL appear anywhere in `src/`.
#### Scenario: Code values use VOICE.* namespace
- **WHEN** a consumer reads `VOICE_CODES["TTS.SYNTHESIS_FAILED"]`
- **THEN** the value is exactly `"VOICE.TTS.SYNTHESIS_FAILED"`
#### Scenario: Registry is frozen
- **WHEN** caller attempts to mutate `VOICE_CODES` at runtime
- **THEN** the assignment fails (object is `Object.freeze`-d) and the values stay stable
**Verified by:** `src/errors/errors.test.ts::"VOICE_CODES freezes a stable map of namespaced codes"`
---
### Requirement: Typed error subclasses with parameterised context
The package SHALL export twelve `VoiceError` subclasses, one per registry code:
`TtsSynthesisFailedError`, `TtsStreamInterruptedError`, `SttTranscriptionFailedError`,
`SttUnsupportedFormatError`, `VoiceNotFoundError`, `VoiceCloneRejectedError`,
`SoundEffectGenerationFailedError`, `VoiceIsolationFailedError`,
`ProviderNotConfiguredError`, `ProviderRateLimitedError`, `VoiceValidationError`,
`AudioFormatUnsupportedError`. Each subclass SHALL accept a `VoiceErrorOptions` argument
extended with subclass-specific structured fields where the proposal mandates
(`format` on STT/audio errors, `voiceId` on `VoiceNotFoundError`, `retryAfterMs` on
`ProviderRateLimitedError`).
#### Scenario: VoiceNotFoundError populates voiceId in message and context
- **WHEN** caller constructs `new VoiceNotFoundError({ voiceId: "abc" })`
- **THEN** `error.message === 'Voice not found: "abc"'`
- **AND** `error.context.voiceId === "abc"`
- **AND** `error.code === "VOICE.VOICE.NOT_FOUND"`
#### Scenario: ProviderRateLimitedError preserves retryAfterMs
- **WHEN** caller constructs `new ProviderRateLimitedError({ retryAfterMs: 60_000 })`
- **THEN** `error.context.retryAfterMs === 60_000`
#### Scenario: SttUnsupportedFormatError omits context when no format
- **WHEN** caller constructs `new SttUnsupportedFormatError({})`
- **THEN** `error.context` is undefined (no empty object)
#### Scenario: traceId propagates on every subclass
- **WHEN** caller passes `traceId` on any subclass
- **THEN** the resulting error exposes the same `traceId`
**Verified by:** `src/errors/errors.test.ts` (every subclass test)
---
### Requirement: Twelve documented audio formats and helpers
The package SHALL declare `AudioFormat` as a Zod enum and TypeScript union containing
exactly these twelve format strings:
`mp3_22050_32`, `mp3_22050_64`, `mp3_44100_128`, `mp3_44100_192`,
`pcm_16000`, `pcm_22050`, `pcm_44100`,
`ulaw_8000`, `wav_44100`, `opus_48000`, `aac_44100`, `flac_44100`.
Format strings SHALL follow the convention `<codec>_<sampleRate>[_<bitrate>]`. Adapters
that cannot serve a requested format SHALL throw `AudioFormatUnsupportedError`.
#### Scenario: every documented format value is accepted
- **WHEN** each of the twelve format strings is parsed by `AudioFormatSchema`
- **THEN** parsing succeeds
#### Scenario: unknown format rejected
- **WHEN** `AudioFormatSchema` parses `"mp3_unknown"`
- **THEN** Zod throws
**Verified by:** `src/types/types.test.ts::"AudioFormatSchema accepts every documented
format value"` AND `src/types/types.test.ts::"AudioFormatSchema rejects unknown formats"`
---
### Requirement: Normalized scales for all numeric parameters
All numeric parameters on requests SHALL be normalized to documented bounded ranges:
voice-setting fields `stability`, `similarityBoost`, `style` to `[0, 1]`;
confidence in `WordTimestamp` to `[0, 1]`;
temperature in `TranscriptionRequest` to `[0, 2]`;
diarization threshold to `[0, 1]`;
prompt influence in `SoundEffectRequest` to `[0, 1]`;
sound-effect duration to `[0.5, 30]` seconds;
`numSpeakers` to `[1, 32]`;
page size to `[1, 100]`;
seeds to non-negative integers.
Adapters MUST remap provider-native scales onto these ranges at the boundary.
#### Scenario: page size outside 1..100 is rejected
- **WHEN** `ListVoicesOptionsSchema` parses `{ pageSize: 0 }` or `{ pageSize: 101 }`
- **THEN** Zod throws
#### Scenario: temperature outside 0..2 is rejected
- **WHEN** `TranscriptionRequestSchema` parses `{ temperature: 2.5 }`
- **THEN** Zod throws
#### Scenario: numSpeakers outside 1..32 is rejected
- **WHEN** `TranscriptionRequestSchema` parses `{ numSpeakers: 33 }`
- **THEN** Zod throws
#### Scenario: durationSeconds outside 0.5..30 is rejected
- **WHEN** `SoundEffectRequestSchema` parses `{ text: "x", durationSeconds: 0.1 }`
- **THEN** Zod throws
**Verified by:** `src/types/types.test.ts::"ListVoicesOptionsSchema rejects pageSize outside 1..100"`,
`src/stt/stt.test.ts::"TranscriptionRequestSchema rejects temperature outside 0..2"`,
`src/stt/stt.test.ts::"TranscriptionRequestSchema rejects numSpeakers outside 1..32"`,
`src/effects/effects.test.ts::"SoundEffectRequestSchema rejects durationSeconds outside 0.5..30"`
---
### Requirement: VoiceSynthesizer engine contract
The package SHALL export an interface `VoiceSynthesizer` with shape:
```ts
interface VoiceSynthesizer {
readonly providerName: string;
synthesize(request: SynthesisRequest): Promise<SynthesisResult>;
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>;
}
```
`SynthesisRequest` SHALL carry `text`, `voiceId`, plus optional `modelId`, `languageCode`,
`voiceSettings`, `outputFormat`, `latencyMode`, `previousText`, `nextText`, `seed`,
`pronunciationDictionaryIds`, and `signal: AbortSignal`. `SynthesisResult` SHALL carry
`audio: Uint8Array`, `format: AudioFormat`, plus optional `durationMs`, `charactersUsed`,
`requestId`. `synthesizeStream` SHALL return a `ReadableStream<Uint8Array>` synchronously —
adapters perform any HTTP handshake inside the stream's `start()` callback, not before
returning. The package SHALL also export `isVoiceSynthesizer(value): value is VoiceSynthesizer`
duck-type guard.
#### Scenario: SynthesisRequestSchema accepts a minimal request
- **WHEN** `SynthesisRequestSchema` parses `{ text: "Hi", voiceId: "v1" }`
- **THEN** parsing succeeds
#### Scenario: SynthesisRequestSchema rejects empty text or voiceId
- **WHEN** parsing `{ text: "", voiceId: "v1" }` or `{ text: "Hi", voiceId: "" }`
- **THEN** Zod throws
#### Scenario: SynthesisResultSchema rejects non-Uint8Array audio
- **WHEN** parsing `{ audio: "string", format: "mp3_44100_128" }`
- **THEN** Zod throws
#### Scenario: duck-type guard rejects engines missing stream method
- **WHEN** `isVoiceSynthesizer({ providerName: "x", synthesize: () => {} })` is called
- **THEN** the guard returns `false`
**Verified by:** `src/tts/tts.test.ts::"SynthesisRequestSchema accepts a minimal request"`,
`src/tts/tts.test.ts::"isVoiceSynthesizer rejects objects missing synthesizeStream function"`
---
### Requirement: TranscriptionEngine engine contract
The package SHALL export an interface `TranscriptionEngine` with shape:
```ts
interface TranscriptionEngine {
readonly providerName: string;
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
}
```
`TranscriptionRequest` SHALL carry `audio: AudioSource`, plus optional `modelId`,
`languageCode`, `tagAudioEvents`, `numSpeakers`, `timestampsGranularity` (`"word" | "character"`),
`diarize`, `diarizationThreshold`, `useMultiChannel`, `entityDetection` (`string[] | "all"`),
`entityRedaction` (same shape), `keyterms`, `temperature`, `seed`, `signal`. `AudioSource`
SHALL be the union `Uint8Array | ReadableStream<Uint8Array> | string` (the string variant is
a URL). `TranscriptionResult` SHALL carry `text` plus optional `words`, `speakers`,
`entities`, `language`, `durationMs`, `requestId`. The package SHALL also export
`isTranscriptionEngine` guard.
#### Scenario: TranscriptionRequestSchema accepts an empty request
- **WHEN** `TranscriptionRequestSchema` parses `{}`
- **THEN** parsing succeeds (every field optional after `audio`, which is excluded from schema)
#### Scenario: entityDetection accepts literal "all"
- **WHEN** parsing `{ entityDetection: "all" }`
- **THEN** parsing succeeds
#### Scenario: duck-type guard rejects engines missing transcribe
- **WHEN** `isTranscriptionEngine({ providerName: "x" })` is called
- **THEN** the guard returns `false`
**Verified by:** `src/stt/stt.test.ts::"TranscriptionRequestSchema accepts an empty request"`,
`src/stt/stt.test.ts::"TranscriptionRequestSchema accepts entityDetection as 'all'"`,
`src/stt/stt.test.ts::"isTranscriptionEngine rejects objects with non-function transcribe"`
---
### Requirement: VoiceRepository engine contract with null-on-not-found
The package SHALL export an interface `VoiceRepository` with shape:
```ts
interface VoiceRepository {
readonly providerName: string;
list(options?: ListVoicesOptions): Promise<VoiceList>;
get(voiceId: string): Promise<Voice | null>;
}
```
`get(voiceId)` SHALL return `null` when the upstream provider reports the voice as not
found. Adapters MUST map provider-specific 404s to `null` and SHALL NOT throw
`VoiceNotFoundError` from `get`. Other failures (auth, 5xx, network) SHALL propagate as
typed `VoiceError` subclasses. `list` SHALL be paginated via opaque `nextPageToken` and
boolean `hasMore`.
#### Scenario: get-result schema accepts null
- **WHEN** `VoiceRepositoryGetResultSchema` parses `null`
- **THEN** parsing succeeds
#### Scenario: get-result schema rejects undefined
- **WHEN** the schema parses `undefined`
- **THEN** Zod throws (only `Voice | null` accepted)
#### Scenario: list-result envelope requires hasMore
- **WHEN** `VoiceRepositoryListResultSchema` parses `{ voices: [], nextPageToken: undefined }`
- **THEN** Zod throws (hasMore required)
**Verified by:** `src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema accepts null"`,
`src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema rejects undefined (only Voice or null)"`
---
### Requirement: SoundEffectEngine engine contract
The package SHALL export an interface `SoundEffectEngine` with shape:
```ts
interface SoundEffectEngine {
readonly providerName: string;
generate(request: SoundEffectRequest): Promise<SoundEffectResult>;
}
```
`SoundEffectRequest` SHALL carry `text` plus optional `durationSeconds: [0.5, 30]`,
`loop`, `promptInfluence: [0, 1]`, `modelId`, `outputFormat`, `signal`. The interface
SHALL NOT include a streaming variant (the dominant provider pattern returns full audio
in a single response). A future streaming extension would arrive in its own subpath.
#### Scenario: empty text is rejected
- **WHEN** `SoundEffectRequestSchema` parses `{ text: "" }`
- **THEN** Zod throws
#### Scenario: promptInfluence outside 0..1 is rejected
- **WHEN** the schema parses `{ text: "x", promptInfluence: 1.5 }`
- **THEN** Zod throws
#### Scenario: duck-type guard rejects engines missing generate
- **WHEN** `isSoundEffectEngine({ providerName: "x" })` is called
- **THEN** the guard returns `false`
**Verified by:** `src/effects/effects.test.ts::"SoundEffectRequestSchema rejects an empty text"`,
`src/effects/effects.test.ts::"SoundEffectRequestSchema rejects promptInfluence outside 0..1"`,
`src/effects/effects.test.ts::"isSoundEffectEngine rejects objects missing generate"`
---
### Requirement: VoiceIsolationEngine engine contract
The package SHALL export an interface `VoiceIsolationEngine` with shape:
```ts
interface VoiceIsolationEngine {
readonly providerName: string;
isolate(request: IsolationRequest): Promise<IsolationResult>;
}
```
`IsolationRequest` SHALL carry `audio: AudioSource` plus optional `outputFormat`,
`signal`. The interface SHALL NOT include a streaming variant — most isolation services
need full audio context for voice-activity detection. Adapters that accept `ReadableStream`
audio SHALL buffer internally and hide the buffering cost from callers.
#### Scenario: empty IsolationRequest is accepted
- **WHEN** `IsolationRequestSchema` parses `{}` (audio + signal excluded from schema)
- **THEN** parsing succeeds
#### Scenario: unknown outputFormat is rejected
- **WHEN** parsing `{ outputFormat: "mp3_unknown" }`
- **THEN** Zod throws
#### Scenario: duck-type guard rejects engines missing isolate
- **WHEN** `isVoiceIsolationEngine({ providerName: "x" })` is called
- **THEN** the guard returns `false`
**Verified by:** `src/isolation/isolation.test.ts::"IsolationRequestSchema accepts an empty request
(every field optional after audio)"`,
`src/isolation/isolation.test.ts::"IsolationRequestSchema rejects unknown outputFormat"`,
`src/isolation/isolation.test.ts::"isVoiceIsolationEngine rejects objects missing isolate"`
---
### Requirement: Binary and stream fields excluded from Zod schemas
Binary payloads (`Uint8Array`/`ReadableStream`) and unserialisable runtime values
(`AbortSignal`) SHALL NOT appear in any Zod request schema. Adapters validate these
structurally at runtime via `instanceof` / `typeof`. Result schemas MAY include
`audio: Uint8Array` because the data is materialised by then. The `audio` field of
`TranscriptionRequest` and `IsolationRequest` SHALL be omitted from their respective
request schemas; the `signal` field SHALL be omitted from every request schema.
#### Scenario: TranscriptionRequestSchema accepts no audio field
- **WHEN** the schema parses `{ modelId: "m" }`
- **THEN** parsing succeeds even though `audio` is part of the TypeScript interface
#### Scenario: IsolationRequestSchema accepts an empty object
- **WHEN** the schema parses `{}`
- **THEN** parsing succeeds (audio + signal both excluded)
**Verified by:** `src/stt/stt.test.ts::"TranscriptionRequestSchema accepts an empty request (every field optional)"`,
`src/isolation/isolation.test.ts::"IsolationRequestSchema accepts an empty request (every field optional after audio)"`
---
### Requirement: Voice cloning intentionally absent
The package SHALL NOT declare a generic `cloneVoice` method on `VoiceRepository` or
any other engine. Voice cloning workflows differ too widely across vendors
(ElevenLabs instant/professional, Azure multi-week, OpenAI none) to share a stable
interface today. A future `VoiceCloner` interface SHALL live on its own subpath once
two or more providers converge. The `VoiceCloneRejectedError` code reserves the
namespace for future use; adapters that surface clone failures from provider-specific
flows MUST throw it.
#### Scenario: VoiceRepository surface contains only list and get
- **WHEN** the `VoiceRepository` interface is enumerated
- **THEN** members are exactly `providerName`, `list`, `get`
#### Scenario: VoiceCloneRejectedError code is registered
- **WHEN** `VOICE_CODES["VOICE.CLONE_REJECTED"]` is read
- **THEN** the value is `"VOICE.VOICE.CLONE_REJECTED"`
**Verified by:** `src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema accepts a valid Voice"`
(via existence and shape of the interface) AND
`src/errors/errors.test.ts::"VoiceCloneRejectedError carries its code"`
---
### Requirement: Streaming responses only on TTS
The package SHALL declare exactly one streaming method across all five engines:
`VoiceSynthesizer.synthesizeStream`. STT, sound-effects, and voice-isolation engines
SHALL return complete results via `Promise`. `synthesizeStream` SHALL return
`ReadableStream<Uint8Array>` synchronously so callers can immediately wire it into
`pipeTo` / `pipeThrough`.
#### Scenario: TTS guard verifies both blocking and streaming methods
- **WHEN** `isVoiceSynthesizer` runs on an object missing `synthesizeStream`
- **THEN** the guard returns `false`
#### Scenario: No other engine guard checks for streaming method
- **WHEN** `isTranscriptionEngine` / `isSoundEffectEngine` / `isVoiceIsolationEngine` run on
an object lacking any stream method
- **THEN** the guards return `true` (streaming is not part of those contracts)
**Verified by:** `src/tts/tts.test.ts::"isVoiceSynthesizer rejects objects missing synthesizeStream function"`,
`src/stt/stt.test.ts::"isTranscriptionEngine accepts a valid engine shape"`,
`src/effects/effects.test.ts::"isSoundEffectEngine accepts a valid engine shape"`,
`src/isolation/isolation.test.ts::"isVoiceIsolationEngine accepts a valid engine shape"`
---
### Requirement: Voice domain type with provider-neutral fields
The package SHALL export a `Voice` interface with `id: string` (min length 1),
`name: string` (min length 1), `category: VoiceCategory`, plus optional
`gender: VoiceGender`, `accent: string`, `age: VoiceAge`,
`languageCodes: readonly string[]` (each 2..35 chars BCP-47),
`description: string`, `previewUrl: string` (URL-validated),
`settings: VoiceSettings`, `metadata: Record<string, unknown>`.
`VoiceCategory` SHALL be the enum `"premade" | "cloned" | "generated" | "professional"`.
`VoiceGender` SHALL be `"male" | "female" | "neutral"`.
`VoiceAge` SHALL be `"young" | "middle" | "old"`.
`metadata` exists for provider-specific labels that don't fit the canonical surface.
#### Scenario: minimal Voice round-trips
- **WHEN** `VoiceSchema` parses `{ id: "v1", name: "V", category: "premade" }`
- **THEN** parsing succeeds
#### Scenario: malformed previewUrl is rejected
- **WHEN** the schema parses `{ id: "v1", name: "V", category: "premade", previewUrl: "not a url" }`
- **THEN** Zod throws
**Verified by:** `src/types/types.test.ts::"VoiceSchema round-trips a minimal voice"`,
`src/types/types.test.ts::"VoiceSchema rejects a malformed previewUrl"`
---
### Requirement: Subpath exports for tree-shaking
The package's `exports` field SHALL expose, at minimum, these subpaths in addition to the
root barrel: `./errors/errors`, `./types/types`, `./tts/tts`, `./stt/stt`,
`./voices/voices`, `./effects/effects`, `./isolation/isolation`. Each subpath SHALL
re-export only the symbols of its module (not other modules), enabling per-engine
tree-shaking for adapters that only need one contract.
#### Scenario: importing `@suaveplan/voice/tts/tts` resolves without pulling other engines
- **WHEN** an adapter imports only from `@suaveplan/voice/tts/tts`
- **THEN** `dist/` byte-size impact contains only TTS module bytes (no STT/voices/effects/isolation)
#### Scenario: every subpath listed in `exports` resolves
- **WHEN** a consumer imports each declared subpath
- **THEN** all resolutions succeed
**Verified by:** `tests/tree-shake.test.ts`
---
### Requirement: 100% statement, branch, function, and line coverage
The package SHALL achieve 100% Istanbul coverage on every `.ts` under `src/` excluding
`.test.ts`, `.spec.ts`, `.types.ts`, `.stories.tsx`, and barrels. Coverage drops below
100% SHALL block publish.
#### Scenario: coverage gate
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/voice` runs
- **THEN** statements / branches / functions / lines are each `100`
**Verified by:** `bunx turbo test:coverage --filter=@suaveplan/voice` (CI gate)