From b99caa95ab9f9343de3c44a6eecb9f885a4f4c57 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:35:23 +0000 Subject: [PATCH 1/2] Add POST /redrob/speech so a client can play speech without holding the key Redrob Cowork's read-aloud has to play audio in the app, and the Redrob key that pays for it belongs to this engine. The route speaks text through the gateway on the engine's own credential and answers with the mp3, so the key never leaves this process. It shares one path with speech_generate: prepareSpeech validates and picks the model and voice, speechKey finds the credential, and requestSpeech makes the one gateway call and checks the answer is audio. Each fails with a typed SpeechError, which the route maps to its declared errors: invalid text is a 400, no credential a 503 (the engine lacks it, not the caller), a gateway refusal or non-audio answer a 502 carrying the gateway's message, and a timeout a 504. The first two are found before anything is sent. The tool's behaviour is unchanged. --- .../routes/instance/httpapi/groups/control.ts | 25 ++- .../instance/httpapi/handlers/control.ts | 32 ++- packages/redrob/src/tool/speech_generate.ts | 186 ++++++++++++------ .../redrob/test/server/httpapi-speech.test.ts | 169 ++++++++++++++++ 4 files changed, 351 insertions(+), 61 deletions(-) create mode 100644 packages/redrob/test/server/httpapi-speech.test.ts diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts index 11c5315093..aa9e52a0ea 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts @@ -1,7 +1,8 @@ import { Auth } from "@/auth" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError, TimeoutError, UpstreamError } from "../errors" import { described } from "./metadata" import { ProviderV2 } from "@redrob-code/core/provider" @@ -28,9 +29,16 @@ export const LogInput = Schema.Struct({ }), }) +export const SpeechInput = Schema.Struct({ + text: Schema.String.annotate({ description: "The words to speak, at most 4096 characters" }), + voice: Schema.optional(Schema.String).annotate({ description: "A voice of the chosen model" }), + model: Schema.optional(Schema.String).annotate({ description: "A speech model id served by the Redrob gateway" }), +}) + export const ControlPaths = { auth: "/auth/:providerID", log: "/log", + speech: "/redrob/speech", } as const export const ControlApi = HttpApi.make("control").add( @@ -59,6 +67,21 @@ export const ControlApi = HttpApi.make("control").add( description: "Remove authentication credentials", }), ), + /* + * Speech on the engine's own Redrob credential, for a client that has to play audio and must + * not hold the key: Redrob Cowork's read-aloud. The key never leaves this process. + */ + HttpApiEndpoint.post("speech", ControlPaths.speech, { + payload: SpeechInput, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType: "audio/mpeg" })), + error: [InvalidRequestError, ServiceUnavailableError, UpstreamError, TimeoutError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "redrob.speech", + summary: "Speak text", + description: "Turn text into mp3 speech through the Redrob gateway, billed to the connected Redrob account.", + }), + ), HttpApiEndpoint.post("log", ControlPaths.log, { query: LogQuery, payload: LogInput, diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts index e120749f18..af0a6c7cd1 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts @@ -3,12 +3,36 @@ import { Auth } from "@/auth" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { RootHttpApi } from "../api" -import { LogInput } from "../groups/control" +import { LogInput, SpeechInput } from "../groups/control" +import { InvalidRequestError, ServiceUnavailableError, TimeoutError, UpstreamError } from "../errors" +import { prepareSpeech, requestSpeech, speechKey, type SpeechError } from "@/tool/speech_generate" +import { HttpClient } from "effect/unstable/http" import { ProviderV2 } from "@redrob-code/core/provider" export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => Effect.gen(function* () { const auth = yield* Auth.Service + const http = yield* HttpClient.HttpClient + + /** Each failure as the endpoint declares it; no_key is 503 because the engine, not the caller, lacks it. */ + const speechFailure = (error: SpeechError) => + error.reason === "invalid" + ? new InvalidRequestError({ message: error.message }) + : error.reason === "no_key" + ? new ServiceUnavailableError({ message: error.message, service: "redrob" }) + : error.reason === "timeout" + ? new TimeoutError({ message: error.message, operation: "speech" }) + : new UpstreamError({ + message: error.message, + service: "redrob", + ...(error.status ? { status: error.status } : {}), + }) + + const speech = Effect.fn("ControlHttpApi.speech")(function* (ctx: { payload: typeof SpeechInput.Type }) { + const prepared = yield* prepareSpeech(ctx.payload) + const key = yield* speechKey(auth) + return (yield* requestSpeech(http, key, prepared)).audio + }, Effect.mapError(speechFailure)) const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { params: { providerID: ProviderV2.ID } @@ -38,6 +62,10 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han return true }) - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + return handlers + .handle("authSet", authSet) + .handle("authRemove", authRemove) + .handle("speech", speech) + .handle("log", log) }), ) diff --git a/packages/redrob/src/tool/speech_generate.ts b/packages/redrob/src/tool/speech_generate.ts index c5e70fd7cf..036eb62cb9 100644 --- a/packages/redrob/src/tool/speech_generate.ts +++ b/packages/redrob/src/tool/speech_generate.ts @@ -16,9 +16,14 @@ import { gatewayMessage, gatewayUrl, redrobKey, slug } from "./image_generate" * Speech through the Redrob gateway's `POST /v1/audio/speech`, on the engine's own Redrob credential, * for the reason image_generate gives: the key is the engine's and stays here. * - * The audio is saved and NOT returned as an attachment. An attachment goes back to the model with the - * next turn, and audio input would steer `auto` to an audio-input model and bill the clip as input on - * every turn after, to tell the model nothing it does not already know: it wrote the words. + * Two callers share `prepareSpeech` and `requestSpeech`: the `speech_generate` tool, which saves an + * mp3, and the engine's `POST /redrob/speech` route, which hands the audio to Redrob Cowork to play + * a reply aloud without the app ever holding the key. + * + * The tool saves its audio and does NOT return it as an attachment. An attachment goes back to the + * model with the next turn, and audio input would steer `auto` to an audio-input model and bill the + * clip as input on every turn after, to tell the model nothing it does not already know: it wrote + * the words. */ /** Multilingual, so a Korean script is spoken as Korean. Any speech model the gateway serves can be named. */ @@ -31,6 +36,111 @@ export const MAX_SPEECH_CHARACTERS = 4_096 const TIMEOUT_MS = 180_000 const MAX_AUDIO_BYTES = 50 * 1024 * 1024 +/** + * Why speech could not be made. `invalid` and `no_key` are found before anything is sent and cost + * nothing; `upstream` and `timeout` are the gateway's. + */ +export class SpeechError extends Schema.TaggedErrorClass()("SpeechError", { + reason: Schema.Literals(["invalid", "no_key", "upstream", "timeout"]), + message: Schema.String, + status: Schema.optional(Schema.Number), +}) {} + +export type PreparedSpeech = { text: string; model: string; voice?: string; characters: number } + +/** Characters as the gateway bills them: code points, so the limit here agrees with its limit there. */ +function characters(text: string): number { + let count = 0 + for (const _ of text) count += 1 + return count +} + +/** The request as it will be sent, or why it would be refused, with nothing sent either way. */ +export const prepareSpeech = Effect.fn("Speech.prepare")(function* (input: { + text: string + model?: string + voice?: string +}) { + const text = input.text.trim() + if (!text) return yield* new SpeechError({ reason: "invalid", message: "Give the words to speak." }) + const length = characters(text) + if (length > MAX_SPEECH_CHARACTERS) { + return yield* new SpeechError({ + reason: "invalid", + message: `The text is ${length} characters; speak at most ${MAX_SPEECH_CHARACTERS} per call and save longer material in parts.`, + }) + } + const model = input.model?.trim() || DEFAULT_SPEECH_MODEL + /* Voices belong to a model, so the default voice only goes with the default model. */ + const voice = input.voice?.trim() || (model === DEFAULT_SPEECH_MODEL ? DEFAULT_SPEECH_VOICE : undefined) + const prepared: PreparedSpeech = { text, model, ...(voice ? { voice } : {}), characters: length } + return prepared +}) + +/** The engine's Redrob key, or a `no_key` failure, which is found before anything is sent. */ +export const speechKey = Effect.fn("Speech.key")(function* (auth: Auth.Interface) { + const key = redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined))) + if (!key) { + return yield* new SpeechError({ + reason: "no_key", + message: "Connect Redrob to generate speech: no Redrob API key is available.", + }) + } + return key +}) + +/** One call to the gateway. The audio is checked for being audio before anyone keeps or plays it. */ +export const requestSpeech = Effect.fn("Speech.request")(function* ( + http: HttpClient.HttpClient, + key: string, + speech: PreparedSpeech, +) { + const request = yield* HttpClientRequest.post(`${gatewayUrl()}/audio/speech`).pipe( + HttpClientRequest.bearerToken(key), + HttpClientRequest.bodyJson({ + model: speech.model, + input: speech.text, + response_format: "mp3", + ...(speech.voice ? { voice: speech.voice } : {}), + }), + Effect.orDie, + ) + const response = yield* http.execute(request).pipe( + Effect.mapError(() => new SpeechError({ reason: "upstream", message: "The Redrob gateway could not be reached." })), + Effect.timeoutOrElse({ + duration: TIMEOUT_MS, + orElse: () => Effect.fail(new SpeechError({ reason: "timeout", message: "Speech generation timed out." })), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* new SpeechError({ + reason: "upstream", + status: response.status, + message: `The Redrob gateway answered ${response.status}: ${gatewayMessage(body)}`, + }) + } + const contentType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() ?? "" + if (!contentType.startsWith("audio/")) { + return yield* new SpeechError({ + reason: "upstream", + message: `The Redrob gateway answered with ${contentType || "no content type"}, not audio.`, + }) + } + const buffer = yield* response.arrayBuffer.pipe( + Effect.mapError(() => new SpeechError({ reason: "upstream", message: "The Redrob gateway's audio was cut off." })), + ) + const audio = new Uint8Array(buffer) + if (audio.byteLength === 0) { + return yield* new SpeechError({ reason: "upstream", message: "The Redrob gateway returned no audio." }) + } + if (audio.byteLength > MAX_AUDIO_BYTES) { + return yield* new SpeechError({ reason: "upstream", message: "The audio is over 50 MB." }) + } + const cost = Number(response.headers["x-redrob-cost-usd"]) + return { audio, contentType, ...(Number.isFinite(cost) ? { costUsd: cost } : {}) } +}) + export const Parameters = Schema.Struct({ text: Schema.String.annotate({ description: `The words to speak, at most ${MAX_SPEECH_CHARACTERS} characters` }), voice: Schema.optional(Schema.String).annotate({ @@ -44,13 +154,6 @@ export const Parameters = Schema.Struct({ }), }) -/** Characters as the gateway bills them: code points, so the limit here agrees with its limit there. */ -function characters(text: string): number { - let count = 0 - for (const _ of text) count += 1 - return count -} - export const SpeechGenerateTool = Tool.define( "speech_generate", Effect.gen(function* () { @@ -64,22 +167,12 @@ export const SpeechGenerateTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const text = params.text.trim() - if (!text) throw new Error("Give the words to speak.") - const length = characters(text) - if (length > MAX_SPEECH_CHARACTERS) { - throw new Error( - `The text is ${length} characters; speak at most ${MAX_SPEECH_CHARACTERS} per call and save longer material in parts.`, - ) - } - const key = redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined))) - if (!key) throw new Error("Connect Redrob to generate speech: no Redrob API key is available.") - const model = params.model?.trim() || DEFAULT_SPEECH_MODEL - const voice = params.voice?.trim() || (model === DEFAULT_SPEECH_MODEL ? DEFAULT_SPEECH_VOICE : undefined) + const speech = yield* prepareSpeech(params) + const key = yield* speechKey(auth) const instance = yield* InstanceState.context const directory = path.join(instance.directory, "artifacts") - const stem = slug(params.filename?.trim() || text) + const stem = slug(params.filename?.trim() || speech.text) let filepath = path.join(directory, `${stem}.mp3`) for (let n = 2; yield* fs.existsSafe(filepath); n += 1) { filepath = path.join(directory, `${stem}-${n}.mp3`) @@ -89,53 +182,30 @@ export const SpeechGenerateTool = Tool.define( permission: "edit", patterns: [path.relative(instance.worktree, filepath)], always: ["*"], - metadata: { filepath, model, voice }, + metadata: { filepath, model: speech.model, voice: speech.voice }, }) - const request = yield* HttpClientRequest.post(`${gatewayUrl()}/audio/speech`).pipe( - HttpClientRequest.bearerToken(key), - HttpClientRequest.bodyJson({ - model, - input: text, - response_format: "mp3", - ...(voice ? { voice } : {}), - }), - ) - const response = yield* http.execute(request).pipe( - Effect.timeoutOrElse({ - duration: TIMEOUT_MS, - orElse: () => Effect.die(new Error("Speech generation timed out")), - }), - ) - if (response.status < 200 || response.status >= 300) { - throw new Error(`The Redrob gateway answered ${response.status}: ${gatewayMessage(yield* response.text)}`) - } - const contentType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() ?? "" - if (!contentType.startsWith("audio/")) { - throw new Error(`The Redrob gateway answered with ${contentType || "no content type"}, not audio.`) - } - const audio = new Uint8Array(yield* response.arrayBuffer) - if (audio.byteLength === 0) throw new Error("The Redrob gateway returned no audio.") - if (audio.byteLength > MAX_AUDIO_BYTES) throw new Error("The audio is over 50 MB.") - - yield* fs.writeWithDirs(filepath, audio) + const spoken = yield* requestSpeech(http, key, speech) + yield* fs.writeWithDirs(filepath, spoken.audio) yield* events.publish(FileSystem.Event.Edited, { file: filepath }) yield* events.publish(Watcher.Event.Updated, { file: filepath, event: "add" }) const relative = path.relative(instance.worktree, filepath) - const cost = Number(response.headers["x-redrob-cost-usd"]) return { title: relative, - output: `Saved ${relative}: ${length} characters spoken with ${model}${voice ? ` (${voice})` : ""}.`, + output: `Saved ${relative}: ${speech.characters} characters spoken with ${speech.model}${speech.voice ? ` (${speech.voice})` : ""}.`, metadata: { filepath, - model, - ...(voice ? { voice } : {}), - characters: length, - ...(Number.isFinite(cost) ? { costUsd: cost } : {}), + model: speech.model, + ...(speech.voice ? { voice: speech.voice } : {}), + characters: speech.characters, + ...(spoken.costUsd === undefined ? {} : { costUsd: spoken.costUsd }), }, } - }).pipe(Effect.orDie), + }).pipe( + Effect.catchTag("SpeechError", (error) => Effect.die(new Error(error.message))), + Effect.orDie, + ), } }), ) diff --git a/packages/redrob/test/server/httpapi-speech.test.ts b/packages/redrob/test/server/httpapi-speech.test.ts new file mode 100644 index 0000000000..5f9b07e778 --- /dev/null +++ b/packages/redrob/test/server/httpapi-speech.test.ts @@ -0,0 +1,169 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Config, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import * as Http from "node:http" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { DEFAULT_SPEECH_MODEL, DEFAULT_SPEECH_VOICE } from "../../src/tool/speech_generate" +import { testEffect } from "../lib/effect" + +/** + * `POST /redrob/speech` through the production route tree, with a stand-in for the Redrob gateway + * behind it. What matters is that the app gets audio while the engine's key only ever goes to the + * gateway, and that every refusal arrives as the endpoint's declared error. + */ + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) +const it = testEffect( + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), +) + +const MP3 = new Uint8Array([0xff, 0xf3, 0x44, 0xc4, 0x01, 0x02]) + +type Seen = { authorization: string | undefined; body: unknown } +type Answer = { status: number; type: string; body: Uint8Array | string } + +/** The engine pointed at the stand-in and holding a Redrob key, restored when the test scope closes. */ +const engineEnv = (gatewayUrl: string) => + Effect.acquireRelease( + Effect.sync(() => { + const saved = { + url: process.env.REDROB_CONSOLE_URL, + auth: process.env.REDROB_AUTH_CONTENT, + key: process.env.REDROB_API_KEY, + } + process.env.REDROB_CONSOLE_URL = gatewayUrl + process.env.REDROB_AUTH_CONTENT = JSON.stringify({ redrob: { type: "api", key: "rk_engine_key" } }) + delete process.env.REDROB_API_KEY + return saved + }), + (saved) => + Effect.sync(() => { + for (const [name, value] of [ + ["REDROB_CONSOLE_URL", saved.url], + ["REDROB_AUTH_CONTENT", saved.auth], + ["REDROB_API_KEY", saved.key], + ] as const) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }), + ) + +/** + * Topology: the engine's routes run on the test's primary server (NodeHttpServer.layerTest), and the + * Redrob gateway stand-in is a second listener built into this test's scope with its own fresh router, + * so its `/audio/speech` cannot collide with anything the engine registers. The engine reaches the + * stand-in through `REDROB_CONSOLE_URL`, the developer seam the gateway URL already honours. + */ +const gateway = Effect.fn("SpeechTest.gateway")(function* (answer: (body: Record) => Answer) { + const seen: Seen[] = [] + const context = yield* Layer.build( + Layer.mergeAll( + Layer.fresh(HttpRouter.layer), + NodeHttpServer.layer(() => Http.createServer(), { host: "127.0.0.1", port: 0 }), + ), + ) + const server = Context.get(context, HttpServer.HttpServer) + const router = Context.get(context, HttpRouter.HttpRouter) + yield* router.add( + "POST", + "/audio/speech", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const raw = yield* request.json.pipe(Effect.orElseSucceed(() => ({}))) + const body = typeof raw === "object" && raw !== null ? Object.fromEntries(Object.entries(raw)) : {} + seen.push({ authorization: request.headers.authorization, body }) + const reply = answer(body) + return typeof reply.body === "string" + ? HttpServerResponse.text(reply.body, { status: reply.status, contentType: reply.type }) + : HttpServerResponse.uint8Array(reply.body, { status: reply.status, contentType: reply.type }) + }), + ) + yield* server.serve(router.asHttpEffect()) + if (server.address._tag !== "TcpAddress") throw new Error("expected a TCP listener") + yield* engineEnv(`http://127.0.0.1:${server.address.port}`) + return { seen } +}) + +const speak = (payload: Record) => + HttpClientRequest.post(ControlPaths.speech).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(HttpClient.execute), + ) + +describe("redrob speech HttpApi", () => { + it.live("answers with the gateway's mp3, sending the engine's key only to the gateway", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + const response = yield* speak({ text: "안녕하세요" }) + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("audio/mpeg") + expect(new Uint8Array(yield* response.arrayBuffer)).toEqual(MP3) + expect(seen).toEqual([ + { + authorization: "Bearer rk_engine_key", + body: { + model: DEFAULT_SPEECH_MODEL, + input: "안녕하세요", + response_format: "mp3", + voice: DEFAULT_SPEECH_VOICE, + }, + }, + ]) + }), + ) + + it.live("refuses empty or over-long text with a 400, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + expect((yield* speak({ text: " " })).status).toBe(400) + expect((yield* speak({ text: "가".repeat(4097) })).status).toBe(400) + expect(seen).toHaveLength(0) + }), + ) + + it.live("without a Redrob key it is a 503, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + process.env.REDROB_AUTH_CONTENT = JSON.stringify({}) + const response = yield* speak({ text: "Hello" }) + expect(response.status).toBe(503) + expect(JSON.stringify(yield* response.json)).toContain("Connect Redrob") + expect(seen).toHaveLength(0) + }), + ) + + it.live("a gateway refusal, or an answer that is not audio, is a 502 with the gateway's message", () => + Effect.gen(function* () { + yield* gateway((body) => + body.input === "json" + ? { status: 200, type: "application/json", body: "{}" } + : { status: 402, type: "application/json", body: JSON.stringify({ error: { message: "Out of credit." } }) }, + ) + const refused = yield* speak({ text: "Hello" }) + expect(refused.status).toBe(502) + expect(JSON.stringify(yield* refused.json)).toContain("answered 402: Out of credit.") + const json = yield* speak({ text: "json" }) + expect(json.status).toBe(502) + expect(JSON.stringify(yield* json.json)).toContain("not audio") + }), + ) +}) From 38c453ba4a73e576294b205294857a228b02c43b Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:43:30 +0000 Subject: [PATCH 2/2] Speak played audio with Eleven Flash v2.5, the fast model, by default POST /redrob/speech exists for audio a client plays at once: a reply read aloud and the voice conversation's answer, where the wait before the first word is what the user feels. It now defaults to eleven-flash-v2.5 with the same voice, sarah. speech_generate keeps eleven-multilingual-v2 for saved files, and a caller can still name any model. --- .../routes/instance/httpapi/groups/control.ts | 4 +++- .../instance/httpapi/handlers/control.ts | 5 ++-- packages/redrob/src/tool/speech_generate.ts | 24 +++++++++++++------ .../redrob/test/server/httpapi-speech.test.ts | 19 +++++++++++++-- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts index aa9e52a0ea..cbff24f227 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts @@ -32,7 +32,9 @@ export const LogInput = Schema.Struct({ export const SpeechInput = Schema.Struct({ text: Schema.String.annotate({ description: "The words to speak, at most 4096 characters" }), voice: Schema.optional(Schema.String).annotate({ description: "A voice of the chosen model" }), - model: Schema.optional(Schema.String).annotate({ description: "A speech model id served by the Redrob gateway" }), + model: Schema.optional(Schema.String).annotate({ + description: "A speech model id served by the Redrob gateway. Defaults to elevenlabs/eleven-flash-v2.5, for playback.", + }), }) export const ControlPaths = { diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts index af0a6c7cd1..9b53910d7f 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts @@ -5,7 +5,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { RootHttpApi } from "../api" import { LogInput, SpeechInput } from "../groups/control" import { InvalidRequestError, ServiceUnavailableError, TimeoutError, UpstreamError } from "../errors" -import { prepareSpeech, requestSpeech, speechKey, type SpeechError } from "@/tool/speech_generate" +import { PLAYBACK_DEFAULTS, prepareSpeech, requestSpeech, speechKey, type SpeechError } from "@/tool/speech_generate" import { HttpClient } from "effect/unstable/http" import { ProviderV2 } from "@redrob-code/core/provider" @@ -29,7 +29,8 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han }) const speech = Effect.fn("ControlHttpApi.speech")(function* (ctx: { payload: typeof SpeechInput.Type }) { - const prepared = yield* prepareSpeech(ctx.payload) + // This route is for audio that is played at once, so it defaults to the fast playback model. + const prepared = yield* prepareSpeech(ctx.payload, PLAYBACK_DEFAULTS) const key = yield* speechKey(auth) return (yield* requestSpeech(http, key, prepared)).audio }, Effect.mapError(speechFailure)) diff --git a/packages/redrob/src/tool/speech_generate.ts b/packages/redrob/src/tool/speech_generate.ts index 036eb62cb9..e358522802 100644 --- a/packages/redrob/src/tool/speech_generate.ts +++ b/packages/redrob/src/tool/speech_generate.ts @@ -30,6 +30,17 @@ import { gatewayMessage, gatewayUrl, redrobKey, slug } from "./image_generate" export const DEFAULT_SPEECH_MODEL = "elevenlabs/eleven-multilingual-v2" export const DEFAULT_SPEECH_VOICE = "sarah" +/** + * For speech that is played as it arrives, a reply read aloud or a voice conversation's answer, where + * the wait before the first word matters more than the last bit of quality. Same voice, so a saved file + * and a reply read aloud sound like the same speaker. Multilingual, so Korean is spoken as Korean. + */ +export const PLAYBACK_SPEECH_MODEL = "elevenlabs/eleven-flash-v2.5" + +export type SpeechDefaults = { model: string; voice: string } +const FILE_DEFAULTS: SpeechDefaults = { model: DEFAULT_SPEECH_MODEL, voice: DEFAULT_SPEECH_VOICE } +export const PLAYBACK_DEFAULTS: SpeechDefaults = { model: PLAYBACK_SPEECH_MODEL, voice: DEFAULT_SPEECH_VOICE } + /** The gateway's limit for one request, checked here so an over-long request costs nothing. */ export const MAX_SPEECH_CHARACTERS = 4_096 @@ -56,11 +67,10 @@ function characters(text: string): number { } /** The request as it will be sent, or why it would be refused, with nothing sent either way. */ -export const prepareSpeech = Effect.fn("Speech.prepare")(function* (input: { - text: string - model?: string - voice?: string -}) { +export const prepareSpeech = Effect.fn("Speech.prepare")(function* ( + input: { text: string; model?: string; voice?: string }, + defaults: SpeechDefaults = FILE_DEFAULTS, +) { const text = input.text.trim() if (!text) return yield* new SpeechError({ reason: "invalid", message: "Give the words to speak." }) const length = characters(text) @@ -70,9 +80,9 @@ export const prepareSpeech = Effect.fn("Speech.prepare")(function* (input: { message: `The text is ${length} characters; speak at most ${MAX_SPEECH_CHARACTERS} per call and save longer material in parts.`, }) } - const model = input.model?.trim() || DEFAULT_SPEECH_MODEL + const model = input.model?.trim() || defaults.model /* Voices belong to a model, so the default voice only goes with the default model. */ - const voice = input.voice?.trim() || (model === DEFAULT_SPEECH_MODEL ? DEFAULT_SPEECH_VOICE : undefined) + const voice = input.voice?.trim() || (model === defaults.model ? defaults.voice : undefined) const prepared: PreparedSpeech = { text, model, ...(voice ? { voice } : {}), characters: length } return prepared }) diff --git a/packages/redrob/test/server/httpapi-speech.test.ts b/packages/redrob/test/server/httpapi-speech.test.ts index 5f9b07e778..de7aa1fb2f 100644 --- a/packages/redrob/test/server/httpapi-speech.test.ts +++ b/packages/redrob/test/server/httpapi-speech.test.ts @@ -13,7 +13,7 @@ import * as Socket from "effect/unstable/socket/Socket" import * as Http from "node:http" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" -import { DEFAULT_SPEECH_MODEL, DEFAULT_SPEECH_VOICE } from "../../src/tool/speech_generate" +import { DEFAULT_SPEECH_MODEL, DEFAULT_SPEECH_VOICE, PLAYBACK_SPEECH_MODEL } from "../../src/tool/speech_generate" import { testEffect } from "../lib/effect" /** @@ -121,7 +121,7 @@ describe("redrob speech HttpApi", () => { { authorization: "Bearer rk_engine_key", body: { - model: DEFAULT_SPEECH_MODEL, + model: PLAYBACK_SPEECH_MODEL, input: "안녕하세요", response_format: "mp3", voice: DEFAULT_SPEECH_VOICE, @@ -131,6 +131,21 @@ describe("redrob speech HttpApi", () => { }), ) + it.live("plays with the fast model by default, and a chosen model keeps the voice it was given", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + expect((yield* speak({ text: "One" })).status).toBe(200) + expect((yield* speak({ text: "Two", model: DEFAULT_SPEECH_MODEL })).status).toBe(200) + expect((yield* speak({ text: "Three", model: DEFAULT_SPEECH_MODEL, voice: "george" })).status).toBe(200) + expect(seen.map((call) => call.body)).toEqual([ + { model: PLAYBACK_SPEECH_MODEL, input: "One", response_format: "mp3", voice: DEFAULT_SPEECH_VOICE }, + // Another model's voices are its own, so none is assumed for it. + { model: DEFAULT_SPEECH_MODEL, input: "Two", response_format: "mp3" }, + { model: DEFAULT_SPEECH_MODEL, input: "Three", response_format: "mp3", voice: "george" }, + ]) + }), + ) + it.live("refuses empty or over-long text with a 400, sending nothing", () => Effect.gen(function* () { const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 }))