@effect-uai/google 0.2.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.d.mts +105 -0
- package/dist/Gemini.d.mts.map +1 -0
- package/dist/Gemini.mjs +170 -0
- package/dist/Gemini.mjs.map +1 -0
- package/dist/GeminiEmbedding.d.mts +64 -0
- package/dist/GeminiEmbedding.d.mts.map +1 -0
- package/dist/GeminiEmbedding.mjs +167 -0
- package/dist/GeminiEmbedding.mjs.map +1 -0
- 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.d.mts +107 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +151 -0
- package/dist/codec.mjs.map +1 -0
- 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 +8 -214
- package/dist/index.mjs +8 -289
- 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 +61 -0
- package/dist/models.d.mts.map +1 -0
- package/dist/models.mjs +1 -0
- package/package.json +32 -12
- package/src/Gemini.ts +3 -3
- package/src/GeminiEmbedding.ts +351 -0
- 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/codec.ts +42 -35
- package/src/geminiSpeechCodec.test.ts +56 -0
- package/src/geminiSpeechCodec.ts +137 -0
- package/src/index.ts +6 -2
- package/src/models.ts +104 -0
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import type { AudioSource } from "@effect-uai/core/Audio"
|
|
6
|
+
import type { TranscriptResult } from "@effect-uai/core/Transcript"
|
|
7
|
+
import * as Transcriber from "@effect-uai/core/Transcriber"
|
|
8
|
+
import * as GeminiTranscriber from "./GeminiTranscriber.js"
|
|
9
|
+
|
|
10
|
+
const cfg: GeminiTranscriber.Config = { apiKey: Redacted.make("test-key") }
|
|
11
|
+
const live = Layer.provide(GeminiTranscriber.layer(cfg), FetchHttpClient.layer)
|
|
12
|
+
|
|
13
|
+
const dummyAudio: AudioSource = {
|
|
14
|
+
_tag: "bytes",
|
|
15
|
+
bytes: new Uint8Array([0, 1, 2, 3]),
|
|
16
|
+
mimeType: "audio/wav",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GeminiTranscriber.buildPrompt", () => {
|
|
20
|
+
it("uses the language-agnostic prompt when no language is set", () => {
|
|
21
|
+
expect(
|
|
22
|
+
GeminiTranscriber.buildPrompt({ audio: dummyAudio, model: "gemini-2.5-flash" }),
|
|
23
|
+
).toContain("Transcribe the audio verbatim. Return only the transcript text")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("includes the language when set", () => {
|
|
27
|
+
expect(
|
|
28
|
+
GeminiTranscriber.buildPrompt({
|
|
29
|
+
audio: dummyAudio,
|
|
30
|
+
model: "gemini-2.5-flash",
|
|
31
|
+
language: "pt-BR",
|
|
32
|
+
}),
|
|
33
|
+
).toContain("in pt-BR")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("appends a free-text prompt as context", () => {
|
|
37
|
+
const out = GeminiTranscriber.buildPrompt({
|
|
38
|
+
audio: dummyAudio,
|
|
39
|
+
model: "gemini-2.5-flash",
|
|
40
|
+
prompt: "Engineering meeting; expect technical jargon.",
|
|
41
|
+
})
|
|
42
|
+
expect(out).toContain("Context: Engineering meeting")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("appends a terms list as biasing vocabulary", () => {
|
|
46
|
+
const out = GeminiTranscriber.buildPrompt({
|
|
47
|
+
audio: dummyAudio,
|
|
48
|
+
model: "gemini-2.5-flash",
|
|
49
|
+
prompt: { terms: ["Effect", "Lyria", "MusicGenerator"] },
|
|
50
|
+
})
|
|
51
|
+
expect(out).toContain("Effect, Lyria, MusicGenerator")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("omits the biasing block when the terms list is empty", () => {
|
|
55
|
+
const out = GeminiTranscriber.buildPrompt({
|
|
56
|
+
audio: dummyAudio,
|
|
57
|
+
model: "gemini-2.5-flash",
|
|
58
|
+
prompt: { terms: [] },
|
|
59
|
+
})
|
|
60
|
+
expect(out).not.toContain("terms / names")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("GeminiTranscriber capability guards (runtime)", () => {
|
|
65
|
+
it("fails Unsupported for wordTimestamps", async () => {
|
|
66
|
+
const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
|
|
67
|
+
s.transcribe({
|
|
68
|
+
audio: dummyAudio,
|
|
69
|
+
model: "gemini-2.5-flash",
|
|
70
|
+
wordTimestamps: true,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
|
|
74
|
+
expect(exit._tag).toBe("Failure")
|
|
75
|
+
if (exit._tag === "Failure") {
|
|
76
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
77
|
+
expect(JSON.stringify(exit.cause)).toContain("wordTimestamps")
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("fails Unsupported for diarization", async () => {
|
|
82
|
+
const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
|
|
83
|
+
s.transcribe({
|
|
84
|
+
audio: dummyAudio,
|
|
85
|
+
model: "gemini-2.5-flash",
|
|
86
|
+
diarization: true,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
|
|
90
|
+
expect(exit._tag).toBe("Failure")
|
|
91
|
+
if (exit._tag === "Failure") {
|
|
92
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
93
|
+
expect(JSON.stringify(exit.cause)).toContain("diarization")
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("streamTranscriptionFrom returns an Unsupported stream", async () => {
|
|
98
|
+
const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
|
|
99
|
+
Stream.runDrain(
|
|
100
|
+
s.streamTranscriptionFrom(Stream.fromIterable([new Uint8Array([0])]), {
|
|
101
|
+
model: "gemini-2.5-flash",
|
|
102
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
|
|
107
|
+
expect(exit._tag).toBe("Failure")
|
|
108
|
+
if (exit._tag === "Failure") {
|
|
109
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
110
|
+
expect(JSON.stringify(exit.cause)).toContain("streamTranscriptionFrom")
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("GeminiTranscriber Layer (compile-time)", () => {
|
|
116
|
+
it("leaves `SttStreaming` unsatisfied when using `streamTranscriptionFrom` against this Layer", () => {
|
|
117
|
+
const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
|
|
118
|
+
const events = audio.pipe(
|
|
119
|
+
Transcriber.streamTranscriptionFrom({
|
|
120
|
+
model: "gemini-2.5-flash",
|
|
121
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
const provided = Stream.runDrain(events).pipe(Effect.provide(live))
|
|
125
|
+
expectTypeOf(provided).toEqualTypeOf<
|
|
126
|
+
Effect.Effect<void, AiError.AiError, Transcriber.SttStreaming>
|
|
127
|
+
>()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("sync `transcribe` requires no marker", () => {
|
|
131
|
+
const t = Transcriber.transcribe({ audio: dummyAudio, model: "gemini-2.5-flash" }).pipe(
|
|
132
|
+
Effect.provide(live),
|
|
133
|
+
)
|
|
134
|
+
expectTypeOf(t).toEqualTypeOf<Effect.Effect<TranscriptResult, AiError.AiError, never>>()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Match, Redacted, Schema, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
+
import type { TranscriptResult } from "@effect-uai/core/Transcript"
|
|
5
|
+
import {
|
|
6
|
+
type CommonTranscribeRequest,
|
|
7
|
+
Transcriber,
|
|
8
|
+
type TranscriberService,
|
|
9
|
+
} from "@effect-uai/core/Transcriber"
|
|
10
|
+
import { audioSourceToInlineData, httpStatusError, transportFailure } from "./geminiSpeechCodec.js"
|
|
11
|
+
import type { GeminiSttModel } from "./models.js"
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Public types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gemini transcription rides on `:generateContent` — the model receives
|
|
19
|
+
* audio plus a textual "transcribe verbatim" prompt and returns plain
|
|
20
|
+
* text. There is no native structured timestamp / diarization channel,
|
|
21
|
+
* so `wordTimestamps` / `diarization` short-circuit with `Unsupported`.
|
|
22
|
+
*/
|
|
23
|
+
export type GeminiTranscribeRequest = Omit<CommonTranscribeRequest, "model"> & {
|
|
24
|
+
readonly model: GeminiSttModel
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type GeminiTranscriberService = {
|
|
28
|
+
readonly transcribe: (
|
|
29
|
+
r: GeminiTranscribeRequest,
|
|
30
|
+
) => Effect.Effect<TranscriptResult, AiError.AiError>
|
|
31
|
+
readonly streamTranscriptionFrom: TranscriberService["streamTranscriptionFrom"]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class GeminiTranscriber extends Context.Service<
|
|
35
|
+
GeminiTranscriber,
|
|
36
|
+
GeminiTranscriberService
|
|
37
|
+
>()("@betalyra/effect-uai/providers/google/GeminiTranscriber") {}
|
|
38
|
+
|
|
39
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Prompt builder + capability guards
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const unsupported = (capability: string, reason: string) =>
|
|
46
|
+
new AiError.Unsupported({ provider: "gemini", capability, reason })
|
|
47
|
+
|
|
48
|
+
const ensureSupported = Match.type<GeminiTranscribeRequest>().pipe(
|
|
49
|
+
Match.when({ wordTimestamps: true }, () =>
|
|
50
|
+
Effect.fail(
|
|
51
|
+
unsupported(
|
|
52
|
+
"wordTimestamps",
|
|
53
|
+
"Gemini's prompt-driven transcription returns plain text — no per-word timing. Use Cloud Speech-to-Text (Chirp 2) for word timestamps.",
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
Match.when({ diarization: true }, () =>
|
|
58
|
+
Effect.fail(
|
|
59
|
+
unsupported(
|
|
60
|
+
"diarization",
|
|
61
|
+
"Gemini's prompt-driven transcription does not surface speaker IDs. Use Cloud Speech-to-Text (Chirp 2) for diarization.",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
Match.orElse(() => Effect.void),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const biasingHint = (r: GeminiTranscribeRequest) => {
|
|
69
|
+
if (r.prompt === undefined) return ""
|
|
70
|
+
if (typeof r.prompt === "string") return `\nContext: ${r.prompt}`
|
|
71
|
+
return r.prompt.terms.length === 0
|
|
72
|
+
? ""
|
|
73
|
+
: `\nThe following terms / names appear in the audio: ${r.prompt.terms.join(", ")}.`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const buildPrompt = (r: GeminiTranscribeRequest) =>
|
|
77
|
+
(r.language !== undefined
|
|
78
|
+
? `Transcribe the audio verbatim in ${r.language}. Return only the transcript text, no preamble.`
|
|
79
|
+
: "Transcribe the audio verbatim. Return only the transcript text, no preamble.") +
|
|
80
|
+
biasingHint(r)
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Wire
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const Wire = Schema.Struct({
|
|
87
|
+
candidates: Schema.optional(
|
|
88
|
+
Schema.Array(
|
|
89
|
+
Schema.Struct({
|
|
90
|
+
content: Schema.optional(
|
|
91
|
+
Schema.Struct({
|
|
92
|
+
parts: Schema.optional(
|
|
93
|
+
Schema.Array(Schema.Struct({ text: Schema.optional(Schema.String) })),
|
|
94
|
+
),
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
const decodeWire = Schema.decodeUnknownEffect(Wire)
|
|
102
|
+
|
|
103
|
+
const collectText = (wire: typeof Wire.Type) =>
|
|
104
|
+
(wire.candidates ?? [])
|
|
105
|
+
.flatMap((c) => c.content?.parts ?? [])
|
|
106
|
+
.flatMap((p) => (p.text !== undefined ? [p.text] : []))
|
|
107
|
+
.join("\n")
|
|
108
|
+
.trim()
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Service implementation
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
|
|
115
|
+
|
|
116
|
+
const transcribeImpl = (cfg: Config) => (request: GeminiTranscribeRequest) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
yield* ensureSupported(request)
|
|
119
|
+
const client = yield* HttpClient.HttpClient
|
|
120
|
+
const inline = yield* audioSourceToInlineData(request.audio)
|
|
121
|
+
const httpRequest = HttpClientRequest.post(
|
|
122
|
+
`${baseUrl(cfg)}/models/${request.model}:generateContent`,
|
|
123
|
+
).pipe(
|
|
124
|
+
HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
|
|
125
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
126
|
+
contents: [{ parts: [{ text: buildPrompt(request) }, { inlineData: inline }] }],
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
130
|
+
if (response.status >= 400) {
|
|
131
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
132
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
133
|
+
}
|
|
134
|
+
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
135
|
+
const text = yield* decodeWire(json).pipe(
|
|
136
|
+
Effect.map(collectText),
|
|
137
|
+
Effect.mapError((cause) => new AiError.GenerationFailed({ provider: "gemini", raw: cause })),
|
|
138
|
+
Effect.flatMap((t) =>
|
|
139
|
+
t.length === 0
|
|
140
|
+
? Effect.fail(
|
|
141
|
+
new AiError.GenerationFailed({
|
|
142
|
+
provider: "gemini",
|
|
143
|
+
raw: {
|
|
144
|
+
message: "Gemini transcription response had no text part.",
|
|
145
|
+
hint: "Likely a prompt-safety rejection or the model returned only metadata.",
|
|
146
|
+
response: json,
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
: Effect.succeed(t),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
text,
|
|
155
|
+
...(request.language !== undefined && { languageCode: request.language }),
|
|
156
|
+
raw: json,
|
|
157
|
+
} satisfies TranscriptResult
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const unsupportedStreamFrom: TranscriberService["streamTranscriptionFrom"] = () =>
|
|
161
|
+
Stream.fail(
|
|
162
|
+
new AiError.Unsupported({
|
|
163
|
+
provider: "gemini",
|
|
164
|
+
capability: "streamTranscriptionFrom",
|
|
165
|
+
reason:
|
|
166
|
+
"Gemini transcription via generateContent is sync-only. Use Cloud Speech-to-Text (StreamingRecognize) for live transcription.",
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Constructors
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export const make = (cfg: Config) =>
|
|
175
|
+
Effect.map(
|
|
176
|
+
HttpClient.HttpClient.asEffect(),
|
|
177
|
+
(client): GeminiTranscriberService => ({
|
|
178
|
+
transcribe: (r) =>
|
|
179
|
+
transcribeImpl(cfg)(r).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
180
|
+
streamTranscriptionFrom: unsupportedStreamFrom,
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Layer registers both `GeminiTranscriber` and the generic
|
|
186
|
+
* `Transcriber`. Does NOT register `SttStreaming` — Gemini's transcription
|
|
187
|
+
* is sync-only, so `streamTranscriptionFrom` against this Layer alone
|
|
188
|
+
* is a compile-time error.
|
|
189
|
+
*/
|
|
190
|
+
export const layer = (cfg: Config) =>
|
|
191
|
+
Layer.merge(
|
|
192
|
+
Layer.effect(GeminiTranscriber, make(cfg)),
|
|
193
|
+
Layer.effect(
|
|
194
|
+
Transcriber,
|
|
195
|
+
Effect.map(
|
|
196
|
+
make(cfg),
|
|
197
|
+
(s): TranscriberService => ({
|
|
198
|
+
transcribe: (req: CommonTranscribeRequest) =>
|
|
199
|
+
s.transcribe(req as GeminiTranscribeRequest),
|
|
200
|
+
streamTranscriptionFrom: s.streamTranscriptionFrom,
|
|
201
|
+
}),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import * as MusicGenerator from "@effect-uai/core/MusicGenerator"
|
|
6
|
+
import { promptsInput } from "@effect-uai/core/Music"
|
|
7
|
+
import * as LyriaGenerator from "./LyriaGenerator.js"
|
|
8
|
+
|
|
9
|
+
const cfg: LyriaGenerator.Config = { apiKey: Redacted.make("test-key") }
|
|
10
|
+
// FetchHttpClient is required for `make`, but these tests only exercise the
|
|
11
|
+
// compile-time gating and runtime Unsupported branches — no real HTTP is made.
|
|
12
|
+
const live = Layer.provide(LyriaGenerator.layer(cfg), FetchHttpClient.layer)
|
|
13
|
+
|
|
14
|
+
describe("buildPrompt", () => {
|
|
15
|
+
it("returns a plain string when prompts is a string", () => {
|
|
16
|
+
const out = LyriaGenerator.buildPrompt({
|
|
17
|
+
model: "lyria-3-clip-preview",
|
|
18
|
+
prompts: "upbeat indie pop",
|
|
19
|
+
})
|
|
20
|
+
expect(out).toBe("upbeat indie pop")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("joins weighted prompts with weight annotations", () => {
|
|
24
|
+
const out = LyriaGenerator.buildPrompt({
|
|
25
|
+
model: "lyria-3-clip-preview",
|
|
26
|
+
prompts: [
|
|
27
|
+
{ text: "minimal techno", weight: 1.0 },
|
|
28
|
+
{ text: "1980s synthwave", weight: 0.3 },
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
expect(out).toContain("minimal techno")
|
|
32
|
+
expect(out).toContain("1980s synthwave (weight 0.3)")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("omits the weight annotation for weight === 1", () => {
|
|
36
|
+
const out = LyriaGenerator.buildPrompt({
|
|
37
|
+
model: "lyria-3-clip-preview",
|
|
38
|
+
prompts: [{ text: "ambient", weight: 1 }],
|
|
39
|
+
})
|
|
40
|
+
expect(out).toBe("ambient")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("splices in bpm, scale, duration, and instrumental hints", () => {
|
|
44
|
+
const out = LyriaGenerator.buildPrompt({
|
|
45
|
+
model: "lyria-3-clip-preview",
|
|
46
|
+
prompts: "house",
|
|
47
|
+
bpm: 124,
|
|
48
|
+
scale: "C_MAJOR",
|
|
49
|
+
durationSeconds: 30,
|
|
50
|
+
instrumental: true,
|
|
51
|
+
})
|
|
52
|
+
expect(out).toContain("Instrumental only")
|
|
53
|
+
expect(out).toContain("BPM: 124")
|
|
54
|
+
expect(out).toContain("Key/scale: C_MAJOR")
|
|
55
|
+
expect(out).toContain("Target duration: 30s")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("appends a lyrics block when lyrics is set and instrumental is not true", () => {
|
|
59
|
+
const out = LyriaGenerator.buildPrompt({
|
|
60
|
+
model: "lyria-3-clip-preview",
|
|
61
|
+
prompts: "indie rock",
|
|
62
|
+
lyrics: "[Verse]\nhello world",
|
|
63
|
+
})
|
|
64
|
+
expect(out).toContain("Lyrics:")
|
|
65
|
+
expect(out).toContain("[Verse]")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("drops the lyrics block when instrumental is true", () => {
|
|
69
|
+
const out = LyriaGenerator.buildPrompt({
|
|
70
|
+
model: "lyria-3-clip-preview",
|
|
71
|
+
prompts: "indie rock",
|
|
72
|
+
lyrics: "[Verse]\nhello world",
|
|
73
|
+
instrumental: true,
|
|
74
|
+
})
|
|
75
|
+
expect(out).not.toContain("Lyrics:")
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("containerToMimeType", () => {
|
|
80
|
+
it("maps mp3 and wav", async () => {
|
|
81
|
+
expect(await Effect.runPromise(LyriaGenerator.containerToMimeType("mp3"))).toBe("audio/mp3")
|
|
82
|
+
expect(await Effect.runPromise(LyriaGenerator.containerToMimeType("wav"))).toBe("audio/wav")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("fails Unsupported for ogg / opus / flac / aac / webm / raw", async () => {
|
|
86
|
+
const exit = await Effect.runPromiseExit(LyriaGenerator.containerToMimeType("ogg"))
|
|
87
|
+
expect(exit._tag).toBe("Failure")
|
|
88
|
+
if (exit._tag === "Failure") {
|
|
89
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
90
|
+
expect(JSON.stringify(exit.cause)).toContain("outputFormat")
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("realizedFormat", () => {
|
|
96
|
+
it("reports 44.1 kHz stereo for both mp3 and wav", () => {
|
|
97
|
+
expect(LyriaGenerator.realizedFormat("audio/mp3")).toEqual({
|
|
98
|
+
container: "mp3",
|
|
99
|
+
encoding: "mp3",
|
|
100
|
+
sampleRate: 44100,
|
|
101
|
+
channels: 2,
|
|
102
|
+
})
|
|
103
|
+
expect(LyriaGenerator.realizedFormat("audio/wav")).toEqual({
|
|
104
|
+
container: "wav",
|
|
105
|
+
encoding: "pcm_s16le",
|
|
106
|
+
sampleRate: 44100,
|
|
107
|
+
channels: 2,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("LyriaGenerator capability guards (runtime)", () => {
|
|
113
|
+
it("streamGenerationFrom returns an Unsupported stream", async () => {
|
|
114
|
+
const program = LyriaGenerator.LyriaGenerator.use((s) =>
|
|
115
|
+
Stream.runDrain(
|
|
116
|
+
s.streamGenerationFrom(Stream.fromIterable([promptsInput([{ text: "x" }])]), {
|
|
117
|
+
model: "lyria-3-clip-preview",
|
|
118
|
+
prompts: "",
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
|
|
123
|
+
expect(exit._tag).toBe("Failure")
|
|
124
|
+
if (exit._tag === "Failure") {
|
|
125
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
126
|
+
expect(JSON.stringify(exit.cause)).toContain("streamGenerationFrom")
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("LyriaGenerator Layer (compile-time)", () => {
|
|
132
|
+
it("leaves `MusicInteractiveSession` unsatisfied when using `streamGenerationFrom` against this Layer", () => {
|
|
133
|
+
const inputs = Stream.fromIterable([promptsInput([{ text: "techno" }])])
|
|
134
|
+
const audio = inputs.pipe(
|
|
135
|
+
MusicGenerator.streamGenerationFrom({
|
|
136
|
+
model: "lyria-3-clip-preview",
|
|
137
|
+
prompts: "",
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
const provided = Stream.runDrain(audio).pipe(Effect.provide(live))
|
|
141
|
+
expectTypeOf(provided).toEqualTypeOf<
|
|
142
|
+
Effect.Effect<void, AiError.AiError, MusicGenerator.MusicInteractiveSession>
|
|
143
|
+
>()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("sync `generate` and chunked `streamGeneration` require no marker", () => {
|
|
147
|
+
const gen = MusicGenerator.generate({
|
|
148
|
+
model: "lyria-3-clip-preview",
|
|
149
|
+
prompts: "ambient",
|
|
150
|
+
}).pipe(Effect.provide(live))
|
|
151
|
+
expectTypeOf(gen).toEqualTypeOf<
|
|
152
|
+
Effect.Effect<import("@effect-uai/core/Music").MusicResult, AiError.AiError, never>
|
|
153
|
+
>()
|
|
154
|
+
const _stream = Stream.runCollect(
|
|
155
|
+
MusicGenerator.streamGeneration({
|
|
156
|
+
model: "lyria-3-clip-preview",
|
|
157
|
+
prompts: "ambient",
|
|
158
|
+
}),
|
|
159
|
+
).pipe(Effect.provide(live))
|
|
160
|
+
expect(_stream).toBeDefined()
|
|
161
|
+
})
|
|
162
|
+
})
|