# Capability Spec: voice-elevenlabs **Package**: `@suaveplan/voice-elevenlabs` **Status**: STABLE **Tier**: universal > Every requirement below carries a `**Verified by:**` line anchored to a real test in > `packages/media/audio/voice-elevenlabs/`. No exceptions. `@suaveplan/voice-elevenlabs` is the ElevenLabs domain adapter for the `@suaveplan/voice` engine family. It implements every `VoiceSynthesizer`, `TranscriptionEngine`, `VoiceRepository`, `SoundEffectEngine`, and `VoiceIsolationEngine` contract by delegating to the typed HTTP/WebSocket transport in `@suaveplan/third-party-elevenlabs`. It owns DTO ↔ domain mapping, ElevenLabs-specific error remapping into the `VOICE.*` namespace, multipart/stream plumbing for the voice surface, and a composite factory that wires every engine against a shared transport. The package additionally ships two ElevenLabs-specific surfaces that are not yet part of the abstract `@suaveplan/voice` core (`ElevenLabsModelCatalog` and `ElevenLabsSpeechToSpeech`) but produce `VOICE.*` errors and therefore belong on the domain-adapter side of the split. ## Requirements ### Requirement: Five voice-domain engine classes The package SHALL export five classes, each implementing the corresponding `@suaveplan/voice` engine interface, all declaring `readonly providerName = "elevenlabs"`: - `ElevenLabsVoiceSynthesizer` implements `VoiceSynthesizer` - `ElevenLabsTranscriptionEngine` implements `TranscriptionEngine` - `ElevenLabsVoiceRepository` implements `VoiceRepository` - `ElevenLabsSoundEffectEngine` implements `SoundEffectEngine` - `ElevenLabsVoiceIsolationEngine` implements `VoiceIsolationEngine` Each class SHALL accept a single `ElevenLabsClient` (from `@suaveplan/third-party-elevenlabs`) in its constructor and own no other state. #### Scenario: every engine passes its duck-type guard - **WHEN** the bundle returned by `createElevenLabsVoice({ apiKey })` is checked against `isVoiceSynthesizer`, `isTranscriptionEngine`, `isVoiceRepository`, `isSoundEffectEngine`, and `isVoiceIsolationEngine` from `@suaveplan/voice` - **THEN** every guard returns `true` for its corresponding field **Verified by:** `src/factory/create-elevenlabs-voice.test.ts::"constructs every voice engine sharing one client"` --- ### Requirement: Adapter performs no transport itself The package SHALL NOT contain any HTTP, WebSocket, retry, rate-limit-header, or OTel-transport code. All such concerns are delegated to `@suaveplan/third-party-elevenlabs` via the injected `ElevenLabsClient`. #### Scenario: no fetch / no WebSocket constructor in src - **WHEN** `src/` is grep'd for `fetch(`, `new WebSocket`, `XMLHttpRequest`, `node:http`, or `node:https` - **THEN** the count is zero (all calls are routed through `client.fetchJson`, `client.fetchBinary`, `client.fetchStream`, `client.fetchMultipartJson`, `client.fetchMultipartBinary`, or `client.openSocket`) **Verified by:** `tests/dep-graph.test.ts` (asserts `@suaveplan/third-party-elevenlabs` is the sole transport dep) --- ### Requirement: Error remapping with cause preserved Every `ElevenLabsApiError` subclass raised by the transport SHALL be remapped to a `VoiceError` subclass from `@suaveplan/voice`. The original error SHALL be preserved via `error.cause`. Mapping table: | Third-party code | Voice domain error | Notes | | --- | --- | --- | | `AUTH.UNAUTHORIZED`, `AUTH.FORBIDDEN` | `ProviderNotConfiguredError` | invalid / missing / under-scoped key | | `REQUEST.NOT_FOUND` (from `voices.get`) | `null` (no throw) | repository null-on-not-found contract | | `REQUEST.NOT_FOUND` (other endpoints) | `VoiceValidationError` | generic resource-missing | | `REQUEST.VALIDATION_FAILED`, `REQUEST.INVALID` | `VoiceValidationError` | body preserved on cause | | `RATE_LIMIT.EXCEEDED`, `RATE_LIMIT.QUOTA_EXHAUSTED` | `ProviderRateLimitedError` | `retryAfterMs` preserved | | `SERVER.TRANSIENT` / `SERVER.UNAVAILABLE` (TTS) | `TtsSynthesisFailedError` | | | `SERVER.TRANSIENT` / `SERVER.UNAVAILABLE` (STT) | `SttTranscriptionFailedError` | | | `SERVER.TRANSIENT` (sound-generation) | `SoundEffectGenerationFailedError` | | | `SERVER.TRANSIENT` (audio-isolation) | `VoiceIsolationFailedError` | | | `STREAM.INTERRUPTED` | `TtsStreamInterruptedError` | | | `RESPONSE.UNSUPPORTED_FORMAT` | `AudioFormatUnsupportedError` | | | `NETWORK.*` (during streams) | `TtsStreamInterruptedError` | | | `NETWORK.*` (other) | `ProviderNotConfiguredError` | timeout / unreachable | #### Scenario: 404 on voices.get returns null - **WHEN** the transport responds with HTTP 404 to `GET /v1/voices/{id}` - **THEN** `repo.get(id)` resolves to `null` (does NOT throw) #### Scenario: 401 on TTS surfaces as ProviderNotConfiguredError with cause - **WHEN** the transport returns HTTP 401 from `POST /v1/text-to-speech/{id}` - **THEN** `tts.synthesize` rejects with `ProviderNotConfiguredError` - **AND** `error.cause` is the original `UnauthorizedError` #### Scenario: 429 on TTS surfaces retryAfterMs in context - **WHEN** the transport returns HTTP 429 with `Retry-After: 60` from a TTS call - **THEN** the engine rejects with `ProviderRateLimitedError` - **AND** `error.context.retryAfterMs === 60_000` **Verified by:** `src/voices/voices.test.ts`, `src/tts/tts.test.ts`, `src/coverage.test.ts` (the comprehensive cross-cutting suite) --- ### Requirement: STT supports all three audio source shapes `TranscriptionEngine.transcribe` SHALL accept `audio: Uint8Array | ReadableStream | string` (a URL) per the `@suaveplan/voice` interface, threading the value through to `client.fetchMultipartJson` against `POST /v1/speech-to-text`. The string variant is forwarded to the transport's `MultipartFile` URL form. #### Scenario: Uint8Array audio accepted - **WHEN** caller awaits `stt.transcribe({ audio: new Uint8Array([...]) })` - **THEN** the request is sent and the parsed transcription returns #### Scenario: ReadableStream audio accepted - **WHEN** caller awaits `stt.transcribe({ audio: })` - **THEN** the request body contains the buffered stream bytes and the transcription returns #### Scenario: URL string audio accepted - **WHEN** caller awaits `stt.transcribe({ audio: "https://example.com/x.wav" })` - **THEN** the transport receives the URL string as the multipart file value **Verified by:** `src/stt/stt.test.ts` --- ### Requirement: TTS streaming returns a ReadableStream synchronously `VoiceSynthesizer.synthesizeStream(request)` SHALL return a `ReadableStream` synchronously (not a `Promise`). The HTTP handshake SHALL be performed inside the stream's `start()` callback so callers can immediately wire the stream into `pipeTo` / `pipeThrough`. #### Scenario: synthesizeStream returns ReadableStream synchronously - **WHEN** caller invokes `synth.synthesizeStream({ text, voiceId })` - **THEN** the return value is a `ReadableStream` (not a `Promise`) **Verified by:** `src/tts/tts.test.ts::"synthesizeStream returns a ReadableStream"` (et al.) --- ### Requirement: AudioFormat round-trips through the adapter Every `@suaveplan/voice` `AudioFormat` value SHALL round-trip through the adapter: a request with `outputFormat: ` SHALL produce a response whose `format` equals `` for every documented format. ElevenLabs-native `output_format` strings map 1:1 to `@suaveplan/voice` `AudioFormat` strings. #### Scenario: mp3_44100_128 round-trips - **WHEN** `tts.synthesize({ outputFormat: "mp3_44100_128", ... })` resolves - **THEN** `result.format === "mp3_44100_128"` **Verified by:** `src/tts/tts.test.ts` (per-format coverage), `src/coverage.test.ts` --- ### Requirement: VoiceCategory / gender / age / accent / languageCodes mapping When converting an ElevenLabs voice DTO into the domain `Voice` shape, the adapter SHALL: - pass `category: 'premade' | 'cloned' | 'generated' | 'professional'` straight through (already compatible with the domain enum); - read `gender`, `age`, `accent` from `labels.gender`, `labels.age`, `labels.accent`; unrecognised label values fall back into `metadata`; - aggregate `languageCodes` from `verified_languages` (BCP-47) and `labels.language`, de-duplicate, and normalise. #### Scenario: full voice maps every recognised label - **WHEN** the transport returns a voice with non-empty labels and `verified_languages` - **THEN** the resulting `Voice` carries `category`, `gender`, `age`, `accent`, and a de-duplicated `languageCodes` array **Verified by:** `src/voices/voices.test.ts` --- ### Requirement: createElevenLabsVoice composite factory The package SHALL export `createElevenLabsVoice(options): ElevenLabsVoiceBundle`. The `options` argument SHALL accept either `{ client: ElevenLabsClient }` (to share a pre-constructed transport with sibling surfaces such as `ElevenLabsModelCatalog` or `ElevenLabsSpeechToSpeech`) or any `ElevenLabsClientOptions` (in which case the factory constructs a fresh client). When both `client` and other transport fields are present, `client` wins and the other fields are ignored. The return shape SHALL be exactly `{ client, tts, stt, voices, effects, isolation }`, with every engine wired to the same `client` instance. #### Scenario: factory constructs fresh client from options - **WHEN** caller invokes `createElevenLabsVoice({ apiKey: "k", baseUrl: "..." })` - **THEN** `bundle.client` is a new `ElevenLabsClient` - **AND** every engine on the bundle shares that client #### Scenario: factory reuses pre-constructed client - **WHEN** caller invokes `createElevenLabsVoice({ client })` - **THEN** `bundle.client === client` (referential identity) - **AND** every engine on the bundle is wired to that client **Verified by:** `src/factory/create-elevenlabs-voice.test.ts::"propagates client options to the underlying transport"`, `src/factory/create-elevenlabs-voice.test.ts::"reuses an externally constructed client when provided"` --- ### Requirement: Provider-specific ElevenLabs surfaces ship alongside the voice engines In addition to the five voice engines, the package SHALL export `ElevenLabsModelCatalog` (the `GET /v1/models` reader) and `ElevenLabsSpeechToSpeech` (the voice-conversion `POST /v1/speech-to-speech/...` surfaces). Both classes consume `ElevenLabsClient` and produce `VOICE.*` errors per the same remapping table. They live in `voice-elevenlabs` rather than `third-party-elevenlabs` because their error vocabulary is voice-domain. #### Scenario: ElevenLabsModelCatalog auth failure maps to ProviderNotConfiguredError - **WHEN** the transport returns HTTP 401 to `GET /v1/models` - **THEN** `catalog.list()` rejects with `ProviderNotConfiguredError` #### Scenario: ElevenLabsSpeechToSpeech transient failure maps to TtsSynthesisFailedError - **WHEN** the transport returns HTTP 503 from a speech-to-speech call - **THEN** the call rejects with `TtsSynthesisFailedError` **Verified by:** `src/models/models.test.ts`, `src/speech-to-speech/speech-to-speech.test.ts`, `src/coverage.test.ts` --- ### Requirement: Universal-tier dependency surface The package's runtime `dependencies` SHALL be exactly `@suaveplan/error`, `@suaveplan/third-party-elevenlabs`, `@suaveplan/types`, `@suaveplan/voice`, `tslib`, `zod` (in that sorted order). No additional `@suaveplan/*` package SHALL appear. The package SHALL be browser-safe — no imports from `node:*`, no `process`, no `Buffer`. #### Scenario: dep-graph asserts exact runtime set - **WHEN** `tests/dep-graph.test.ts` runs - **THEN** `Object.keys(pkg.dependencies)` (sorted) equals exactly the six names above #### Scenario: tree-shake asserts subpath exports - **WHEN** `tests/tree-shake.test.ts` runs - **THEN** every advertised subpath (`./tts/tts`, `./stt/stt`, `./voices/voices`, `./sound-effects/sound-effects`, `./voice-isolation/voice-isolation`, `./factory/create-elevenlabs-voice`, `./models/models`, `./speech-to-speech/speech-to-speech`) is present in the `exports` field **Verified by:** `tests/dep-graph.test.ts`, `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 gate failure SHALL block publish. #### Scenario: coverage gate - **WHEN** `bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs` runs - **THEN** statements / branches / functions / lines are each `100` **Verified by:** `bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs` (CI gate)