14 KiB
OpenSpec Change: voice-elevenlabs
- Change ID: voice-elevenlabs
- Package:
@suaveplan/voice-elevenlabs - Category: universal
- Target path:
packages/audio/voice-elevenlabs - Status: PROPOSED (rewritten 2026-05-02 — supersedes the 2026-04-24 fused-HTTP design)
- Date: 2026-05-02
- Depends on:
openspec/changes/voice/(abstract core interfaces)openspec/changes/third-party-elevenlabs/(typed thin client; Wave 1 voice surface)
- Reference spec: none — new capability (genesis-original)
Summary
Deliver @suaveplan/voice-elevenlabs on the genesis branch as a thin domain adapter that implements every @suaveplan/voice engine interface (VoiceSynthesizer, TranscriptionEngine, VoiceRepository, SoundEffectEngine, VoiceIsolationEngine) by delegating to @suaveplan/third-party-elevenlabs. It owns no HTTP, no auth, no retry, no rate-limit handling, no OTel transport spans, and no Zod schemas for ElevenLabs DTOs — those all live in the third-party package. Its sole responsibilities are:
- DTO ↔ domain mapping. ElevenLabs-shaped DTOs from
@suaveplan/third-party-elevenlabsbecome@suaveplan/voicetypes (Voice,SynthesisResult,TranscriptionResult,SoundEffectResult,IsolationResult). - Error remapping.
ElevenLabsApiErrorsubclasses are remapped toVoiceErrorcodes per the voice-domain semantics (e.g.THIRD_PARTY_ELEVENLABS.REQUEST.NOT_FOUNDfromvoices.get(id)→VOICE.VOICE.NOT_FOUND). - Multipart and stream plumbing for the voice surface. STT audio sources (
Uint8Array | ReadableStream | string) are converted to theMultipartFileenvelope expected by@suaveplan/third-party-elevenlabs; streaming TTS bodies pass through unchanged. - Composite factory.
createElevenLabsVoice(options)returns the five engines plus the underlyingElevenLabsClientfor callers who need to escape to lower levels.
Why this design supersedes the 2026-04-24 proposal
The original proposal fused HTTP plumbing into the adapter. That design failed three forward-looking constraints:
- ElevenLabs surfaces non-voice domains. Music, Dubbing, Studio, Audio Native, Conversational AI, Pronunciation Dictionaries, History, Workspace administration. A voice-only adapter cannot host those.
- Every future ElevenLabs adapter would re-implement the same
fetchwrapper, retry policy, rate-limit parsing, and OTel instrumentation. That is duplication on a scale we are not willing to accept. - The 2026-04-24 implementation diverged from its own spec on multiple load-bearing points. No
@suaveplan/retryintegration despite the dep being declared. NoX-RateLimit-Remainingparsing. 5xx incorrectly mapped toProviderNotConfiguredError. No OTel spans/counters/histograms. The fused design encouraged these omissions because the package was implementing too much at once.
The rewrite splits responsibilities: @suaveplan/third-party-elevenlabs owns transport correctness; @suaveplan/voice-elevenlabs owns domain mapping. Both are testable independently.
Out of scope
- Any HTTP, auth, retry, OTel transport spans, or rate-limit logic. Those live in
@suaveplan/third-party-elevenlabs. - Voice cloning. ElevenLabs clone APIs (instant-clone, professional-clone, voice-design) ship in Wave 2 of the third-party client (
third-party-elevenlabs-voice-extensions); the corresponding voice-domain adapter will land via a follow-up change once@suaveplan/voiceadds aVoiceClonerinterface. - Conversational AI. Ships under a separate package
@suaveplan/conversational-elevenlabsagainst a future@suaveplan/conversationalcore. - Music, Dubbing, Studio, Audio Native. Each gets its own domain core + provider adapter pair when the domain is in scope.
- Speech-to-speech (voice changer). The third-party client exposes it in Wave 1, but
@suaveplan/voicedoes not yet have aSpeechConverterinterface. When it does, this adapter gains aconvert()method.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/voice (workspace:^) |
Abstract interfaces, types, schemas, VoiceError codes |
@suaveplan/third-party-elevenlabs (workspace:^) |
Typed REST/WebSocket client, ElevenLabs DTOs, transport-level errors |
@suaveplan/error (workspace:^) |
SuaveplanError base + cause-chain preservation when remapping |
@suaveplan/types (workspace:^) |
Clock, LoggerLike |
@suaveplan/streams (workspace:^) |
Stream helpers (re-exported from third-party for convenience-free access) |
zod (catalog:forms) |
Mapping-result validation in tests |
tslib (catalog:build) |
Runtime helpers |
Dev deps: @suaveplan/testing (workspace:^, dev-only), vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).
@opentelemetry/api is not a direct dep of this package. Transport-level OTel instrumentation lives in @suaveplan/third-party-elevenlabs. The adapter does not emit its own spans because it adds no I/O on top of the client; if a future scenario justifies adapter-level spans, they will be added under a follow-up change with explicit motivation.
Public API surface
Engine implementations
class ElevenLabsTtsEngine implements VoiceSynthesizer {
readonly providerName: 'elevenlabs'
constructor(deps: { tts: ElevenLabsTts }, options?: ElevenLabsTtsAdapterOptions)
synthesize(request: SynthesisRequest): Promise<SynthesisResult>
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>
}
class ElevenLabsSttEngine implements TranscriptionEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { stt: ElevenLabsStt }, options?: ElevenLabsSttAdapterOptions)
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>
}
class ElevenLabsVoiceRepository implements VoiceRepository {
readonly providerName: 'elevenlabs'
constructor(deps: { voices: ElevenLabsVoices })
list(options?: ListVoicesOptions): Promise<VoiceList>
get(voiceId: string): Promise<Voice | null>
}
class ElevenLabsSoundEffectEngine implements SoundEffectEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { soundGeneration: ElevenLabsSoundGeneration }, options?: ElevenLabsSoundEffectAdapterOptions)
generate(request: SoundEffectRequest): Promise<SoundEffectResult>
}
class ElevenLabsVoiceIsolationEngine implements VoiceIsolationEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { audioIsolation: ElevenLabsAudioIsolation })
isolate(request: IsolationRequest): Promise<IsolationResult>
}
ElevenLabsTtsAdapterOptions carries voice-specific defaults (e.g. defaultModelId: 'eleven_multilingual_v2', defaultVoiceSettings: VoiceSettings). The adapter never owns the API key or the base URL — those belong to the ElevenLabsClient injected upstream.
Composite factory
function createElevenLabsVoice(options: CreateElevenLabsVoiceOptions): {
client: ElevenLabsClient
tts: ElevenLabsTtsEngine
stt: ElevenLabsSttEngine
voices: ElevenLabsVoiceRepository
effects: ElevenLabsSoundEffectEngine
isolation: ElevenLabsVoiceIsolationEngine
}
interface CreateElevenLabsVoiceOptions {
// Either an existing client...
client?: ElevenLabsClient
// ...or the inputs needed to construct one (forwarded to ElevenLabsClient unchanged)
apiKey?: string
baseUrl?: string
timeoutMs?: number
retryPolicy?: RetryPolicy
fetchImpl?: typeof fetch
webSocketImpl?: WebSocketImpl
clock?: Clock
logger?: LoggerLike
events?: EventBusLike<ElevenLabsClientEvents>
userAgent?: string
defaultHeaders?: Record<string, string>
// Adapter-specific defaults
defaultModelId?: string
defaultVoiceSettings?: VoiceSettings
defaultSoundEffectModelId?: string
}
If client is provided, apiKey and the other client options are ignored; otherwise the factory constructs a fresh ElevenLabsClient. Either way, the adapter classes share the same client instance — so retry, rate-limit accounting, and OTel are unified across engines.
ElevenLabs API mapping
| Voice interface | Third-party module | Endpoint |
|---|---|---|
VoiceSynthesizer.synthesize |
tts.synthesize(voiceId, body) |
POST /v1/text-to-speech/{id} |
VoiceSynthesizer.synthesizeStream |
tts.synthesizeStream(voiceId, body) |
POST /v1/text-to-speech/{id}/stream |
TranscriptionEngine.transcribe |
stt.transcribe(body) |
POST /v1/speech-to-text (multipart) |
VoiceRepository.list |
voices.list(params) |
GET /v1/voices |
VoiceRepository.get |
voices.get(voiceId) |
GET /v1/voices/{id} |
SoundEffectEngine.generate |
soundGeneration.generate(body) |
POST /v1/sound-generation |
VoiceIsolationEngine.isolate |
audioIsolation.isolate(body) |
POST /v1/audio-isolation |
Error remapping table
The adapter remaps every ElevenLabsApiError subclass to a VoiceError code and preserves the original via cause:
| Third-party code | Voice domain code | Notes |
|---|---|---|
AUTH.UNAUTHORIZED |
VOICE.PROVIDER.NOT_CONFIGURED |
The API key is invalid or missing |
AUTH.FORBIDDEN |
VOICE.PROVIDER.NOT_CONFIGURED |
Key valid but lacks scope |
REQUEST.NOT_FOUND (from voices.get) |
VOICE.VOICE.NOT_FOUND |
With context.voiceId populated |
REQUEST.NOT_FOUND (other endpoints) |
VOICE.VALIDATION.INVALID_REQUEST |
Generic resource-missing |
REQUEST.VALIDATION_FAILED |
VOICE.VALIDATION.INVALID_REQUEST |
Body preserved on cause.context |
REQUEST.INVALID |
VOICE.VALIDATION.INVALID_REQUEST |
|
RATE_LIMIT.EXCEEDED |
VOICE.PROVIDER.RATE_LIMITED |
retryAfterMs preserved |
RATE_LIMIT.QUOTA_EXHAUSTED |
VOICE.PROVIDER.RATE_LIMITED |
|
SERVER.TRANSIENT (TTS endpoints) |
VOICE.TTS.SYNTHESIS_FAILED |
|
SERVER.TRANSIENT (STT endpoints) |
VOICE.STT.TRANSCRIPTION_FAILED |
|
SERVER.TRANSIENT (sound-generation) |
VOICE.EFFECTS.GENERATION_FAILED |
|
SERVER.TRANSIENT (audio-isolation) |
VOICE.ISOLATION.FAILED |
|
SERVER.UNAVAILABLE |
Same as SERVER.TRANSIENT per endpoint |
|
STREAM.INTERRUPTED |
VOICE.TTS.STREAM_INTERRUPTED |
|
RESPONSE.MALFORMED |
VOICE.PROVIDER.NOT_CONFIGURED |
Indicates an ElevenLabs API contract change |
RESPONSE.UNSUPPORTED_FORMAT |
VOICE.AUDIO.FORMAT_UNSUPPORTED |
|
NETWORK.* |
VOICE.PROVIDER.NOT_CONFIGURED (timeout/unreachable) or VOICE.TTS.STREAM_INTERRUPTED (during streams) |
Every remapped error preserves the original via cause so callers can inspect the underlying transport detail when needed.
DTO mapping decisions
VoiceCategorymapping. ElevenLabs returnscategory: 'premade' | 'cloned' | 'generated' | 'professional'directly compatible with@suaveplan/voiceenum — no remapping needed.Voice.languageCodes. Aggregated fromverified_languages(BCP-47) andlabels.languageif present. De-duplicated and ISO-639-normalised.Voice.gender/Voice.age/Voice.accent. Read fromlabels.gender,labels.age,labels.accent. Unrecognised values fall back tometadata.SynthesisResult.charactersUsed. Read from responsemetadata.characterCost(third-party client surfacesX-Character-Costheader).TranscriptionResult.words. Mapped from ElevenLabswords[]array, dropping non-word entries (type !== 'word'entersentitiesinstead).TranscriptionResult.speakers. Derived by collapsing consecutivewords[]entries with the samespeaker_idinto{startMs, endMs, speakerId}segments.AudioFormatround-trip. All@suaveplan/voiceAudioFormatstrings (mp3_22050_32,pcm_16000, etc.) map to ElevenLabsoutput_formatvalues 1:1 — verified by property test.
Parity Checklist
Genesis-original package — the prior packages/audio/voice-elevenlabs implementation (2026-05-01) is being deleted and rewritten, not migrated, because the architecture changes substantially. The deleted code is superseded by the splits described in ## Why this design supersedes the 2026-04-24 proposal. No upstream @suaveplan/voice-elevenlabs exists in suaveplan main.
| Capability | Prior fused build | This adapter | Notes |
|---|---|---|---|
| TTS synthesize | ✅ partial | ✅ | Now via tts.synthesize |
| TTS streaming | ✅ partial | ✅ | Now via tts.synthesizeStream |
| STT transcribe | ✅ partial | ✅ | Now via stt.transcribe |
| Voice list/get | ✅ partial | ✅ | Now via voices.list / voices.get |
| Sound generation | ✅ partial | ✅ | Now via soundGeneration.generate |
| Audio isolation | ✅ partial | ✅ | Now via audioIsolation.isolate |
| Retry / rate-limit | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| OTel | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
X-RateLimit-Remaining |
🚫 | ✅ | Was missing in prior build; now provided by third-party client |
X-Character-Cost |
🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| 5xx mapped to transient | 🚫 (incorrectly mapped to PROVIDER.NOT_CONFIGURED) |
✅ | Per error remapping table |
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/voice-elevenlabs to enumerate. Currently no shipped genesis package consumes @suaveplan/voice-elevenlabs (the rebuild lands before any downstream takes a dep on it).
- Future
@suaveplan/api-plugin-voice— [ ] tests run / [ ] docs reviewed / [x] no action needed (does not yet exist on genesis) - Future
@suaveplan/react-voice— [ ] tests run / [ ] docs reviewed / [x] no action needed (does not yet exist on genesis)
Migration
The 2026-05-01 fused-HTTP build is deleted in this change. Any in-progress consumer code that imported from @suaveplan/voice-elevenlabs must:
-
Old:
import { ElevenLabsClient } from '@suaveplan/voice-elevenlabs/client/...' -
New:
import { ElevenLabsClient } from '@suaveplan/third-party-elevenlabs' -
Old:
import { createElevenLabsVoice } from '@suaveplan/voice-elevenlabs/factory/factory' -
New: Same path, same return shape —
createElevenLabsVoice(options)is preserved with identical signature except thatapiKey/baseUrl/timeoutMsare now forwarded toElevenLabsClientrather than consumed locally, and the returned object now includesclient: ElevenLabsClient.
Search command for downstream consumers:
grep -rln "@suaveplan/voice-elevenlabs/client" packages/ | grep -v "/dist/\|/coverage/\|/voice-elevenlabs/"
If any hits exist, update them to import from @suaveplan/third-party-elevenlabs before merging.