293 lines
12 KiB
Markdown
293 lines
12 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: voice-elevenlabs
|
|
|
|
**Package**: `@suaveplan/voice-elevenlabs`
|
|
**Status**: STABLE
|
|
**Tier**: universal
|
|
|
|
> Every requirement below carries a `**Verified by:**` line anchored to a real test in
|
|
> `packages/media/audio/voice-elevenlabs/`. No exceptions.
|
|
|
|
`@suaveplan/voice-elevenlabs` is the ElevenLabs domain adapter for the
|
|
`@suaveplan/voice` engine family. It implements every `VoiceSynthesizer`,
|
|
`TranscriptionEngine`, `VoiceRepository`, `SoundEffectEngine`, and
|
|
`VoiceIsolationEngine` contract by delegating to the typed HTTP/WebSocket
|
|
transport in `@suaveplan/third-party-elevenlabs`. It owns DTO ↔ domain mapping,
|
|
ElevenLabs-specific error remapping into the `VOICE.*` namespace,
|
|
multipart/stream plumbing for the voice surface, and a composite factory that
|
|
wires every engine against a shared transport.
|
|
|
|
The package additionally ships two ElevenLabs-specific surfaces that are not
|
|
yet part of the abstract `@suaveplan/voice` core (`ElevenLabsModelCatalog` and
|
|
`ElevenLabsSpeechToSpeech`) but produce `VOICE.*` errors and therefore belong
|
|
on the domain-adapter side of the split.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Five voice-domain engine classes
|
|
|
|
The package SHALL export five classes, each implementing the corresponding
|
|
`@suaveplan/voice` engine interface, all declaring
|
|
`readonly providerName = "elevenlabs"`:
|
|
|
|
- `ElevenLabsVoiceSynthesizer` implements `VoiceSynthesizer`
|
|
- `ElevenLabsTranscriptionEngine` implements `TranscriptionEngine`
|
|
- `ElevenLabsVoiceRepository` implements `VoiceRepository`
|
|
- `ElevenLabsSoundEffectEngine` implements `SoundEffectEngine`
|
|
- `ElevenLabsVoiceIsolationEngine` implements `VoiceIsolationEngine`
|
|
|
|
Each class SHALL accept a single `ElevenLabsClient` (from
|
|
`@suaveplan/third-party-elevenlabs`) in its constructor and own no other
|
|
state.
|
|
|
|
#### Scenario: every engine passes its duck-type guard
|
|
|
|
- **WHEN** the bundle returned by `createElevenLabsVoice({ apiKey })` is checked
|
|
against `isVoiceSynthesizer`, `isTranscriptionEngine`, `isVoiceRepository`,
|
|
`isSoundEffectEngine`, and `isVoiceIsolationEngine` from `@suaveplan/voice`
|
|
- **THEN** every guard returns `true` for its corresponding field
|
|
|
|
**Verified by:** `src/factory/create-elevenlabs-voice.test.ts::"constructs every voice engine sharing one client"`
|
|
|
|
---
|
|
|
|
### Requirement: Adapter performs no transport itself
|
|
|
|
The package SHALL NOT contain any HTTP, WebSocket, retry, rate-limit-header,
|
|
or OTel-transport code. All such concerns are delegated to
|
|
`@suaveplan/third-party-elevenlabs` via the injected `ElevenLabsClient`.
|
|
|
|
#### Scenario: no fetch / no WebSocket constructor in src
|
|
|
|
- **WHEN** `src/` is grep'd for `fetch(`, `new WebSocket`, `XMLHttpRequest`,
|
|
`node:http`, or `node:https`
|
|
- **THEN** the count is zero (all calls are routed through `client.fetchJson`,
|
|
`client.fetchBinary`, `client.fetchStream`, `client.fetchMultipartJson`,
|
|
`client.fetchMultipartBinary`, or `client.openSocket`)
|
|
|
|
**Verified by:** `tests/dep-graph.test.ts` (asserts `@suaveplan/third-party-elevenlabs`
|
|
is the sole transport dep)
|
|
|
|
---
|
|
|
|
### Requirement: Error remapping with cause preserved
|
|
|
|
Every `ElevenLabsApiError` subclass raised by the transport SHALL be remapped
|
|
to a `VoiceError` subclass from `@suaveplan/voice`. The original error SHALL
|
|
be preserved via `error.cause`. Mapping table:
|
|
|
|
| Third-party code | Voice domain error | Notes |
|
|
| --- | --- | --- |
|
|
| `AUTH.UNAUTHORIZED`, `AUTH.FORBIDDEN` | `ProviderNotConfiguredError` | invalid / missing / under-scoped key |
|
|
| `REQUEST.NOT_FOUND` (from `voices.get`) | `null` (no throw) | repository null-on-not-found contract |
|
|
| `REQUEST.NOT_FOUND` (other endpoints) | `VoiceValidationError` | generic resource-missing |
|
|
| `REQUEST.VALIDATION_FAILED`, `REQUEST.INVALID` | `VoiceValidationError` | body preserved on cause |
|
|
| `RATE_LIMIT.EXCEEDED`, `RATE_LIMIT.QUOTA_EXHAUSTED` | `ProviderRateLimitedError` | `retryAfterMs` preserved |
|
|
| `SERVER.TRANSIENT` / `SERVER.UNAVAILABLE` (TTS) | `TtsSynthesisFailedError` | |
|
|
| `SERVER.TRANSIENT` / `SERVER.UNAVAILABLE` (STT) | `SttTranscriptionFailedError` | |
|
|
| `SERVER.TRANSIENT` (sound-generation) | `SoundEffectGenerationFailedError` | |
|
|
| `SERVER.TRANSIENT` (audio-isolation) | `VoiceIsolationFailedError` | |
|
|
| `STREAM.INTERRUPTED` | `TtsStreamInterruptedError` | |
|
|
| `RESPONSE.UNSUPPORTED_FORMAT` | `AudioFormatUnsupportedError` | |
|
|
| `NETWORK.*` (during streams) | `TtsStreamInterruptedError` | |
|
|
| `NETWORK.*` (other) | `ProviderNotConfiguredError` | timeout / unreachable |
|
|
|
|
#### Scenario: 404 on voices.get returns null
|
|
|
|
- **WHEN** the transport responds with HTTP 404 to `GET /v1/voices/{id}`
|
|
- **THEN** `repo.get(id)` resolves to `null` (does NOT throw)
|
|
|
|
#### Scenario: 401 on TTS surfaces as ProviderNotConfiguredError with cause
|
|
|
|
- **WHEN** the transport returns HTTP 401 from `POST /v1/text-to-speech/{id}`
|
|
- **THEN** `tts.synthesize` rejects with `ProviderNotConfiguredError`
|
|
- **AND** `error.cause` is the original `UnauthorizedError`
|
|
|
|
#### Scenario: 429 on TTS surfaces retryAfterMs in context
|
|
|
|
- **WHEN** the transport returns HTTP 429 with `Retry-After: 60` from a TTS call
|
|
- **THEN** the engine rejects with `ProviderRateLimitedError`
|
|
- **AND** `error.context.retryAfterMs === 60_000`
|
|
|
|
**Verified by:** `src/voices/voices.test.ts`, `src/tts/tts.test.ts`,
|
|
`src/coverage.test.ts` (the comprehensive cross-cutting suite)
|
|
|
|
---
|
|
|
|
### Requirement: STT supports all three audio source shapes
|
|
|
|
`TranscriptionEngine.transcribe` SHALL accept `audio: Uint8Array |
|
|
ReadableStream<Uint8Array> | string` (a URL) per the `@suaveplan/voice`
|
|
interface, threading the value through to `client.fetchMultipartJson` against
|
|
`POST /v1/speech-to-text`. The string variant is forwarded to the transport's
|
|
`MultipartFile` URL form.
|
|
|
|
#### Scenario: Uint8Array audio accepted
|
|
|
|
- **WHEN** caller awaits `stt.transcribe({ audio: new Uint8Array([...]) })`
|
|
- **THEN** the request is sent and the parsed transcription returns
|
|
|
|
#### Scenario: ReadableStream audio accepted
|
|
|
|
- **WHEN** caller awaits `stt.transcribe({ audio: <readable stream> })`
|
|
- **THEN** the request body contains the buffered stream bytes and the
|
|
transcription returns
|
|
|
|
#### Scenario: URL string audio accepted
|
|
|
|
- **WHEN** caller awaits `stt.transcribe({ audio: "https://example.com/x.wav" })`
|
|
- **THEN** the transport receives the URL string as the multipart file value
|
|
|
|
**Verified by:** `src/stt/stt.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: TTS streaming returns a ReadableStream synchronously
|
|
|
|
`VoiceSynthesizer.synthesizeStream(request)` SHALL return a
|
|
`ReadableStream<Uint8Array>` synchronously (not a `Promise`). The HTTP
|
|
handshake SHALL be performed inside the stream's `start()` callback so callers
|
|
can immediately wire the stream into `pipeTo` / `pipeThrough`.
|
|
|
|
#### Scenario: synthesizeStream returns ReadableStream synchronously
|
|
|
|
- **WHEN** caller invokes `synth.synthesizeStream({ text, voiceId })`
|
|
- **THEN** the return value is a `ReadableStream<Uint8Array>` (not a `Promise`)
|
|
|
|
**Verified by:** `src/tts/tts.test.ts::"synthesizeStream returns a ReadableStream"`
|
|
(et al.)
|
|
|
|
---
|
|
|
|
### Requirement: AudioFormat round-trips through the adapter
|
|
|
|
Every `@suaveplan/voice` `AudioFormat` value SHALL round-trip through the
|
|
adapter: a request with `outputFormat: <fmt>` SHALL produce a response whose
|
|
`format` equals `<fmt>` for every documented format. ElevenLabs-native
|
|
`output_format` strings map 1:1 to `@suaveplan/voice` `AudioFormat` strings.
|
|
|
|
#### Scenario: mp3_44100_128 round-trips
|
|
|
|
- **WHEN** `tts.synthesize({ outputFormat: "mp3_44100_128", ... })` resolves
|
|
- **THEN** `result.format === "mp3_44100_128"`
|
|
|
|
**Verified by:** `src/tts/tts.test.ts` (per-format coverage),
|
|
`src/coverage.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: VoiceCategory / gender / age / accent / languageCodes mapping
|
|
|
|
When converting an ElevenLabs voice DTO into the domain `Voice` shape, the
|
|
adapter SHALL:
|
|
|
|
- pass `category: 'premade' | 'cloned' | 'generated' | 'professional'` straight
|
|
through (already compatible with the domain enum);
|
|
- read `gender`, `age`, `accent` from `labels.gender`, `labels.age`,
|
|
`labels.accent`; unrecognised label values fall back into `metadata`;
|
|
- aggregate `languageCodes` from `verified_languages` (BCP-47) and
|
|
`labels.language`, de-duplicate, and normalise.
|
|
|
|
#### Scenario: full voice maps every recognised label
|
|
|
|
- **WHEN** the transport returns a voice with non-empty labels and
|
|
`verified_languages`
|
|
- **THEN** the resulting `Voice` carries `category`, `gender`, `age`, `accent`,
|
|
and a de-duplicated `languageCodes` array
|
|
|
|
**Verified by:** `src/voices/voices.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: createElevenLabsVoice composite factory
|
|
|
|
The package SHALL export `createElevenLabsVoice(options): ElevenLabsVoiceBundle`.
|
|
The `options` argument SHALL accept either `{ client: ElevenLabsClient }` (to
|
|
share a pre-constructed transport with sibling surfaces such as
|
|
`ElevenLabsModelCatalog` or `ElevenLabsSpeechToSpeech`) or any
|
|
`ElevenLabsClientOptions` (in which case the factory constructs a fresh client).
|
|
When both `client` and other transport fields are present, `client` wins and
|
|
the other fields are ignored. The return shape SHALL be exactly
|
|
`{ client, tts, stt, voices, effects, isolation }`, with every engine wired to
|
|
the same `client` instance.
|
|
|
|
#### Scenario: factory constructs fresh client from options
|
|
|
|
- **WHEN** caller invokes `createElevenLabsVoice({ apiKey: "k", baseUrl: "..." })`
|
|
- **THEN** `bundle.client` is a new `ElevenLabsClient`
|
|
- **AND** every engine on the bundle shares that client
|
|
|
|
#### Scenario: factory reuses pre-constructed client
|
|
|
|
- **WHEN** caller invokes `createElevenLabsVoice({ client })`
|
|
- **THEN** `bundle.client === client` (referential identity)
|
|
- **AND** every engine on the bundle is wired to that client
|
|
|
|
**Verified by:** `src/factory/create-elevenlabs-voice.test.ts::"propagates client options to the underlying transport"`,
|
|
`src/factory/create-elevenlabs-voice.test.ts::"reuses an externally constructed client when provided"`
|
|
|
|
---
|
|
|
|
### Requirement: Provider-specific ElevenLabs surfaces ship alongside the voice engines
|
|
|
|
In addition to the five voice engines, the package SHALL export
|
|
`ElevenLabsModelCatalog` (the `GET /v1/models` reader) and
|
|
`ElevenLabsSpeechToSpeech` (the voice-conversion `POST /v1/speech-to-speech/...`
|
|
surfaces). Both classes consume `ElevenLabsClient` and produce `VOICE.*`
|
|
errors per the same remapping table. They live in `voice-elevenlabs` rather
|
|
than `third-party-elevenlabs` because their error vocabulary is voice-domain.
|
|
|
|
#### Scenario: ElevenLabsModelCatalog auth failure maps to ProviderNotConfiguredError
|
|
|
|
- **WHEN** the transport returns HTTP 401 to `GET /v1/models`
|
|
- **THEN** `catalog.list()` rejects with `ProviderNotConfiguredError`
|
|
|
|
#### Scenario: ElevenLabsSpeechToSpeech transient failure maps to TtsSynthesisFailedError
|
|
|
|
- **WHEN** the transport returns HTTP 503 from a speech-to-speech call
|
|
- **THEN** the call rejects with `TtsSynthesisFailedError`
|
|
|
|
**Verified by:** `src/models/models.test.ts`,
|
|
`src/speech-to-speech/speech-to-speech.test.ts`, `src/coverage.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: Universal-tier dependency surface
|
|
|
|
The package's runtime `dependencies` SHALL be exactly
|
|
`@suaveplan/error`, `@suaveplan/third-party-elevenlabs`, `@suaveplan/types`,
|
|
`@suaveplan/voice`, `tslib`, `zod` (in that sorted order). No additional
|
|
`@suaveplan/*` package SHALL appear. The package SHALL be browser-safe — no
|
|
imports from `node:*`, no `process`, no `Buffer`.
|
|
|
|
#### Scenario: dep-graph asserts exact runtime set
|
|
|
|
- **WHEN** `tests/dep-graph.test.ts` runs
|
|
- **THEN** `Object.keys(pkg.dependencies)` (sorted) equals exactly the six names above
|
|
|
|
#### Scenario: tree-shake asserts subpath exports
|
|
|
|
- **WHEN** `tests/tree-shake.test.ts` runs
|
|
- **THEN** every advertised subpath (`./tts/tts`, `./stt/stt`, `./voices/voices`,
|
|
`./sound-effects/sound-effects`, `./voice-isolation/voice-isolation`,
|
|
`./factory/create-elevenlabs-voice`, `./models/models`,
|
|
`./speech-to-speech/speech-to-speech`) is present in the `exports` field
|
|
|
|
**Verified by:** `tests/dep-graph.test.ts`, `tests/tree-shake.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: 100% statement, branch, function, and line coverage
|
|
|
|
The package SHALL achieve 100% Istanbul coverage on every `.ts` under `src/`
|
|
excluding `.test.ts`, `.spec.ts`, `.types.ts`, `.stories.tsx`, and barrels.
|
|
Coverage gate failure SHALL block publish.
|
|
|
|
#### Scenario: coverage gate
|
|
|
|
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs` runs
|
|
- **THEN** statements / branches / functions / lines are each `100`
|
|
|
|
**Verified by:** `bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs` (CI gate)
|