Files
suaveplan/openspec/archive/2026-05-22-voice-elevenlabs/specs/voice-elevenlabs/spec.md
2026-05-27 23:29:53 +01:00

112 lines
6.2 KiB
Markdown

# 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