# Capability Spec Delta: voice-elevenlabs This delta is the first revision of `openspec/specs/voice-elevenlabs/spec.md`. On archival of change `voice-elevenlabs`, the `## ADDED Requirements` block below is folded into a fresh canonical spec. --- ## ADDED Requirements ### Requirement: ElevenLabs adapter implements every voice engine interface The package SHALL provide implementations of `VoiceSynthesizer`, `TranscriptionEngine`, `VoiceRepository`, `SoundEffectEngine`, and `VoiceIsolationEngine` from `@suaveplan/voice` that delegate all transport concerns to `@suaveplan/third-party-elevenlabs`. #### Scenario: All five engines are exposed under stable names - **GIVEN** a consumer that has installed `@suaveplan/voice-elevenlabs` - **WHEN** they import the package barrel - **THEN** the barrel re-exports `ElevenLabsTtsEngine`, `ElevenLabsSttEngine`, `ElevenLabsVoiceRepository`, `ElevenLabsSoundEffectEngine`, and `ElevenLabsVoiceIsolationEngine` - **AND** each class declares `readonly providerName = 'elevenlabs'` - **AND** each class implements its corresponding `@suaveplan/voice` interface as verified by structural assignment in tests ### Requirement: Adapter performs no transport itself The adapter SHALL NOT perform any HTTP, WebSocket, retry, rate-limit, or OpenTelemetry transport spans of its own. All transport concerns are delegated to the `ElevenLabsClient` from `@suaveplan/third-party-elevenlabs`. #### Scenario: Adapter package has zero direct fetch usage - **GIVEN** the adapter source tree - **WHEN** the source is grepped for `fetch(`, `WebSocket`, `XMLHttpRequest`, `http.request`, `setTimeout` (used as request timeout), or any direct OTel `tracer.startSpan` call for a *transport* span - **THEN** zero results are found in `src/` (excluding tests that inject these via the third-party `fetchImpl` for fixture purposes) ### Requirement: Errors are remapped with cause preserved Every error originating from `@suaveplan/third-party-elevenlabs` SHALL be remapped to a `VoiceError` per the proposal's error-remapping table, with the original error retained via `cause`. #### Scenario: voices.get(id) 404 maps to null, not VoiceError - **GIVEN** the third-party `voices.get(id)` call throws `RequestNotFoundError` - **WHEN** the adapter's `VoiceRepository.get(id)` is awaited - **THEN** the adapter resolves to `null` (per the `VoiceRepository.get` contract) - **AND** no `VoiceError` is thrown #### Scenario: voices.get(id) 401 maps to VOICE.PROVIDER.NOT_CONFIGURED with cause - **GIVEN** the third-party `voices.get(id)` call throws `UnauthorizedError` - **WHEN** the adapter's `VoiceRepository.get(id)` is awaited - **THEN** the adapter throws `ProviderNotConfiguredError` with code `VOICE.PROVIDER.NOT_CONFIGURED` - **AND** `error.cause` is the original `UnauthorizedError` - **AND** `error.context.voiceId === id` #### Scenario: tts.synthesize 5xx maps to VOICE.TTS.SYNTHESIS_FAILED - **GIVEN** the third-party `tts.synthesize(voiceId, body)` call throws `ServerTransientError` - **WHEN** the adapter's `VoiceSynthesizer.synthesize(req)` is awaited - **THEN** the adapter throws `TtsSynthesisFailedError` with code `VOICE.TTS.SYNTHESIS_FAILED` - **AND** `error.cause` is the original `ServerTransientError` #### Scenario: tts.synthesizeStream 429 maps to VOICE.PROVIDER.RATE_LIMITED with retryAfterMs - **GIVEN** the third-party `tts.synthesizeStream(voiceId, body)` call throws `RateLimitExceededError` with `context.retryAfterMs === 5000` - **WHEN** the adapter's `VoiceSynthesizer.synthesizeStream(req)` is invoked - **THEN** the adapter throws `ProviderRateLimitedError` with code `VOICE.PROVIDER.RATE_LIMITED` - **AND** `error.context.retryAfterMs === 5000` - **AND** `error.cause` is the original `RateLimitExceededError` ### Requirement: AudioFormat passes through unchanged Every `@suaveplan/voice` `AudioFormat` value SHALL be accepted by the adapter without remapping, threading through to ElevenLabs' `output_format` parameter as-is. #### Scenario: AudioFormat round-trip property holds - **GIVEN** any documented `@suaveplan/voice` `AudioFormat` value - **WHEN** the adapter constructs a `SynthesisRequest` containing it and calls `synthesize` - **THEN** the third-party request body contains the same string in `output_format` - **AND** the adapter's returned `SynthesisResult.format` is the same string ### Requirement: Composite factory accepts client or client options `createElevenLabsVoice` SHALL accept either an already-constructed `ElevenLabsClient` or the inputs needed to construct one. Either way, the returned object SHALL include the shared client instance under `client`. #### Scenario: Factory uses provided client - **GIVEN** an existing `ElevenLabsClient` instance - **WHEN** the caller invokes `createElevenLabsVoice({ client })` - **THEN** the returned object's `.client === client` - **AND** every returned engine uses that same client for transport #### Scenario: Factory constructs client from API key - **GIVEN** a caller that supplies only `{ apiKey: 'sk_...' }` - **WHEN** the caller invokes `createElevenLabsVoice({ apiKey })` - **THEN** the factory constructs a fresh `ElevenLabsClient` with that key - **AND** the returned object's `.client` is that fresh client - **AND** every returned engine uses that same client for transport ### Requirement: STT supports all three audio source shapes `TranscriptionEngine.transcribe` SHALL accept `audio: Uint8Array | ReadableStream | string` per the `@suaveplan/voice` interface, threading the value through to the third-party `stt.transcribe` multipart body. #### Scenario: ReadableStream audio is accepted - **GIVEN** a `TranscriptionRequest` whose `audio` is a `ReadableStream` of two chunks - **WHEN** the adapter awaits `transcribe(req)` - **THEN** the third-party `stt.transcribe` is called with a `MultipartFile` whose stream emits the same two chunks - **AND** the resolved `TranscriptionResult` reflects the parsed response #### Scenario: URL audio is accepted - **GIVEN** a `TranscriptionRequest` whose `audio` is `'https://example.com/sample.wav'` - **WHEN** the adapter awaits `transcribe(req)` - **THEN** the third-party `stt.transcribe` is called with `MultipartFile = 'https://example.com/sample.wav'` - **AND** the third-party client (not the adapter) is responsible for fetching the URL bytes