Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
# @suaveplan/voice-elevenlabs - ElevenLabs domain adapter for @suaveplan/voice: TTS, STT, voice repository, sound effects, and voice isolation engines implemented by delegating to @suaveplan/third-party-elevenlabs.
ElevenLabs domain adapter for the `@suaveplan/voice` interface family.
This package implements every `@suaveplan/voice` engine contract —
`VoiceSynthesizer`, `TranscriptionEngine`, `VoiceRepository`,
`SoundEffectEngine`, `VoiceIsolationEngine` — 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.
<!-- begin:auto-docs -->
## Documentation tree
- [factory](./src/factory/create-elevenlabs-voice.md) — Composition root for the `@suaveplan/voice` engine adapters that target ElevenLabs. One call, one shared transport, fiv…
- [create-elevenlabs-voice](./src/factory/create-elevenlabs-voice.md) — Composition root for the `@suaveplan/voice` engine adapters that target ElevenLabs. One call, one shared transport, fiv…
- [models](./src/models/models.md) — ElevenLabs model catalog. Lists every model the API key has access to, along with capability flags (TTS / voice convers…
- [models](./src/models/models.md) — ElevenLabs model catalog. Lists every model the API key has access to, along with capability flags (TTS / voice convers…
- [sound-effects](./src/sound-effects/sound-effects.md) — ElevenLabs implementation of `@suaveplan/voice`'s `SoundEffectEngine` interface. Posts a JSON body to `POST /v1/sound-g…
- [sound-effects](./src/sound-effects/sound-effects.md) — ElevenLabs implementation of `@suaveplan/voice`'s `SoundEffectEngine` interface. Posts a JSON body to `POST /v1/sound-g…
- [speech-to-speech](./src/speech-to-speech/speech-to-speech.md) — ElevenLabs Speech-to-Speech (voice conversion) adapter. Given source audio and a target `voiceId`, produces audio that…
- [speech-to-speech](./src/speech-to-speech/speech-to-speech.md) — ElevenLabs Speech-to-Speech (voice conversion) adapter. Given source audio and a target `voiceId`, produces audio that…
- [stt](./src/stt/stt.md) — ElevenLabs implementation of `@suaveplan/voice`'s `TranscriptionEngine` interface. Posts the audio as multipart form-da…
- [stt](./src/stt/stt.md) — ElevenLabs implementation of `@suaveplan/voice`'s `TranscriptionEngine` interface. Posts the audio as multipart form-da…
- [tts](./src/tts/tts.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceSynthesizer` interface. Posts to `/v1/text-to-speech/{voice_id}…
- [tts](./src/tts/tts.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceSynthesizer` interface. Posts to `/v1/text-to-speech/{voice_id}…
- [voice-isolation](./src/voice-isolation/voice-isolation.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceIsolationEngine` interface. Posts the audio as multipart form-d…
- [voice-isolation](./src/voice-isolation/voice-isolation.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceIsolationEngine` interface. Posts the audio as multipart form-d…
- [voices](./src/voices/voices.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceRepository` interface. Lists voices via `GET /v1/voices` and lo…
- [voices](./src/voices/voices.md) — ElevenLabs implementation of `@suaveplan/voice`'s `VoiceRepository` interface. Lists voices via `GET /v1/voices` and lo…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/voice-elevenlabs @suaveplan/voice
```
## Quick start
```ts
import { createElevenLabsVoice } from "@suaveplan/voice-elevenlabs";
const { tts, voices } = createElevenLabsVoice({
apiKey: process.env.ELEVENLABS_KEY!,
});
const all = await voices.list();
const result = await tts.synthesize({
text: "hello",
voiceId: all.voices[0]!.id,
});
```
## Modules
| Subpath | Engine | `@suaveplan/voice` contract |
| --- | --- | --- |
| `./tts/tts` | `ElevenLabsVoiceSynthesizer` | `VoiceSynthesizer` |
| `./stt/stt` | `ElevenLabsTranscriptionEngine` | `TranscriptionEngine` |
| `./voices/voices` | `ElevenLabsVoiceRepository` | `VoiceRepository` |
| `./sound-effects/sound-effects` | `ElevenLabsSoundEffectEngine` | `SoundEffectEngine` |
| `./voice-isolation/voice-isolation` | `ElevenLabsVoiceIsolationEngine` | `VoiceIsolationEngine` |
| `./factory/create-elevenlabs-voice` | `createElevenLabsVoice` | bundle factory |
## Composing with other ElevenLabs surfaces
The factory accepts a pre-constructed `ElevenLabsClient` from
`@suaveplan/third-party-elevenlabs` so the same transport (with its
retry, rate-limit, OTel, base URL, and event-bus configuration) can be
shared with ElevenLabs-specific surfaces that are *not* part of the
voice domain — for example the model catalogue or the
speech-to-speech (voice-conversion) endpoint:
```ts
import {
ElevenLabsClient,
ElevenLabsModelCatalog,
ElevenLabsSpeechToSpeech,
} from "@suaveplan/third-party-elevenlabs";
import { createElevenLabsVoice } from "@suaveplan/voice-elevenlabs";
const client = new ElevenLabsClient({
apiKey: process.env.ELEVENLABS_KEY!,
baseUrl: "https://eu.api.elevenlabs.io",
});
const voice = createElevenLabsVoice({ client });
const models = new ElevenLabsModelCatalog(client);
const s2s = new ElevenLabsSpeechToSpeech(client);
```
## Error remapping
Every `ElevenLabsApiError` subclass raised by the underlying transport
is remapped into a `VoiceError` from `@suaveplan/voice`, preserving the
original via `.cause`. The full table is documented inline at the call
sites; a representative subset:
| Third-party error | Voice domain error |
| --- | --- |
| `UnauthorizedError`, `ForbiddenError` | `ProviderNotConfiguredError` |
| `RequestNotFoundError` (from `voices.get`) | resolves to `null` (no throw) |
| `RequestValidationFailedError`, `RequestInvalidError` | `VoiceValidationError` |
| `RateLimitExceededError`, `RateLimitQuotaExhaustedError` | `ProviderRateLimitedError` (with `retryAfterMs`) |
| `ServerTransientError` / `ServerUnavailableError` (TTS) | `TtsSynthesisFailedError` |
| `ServerTransientError` (STT) | `SttTranscriptionFailedError` |
| `StreamInterruptedError` | `TtsStreamInterruptedError` |
| `ResponseUnsupportedFormatError` | `AudioFormatUnsupportedError` |
`voices.get(voiceId)` follows the `VoiceRepository` null-on-not-found
contract: any upstream 404 is mapped to `null` rather than thrown.
## Architecture
```
@suaveplan/voice abstract engine interfaces
▲
│ implements
│
@suaveplan/voice-elevenlabs domain adapter (this package)
│
│ delegates HTTP / WebSocket / multipart
▼
@suaveplan/third-party-elevenlabs typed transport, retry, OTel,
rate-limit headers, raw DTOs
```
Future ElevenLabs surfaces (music, dubbing, conversational AI) compose
against the same `@suaveplan/third-party-elevenlabs` client; they do not
fan out from this package. This keeps each surface's domain-mapping
concerns isolated.
## Verification
```sh
bunx turbo lint typecheck test --filter=@suaveplan/voice-elevenlabs
bunx turbo test:coverage --filter=@suaveplan/voice-elevenlabs
```
Coverage gate: 100/100/100/100 statements/branches/functions/lines.

View File

@@ -0,0 +1,144 @@
{
"name": "@suaveplan/voice-elevenlabs",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "ElevenLabs domain adapter for @suaveplan/voice: TTS, STT, voice repository, sound effects, and voice isolation engines implemented by delegating to @suaveplan/third-party-elevenlabs.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./tts/tts": {
"import": {
"types": "./dist/tts/tts.d.ts",
"default": "./dist/tts/tts.js"
},
"require": {
"types": "./dist/tts/tts.d.ts",
"default": "./dist/tts/tts.js"
}
},
"./stt/stt": {
"import": {
"types": "./dist/stt/stt.d.ts",
"default": "./dist/stt/stt.js"
},
"require": {
"types": "./dist/stt/stt.d.ts",
"default": "./dist/stt/stt.js"
}
},
"./voices/voices": {
"import": {
"types": "./dist/voices/voices.d.ts",
"default": "./dist/voices/voices.js"
},
"require": {
"types": "./dist/voices/voices.d.ts",
"default": "./dist/voices/voices.js"
}
},
"./sound-effects/sound-effects": {
"import": {
"types": "./dist/sound-effects/sound-effects.d.ts",
"default": "./dist/sound-effects/sound-effects.js"
},
"require": {
"types": "./dist/sound-effects/sound-effects.d.ts",
"default": "./dist/sound-effects/sound-effects.js"
}
},
"./voice-isolation/voice-isolation": {
"import": {
"types": "./dist/voice-isolation/voice-isolation.d.ts",
"default": "./dist/voice-isolation/voice-isolation.js"
},
"require": {
"types": "./dist/voice-isolation/voice-isolation.d.ts",
"default": "./dist/voice-isolation/voice-isolation.js"
}
},
"./factory/create-elevenlabs-voice": {
"import": {
"types": "./dist/factory/create-elevenlabs-voice.d.ts",
"default": "./dist/factory/create-elevenlabs-voice.js"
},
"require": {
"types": "./dist/factory/create-elevenlabs-voice.d.ts",
"default": "./dist/factory/create-elevenlabs-voice.js"
}
},
"./models/models": {
"import": {
"types": "./dist/models/models.d.ts",
"default": "./dist/models/models.js"
},
"require": {
"types": "./dist/models/models.d.ts",
"default": "./dist/models/models.js"
}
},
"./speech-to-speech/speech-to-speech": {
"import": {
"types": "./dist/speech-to-speech/speech-to-speech.d.ts",
"default": "./dist/speech-to-speech/speech-to-speech.js"
},
"require": {
"types": "./dist/speech-to-speech/speech-to-speech.d.ts",
"default": "./dist/speech-to-speech/speech-to-speech.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/third-party-elevenlabs": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/voice": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
# `factory/create-elevenlabs-voice`
Composition root for the `@suaveplan/voice` engine adapters that target
ElevenLabs. One call, one shared transport, five voice engines wired up.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/voice-elevenlabs` exposes five domain adapters (TTS, STT,
voices, sound-effects, voice-isolation). Each needs an
`ElevenLabsClient` from `@suaveplan/third-party-elevenlabs`. Without a
factory, every consumer would manually construct each adapter and
remember to share the same client between them — duplicating the same
boilerplate over and over and leaving room for subtle bugs (one adapter
using a different timeout, one created on every request and leaking
event listeners). `createElevenLabsVoice` makes the right thing easy:
either supply a pre-constructed client (so the same transport can be
shared with `ElevenLabsModelCatalog` or `ElevenLabsSpeechToSpeech` from
`@suaveplan/third-party-elevenlabs`), or pass the same
`ElevenLabsClientOptions` a fresh client would accept, and get all five
voice engines back as a typed bundle.
## Features
- Single function: `createElevenLabsVoice(options)`.
- Returns `ElevenLabsVoiceBundle` — `{ client, tts, stt, voices, effects,
isolation }`.
- Every engine shares the same `client`, which means they share the same
retry policy, timeout, event bus, logger, and observability context.
- The `client` field is exposed so consumers can issue ad-hoc requests
(e.g. account-info endpoints not covered by the engines) against the
same transport, or wire it into a sibling provider catalogue.
- `options.client` overrides every transport-construction field; pass a
client explicitly to compose with surfaces outside the voice domain.
## Usage: Basic Example
```ts
import { createElevenLabsVoice } from "@suaveplan/voice-elevenlabs";
const { tts, voices } = createElevenLabsVoice({
apiKey: process.env.ELEVENLABS_KEY!,
});
const all = await voices.list();
const result = await tts.synthesize({
text: "hello",
voiceId: all.voices[0]!.id,
});
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsModelCatalog,
} from "@suaveplan/third-party-elevenlabs";
import { createElevenLabsVoice } from "@suaveplan/voice-elevenlabs";
// One transport shared by both the voice engines AND the model catalogue.
const client = new ElevenLabsClient({
apiKey: process.env.ELEVENLABS_KEY!,
baseUrl: "https://eu.api.elevenlabs.io",
timeoutMs: 60_000,
maxRetries: 5,
initialRetryDelayMs: 250,
fetchImpl: instrumentedFetch,
webSocketImpl: WS, // 'ws' on Node
events: appEventBus,
logger: appLogger,
userAgent: "my-app/1.4.2",
defaultHeaders: { "x-tenant": "acme" },
});
const voice = createElevenLabsVoice({ client });
const models = new ElevenLabsModelCatalog(client);
const [voiceList, modelList] = await Promise.all([
voice.voices.list(),
models.list(),
]);
```
## API Reference
- `interface ElevenLabsVoiceBundle { client, tts, stt, voices, effects, isolation }`
- `type CreateElevenLabsVoiceOptions = { client: ElevenLabsClient } | (ElevenLabsClientOptions & { client?: undefined })`
- `function createElevenLabsVoice(options: CreateElevenLabsVoiceOptions): ElevenLabsVoiceBundle`
Each field is the corresponding `@suaveplan/voice` engine implementation
instantiated against the shared `client`.
## Implementation Notes
- When `options.client` is present it is reused as-is; every other field
on `options` is ignored. This is by design so consumers can wire their
own transport (e.g. an `ElevenLabsClient` already configured for an EU
shard, a private base URL, or a shared event bus) and the voice
adapters drop straight in.
- When `options.client` is absent the factory delegates to
`new ElevenLabsClient(options)` so every `ElevenLabsClientOptions`
field is honoured. Invalid options surface as the same construction
errors a direct `new ElevenLabsClient(opts)` would raise.
- The factory does not validate `apiKey` — passing an empty string
surfaces as a 401 / `VOICE.PROVIDER.NOT_CONFIGURED` on the first call
rather than a synchronous throw.
- The bundle is a plain object literal; consumers can destructure freely
(`const { tts } = createElevenLabsVoice(...)`) without losing the
shared client reference.
- Stateful adapters (none currently) would still be instantiated per
call. Call `createElevenLabsVoice` once per process / per long-lived
consumer, not once per request.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
isSoundEffectEngine,
isTranscriptionEngine,
isVoiceIsolationEngine,
isVoiceRepository,
isVoiceSynthesizer,
} from "@suaveplan/voice";
import { createElevenLabsVoice } from "./create-elevenlabs-voice.js";
describe("createElevenLabsVoice", () => {
const fetchImpl: typeof fetch = () =>
Promise.resolve(
new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
})
);
it("constructs every voice engine sharing one client", () => {
const bundle = createElevenLabsVoice({ apiKey: "k", fetchImpl });
expect(bundle.client).toBeInstanceOf(ElevenLabsClient);
expect(isVoiceSynthesizer(bundle.tts)).toBe(true);
expect(isTranscriptionEngine(bundle.stt)).toBe(true);
expect(isVoiceRepository(bundle.voices)).toBe(true);
expect(isVoiceIsolationEngine(bundle.isolation)).toBe(true);
expect(isSoundEffectEngine(bundle.effects)).toBe(true);
});
it("propagates client options to the underlying transport", async () => {
let observedUrl = "";
const tracingFetch: typeof fetch = (input) => {
observedUrl = String(input);
return Promise.resolve(
new Response('{"voices":[],"has_more":false}', {
status: 200,
headers: { "content-type": "application/json" },
})
);
};
const bundle = createElevenLabsVoice({
apiKey: "k",
fetchImpl: tracingFetch,
baseUrl: "https://eu.example.com",
});
await bundle.voices.list();
expect(observedUrl).toBe("https://eu.example.com/v1/voices");
});
it("reuses an externally constructed client when provided", async () => {
let observedUrl = "";
const tracingFetch: typeof fetch = (input) => {
observedUrl = String(input);
return Promise.resolve(
new Response('{"voices":[],"has_more":false}', {
status: 200,
headers: { "content-type": "application/json" },
})
);
};
const client = new ElevenLabsClient({
apiKey: "k",
fetchImpl: tracingFetch,
baseUrl: "https://shared.example.com",
});
const bundle = createElevenLabsVoice({ client });
expect(bundle.client).toBe(client);
await bundle.voices.list();
expect(observedUrl).toBe("https://shared.example.com/v1/voices");
});
});

View File

@@ -0,0 +1,57 @@
import {
ElevenLabsClient,
type ElevenLabsClientOptions,
} from "@suaveplan/third-party-elevenlabs";
import { ElevenLabsSoundEffectEngine } from "../sound-effects/sound-effects.js";
import { ElevenLabsTranscriptionEngine } from "../stt/stt.js";
import { ElevenLabsVoiceSynthesizer } from "../tts/tts.js";
import { ElevenLabsVoiceIsolationEngine } from "../voice-isolation/voice-isolation.js";
import { ElevenLabsVoiceRepository } from "../voices/voices.js";
/**
* Options accepted by {@link createElevenLabsVoice}. Callers may either supply
* a pre-constructed {@link ElevenLabsClient} (so the same transport can be
* shared with other provider surfaces such as
* `@suaveplan/third-party-elevenlabs`'s `ElevenLabsModelCatalog`) or pass the
* same {@link ElevenLabsClientOptions} a fresh client would accept; when
* `client` is provided every transport option is ignored.
*/
export type CreateElevenLabsVoiceOptions =
| { readonly client: ElevenLabsClient }
| (ElevenLabsClientOptions & { readonly client?: undefined });
/**
* Bundle of every voice-domain ElevenLabs adapter, all sharing a single
* {@link ElevenLabsClient}. Each field implements the corresponding
* `@suaveplan/voice` engine interface.
*/
export interface ElevenLabsVoiceBundle {
readonly client: ElevenLabsClient;
readonly tts: ElevenLabsVoiceSynthesizer;
readonly stt: ElevenLabsTranscriptionEngine;
readonly voices: ElevenLabsVoiceRepository;
readonly effects: ElevenLabsSoundEffectEngine;
readonly isolation: ElevenLabsVoiceIsolationEngine;
}
/**
* Construct the five `@suaveplan/voice` engine adapters for ElevenLabs against
* a single shared transport. When `options.client` is provided it is used as-is
* and every other field on `options` is ignored; otherwise a new
* {@link ElevenLabsClient} is constructed from the remaining options.
*/
export function createElevenLabsVoice(
options: CreateElevenLabsVoiceOptions
): ElevenLabsVoiceBundle {
const client =
options.client ??
new ElevenLabsClient(options as ElevenLabsClientOptions);
return {
client,
tts: new ElevenLabsVoiceSynthesizer(client),
stt: new ElevenLabsTranscriptionEngine(client),
voices: new ElevenLabsVoiceRepository(client),
effects: new ElevenLabsSoundEffectEngine(client),
isolation: new ElevenLabsVoiceIsolationEngine(client),
};
}

View File

@@ -0,0 +1,5 @@
export {
type CreateElevenLabsVoiceOptions,
createElevenLabsVoice,
type ElevenLabsVoiceBundle,
} from "./create-elevenlabs-voice.js";

View File

@@ -0,0 +1,20 @@
export {
type CreateElevenLabsVoiceOptions,
createElevenLabsVoice,
type ElevenLabsVoiceBundle,
} from "./factory/index.js";
export {
type ElevenLabsLanguage,
type ElevenLabsModel,
ElevenLabsModelCatalog,
} from "./models/index.js";
export { ElevenLabsSoundEffectEngine } from "./sound-effects/index.js";
export {
ElevenLabsSpeechToSpeech,
type VoiceConversionRequest,
type VoiceConversionResult,
} from "./speech-to-speech/index.js";
export { ElevenLabsTranscriptionEngine } from "./stt/index.js";
export { ElevenLabsVoiceSynthesizer } from "./tts/index.js";
export { ElevenLabsVoiceIsolationEngine } from "./voice-isolation/index.js";
export { ElevenLabsVoiceRepository } from "./voices/index.js";

View File

@@ -0,0 +1,5 @@
export type {
ElevenLabsLanguage,
ElevenLabsModel,
} from "./models.js";
export { ElevenLabsModelCatalog } from "./models.js";

View File

@@ -0,0 +1,89 @@
# `models`
ElevenLabs model catalog. Lists every model the API key has access to,
along with capability flags (TTS / voice conversion / fine-tuning) and
the language matrix. Calls `GET /v1/models`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Most ElevenLabs adapters take an optional `modelId` parameter — but
which model is appropriate depends on the operation, the language, the
account tier, and whether the model serves Pro voices or requires Alpha
access. This catalog gives application code a typed view of the
account's available models so it can pick the right one at runtime
instead of hard-coding strings, and degrade gracefully if a previously
chosen model is no longer accessible.
## Features
- One method, `list()`, returning `readonly ElevenLabsModel[]`.
- Each `ElevenLabsModel` carries the model id, display name, capability
flags (`canDoTextToSpeech`, `canDoVoiceConversion`, `canBeFinetuned`),
language matrix, and account-tier limits (`maxCharactersFree`,
`maxCharactersSubscribed`).
- Optional metadata (`description`, `requiresAlphaAccess`,
`servesProVoices`, `tokenCostFactor`) is included only when present in
the upstream response.
- Boolean capability flags default to `false` when missing, and the
language list defaults to `[]`.
- Errors are remapped onto the `@suaveplan/voice` domain:
`ProviderNotConfiguredError`, `ProviderRateLimitedError`, and a
generic `VoiceError` for everything else.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { models } = createElevenLabsAdapters({ apiKey: KEY });
const all = await models.list();
for (const m of all) console.log(m.modelId, m.name);
```
## Usage: Advanced Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { models } = createElevenLabsAdapters({ apiKey: KEY });
const all = await models.list();
const ttsModelsForEnglish = all.filter(
(m) =>
m.canDoTextToSpeech &&
m.languages.some((l) => l.languageId === "en") &&
!m.requiresAlphaAccess
);
const cheapest = ttsModelsForEnglish.reduce((best, m) =>
(m.tokenCostFactor ?? 1) < (best.tokenCostFactor ?? 1) ? m : best
);
console.log("picked:", cheapest.modelId);
```
## API Reference
- `interface ElevenLabsLanguage { languageId: string; name: string }`
- `interface ElevenLabsModel { modelId, name, canDoTextToSpeech, canDoVoiceConversion, canBeFinetuned, languages, description?, maxCharactersFree?, maxCharactersSubscribed?, requiresAlphaAccess?, servesProVoices?, tokenCostFactor? }`
- `class ElevenLabsModelCatalog`
- `providerName: "elevenlabs"`
- `list(): Promise<readonly ElevenLabsModel[]>`
Throws: `ProviderNotConfiguredError`, `ProviderRateLimitedError`, and
generic VoiceError-shaped failures.
## Implementation Notes
- The endpoint returns an array, not a paged envelope. There is no
`pageToken` to follow.
- Language entries with missing `language_id` or `name` are still
emitted, but with empty strings — caller code should guard against
this rather than crash.
- The catalog is not cached. Wrap it in an async-cache if you call it
frequently — model lists change rarely (days / weeks).
- `tokenCostFactor` is ElevenLabs's relative pricing multiplier vs. the
base model. A value of `1` means 1× character billing; `0.5` means
half-cost (cheaper, often lower fidelity).

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
ProviderNotConfiguredError,
ProviderRateLimitedError,
} from "@suaveplan/voice";
import { ElevenLabsModelCatalog } from "./models.js";
interface FetchCall {
url: string;
}
function client(responses: Array<Response | Error>): {
cat: ElevenLabsModelCatalog;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input) => {
calls.push({ url: String(input) });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
cat: new ElevenLabsModelCatalog(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function jsonResp(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}
describe("ElevenLabsModelCatalog", () => {
it("list maps full model record", async () => {
const { cat, calls } = client([
jsonResp([
{
model_id: "eleven_multilingual_v2",
name: "Eleven Multilingual v2",
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_be_finetuned: true,
languages: [
{ language_id: "en", name: "English" },
{ language_id: "es", name: "Spanish" },
],
description: "high quality TTS",
max_characters_request_free_user: 333,
max_characters_request_subscribed_user: 5000,
requires_alpha_access: false,
serves_pro_voices: true,
token_cost_factor: 1,
},
]),
]);
const models = await cat.list();
expect(models).toHaveLength(1);
const m = models[0];
expect(m?.modelId).toBe("eleven_multilingual_v2");
expect(m?.canDoTextToSpeech).toBe(true);
expect(m?.canBeFinetuned).toBe(true);
expect(m?.languages).toHaveLength(2);
expect(m?.languages[0]?.languageId).toBe("en");
expect(m?.maxCharactersFree).toBe(333);
expect(m?.maxCharactersSubscribed).toBe(5000);
expect(m?.tokenCostFactor).toBe(1);
expect(calls[0]?.url).toContain("/v1/models");
});
it("list defaults capability flags when missing", async () => {
const { cat } = client([jsonResp([{ model_id: "m1", name: "M" }])]);
const models = await cat.list();
const m = models[0];
expect(m?.canDoTextToSpeech).toBe(false);
expect(m?.canDoVoiceConversion).toBe(false);
expect(m?.canBeFinetuned).toBe(false);
expect(m?.languages).toEqual([]);
});
it("list maps language entries with missing fields safely", async () => {
const { cat } = client([
jsonResp([
{
model_id: "m",
name: "M",
languages: [{}, { language_id: "fr" }],
},
]),
]);
const models = await cat.list();
expect(models[0]?.languages).toEqual([
{ languageId: "", name: "" },
{ languageId: "fr", name: "" },
]);
});
it("list remaps 401 to ProviderNotConfiguredError", async () => {
const { cat } = client([new Response("", { status: 401 })]);
await expect(cat.list()).rejects.toBeInstanceOf(
ProviderNotConfiguredError
);
});
it("list remaps 429 to ProviderRateLimitedError", async () => {
const { cat } = client([
new Response("", { status: 429, headers: { "retry-after": "2" } }),
]);
await expect(cat.list()).rejects.toBeInstanceOf(
ProviderRateLimitedError
);
});
it("list remaps generic API failure to a VoiceError", async () => {
const { cat } = client([new Response("", { status: 500 })]);
await expect(cat.list()).rejects.toBeDefined();
});
it("list lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network"));
const cat = new ElevenLabsModelCatalog(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(cat.list()).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,136 @@
import type { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
ProviderNotConfiguredError,
ProviderRateLimitedError,
VOICE_CODES,
VoiceError,
} from "@suaveplan/voice";
/** A language supported by an ElevenLabs model. */
export interface ElevenLabsLanguage {
readonly languageId: string;
readonly name: string;
}
/** A single ElevenLabs model entry as returned by `GET /v1/models`. */
export interface ElevenLabsModel {
readonly modelId: string;
readonly name: string;
readonly canDoTextToSpeech: boolean;
readonly canDoVoiceConversion: boolean;
readonly canBeFinetuned: boolean;
readonly languages: readonly ElevenLabsLanguage[];
readonly description?: string;
readonly maxCharactersFree?: number;
readonly maxCharactersSubscribed?: number;
readonly requiresAlphaAccess?: boolean;
readonly servesProVoices?: boolean;
readonly tokenCostFactor?: number;
}
interface RawLanguage {
readonly language_id?: string;
readonly name?: string;
}
interface RawModel {
readonly model_id: string;
readonly name: string;
readonly can_do_text_to_speech?: boolean;
readonly can_do_voice_conversion?: boolean;
readonly can_be_finetuned?: boolean;
readonly languages?: readonly RawLanguage[];
readonly description?: string;
readonly max_characters_request_free_user?: number;
readonly max_characters_request_subscribed_user?: number;
readonly requires_alpha_access?: boolean;
readonly serves_pro_voices?: boolean;
readonly token_cost_factor?: number;
}
class ModelsListFailedError extends VoiceError {
constructor(opts: { readonly cause: unknown }) {
super(
VOICE_CODES["PROVIDER.NOT_CONFIGURED"],
"ElevenLabs models list failed",
{ cause: opts.cause }
);
}
}
function mapLanguage(l: RawLanguage): ElevenLabsLanguage {
return {
languageId: l.language_id ?? "",
name: l.name ?? "",
};
}
function mapModel(m: RawModel): ElevenLabsModel {
const out: { -readonly [K in keyof ElevenLabsModel]: ElevenLabsModel[K] } =
{
modelId: m.model_id,
name: m.name,
canDoTextToSpeech: m.can_do_text_to_speech ?? false,
canDoVoiceConversion: m.can_do_voice_conversion ?? false,
canBeFinetuned: m.can_be_finetuned ?? false,
languages: (m.languages ?? []).map(mapLanguage),
};
if (m.description !== undefined) out.description = m.description;
if (m.max_characters_request_free_user !== undefined)
out.maxCharactersFree = m.max_characters_request_free_user;
if (m.max_characters_request_subscribed_user !== undefined)
out.maxCharactersSubscribed = m.max_characters_request_subscribed_user;
if (m.requires_alpha_access !== undefined)
out.requiresAlphaAccess = m.requires_alpha_access;
if (m.serves_pro_voices !== undefined)
out.servesProVoices = m.serves_pro_voices;
if (m.token_cost_factor !== undefined)
out.tokenCostFactor = m.token_cost_factor;
return out;
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new ModelsListFailedError({ cause: err });
}
/**
* ElevenLabs model catalog. Lists every model the API key has access to,
* along with capability flags (TTS / voice conversion / fine-tuning) and
* the language matrix.
*/
export class ElevenLabsModelCatalog {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async list(): Promise<readonly ElevenLabsModel[]> {
try {
const res = await this.#client.fetchJson<readonly RawModel[]>({
method: "GET",
path: "/v1/models",
feature: "models",
operation: "list",
});
return res.body.map(mapModel);
} catch (err) {
remapError(err);
}
}
}

View File

@@ -0,0 +1 @@
export { ElevenLabsSoundEffectEngine } from "./sound-effects.js";

View File

@@ -0,0 +1,93 @@
# `sound-generation`
ElevenLabs implementation of `@suaveplan/voice`'s `SoundEffectEngine`
interface. Posts a JSON body to `POST /v1/sound-generation` and returns
the generated audio as raw bytes.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generative sound effects are an ElevenLabs strength but the
`/v1/sound-generation` endpoint has its own request shape with
snake_case fields and ElevenLabs-specific knobs (`prompt_influence`,
`loop`). This adapter maps the `SoundEffectRequest` shape
(camelCase, with optional duration / prompt-influence / loop / model id /
output format) onto the wire body and remaps every error onto the
`@suaveplan/voice` domain. Application code stays portable across
providers; only this file knows the upstream conventions.
## Features
- One method, `generate(request)`, returning the raw audio bytes plus
the requested format (defaulting to `mp3_44100_128`).
- Optional `durationSeconds` (0.5–30s typical), `loop`,
`promptInfluence` (0..1), `modelId`, and `outputFormat` are forwarded
when defined and omitted otherwise.
- Surfaces the upstream `x-request-id` on the result.
- Errors are remapped: 401 → `ProviderNotConfiguredError`, 429 →
`ProviderRateLimitedError` (with `retryAfterMs`), 5xx →
`SoundEffectGenerationFailedError`.
- Built-in retry / backoff inherited from the shared
`ElevenLabsClient`.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { soundEffects } = createElevenLabsAdapters({ apiKey: KEY });
const { audio } = await soundEffects.generate({ text: "ocean waves at dusk" });
await Bun.write("waves.mp3", audio);
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsSoundEffectEngine,
} from "@suaveplan/third-party-elevenlabs";
const eng = new ElevenLabsSoundEffectEngine(
new ElevenLabsClient({ apiKey: KEY, timeoutMs: 60_000 })
);
const ctrl = new AbortController();
const result = await eng.generate({
text: "thunderclap followed by rain",
durationSeconds: 8,
loop: false,
promptInfluence: 0.7,
modelId: "sound_v2",
outputFormat: "wav_44100",
signal: ctrl.signal,
});
console.log(`got ${result.audio.byteLength} bytes`, result.requestId);
```
## API Reference
- `class ElevenLabsSoundEffectEngine implements SoundEffectEngine`
- `providerName: "elevenlabs"`
- `generate(request: SoundEffectRequest): Promise<SoundEffectResult>`
- returns `{ audio, format, requestId? }`.
Throws: `ProviderNotConfiguredError`, `ProviderRateLimitedError`,
`SoundEffectGenerationFailedError`.
## Implementation Notes
- The `format` on the result reflects the **requested** format, not
necessarily the encoded output — ElevenLabs may return a different
format if it rejects the request. Inspect the response bytes / use a
metadata reader if you need ground truth.
- `prompt_influence` defaults to ElevenLabs's server-side default when
omitted (commonly 0.3); set explicitly to 0–1 for deterministic
control.
- `text` is sent verbatim — there is no client-side validation. Empty
text will be rejected upstream as a 422.
- The adapter inherits retry, timeout, and observability events from the
shared `ElevenLabsClient`. Configure them on the client, not here.

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
isSoundEffectEngine,
ProviderNotConfiguredError,
ProviderRateLimitedError,
SoundEffectGenerationFailedError,
} from "@suaveplan/voice";
import { ElevenLabsSoundEffectEngine } from "./sound-effects.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
eng: ElevenLabsSoundEffectEngine;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
eng: new ElevenLabsSoundEffectEngine(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function audioResp(
bytes: number[],
headers: Record<string, string> = {}
): Response {
return new Response(new Uint8Array(bytes), {
status: 200,
headers: { "content-type": "audio/mpeg", ...headers },
});
}
describe("ElevenLabsSoundEffectEngine", () => {
it("identifies as 'elevenlabs' and satisfies SoundEffectEngine", () => {
const { eng } = client([]);
expect(eng.providerName).toBe("elevenlabs");
expect(isSoundEffectEngine(eng)).toBe(true);
});
it("generate posts JSON body and returns SoundEffectResult", async () => {
const { eng, calls } = client([
audioResp([8, 7, 6], { "x-request-id": "rid" }),
]);
const r = await eng.generate({
text: "ocean waves",
durationSeconds: 5,
loop: true,
promptInfluence: 0.7,
modelId: "sound_v2",
outputFormat: "wav_44100",
});
expect(r.audio).toEqual(new Uint8Array([8, 7, 6]));
expect(r.format).toBe("wav_44100");
expect(r.requestId).toBe("rid");
const body = JSON.parse(String(calls[0]?.init.body));
expect(body).toEqual({
text: "ocean waves",
duration_seconds: 5,
loop: true,
prompt_influence: 0.7,
model_id: "sound_v2",
output_format: "wav_44100",
});
});
it("generate defaults format when not specified", async () => {
const { eng } = client([audioResp([1])]);
const r = await eng.generate({ text: "x" });
expect(r.format).toBe("mp3_44100_128");
});
it("generate remaps 401 to ProviderNotConfiguredError", async () => {
const { eng } = client([new Response("", { status: 401 })]);
await expect(eng.generate({ text: "x" })).rejects.toBeInstanceOf(
ProviderNotConfiguredError
);
});
it("generate remaps 429 to ProviderRateLimitedError", async () => {
const { eng } = client([
new Response("", { status: 429, headers: { "retry-after": "1" } }),
]);
let caught: unknown;
try {
await eng.generate({ text: "x" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
expect(
(caught as ProviderRateLimitedError).context?.["retryAfterMs"]
).toBe(1000);
});
it("generate remaps generic API failure to SoundEffectGenerationFailedError", async () => {
const { eng } = client([new Response("", { status: 500 })]);
await expect(eng.generate({ text: "x" })).rejects.toBeInstanceOf(
SoundEffectGenerationFailedError
);
});
it("generate lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network"));
const eng = new ElevenLabsSoundEffectEngine(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(eng.generate({ text: "x" })).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,89 @@
import type { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
ProviderNotConfiguredError,
ProviderRateLimitedError,
type SoundEffectEngine,
SoundEffectGenerationFailedError,
type SoundEffectRequest,
type SoundEffectResult,
} from "@suaveplan/voice";
function buildBody(req: SoundEffectRequest): Readonly<Record<string, unknown>> {
const body: Record<string, unknown> = { text: req.text };
if (req.durationSeconds !== undefined)
body["duration_seconds"] = req.durationSeconds;
if (req.loop !== undefined) body["loop"] = req.loop;
if (req.promptInfluence !== undefined)
body["prompt_influence"] = req.promptInfluence;
if (req.modelId !== undefined) body["model_id"] = req.modelId;
if (req.outputFormat !== undefined)
body["output_format"] = req.outputFormat;
return body;
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new SoundEffectGenerationFailedError({ cause: err });
}
/**
* ElevenLabs implementation of `SoundEffectEngine`. Calls
* `POST /v1/sound-generation` with a JSON body and returns the generated
* sound effect as raw audio bytes.
*/
export class ElevenLabsSoundEffectEngine implements SoundEffectEngine {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async generate(request: SoundEffectRequest): Promise<SoundEffectResult> {
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
body: Readonly<Record<string, unknown>>;
accept: string;
signal?: AbortSignal;
} = {
method: "POST",
path: "/v1/sound-generation",
feature: "sound_effects",
operation: "generate",
body: buildBody(request),
accept: "audio/mpeg",
};
if (request.signal !== undefined) init.signal = request.signal;
let response: Awaited<ReturnType<ElevenLabsClient["fetchBinary"]>>;
try {
response = await this.#client.fetchBinary(init);
} catch (err) {
remapError(err);
}
const out: {
-readonly [K in keyof SoundEffectResult]: SoundEffectResult[K];
} = {
audio: response.body,
format: request.outputFormat ?? "mp3_44100_128",
};
if (response.metadata.requestId !== undefined)
out.requestId = response.metadata.requestId;
return out;
}
}

View File

@@ -0,0 +1,5 @@
export type {
VoiceConversionRequest,
VoiceConversionResult,
} from "./speech-to-speech.js";
export { ElevenLabsSpeechToSpeech } from "./speech-to-speech.js";

View File

@@ -0,0 +1,104 @@
# `speech-to-speech`
ElevenLabs Speech-to-Speech (voice conversion) adapter. Given source
audio and a target `voiceId`, produces audio that preserves the source
cadence and prosody but adopts the target voice's timbre. Posts to
`POST /v1/speech-to-speech/{voice_id}` as multipart form-data.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Voice conversion is an ElevenLabs-specific capability that hasn't been
abstracted into the `@suaveplan/voice` core (the abstraction surface for
"voice" is still settling — different providers expose conversion with
very different envelopes). The adapter exposes a clean
`VoiceConversionRequest` / `VoiceConversionResult` shape that mirrors
the voice domain conventions (camelCase, AbortSignal,
`AudioSource` union) so it composes naturally with the rest of the
package even though no abstract interface backs it yet.
## Features
- Accepts `Uint8Array`, `ReadableStream<Uint8Array>`, or a URL string as
the source audio.
- Optional `voiceSettings`, `modelId`, and `outputFormat` are forwarded
when defined; output format is validated client-side against the eight
ElevenLabs-supported values and rejected with `TtsSynthesisFailedError`
if unsupported.
- Returns the converted audio as raw bytes with the requested format
label and the upstream `x-request-id` when present.
- Errors are remapped onto the `@suaveplan/voice` domain:
`ProviderNotConfiguredError`, `ProviderRateLimitedError`,
`TtsSynthesisFailedError`.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { speechToSpeech } = createElevenLabsAdapters({ apiKey: KEY });
const source = new Uint8Array(
await Bun.file("acting-take.wav").arrayBuffer()
);
const { audio } = await speechToSpeech.convert({
audio: source,
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
await Bun.write("converted.mp3", audio);
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsSpeechToSpeech,
} from "@suaveplan/third-party-elevenlabs";
const sts = new ElevenLabsSpeechToSpeech(
new ElevenLabsClient({ apiKey: KEY, timeoutMs: 120_000 })
);
const result = await sts.convert({
audio: "https://example.com/raw-take.wav",
voiceId: "abc",
modelId: "eleven_multilingual_sts_v2",
outputFormat: "pcm_44100",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.6,
style: 0.1,
speakerBoost: false,
},
});
console.log("rid:", result.requestId);
```
## API Reference
- `interface VoiceConversionRequest { audio, voiceId, outputFormat?, voiceSettings?, modelId?, signal? }`
- `interface VoiceConversionResult { audio: Uint8Array, format: AudioFormat, requestId?: string }`
- `class ElevenLabsSpeechToSpeech`
- `providerName: "elevenlabs"`
- `convert(request: VoiceConversionRequest): Promise<VoiceConversionResult>`
Throws: `ProviderNotConfiguredError`, `ProviderRateLimitedError`,
`TtsSynthesisFailedError`.
## Implementation Notes
- `voice_settings` is sent as a JSON-string scalar field inside the
multipart envelope (not as a separate JSON part) because that's the
format ElevenLabs's `/v1/speech-to-speech` endpoint accepts.
- Output formats that aren't in ElevenLabs's supported set
(`mp3_22050_32`, `mp3_44100_128`, `mp3_44100_192`, `pcm_16000`,
`pcm_22050`, `pcm_44100`, `ulaw_8000`) are rejected client-side rather
than letting the request reach ElevenLabs and come back as a 422.
- The default `outputFormat` label on the result is `mp3_44100_128`,
matching ElevenLabs's server default.
- Long source audio (> 30s) needs a bumped `timeoutMs` on the underlying
client.

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
ProviderNotConfiguredError,
ProviderRateLimitedError,
TtsSynthesisFailedError,
} from "@suaveplan/voice";
import { ElevenLabsSpeechToSpeech } from "./speech-to-speech.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
sts: ElevenLabsSpeechToSpeech;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
sts: new ElevenLabsSpeechToSpeech(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function audioResp(
bytes: number[],
headers: Record<string, string> = {}
): Response {
return new Response(new Uint8Array(bytes), {
status: 200,
headers: { "content-type": "audio/mpeg", ...headers },
});
}
describe("ElevenLabsSpeechToSpeech", () => {
it("identifies as 'elevenlabs'", () => {
const { sts } = client([]);
expect(sts.providerName).toBe("elevenlabs");
});
it("convert posts multipart to /v1/speech-to-speech/{voice_id}", async () => {
const { sts, calls } = client([
audioResp([5, 5], { "x-request-id": "rid" }),
]);
const r = await sts.convert({
audio: new Uint8Array([1, 2]),
voiceId: "vTarget",
outputFormat: "mp3_44100_128",
modelId: "eleven_multilingual_sts_v2",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.6,
style: 0.1,
speakerBoost: false,
},
});
expect(r.audio).toEqual(new Uint8Array([5, 5]));
expect(r.format).toBe("mp3_44100_128");
expect(r.requestId).toBe("rid");
expect(calls[0]?.url).toContain("/v1/speech-to-speech/vTarget");
});
it("convert defaults format when not specified", async () => {
const { sts } = client([audioResp([1])]);
const r = await sts.convert({
audio: new Uint8Array(),
voiceId: "v",
});
expect(r.format).toBe("mp3_44100_128");
});
it("convert url-encodes voice id", async () => {
const { sts, calls } = client([audioResp([1])]);
await sts.convert({ audio: new Uint8Array(), voiceId: "v 1" });
expect(calls[0]?.url).toContain("/v1/speech-to-speech/v%201");
});
it("convert accepts ReadableStream audio", async () => {
const { sts } = client([audioResp([1])]);
const stream = new ReadableStream<Uint8Array>({
start(c): void {
c.enqueue(new Uint8Array([0]));
c.close();
},
});
const r = await sts.convert({ audio: stream, voiceId: "v" });
expect(r.audio).toBeDefined();
});
it("convert accepts URL audio", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response(new Uint8Array([1]), { status: 200 })
)) as unknown as typeof fetch;
try {
const { sts } = client([audioResp([1])]);
const r = await sts.convert({
audio: "https://example.com/a.wav",
voiceId: "v",
});
expect(r.audio).toBeDefined();
} finally {
globalThis.fetch = originalFetch;
}
});
it("convert throws TtsSynthesisFailedError when audio URL fetch fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response("", { status: 500 })
)) as unknown as typeof fetch;
try {
const { sts } = client([]);
await expect(
sts.convert({
audio: "https://example.com/a.wav",
voiceId: "v",
})
).rejects.toBeInstanceOf(TtsSynthesisFailedError);
} finally {
globalThis.fetch = originalFetch;
}
});
it("convert rejects unsupported output formats", async () => {
const { sts } = client([]);
await expect(
sts.convert({
audio: new Uint8Array(),
voiceId: "v",
outputFormat: "wav_44100",
})
).rejects.toBeInstanceOf(TtsSynthesisFailedError);
});
it("convert remaps 401 to ProviderNotConfiguredError", async () => {
const { sts } = client([new Response("", { status: 401 })]);
await expect(
sts.convert({ audio: new Uint8Array(), voiceId: "v" })
).rejects.toBeInstanceOf(ProviderNotConfiguredError);
});
it("convert remaps 429 to ProviderRateLimitedError", async () => {
const { sts } = client([
new Response("", { status: 429, headers: { "retry-after": "2" } }),
]);
let caught: unknown;
try {
await sts.convert({ audio: new Uint8Array(), voiceId: "v" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
});
it("convert remaps generic API failure to TtsSynthesisFailedError", async () => {
const { sts } = client([new Response("", { status: 500 })]);
await expect(
sts.convert({ audio: new Uint8Array(), voiceId: "v" })
).rejects.toBeInstanceOf(TtsSynthesisFailedError);
});
it("convert lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network"));
const sts = new ElevenLabsSpeechToSpeech(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(
sts.convert({ audio: new Uint8Array(), voiceId: "v" })
).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,194 @@
import type {
ElevenLabsClient,
MultipartField,
} from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
type AudioFormat,
type AudioSource,
ProviderNotConfiguredError,
ProviderRateLimitedError,
TtsSynthesisFailedError,
type VoiceSettings,
} from "@suaveplan/voice";
/**
* Request envelope for ElevenLabs Speech-to-Speech (voice conversion).
* Voice conversion is an ElevenLabs-specific capability not yet abstracted
* into the `@suaveplan/voice` core, so the request shape lives in this
* adapter package.
*/
export interface VoiceConversionRequest {
/** Source audio to convert. Buffered before upload. */
readonly audio: AudioSource;
/** Target voice id whose timbre the output will adopt. */
readonly voiceId: string;
/** Optional output format. Defaults to ElevenLabs server default. */
readonly outputFormat?: AudioFormat;
/** Optional voice tuning parameters. */
readonly voiceSettings?: VoiceSettings;
/** Optional model id (e.g. `eleven_multilingual_sts_v2`). */
readonly modelId?: string;
/** Optional cancellation. */
readonly signal?: AbortSignal;
}
/** Result of a voice-conversion call. */
export interface VoiceConversionResult {
readonly audio: Uint8Array;
readonly format: AudioFormat;
readonly requestId?: string;
}
const FORMAT_MAP: Readonly<Partial<Record<AudioFormat, string>>> = {
mp3_22050_32: "mp3_22050_32",
mp3_44100_128: "mp3_44100_128",
mp3_44100_192: "mp3_44100_192",
pcm_16000: "pcm_16000",
pcm_22050: "pcm_22050",
pcm_44100: "pcm_44100",
ulaw_8000: "ulaw_8000",
};
async function bufferAudio(src: AudioSource): Promise<Uint8Array> {
if (src instanceof Uint8Array) return src;
if (typeof src === "string") {
const res = await fetch(src);
if (!res.ok)
throw new TtsSynthesisFailedError({
message: `Failed to fetch audio URL: ${res.status}`,
});
return new Uint8Array(await res.arrayBuffer());
}
const reader = src.getReader();
const chunks: Uint8Array[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value !== undefined) chunks.push(value);
}
} finally {
reader.releaseLock();
}
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
return merged;
}
function mapVoiceSettings(s: VoiceSettings | undefined): string | undefined {
if (s === undefined) return undefined;
const out: Record<string, unknown> = {};
if (s.stability !== undefined) out["stability"] = s.stability;
if (s.similarityBoost !== undefined)
out["similarity_boost"] = s.similarityBoost;
if (s.style !== undefined) out["style"] = s.style;
if (s.speakerBoost !== undefined) out["use_speaker_boost"] = s.speakerBoost;
return JSON.stringify(out);
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new TtsSynthesisFailedError({ cause: err });
}
/**
* ElevenLabs Speech-to-Speech (voice conversion) adapter. Given source
* audio and a target `voiceId`, produces audio that preserves the source
* cadence and prosody but adopts the target voice's timbre.
*/
export class ElevenLabsSpeechToSpeech {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async convert(
request: VoiceConversionRequest
): Promise<VoiceConversionResult> {
const audio = await bufferAudio(request.audio);
const fields: MultipartField[] = [
{
kind: "file",
name: "audio",
filename: "audio.bin",
contentType: "application/octet-stream",
value: audio,
},
];
const settings = mapVoiceSettings(request.voiceSettings);
if (settings !== undefined)
fields.push({
kind: "scalar",
name: "voice_settings",
value: settings,
});
if (request.modelId !== undefined)
fields.push({
kind: "scalar",
name: "model_id",
value: request.modelId,
});
if (request.outputFormat !== undefined) {
const fmt = FORMAT_MAP[request.outputFormat];
if (fmt === undefined)
throw new TtsSynthesisFailedError({
message: `Unsupported output format: ${request.outputFormat}`,
});
fields.push({ kind: "scalar", name: "output_format", value: fmt });
}
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
fields: readonly MultipartField[];
accept: string;
signal?: AbortSignal;
} = {
method: "POST",
path: `/v1/speech-to-speech/${encodeURIComponent(request.voiceId)}`,
feature: "speech_to_speech",
operation: "convert",
fields,
accept: "audio/mpeg",
};
if (request.signal !== undefined) init.signal = request.signal;
let response: Awaited<
ReturnType<ElevenLabsClient["fetchMultipartBinary"]>
>;
try {
response = await this.#client.fetchMultipartBinary(init);
} catch (err) {
remapError(err);
}
const out: {
-readonly [K in keyof VoiceConversionResult]: VoiceConversionResult[K];
} = {
audio: response.body,
format: request.outputFormat ?? "mp3_44100_128",
};
if (response.metadata.requestId !== undefined)
out.requestId = response.metadata.requestId;
return out;
}
}

View File

@@ -0,0 +1 @@
export { ElevenLabsTranscriptionEngine } from "./stt.js";

View File

@@ -0,0 +1,105 @@
# `stt`
ElevenLabs implementation of `@suaveplan/voice`'s `TranscriptionEngine`
interface. Posts the audio as multipart form-data to
`POST /v1/speech-to-text` and maps the response into the voice
domain shape.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Speech-to-text providers vary wildly in their wire formats — some accept
raw bytes, some require a URL, some return word timings as
`{start, end}` floats and others as integer-millisecond ranges. This
adapter handles ElevenLabs's idiosyncrasies (multipart upload required,
seconds-based word timings, `request_id` returned in the body sometimes
and the response header other times) and presents the canonical
`@suaveplan/voice/TranscriptionResult` shape. Application code stays
provider-agnostic; only this file knows ElevenLabs's quirks.
## Features
- Accepts `Uint8Array`, `ReadableStream<Uint8Array>`, or a URL string as
the audio source — streams are buffered and URLs are fetched first
because ElevenLabs requires a complete multipart body.
- Maps every optional `TranscriptionRequest` field onto the corresponding
wire field, including `tag_audio_events`, `num_speakers`,
`timestamps_granularity`, `diarize`, `diarization_threshold`,
`use_multi_channel`, `entity_detection`, `entity_redaction`,
`keyterms`, `temperature`, and `seed`.
- Word timestamps are converted from seconds (ElevenLabs) to milliseconds
and `spacing` / `audio_event` entries are filtered out.
- Word `logprob` is converted to a normalised confidence in `[0, 1]`
via `exp(logprob)`.
- Errors are remapped onto `@suaveplan/voice` domain errors:
`ProviderNotConfiguredError`, `ProviderRateLimitedError` (with
`retryAfterMs`), and `SttTranscriptionFailedError`.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { transcriber } = createElevenLabsAdapters({ apiKey: KEY });
const audio = new Uint8Array(await Bun.file("clip.wav").arrayBuffer());
const result = await transcriber.transcribe({ audio });
console.log(result.text);
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsTranscriptionEngine,
} from "@suaveplan/third-party-elevenlabs";
const stt = new ElevenLabsTranscriptionEngine(
new ElevenLabsClient({ apiKey: KEY })
);
const result = await stt.transcribe({
audio: "https://example.com/podcast.mp3",
modelId: "scribe_v1",
languageCode: "en",
timestampsGranularity: "word",
diarize: true,
numSpeakers: 3,
diarizationThreshold: 0.4,
entityDetection: ["person", "location"],
entityRedaction: "all",
keyterms: ["Suaveplan", "suaveplan"],
tagAudioEvents: true,
temperature: 0,
seed: 42,
});
for (const w of result.words ?? []) {
console.log(`[${w.speakerId}] ${w.startMs}–${w.endMs}: ${w.word}`);
}
```
## API Reference
- `class ElevenLabsTranscriptionEngine implements TranscriptionEngine`
- `providerName: "elevenlabs"`
- `transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>`
- returns `{ text, words?, language?, requestId? }`.
- `words` is omitted entirely when the response contains only spacing /
audio-event entries.
- `requestId` prefers the response header value; falls back to the
body's `request_id` field.
## Implementation Notes
- Default `model_id` is `scribe_v1`. Override with `request.modelId`.
- `entityDetection` and `entityRedaction` accept either an array or the
literal `"all"` per the upstream contract.
- `keyterms` is sent as a comma-joined string (ElevenLabs's wire format),
so commas embedded in keyterms will be split on the server side. If
you need commas inside keyterms, escape them before passing in.
- `audio_event` and `spacing` word entries are dropped because the
`@suaveplan/voice/WordTimestamp` schema requires `word: string().min(1)`.

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
isTranscriptionEngine,
ProviderNotConfiguredError,
ProviderRateLimitedError,
SttTranscriptionFailedError,
} from "@suaveplan/voice";
import { ElevenLabsTranscriptionEngine } from "./stt.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
stt: ElevenLabsTranscriptionEngine;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
stt: new ElevenLabsTranscriptionEngine(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function jsonResp(
body: unknown,
headers: Record<string, string> = {}
): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json", ...headers },
});
}
describe("ElevenLabsTranscriptionEngine", () => {
it("identifies as 'elevenlabs' and satisfies TranscriptionEngine", () => {
const { stt } = client([]);
expect(stt.providerName).toBe("elevenlabs");
expect(isTranscriptionEngine(stt)).toBe(true);
});
it("transcribe posts multipart with Uint8Array audio and returns mapped result", async () => {
const { stt, calls } = client([
jsonResp(
{
text: "hello world",
language_code: "en",
words: [
{ text: "hello", start: 0, end: 0.5, type: "word" },
{ text: " ", start: 0.5, end: 0.6, type: "spacing" },
{
text: "world",
start: 0.6,
end: 1.0,
type: "word",
speaker_id: "spk_0",
channel_index: 0,
logprob: -0.1,
},
],
},
{ "x-request-id": "rid" }
),
]);
const res = await stt.transcribe({
audio: new Uint8Array([1, 2, 3]),
modelId: "scribe_v1",
languageCode: "en",
tagAudioEvents: true,
numSpeakers: 2,
timestampsGranularity: "word",
diarize: true,
diarizationThreshold: 0.4,
useMultiChannel: false,
temperature: 0,
seed: 1,
entityDetection: ["person", "location"],
entityRedaction: "all",
keyterms: ["alpha", "beta"],
});
expect(res.text).toBe("hello world");
expect(res.language).toBe("en");
expect(res.requestId).toBe("rid");
expect(res.words).toHaveLength(2);
expect(res.words?.[0]).toEqual({
word: "hello",
startMs: 0,
endMs: 500,
});
expect(res.words?.[1]?.speakerId).toBe("spk_0");
expect(res.words?.[1]?.channelIndex).toBe(0);
expect(typeof res.words?.[1]?.confidence).toBe("number");
expect(calls[0]?.url).toContain("/v1/speech-to-text");
expect(calls[0]?.init.body).toBeInstanceOf(FormData);
});
it("transcribe defaults model_id to scribe_v1 when omitted", async () => {
const { stt } = client([jsonResp({ text: "x" })]);
const res = await stt.transcribe({ audio: new Uint8Array() });
expect(res.text).toBe("x");
});
it("transcribe accepts ReadableStream audio", async () => {
const { stt } = client([jsonResp({ text: "x" })]);
const stream = new ReadableStream<Uint8Array>({
start(c): void {
c.enqueue(new Uint8Array([1]));
c.enqueue(new Uint8Array([2, 3]));
c.close();
},
});
const res = await stt.transcribe({ audio: stream });
expect(res.text).toBe("x");
});
it("transcribe accepts URL audio", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response(new Uint8Array([7, 8]), { status: 200 })
)) as unknown as typeof fetch;
try {
const { stt } = client([jsonResp({ text: "x" })]);
const res = await stt.transcribe({
audio: "https://example.com/a.wav",
});
expect(res.text).toBe("x");
} finally {
globalThis.fetch = originalFetch;
}
});
it("transcribe throws SttTranscriptionFailedError when audio URL fetch fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response("", { status: 500 })
)) as unknown as typeof fetch;
try {
const { stt } = client([]);
await expect(
stt.transcribe({ audio: "https://example.com/a.wav" })
).rejects.toBeInstanceOf(SttTranscriptionFailedError);
} finally {
globalThis.fetch = originalFetch;
}
});
it("transcribe falls back to body.request_id when response header missing", async () => {
const { stt } = client([
jsonResp({ text: "x", request_id: "body-rid" }),
]);
const res = await stt.transcribe({ audio: new Uint8Array() });
expect(res.requestId).toBe("body-rid");
});
it("transcribe omits words[] when only spacing entries present", async () => {
const { stt } = client([
jsonResp({
text: "x",
words: [{ text: " ", start: 0, end: 0.1, type: "spacing" }],
}),
]);
const res = await stt.transcribe({ audio: new Uint8Array() });
expect(res.words).toBeUndefined();
});
it("transcribe accepts entityDetection: 'all'", async () => {
const { stt } = client([jsonResp({ text: "x" })]);
await stt.transcribe({
audio: new Uint8Array(),
entityDetection: "all",
});
});
it("transcribe omits empty keyterms[]", async () => {
const { stt } = client([jsonResp({ text: "x" })]);
await stt.transcribe({ audio: new Uint8Array(), keyterms: [] });
});
it("transcribe remaps 401 to ProviderNotConfiguredError", async () => {
const { stt } = client([new Response("", { status: 401 })]);
await expect(
stt.transcribe({ audio: new Uint8Array() })
).rejects.toBeInstanceOf(ProviderNotConfiguredError);
});
it("transcribe remaps 429 to ProviderRateLimitedError", async () => {
const { stt } = client([
new Response("", { status: 429, headers: { "retry-after": "3" } }),
]);
let caught: unknown;
try {
await stt.transcribe({ audio: new Uint8Array() });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
expect(
(caught as ProviderRateLimitedError).context?.["retryAfterMs"]
).toBe(3000);
});
it("transcribe remaps generic API failure to SttTranscriptionFailedError", async () => {
const { stt } = client([new Response("", { status: 500 })]);
await expect(
stt.transcribe({ audio: new Uint8Array() })
).rejects.toBeInstanceOf(SttTranscriptionFailedError);
});
it("transcribe lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network down"));
const stt = new ElevenLabsTranscriptionEngine(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(
stt.transcribe({ audio: new Uint8Array() })
).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,221 @@
import type {
ElevenLabsClient,
JsonResponse,
MultipartField,
} from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
ProviderNotConfiguredError,
ProviderRateLimitedError,
SttTranscriptionFailedError,
type TranscriptionEngine,
type TranscriptionRequest,
type TranscriptionResult,
type WordTimestamp,
} from "@suaveplan/voice";
interface ElevenLabsWord {
readonly text: string;
readonly start: number;
readonly end: number;
readonly type?: "word" | "spacing" | "audio_event";
readonly speaker_id?: string;
readonly channel_index?: number;
readonly logprob?: number;
}
interface ElevenLabsTranscriptionResponse {
readonly text: string;
readonly language_code?: string;
readonly language_probability?: number;
readonly words?: readonly ElevenLabsWord[];
readonly request_id?: string;
}
function mapWord(w: ElevenLabsWord): WordTimestamp | null {
if (w.type === "spacing" || w.type === "audio_event") return null;
const out: { -readonly [K in keyof WordTimestamp]: WordTimestamp[K] } = {
word: w.text,
startMs: Math.round(w.start * 1000),
endMs: Math.round(w.end * 1000),
};
if (w.speaker_id !== undefined) out.speakerId = w.speaker_id;
if (w.channel_index !== undefined) out.channelIndex = w.channel_index;
if (w.logprob !== undefined) {
const conf = Math.max(0, Math.min(1, Math.exp(w.logprob)));
out.confidence = conf;
}
return out;
}
async function bufferAudio(
src: TranscriptionRequest["audio"]
): Promise<Uint8Array> {
if (src instanceof Uint8Array) return src;
if (typeof src === "string") {
const res = await fetch(src);
if (!res.ok)
throw new SttTranscriptionFailedError({
message: `Failed to fetch audio URL: ${res.status}`,
});
return new Uint8Array(await res.arrayBuffer());
}
const reader = src.getReader();
const chunks: Uint8Array[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value !== undefined) chunks.push(value);
}
} finally {
reader.releaseLock();
}
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
return merged;
}
function buildFields(
audio: Uint8Array,
req: TranscriptionRequest
): readonly MultipartField[] {
const fields: MultipartField[] = [
{
kind: "file",
name: "file",
filename: "audio.bin",
contentType: "application/octet-stream",
value: audio,
},
{
kind: "scalar",
name: "model_id",
value: req.modelId ?? "scribe_v1",
},
];
const push = (name: string, value: string): void => {
fields.push({ kind: "scalar", name, value });
};
if (req.languageCode !== undefined) push("language_code", req.languageCode);
if (req.tagAudioEvents !== undefined)
push("tag_audio_events", String(req.tagAudioEvents));
if (req.numSpeakers !== undefined)
push("num_speakers", String(req.numSpeakers));
if (req.timestampsGranularity !== undefined)
push("timestamps_granularity", req.timestampsGranularity);
if (req.diarize !== undefined) push("diarize", String(req.diarize));
if (req.diarizationThreshold !== undefined)
push("diarization_threshold", String(req.diarizationThreshold));
if (req.useMultiChannel !== undefined)
push("use_multi_channel", String(req.useMultiChannel));
if (req.temperature !== undefined)
push("temperature", String(req.temperature));
if (req.seed !== undefined) push("seed", String(req.seed));
if (req.entityDetection !== undefined) {
const v =
req.entityDetection === "all"
? "all"
: req.entityDetection.join(",");
push("entity_detection", v);
}
if (req.entityRedaction !== undefined) {
const v =
req.entityRedaction === "all"
? "all"
: req.entityRedaction.join(",");
push("entity_redaction", v);
}
if (req.keyterms !== undefined && req.keyterms.length > 0) {
push("keyterms", req.keyterms.join(","));
}
return fields;
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new SttTranscriptionFailedError({ cause: err });
}
/**
* ElevenLabs implementation of `TranscriptionEngine`. Posts the audio as
* multipart form-data to `POST /v1/speech-to-text` and maps the response
* into the genesis voice domain shape.
*/
export class ElevenLabsTranscriptionEngine implements TranscriptionEngine {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async transcribe(
request: TranscriptionRequest
): Promise<TranscriptionResult> {
const audio = await bufferAudio(request.audio);
const fields = buildFields(audio, request);
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
fields: readonly MultipartField[];
accept: string;
signal?: AbortSignal;
} = {
method: "POST",
path: "/v1/speech-to-text",
feature: "stt",
operation: "transcribe",
fields,
accept: "application/json",
};
if (request.signal !== undefined) init.signal = request.signal;
let response: JsonResponse<ElevenLabsTranscriptionResponse>;
try {
response =
await this.#client.fetchMultipartJson<ElevenLabsTranscriptionResponse>(
init
);
} catch (err) {
remapError(err);
}
const body = response.body;
const result: {
-readonly [K in keyof TranscriptionResult]: TranscriptionResult[K];
} = { text: body.text };
if (body.words !== undefined) {
const mapped: WordTimestamp[] = [];
for (const w of body.words) {
const m = mapWord(w);
if (m !== null) mapped.push(m);
}
if (mapped.length > 0) result.words = mapped;
}
if (body.language_code !== undefined)
result.language = body.language_code;
if (response.metadata.requestId !== undefined)
result.requestId = response.metadata.requestId;
else if (body.request_id !== undefined)
result.requestId = body.request_id;
return result;
}
}

View File

@@ -0,0 +1 @@
export { ElevenLabsVoiceSynthesizer } from "./tts.js";

View File

@@ -0,0 +1,122 @@
# `tts`
ElevenLabs implementation of `@suaveplan/voice`'s `VoiceSynthesizer`
interface. Posts to `/v1/text-to-speech/{voice_id}` for buffered
synthesis and the `/stream` variant for incremental streaming.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Application code that depends on `@suaveplan/voice/VoiceSynthesizer`
should be portable across providers. This adapter is the seam between
the abstract domain interface and ElevenLabs's REST API: it maps the
`SynthesisRequest` shape onto the wire body, mirrors voice
settings into ElevenLabs's `voice_settings` envelope, validates output
formats against the subset ElevenLabs supports, and remaps every
ElevenLabs error onto the corresponding `@suaveplan/voice` domain error.
## Features
- Implements `synthesize(request)` for buffered audio and
`synthesizeStream(request)` for an incremental `ReadableStream<Uint8Array>`.
- Eight ElevenLabs-supported `AudioFormat` values are passed through
with explicit mapping; unsupported formats throw
`AudioFormatUnsupportedError` client-side before the request is sent.
- Voice settings (stability, similarity_boost, style, use_speaker_boost)
are mapped from the camelCase names to the wire snake_case
names with all four optional.
- Latency mode 0–4 is forwarded as `optimize_streaming_latency`.
- Pronunciation dictionary IDs are wrapped into the
`pronunciation_dictionary_locators` envelope ElevenLabs expects.
- Errors are remapped: 401 → `ProviderNotConfiguredError`, 429 →
`ProviderRateLimitedError` (with `retryAfterMs`), `STREAM.INTERRUPTED`
→ `TtsStreamInterruptedError`, everything else →
`TtsSynthesisFailedError`.
## Usage: Basic Example
```ts
import {
createElevenLabsAdapters,
} from "@suaveplan/third-party-elevenlabs";
const { synthesizer } = createElevenLabsAdapters({
apiKey: process.env.ELEVENLABS_KEY!,
});
const result = await synthesizer.synthesize({
text: "hello world",
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
await Bun.write("hello.mp3", result.audio);
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsVoiceSynthesizer,
} from "@suaveplan/third-party-elevenlabs";
const client = new ElevenLabsClient({ apiKey: KEY });
const synth = new ElevenLabsVoiceSynthesizer(client);
const stream = synth.synthesizeStream({
text: "long passage…",
voiceId: "abc",
modelId: "eleven_multilingual_v2",
languageCode: "en",
outputFormat: "pcm_44100",
latencyMode: 3,
voiceSettings: {
stability: 0.4,
similarityBoost: 0.6,
style: 0.1,
speakerBoost: true,
},
previousText: "Once upon a time …",
nextText: "and they lived happily.",
seed: 42,
pronunciationDictionaryIds: ["dict-en-1"],
});
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
sink.write(value);
}
```
## API Reference
- `class ElevenLabsVoiceSynthesizer implements VoiceSynthesizer`
- `providerName: "elevenlabs"`
- `synthesize(request: SynthesisRequest): Promise<SynthesisResult>` —
`result.audio` is the raw bytes; `format` is the requested format
(defaults to `mp3_44100_128`); `requestId` and `charactersUsed` are
populated from response headers when present.
- `synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>`
— errors during the stream are surfaced via stream `controller.error`.
Throws (sync from `synthesizeStream` if format is unsupported, async
otherwise): `AudioFormatUnsupportedError`, `ProviderNotConfiguredError`,
`ProviderRateLimitedError`, `TtsSynthesisFailedError`,
`TtsStreamInterruptedError`.
## Implementation Notes
- The default `outputFormat` for the result is `mp3_44100_128` — this is
ElevenLabs's server-side default and matches the bytes you'll actually
receive when no format is requested.
- `voice_id` is `encodeURIComponent`-escaped because some workspace IDs
contain spaces.
- Format mapping is a static record rather than a passthrough so that
adding a new format requires an explicit code change, not a silent
wire-passthrough that ElevenLabs might or might not accept.
- The streaming path constructs the `ReadableStream` itself instead of
returning the raw response body so that error remapping can happen
inside `controller.error` rather than as a synchronous throw.

View File

@@ -0,0 +1,272 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
AudioFormatUnsupportedError,
isVoiceSynthesizer,
ProviderNotConfiguredError,
ProviderRateLimitedError,
TtsStreamInterruptedError,
TtsSynthesisFailedError,
} from "@suaveplan/voice";
import { ElevenLabsVoiceSynthesizer } from "./tts.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
synth: ElevenLabsVoiceSynthesizer;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
synth: new ElevenLabsVoiceSynthesizer(
new ElevenLabsClient({
apiKey: "key",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function audio(
bytes: number[],
headers: Record<string, string> = {}
): Response {
return new Response(new Uint8Array(bytes), {
status: 200,
headers: { "content-type": "audio/mpeg", ...headers },
});
}
describe("ElevenLabsVoiceSynthesizer", () => {
it("identifies as 'elevenlabs' and satisfies VoiceSynthesizer", () => {
const { synth } = client([]);
expect(synth.providerName).toBe("elevenlabs");
expect(isVoiceSynthesizer(synth)).toBe(true);
});
it("synthesize posts JSON body and returns SynthesisResult", async () => {
const { synth, calls } = client([
audio([1, 2, 3], {
"x-request-id": "rid-1",
"character-cost": "12",
}),
]);
const res = await synth.synthesize({
text: "hello",
voiceId: "v123",
modelId: "eleven_multilingual_v2",
languageCode: "en",
outputFormat: "mp3_44100_128",
latencyMode: 2,
voiceSettings: {
stability: 0.4,
similarityBoost: 0.6,
style: 0.1,
speakerBoost: true,
},
previousText: "prev",
nextText: "next",
seed: 42,
pronunciationDictionaryIds: ["pd1"],
});
expect(res.audio).toEqual(new Uint8Array([1, 2, 3]));
expect(res.format).toBe("mp3_44100_128");
expect(res.requestId).toBe("rid-1");
expect(res.charactersUsed).toBe(12);
const c = calls[0];
expect(c?.url).toContain("/v1/text-to-speech/v123");
expect(c?.url).toContain("output_format=mp3_44100_128");
expect(c?.url).toContain("optimize_streaming_latency=2");
const body = JSON.parse(String(c?.init.body));
expect(body.text).toBe("hello");
expect(body.model_id).toBe("eleven_multilingual_v2");
expect(body.language_code).toBe("en");
expect(body.previous_text).toBe("prev");
expect(body.next_text).toBe("next");
expect(body.seed).toBe(42);
expect(body.voice_settings).toEqual({
stability: 0.4,
similarity_boost: 0.6,
style: 0.1,
use_speaker_boost: true,
});
expect(body.pronunciation_dictionary_locators).toEqual([
{ pronunciation_dictionary_id: "pd1" },
]);
});
it("synthesize defaults format to mp3_44100_128 when omitted", async () => {
const { synth } = client([audio([0])]);
const res = await synth.synthesize({ text: "x", voiceId: "v" });
expect(res.format).toBe("mp3_44100_128");
});
it("synthesize url-encodes voice id with special chars", async () => {
const { synth, calls } = client([audio([0])]);
await synth.synthesize({ text: "x", voiceId: "id with space" });
expect(calls[0]?.url).toContain("/v1/text-to-speech/id%20with%20space");
});
it("synthesize throws AudioFormatUnsupportedError for non-elevenlabs format", async () => {
const { synth } = client([]);
await expect(
synth.synthesize({
text: "x",
voiceId: "v",
outputFormat: "wav_44100",
})
).rejects.toBeInstanceOf(AudioFormatUnsupportedError);
});
it("synthesize remaps 401 to ProviderNotConfiguredError", async () => {
const { synth } = client([new Response("", { status: 401 })]);
await expect(
synth.synthesize({ text: "x", voiceId: "v" })
).rejects.toBeInstanceOf(ProviderNotConfiguredError);
});
it("synthesize remaps 429 with retry-after to ProviderRateLimitedError", async () => {
const { synth } = client([
new Response("", {
status: 429,
headers: { "retry-after": "5" },
}),
]);
let caught: unknown;
try {
await synth.synthesize({ text: "x", voiceId: "v" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
expect(
(caught as ProviderRateLimitedError).context?.["retryAfterMs"]
).toBe(5000);
});
it("synthesize remaps 429 without retry-after still to ProviderRateLimitedError", async () => {
const { synth } = client([new Response("", { status: 429 })]);
await expect(
synth.synthesize({ text: "x", voiceId: "v" })
).rejects.toBeInstanceOf(ProviderRateLimitedError);
});
it("synthesize remaps generic API failure to TtsSynthesisFailedError", async () => {
const { synth } = client([new Response("", { status: 500 })]);
await expect(
synth.synthesize({ text: "x", voiceId: "v" })
).rejects.toBeInstanceOf(TtsSynthesisFailedError);
});
it("synthesize lets non-ElevenLabs errors propagate", async () => {
const boom = new TypeError("not a fetch error");
const fetchImpl: typeof fetch = () => Promise.reject(boom);
const synth = new ElevenLabsVoiceSynthesizer(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(
synth.synthesize({ text: "x", voiceId: "v" })
).rejects.toBeDefined();
});
it("synthesize omits empty pronunciation_dictionary_locators", async () => {
const { synth, calls } = client([audio([0])]);
await synth.synthesize({
text: "x",
voiceId: "v",
pronunciationDictionaryIds: [],
});
const body = JSON.parse(String(calls[0]?.init.body));
expect(body.pronunciation_dictionary_locators).toBeUndefined();
});
it("synthesizeStream returns a ReadableStream of bytes", async () => {
const stream = new ReadableStream<Uint8Array>({
start(c): void {
c.enqueue(new Uint8Array([5, 6]));
c.close();
},
});
const { synth, calls } = client([
new Response(stream, { status: 200 }),
]);
const out = synth.synthesizeStream({
text: "x",
voiceId: "v",
outputFormat: "mp3_22050_32",
latencyMode: 4,
});
const reader = out.getReader();
const { value } = await reader.read();
expect(value).toEqual(new Uint8Array([5, 6]));
const { done } = await reader.read();
expect(done).toBe(true);
expect(calls[0]?.url).toContain("/v1/text-to-speech/v/stream");
expect(calls[0]?.url).toContain("output_format=mp3_22050_32");
});
it("synthesizeStream propagates rate-limit as ProviderRateLimitedError on stream", async () => {
const { synth } = client([new Response("", { status: 429 })]);
const out = synth.synthesizeStream({ text: "x", voiceId: "v" });
const reader = out.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(
ProviderRateLimitedError
);
});
it("synthesizeStream throws AudioFormatUnsupportedError synchronously for unsupported format", () => {
const { synth } = client([]);
expect(() =>
synth.synthesizeStream({
text: "x",
voiceId: "v",
outputFormat: "opus_48000",
})
).toThrow(AudioFormatUnsupportedError);
});
it("synthesizeStream remaps 500 to TtsSynthesisFailedError on the stream", async () => {
const { synth } = client([new Response("", { status: 500 })]);
const out = synth.synthesizeStream({ text: "x", voiceId: "v" });
const reader = out.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(
TtsSynthesisFailedError
);
});
it("synthesizeStream propagates explicit TtsStreamInterruptedError when error.code is STREAM.INTERRUPTED", async () => {
// Force a StreamInterruptedError by failing the fetch with an
// error our remapper will see.
const { StreamInterruptedError } = await import(
"@suaveplan/third-party-elevenlabs"
);
const fetchImpl: typeof fetch = () =>
Promise.reject(new StreamInterruptedError({ endpoint: "x" }));
const c = new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
});
const synth = new ElevenLabsVoiceSynthesizer(c);
const out = synth.synthesizeStream({ text: "x", voiceId: "v" });
const reader = out.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(
TtsStreamInterruptedError
);
});
});

View File

@@ -0,0 +1,217 @@
import type { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
ELEVENLABS_CODES,
ElevenLabsApiError,
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import type {
AudioFormat,
LatencyMode,
SynthesisRequest,
SynthesisResult,
VoiceSettings,
} from "@suaveplan/voice";
import {
AudioFormatUnsupportedError,
ProviderNotConfiguredError,
ProviderRateLimitedError,
TtsStreamInterruptedError,
TtsSynthesisFailedError,
type VoiceSynthesizer,
} from "@suaveplan/voice";
const FORMAT_MAP: Readonly<Partial<Record<AudioFormat, string>>> = {
mp3_22050_32: "mp3_22050_32",
mp3_44100_128: "mp3_44100_128",
mp3_44100_192: "mp3_44100_192",
pcm_16000: "pcm_16000",
pcm_22050: "pcm_22050",
pcm_44100: "pcm_44100",
ulaw_8000: "ulaw_8000",
};
function mapFormat(format: AudioFormat | undefined): string | undefined {
if (format === undefined) return undefined;
const mapped = FORMAT_MAP[format];
if (mapped === undefined) {
throw new AudioFormatUnsupportedError({ format });
}
return mapped;
}
function mapVoiceSettings(
s: VoiceSettings | undefined
): Readonly<Record<string, unknown>> | undefined {
if (s === undefined) return undefined;
const out: Record<string, unknown> = {};
if (s.stability !== undefined) out["stability"] = s.stability;
if (s.similarityBoost !== undefined)
out["similarity_boost"] = s.similarityBoost;
if (s.style !== undefined) out["style"] = s.style;
if (s.speakerBoost !== undefined) out["use_speaker_boost"] = s.speakerBoost;
return out;
}
function buildBody(req: SynthesisRequest): Readonly<Record<string, unknown>> {
const body: Record<string, unknown> = { text: req.text };
if (req.modelId !== undefined) body["model_id"] = req.modelId;
if (req.languageCode !== undefined)
body["language_code"] = req.languageCode;
const settings = mapVoiceSettings(req.voiceSettings);
if (settings !== undefined) body["voice_settings"] = settings;
if (req.previousText !== undefined)
body["previous_text"] = req.previousText;
if (req.nextText !== undefined) body["next_text"] = req.nextText;
if (req.seed !== undefined) body["seed"] = req.seed;
if (
req.pronunciationDictionaryIds !== undefined &&
req.pronunciationDictionaryIds.length > 0
) {
body["pronunciation_dictionary_locators"] =
req.pronunciationDictionaryIds.map((id) => ({
pronunciation_dictionary_id: id,
}));
}
return body;
}
function buildQuery(
format: string | undefined,
latency: LatencyMode | undefined
): Readonly<Record<string, string | number | boolean | undefined>> | undefined {
const q: Record<string, string | number> = {};
if (format !== undefined) q["output_format"] = format;
if (latency !== undefined) q["optimize_streaming_latency"] = latency;
return Object.keys(q).length > 0 ? q : undefined;
}
function remapError(err: unknown, fallback: string): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const retryAfterMs = ctx?.retryAfterMs;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (retryAfterMs !== undefined) opts.retryAfterMs = retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
if (
err instanceof ElevenLabsApiError &&
err.code === ELEVENLABS_CODES["STREAM.INTERRUPTED"]
) {
throw new TtsStreamInterruptedError({ cause: err });
}
throw new TtsSynthesisFailedError({ cause: err, message: fallback });
}
/**
* ElevenLabs implementation of `VoiceSynthesizer`. Calls
* `POST /v1/text-to-speech/{voice_id}` for buffered synthesis and the
* `/stream` variant for incremental streaming.
*/
export class ElevenLabsVoiceSynthesizer implements VoiceSynthesizer {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async synthesize(request: SynthesisRequest): Promise<SynthesisResult> {
const fmt = mapFormat(request.outputFormat);
const path = `/v1/text-to-speech/${encodeURIComponent(request.voiceId)}`;
const query = buildQuery(fmt, request.latencyMode);
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
body: Readonly<Record<string, unknown>>;
accept: string;
query?: Readonly<
Record<string, string | number | boolean | undefined>
>;
signal?: AbortSignal;
} = {
method: "POST",
path,
feature: "tts",
operation: "synthesize",
body: buildBody(request),
accept: "audio/mpeg",
};
if (query !== undefined) init.query = query;
if (request.signal !== undefined) init.signal = request.signal;
let response: Awaited<ReturnType<ElevenLabsClient["fetchBinary"]>>;
try {
response = await this.#client.fetchBinary(init);
} catch (err) {
remapError(err, "ElevenLabs synthesize failed");
}
const result: {
-readonly [K in keyof SynthesisResult]: SynthesisResult[K];
} = {
audio: response.body,
format: request.outputFormat ?? "mp3_44100_128",
};
if (response.metadata.requestId !== undefined)
result.requestId = response.metadata.requestId;
if (response.metadata.characterCost !== undefined)
result.charactersUsed = response.metadata.characterCost;
return result;
}
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array> {
const fmt = mapFormat(request.outputFormat);
const client = this.#client;
const path = `/v1/text-to-speech/${encodeURIComponent(request.voiceId)}/stream`;
const query = buildQuery(fmt, request.latencyMode);
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
body: Readonly<Record<string, unknown>>;
accept: string;
query?: Readonly<
Record<string, string | number | boolean | undefined>
>;
signal?: AbortSignal;
} = {
method: "POST",
path,
feature: "tts",
operation: "synthesize_stream",
body: buildBody(request),
accept: "audio/mpeg",
};
if (query !== undefined) init.query = query;
if (request.signal !== undefined) init.signal = request.signal;
return new ReadableStream<Uint8Array>({
async start(controller): Promise<void> {
try {
const res = await client.fetchStream(init);
const reader = res.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value !== undefined) controller.enqueue(value);
}
controller.close();
} finally {
reader.releaseLock();
}
} catch (err) {
try {
remapError(err, "ElevenLabs synthesizeStream failed");
} catch (mapped) {
controller.error(mapped);
}
}
},
});
}
}

View File

@@ -0,0 +1 @@
export { ElevenLabsVoiceIsolationEngine } from "./voice-isolation.js";

View File

@@ -0,0 +1,92 @@
# `audio-isolation`
ElevenLabs implementation of `@suaveplan/voice`'s `VoiceIsolationEngine`
interface. Posts the audio as multipart form-data to
`POST /v1/audio-isolation` and returns the isolated voice as raw bytes.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Voice isolation is one of those capabilities every audio platform
exposes differently — some take a URL, some take a base64 blob, some
return WAV, some return MP3. This adapter handles ElevenLabs's
multipart-only contract and presents the canonical
`@suaveplan/voice/IsolationResult` shape so callers can swap providers
without touching their code path. Buffered upload, error remapping, and
default-format selection all live here.
## Features
- Accepts `Uint8Array`, `ReadableStream<Uint8Array>`, or a URL string as
the audio source — streams are drained and URLs are fetched into
memory first because ElevenLabs requires a complete body.
- Returns the isolated audio as `Uint8Array` plus the format the caller
asked for (defaulting to `mp3_44100_128`).
- Surfaces the upstream `x-request-id` on the result when present.
- Errors are remapped: 401 → `ProviderNotConfiguredError`, 429 →
`ProviderRateLimitedError` (with `retryAfterMs`), URL-fetch failures
and 5xx → `VoiceIsolationFailedError`.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { isolation } = createElevenLabsAdapters({ apiKey: KEY });
const noisy = new Uint8Array(await Bun.file("noisy.wav").arrayBuffer());
const { audio } = await isolation.isolate({ audio: noisy });
await Bun.write("clean.mp3", audio);
```
## Usage: Advanced Example
```ts
import {
ElevenLabsClient,
ElevenLabsVoiceIsolationEngine,
} from "@suaveplan/third-party-elevenlabs";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 60_000);
const eng = new ElevenLabsVoiceIsolationEngine(
new ElevenLabsClient({
apiKey: KEY,
timeoutMs: 120_000,
maxRetries: 5,
})
);
const result = await eng.isolate({
audio: "https://example.com/raw-recording.wav",
outputFormat: "wav_44100",
signal: ctrl.signal,
});
console.log(`isolated ${result.audio.byteLength} bytes`, result.requestId);
```
## API Reference
- `class ElevenLabsVoiceIsolationEngine implements VoiceIsolationEngine`
- `providerName: "elevenlabs"`
- `isolate(request: IsolationRequest): Promise<IsolationResult>` —
returns `{ audio, format, requestId? }`.
Throws: `ProviderNotConfiguredError`, `ProviderRateLimitedError`,
`VoiceIsolationFailedError`.
## Implementation Notes
- `outputFormat` on the result is purely a label — ElevenLabs's
`/v1/audio-isolation` endpoint doesn't currently accept a format
parameter, so the bytes are whatever ElevenLabs sends (typically MP3).
Callers that need a specific format should re-encode after isolation.
- URL audio sources are fetched with the global `fetch` (not the typed
client) because they're external URLs that don't accept the ElevenLabs
API key.
- Long uploads (> 30s) require bumping `timeoutMs` on the underlying
client. The default is 30s.

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
isVoiceIsolationEngine,
ProviderNotConfiguredError,
ProviderRateLimitedError,
VoiceIsolationFailedError,
} from "@suaveplan/voice";
import { ElevenLabsVoiceIsolationEngine } from "./voice-isolation.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
iso: ElevenLabsVoiceIsolationEngine;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
iso: new ElevenLabsVoiceIsolationEngine(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function audioResp(
bytes: number[],
headers: Record<string, string> = {}
): Response {
return new Response(new Uint8Array(bytes), {
status: 200,
headers: { "content-type": "audio/mpeg", ...headers },
});
}
describe("ElevenLabsVoiceIsolationEngine", () => {
it("identifies as 'elevenlabs' and satisfies VoiceIsolationEngine", () => {
const { iso } = client([]);
expect(iso.providerName).toBe("elevenlabs");
expect(isVoiceIsolationEngine(iso)).toBe(true);
});
it("isolate posts multipart and returns IsolationResult", async () => {
const { iso, calls } = client([
audioResp([9, 9], { "x-request-id": "rid" }),
]);
const r = await iso.isolate({ audio: new Uint8Array([1, 2]) });
expect(r.audio).toEqual(new Uint8Array([9, 9]));
expect(r.format).toBe("mp3_44100_128");
expect(r.requestId).toBe("rid");
expect(calls[0]?.url).toContain("/v1/audio-isolation");
expect(calls[0]?.init.body).toBeInstanceOf(FormData);
});
it("isolate respects requested outputFormat in result", async () => {
const { iso } = client([audioResp([1])]);
const r = await iso.isolate({
audio: new Uint8Array(),
outputFormat: "pcm_44100",
});
expect(r.format).toBe("pcm_44100");
});
it("isolate accepts ReadableStream audio", async () => {
const { iso } = client([audioResp([1])]);
const stream = new ReadableStream<Uint8Array>({
start(c): void {
c.enqueue(new Uint8Array([1]));
c.enqueue(new Uint8Array([2]));
c.close();
},
});
const r = await iso.isolate({ audio: stream });
expect(r.audio.byteLength).toBe(1);
});
it("isolate accepts URL audio", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response(new Uint8Array([1]), { status: 200 })
)) as unknown as typeof fetch;
try {
const { iso } = client([audioResp([1])]);
const r = await iso.isolate({ audio: "https://example.com/a.wav" });
expect(r.audio).toBeDefined();
} finally {
globalThis.fetch = originalFetch;
}
});
it("isolate throws VoiceIsolationFailedError when audio URL fetch fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((): Promise<Response> =>
Promise.resolve(
new Response("", { status: 500 })
)) as unknown as typeof fetch;
try {
const { iso } = client([]);
await expect(
iso.isolate({ audio: "https://example.com/a.wav" })
).rejects.toBeInstanceOf(VoiceIsolationFailedError);
} finally {
globalThis.fetch = originalFetch;
}
});
it("isolate remaps 401 to ProviderNotConfiguredError", async () => {
const { iso } = client([new Response("", { status: 401 })]);
await expect(
iso.isolate({ audio: new Uint8Array() })
).rejects.toBeInstanceOf(ProviderNotConfiguredError);
});
it("isolate remaps 429 to ProviderRateLimitedError", async () => {
const { iso } = client([
new Response("", { status: 429, headers: { "retry-after": "2" } }),
]);
let caught: unknown;
try {
await iso.isolate({ audio: new Uint8Array() });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
expect(
(caught as ProviderRateLimitedError).context?.["retryAfterMs"]
).toBe(2000);
});
it("isolate remaps generic API failure to VoiceIsolationFailedError", async () => {
const { iso } = client([new Response("", { status: 500 })]);
await expect(
iso.isolate({ audio: new Uint8Array() })
).rejects.toBeInstanceOf(VoiceIsolationFailedError);
});
it("isolate lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network"));
const iso = new ElevenLabsVoiceIsolationEngine(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(
iso.isolate({ audio: new Uint8Array() })
).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,124 @@
import type {
ElevenLabsClient,
MultipartField,
} from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
type IsolationRequest,
type IsolationResult,
ProviderNotConfiguredError,
ProviderRateLimitedError,
type VoiceIsolationEngine,
VoiceIsolationFailedError,
} from "@suaveplan/voice";
async function bufferAudio(
src: IsolationRequest["audio"]
): Promise<Uint8Array> {
if (src instanceof Uint8Array) return src;
if (typeof src === "string") {
const res = await fetch(src);
if (!res.ok)
throw new VoiceIsolationFailedError({
message: `Failed to fetch audio URL: ${res.status}`,
});
return new Uint8Array(await res.arrayBuffer());
}
const reader = src.getReader();
const chunks: Uint8Array[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value !== undefined) chunks.push(value);
}
} finally {
reader.releaseLock();
}
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
return merged;
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new VoiceIsolationFailedError({ cause: err });
}
/**
* ElevenLabs implementation of `VoiceIsolationEngine`. Posts the audio as
* multipart form-data to `POST /v1/audio-isolation` and returns the
* isolated voice as raw bytes.
*/
export class ElevenLabsVoiceIsolationEngine implements VoiceIsolationEngine {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async isolate(request: IsolationRequest): Promise<IsolationResult> {
const audio = await bufferAudio(request.audio);
const fields: MultipartField[] = [
{
kind: "file",
name: "audio",
filename: "audio.bin",
contentType: "application/octet-stream",
value: audio,
},
];
const init: {
method: "POST";
path: string;
feature: string;
operation: string;
fields: readonly MultipartField[];
accept: string;
signal?: AbortSignal;
} = {
method: "POST",
path: "/v1/audio-isolation",
feature: "isolation",
operation: "isolate",
fields,
accept: "audio/mpeg",
};
if (request.signal !== undefined) init.signal = request.signal;
let response: Awaited<
ReturnType<ElevenLabsClient["fetchMultipartBinary"]>
>;
try {
response = await this.#client.fetchMultipartBinary(init);
} catch (err) {
remapError(err);
}
const out: {
-readonly [K in keyof IsolationResult]: IsolationResult[K];
} = {
audio: response.body,
format: request.outputFormat ?? "mp3_44100_128",
};
if (response.metadata.requestId !== undefined)
out.requestId = response.metadata.requestId;
return out;
}
}

View File

@@ -0,0 +1 @@
export { ElevenLabsVoiceRepository } from "./voices.js";

View File

@@ -0,0 +1,100 @@
# `voices`
ElevenLabs implementation of `@suaveplan/voice`'s `VoiceRepository`
interface. Lists voices via `GET /v1/voices` and looks them up via
`GET /v1/voices/{voice_id}`. 404 on lookup is normalised to `null` per
the contract.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
ElevenLabs returns voice records with a labels bag (`accent`,
`description`, `age`, `gender`, `use_case`) and a settings bag
(`stability`, `similarity_boost`, `style`, `use_speaker_boost`) that
look nothing like the `Voice` shape. This adapter maps the
upstream record onto the package schema, normalises gender / age
strings, copies labels into a `metadata` bag for round-tripping, and
honours the `VoiceRepository.get()` contract that "not found" must
resolve to `null` rather than throw.
## Features
- `list(options?)` — paged listing with `category`, `languageCode`,
`search`, `pageSize`, and `pageToken` filters.
- `get(voiceId)` — returns the mapped voice or `null` on 404.
- Maps unknown / non-canonical category strings to `"premade"`.
- Tolerates missing `gender` / `age` fields and only emits values that
match the canonical enums; unknown values are dropped silently.
- Pulls language code from `fine_tuning.language` into a single-element
`languageCodes` array.
- Settings mapping converts each ElevenLabs snake_case field into the
camelCase equivalent, omitting unspecified entries.
- Description falls back to `labels.description` when the top-level
field is absent.
## Usage: Basic Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { voices } = createElevenLabsAdapters({ apiKey: KEY });
const page = await voices.list({ category: "premade" });
for (const v of page.voices) console.log(v.id, v.name);
const adam = await voices.get("21m00Tcm4TlvDq8ikWAM");
if (adam === null) throw new Error("voice missing");
```
## Usage: Advanced Example
```ts
import { createElevenLabsAdapters } from "@suaveplan/third-party-elevenlabs";
const { voices } = createElevenLabsAdapters({ apiKey: KEY });
let pageToken: string | undefined;
const all: Voice[] = [];
do {
const page = await voices.list({
category: "professional",
languageCode: "en",
search: "narrator",
pageSize: 50,
...(pageToken !== undefined ? { pageToken } : {}),
});
all.push(...page.voices);
pageToken = page.hasMore ? page.nextPageToken : undefined;
} while (pageToken !== undefined);
console.log(`${all.length} voices retrieved`);
```
## API Reference
- `class ElevenLabsVoiceRepository implements VoiceRepository`
- `providerName: "elevenlabs"`
- `list(options?: ListVoicesOptions): Promise<VoiceList>` — returns
`{ voices, hasMore, nextPageToken? }`. `hasMore` defaults to `false`
when the upstream omits it.
- `get(voiceId: string): Promise<Voice | null>` — `null` on 404, throws
on any other error.
Throws: `ProviderNotConfiguredError`, `ProviderRateLimitedError`, and
generic VoiceError-shaped failures.
## Implementation Notes
- Age detection uses substring matching on the ElevenLabs label
(`"young adult"` → `"young"`, `"middle aged"` → `"middle"`, `"old man"`
→ `"old"`) because ElevenLabs has no canonical enum.
- Gender is matched exactly against `male` / `female` / `neutral`; other
values (e.g. ElevenLabs's occasional `"non-binary"`) are dropped — the
raw label remains accessible via `metadata`.
- `voice_id` is `encodeURIComponent`-escaped before being interpolated
into the URL so that workspace IDs with spaces work correctly.
- A 404 on `get` is the **only** non-2xx response that's swallowed; every
other error (401 / 422 / 429 / 5xx) throws.

View File

@@ -0,0 +1,232 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
import {
isVoiceRepository,
ProviderNotConfiguredError,
ProviderRateLimitedError,
} from "@suaveplan/voice";
import { ElevenLabsVoiceRepository } from "./voices.js";
interface FetchCall {
url: string;
init: RequestInit;
}
function client(responses: Array<Response | Error>): {
repo: ElevenLabsVoiceRepository;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
let i = 0;
const fetchImpl: typeof fetch = (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
const slot = responses[i++];
if (slot === undefined)
return Promise.reject(new Error("no more responses"));
if (slot instanceof Error) return Promise.reject(slot);
return Promise.resolve(slot);
};
return {
repo: new ElevenLabsVoiceRepository(
new ElevenLabsClient({
apiKey: "k",
fetchImpl,
maxRetries: 0,
initialRetryDelayMs: 1,
})
),
calls,
};
}
function jsonResp(
body: unknown,
headers: Record<string, string> = {}
): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json", ...headers },
});
}
describe("ElevenLabsVoiceRepository", () => {
it("identifies as 'elevenlabs' and satisfies VoiceRepository", () => {
const { repo } = client([]);
expect(repo.providerName).toBe("elevenlabs");
expect(isVoiceRepository(repo)).toBe(true);
});
it("list maps voices, paging fields, and labels", async () => {
const { repo, calls } = client([
jsonResp({
voices: [
{
voice_id: "v1",
name: "Adam",
category: "premade",
labels: {
accent: "american",
description: "deep",
age: "young adult",
gender: "male",
},
preview_url: "https://example.com/a.mp3",
settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
},
fine_tuning: { language: "en" },
},
{
voice_id: "v2",
name: "Sarah",
category: "professional",
labels: { gender: "female", age: "middle aged" },
},
],
has_more: true,
next_page_token: "tok",
}),
]);
const result = await repo.list({
category: "premade",
languageCode: "en",
search: "ad",
pageSize: 10,
pageToken: "p1",
});
expect(result.voices).toHaveLength(2);
expect(result.hasMore).toBe(true);
expect(result.nextPageToken).toBe("tok");
const [first, second] = result.voices;
expect(first?.id).toBe("v1");
expect(first?.gender).toBe("male");
expect(first?.accent).toBe("american");
expect(first?.age).toBe("young");
expect(first?.languageCodes).toEqual(["en"]);
expect(first?.previewUrl).toBe("https://example.com/a.mp3");
expect(first?.settings).toEqual({
stability: 0.5,
similarityBoost: 0.75,
style: 0,
speakerBoost: true,
});
expect(second?.age).toBe("middle");
const url = calls[0]?.url ?? "";
expect(url).toContain("category=premade");
expect(url).toContain("language_code=en");
expect(url).toContain("search=ad");
expect(url).toContain("page_size=10");
expect(url).toContain("next_page_token=p1");
});
it("list defaults hasMore to false when missing", async () => {
const { repo } = client([jsonResp({ voices: [] })]);
const result = await repo.list();
expect(result.hasMore).toBe(false);
expect(result.nextPageToken).toBeUndefined();
});
it("list maps unknown category to 'premade'", async () => {
const { repo } = client([
jsonResp({
voices: [{ voice_id: "v", name: "X", category: "wat" }],
}),
]);
const result = await repo.list();
expect(result.voices[0]?.category).toBe("premade");
});
it("list ignores unknown gender / age values", async () => {
const { repo } = client([
jsonResp({
voices: [
{
voice_id: "v",
name: "X",
labels: { gender: "robot", age: "centurion" },
},
],
}),
]);
const result = await repo.list();
expect(result.voices[0]?.gender).toBeUndefined();
expect(result.voices[0]?.age).toBeUndefined();
});
it("list passes empty options without query string", async () => {
const { repo, calls } = client([jsonResp({ voices: [] })]);
await repo.list();
expect(calls[0]?.url).not.toContain("?");
});
it("get returns a Voice on 200", async () => {
const { repo, calls } = client([
jsonResp({ voice_id: "v9", name: "Zara", category: "cloned" }),
]);
const v = await repo.get("v9");
expect(v?.id).toBe("v9");
expect(v?.category).toBe("cloned");
expect(calls[0]?.url).toContain("/v1/voices/v9");
});
it("get returns null on 404", async () => {
const { repo } = client([new Response("", { status: 404 })]);
const v = await repo.get("nope");
expect(v).toBeNull();
});
it("get url-encodes voice id", async () => {
const { repo, calls } = client([
jsonResp({ voice_id: "v 1", name: "n" }),
]);
await repo.get("v 1");
expect(calls[0]?.url).toContain("/v1/voices/v%201");
});
it("list remaps 401 to ProviderNotConfiguredError", async () => {
const { repo } = client([new Response("", { status: 401 })]);
await expect(repo.list()).rejects.toBeInstanceOf(
ProviderNotConfiguredError
);
});
it("list remaps 429 to ProviderRateLimitedError", async () => {
const { repo } = client([
new Response("", { status: 429, headers: { "retry-after": "4" } }),
]);
let caught: unknown;
try {
await repo.list();
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ProviderRateLimitedError);
expect(
(caught as ProviderRateLimitedError).context?.["retryAfterMs"]
).toBe(4000);
});
it("get remaps 401 to ProviderNotConfiguredError", async () => {
const { repo } = client([new Response("", { status: 401 })]);
await expect(repo.get("x")).rejects.toBeInstanceOf(
ProviderNotConfiguredError
);
});
it("get remaps 500 to a VoiceError", async () => {
const { repo } = client([new Response("", { status: 500 })]);
await expect(repo.get("x")).rejects.toBeDefined();
});
it("list lets non-ElevenLabs errors propagate", async () => {
const fetchImpl: typeof fetch = () =>
Promise.reject(new TypeError("network"));
const repo = new ElevenLabsVoiceRepository(
new ElevenLabsClient({ apiKey: "k", fetchImpl, maxRetries: 0 })
);
await expect(repo.list()).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,210 @@
import type {
ElevenLabsClient,
JsonResponse,
} from "@suaveplan/third-party-elevenlabs";
import {
RateLimitExceededError,
RequestNotFoundError,
UnauthorizedError,
} from "@suaveplan/third-party-elevenlabs";
import {
type ListVoicesOptions,
ProviderNotConfiguredError,
ProviderRateLimitedError,
VOICE_CODES,
type Voice,
type VoiceCategory,
VoiceError,
type VoiceList,
type VoiceRepository,
type VoiceSettings,
} from "@suaveplan/voice";
interface ElevenLabsVoiceLabels {
readonly accent?: string;
readonly description?: string;
readonly age?: string;
readonly gender?: string;
readonly use_case?: string;
}
interface ElevenLabsVoiceItem {
readonly voice_id: string;
readonly name: string;
readonly category?: string;
readonly labels?: ElevenLabsVoiceLabels;
readonly description?: string;
readonly preview_url?: string;
readonly settings?: {
readonly stability?: number;
readonly similarity_boost?: number;
readonly style?: number;
readonly use_speaker_boost?: boolean;
};
readonly available_for_tiers?: readonly string[];
readonly fine_tuning?: {
readonly language?: string;
readonly verification_attempts?: unknown;
};
}
interface ElevenLabsVoicesListResponse {
readonly voices: readonly ElevenLabsVoiceItem[];
readonly has_more?: boolean;
readonly next_page_token?: string;
readonly total_count?: number;
}
const CATEGORY_MAP: Readonly<Record<string, VoiceCategory>> = {
premade: "premade",
cloned: "cloned",
generated: "generated",
professional: "professional",
};
function mapCategory(raw: string | undefined): VoiceCategory {
if (raw === undefined) return "premade";
return CATEGORY_MAP[raw] ?? "premade";
}
function mapAge(raw: string | undefined): Voice["age"] | undefined {
if (raw === undefined) return undefined;
const lower = raw.toLowerCase();
if (lower.includes("young")) return "young";
if (lower.includes("old")) return "old";
if (lower.includes("middle")) return "middle";
return undefined;
}
function mapGender(raw: string | undefined): Voice["gender"] | undefined {
if (raw === undefined) return undefined;
const lower = raw.toLowerCase();
if (lower === "male" || lower === "female" || lower === "neutral")
return lower;
return undefined;
}
function mapSettings(
s: ElevenLabsVoiceItem["settings"]
): VoiceSettings | undefined {
if (s === undefined) return undefined;
const out: { -readonly [K in keyof VoiceSettings]: VoiceSettings[K] } = {};
if (s.stability !== undefined) out.stability = s.stability;
if (s.similarity_boost !== undefined)
out.similarityBoost = s.similarity_boost;
if (s.style !== undefined) out.style = s.style;
if (s.use_speaker_boost !== undefined)
out.speakerBoost = s.use_speaker_boost;
return out;
}
function mapVoice(item: ElevenLabsVoiceItem): Voice {
const out: { -readonly [K in keyof Voice]: Voice[K] } = {
id: item.voice_id,
name: item.name,
category: mapCategory(item.category),
};
const gender = mapGender(item.labels?.gender);
if (gender !== undefined) out.gender = gender;
if (item.labels?.accent !== undefined) out.accent = item.labels.accent;
const age = mapAge(item.labels?.age);
if (age !== undefined) out.age = age;
if (item.fine_tuning?.language !== undefined)
out.languageCodes = [item.fine_tuning.language];
const description = item.description ?? item.labels?.description;
if (description !== undefined) out.description = description;
if (item.preview_url !== undefined) out.previewUrl = item.preview_url;
const settings = mapSettings(item.settings);
if (settings !== undefined) out.settings = settings;
if (item.labels !== undefined) {
out.metadata = item.labels as unknown as Record<string, unknown>;
}
return out;
}
function remapError(err: unknown): never {
if (err instanceof UnauthorizedError) {
throw new ProviderNotConfiguredError({ cause: err });
}
if (err instanceof RateLimitExceededError) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
const opts: { cause: unknown; retryAfterMs?: number } = { cause: err };
if (ctx?.retryAfterMs !== undefined)
opts.retryAfterMs = ctx.retryAfterMs;
throw new ProviderRateLimitedError(opts);
}
throw new VoicesListFailedError({ cause: err });
}
class VoicesListFailedError extends VoiceError {
constructor(opts: { readonly cause: unknown }) {
super(
VOICE_CODES["PROVIDER.NOT_CONFIGURED"],
"ElevenLabs voices list failed",
{ cause: opts.cause }
);
}
}
/**
* ElevenLabs implementation of `VoiceRepository`. Lists voices via
* `GET /v1/voices` and looks them up via `GET /v1/voices/{voice_id}`.
* 404 on lookup is normalised to `null` per the contract.
*/
export class ElevenLabsVoiceRepository implements VoiceRepository {
readonly providerName = "elevenlabs";
readonly #client: ElevenLabsClient;
constructor(client: ElevenLabsClient) {
this.#client = client;
}
async list(options: ListVoicesOptions = {}): Promise<VoiceList> {
const query: Record<string, string | number> = {};
if (options.category !== undefined)
query["category"] = options.category;
if (options.languageCode !== undefined)
query["language_code"] = options.languageCode;
if (options.search !== undefined) query["search"] = options.search;
if (options.pageToken !== undefined)
query["next_page_token"] = options.pageToken;
if (options.pageSize !== undefined)
query["page_size"] = options.pageSize;
let response: JsonResponse<ElevenLabsVoicesListResponse>;
try {
response =
await this.#client.fetchJson<ElevenLabsVoicesListResponse>({
method: "GET",
path: "/v1/voices",
feature: "voices",
operation: "list",
...(Object.keys(query).length > 0 ? { query } : {}),
});
} catch (err) {
remapError(err);
}
const voices = response.body.voices.map(mapVoice);
const out: { -readonly [K in keyof VoiceList]: VoiceList[K] } = {
voices,
hasMore: response.body.has_more ?? false,
};
if (response.body.next_page_token !== undefined)
out.nextPageToken = response.body.next_page_token;
return out;
}
async get(voiceId: string): Promise<Voice | null> {
try {
const res = await this.#client.fetchJson<ElevenLabsVoiceItem>({
method: "GET",
path: `/v1/voices/${encodeURIComponent(voiceId)}`,
feature: "voices",
operation: "get",
});
return mapVoice(res.body);
} catch (err) {
if (err instanceof RequestNotFoundError) return null;
remapError(err);
}
}
}

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@suaveplan/error",
"@suaveplan/third-party-elevenlabs",
"@suaveplan/types",
"@suaveplan/voice",
"tslib",
"zod",
]);
});
it("has no peer dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([]);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports declares every engine subpath", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
const keys = Object.keys(pkg.exports ?? {});
for (const subpath of [
".",
"./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",
]) {
expect(keys).toContain(subpath);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/voice-elevenlabs",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/third-party-elevenlabs",
"@suaveplan/types",
"@suaveplan/voice",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"src/**/constants/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});