Files
2026-05-27 23:29:53 +01:00

293 lines
12 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<Uint8Array> | 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: <readable stream> })`
- **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<Uint8Array>` 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<Uint8Array>` (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: <fmt>` SHALL produce a response whose
`format` equals `<fmt>` 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)