@effect-uai/google 0.3.0 → 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/dist/Gemini.mjs +1 -1
- package/dist/GeminiEmbedding.mjs +1 -1
- package/dist/GeminiSynthesizer.d.mts +48 -0
- package/dist/GeminiSynthesizer.d.mts.map +1 -0
- package/dist/GeminiSynthesizer.mjs +95 -0
- package/dist/GeminiSynthesizer.mjs.map +1 -0
- package/dist/GeminiSynthesizer.test.d.mts +1 -0
- package/dist/GeminiSynthesizer.test.mjs +84 -0
- package/dist/GeminiSynthesizer.test.mjs.map +1 -0
- package/dist/GeminiTranscriber.d.mts +42 -0
- package/dist/GeminiTranscriber.d.mts.map +1 -0
- package/dist/GeminiTranscriber.mjs +80 -0
- package/dist/GeminiTranscriber.mjs.map +1 -0
- package/dist/GeminiTranscriber.test.d.mts +1 -0
- package/dist/GeminiTranscriber.test.mjs +125 -0
- package/dist/GeminiTranscriber.test.mjs.map +1 -0
- package/dist/LyriaGenerator.d.mts +57 -0
- package/dist/LyriaGenerator.d.mts.map +1 -0
- package/dist/LyriaGenerator.mjs +202 -0
- package/dist/LyriaGenerator.mjs.map +1 -0
- package/dist/LyriaGenerator.test.d.mts +1 -0
- package/dist/LyriaGenerator.test.mjs +138 -0
- package/dist/LyriaGenerator.test.mjs.map +1 -0
- package/dist/{chunk-CfYAbeIz.mjs → chunk-uyGKjUfl.mjs} +2 -1
- package/dist/codec.mjs +1 -1
- package/dist/dist-DMsg8YLb.mjs +13782 -0
- package/dist/dist-DMsg8YLb.mjs.map +1 -0
- package/dist/geminiSpeechCodec.d.mts +37 -0
- package/dist/geminiSpeechCodec.d.mts.map +1 -0
- package/dist/geminiSpeechCodec.mjs +110 -0
- package/dist/geminiSpeechCodec.mjs.map +1 -0
- package/dist/geminiSpeechCodec.test.d.mts +1 -0
- package/dist/geminiSpeechCodec.test.mjs +78 -0
- package/dist/geminiSpeechCodec.test.mjs.map +1 -0
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +4 -1
- 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 +35 -1
- package/dist/models.d.mts.map +1 -1
- package/package.json +14 -2
- package/src/GeminiSynthesizer.test.ts +93 -0
- package/src/GeminiSynthesizer.ts +214 -0
- package/src/GeminiTranscriber.test.ts +136 -0
- package/src/GeminiTranscriber.ts +204 -0
- package/src/LyriaGenerator.test.ts +162 -0
- package/src/LyriaGenerator.ts +394 -0
- package/src/geminiSpeechCodec.test.ts +56 -0
- package/src/geminiSpeechCodec.ts +137 -0
- package/src/index.ts +3 -0
- package/src/models.ts +84 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
Context,
|
|
4
|
+
Effect,
|
|
5
|
+
Encoding,
|
|
6
|
+
Layer,
|
|
7
|
+
Match,
|
|
8
|
+
Option,
|
|
9
|
+
Redacted,
|
|
10
|
+
Result,
|
|
11
|
+
Schema,
|
|
12
|
+
Stream,
|
|
13
|
+
} from "effect"
|
|
14
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
15
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
16
|
+
import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
|
|
17
|
+
import type {
|
|
18
|
+
CommonGenerateMusicRequest,
|
|
19
|
+
CommonStreamGenerateMusicRequest,
|
|
20
|
+
MusicResult,
|
|
21
|
+
MusicSessionInput,
|
|
22
|
+
WeightedPrompt,
|
|
23
|
+
} from "@effect-uai/core/Music"
|
|
24
|
+
import { MusicGenerator, type MusicGeneratorService } from "@effect-uai/core/MusicGenerator"
|
|
25
|
+
import type { LyriaModel } from "./models.js"
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lyria-typed generate request. Narrows `model` to `LyriaModel`. The
|
|
33
|
+
* Lyria sync API (`generateContent`) accepts only `audio/mp3` and
|
|
34
|
+
* `audio/wav` output; `bpm`, `scale`, `instrumental` etc. on the
|
|
35
|
+
* `CommonGenerateMusicRequest` are flattened into the prompt text
|
|
36
|
+
* because the public API does not expose them as structured fields.
|
|
37
|
+
*/
|
|
38
|
+
export type LyriaGenerateRequest = Omit<CommonGenerateMusicRequest, "model"> & {
|
|
39
|
+
readonly model: LyriaModel
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type LyriaGeneratorService = {
|
|
43
|
+
readonly generate: (request: LyriaGenerateRequest) => Effect.Effect<MusicResult, AiError.AiError>
|
|
44
|
+
readonly streamGeneration: (
|
|
45
|
+
request: LyriaGenerateRequest,
|
|
46
|
+
) => Stream.Stream<AudioChunk, AiError.AiError>
|
|
47
|
+
readonly streamGenerationFrom: <E, R>(
|
|
48
|
+
input: Stream.Stream<MusicSessionInput, E, R>,
|
|
49
|
+
request: CommonStreamGenerateMusicRequest,
|
|
50
|
+
) => Stream.Stream<AudioChunk, AiError.AiError | E, R>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class LyriaGenerator extends Context.Service<LyriaGenerator, LyriaGeneratorService>()(
|
|
54
|
+
"@betalyra/effect-uai/providers/google/LyriaGenerator",
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
export type Config = {
|
|
58
|
+
readonly apiKey: Redacted.Redacted
|
|
59
|
+
readonly baseUrl?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Codec — request → prompt text + response_format
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export type LyriaResponseMimeType = "audio/mp3" | "audio/wav"
|
|
67
|
+
|
|
68
|
+
export const containerToMimeType: (
|
|
69
|
+
container: AudioFormat["container"],
|
|
70
|
+
) => Effect.Effect<LyriaResponseMimeType, AiError.AiError> = Match.type<
|
|
71
|
+
AudioFormat["container"]
|
|
72
|
+
>().pipe(
|
|
73
|
+
Match.when("mp3", () => Effect.succeed<LyriaResponseMimeType>("audio/mp3")),
|
|
74
|
+
Match.when("wav", () => Effect.succeed<LyriaResponseMimeType>("audio/wav")),
|
|
75
|
+
Match.whenOr("ogg", "opus", "flac", "aac", "webm", "raw", (c) =>
|
|
76
|
+
Effect.fail(
|
|
77
|
+
new AiError.Unsupported({
|
|
78
|
+
provider: "lyria",
|
|
79
|
+
capability: "outputFormat",
|
|
80
|
+
reason: `Lyria 3 only produces audio/mp3 or audio/wav; ${c} is not available. Use container: "mp3" or "wav".`,
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
Match.exhaustive,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this a Lyria 3 Clip model variant? Clip is fixed at 30 s MP3 — it
|
|
89
|
+
* does not accept a `responseFormat` on `generationConfig` (the live
|
|
90
|
+
* API rejects unknown fields, despite what the docs imply). Pro
|
|
91
|
+
* accepts the documented `responseFormat` knob.
|
|
92
|
+
*/
|
|
93
|
+
const isClipModel = (model: string): boolean => model.includes("clip")
|
|
94
|
+
|
|
95
|
+
export const realizedFormat: (mime: LyriaResponseMimeType) => AudioFormat =
|
|
96
|
+
Match.type<LyriaResponseMimeType>().pipe(
|
|
97
|
+
Match.when(
|
|
98
|
+
"audio/mp3",
|
|
99
|
+
(): AudioFormat => ({ container: "mp3", encoding: "mp3", sampleRate: 44100, channels: 2 }),
|
|
100
|
+
),
|
|
101
|
+
Match.when(
|
|
102
|
+
"audio/wav",
|
|
103
|
+
(): AudioFormat => ({
|
|
104
|
+
container: "wav",
|
|
105
|
+
encoding: "pcm_s16le",
|
|
106
|
+
sampleRate: 44100,
|
|
107
|
+
channels: 2,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
Match.exhaustive,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Collapse `prompts | WeightedPrompt[]` into a single prompt string and
|
|
115
|
+
* splice in non-structural hints (`lyrics`, `bpm`, `scale`,
|
|
116
|
+
* `instrumental`, `durationSeconds`). The Lyria 3 sync API only takes
|
|
117
|
+
* one text part — every condition lives inline.
|
|
118
|
+
*/
|
|
119
|
+
export const buildPrompt = (request: CommonGenerateMusicRequest): string => {
|
|
120
|
+
const promptText = Match.value(request.prompts).pipe(
|
|
121
|
+
Match.when(Match.string, (s) => s),
|
|
122
|
+
Match.orElse((ws: ReadonlyArray<WeightedPrompt>) =>
|
|
123
|
+
Arr.map(ws, (w) =>
|
|
124
|
+
w.weight !== undefined && w.weight !== 1 ? `${w.text} (weight ${w.weight})` : w.text,
|
|
125
|
+
).join(". "),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
const hints = Arr.getSomes([
|
|
129
|
+
request.instrumental === true ? Option.some("Instrumental only — no vocals.") : Option.none(),
|
|
130
|
+
request.bpm !== undefined ? Option.some(`BPM: ${request.bpm}.`) : Option.none(),
|
|
131
|
+
request.scale !== undefined ? Option.some(`Key/scale: ${request.scale}.`) : Option.none(),
|
|
132
|
+
request.durationSeconds !== undefined
|
|
133
|
+
? Option.some(`Target duration: ${request.durationSeconds}s.`)
|
|
134
|
+
: Option.none(),
|
|
135
|
+
])
|
|
136
|
+
const lyricsBlock =
|
|
137
|
+
request.lyrics !== undefined && request.instrumental !== true
|
|
138
|
+
? `\n\nLyrics:\n${request.lyrics}`
|
|
139
|
+
: ""
|
|
140
|
+
return [promptText, ...hints].join(" ") + lyricsBlock
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Wire response schema — just enough to find audio + optional lyrics text
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const InlineData = Schema.Struct({
|
|
148
|
+
mimeType: Schema.optional(Schema.String),
|
|
149
|
+
data: Schema.String,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const Part = Schema.Struct({
|
|
153
|
+
text: Schema.optional(Schema.String),
|
|
154
|
+
inlineData: Schema.optional(InlineData),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const Content = Schema.Struct({
|
|
158
|
+
parts: Schema.optional(Schema.Array(Part)),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const Candidate = Schema.Struct({
|
|
162
|
+
content: Schema.optional(Content),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const Wire = Schema.Struct({
|
|
166
|
+
candidates: Schema.optional(Schema.Array(Candidate)),
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const decodeWire = Schema.decodeUnknownEffect(Wire)
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// HTTP plumbing
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
const baseUrl = (cfg: Config): string =>
|
|
176
|
+
cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
|
|
177
|
+
|
|
178
|
+
const transportFailure = (cause: unknown): AiError.AiError =>
|
|
179
|
+
new AiError.Unavailable({ provider: "lyria", raw: cause })
|
|
180
|
+
|
|
181
|
+
const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
|
|
182
|
+
Match.value(status).pipe(
|
|
183
|
+
Match.when(
|
|
184
|
+
429,
|
|
185
|
+
(): AiError.AiError => new AiError.RateLimited({ provider: "lyria", raw: body }),
|
|
186
|
+
),
|
|
187
|
+
Match.whenOr(
|
|
188
|
+
408,
|
|
189
|
+
504,
|
|
190
|
+
(): AiError.AiError => new AiError.Timeout({ provider: "lyria", raw: body }),
|
|
191
|
+
),
|
|
192
|
+
Match.when(
|
|
193
|
+
401,
|
|
194
|
+
(): AiError.AiError =>
|
|
195
|
+
new AiError.AuthFailed({ provider: "lyria", subtype: "auth", raw: body }),
|
|
196
|
+
),
|
|
197
|
+
Match.when(
|
|
198
|
+
403,
|
|
199
|
+
(): AiError.AiError =>
|
|
200
|
+
new AiError.AuthFailed({ provider: "lyria", subtype: "permission", raw: body }),
|
|
201
|
+
),
|
|
202
|
+
Match.when(
|
|
203
|
+
402,
|
|
204
|
+
(): AiError.AiError =>
|
|
205
|
+
new AiError.AuthFailed({ provider: "lyria", subtype: "billing", raw: body }),
|
|
206
|
+
),
|
|
207
|
+
Match.when(
|
|
208
|
+
(n) => n >= 500,
|
|
209
|
+
(n): AiError.AiError => new AiError.Unavailable({ provider: "lyria", status: n, raw: body }),
|
|
210
|
+
),
|
|
211
|
+
Match.orElse(
|
|
212
|
+
(): AiError.AiError => new AiError.InvalidRequest({ provider: "lyria", raw: body }),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const decodeBase64ToBytes = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
217
|
+
Result.match(Encoding.decodeBase64(b64), {
|
|
218
|
+
onSuccess: Effect.succeed,
|
|
219
|
+
onFailure: (cause) =>
|
|
220
|
+
Effect.fail(new AiError.InvalidRequest({ provider: "lyria", param: "audio", raw: cause })),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Find the first inline-data audio part and the concatenated text parts
|
|
225
|
+
* from a Wire response.
|
|
226
|
+
*/
|
|
227
|
+
const extractParts = (
|
|
228
|
+
wire: typeof Wire.Type,
|
|
229
|
+
): { readonly inline?: typeof InlineData.Type; readonly text?: string } => {
|
|
230
|
+
const allParts = (wire.candidates ?? []).flatMap((c) => c.content?.parts ?? [])
|
|
231
|
+
const inline = allParts.find((p) => p.inlineData !== undefined)?.inlineData
|
|
232
|
+
const textParts = allParts.flatMap((p) => (p.text !== undefined ? [p.text] : []))
|
|
233
|
+
const text = textParts.length === 0 ? undefined : textParts.join("\n")
|
|
234
|
+
return {
|
|
235
|
+
...(inline !== undefined && { inline }),
|
|
236
|
+
...(text !== undefined && { text }),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Service implementation
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const generateImpl =
|
|
245
|
+
(cfg: Config) =>
|
|
246
|
+
(
|
|
247
|
+
request: LyriaGenerateRequest,
|
|
248
|
+
): Effect.Effect<MusicResult, AiError.AiError, HttpClient.HttpClient> =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
const client = yield* HttpClient.HttpClient
|
|
251
|
+
const requestedContainer = (request.outputFormat?.container ??
|
|
252
|
+
"mp3") satisfies AudioFormat["container"]
|
|
253
|
+
const mimeType = yield* containerToMimeType(requestedContainer)
|
|
254
|
+
// Clip is fixed 30 s mp3 — reject wav up front rather than sending
|
|
255
|
+
// a `responseFormat` that the live API ignores or rejects.
|
|
256
|
+
if (isClipModel(request.model) && mimeType === "audio/wav") {
|
|
257
|
+
return yield* Effect.fail(
|
|
258
|
+
new AiError.Unsupported({
|
|
259
|
+
provider: "lyria",
|
|
260
|
+
capability: "outputFormat",
|
|
261
|
+
reason: `lyria-3-clip-preview is fixed at audio/mp3. Use lyria-3-pro-preview for audio/wav output.`,
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
const promptText = buildPrompt(request)
|
|
266
|
+
const body = isClipModel(request.model)
|
|
267
|
+
? { contents: [{ parts: [{ text: promptText }] }] }
|
|
268
|
+
: {
|
|
269
|
+
contents: [{ parts: [{ text: promptText }] }],
|
|
270
|
+
generationConfig: {
|
|
271
|
+
responseModalities: ["AUDIO", "TEXT"],
|
|
272
|
+
responseFormat: { audio: { mimeType } },
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
const httpRequest = HttpClientRequest.post(
|
|
276
|
+
`${baseUrl(cfg)}/models/${request.model}:generateContent`,
|
|
277
|
+
).pipe(
|
|
278
|
+
HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
|
|
279
|
+
HttpClientRequest.bodyJsonUnsafe(body),
|
|
280
|
+
)
|
|
281
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
282
|
+
if (response.status >= 400) {
|
|
283
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
284
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
285
|
+
}
|
|
286
|
+
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
287
|
+
const wire = yield* decodeWire(json).pipe(
|
|
288
|
+
Effect.mapError(
|
|
289
|
+
(cause): AiError.AiError =>
|
|
290
|
+
new AiError.GenerationFailed({ provider: "lyria", raw: cause }),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
const { inline, text } = extractParts(wire)
|
|
294
|
+
if (inline === undefined) {
|
|
295
|
+
// No audio came back. Most common cause: Lyria filtered the prompt
|
|
296
|
+
// (named-artist references, copyrighted lyrics, etc.). Surface the
|
|
297
|
+
// full response body — text parts, finish reasons, prompt-feedback
|
|
298
|
+
// blocks — so the caller can see why.
|
|
299
|
+
return yield* Effect.fail(
|
|
300
|
+
new AiError.GenerationFailed({
|
|
301
|
+
provider: "lyria",
|
|
302
|
+
raw: {
|
|
303
|
+
message: "Lyria response had no inlineData audio part.",
|
|
304
|
+
hint: "Likely a prompt-filter rejection. Lyria filters references to real artists / copyrighted song lyrics. Rephrase with descriptive style language instead of artist names.",
|
|
305
|
+
textParts: text,
|
|
306
|
+
response: json,
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
const bytes = yield* decodeBase64ToBytes(inline.data)
|
|
312
|
+
return {
|
|
313
|
+
format: realizedFormat(mimeType),
|
|
314
|
+
bytes,
|
|
315
|
+
...(text !== undefined && { lyrics: text }),
|
|
316
|
+
watermark: { kind: "synthid" },
|
|
317
|
+
} satisfies MusicResult
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const streamGenerationImpl =
|
|
321
|
+
(cfg: Config) =>
|
|
322
|
+
(
|
|
323
|
+
request: LyriaGenerateRequest,
|
|
324
|
+
): Stream.Stream<AudioChunk, AiError.AiError, HttpClient.HttpClient> =>
|
|
325
|
+
Stream.unwrap(
|
|
326
|
+
Effect.map(generateImpl(cfg)(request), (result) =>
|
|
327
|
+
Stream.succeed<AudioChunk>({ bytes: result.bytes }),
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Lyria 3 sync has no bidirectional session at the wire level — that's
|
|
333
|
+
* Lyria RealTime (Phase 1b, separate Layer). The provider Layer here
|
|
334
|
+
* also does NOT register `MusicInteractiveSession`, so callers using
|
|
335
|
+
* `MusicGenerator.streamGenerationFrom` against only this Layer get a
|
|
336
|
+
* compile-time error before this runtime fallback ever fires.
|
|
337
|
+
*/
|
|
338
|
+
const streamGenerationFromUnsupported = <E, R>(
|
|
339
|
+
_input: Stream.Stream<MusicSessionInput, E, R>,
|
|
340
|
+
_request: CommonStreamGenerateMusicRequest,
|
|
341
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R> => {
|
|
342
|
+
const fail: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
|
|
343
|
+
new AiError.Unsupported({
|
|
344
|
+
provider: "lyria",
|
|
345
|
+
capability: "streamGenerationFrom",
|
|
346
|
+
reason:
|
|
347
|
+
"Lyria 3 sync does not support bidirectional sessions. Use the Lyria RealTime Layer (WebSocket BidiGenerateMusic) for interactive mid-session prompts.",
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
return fail
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Constructors
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
export const make = (
|
|
358
|
+
cfg: Config,
|
|
359
|
+
): Effect.Effect<LyriaGeneratorService, never, HttpClient.HttpClient> =>
|
|
360
|
+
Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
|
|
361
|
+
generate: (request) =>
|
|
362
|
+
generateImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
363
|
+
streamGeneration: (request) =>
|
|
364
|
+
streamGenerationImpl(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client)),
|
|
365
|
+
streamGenerationFrom: streamGenerationFromUnsupported,
|
|
366
|
+
}))
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Layer that registers both the provider-specific `LyriaGenerator` tag
|
|
370
|
+
* and the generic `MusicGenerator` tag.
|
|
371
|
+
*
|
|
372
|
+
* Does NOT register the `MusicInteractiveSession` capability marker —
|
|
373
|
+
* Lyria 3 sync has no bidirectional session at the wire level. Code
|
|
374
|
+
* calling `MusicGenerator.streamGenerationFrom` will fail to typecheck
|
|
375
|
+
* against this Layer alone, which is the intended UX.
|
|
376
|
+
*/
|
|
377
|
+
export const layer = (
|
|
378
|
+
cfg: Config,
|
|
379
|
+
): Layer.Layer<LyriaGenerator | MusicGenerator, never, HttpClient.HttpClient> =>
|
|
380
|
+
Layer.merge(
|
|
381
|
+
Layer.effect(LyriaGenerator, make(cfg)),
|
|
382
|
+
Layer.effect(
|
|
383
|
+
MusicGenerator,
|
|
384
|
+
Effect.map(
|
|
385
|
+
make(cfg),
|
|
386
|
+
(s): MusicGeneratorService => ({
|
|
387
|
+
generate: (req: CommonGenerateMusicRequest) => s.generate(req as LyriaGenerateRequest),
|
|
388
|
+
streamGeneration: (req: CommonStreamGenerateMusicRequest) =>
|
|
389
|
+
s.streamGeneration(req as LyriaGenerateRequest),
|
|
390
|
+
streamGenerationFrom: s.streamGenerationFrom,
|
|
391
|
+
}),
|
|
392
|
+
),
|
|
393
|
+
),
|
|
394
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect, Encoding } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import type { AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
import { audioSourceToInlineData, wrapPcmAsWav } from "./geminiSpeechCodec.js"
|
|
5
|
+
|
|
6
|
+
describe("audioSourceToInlineData", () => {
|
|
7
|
+
it("encodes a `bytes` source to base64 with its MIME type", async () => {
|
|
8
|
+
const original = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
|
9
|
+
const source: AudioSource = { _tag: "bytes", bytes: original, mimeType: "audio/wav" }
|
|
10
|
+
const inline = await Effect.runPromise(audioSourceToInlineData(source))
|
|
11
|
+
expect(inline.mimeType).toBe("audio/wav")
|
|
12
|
+
expect(inline.data).toBe(Encoding.encodeBase64(original))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("passes a `base64` source through unchanged", async () => {
|
|
16
|
+
const b64 = Encoding.encodeBase64(new Uint8Array([1, 2, 3]))
|
|
17
|
+
const source: AudioSource = { _tag: "base64", base64: b64, mimeType: "audio/mp3" }
|
|
18
|
+
const inline = await Effect.runPromise(audioSourceToInlineData(source))
|
|
19
|
+
expect(inline.mimeType).toBe("audio/mp3")
|
|
20
|
+
expect(inline.data).toBe(b64)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("fails InvalidRequest for a URL source", async () => {
|
|
24
|
+
const source: AudioSource = { _tag: "url", url: "https://x/y.mp3", mimeType: "audio/mp3" }
|
|
25
|
+
const exit = await Effect.runPromiseExit(audioSourceToInlineData(source))
|
|
26
|
+
expect(exit._tag).toBe("Failure")
|
|
27
|
+
if (exit._tag === "Failure") {
|
|
28
|
+
expect(JSON.stringify(exit.cause)).toContain("InvalidRequest")
|
|
29
|
+
expect(JSON.stringify(exit.cause)).toContain("Files API")
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("wrapPcmAsWav", () => {
|
|
35
|
+
it("produces a 44-byte RIFF/WAVE header for 16-bit mono PCM at 24 kHz", () => {
|
|
36
|
+
const pcm = new Uint8Array([0x00, 0x01, 0x00, 0x02])
|
|
37
|
+
const wav = wrapPcmAsWav(pcm, 24000, 1, 16)
|
|
38
|
+
expect(wav.length).toBe(44 + pcm.length)
|
|
39
|
+
const ascii = (start: number, len: number) =>
|
|
40
|
+
String.fromCharCode(...Array.from(wav.slice(start, start + len)))
|
|
41
|
+
expect(ascii(0, 4)).toBe("RIFF")
|
|
42
|
+
expect(ascii(8, 4)).toBe("WAVE")
|
|
43
|
+
expect(ascii(12, 4)).toBe("fmt ")
|
|
44
|
+
expect(ascii(36, 4)).toBe("data")
|
|
45
|
+
// RIFF chunk size = total - 8
|
|
46
|
+
const view = new DataView(wav.buffer)
|
|
47
|
+
expect(view.getUint32(4, true)).toBe(wav.length - 8)
|
|
48
|
+
expect(view.getUint16(20, true)).toBe(1) // PCM
|
|
49
|
+
expect(view.getUint16(22, true)).toBe(1) // mono
|
|
50
|
+
expect(view.getUint32(24, true)).toBe(24000) // sample rate
|
|
51
|
+
expect(view.getUint16(34, true)).toBe(16) // bits per sample
|
|
52
|
+
expect(view.getUint32(40, true)).toBe(pcm.length) // data size
|
|
53
|
+
// Payload preserved
|
|
54
|
+
expect(Array.from(wav.slice(44))).toEqual(Array.from(pcm))
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Effect, Encoding, Match, Result } from "effect"
|
|
2
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
3
|
+
import type { AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared codec helpers for Gemini speech endpoints. The TTS and STT
|
|
7
|
+
* variants both ride on `:generateContent`, so the audio-in / audio-out
|
|
8
|
+
* shapes are siblings.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const urlNotSupported: AiError.AiError = new AiError.InvalidRequest({
|
|
12
|
+
provider: "gemini",
|
|
13
|
+
param: "audio",
|
|
14
|
+
raw: 'Gemini inline audio does not accept URL sources. Either fetch the URL yourself and pass `{ _tag: "bytes", bytes, mimeType }`, or upload via the Files API and pass a `fileData` URI (not yet wired here).',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Wire shape for an inline-audio part on `generateContent`. */
|
|
18
|
+
export type InlineAudioData = {
|
|
19
|
+
readonly mimeType: string
|
|
20
|
+
readonly data: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Encode any `AudioSource` into the `inlineData` shape expected by
|
|
25
|
+
* Gemini's `generateContent`. URL variants are rejected with
|
|
26
|
+
* `InvalidRequest` — Files API upload is a separate flow.
|
|
27
|
+
*
|
|
28
|
+
* Caller is responsible for keeping the total request body under
|
|
29
|
+
* 20 MB. Beyond that, Gemini's Files API is the only path.
|
|
30
|
+
*/
|
|
31
|
+
export const audioSourceToInlineData: (
|
|
32
|
+
audio: AudioSource,
|
|
33
|
+
) => Effect.Effect<InlineAudioData, AiError.AiError> = Match.type<AudioSource>().pipe(
|
|
34
|
+
Match.tag("bytes", (a) =>
|
|
35
|
+
Effect.succeed<InlineAudioData>({
|
|
36
|
+
mimeType: a.mimeType,
|
|
37
|
+
data: Encoding.encodeBase64(a.bytes),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
Match.tag("base64", (a) =>
|
|
41
|
+
Effect.succeed<InlineAudioData>({ mimeType: a.mimeType, data: a.base64 }),
|
|
42
|
+
),
|
|
43
|
+
Match.tag("url", () => Effect.fail(urlNotSupported)),
|
|
44
|
+
Match.exhaustive,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/** Decode a base64-encoded audio payload into bytes. */
|
|
48
|
+
export const decodeBase64Audio = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
49
|
+
Result.match(Encoding.decodeBase64(b64), {
|
|
50
|
+
onSuccess: Effect.succeed,
|
|
51
|
+
onFailure: (cause) =>
|
|
52
|
+
Effect.fail(new AiError.InvalidRequest({ provider: "gemini", param: "audio", raw: cause })),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wrap a raw 16-bit signed little-endian PCM buffer in a standard 44-byte
|
|
57
|
+
* RIFF/WAVE header. Gemini TTS returns PCM-only — for playback or for
|
|
58
|
+
* round-tripping into Gemini transcription (which accepts wav, not raw
|
|
59
|
+
* PCM), the bytes need a container.
|
|
60
|
+
*/
|
|
61
|
+
export const wrapPcmAsWav = (
|
|
62
|
+
pcm: Uint8Array,
|
|
63
|
+
sampleRate: number,
|
|
64
|
+
channels: number,
|
|
65
|
+
bitsPerSample = 16,
|
|
66
|
+
): Uint8Array => {
|
|
67
|
+
const dataSize = pcm.length
|
|
68
|
+
const blockAlign = (channels * bitsPerSample) / 8
|
|
69
|
+
const byteRate = sampleRate * blockAlign
|
|
70
|
+
const totalSize = 44 + dataSize
|
|
71
|
+
const buffer = new ArrayBuffer(totalSize)
|
|
72
|
+
const view = new DataView(buffer)
|
|
73
|
+
const writeAscii = (offset: number, s: string): void => {
|
|
74
|
+
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i))
|
|
75
|
+
}
|
|
76
|
+
writeAscii(0, "RIFF")
|
|
77
|
+
view.setUint32(4, totalSize - 8, true)
|
|
78
|
+
writeAscii(8, "WAVE")
|
|
79
|
+
writeAscii(12, "fmt ")
|
|
80
|
+
view.setUint32(16, 16, true)
|
|
81
|
+
view.setUint16(20, 1, true)
|
|
82
|
+
view.setUint16(22, channels, true)
|
|
83
|
+
view.setUint32(24, sampleRate, true)
|
|
84
|
+
view.setUint32(28, byteRate, true)
|
|
85
|
+
view.setUint16(32, blockAlign, true)
|
|
86
|
+
view.setUint16(34, bitsPerSample, true)
|
|
87
|
+
writeAscii(36, "data")
|
|
88
|
+
view.setUint32(40, dataSize, true)
|
|
89
|
+
new Uint8Array(buffer, 44, dataSize).set(pcm)
|
|
90
|
+
return new Uint8Array(buffer)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* HTTP status → typed `AiError`. Shared between synthesizer and
|
|
95
|
+
* transcriber.
|
|
96
|
+
*/
|
|
97
|
+
export const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
|
|
98
|
+
Match.value(status).pipe(
|
|
99
|
+
Match.when(
|
|
100
|
+
429,
|
|
101
|
+
(): AiError.AiError => new AiError.RateLimited({ provider: "gemini", raw: body }),
|
|
102
|
+
),
|
|
103
|
+
Match.whenOr(
|
|
104
|
+
408,
|
|
105
|
+
504,
|
|
106
|
+
(): AiError.AiError => new AiError.Timeout({ provider: "gemini", raw: body }),
|
|
107
|
+
),
|
|
108
|
+
Match.when(
|
|
109
|
+
401,
|
|
110
|
+
(): AiError.AiError =>
|
|
111
|
+
new AiError.AuthFailed({ provider: "gemini", subtype: "auth", raw: body }),
|
|
112
|
+
),
|
|
113
|
+
Match.when(
|
|
114
|
+
403,
|
|
115
|
+
(): AiError.AiError =>
|
|
116
|
+
new AiError.AuthFailed({ provider: "gemini", subtype: "permission", raw: body }),
|
|
117
|
+
),
|
|
118
|
+
Match.when(
|
|
119
|
+
402,
|
|
120
|
+
(): AiError.AiError =>
|
|
121
|
+
new AiError.AuthFailed({ provider: "gemini", subtype: "billing", raw: body }),
|
|
122
|
+
),
|
|
123
|
+
Match.when(
|
|
124
|
+
413,
|
|
125
|
+
(): AiError.AiError => new AiError.ContextLengthExceeded({ provider: "gemini", raw: body }),
|
|
126
|
+
),
|
|
127
|
+
Match.when(
|
|
128
|
+
(n) => n >= 500,
|
|
129
|
+
(n): AiError.AiError => new AiError.Unavailable({ provider: "gemini", status: n, raw: body }),
|
|
130
|
+
),
|
|
131
|
+
Match.orElse(
|
|
132
|
+
(): AiError.AiError => new AiError.InvalidRequest({ provider: "gemini", raw: body }),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
export const transportFailure = (cause: unknown): AiError.AiError =>
|
|
137
|
+
new AiError.Unavailable({ provider: "gemini", raw: cause })
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * as Gemini from "./Gemini.js"
|
|
2
2
|
export * as GeminiEmbedding from "./GeminiEmbedding.js"
|
|
3
|
+
export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
|
|
4
|
+
export * as GeminiTranscriber from "./GeminiTranscriber.js"
|
|
5
|
+
export * as LyriaGenerator from "./LyriaGenerator.js"
|
|
3
6
|
export * as codec from "./codec.js"
|
|
4
7
|
export * from "./models.js"
|
package/src/models.ts
CHANGED
|
@@ -31,6 +31,90 @@ export type GoogleModel =
|
|
|
31
31
|
*
|
|
32
32
|
* Reference: https://ai.google.dev/gemini-api/docs/embeddings
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* Known Gemini TTS model identifiers. `(string & {})` tail keeps
|
|
36
|
+
* autocomplete on the literals while still accepting any string.
|
|
37
|
+
*
|
|
38
|
+
* Reference: https://ai.google.dev/gemini-api/docs/speech-generation
|
|
39
|
+
*/
|
|
40
|
+
export type GeminiTtsModel =
|
|
41
|
+
| "gemini-2.5-flash-preview-tts"
|
|
42
|
+
| "gemini-2.5-pro-preview-tts"
|
|
43
|
+
| "gemini-3.1-flash-tts-preview"
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
45
|
+
| (string & {})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Known Gemini models that accept audio input via `inlineData` /
|
|
49
|
+
* `fileData` on `generateContent`. Used by `GeminiTranscriber` —
|
|
50
|
+
* transcription is prompt-driven on top of these models, not a
|
|
51
|
+
* dedicated endpoint.
|
|
52
|
+
*
|
|
53
|
+
* Reference: https://ai.google.dev/gemini-api/docs/audio
|
|
54
|
+
*/
|
|
55
|
+
export type GeminiSttModel =
|
|
56
|
+
| "gemini-3-flash-preview"
|
|
57
|
+
| "gemini-3.1-pro-preview"
|
|
58
|
+
| "gemini-3.1-flash-lite-preview"
|
|
59
|
+
| "gemini-2.5-flash"
|
|
60
|
+
| "gemini-2.5-pro"
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
62
|
+
| (string & {})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 30 prebuilt voice names for Gemini TTS. No custom-voice / cloning path
|
|
66
|
+
* is exposed on this surface, so the tail is stock-only (no
|
|
67
|
+
* `(string & {})` escape).
|
|
68
|
+
*
|
|
69
|
+
* Reference: https://ai.google.dev/gemini-api/docs/speech-generation
|
|
70
|
+
*/
|
|
71
|
+
export type GeminiVoiceName =
|
|
72
|
+
| "Zephyr"
|
|
73
|
+
| "Puck"
|
|
74
|
+
| "Charon"
|
|
75
|
+
| "Kore"
|
|
76
|
+
| "Fenrir"
|
|
77
|
+
| "Leda"
|
|
78
|
+
| "Orus"
|
|
79
|
+
| "Aoede"
|
|
80
|
+
| "Callirrhoe"
|
|
81
|
+
| "Autonoe"
|
|
82
|
+
| "Enceladus"
|
|
83
|
+
| "Iapetus"
|
|
84
|
+
| "Umbriel"
|
|
85
|
+
| "Algieba"
|
|
86
|
+
| "Despina"
|
|
87
|
+
| "Erinome"
|
|
88
|
+
| "Algenib"
|
|
89
|
+
| "Rasalgethi"
|
|
90
|
+
| "Laomedeia"
|
|
91
|
+
| "Achernar"
|
|
92
|
+
| "Alnilam"
|
|
93
|
+
| "Schedar"
|
|
94
|
+
| "Gacrux"
|
|
95
|
+
| "Pulcherrima"
|
|
96
|
+
| "Achird"
|
|
97
|
+
| "Zubenelgenubi"
|
|
98
|
+
| "Vindemiatrix"
|
|
99
|
+
| "Sadachbia"
|
|
100
|
+
| "Sadaltager"
|
|
101
|
+
| "Sulafat"
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Known Lyria (music generation) model identifiers. `(string & {})` tail
|
|
105
|
+
* keeps autocomplete on the literals while still accepting any string.
|
|
106
|
+
*
|
|
107
|
+
* - `lyria-3-clip-preview` — fixed 30s output, MP3 only.
|
|
108
|
+
* - `lyria-3-pro-preview` — up to a couple of minutes, MP3 or WAV.
|
|
109
|
+
*
|
|
110
|
+
* Reference: https://ai.google.dev/gemini-api/docs/music-generation
|
|
111
|
+
*/
|
|
112
|
+
export type LyriaModel =
|
|
113
|
+
| "lyria-3-clip-preview"
|
|
114
|
+
| "lyria-3-pro-preview"
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
116
|
+
| (string & {})
|
|
117
|
+
|
|
34
118
|
export type GoogleEmbeddingModel =
|
|
35
119
|
| "gemini-embedding-2"
|
|
36
120
|
| "gemini-embedding-001"
|