@effect-uai/elevenlabs 0.5.2 → 0.7.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/dist/ElevenLabsMusicGenerator.d.mts +75 -0
- package/dist/ElevenLabsMusicGenerator.d.mts.map +1 -0
- package/dist/ElevenLabsMusicGenerator.mjs +191 -0
- package/dist/ElevenLabsMusicGenerator.mjs.map +1 -0
- package/dist/ElevenLabsMusicGenerator.test.mjs +98 -0
- package/dist/ElevenLabsMusicGenerator.test.mjs.map +1 -0
- package/dist/ElevenLabsSynthesizer.d.mts +25 -14
- package/dist/ElevenLabsSynthesizer.d.mts.map +1 -1
- package/dist/ElevenLabsSynthesizer.mjs +64 -11
- package/dist/ElevenLabsSynthesizer.mjs.map +1 -1
- package/dist/ElevenLabsTranscriber.d.mts +7 -5
- package/dist/ElevenLabsTranscriber.d.mts.map +1 -1
- package/dist/ElevenLabsTranscriber.mjs +11 -3
- package/dist/ElevenLabsTranscriber.mjs.map +1 -1
- package/dist/codec.d.mts +25 -2
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +18 -1
- package/dist/codec.mjs.map +1 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +3 -1
- package/dist/models.d.mts +13 -1
- package/dist/models.d.mts.map +1 -1
- package/dist/musicCodec-CZni1gAQ.d.mts +42 -0
- package/dist/musicCodec-CZni1gAQ.d.mts.map +1 -0
- package/dist/musicCodec.d.mts +2 -0
- package/dist/musicCodec.mjs +38 -0
- package/dist/musicCodec.mjs.map +1 -0
- package/dist/realtimeStt.d.mts +5 -2
- package/dist/realtimeStt.d.mts.map +1 -1
- package/dist/realtimeStt.mjs +16 -4
- package/dist/realtimeStt.mjs.map +1 -1
- package/dist/realtimeTts.d.mts +10 -3
- package/dist/realtimeTts.d.mts.map +1 -1
- package/dist/realtimeTts.mjs +7 -4
- package/dist/realtimeTts.mjs.map +1 -1
- package/dist/region.d.mts +29 -0
- package/dist/region.d.mts.map +1 -0
- package/dist/region.mjs +20 -0
- package/dist/region.mjs.map +1 -0
- package/dist/region.test.d.mts +1 -0
- package/dist/region.test.mjs +30 -0
- package/dist/region.test.mjs.map +1 -0
- package/package.json +9 -3
- package/src/ElevenLabsMusicGenerator.test.ts +116 -0
- package/src/ElevenLabsMusicGenerator.ts +353 -0
- package/src/ElevenLabsSynthesizer.ts +103 -8
- package/src/ElevenLabsTranscriber.ts +18 -4
- package/src/codec.ts +47 -0
- package/src/index.ts +2 -0
- package/src/models.ts +17 -0
- package/src/musicCodec.ts +86 -0
- package/src/realtimeStt.ts +19 -3
- package/src/realtimeTts.ts +18 -2
- package/src/region.test.ts +30 -0
- package/src/region.ts +34 -0
- package/dist/ElevenLabsSynthesizer.test.mjs +0 -32
- package/dist/ElevenLabsSynthesizer.test.mjs.map +0 -1
- package/src/ElevenLabsSynthesizer.test.ts +0 -49
- /package/dist/{ElevenLabsSynthesizer.test.d.mts → ElevenLabsMusicGenerator.test.d.mts} +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { Context, Duration, Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
+
import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
|
|
5
|
+
import type {
|
|
6
|
+
CommonGenerateMusicRequest,
|
|
7
|
+
CommonStreamGenerateMusicRequest,
|
|
8
|
+
GenerateResult,
|
|
9
|
+
MusicResult,
|
|
10
|
+
MusicSessionInput,
|
|
11
|
+
MusicStreamEvent,
|
|
12
|
+
} from "@effect-uai/core/Music"
|
|
13
|
+
import { singleVariant } from "@effect-uai/core/Music"
|
|
14
|
+
import { MusicGenerator, type MusicGeneratorService } from "@effect-uai/core/MusicGenerator"
|
|
15
|
+
import { defaultFormat, formatToOutputSlug } from "./codec.js"
|
|
16
|
+
import type { ElevenLabsMusicModel } from "./models.js"
|
|
17
|
+
import {
|
|
18
|
+
decodeCompositionPlan,
|
|
19
|
+
type ElevenLabsCompositionPlan,
|
|
20
|
+
warnDroppedPromptModeHints,
|
|
21
|
+
wireCompositionPlan,
|
|
22
|
+
} from "./musicCodec.js"
|
|
23
|
+
import { type ElevenLabsRegion, resolveHost } from "./region.js"
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Public types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* ElevenLabs-typed music request. Narrows `model` to
|
|
31
|
+
* `ElevenLabsMusicModel` and adds provider-specific extras:
|
|
32
|
+
*
|
|
33
|
+
* - `compositionPlan` — switch from prompt mode to plan mode. Mutually
|
|
34
|
+
* exclusive with `prompt` on the wire.
|
|
35
|
+
* - `forceInstrumental` — skip vocals. ElevenLabs has no `instrumental`
|
|
36
|
+
* on the cross-provider Common request (the field was removed in
|
|
37
|
+
* v0.7 because it has wildly different semantics per provider); the
|
|
38
|
+
* typed surface restores it.
|
|
39
|
+
* - `signWithC2pa` — opt-in C2PA Content Credentials, MP3 output only.
|
|
40
|
+
* - `respectSectionsDurations` — composition-plan mode only. False
|
|
41
|
+
* trades section-duration accuracy for quality + latency.
|
|
42
|
+
*
|
|
43
|
+
* Setting `compositionPlan` with a non-empty `prompt`, or with
|
|
44
|
+
* `duration`, fails `InvalidRequest` at the codec layer.
|
|
45
|
+
*/
|
|
46
|
+
export type ElevenLabsMusicGenerateRequest = Omit<CommonGenerateMusicRequest, "model"> & {
|
|
47
|
+
readonly model?: ElevenLabsMusicModel
|
|
48
|
+
readonly compositionPlan?: ElevenLabsCompositionPlan
|
|
49
|
+
readonly forceInstrumental?: boolean
|
|
50
|
+
readonly signWithC2pa?: boolean
|
|
51
|
+
readonly respectSectionsDurations?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CreateCompositionPlanRequest = {
|
|
55
|
+
readonly prompt: string
|
|
56
|
+
readonly duration?: Duration.Duration
|
|
57
|
+
readonly sourceCompositionPlan?: ElevenLabsCompositionPlan
|
|
58
|
+
readonly model?: ElevenLabsMusicModel
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ElevenLabsMusicGeneratorService = {
|
|
62
|
+
readonly generate: (
|
|
63
|
+
request: ElevenLabsMusicGenerateRequest,
|
|
64
|
+
) => Effect.Effect<GenerateResult, AiError.AiError>
|
|
65
|
+
readonly streamGeneration: (
|
|
66
|
+
request: ElevenLabsMusicGenerateRequest,
|
|
67
|
+
) => Stream.Stream<AudioChunk, AiError.AiError>
|
|
68
|
+
readonly streamGenerationFrom: <E, R>(
|
|
69
|
+
input: Stream.Stream<MusicSessionInput, E, R>,
|
|
70
|
+
request: CommonStreamGenerateMusicRequest,
|
|
71
|
+
) => Stream.Stream<MusicStreamEvent, AiError.AiError | E, R>
|
|
72
|
+
/**
|
|
73
|
+
* Free endpoint (rate-limited, no credit cost): turn a prompt into a
|
|
74
|
+
* structured `MusicPrompt` plan. Feed the result back into
|
|
75
|
+
* `generate` / `streamGeneration` via `compositionPlan`.
|
|
76
|
+
*/
|
|
77
|
+
readonly createCompositionPlan: (
|
|
78
|
+
request: CreateCompositionPlanRequest,
|
|
79
|
+
) => Effect.Effect<ElevenLabsCompositionPlan, AiError.AiError>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ElevenLabsMusicGenerator extends Context.Service<
|
|
83
|
+
ElevenLabsMusicGenerator,
|
|
84
|
+
ElevenLabsMusicGeneratorService
|
|
85
|
+
>()("@betalyra/effect-uai/providers/elevenlabs/ElevenLabsMusicGenerator") {}
|
|
86
|
+
|
|
87
|
+
export type Config = {
|
|
88
|
+
readonly apiKey: Redacted.Redacted
|
|
89
|
+
readonly baseUrl?: string
|
|
90
|
+
readonly region?: ElevenLabsRegion
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DEFAULT_MODEL: ElevenLabsMusicModel = "music_v1"
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// HTTP errors (music namespace; reuses the same status taxonomy)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const musicTransportFailure = (cause: unknown): AiError.AiError =>
|
|
100
|
+
new AiError.Unavailable({ provider: "elevenlabs-music", raw: cause })
|
|
101
|
+
|
|
102
|
+
const musicHttpStatusError = (status: number, body: string): AiError.AiError => {
|
|
103
|
+
if (status === 429) return new AiError.RateLimited({ provider: "elevenlabs-music", raw: body })
|
|
104
|
+
if (status === 408 || status === 504)
|
|
105
|
+
return new AiError.Timeout({ provider: "elevenlabs-music", raw: body })
|
|
106
|
+
if (status === 401)
|
|
107
|
+
return new AiError.AuthFailed({ provider: "elevenlabs-music", subtype: "auth", raw: body })
|
|
108
|
+
if (status === 403)
|
|
109
|
+
return new AiError.AuthFailed({
|
|
110
|
+
provider: "elevenlabs-music",
|
|
111
|
+
subtype: "permission",
|
|
112
|
+
raw: body,
|
|
113
|
+
})
|
|
114
|
+
if (status === 402)
|
|
115
|
+
return new AiError.AuthFailed({
|
|
116
|
+
provider: "elevenlabs-music",
|
|
117
|
+
subtype: "billing",
|
|
118
|
+
raw: body,
|
|
119
|
+
})
|
|
120
|
+
if (status >= 500)
|
|
121
|
+
return new AiError.Unavailable({ provider: "elevenlabs-music", status, raw: body })
|
|
122
|
+
return new AiError.InvalidRequest({ provider: "elevenlabs-music", raw: body })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Wire body
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the JSON body for `/v1/music` (sync + stream share the body).
|
|
131
|
+
*
|
|
132
|
+
* Mutual exclusion: `compositionPlan` and prompt-mode fields cannot
|
|
133
|
+
* coexist. When `compositionPlan` is set, `prompt` must be empty and
|
|
134
|
+
* `duration` must be unset (section durations live on the plan).
|
|
135
|
+
*/
|
|
136
|
+
const buildBody = (request: ElevenLabsMusicGenerateRequest) =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const model = request.model ?? DEFAULT_MODEL
|
|
139
|
+
if (request.compositionPlan !== undefined) {
|
|
140
|
+
if (request.prompt.length > 0) {
|
|
141
|
+
return yield* Effect.fail(
|
|
142
|
+
new AiError.InvalidRequest({
|
|
143
|
+
provider: "elevenlabs-music",
|
|
144
|
+
param: "prompt",
|
|
145
|
+
raw: "`prompt` and `compositionPlan` are mutually exclusive. Pass one or the other (compositionPlan carries its own per-section lyrics and styles).",
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
if (request.duration !== undefined) {
|
|
150
|
+
return yield* Effect.fail(
|
|
151
|
+
new AiError.InvalidRequest({
|
|
152
|
+
provider: "elevenlabs-music",
|
|
153
|
+
param: "duration",
|
|
154
|
+
raw: "`duration` is ignored when `compositionPlan` is set; section durations live on the plan. Remove `duration`.",
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
composition_plan: wireCompositionPlan(request.compositionPlan),
|
|
160
|
+
model_id: model,
|
|
161
|
+
...(request.seed !== undefined && { seed: request.seed }),
|
|
162
|
+
...(request.forceInstrumental !== undefined && {
|
|
163
|
+
force_instrumental: request.forceInstrumental,
|
|
164
|
+
}),
|
|
165
|
+
...(request.respectSectionsDurations !== undefined && {
|
|
166
|
+
respect_sections_durations: request.respectSectionsDurations,
|
|
167
|
+
}),
|
|
168
|
+
...(request.signWithC2pa !== undefined && { sign_with_c2pa: request.signWithC2pa }),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
yield* warnDroppedPromptModeHints(request as CommonGenerateMusicRequest)
|
|
172
|
+
return {
|
|
173
|
+
prompt: request.prompt,
|
|
174
|
+
model_id: model,
|
|
175
|
+
...(request.duration !== undefined && {
|
|
176
|
+
music_length_ms: Duration.toMillis(request.duration),
|
|
177
|
+
}),
|
|
178
|
+
...(request.seed !== undefined && { seed: request.seed }),
|
|
179
|
+
...(request.forceInstrumental !== undefined && {
|
|
180
|
+
force_instrumental: request.forceInstrumental,
|
|
181
|
+
}),
|
|
182
|
+
...(request.signWithC2pa !== undefined && { sign_with_c2pa: request.signWithC2pa }),
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const buildMusicHttpRequest = (
|
|
187
|
+
cfg: Config,
|
|
188
|
+
request: ElevenLabsMusicGenerateRequest,
|
|
189
|
+
path: "" | "/stream",
|
|
190
|
+
) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const format: AudioFormat = request.outputFormat ?? defaultFormat
|
|
193
|
+
const slug = yield* formatToOutputSlug(format)
|
|
194
|
+
const body = yield* buildBody(request)
|
|
195
|
+
const url = `${resolveHost(cfg)}/music${path}?output_format=${slug}`
|
|
196
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
197
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
198
|
+
HttpClientRequest.bodyJsonUnsafe(body),
|
|
199
|
+
)
|
|
200
|
+
return { httpRequest, format }
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Service implementation
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const generateImpl =
|
|
208
|
+
(cfg: Config) =>
|
|
209
|
+
(
|
|
210
|
+
request: ElevenLabsMusicGenerateRequest,
|
|
211
|
+
): Effect.Effect<GenerateResult, AiError.AiError, HttpClient.HttpClient> =>
|
|
212
|
+
Effect.gen(function* () {
|
|
213
|
+
const client = yield* HttpClient.HttpClient
|
|
214
|
+
const { httpRequest, format } = yield* buildMusicHttpRequest(cfg, request, "")
|
|
215
|
+
const response = yield* client
|
|
216
|
+
.execute(httpRequest)
|
|
217
|
+
.pipe(Effect.mapError(musicTransportFailure))
|
|
218
|
+
if (response.status >= 400) {
|
|
219
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
220
|
+
return yield* Effect.fail(musicHttpStatusError(response.status, text))
|
|
221
|
+
}
|
|
222
|
+
const buffer = yield* response.arrayBuffer.pipe(Effect.mapError(musicTransportFailure))
|
|
223
|
+
const bytes = new Uint8Array(buffer)
|
|
224
|
+
const songId = response.headers["song_id"] ?? response.headers["x-song-id"]
|
|
225
|
+
const result: MusicResult = {
|
|
226
|
+
audio: { format, bytes },
|
|
227
|
+
provider: "elevenlabs-music",
|
|
228
|
+
...(typeof songId === "string" && { songId }),
|
|
229
|
+
...(request.signWithC2pa === true && { watermark: "c2pa" as const }),
|
|
230
|
+
}
|
|
231
|
+
return singleVariant(result)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const streamGenerationImpl =
|
|
235
|
+
(cfg: Config) =>
|
|
236
|
+
(
|
|
237
|
+
request: ElevenLabsMusicGenerateRequest,
|
|
238
|
+
): Stream.Stream<AudioChunk, AiError.AiError, HttpClient.HttpClient> =>
|
|
239
|
+
Stream.unwrap(
|
|
240
|
+
Effect.gen(function* () {
|
|
241
|
+
const client = yield* HttpClient.HttpClient
|
|
242
|
+
const { httpRequest } = yield* buildMusicHttpRequest(cfg, request, "/stream")
|
|
243
|
+
const response = yield* client
|
|
244
|
+
.execute(httpRequest)
|
|
245
|
+
.pipe(Effect.mapError(musicTransportFailure))
|
|
246
|
+
if (response.status >= 400) {
|
|
247
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
248
|
+
return Stream.fail(musicHttpStatusError(response.status, text))
|
|
249
|
+
}
|
|
250
|
+
return response.stream.pipe(
|
|
251
|
+
Stream.mapError(musicTransportFailure),
|
|
252
|
+
Stream.map((bytes): AudioChunk => ({ bytes })),
|
|
253
|
+
)
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* ElevenLabs music has no bidirectional session at the wire level.
|
|
259
|
+
* Layer does not register `MusicInteractiveSession`, so callers using
|
|
260
|
+
* `MusicGenerator.streamGenerationFrom` get a compile-time error
|
|
261
|
+
* before this runtime fallback fires.
|
|
262
|
+
*/
|
|
263
|
+
const streamGenerationFromUnsupported = <E, R>(
|
|
264
|
+
_input: Stream.Stream<MusicSessionInput, E, R>,
|
|
265
|
+
_request: CommonStreamGenerateMusicRequest,
|
|
266
|
+
): Stream.Stream<MusicStreamEvent, AiError.AiError | E, R> =>
|
|
267
|
+
Stream.fail(
|
|
268
|
+
new AiError.Unsupported({
|
|
269
|
+
provider: "elevenlabs-music",
|
|
270
|
+
capability: "streamGenerationFrom",
|
|
271
|
+
reason:
|
|
272
|
+
"ElevenLabs music has no bidirectional session. Use `streamGeneration` for chunked output, or `createCompositionPlan` + `generate` for structured plans.",
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const createCompositionPlanImpl =
|
|
277
|
+
(cfg: Config) =>
|
|
278
|
+
(
|
|
279
|
+
request: CreateCompositionPlanRequest,
|
|
280
|
+
): Effect.Effect<ElevenLabsCompositionPlan, AiError.AiError, HttpClient.HttpClient> =>
|
|
281
|
+
Effect.gen(function* () {
|
|
282
|
+
const client = yield* HttpClient.HttpClient
|
|
283
|
+
const body = {
|
|
284
|
+
prompt: request.prompt,
|
|
285
|
+
model_id: request.model ?? DEFAULT_MODEL,
|
|
286
|
+
...(request.duration !== undefined && {
|
|
287
|
+
music_length_ms: Duration.toMillis(request.duration),
|
|
288
|
+
}),
|
|
289
|
+
...(request.sourceCompositionPlan !== undefined && {
|
|
290
|
+
source_composition_plan: wireCompositionPlan(request.sourceCompositionPlan),
|
|
291
|
+
}),
|
|
292
|
+
}
|
|
293
|
+
const httpRequest = HttpClientRequest.post(`${resolveHost(cfg)}/music/plan`).pipe(
|
|
294
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
295
|
+
HttpClientRequest.bodyJsonUnsafe(body),
|
|
296
|
+
)
|
|
297
|
+
const response = yield* client
|
|
298
|
+
.execute(httpRequest)
|
|
299
|
+
.pipe(Effect.mapError(musicTransportFailure))
|
|
300
|
+
if (response.status >= 400) {
|
|
301
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
302
|
+
return yield* Effect.fail(musicHttpStatusError(response.status, text))
|
|
303
|
+
}
|
|
304
|
+
const json = yield* response.json.pipe(Effect.mapError(musicTransportFailure))
|
|
305
|
+
return decodeCompositionPlan(json)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Constructors
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
export const make = (
|
|
313
|
+
cfg: Config,
|
|
314
|
+
): Effect.Effect<ElevenLabsMusicGeneratorService, never, HttpClient.HttpClient> =>
|
|
315
|
+
Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
|
|
316
|
+
generate: (request) =>
|
|
317
|
+
generateImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
318
|
+
streamGeneration: (request) =>
|
|
319
|
+
streamGenerationImpl(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client)),
|
|
320
|
+
streamGenerationFrom: streamGenerationFromUnsupported,
|
|
321
|
+
createCompositionPlan: (request) =>
|
|
322
|
+
createCompositionPlanImpl(cfg)(request).pipe(
|
|
323
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
324
|
+
),
|
|
325
|
+
}))
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Layer that registers the provider-specific `ElevenLabsMusicGenerator`
|
|
329
|
+
* tag and the generic `MusicGenerator` tag.
|
|
330
|
+
*
|
|
331
|
+
* Does NOT register `MusicInteractiveSession`. ElevenLabs music has no
|
|
332
|
+
* bidirectional wire endpoint; `streamGenerationFrom` against this
|
|
333
|
+
* Layer alone fails to typecheck.
|
|
334
|
+
*/
|
|
335
|
+
export const layer = (
|
|
336
|
+
cfg: Config,
|
|
337
|
+
): Layer.Layer<ElevenLabsMusicGenerator | MusicGenerator, never, HttpClient.HttpClient> =>
|
|
338
|
+
Layer.merge(
|
|
339
|
+
Layer.effect(ElevenLabsMusicGenerator, make(cfg)),
|
|
340
|
+
Layer.effect(
|
|
341
|
+
MusicGenerator,
|
|
342
|
+
Effect.map(
|
|
343
|
+
make(cfg),
|
|
344
|
+
(s): MusicGeneratorService => ({
|
|
345
|
+
generate: (req: CommonGenerateMusicRequest) =>
|
|
346
|
+
s.generate({ ...req, model: req.model as ElevenLabsMusicModel }),
|
|
347
|
+
streamGeneration: (req: CommonStreamGenerateMusicRequest) =>
|
|
348
|
+
s.streamGeneration({ ...req, model: req.model as ElevenLabsMusicModel }),
|
|
349
|
+
streamGenerationFrom: s.streamGenerationFrom,
|
|
350
|
+
}),
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
)
|
|
@@ -5,7 +5,9 @@ import type * as AiError from "@effect-uai/core/AiError"
|
|
|
5
5
|
import type { AudioBlob, AudioChunk } from "@effect-uai/core/Audio"
|
|
6
6
|
import {
|
|
7
7
|
type CommonStreamSynthesizeRequest,
|
|
8
|
+
type CommonSynthesizeDialogueRequest,
|
|
8
9
|
type CommonSynthesizeRequest,
|
|
10
|
+
MultiSpeakerTts,
|
|
9
11
|
SpeechSynthesizer,
|
|
10
12
|
type SpeechSynthesizerService,
|
|
11
13
|
TtsIncrementalText,
|
|
@@ -14,12 +16,16 @@ import {
|
|
|
14
16
|
defaultFormat,
|
|
15
17
|
formatToOutputSlug,
|
|
16
18
|
httpStatusError,
|
|
19
|
+
type PronunciationDictionaryLocator,
|
|
20
|
+
rejectInlinePronunciations,
|
|
17
21
|
transportFailure,
|
|
18
22
|
type VoiceSettings,
|
|
23
|
+
wirePronunciationLocators,
|
|
19
24
|
wireVoiceSettings,
|
|
20
25
|
} from "./codec.js"
|
|
21
26
|
import type { ElevenLabsTtsModel, ElevenLabsVoiceId } from "./models.js"
|
|
22
27
|
import { streamSynthesis as realtimeStream } from "./realtimeTts.js"
|
|
28
|
+
import { type ElevenLabsRegion, resolveHost } from "./region.js"
|
|
23
29
|
|
|
24
30
|
// ---------------------------------------------------------------------------
|
|
25
31
|
// Public types
|
|
@@ -30,6 +36,11 @@ import { streamSynthesis as realtimeStream } from "./realtimeTts.js"
|
|
|
30
36
|
* provider's prosody/timbre controls; `seed` makes generation
|
|
31
37
|
* deterministic; `previousText` / `nextText` thread context across
|
|
32
38
|
* sequential calls for natural prosody.
|
|
39
|
+
*
|
|
40
|
+
* `pronunciationDictionaryLocators` references pre-provisioned
|
|
41
|
+
* pronunciation dictionaries by ID. Inline `pronunciations` (IPA) on
|
|
42
|
+
* the Common request are not supported by ElevenLabs and fail
|
|
43
|
+
* `Unsupported`; use a dictionary for phonetic control.
|
|
33
44
|
*/
|
|
34
45
|
export type ElevenLabsSynthesizeRequest = Omit<CommonSynthesizeRequest, "model" | "voiceId"> & {
|
|
35
46
|
readonly model: ElevenLabsTtsModel
|
|
@@ -38,6 +49,7 @@ export type ElevenLabsSynthesizeRequest = Omit<CommonSynthesizeRequest, "model"
|
|
|
38
49
|
readonly seed?: number
|
|
39
50
|
readonly previousText?: string
|
|
40
51
|
readonly nextText?: string
|
|
52
|
+
readonly pronunciationDictionaryLocators?: ReadonlyArray<PronunciationDictionaryLocator>
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
export type ElevenLabsSynthesizerService = {
|
|
@@ -46,6 +58,8 @@ export type ElevenLabsSynthesizerService = {
|
|
|
46
58
|
r: ElevenLabsSynthesizeRequest,
|
|
47
59
|
) => Stream.Stream<AudioChunk, AiError.AiError>
|
|
48
60
|
readonly streamSynthesisFrom: SpeechSynthesizerService["streamSynthesisFrom"]
|
|
61
|
+
readonly synthesizeDialogue: SpeechSynthesizerService["synthesizeDialogue"]
|
|
62
|
+
readonly streamSynthesizeDialogue: SpeechSynthesizerService["streamSynthesizeDialogue"]
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export class ElevenLabsSynthesizer extends Context.Service<
|
|
@@ -53,7 +67,11 @@ export class ElevenLabsSynthesizer extends Context.Service<
|
|
|
53
67
|
ElevenLabsSynthesizerService
|
|
54
68
|
>()("@betalyra/effect-uai/providers/elevenlabs/ElevenLabsSynthesizer") {}
|
|
55
69
|
|
|
56
|
-
export type Config = {
|
|
70
|
+
export type Config = {
|
|
71
|
+
readonly apiKey: Redacted.Redacted
|
|
72
|
+
readonly baseUrl?: string
|
|
73
|
+
readonly region?: ElevenLabsRegion
|
|
74
|
+
}
|
|
57
75
|
|
|
58
76
|
// ---------------------------------------------------------------------------
|
|
59
77
|
// Codec — request → JSON body
|
|
@@ -66,20 +84,82 @@ const buildBody = (r: ElevenLabsSynthesizeRequest) => ({
|
|
|
66
84
|
...(r.seed !== undefined && { seed: r.seed }),
|
|
67
85
|
...(r.previousText !== undefined && { previous_text: r.previousText }),
|
|
68
86
|
...(r.nextText !== undefined && { next_text: r.nextText }),
|
|
87
|
+
...wirePronunciationLocators(r.pronunciationDictionaryLocators),
|
|
69
88
|
...(r.voiceSettings !== undefined && { voice_settings: wireVoiceSettings(r.voiceSettings) }),
|
|
70
89
|
})
|
|
71
90
|
|
|
72
91
|
// ---------------------------------------------------------------------------
|
|
73
|
-
//
|
|
92
|
+
// Dialogue — POST /v1/text-to-dialogue (+ /stream variant)
|
|
74
93
|
// ---------------------------------------------------------------------------
|
|
75
94
|
|
|
76
|
-
const
|
|
95
|
+
const DEFAULT_DIALOGUE_MODEL = "eleven_v3"
|
|
96
|
+
|
|
97
|
+
const buildDialogueBody = (r: CommonSynthesizeDialogueRequest) => ({
|
|
98
|
+
inputs: r.turns.map((t) => ({
|
|
99
|
+
voice_id: t.voiceId,
|
|
100
|
+
text: t.text,
|
|
101
|
+
})),
|
|
102
|
+
model_id: r.model ?? DEFAULT_DIALOGUE_MODEL,
|
|
103
|
+
...(r.languageCode !== undefined && { language_code: r.languageCode }),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const buildDialogueHttpRequest = (
|
|
107
|
+
cfg: Config,
|
|
108
|
+
r: CommonSynthesizeDialogueRequest,
|
|
109
|
+
path: "" | "/stream",
|
|
110
|
+
) =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const format = r.outputFormat ?? defaultFormat
|
|
113
|
+
const slug = yield* formatToOutputSlug(format)
|
|
114
|
+
const url = `${resolveHost(cfg)}/text-to-dialogue${path}?output_format=${slug}`
|
|
115
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
116
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
117
|
+
HttpClientRequest.bodyJsonUnsafe(buildDialogueBody(r)),
|
|
118
|
+
)
|
|
119
|
+
return { httpRequest, format }
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const synthesizeDialogueImpl = (cfg: Config) => (request: CommonSynthesizeDialogueRequest) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
yield* rejectInlinePronunciations(request.pronunciations)
|
|
125
|
+
const client = yield* HttpClient.HttpClient
|
|
126
|
+
const { httpRequest, format } = yield* buildDialogueHttpRequest(cfg, request, "")
|
|
127
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
128
|
+
if (response.status >= 400) {
|
|
129
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
130
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
131
|
+
}
|
|
132
|
+
const bytes = yield* response.arrayBuffer.pipe(Effect.mapError(transportFailure))
|
|
133
|
+
return { format, bytes: new Uint8Array(bytes) } satisfies AudioBlob
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const streamSynthesizeDialogueImpl = (cfg: Config) => (request: CommonSynthesizeDialogueRequest) =>
|
|
137
|
+
Stream.unwrap(
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
yield* rejectInlinePronunciations(request.pronunciations)
|
|
140
|
+
const client = yield* HttpClient.HttpClient
|
|
141
|
+
const { httpRequest } = yield* buildDialogueHttpRequest(cfg, request, "/stream")
|
|
142
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
143
|
+
if (response.status >= 400) {
|
|
144
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
145
|
+
return Stream.fail(httpStatusError(response.status, text))
|
|
146
|
+
}
|
|
147
|
+
return response.stream.pipe(
|
|
148
|
+
Stream.mapError(transportFailure),
|
|
149
|
+
Stream.map((bytes): AudioChunk => ({ bytes })),
|
|
150
|
+
)
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// HTTP plumbing (sync + chunked-HTTP streaming)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
77
157
|
|
|
78
158
|
const buildHttpRequest = (cfg: Config, r: ElevenLabsSynthesizeRequest, path: "" | "/stream") =>
|
|
79
159
|
Effect.gen(function* () {
|
|
80
160
|
const format = r.outputFormat ?? defaultFormat
|
|
81
161
|
const slug = yield* formatToOutputSlug(format)
|
|
82
|
-
const url = `${
|
|
162
|
+
const url = `${resolveHost(cfg)}/text-to-speech/${r.voiceId}${path}?output_format=${slug}`
|
|
83
163
|
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
84
164
|
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
85
165
|
HttpClientRequest.bodyJsonUnsafe(buildBody(r)),
|
|
@@ -89,6 +169,7 @@ const buildHttpRequest = (cfg: Config, r: ElevenLabsSynthesizeRequest, path: ""
|
|
|
89
169
|
|
|
90
170
|
const synthesizeImpl = (cfg: Config) => (request: ElevenLabsSynthesizeRequest) =>
|
|
91
171
|
Effect.gen(function* () {
|
|
172
|
+
yield* rejectInlinePronunciations(request.pronunciations)
|
|
92
173
|
const client = yield* HttpClient.HttpClient
|
|
93
174
|
const { httpRequest, format } = yield* buildHttpRequest(cfg, request, "")
|
|
94
175
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
@@ -103,6 +184,7 @@ const synthesizeImpl = (cfg: Config) => (request: ElevenLabsSynthesizeRequest) =
|
|
|
103
184
|
const streamSynthesisImpl = (cfg: Config) => (request: ElevenLabsSynthesizeRequest) =>
|
|
104
185
|
Stream.unwrap(
|
|
105
186
|
Effect.gen(function* () {
|
|
187
|
+
yield* rejectInlinePronunciations(request.pronunciations)
|
|
106
188
|
const client = yield* HttpClient.HttpClient
|
|
107
189
|
const { httpRequest } = yield* buildHttpRequest(cfg, request, "/stream")
|
|
108
190
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
@@ -134,15 +216,25 @@ export const make = (cfg: Config) =>
|
|
|
134
216
|
realtimeStream(cfg)(textIn, request as ElevenLabsSynthesizeRequest).pipe(
|
|
135
217
|
Stream.provideService(Socket.WebSocketConstructor, ctor),
|
|
136
218
|
),
|
|
219
|
+
synthesizeDialogue: (request) =>
|
|
220
|
+
synthesizeDialogueImpl(cfg)(request).pipe(
|
|
221
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
222
|
+
),
|
|
223
|
+
streamSynthesizeDialogue: (request) =>
|
|
224
|
+
streamSynthesizeDialogueImpl(cfg)(request).pipe(
|
|
225
|
+
Stream.provideService(HttpClient.HttpClient, client),
|
|
226
|
+
),
|
|
137
227
|
} satisfies ElevenLabsSynthesizerService
|
|
138
228
|
})
|
|
139
229
|
|
|
140
230
|
/**
|
|
141
231
|
* Layer registers `ElevenLabsSynthesizer`, the generic
|
|
142
|
-
* `SpeechSynthesizer`,
|
|
143
|
-
* text-in
|
|
144
|
-
*
|
|
145
|
-
*
|
|
232
|
+
* `SpeechSynthesizer`, the `TtsIncrementalText` marker (incremental
|
|
233
|
+
* text-in via `/stream-input` WebSocket), **and `MultiSpeakerTts`** —
|
|
234
|
+
* multi-speaker dialogue is wired to `POST /v1/text-to-dialogue`
|
|
235
|
+
* (sync) and `/v1/text-to-dialogue/stream` (chunked). Provide
|
|
236
|
+
* `Socket.layerWebSocketConstructorGlobal` and an `HttpClient` Layer
|
|
237
|
+
* at the call site.
|
|
146
238
|
*/
|
|
147
239
|
export const layer = (cfg: Config) =>
|
|
148
240
|
Layer.mergeAll(
|
|
@@ -158,8 +250,11 @@ export const layer = (cfg: Config) =>
|
|
|
158
250
|
s.streamSynthesis(req as ElevenLabsSynthesizeRequest),
|
|
159
251
|
streamSynthesisFrom: (textIn, req: CommonStreamSynthesizeRequest) =>
|
|
160
252
|
s.streamSynthesisFrom(textIn, req),
|
|
253
|
+
synthesizeDialogue: s.synthesizeDialogue,
|
|
254
|
+
streamSynthesizeDialogue: s.streamSynthesizeDialogue,
|
|
161
255
|
}),
|
|
162
256
|
),
|
|
163
257
|
),
|
|
164
258
|
Layer.succeed(TtsIncrementalText, undefined),
|
|
259
|
+
Layer.succeed(MultiSpeakerTts, undefined),
|
|
165
260
|
)
|
|
@@ -2,6 +2,7 @@ import { Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
|
|
|
2
2
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
3
|
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
4
|
import * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import * as Capabilities from "@effect-uai/core/Capabilities"
|
|
5
6
|
import type { TranscriptResult, WordTimestamp } from "@effect-uai/core/Transcript"
|
|
6
7
|
import {
|
|
7
8
|
type CommonTranscribeRequest,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
import { audioToBlob, defaultFileName, httpStatusError, transportFailure } from "./codec.js"
|
|
13
14
|
import type { ElevenLabsSttModel } from "./models.js"
|
|
14
15
|
import { streamTranscription } from "./realtimeStt.js"
|
|
16
|
+
import { type ElevenLabsRegion, resolveHost } from "./region.js"
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Public types
|
|
@@ -43,7 +45,11 @@ export class ElevenLabsTranscriber extends Context.Service<
|
|
|
43
45
|
ElevenLabsTranscriberService
|
|
44
46
|
>()("@betalyra/effect-uai/providers/elevenlabs/ElevenLabsTranscriber") {}
|
|
45
47
|
|
|
46
|
-
export type Config = {
|
|
48
|
+
export type Config = {
|
|
49
|
+
readonly apiKey: Redacted.Redacted
|
|
50
|
+
readonly baseUrl?: string
|
|
51
|
+
readonly region?: ElevenLabsRegion
|
|
52
|
+
}
|
|
47
53
|
|
|
48
54
|
// ---------------------------------------------------------------------------
|
|
49
55
|
// Response schema
|
|
@@ -96,6 +102,16 @@ const buildForm = (request: ElevenLabsTranscribeRequest) =>
|
|
|
96
102
|
if (request.numSpeakers !== undefined) form.set("num_speakers", String(request.numSpeakers))
|
|
97
103
|
if (request.audioEvents !== undefined)
|
|
98
104
|
form.set("tag_audio_events", request.audioEvents ? "true" : "false")
|
|
105
|
+
// Vocab biasing → Scribe `keyterms` (≤1000 terms, ≤50 chars). Each
|
|
106
|
+
// term is a repeated form field (`keyterms=a&keyterms=b`), matching
|
|
107
|
+
// the ElevenLabs SDK. Scribe has no free-form prompt field.
|
|
108
|
+
for (const term of request.biasingTerms ?? []) form.append("keyterms", term)
|
|
109
|
+
yield* Capabilities.warnDroppedWhen(request.prompt, {
|
|
110
|
+
provider: "elevenlabs",
|
|
111
|
+
capability: "prompt",
|
|
112
|
+
field: "prompt",
|
|
113
|
+
reason: "ElevenLabs Scribe has no free-form prompt field; bias via `biasingTerms`.",
|
|
114
|
+
})
|
|
99
115
|
return form
|
|
100
116
|
})
|
|
101
117
|
|
|
@@ -103,13 +119,11 @@ const buildForm = (request: ElevenLabsTranscribeRequest) =>
|
|
|
103
119
|
// HTTP plumbing
|
|
104
120
|
// ---------------------------------------------------------------------------
|
|
105
121
|
|
|
106
|
-
const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://api.elevenlabs.io/v1"
|
|
107
|
-
|
|
108
122
|
const transcribeImpl = (cfg: Config) => (request: ElevenLabsTranscribeRequest) =>
|
|
109
123
|
Effect.gen(function* () {
|
|
110
124
|
const client = yield* HttpClient.HttpClient
|
|
111
125
|
const form = yield* buildForm(request)
|
|
112
|
-
const httpRequest = HttpClientRequest.post(`${
|
|
126
|
+
const httpRequest = HttpClientRequest.post(`${resolveHost(cfg)}/speech-to-text`).pipe(
|
|
113
127
|
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
114
128
|
HttpClientRequest.bodyFormData(form),
|
|
115
129
|
)
|
package/src/codec.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Array as Arr, Effect, Encoding, Match, Option, Result } from "effect"
|
|
2
2
|
import * as AiError from "@effect-uai/core/AiError"
|
|
3
3
|
import type { AudioFormat, AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
import type { CustomPronunciation } from "@effect-uai/core/SpeechSynthesizer"
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Shared TTS voice-settings shape (sync + streaming use the same fields)
|
|
@@ -25,6 +26,52 @@ export const wireVoiceSettings = (v: VoiceSettings | undefined) =>
|
|
|
25
26
|
...(v.speed !== undefined && { speed: v.speed }),
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Pronunciation: dictionary locators (supported) vs inline IPA (rejected)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reference to a pre-provisioned ElevenLabs pronunciation dictionary.
|
|
35
|
+
* The caller provisions and versions the dictionary out-of-band
|
|
36
|
+
* (dashboard or the pronunciation-dictionary API); we only reference it.
|
|
37
|
+
*/
|
|
38
|
+
export type PronunciationDictionaryLocator = {
|
|
39
|
+
readonly dictionaryId: string
|
|
40
|
+
readonly versionId: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const wirePronunciationLocators = (
|
|
44
|
+
locators: ReadonlyArray<PronunciationDictionaryLocator> | undefined,
|
|
45
|
+
) =>
|
|
46
|
+
locators === undefined || locators.length === 0
|
|
47
|
+
? undefined
|
|
48
|
+
: {
|
|
49
|
+
pronunciation_dictionary_locators: locators.map((l) => ({
|
|
50
|
+
pronunciation_dictionary_id: l.dictionaryId,
|
|
51
|
+
version_id: l.versionId,
|
|
52
|
+
})),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ElevenLabs has no stateless inline IPA path (legacy `<phoneme>` SSML
|
|
57
|
+
* is dropped; modern phonetics live in pre-uploaded dictionaries).
|
|
58
|
+
* Inline `pronunciations` are load-bearing (bucket 1), so reject rather
|
|
59
|
+
* than silently mispronounce. Use `pronunciationDictionaryLocators`.
|
|
60
|
+
*/
|
|
61
|
+
export const rejectInlinePronunciations = (
|
|
62
|
+
pronunciations: ReadonlyArray<CustomPronunciation> | undefined,
|
|
63
|
+
): Effect.Effect<void, AiError.AiError> =>
|
|
64
|
+
pronunciations !== undefined && pronunciations.length > 0
|
|
65
|
+
? Effect.fail(
|
|
66
|
+
new AiError.Unsupported({
|
|
67
|
+
provider: "elevenlabs",
|
|
68
|
+
capability: "pronunciations",
|
|
69
|
+
reason:
|
|
70
|
+
"ElevenLabs has no stateless inline IPA path. Provision a pronunciation dictionary and pass `pronunciationDictionaryLocators`, or use a provider with inline phonemes (Inworld, Google).",
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
: Effect.void
|
|
74
|
+
|
|
28
75
|
// ---------------------------------------------------------------------------
|
|
29
76
|
// AudioFormat → ElevenLabs `output_format` query value
|
|
30
77
|
// ---------------------------------------------------------------------------
|