# 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`. 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 `_[_]`. 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; synthesizeStream(request: SynthesisRequest): ReadableStream; } ``` `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` 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; } ``` `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 | 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; get(voiceId: string): Promise; } ``` `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; } ``` `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; } ``` `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` 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`. `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)