112 lines
6.2 KiB
Markdown
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
|