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

6.2 KiB

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