Files
suaveplan/packages/media/audio/voice-elevenlabs/src/tts/tts.md
2026-05-27 23:47:53 +01:00

4.3 KiB
Raw Blame History

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.

(no submodules)

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

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

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.