12 KiB
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 inpackages/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":
ElevenLabsVoiceSynthesizerimplementsVoiceSynthesizerElevenLabsTranscriptionEngineimplementsTranscriptionEngineElevenLabsVoiceRepositoryimplementsVoiceRepositoryElevenLabsSoundEffectEngineimplementsSoundEffectEngineElevenLabsVoiceIsolationEngineimplementsVoiceIsolationEngine
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 againstisVoiceSynthesizer,isTranscriptionEngine,isVoiceRepository,isSoundEffectEngine, andisVoiceIsolationEnginefrom@suaveplan/voice - THEN every guard returns
truefor 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 forfetch(,new WebSocket,XMLHttpRequest,node:http, ornode:https - THEN the count is zero (all calls are routed through
client.fetchJson,client.fetchBinary,client.fetchStream,client.fetchMultipartJson,client.fetchMultipartBinary, orclient.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 tonull(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.synthesizerejects withProviderNotConfiguredError - AND
error.causeis the originalUnauthorizedError
Scenario: 429 on TTS surfaces retryAfterMs in context
- WHEN the transport returns HTTP 429 with
Retry-After: 60from 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 aPromise)
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,accentfromlabels.gender,labels.age,labels.accent; unrecognised label values fall back intometadata; - aggregate
languageCodesfromverified_languages(BCP-47) andlabels.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
Voicecarriescategory,gender,age,accent, and a de-duplicatedlanguageCodesarray
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.clientis a newElevenLabsClient - 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 withProviderNotConfiguredError
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.tsruns - THEN
Object.keys(pkg.dependencies)(sorted) equals exactly the six names above
Scenario: tree-shake asserts subpath exports
- WHEN
tests/tree-shake.test.tsruns - 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 theexportsfield
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-elevenlabsruns - THEN statements / branches / functions / lines are each
100
Verified by: bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs (CI gate)