@effect-uai/elevenlabs 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/ElevenLabsSynthesizer.d.mts +58 -0
- package/dist/ElevenLabsSynthesizer.d.mts.map +1 -0
- package/dist/ElevenLabsSynthesizer.mjs +82 -0
- package/dist/ElevenLabsSynthesizer.mjs.map +1 -0
- package/dist/ElevenLabsSynthesizer.test.d.mts +1 -0
- package/dist/ElevenLabsSynthesizer.test.mjs +32 -0
- package/dist/ElevenLabsSynthesizer.test.mjs.map +1 -0
- package/dist/ElevenLabsTranscriber.d.mts +57 -0
- package/dist/ElevenLabsTranscriber.d.mts.map +1 -0
- package/dist/ElevenLabsTranscriber.mjs +97 -0
- package/dist/ElevenLabsTranscriber.mjs.map +1 -0
- package/dist/ElevenLabsTranscriber.test.d.mts +1 -0
- package/dist/ElevenLabsTranscriber.test.mjs +58 -0
- package/dist/ElevenLabsTranscriber.test.mjs.map +1 -0
- package/dist/chunk-uyGKjUfl.mjs +14 -0
- package/dist/codec.d.mts +43 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +120 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/codec.test.d.mts +1 -0
- package/dist/codec.test.mjs +142 -0
- package/dist/codec.test.mjs.map +1 -0
- package/dist/dist-DMsg8YLb.mjs +13782 -0
- package/dist/dist-DMsg8YLb.mjs.map +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +5 -0
- package/dist/magic-string.es-CaWgF-3p.mjs +1013 -0
- package/dist/magic-string.es-CaWgF-3p.mjs.map +1 -0
- package/dist/models.d.mts +37 -0
- package/dist/models.d.mts.map +1 -0
- package/dist/models.mjs +1 -0
- package/dist/realtimeStt.d.mts +67 -0
- package/dist/realtimeStt.d.mts.map +1 -0
- package/dist/realtimeStt.mjs +163 -0
- package/dist/realtimeStt.mjs.map +1 -0
- package/dist/realtimeTts.d.mts +31 -0
- package/dist/realtimeTts.d.mts.map +1 -0
- package/dist/realtimeTts.mjs +77 -0
- package/dist/realtimeTts.mjs.map +1 -0
- package/package.json +64 -0
- package/src/ElevenLabsSynthesizer.test.ts +49 -0
- package/src/ElevenLabsSynthesizer.ts +165 -0
- package/src/ElevenLabsTranscriber.test.ts +63 -0
- package/src/ElevenLabsTranscriber.ts +179 -0
- package/src/codec.test.ts +128 -0
- package/src/codec.ts +185 -0
- package/src/index.ts +4 -0
- package/src/models.ts +46 -0
- package/src/realtimeStt.ts +273 -0
- package/src/realtimeTts.ts +147 -0
package/src/codec.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Encoding, Match, Option, Result } from "effect"
|
|
2
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
3
|
+
import type { AudioFormat, AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Shared TTS voice-settings shape (sync + streaming use the same fields)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export type VoiceSettings = {
|
|
10
|
+
readonly stability?: number
|
|
11
|
+
readonly similarityBoost?: number
|
|
12
|
+
readonly style?: number
|
|
13
|
+
readonly useSpeakerBoost?: boolean
|
|
14
|
+
readonly speed?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const wireVoiceSettings = (v: VoiceSettings | undefined) =>
|
|
18
|
+
v === undefined
|
|
19
|
+
? undefined
|
|
20
|
+
: {
|
|
21
|
+
...(v.stability !== undefined && { stability: v.stability }),
|
|
22
|
+
...(v.similarityBoost !== undefined && { similarity_boost: v.similarityBoost }),
|
|
23
|
+
...(v.style !== undefined && { style: v.style }),
|
|
24
|
+
...(v.useSpeakerBoost !== undefined && { use_speaker_boost: v.useSpeakerBoost }),
|
|
25
|
+
...(v.speed !== undefined && { speed: v.speed }),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// AudioFormat → ElevenLabs `output_format` query value
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ElevenLabs encodes container + sample rate + bitrate in a single
|
|
34
|
+
* dot-less slug (`mp3_44100_128`, `pcm_16000`, `wav_48000`, `ulaw_8000`,
|
|
35
|
+
* `opus_48000_128`, etc.). This helper inverts the common cases of our
|
|
36
|
+
* `AudioFormat` shape into that slug.
|
|
37
|
+
*
|
|
38
|
+
* Only the formats that map cleanly are produced. Anything we can't
|
|
39
|
+
* encode (mismatched bitrate, unsupported sample rate combination)
|
|
40
|
+
* fails `Unsupported` with the requested format echoed so the user can
|
|
41
|
+
* fix it.
|
|
42
|
+
*/
|
|
43
|
+
export const formatToOutputSlug: (format: AudioFormat) => Effect.Effect<string, AiError.AiError> = (
|
|
44
|
+
format,
|
|
45
|
+
) =>
|
|
46
|
+
Match.value(format).pipe(
|
|
47
|
+
Match.when({ container: "mp3" }, (f) =>
|
|
48
|
+
Effect.succeed(`mp3_${f.sampleRate}_${f.bitRate ?? 128}`),
|
|
49
|
+
),
|
|
50
|
+
Match.when({ container: "opus" }, (f) =>
|
|
51
|
+
Effect.succeed(`opus_${f.sampleRate}_${f.bitRate ?? 128}`),
|
|
52
|
+
),
|
|
53
|
+
Match.when({ container: "wav" }, (f) => Effect.succeed(`wav_${f.sampleRate}`)),
|
|
54
|
+
Match.when({ container: "raw", encoding: "pcm_s16le" }, (f) =>
|
|
55
|
+
Effect.succeed(`pcm_${f.sampleRate}`),
|
|
56
|
+
),
|
|
57
|
+
Match.when({ container: "raw", encoding: "pcm_mulaw" }, (f) =>
|
|
58
|
+
Effect.succeed(`ulaw_${f.sampleRate}`),
|
|
59
|
+
),
|
|
60
|
+
Match.when({ container: "raw", encoding: "pcm_alaw" }, (f) =>
|
|
61
|
+
Effect.succeed(`alaw_${f.sampleRate}`),
|
|
62
|
+
),
|
|
63
|
+
Match.orElse((f) =>
|
|
64
|
+
Effect.fail(
|
|
65
|
+
new AiError.Unsupported({
|
|
66
|
+
provider: "elevenlabs",
|
|
67
|
+
capability: "outputFormat",
|
|
68
|
+
reason: `Cannot encode ${JSON.stringify(f)} as ElevenLabs output_format. Supported: mp3 + (22050|44100), opus 48000, wav 48000, pcm/ulaw/alaw at any sample rate.`,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** Default output: 128 kbps MP3 at 44.1 kHz. */
|
|
75
|
+
export const defaultFormat: AudioFormat = {
|
|
76
|
+
container: "mp3",
|
|
77
|
+
encoding: "mp3",
|
|
78
|
+
sampleRate: 44100,
|
|
79
|
+
bitRate: 128,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// AudioSource → Blob (STT multipart upload)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const urlNotSupported: AiError.AiError = new AiError.InvalidRequest({
|
|
87
|
+
provider: "elevenlabs",
|
|
88
|
+
param: "audio",
|
|
89
|
+
raw: 'ElevenLabs accepts URLs via the `cloud_storage_url` field, not the `file` field. Fetch the URL yourself and pass `{ _tag: "bytes", bytes, mimeType }` to use the inline path.',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const decodeBase64ToBytes = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
93
|
+
Result.match(Encoding.decodeBase64(b64), {
|
|
94
|
+
onSuccess: Effect.succeed,
|
|
95
|
+
onFailure: (cause) =>
|
|
96
|
+
Effect.fail(
|
|
97
|
+
new AiError.InvalidRequest({ provider: "elevenlabs", param: "audio", raw: cause }),
|
|
98
|
+
),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const bytesToBlob = (bytes: Uint8Array, mimeType: string): Blob =>
|
|
102
|
+
new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mimeType })
|
|
103
|
+
|
|
104
|
+
export const audioToBlob: (audio: AudioSource) => Effect.Effect<Blob, AiError.AiError> =
|
|
105
|
+
Match.type<AudioSource>().pipe(
|
|
106
|
+
Match.tag("bytes", (a) => Effect.succeed(bytesToBlob(a.bytes, a.mimeType))),
|
|
107
|
+
Match.tag("base64", (a) =>
|
|
108
|
+
decodeBase64ToBytes(a.base64).pipe(Effect.map((bytes) => bytesToBlob(bytes, a.mimeType))),
|
|
109
|
+
),
|
|
110
|
+
Match.tag("url", () => Effect.fail(urlNotSupported)),
|
|
111
|
+
Match.exhaustive,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// MIME → file extension (for the multipart `filename` field)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
const EXTENSION_BY_MIME: ReadonlyArray<readonly [string, string]> = [
|
|
119
|
+
["mpeg", "mp3"],
|
|
120
|
+
["mp3", "mp3"],
|
|
121
|
+
["wav", "wav"],
|
|
122
|
+
["ogg", "ogg"],
|
|
123
|
+
["opus", "ogg"],
|
|
124
|
+
["flac", "flac"],
|
|
125
|
+
["aac", "aac"],
|
|
126
|
+
["mp4", "m4a"],
|
|
127
|
+
["m4a", "m4a"],
|
|
128
|
+
["webm", "webm"],
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
export const defaultFileName = (mimeType: string): string =>
|
|
132
|
+
Arr.findFirst(EXTENSION_BY_MIME, ([pattern]) => mimeType.includes(pattern)).pipe(
|
|
133
|
+
Option.match({
|
|
134
|
+
onNone: () => "audio",
|
|
135
|
+
onSome: ([, ext]) => `audio.${ext}`,
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// HTTP errors
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
|
|
144
|
+
Match.value(status).pipe(
|
|
145
|
+
Match.when(
|
|
146
|
+
429,
|
|
147
|
+
(): AiError.AiError => new AiError.RateLimited({ provider: "elevenlabs", raw: body }),
|
|
148
|
+
),
|
|
149
|
+
Match.whenOr(
|
|
150
|
+
408,
|
|
151
|
+
504,
|
|
152
|
+
(): AiError.AiError => new AiError.Timeout({ provider: "elevenlabs", raw: body }),
|
|
153
|
+
),
|
|
154
|
+
Match.when(
|
|
155
|
+
401,
|
|
156
|
+
(): AiError.AiError =>
|
|
157
|
+
new AiError.AuthFailed({ provider: "elevenlabs", subtype: "auth", raw: body }),
|
|
158
|
+
),
|
|
159
|
+
Match.when(
|
|
160
|
+
403,
|
|
161
|
+
(): AiError.AiError =>
|
|
162
|
+
new AiError.AuthFailed({ provider: "elevenlabs", subtype: "permission", raw: body }),
|
|
163
|
+
),
|
|
164
|
+
Match.when(
|
|
165
|
+
402,
|
|
166
|
+
(): AiError.AiError =>
|
|
167
|
+
new AiError.AuthFailed({ provider: "elevenlabs", subtype: "billing", raw: body }),
|
|
168
|
+
),
|
|
169
|
+
Match.when(
|
|
170
|
+
413,
|
|
171
|
+
(): AiError.AiError =>
|
|
172
|
+
new AiError.ContextLengthExceeded({ provider: "elevenlabs", raw: body }),
|
|
173
|
+
),
|
|
174
|
+
Match.when(
|
|
175
|
+
(n) => n >= 500,
|
|
176
|
+
(n): AiError.AiError =>
|
|
177
|
+
new AiError.Unavailable({ provider: "elevenlabs", status: n, raw: body }),
|
|
178
|
+
),
|
|
179
|
+
Match.orElse(
|
|
180
|
+
(): AiError.AiError => new AiError.InvalidRequest({ provider: "elevenlabs", raw: body }),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
export const transportFailure = (cause: unknown): AiError.AiError =>
|
|
185
|
+
new AiError.Unavailable({ provider: "elevenlabs", raw: cause })
|
package/src/index.ts
ADDED
package/src/models.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known ElevenLabs TTS model identifiers (as of mid-2026). The
|
|
3
|
+
* `(string & {})` tail keeps autocomplete on the literals while still
|
|
4
|
+
* accepting any string, so newly-released models work without an SDK
|
|
5
|
+
* update.
|
|
6
|
+
*
|
|
7
|
+
* - `eleven_v3` — most expressive; supports inline audio-tag emotion
|
|
8
|
+
* directives (`<laugh>`, `<whisper>`, etc.). 70+ languages.
|
|
9
|
+
* - `eleven_multilingual_v2` — production-grade multilingual. 29
|
|
10
|
+
* languages. Default for the API.
|
|
11
|
+
* - `eleven_turbo_v2_5` — low latency, 32 languages.
|
|
12
|
+
* - `eleven_flash_v2_5` — sub-100 ms first-byte, 32 languages.
|
|
13
|
+
*
|
|
14
|
+
* Reference: https://elevenlabs.io/docs/overview/models
|
|
15
|
+
*/
|
|
16
|
+
export type ElevenLabsTtsModel =
|
|
17
|
+
| "eleven_v3"
|
|
18
|
+
| "eleven_multilingual_v2"
|
|
19
|
+
| "eleven_turbo_v2_5"
|
|
20
|
+
| "eleven_flash_v2_5"
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
22
|
+
| (string & {})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Known ElevenLabs STT model identifiers.
|
|
26
|
+
*
|
|
27
|
+
* - `scribe_v2` — current generation, recommended.
|
|
28
|
+
* - `scribe_v1` — legacy.
|
|
29
|
+
* - `scribe_v2_realtime` — WebSocket streaming variant (Phase 2b).
|
|
30
|
+
*
|
|
31
|
+
* Reference: https://elevenlabs.io/docs/api-reference/speech-to-text
|
|
32
|
+
*/
|
|
33
|
+
export type ElevenLabsSttModel =
|
|
34
|
+
| "scribe_v2"
|
|
35
|
+
| "scribe_v1"
|
|
36
|
+
| "scribe_v2_realtime"
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
38
|
+
| (string & {})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ElevenLabs voice IDs are 20-character alphanumeric strings (e.g.
|
|
42
|
+
* `JBFqnCBsd6RMkjVDRZzb`). Both stock and cloned voices use the same
|
|
43
|
+
* opaque format, so there's no useful literal narrowing here — the
|
|
44
|
+
* full catalog is fetched dynamically via `GET /v1/voices`.
|
|
45
|
+
*/
|
|
46
|
+
export type ElevenLabsVoiceId = string
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Cause, Effect, Encoding, Match, Queue, Redacted, Schema, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import type { AudioFormat } from "@effect-uai/core/Audio"
|
|
6
|
+
import * as JSONL from "@effect-uai/core/JSONL"
|
|
7
|
+
import type { TranscriptEvent, WordTimestamp } from "@effect-uai/core/Transcript"
|
|
8
|
+
import type { CommonStreamTranscribeRequest } from "@effect-uai/core/Transcriber"
|
|
9
|
+
import { httpStatusError, transportFailure } from "./codec.js"
|
|
10
|
+
|
|
11
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// AudioFormat → audio_format slug
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const unsupportedInputFormat = (format: AudioFormat) =>
|
|
18
|
+
new AiError.Unsupported({
|
|
19
|
+
provider: "elevenlabs",
|
|
20
|
+
capability: "inputFormat",
|
|
21
|
+
reason: `ElevenLabs realtime STT accepts pcm_<rate> or ulaw_8000 only. Got ${JSON.stringify(format)}.`,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const inputFormatToSlug: (format: AudioFormat) => Effect.Effect<string, AiError.AiError> =
|
|
25
|
+
Match.type<AudioFormat>().pipe(
|
|
26
|
+
Match.when({ container: "raw", encoding: "pcm_s16le" }, (f) =>
|
|
27
|
+
Effect.succeed(`pcm_${f.sampleRate}`),
|
|
28
|
+
),
|
|
29
|
+
Match.when({ container: "raw", encoding: "pcm_mulaw", sampleRate: 8000 }, () =>
|
|
30
|
+
Effect.succeed("ulaw_8000"),
|
|
31
|
+
),
|
|
32
|
+
Match.orElse((f) => Effect.fail(unsupportedInputFormat(f))),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Single-use token — auth rides on a query param so any
|
|
37
|
+
// `globalThis.WebSocket` works (browser, Node 22+, Bun, Deno).
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const TokenWire = Schema.Struct({ token: Schema.String })
|
|
41
|
+
const decodeToken = Schema.decodeUnknownEffect(TokenWire)
|
|
42
|
+
|
|
43
|
+
export const fetchSingleUseToken = (cfg: Config) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const client = yield* HttpClient.HttpClient
|
|
46
|
+
const url = `${cfg.baseUrl ?? "https://api.elevenlabs.io/v1"}/single-use-token/realtime_scribe`
|
|
47
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
48
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
49
|
+
)
|
|
50
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
51
|
+
if (response.status >= 400) {
|
|
52
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
53
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
54
|
+
}
|
|
55
|
+
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
56
|
+
const wire = yield* decodeToken(json).pipe(
|
|
57
|
+
Effect.mapError(
|
|
58
|
+
(cause) =>
|
|
59
|
+
new AiError.GenerationFailed({
|
|
60
|
+
provider: "elevenlabs",
|
|
61
|
+
raw: { message: "single-use-token response missing `token` field", cause, json },
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
return wire.token
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Wire schemas (server → client)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const RealtimeWord = Schema.Struct({
|
|
73
|
+
text: Schema.String,
|
|
74
|
+
start: Schema.Number,
|
|
75
|
+
end: Schema.Number,
|
|
76
|
+
type: Schema.optional(Schema.String),
|
|
77
|
+
speaker_id: Schema.optional(Schema.NullOr(Schema.String)),
|
|
78
|
+
logprob: Schema.optional(Schema.Number),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const ServerMessage = Schema.Union([
|
|
82
|
+
Schema.Struct({
|
|
83
|
+
message_type: Schema.Literal("session_started"),
|
|
84
|
+
session_id: Schema.optional(Schema.String),
|
|
85
|
+
}),
|
|
86
|
+
Schema.Struct({
|
|
87
|
+
message_type: Schema.Literal("partial_transcript"),
|
|
88
|
+
text: Schema.String,
|
|
89
|
+
}),
|
|
90
|
+
Schema.Struct({
|
|
91
|
+
message_type: Schema.Literal("committed_transcript"),
|
|
92
|
+
text: Schema.String,
|
|
93
|
+
}),
|
|
94
|
+
Schema.Struct({
|
|
95
|
+
message_type: Schema.Literal("committed_transcript_with_timestamps"),
|
|
96
|
+
text: Schema.String,
|
|
97
|
+
language_code: Schema.optional(Schema.NullOr(Schema.String)),
|
|
98
|
+
words: Schema.optional(Schema.NullOr(Schema.Array(RealtimeWord))),
|
|
99
|
+
}),
|
|
100
|
+
Schema.Struct({
|
|
101
|
+
message_type: Schema.Literals(["error", "auth_error", "rate_limited", "quota_exceeded"]),
|
|
102
|
+
error: Schema.optional(Schema.Unknown),
|
|
103
|
+
}),
|
|
104
|
+
])
|
|
105
|
+
const decodeServerMessage = Schema.decodeUnknownEffect(ServerMessage)
|
|
106
|
+
|
|
107
|
+
const wireToWordTimestamp = (w: typeof RealtimeWord.Type): WordTimestamp => ({
|
|
108
|
+
text: w.text,
|
|
109
|
+
startSeconds: w.start,
|
|
110
|
+
endSeconds: w.end,
|
|
111
|
+
...(w.speaker_id != null && { speakerId: w.speaker_id }),
|
|
112
|
+
...(w.logprob !== undefined && { confidence: Math.exp(w.logprob) }),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Map a server frame to a `TranscriptEvent`. The two `committed_*` variants
|
|
117
|
+
* need disambiguation:
|
|
118
|
+
*
|
|
119
|
+
* - When `include_timestamps=true` on the URL, the server emits BOTH
|
|
120
|
+
* `committed_transcript` and `committed_transcript_with_timestamps` for
|
|
121
|
+
* each segment — same text, the latter is a strict superset. The bare
|
|
122
|
+
* variant must be suppressed or callers see two finals per utterance.
|
|
123
|
+
* - When `include_timestamps=false`, the server emits ONLY
|
|
124
|
+
* `committed_transcript`. Suppressing it loses every final.
|
|
125
|
+
*
|
|
126
|
+
* So suppression must be conditional on `includeTimestamps`, which is what
|
|
127
|
+
* `wordTimestamps` resolves to on the URL.
|
|
128
|
+
*/
|
|
129
|
+
export const wireToEvent =
|
|
130
|
+
(includeTimestamps: boolean) =>
|
|
131
|
+
(msg: typeof ServerMessage.Type): TranscriptEvent | undefined =>
|
|
132
|
+
Match.value(msg).pipe(
|
|
133
|
+
Match.when({ message_type: "session_started" }, () => undefined),
|
|
134
|
+
Match.when(
|
|
135
|
+
{ message_type: "partial_transcript" },
|
|
136
|
+
(m): TranscriptEvent => ({ _tag: "partial", text: m.text }),
|
|
137
|
+
),
|
|
138
|
+
Match.when({ message_type: "committed_transcript" }, (m): TranscriptEvent | undefined =>
|
|
139
|
+
includeTimestamps
|
|
140
|
+
? undefined // superseded by the `_with_timestamps` variant
|
|
141
|
+
: { _tag: "final", text: m.text },
|
|
142
|
+
),
|
|
143
|
+
Match.when({ message_type: "committed_transcript_with_timestamps" }, (m): TranscriptEvent => {
|
|
144
|
+
const words = m.words == null ? undefined : m.words.map(wireToWordTimestamp)
|
|
145
|
+
return {
|
|
146
|
+
_tag: "final",
|
|
147
|
+
text: m.text,
|
|
148
|
+
...(m.language_code != null && { languageCode: m.language_code }),
|
|
149
|
+
...(words !== undefined && words.length > 0 && { words }),
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
Match.orElse(
|
|
153
|
+
(m): TranscriptEvent => ({
|
|
154
|
+
_tag: "error",
|
|
155
|
+
code: m.message_type,
|
|
156
|
+
message:
|
|
157
|
+
typeof m.error === "string"
|
|
158
|
+
? m.error
|
|
159
|
+
: JSON.stringify(m.error ?? `ElevenLabs ${m.message_type}`),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// URL + frame builders
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export type RealtimeOptions = {
|
|
169
|
+
readonly model?: string
|
|
170
|
+
readonly languageCode?: string
|
|
171
|
+
readonly includeTimestamps?: boolean
|
|
172
|
+
readonly commitStrategy?: "manual" | "vad"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const buildWsUrl = (
|
|
176
|
+
cfg: Config,
|
|
177
|
+
token: string,
|
|
178
|
+
audioFormat: string,
|
|
179
|
+
opts: RealtimeOptions,
|
|
180
|
+
) => {
|
|
181
|
+
const wsBase = (cfg.baseUrl ?? "https://api.elevenlabs.io/v1").replace(/^http/, "ws")
|
|
182
|
+
const params = new URLSearchParams({
|
|
183
|
+
token,
|
|
184
|
+
audio_format: audioFormat,
|
|
185
|
+
commit_strategy: opts.commitStrategy ?? "vad",
|
|
186
|
+
...(opts.model !== undefined && { model_id: opts.model }),
|
|
187
|
+
...(opts.languageCode !== undefined && { language_code: opts.languageCode }),
|
|
188
|
+
...(opts.includeTimestamps === true && { include_timestamps: "true" }),
|
|
189
|
+
})
|
|
190
|
+
return `${wsBase}/speech-to-text/realtime?${params.toString()}`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const encodeAudioFrame = (bytes: Uint8Array, sampleRate: number) =>
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
message_type: "input_audio_chunk",
|
|
196
|
+
audio_base_64: Encoding.encodeBase64(bytes),
|
|
197
|
+
sample_rate: sampleRate,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Parse one server message and emit to the queue if it maps to a
|
|
202
|
+
// TranscriptEvent. Invalid JSON / unknown shapes are silently dropped —
|
|
203
|
+
// the connection stays open and we keep streaming subsequent messages.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const handleServerMessage =
|
|
207
|
+
(
|
|
208
|
+
queue: Queue.Queue<TranscriptEvent, Cause.Done>,
|
|
209
|
+
mapEvent: (msg: typeof ServerMessage.Type) => TranscriptEvent | undefined,
|
|
210
|
+
) =>
|
|
211
|
+
(raw: string) =>
|
|
212
|
+
Effect.gen(function* () {
|
|
213
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
214
|
+
if (json === undefined) return
|
|
215
|
+
const decoded = yield* decodeServerMessage(json).pipe(Effect.option)
|
|
216
|
+
if (decoded._tag === "None") return
|
|
217
|
+
const event = mapEvent(decoded.value)
|
|
218
|
+
if (event !== undefined) yield* Queue.offer(queue, event)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Stream<Uint8Array> → Stream<TranscriptEvent>
|
|
223
|
+
//
|
|
224
|
+
// Requires `HttpClient` (single-use token fetch) and
|
|
225
|
+
// `Socket.WebSocketConstructor` (any `globalThis.WebSocket` works) in
|
|
226
|
+
// context — `Socket.layerWebSocketConstructorGlobal` covers every
|
|
227
|
+
// modern runtime.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export const streamTranscription =
|
|
231
|
+
(cfg: Config) =>
|
|
232
|
+
<E, R>(
|
|
233
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
234
|
+
request: CommonStreamTranscribeRequest,
|
|
235
|
+
): Stream.Stream<
|
|
236
|
+
TranscriptEvent,
|
|
237
|
+
AiError.AiError | E,
|
|
238
|
+
R | HttpClient.HttpClient | Socket.WebSocketConstructor
|
|
239
|
+
> =>
|
|
240
|
+
Stream.unwrap(
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const slug = yield* inputFormatToSlug(request.inputFormat)
|
|
243
|
+
const token = yield* fetchSingleUseToken(cfg)
|
|
244
|
+
const includeTimestamps = request.wordTimestamps === true
|
|
245
|
+
const url = buildWsUrl(cfg, token, slug, {
|
|
246
|
+
...(request.model !== undefined && { model: request.model }),
|
|
247
|
+
...(request.language !== undefined && { languageCode: request.language }),
|
|
248
|
+
includeTimestamps,
|
|
249
|
+
})
|
|
250
|
+
const socket = yield* Socket.makeWebSocket(url, {
|
|
251
|
+
// Effect's Socket treats all close codes as errors by default — that
|
|
252
|
+
// surfaces a clean server-side close as a stream failure. Whitelist
|
|
253
|
+
// standard clean-close codes (1000 / 1001 / 1005).
|
|
254
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
255
|
+
})
|
|
256
|
+
const queue = yield* Queue.bounded<TranscriptEvent, Cause.Done>(64)
|
|
257
|
+
const sampleRate = request.inputFormat.sampleRate
|
|
258
|
+
|
|
259
|
+
const write = yield* socket.writer
|
|
260
|
+
yield* Stream.runForEach(audioIn, (bytes) =>
|
|
261
|
+
write(encodeAudioFrame(bytes, sampleRate)),
|
|
262
|
+
).pipe(Effect.ignore, Effect.forkScoped)
|
|
263
|
+
|
|
264
|
+
// `Queue.end` flushes pending events then fails the next take with
|
|
265
|
+
// `Done` — clean stream end. `Queue.shutdown` would CLEAR queued
|
|
266
|
+
// items and interrupt pending takes (wrong for graceful teardown).
|
|
267
|
+
yield* socket
|
|
268
|
+
.runString(handleServerMessage(queue, wireToEvent(includeTimestamps)))
|
|
269
|
+
.pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
|
|
270
|
+
|
|
271
|
+
return Stream.fromQueue(queue)
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Cause, Effect, Encoding, Queue, Redacted, Result, Schema, Stream } from "effect"
|
|
2
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
3
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
+
import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
|
|
5
|
+
import * as JSONL from "@effect-uai/core/JSONL"
|
|
6
|
+
import {
|
|
7
|
+
defaultFormat,
|
|
8
|
+
formatToOutputSlug,
|
|
9
|
+
type VoiceSettings,
|
|
10
|
+
wireVoiceSettings,
|
|
11
|
+
} from "./codec.js"
|
|
12
|
+
import type { ElevenLabsTtsModel, ElevenLabsVoiceId } from "./models.js"
|
|
13
|
+
|
|
14
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
15
|
+
|
|
16
|
+
export type { VoiceSettings } from "./codec.js"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Incremental-text-in request for `/stream-input`. `voiceSettings`
|
|
20
|
+
* applies only on the BOS frame — mid-stream voice changes are
|
|
21
|
+
* rejected. `autoMode: true` (default) lets the model pick flush
|
|
22
|
+
* boundaries, which is what you want for LLM-token streams.
|
|
23
|
+
*/
|
|
24
|
+
export type StreamSynthesizeRequest = {
|
|
25
|
+
readonly model?: ElevenLabsTtsModel
|
|
26
|
+
readonly voiceId: ElevenLabsVoiceId
|
|
27
|
+
readonly outputFormat?: AudioFormat
|
|
28
|
+
readonly languageCode?: string
|
|
29
|
+
readonly voiceSettings?: VoiceSettings
|
|
30
|
+
readonly autoMode?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// URL + frame builders
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export const buildWsUrl = (cfg: Config, request: StreamSynthesizeRequest, outputFormat: string) => {
|
|
38
|
+
const wsBase = (cfg.baseUrl ?? "https://api.elevenlabs.io/v1").replace(/^http/, "ws")
|
|
39
|
+
const params = new URLSearchParams({
|
|
40
|
+
output_format: outputFormat,
|
|
41
|
+
auto_mode: String(request.autoMode ?? true),
|
|
42
|
+
...(request.model !== undefined && { model_id: request.model }),
|
|
43
|
+
...(request.languageCode !== undefined && { language_code: request.languageCode }),
|
|
44
|
+
})
|
|
45
|
+
return `${wsBase}/text-to-speech/${request.voiceId}/stream-input?${params.toString()}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const bosFrame = (cfg: Config, request: StreamSynthesizeRequest) => {
|
|
49
|
+
const vs = wireVoiceSettings(request.voiceSettings)
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
text: " ",
|
|
52
|
+
"xi-api-key": Redacted.value(cfg.apiKey),
|
|
53
|
+
...(vs !== undefined && { voice_settings: vs }),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const textFrame = (text: string) => JSON.stringify({ text: text.endsWith(" ") ? text : `${text} ` })
|
|
58
|
+
const eosFrame = JSON.stringify({ text: "" })
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Wire schema (server → client) + helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const ServerFrame = Schema.Struct({
|
|
65
|
+
audio: Schema.optional(Schema.NullOr(Schema.String)),
|
|
66
|
+
isFinal: Schema.optional(Schema.NullOr(Schema.Boolean)),
|
|
67
|
+
error: Schema.optional(Schema.Unknown),
|
|
68
|
+
message: Schema.optional(Schema.String),
|
|
69
|
+
})
|
|
70
|
+
const decodeServerFrame = Schema.decodeUnknownEffect(ServerFrame)
|
|
71
|
+
|
|
72
|
+
const decodeAudio = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
73
|
+
Result.match(Encoding.decodeBase64(b64), {
|
|
74
|
+
onSuccess: Effect.succeed,
|
|
75
|
+
onFailure: (cause) =>
|
|
76
|
+
Effect.fail(
|
|
77
|
+
new AiError.GenerationFailed({
|
|
78
|
+
provider: "elevenlabs",
|
|
79
|
+
raw: { message: "failed to decode audio frame", cause },
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const handleServerFrame = (queue: Queue.Queue<AudioChunk, Cause.Done>) => (raw: string) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
87
|
+
if (json === undefined) return
|
|
88
|
+
const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
|
|
89
|
+
if (decoded._tag === "None") return
|
|
90
|
+
const frame = decoded.value
|
|
91
|
+
if (frame.error !== undefined) {
|
|
92
|
+
yield* Effect.logWarning("[elevenlabs-tts] server error frame", {
|
|
93
|
+
error: frame.error,
|
|
94
|
+
message: frame.message,
|
|
95
|
+
})
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (frame.audio == null || frame.audio === "") return
|
|
99
|
+
const bytes = yield* decodeAudio(frame.audio).pipe(Effect.option)
|
|
100
|
+
if (bytes._tag === "Some") yield* Queue.offer(queue, { bytes: bytes.value })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Stream<string> → Stream<AudioChunk>. Requires `Socket.WebSocketConstructor`.
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export const streamSynthesis =
|
|
108
|
+
(cfg: Config) =>
|
|
109
|
+
<E, R>(
|
|
110
|
+
textIn: Stream.Stream<string, E, R>,
|
|
111
|
+
request: StreamSynthesizeRequest,
|
|
112
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R | Socket.WebSocketConstructor> =>
|
|
113
|
+
Stream.unwrap(
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const slug = yield* formatToOutputSlug(request.outputFormat ?? defaultFormat)
|
|
116
|
+
// ElevenLabs closes `/stream-input` with code 1000 after delivering the
|
|
117
|
+
// final audio chunk. Effect's default treats all close codes as errors,
|
|
118
|
+
// which would surface as a stream failure right after the last audio
|
|
119
|
+
// arrives. Whitelist standard clean-close codes.
|
|
120
|
+
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg, request, slug), {
|
|
121
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
122
|
+
})
|
|
123
|
+
const queue = yield* Queue.bounded<AudioChunk, Cause.Done>(64)
|
|
124
|
+
const write = yield* socket.writer
|
|
125
|
+
|
|
126
|
+
// Writer fiber: BOS → drain text stream → EOS. Socket-side failures
|
|
127
|
+
// end this fiber; the reader still surfaces a clean stream end via
|
|
128
|
+
// `Queue.end` (in the ensuring below) when the upstream WS closes.
|
|
129
|
+
yield* Effect.gen(function* () {
|
|
130
|
+
yield* write(bosFrame(cfg, request))
|
|
131
|
+
yield* Stream.runForEach(textIn, (text) =>
|
|
132
|
+
text.length === 0 ? Effect.void : write(textFrame(text)),
|
|
133
|
+
)
|
|
134
|
+
yield* write(eosFrame)
|
|
135
|
+
}).pipe(Effect.ignore, Effect.forkScoped)
|
|
136
|
+
|
|
137
|
+
// Reader fiber. `ensuring(Queue.end)` flushes pending chunks then
|
|
138
|
+
// fails the next take with `Done`, which `Stream.fromQueue` treats
|
|
139
|
+
// as a clean end. (`Queue.shutdown` would CLEAR queued items and
|
|
140
|
+
// interrupt pending takes — wrong for graceful teardown.)
|
|
141
|
+
yield* socket
|
|
142
|
+
.runString(handleServerFrame(queue))
|
|
143
|
+
.pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
|
|
144
|
+
|
|
145
|
+
return Stream.fromQueue(queue)
|
|
146
|
+
}),
|
|
147
|
+
)
|