Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
# OpenSpec Change: voice-elevenlabs
- **Change ID**: voice-elevenlabs
- **Package**: `@suaveplan/voice-elevenlabs`
- **Category**: universal
- **Target path**: `packages/audio/voice-elevenlabs`
- **Status**: PROPOSED (rewritten 2026-05-02 — supersedes the 2026-04-24 fused-HTTP design)
- **Date**: 2026-05-02
- **Depends on**:
- `openspec/changes/voice/` (abstract core interfaces)
- `openspec/changes/third-party-elevenlabs/` (typed thin client; Wave 1 voice surface)
- **Reference spec**: none — new capability (genesis-original)
---
## Summary
Deliver `@suaveplan/voice-elevenlabs` on the genesis branch as a **thin domain adapter** that implements every `@suaveplan/voice` engine interface (`VoiceSynthesizer`, `TranscriptionEngine`, `VoiceRepository`, `SoundEffectEngine`, `VoiceIsolationEngine`) by delegating to `@suaveplan/third-party-elevenlabs`. It owns no HTTP, no auth, no retry, no rate-limit handling, no OTel transport spans, and no Zod schemas for ElevenLabs DTOs — those all live in the third-party package. Its sole responsibilities are:
1. **DTO ↔ domain mapping.** ElevenLabs-shaped DTOs from `@suaveplan/third-party-elevenlabs` become `@suaveplan/voice` types (`Voice`, `SynthesisResult`, `TranscriptionResult`, `SoundEffectResult`, `IsolationResult`).
2. **Error remapping.** `ElevenLabsApiError` subclasses are remapped to `VoiceError` codes per the voice-domain semantics (e.g. `THIRD_PARTY_ELEVENLABS.REQUEST.NOT_FOUND` from `voices.get(id)` → `VOICE.VOICE.NOT_FOUND`).
3. **Multipart and stream plumbing for the voice surface.** STT audio sources (`Uint8Array | ReadableStream | string`) are converted to the `MultipartFile` envelope expected by `@suaveplan/third-party-elevenlabs`; streaming TTS bodies pass through unchanged.
4. **Composite factory.** `createElevenLabsVoice(options)` returns the five engines plus the underlying `ElevenLabsClient` for callers who need to escape to lower levels.
---
## Why this design supersedes the 2026-04-24 proposal
The original proposal fused HTTP plumbing into the adapter. That design failed three forward-looking constraints:
1. **ElevenLabs surfaces non-voice domains.** Music, Dubbing, Studio, Audio Native, Conversational AI, Pronunciation Dictionaries, History, Workspace administration. A voice-only adapter cannot host those.
2. **Every future ElevenLabs adapter would re-implement the same `fetch` wrapper, retry policy, rate-limit parsing, and OTel instrumentation.** That is duplication on a scale we are not willing to accept.
3. **The 2026-04-24 implementation diverged from its own spec on multiple load-bearing points.** No `@suaveplan/retry` integration despite the dep being declared. No `X-RateLimit-Remaining` parsing. 5xx incorrectly mapped to `ProviderNotConfiguredError`. No OTel spans/counters/histograms. The fused design encouraged these omissions because the package was implementing too much at once.
The rewrite splits responsibilities: `@suaveplan/third-party-elevenlabs` owns transport correctness; `@suaveplan/voice-elevenlabs` owns domain mapping. Both are testable independently.
---
## Out of scope
- Any HTTP, auth, retry, OTel transport spans, or rate-limit logic. Those live in `@suaveplan/third-party-elevenlabs`.
- Voice cloning. ElevenLabs clone APIs (instant-clone, professional-clone, voice-design) ship in Wave 2 of the third-party client (`third-party-elevenlabs-voice-extensions`); the corresponding voice-domain adapter will land via a follow-up change once `@suaveplan/voice` adds a `VoiceCloner` interface.
- Conversational AI. Ships under a separate package `@suaveplan/conversational-elevenlabs` against a future `@suaveplan/conversational` core.
- Music, Dubbing, Studio, Audio Native. Each gets its own domain core + provider adapter pair when the domain is in scope.
- Speech-to-speech (voice changer). The third-party client exposes it in Wave 1, but `@suaveplan/voice` does not yet have a `SpeechConverter` interface. When it does, this adapter gains a `convert()` method.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/voice` (`workspace:^`) | Abstract interfaces, types, schemas, `VoiceError` codes |
| `@suaveplan/third-party-elevenlabs` (`workspace:^`) | Typed REST/WebSocket client, ElevenLabs DTOs, transport-level errors |
| `@suaveplan/error` (`workspace:^`) | `SuaveplanError` base + cause-chain preservation when remapping |
| `@suaveplan/types` (`workspace:^`) | `Clock`, `LoggerLike` |
| `@suaveplan/streams` (`workspace:^`) | Stream helpers (re-exported from third-party for convenience-free access) |
| `zod` (`catalog:forms`) | Mapping-result validation in tests |
| `tslib` (`catalog:build`) | Runtime helpers |
Dev deps: `@suaveplan/testing` (`workspace:^`, dev-only), `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
`@opentelemetry/api` is **not** a direct dep of this package. Transport-level OTel instrumentation lives in `@suaveplan/third-party-elevenlabs`. The adapter does not emit its own spans because it adds no I/O on top of the client; if a future scenario justifies adapter-level spans, they will be added under a follow-up change with explicit motivation.
---
## Public API surface
### Engine implementations
```ts
class ElevenLabsTtsEngine implements VoiceSynthesizer {
readonly providerName: 'elevenlabs'
constructor(deps: { tts: ElevenLabsTts }, options?: ElevenLabsTtsAdapterOptions)
synthesize(request: SynthesisRequest): Promise<SynthesisResult>
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>
}
class ElevenLabsSttEngine implements TranscriptionEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { stt: ElevenLabsStt }, options?: ElevenLabsSttAdapterOptions)
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>
}
class ElevenLabsVoiceRepository implements VoiceRepository {
readonly providerName: 'elevenlabs'
constructor(deps: { voices: ElevenLabsVoices })
list(options?: ListVoicesOptions): Promise<VoiceList>
get(voiceId: string): Promise<Voice | null>
}
class ElevenLabsSoundEffectEngine implements SoundEffectEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { soundGeneration: ElevenLabsSoundGeneration }, options?: ElevenLabsSoundEffectAdapterOptions)
generate(request: SoundEffectRequest): Promise<SoundEffectResult>
}
class ElevenLabsVoiceIsolationEngine implements VoiceIsolationEngine {
readonly providerName: 'elevenlabs'
constructor(deps: { audioIsolation: ElevenLabsAudioIsolation })
isolate(request: IsolationRequest): Promise<IsolationResult>
}
```
`ElevenLabsTtsAdapterOptions` carries voice-specific defaults (e.g. `defaultModelId: 'eleven_multilingual_v2'`, `defaultVoiceSettings: VoiceSettings`). The adapter never owns the API key or the base URL — those belong to the `ElevenLabsClient` injected upstream.
### Composite factory
```ts
function createElevenLabsVoice(options: CreateElevenLabsVoiceOptions): {
client: ElevenLabsClient
tts: ElevenLabsTtsEngine
stt: ElevenLabsSttEngine
voices: ElevenLabsVoiceRepository
effects: ElevenLabsSoundEffectEngine
isolation: ElevenLabsVoiceIsolationEngine
}
interface CreateElevenLabsVoiceOptions {
// Either an existing client...
client?: ElevenLabsClient
// ...or the inputs needed to construct one (forwarded to ElevenLabsClient unchanged)
apiKey?: string
baseUrl?: string
timeoutMs?: number
retryPolicy?: RetryPolicy
fetchImpl?: typeof fetch
webSocketImpl?: WebSocketImpl
clock?: Clock
logger?: LoggerLike
events?: EventBusLike<ElevenLabsClientEvents>
userAgent?: string
defaultHeaders?: Record<string, string>
// Adapter-specific defaults
defaultModelId?: string
defaultVoiceSettings?: VoiceSettings
defaultSoundEffectModelId?: string
}
```
If `client` is provided, `apiKey` and the other client options are ignored; otherwise the factory constructs a fresh `ElevenLabsClient`. Either way, the adapter classes share the same client instance — so retry, rate-limit accounting, and OTel are unified across engines.
---
## ElevenLabs API mapping
| Voice interface | Third-party module | Endpoint |
|---|---|---|
| `VoiceSynthesizer.synthesize` | `tts.synthesize(voiceId, body)` | `POST /v1/text-to-speech/{id}` |
| `VoiceSynthesizer.synthesizeStream` | `tts.synthesizeStream(voiceId, body)` | `POST /v1/text-to-speech/{id}/stream` |
| `TranscriptionEngine.transcribe` | `stt.transcribe(body)` | `POST /v1/speech-to-text` (multipart) |
| `VoiceRepository.list` | `voices.list(params)` | `GET /v1/voices` |
| `VoiceRepository.get` | `voices.get(voiceId)` | `GET /v1/voices/{id}` |
| `SoundEffectEngine.generate` | `soundGeneration.generate(body)` | `POST /v1/sound-generation` |
| `VoiceIsolationEngine.isolate` | `audioIsolation.isolate(body)` | `POST /v1/audio-isolation` |
---
## Error remapping table
The adapter remaps every `ElevenLabsApiError` subclass to a `VoiceError` code and preserves the original via `cause`:
| Third-party code | Voice domain code | Notes |
|---|---|---|
| `AUTH.UNAUTHORIZED` | `VOICE.PROVIDER.NOT_CONFIGURED` | The API key is invalid or missing |
| `AUTH.FORBIDDEN` | `VOICE.PROVIDER.NOT_CONFIGURED` | Key valid but lacks scope |
| `REQUEST.NOT_FOUND` (from `voices.get`) | `VOICE.VOICE.NOT_FOUND` | With `context.voiceId` populated |
| `REQUEST.NOT_FOUND` (other endpoints) | `VOICE.VALIDATION.INVALID_REQUEST` | Generic resource-missing |
| `REQUEST.VALIDATION_FAILED` | `VOICE.VALIDATION.INVALID_REQUEST` | Body preserved on `cause.context` |
| `REQUEST.INVALID` | `VOICE.VALIDATION.INVALID_REQUEST` | |
| `RATE_LIMIT.EXCEEDED` | `VOICE.PROVIDER.RATE_LIMITED` | `retryAfterMs` preserved |
| `RATE_LIMIT.QUOTA_EXHAUSTED` | `VOICE.PROVIDER.RATE_LIMITED` | |
| `SERVER.TRANSIENT` (TTS endpoints) | `VOICE.TTS.SYNTHESIS_FAILED` | |
| `SERVER.TRANSIENT` (STT endpoints) | `VOICE.STT.TRANSCRIPTION_FAILED` | |
| `SERVER.TRANSIENT` (sound-generation) | `VOICE.EFFECTS.GENERATION_FAILED` | |
| `SERVER.TRANSIENT` (audio-isolation) | `VOICE.ISOLATION.FAILED` | |
| `SERVER.UNAVAILABLE` | Same as `SERVER.TRANSIENT` per endpoint | |
| `STREAM.INTERRUPTED` | `VOICE.TTS.STREAM_INTERRUPTED` | |
| `RESPONSE.MALFORMED` | `VOICE.PROVIDER.NOT_CONFIGURED` | Indicates an ElevenLabs API contract change |
| `RESPONSE.UNSUPPORTED_FORMAT` | `VOICE.AUDIO.FORMAT_UNSUPPORTED` | |
| `NETWORK.*` | `VOICE.PROVIDER.NOT_CONFIGURED` (timeout/unreachable) or `VOICE.TTS.STREAM_INTERRUPTED` (during streams) | |
Every remapped error preserves the original via `cause` so callers can inspect the underlying transport detail when needed.
---
## DTO mapping decisions
1. **`VoiceCategory` mapping.** ElevenLabs returns `category: 'premade' | 'cloned' | 'generated' | 'professional'` directly compatible with `@suaveplan/voice` enum — no remapping needed.
2. **`Voice.languageCodes`.** Aggregated from `verified_languages` (BCP-47) and `labels.language` if present. De-duplicated and ISO-639-normalised.
3. **`Voice.gender` / `Voice.age` / `Voice.accent`.** Read from `labels.gender`, `labels.age`, `labels.accent`. Unrecognised values fall back to `metadata`.
4. **`SynthesisResult.charactersUsed`.** Read from response `metadata.characterCost` (third-party client surfaces `X-Character-Cost` header).
5. **`TranscriptionResult.words`.** Mapped from ElevenLabs `words[]` array, dropping non-word entries (`type !== 'word'` enters `entities` instead).
6. **`TranscriptionResult.speakers`.** Derived by collapsing consecutive `words[]` entries with the same `speaker_id` into `{startMs, endMs, speakerId}` segments.
7. **`AudioFormat` round-trip.** All `@suaveplan/voice` `AudioFormat` strings (`mp3_22050_32`, `pcm_16000`, etc.) map to ElevenLabs `output_format` values 1:1 — verified by property test.
---
## Parity Checklist
Genesis-original package — the prior `packages/audio/voice-elevenlabs` implementation (2026-05-01) is being **deleted and rewritten**, not migrated, because the architecture changes substantially. The deleted code is superseded by the splits described in `## Why this design supersedes the 2026-04-24 proposal`. No upstream `@suaveplan/voice-elevenlabs` exists in suaveplan main.
| Capability | Prior fused build | This adapter | Notes |
|---|---|---|---|
| TTS synthesize | ✅ partial | ✅ | Now via `tts.synthesize` |
| TTS streaming | ✅ partial | ✅ | Now via `tts.synthesizeStream` |
| STT transcribe | ✅ partial | ✅ | Now via `stt.transcribe` |
| Voice list/get | ✅ partial | ✅ | Now via `voices.list` / `voices.get` |
| Sound generation | ✅ partial | ✅ | Now via `soundGeneration.generate` |
| Audio isolation | ✅ partial | ✅ | Now via `audioIsolation.isolate` |
| Retry / rate-limit | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| OTel | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| `X-RateLimit-Remaining` | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| `X-Character-Cost` | 🚫 | ✅ | Was missing in prior build; now provided by third-party client |
| 5xx mapped to transient | 🚫 (incorrectly mapped to `PROVIDER.NOT_CONFIGURED`) | ✅ | Per error remapping table |
---
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/voice-elevenlabs` to enumerate. Currently no shipped genesis package consumes `@suaveplan/voice-elevenlabs` (the rebuild lands before any downstream takes a dep on it).
- Future `@suaveplan/api-plugin-voice` — [ ] tests run / [ ] docs reviewed / [x] no action needed (does not yet exist on genesis)
- Future `@suaveplan/react-voice` — [ ] tests run / [ ] docs reviewed / [x] no action needed (does not yet exist on genesis)
### Migration
The 2026-05-01 fused-HTTP build is deleted in this change. Any in-progress consumer code that imported from `@suaveplan/voice-elevenlabs` must:
- **Old:** `import { ElevenLabsClient } from '@suaveplan/voice-elevenlabs/client/...'`
- **New:** `import { ElevenLabsClient } from '@suaveplan/third-party-elevenlabs'`
- **Old:** `import { createElevenLabsVoice } from '@suaveplan/voice-elevenlabs/factory/factory'`
- **New:** Same path, same return shape — `createElevenLabsVoice(options)` is preserved with identical signature except that `apiKey` / `baseUrl` / `timeoutMs` are now forwarded to `ElevenLabsClient` rather than consumed locally, and the returned object now includes `client: ElevenLabsClient`.
Search command for downstream consumers:
```bash
grep -rln "@suaveplan/voice-elevenlabs/client" packages/ | grep -v "/dist/\|/coverage/\|/voice-elevenlabs/"
```
If any hits exist, update them to import from `@suaveplan/third-party-elevenlabs` before merging.

View File

@@ -0,0 +1,111 @@
# 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

View File

@@ -0,0 +1,154 @@
# Tasks: voice-elevenlabs
**Change ID**: voice-elevenlabs
**Package**: `@suaveplan/voice-elevenlabs`
**Target path**: `packages/audio/voice-elevenlabs`
**Prerequisite packages on Verdaccio**: `@suaveplan/voice`, `@suaveplan/third-party-elevenlabs`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/streams`
> Supersedes the 2026-04-24 fused-HTTP design. The prior `packages/audio/voice-elevenlabs` implementation MUST be deleted before this rebuild starts.
---
## 0. Pre-rebuild teardown
- [x] Confirm `git status` shows the prior `packages/audio/voice-elevenlabs` content is committed or accounted for; if uncommitted scratch work exists, stash it explicitly with the user.
- [x] `rm -rf packages/audio/voice-elevenlabs` — delete the entire prior implementation.
- [x] Confirm no other package imports from `@suaveplan/voice-elevenlabs` (`grep -rln '@suaveplan/voice-elevenlabs' packages/`); the only references should be inside the to-be-deleted folder itself.
---
## 1. Scaffold
- [x] Create `packages/audio/voice-elevenlabs/` with `src/`, `tests/`, `src/__benchmarks__/`.
- [x] Write `package.json` at version `0.1.0`, `"type": "module"`, `"sideEffects": false`, full subpath `exports` map per feature module, deps `@suaveplan/voice`, `@suaveplan/third-party-elevenlabs`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/streams`, `zod`, `tslib` (catalogs only).
- [x] Write `tsconfig.lib.json` extending `../../../tsconfig.base.json`.
- [x] Write `vite.config.ts` with multi-entry build for every feature module + factory.
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`, coverage thresholds 100/100/100/100.
- [x] Add `lint`, `lint:fix`, `format`, `check`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Error remapping module
- [x] `src/errors/remap.ts` — `mapElevenLabsError(err: unknown, context: { operation: VoiceOperation; voiceId?: string }): VoiceError` covering every `ElevenLabsApiError` subclass per the proposal §Error remapping table. Preserves the original error via `cause`.
- [x] `src/errors/remap.schemas.ts` — Zod for `VoiceOperation = 'tts' | 'tts-stream' | 'stt' | 'voices' | 'sound-generation' | 'audio-isolation'`.
- [x] `src/errors/remap.md`
- [x] `src/errors/remap.test.ts` — every `ElevenLabsApiError` subclass produces the expected `VoiceError` code, preserves `cause`, and threads through `context.voiceId` where the table specifies.
---
## 3. Engine implementations
### 3.1 `tts`
- [x] `src/tts/tts.ts` — `ElevenLabsTtsEngine` implements `VoiceSynthesizer`.
- `synthesize(req)`: maps `SynthesisRequest` → ElevenLabs TTS body via `mapSynthesisRequest()`; calls `tts.synthesize(req.voiceId, body)`; maps `BinaryResponse` → `SynthesisResult` (audio bytes, format echoed from request, `charactersUsed` from `metadata.characterCost`).
- `synthesizeStream(req)`: maps request, calls `tts.synthesizeStream(req.voiceId, body)`, returns `response.body` directly.
- All errors flow through `mapElevenLabsError`.
- [x] `src/tts/tts.schemas.ts` — Zod for `ElevenLabsTtsAdapterOptions`, `mapSynthesisRequest` input/output.
- [x] `src/tts/tts.md`
- [x] `src/tts/tts.test.ts` — happy-path synthesize, happy-path stream, voice-not-found, validation error, rate-limited with `retryAfterMs`, abort, `cause` chain preserved.
### 3.2 `stt`
- [x] `src/stt/stt.ts` — `ElevenLabsSttEngine` implements `TranscriptionEngine`.
- `transcribe(req)`: maps `TranscriptionRequest` → ElevenLabs STT multipart body (audio: `Uint8Array | ReadableStream | string` passed through to third-party `MultipartFile`); calls `stt.transcribe(body)`; maps response to `TranscriptionResult` (words, derived speakers, entities split from word-stream).
- [x] `src/stt/stt.schemas.ts`
- [x] `src/stt/stt.md`
- [x] `src/stt/stt.test.ts` — transcribe from bytes / stream / URL, with diarization, with entity detection, error remapping.
### 3.3 `voices`
- [x] `src/voices/voices.ts` — `ElevenLabsVoiceRepository` implements `VoiceRepository`.
- `list(opts)`: calls `voices.list({ ...opts })`; maps each ElevenLabs voice DTO → `Voice`; `nextPageToken`/`hasMore` derived from response.
- `get(id)`: calls `voices.get(id)`; remaps `THIRD_PARTY_ELEVENLABS.REQUEST.NOT_FOUND` → `null` (per `VoiceRepository.get` contract); other errors flow through `mapElevenLabsError` with `voiceId` context.
- [x] `src/voices/voices.schemas.ts`
- [x] `src/voices/voices.md`
- [x] `src/voices/voices.test.ts`
### 3.4 `sound-effects`
- [x] `src/sound-effects/sound-effects.ts` — `ElevenLabsSoundEffectEngine` implements `SoundEffectEngine`.
- `generate(req)`: maps `SoundEffectRequest` → ElevenLabs sound-generation body; calls `soundGeneration.generate(body)`; maps response to `SoundEffectResult`.
- [x] `src/sound-effects/sound-effects.schemas.ts`
- [x] `src/sound-effects/sound-effects.md`
- [x] `src/sound-effects/sound-effects.test.ts`
### 3.5 `voice-isolation`
- [x] `src/voice-isolation/voice-isolation.ts` — `ElevenLabsVoiceIsolationEngine` implements `VoiceIsolationEngine`.
- `isolate(req)`: maps `IsolationRequest` → ElevenLabs isolation multipart body; calls `audioIsolation.isolate(body)`; maps response to `IsolationResult`.
- [x] `src/voice-isolation/voice-isolation.schemas.ts`
- [x] `src/voice-isolation/voice-isolation.md`
- [x] `src/voice-isolation/voice-isolation.test.ts`
---
## 4. Composite factory
- [x] `src/factory/factory.ts` — `createElevenLabsVoice(options)` returns `{ client, tts, stt, voices, effects, isolation }`.
- Accepts either an existing `ElevenLabsClient` or `ElevenLabsClientOptions` to construct one fresh.
- Wires every engine to share the same client instance.
- [x] `src/factory/factory.schemas.ts` — Zod for `CreateElevenLabsVoiceOptions`.
- [x] `src/factory/factory.md`
- [x] `src/factory/factory.test.ts` — factory returns five engines + client; engines share client; both option shapes (existing client vs construct fresh) work.
---
## 5. Index
- [x] `src/index.ts` — barrel re-export of all five engines + factory + adapter-options types + `mapElevenLabsError` (for advanced callers).
---
## 6. Property-based tests (`tests/property.test.ts`)
- [x] `AudioFormat` round-trip — every `@suaveplan/voice` `AudioFormat` value passes through to ElevenLabs `output_format` and back without modification.
- [x] `SynthesisRequest` round-trip — arbitrary valid request maps to ElevenLabs body and remaps to equivalent `SynthesisResult` shape (subject to documented field mappings).
- [x] Error remapping totality — every `ElevenLabsApiError` subclass produces a `VoiceError` for every documented operation; no unmapped paths.
---
## 7. Benchmarks (`src/__benchmarks__/voice-elevenlabs.bench.ts`)
- [x] `ElevenLabsTtsEngine.synthesize` round-trip with mocked `fetchImpl` (no network).
- [x] `ElevenLabsSttEngine.transcribe` round-trip with mocked `fetchImpl`.
---
## 8. Pre-publish gate
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/voice-elevenlabs` — green.
- [x] `bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs` — 100/100/100/100.
- [x] `bun run docs:check --filter=@suaveplan/voice-elevenlabs` — ≥ 90%.
- [x] Banned-pattern scan in `src/` — zero hits.
- [x] `README.md` exists at package root.
- [x] Subpath exports count matches feature module count.
- [x] No bare versions in `package.json`.
- [x] `bunx turbo test --filter=...@suaveplan/voice-elevenlabs` — dependents pass (currently none).
---
## 9. Archive
- [x] Fold spec deltas into `openspec/specs/voice-elevenlabs/spec.md`.
- [x] Move `openspec/changes/voice-elevenlabs/` → `openspec/archive/2026-05-02-voice-elevenlabs/`.
- [x] Publish to Verdaccio: `bun scripts/publish.ts --package @suaveplan/voice-elevenlabs`.
- [x] Update `STATUS.md`.
---
## Audit Note (2026-05-02)
**Status**: DEFERRED.
State at audit:
- The package directory `packages/audio/voice-elevenlabs/` does not exist on genesis. The teardown step (Section 0) is a no-op for the audit.
- Upstream `@suaveplan/voice` is shipped (103 tests / 100% coverage / archived 2026-05-02).
- Upstream `@suaveplan/third-party-elevenlabs` is **also deferred** (typecheck green after this audit's edits, but missing `src/index.ts`, factory implementation, property tests, benchmarks, README) — `voice-elevenlabs` cannot ship until that prerequisite lands.
Resuming work:
1. Finish `third-party-elevenlabs` (see its audit note) so the third-party client surface is stable.
2. Then execute Sections 1–9 of this tasks file to scaffold `packages/audio/voice-elevenlabs/` and wire the five engines + factory.