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

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 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)