Initial Commit
This commit is contained in:
143
packages/media/audio/voice-elevenlabs/README.md
Normal file
143
packages/media/audio/voice-elevenlabs/README.md
Normal 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.
|
||||
|
||||
144
packages/media/audio/voice-elevenlabs/package.json
Normal file
144
packages/media/audio/voice-elevenlabs/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1083
packages/media/audio/voice-elevenlabs/src/coverage.test.ts
Normal file
1083
packages/media/audio/voice-elevenlabs/src/coverage.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateElevenLabsVoiceOptions,
|
||||
createElevenLabsVoice,
|
||||
type ElevenLabsVoiceBundle,
|
||||
} from "./create-elevenlabs-voice.js";
|
||||
20
packages/media/audio/voice-elevenlabs/src/index.ts
Normal file
20
packages/media/audio/voice-elevenlabs/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
ElevenLabsLanguage,
|
||||
ElevenLabsModel,
|
||||
} from "./models.js";
|
||||
export { ElevenLabsModelCatalog } from "./models.js";
|
||||
89
packages/media/audio/voice-elevenlabs/src/models/models.md
Normal file
89
packages/media/audio/voice-elevenlabs/src/models/models.md
Normal 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).
|
||||
140
packages/media/audio/voice-elevenlabs/src/models/models.test.ts
Normal file
140
packages/media/audio/voice-elevenlabs/src/models/models.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
136
packages/media/audio/voice-elevenlabs/src/models/models.ts
Normal file
136
packages/media/audio/voice-elevenlabs/src/models/models.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ElevenLabsSoundEffectEngine } from "./sound-effects.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
VoiceConversionRequest,
|
||||
VoiceConversionResult,
|
||||
} from "./speech-to-speech.js";
|
||||
export { ElevenLabsSpeechToSpeech } from "./speech-to-speech.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
packages/media/audio/voice-elevenlabs/src/stt/index.ts
Normal file
1
packages/media/audio/voice-elevenlabs/src/stt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ElevenLabsTranscriptionEngine } from "./stt.js";
|
||||
105
packages/media/audio/voice-elevenlabs/src/stt/stt.md
Normal file
105
packages/media/audio/voice-elevenlabs/src/stt/stt.md
Normal 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)`.
|
||||
239
packages/media/audio/voice-elevenlabs/src/stt/stt.test.ts
Normal file
239
packages/media/audio/voice-elevenlabs/src/stt/stt.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
221
packages/media/audio/voice-elevenlabs/src/stt/stt.ts
Normal file
221
packages/media/audio/voice-elevenlabs/src/stt/stt.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
packages/media/audio/voice-elevenlabs/src/tts/index.ts
Normal file
1
packages/media/audio/voice-elevenlabs/src/tts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ElevenLabsVoiceSynthesizer } from "./tts.js";
|
||||
122
packages/media/audio/voice-elevenlabs/src/tts/tts.md
Normal file
122
packages/media/audio/voice-elevenlabs/src/tts/tts.md
Normal 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.
|
||||
272
packages/media/audio/voice-elevenlabs/src/tts/tts.test.ts
Normal file
272
packages/media/audio/voice-elevenlabs/src/tts/tts.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
217
packages/media/audio/voice-elevenlabs/src/tts/tts.ts
Normal file
217
packages/media/audio/voice-elevenlabs/src/tts/tts.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ElevenLabsVoiceIsolationEngine } from "./voice-isolation.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ElevenLabsVoiceRepository } from "./voices.js";
|
||||
100
packages/media/audio/voice-elevenlabs/src/voices/voices.md
Normal file
100
packages/media/audio/voice-elevenlabs/src/voices/voices.md
Normal 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.
|
||||
232
packages/media/audio/voice-elevenlabs/src/voices/voices.test.ts
Normal file
232
packages/media/audio/voice-elevenlabs/src/voices/voices.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
210
packages/media/audio/voice-elevenlabs/src/voices/voices.ts
Normal file
210
packages/media/audio/voice-elevenlabs/src/voices/voices.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/voice-elevenlabs/tsconfig.lib.json
Normal file
12
packages/media/audio/voice-elevenlabs/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/media/audio/voice-elevenlabs/vite.config.ts
Normal file
38
packages/media/audio/voice-elevenlabs/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/media/audio/voice-elevenlabs/vitest.config.ts
Normal file
27
packages/media/audio/voice-elevenlabs/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user