@shogo-ai/voice 1.9.13 → 2.4.1
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/README.md +89 -0
- package/dist/{chunk-S3S2RNO6.js → chunk-BI6SL35K.js} +1 -184
- package/dist/chunk-CLF25CZC.js +987 -0
- package/dist/chunk-KMSL5PWK.js +540 -0
- package/dist/chunk-ME3SIZZU.js +581 -0
- package/dist/chunk-TQOBJMEK.js +17 -0
- package/dist/{chunk-T3T4EBWY.js → chunk-UH75UVM2.js} +6 -2
- package/dist/chunk-UW7VQ3S6.js +148 -0
- package/dist/chunk-XE4LLC7Z.js +1067 -0
- package/dist/chunk-ZAW6F3YT.js +172 -0
- package/dist/index.cjs +735 -25
- package/dist/index.d.cts +373 -3
- package/dist/index.d.ts +373 -3
- package/dist/index.js +59 -3
- package/dist/live/client.cjs +173 -0
- package/dist/live/client.d.cts +132 -0
- package/dist/live/client.d.ts +132 -0
- package/dist/live/client.js +9 -0
- package/dist/native/index.d.cts +5 -2
- package/dist/native/index.d.ts +5 -2
- package/dist/native/index.js +15 -11
- package/dist/{organicParticlesShaders-DIva0vBO.d.ts → organicParticlesShaders-seZq7jYX.d.cts} +1 -421
- package/dist/{organicParticlesShaders-DIva0vBO.d.cts → organicParticlesShaders-seZq7jYX.d.ts} +1 -421
- package/dist/particlesConfig-0YbdPOdY.d.cts +223 -0
- package/dist/particlesConfig-0YbdPOdY.d.ts +223 -0
- package/dist/react/conversation.cjs +789 -0
- package/dist/react/conversation.d.cts +134 -0
- package/dist/react/conversation.d.ts +134 -0
- package/dist/react/conversation.js +13 -0
- package/dist/react/index.cjs +374 -36
- package/dist/react/index.d.cts +10 -141
- package/dist/react/index.d.ts +10 -141
- package/dist/react/index.js +23 -770
- package/dist/react/visuals.cjs +1048 -0
- package/dist/react/visuals.d.cts +70 -0
- package/dist/react/visuals.d.ts +70 -0
- package/dist/react/visuals.js +10 -0
- package/dist/route/agent.cjs +1038 -85
- package/dist/route/agent.js +3 -3
- package/dist/route/audio-tags.cjs +1038 -85
- package/dist/route/audio-tags.js +3 -3
- package/dist/route/index.cjs +1042 -85
- package/dist/route/index.d.cts +19 -3
- package/dist/route/index.d.ts +19 -3
- package/dist/route/index.js +3 -3
- package/dist/route/music.cjs +1038 -85
- package/dist/route/music.js +3 -3
- package/dist/route/op.cjs +2118 -0
- package/dist/route/op.d.cts +43 -0
- package/dist/route/op.d.ts +43 -0
- package/dist/route/op.js +27 -0
- package/dist/route/signed-url.cjs +1038 -85
- package/dist/route/signed-url.js +3 -3
- package/dist/route/tts-preview.cjs +1038 -85
- package/dist/route/tts-preview.d.cts +6 -4
- package/dist/route/tts-preview.d.ts +6 -4
- package/dist/route/tts-preview.js +3 -3
- package/dist/server.cjs +1038 -85
- package/dist/server.d.cts +40 -8
- package/dist/server.d.ts +40 -8
- package/dist/server.js +2 -2
- package/dist/types-Bfhu-aQH.d.cts +199 -0
- package/dist/types-Bfhu-aQH.d.ts +199 -0
- package/dist/{types-W9y6eMj3.d.ts → types-CadjTXNH.d.cts} +193 -12
- package/dist/{types-W9y6eMj3.d.cts → types-CadjTXNH.d.ts} +193 -12
- package/package.json +21 -1
- package/dist/chunk-B4JC3DK2.js +0 -529
- package/dist/chunk-O362EJQR.js +0 -524
package/dist/server.cjs
CHANGED
|
@@ -192,19 +192,74 @@ function composeAgentPrompt(basePrompt, options = {}) {
|
|
|
192
192
|
|
|
193
193
|
// src/elevenlabs.ts
|
|
194
194
|
var DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
|
|
195
|
-
var
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
195
|
+
var VOICE_TTS_MODELS = [
|
|
196
|
+
{ id: "eleven_v4", label: "Eleven v4", realtime: false, maxChars: 1e4 },
|
|
197
|
+
{ id: "eleven_v4_turbo", label: "Eleven v4 Turbo", realtime: true, maxChars: 1e4 },
|
|
198
|
+
{ id: "eleven_v3", label: "Eleven v3", realtime: false, maxChars: 5e3 },
|
|
199
|
+
{
|
|
200
|
+
id: "eleven_v3_conversational",
|
|
201
|
+
label: "Eleven v3 Conversational",
|
|
202
|
+
realtime: true,
|
|
203
|
+
maxChars: 5e3
|
|
204
|
+
},
|
|
205
|
+
{ id: "eleven_multilingual_v2", label: "Eleven Multilingual v2", realtime: false, maxChars: 1e4 },
|
|
206
|
+
{ id: "eleven_flash_v2_5", label: "Eleven Flash v2.5", realtime: true, maxChars: 4e4 },
|
|
207
|
+
{ id: "eleven_flash_v2", label: "Eleven Flash v2", realtime: true, maxChars: 3e4 },
|
|
208
|
+
{ id: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5", realtime: true, maxChars: 4e4 },
|
|
209
|
+
{ id: "eleven_turbo_v2", label: "Eleven Turbo v2", realtime: true, maxChars: 3e4, deprecated: true }
|
|
210
|
+
];
|
|
211
|
+
var VOICE_TTS_MODEL_IDS = VOICE_TTS_MODELS.map((m) => m.id);
|
|
212
|
+
var DEFAULT_TTS_MODEL = "eleven_v4";
|
|
213
|
+
var CONVAI_SUPPORTED_TTS_MODELS = new Set(
|
|
214
|
+
VOICE_TTS_MODELS.filter((m) => m.realtime).map((m) => m.id)
|
|
215
|
+
);
|
|
216
|
+
var CONVAI_TTS_MODEL_FALLBACK = "eleven_v4_turbo";
|
|
217
|
+
var TTS_FALLBACK_MODEL = "eleven_flash_v2_5";
|
|
218
|
+
function getVoiceTtsModel(modelId) {
|
|
219
|
+
if (!modelId) return void 0;
|
|
220
|
+
return VOICE_TTS_MODELS.find((m) => m.id === modelId);
|
|
221
|
+
}
|
|
222
|
+
var loggedDowngrades = /* @__PURE__ */ new Set();
|
|
223
|
+
function resolveConvaiTtsModelDetailed(requested) {
|
|
224
|
+
if (requested && CONVAI_SUPPORTED_TTS_MODELS.has(requested)) {
|
|
225
|
+
return { modelId: requested, requested, downgraded: false };
|
|
226
|
+
}
|
|
227
|
+
const known = getVoiceTtsModel(requested);
|
|
228
|
+
const reason = !requested ? void 0 : known ? `${requested} is not supported by the convai realtime runtime` : `unknown TTS model ${requested}`;
|
|
229
|
+
if (reason && !loggedDowngrades.has(requested)) {
|
|
230
|
+
loggedDowngrades.add(requested);
|
|
231
|
+
try {
|
|
232
|
+
console.warn(
|
|
233
|
+
`[voice] convai TTS model ${requested} is unavailable (${reason}); using ${CONVAI_TTS_MODEL_FALLBACK}`
|
|
234
|
+
);
|
|
235
|
+
} catch {
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
modelId: CONVAI_TTS_MODEL_FALLBACK,
|
|
240
|
+
requested: requested ?? null,
|
|
241
|
+
downgraded: Boolean(requested),
|
|
242
|
+
...reason ? { reason } : {}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
203
245
|
function resolveConvaiTtsModel(requested) {
|
|
204
|
-
|
|
205
|
-
|
|
246
|
+
return resolveConvaiTtsModelDetailed(requested).modelId;
|
|
247
|
+
}
|
|
248
|
+
function isModelRelatedTtsFailure(status, body) {
|
|
249
|
+
if (status === 402) return true;
|
|
250
|
+
if (status !== 400 && status !== 403 && status !== 404 && status !== 422) return false;
|
|
251
|
+
return /model|plan|permission/i.test(body);
|
|
206
252
|
}
|
|
207
253
|
var DEFAULT_MUSIC_MODEL = "music_v1";
|
|
254
|
+
var VOICE_STS_MODEL_IDS = [
|
|
255
|
+
"eleven_multilingual_sts_v2",
|
|
256
|
+
"eleven_english_sts_v2"
|
|
257
|
+
];
|
|
258
|
+
var DEFAULT_STS_MODEL = "eleven_multilingual_sts_v2";
|
|
259
|
+
var VOICE_SPEECH_TO_TEXT_MODEL_IDS = ["scribe_v1"];
|
|
260
|
+
var DEFAULT_SPEECH_TO_TEXT_MODEL = "scribe_v1";
|
|
261
|
+
var VOICE_SOUND_GENERATION_MODEL_IDS = ["eleven_text_to_sound_v2"];
|
|
262
|
+
var DEFAULT_SOUND_GENERATION_MODEL = "eleven_text_to_sound_v2";
|
|
208
263
|
var ElevenLabsApiError = class extends Error {
|
|
209
264
|
constructor(message, status, body) {
|
|
210
265
|
super(message);
|
|
@@ -369,12 +424,18 @@ var ElevenLabsClient = class {
|
|
|
369
424
|
return data.signed_url;
|
|
370
425
|
}
|
|
371
426
|
/**
|
|
372
|
-
* One-shot TTS. Returns the raw audio bytes plus the model
|
|
373
|
-
*
|
|
374
|
-
*
|
|
427
|
+
* One-shot TTS. Returns the raw audio bytes plus the model that actually
|
|
428
|
+
* produced them.
|
|
429
|
+
*
|
|
430
|
+
* The requested model is never swapped silently: we only retry on
|
|
431
|
+
* {@link TTS_FALLBACK_MODEL} when the failure is plausibly about
|
|
432
|
+
* the model (400/403/404/422 whose body mentions model/plan/permission,
|
|
433
|
+
* or 402 for an out-of-plan model). Auth (401), rate limit (429), 5xx and
|
|
434
|
+
* network errors are surfaced as-is. When a fallback does happen the
|
|
435
|
+
* result reports `requestedModel`, `fellBack` and `fallbackReason`.
|
|
375
436
|
*/
|
|
376
437
|
async textToSpeech(params) {
|
|
377
|
-
const
|
|
438
|
+
const requestedModel = params.modelId && params.modelId.length > 0 ? params.modelId : DEFAULT_TTS_MODEL;
|
|
378
439
|
const accept = params.accept ?? "audio/mpeg";
|
|
379
440
|
const attempt = async (m) => {
|
|
380
441
|
const res2 = await this.fetchImpl(
|
|
@@ -391,19 +452,39 @@ var ElevenLabsClient = class {
|
|
|
391
452
|
);
|
|
392
453
|
return res2;
|
|
393
454
|
};
|
|
394
|
-
let res = await attempt(
|
|
395
|
-
let used =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
used = "eleven_turbo_v2_5";
|
|
399
|
-
}
|
|
455
|
+
let res = await attempt(requestedModel);
|
|
456
|
+
let used = requestedModel;
|
|
457
|
+
let fellBack = false;
|
|
458
|
+
let fallbackReason;
|
|
400
459
|
if (!res.ok) {
|
|
401
|
-
const
|
|
402
|
-
|
|
460
|
+
const body = await res.text();
|
|
461
|
+
if (requestedModel !== TTS_FALLBACK_MODEL && isModelRelatedTtsFailure(res.status, body)) {
|
|
462
|
+
fallbackReason = `elevenlabs ${res.status} for ${requestedModel}: ${body.slice(0, 200)}`;
|
|
463
|
+
res = await attempt(TTS_FALLBACK_MODEL);
|
|
464
|
+
used = TTS_FALLBACK_MODEL;
|
|
465
|
+
fellBack = true;
|
|
466
|
+
if (!res.ok) {
|
|
467
|
+
const retryBody = await res.text();
|
|
468
|
+
throw new ElevenLabsApiError(
|
|
469
|
+
`textToSpeech failed: ${res.status}`,
|
|
470
|
+
res.status,
|
|
471
|
+
retryBody
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
throw new ElevenLabsApiError(`textToSpeech failed: ${res.status}`, res.status, body);
|
|
476
|
+
}
|
|
403
477
|
}
|
|
404
478
|
const audio = await res.arrayBuffer();
|
|
405
479
|
const contentType = res.headers.get("content-type") ?? accept;
|
|
406
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
audio,
|
|
482
|
+
modelId: used,
|
|
483
|
+
requestedModel,
|
|
484
|
+
fellBack,
|
|
485
|
+
...fallbackReason ? { fallbackReason } : {},
|
|
486
|
+
contentType
|
|
487
|
+
};
|
|
407
488
|
}
|
|
408
489
|
/**
|
|
409
490
|
* Low-level escape hatch for any ElevenLabs endpoint not covered by a
|
|
@@ -509,6 +590,119 @@ var ElevenLabsClient = class {
|
|
|
509
590
|
}
|
|
510
591
|
return { audio, contentType };
|
|
511
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Convert speech to speech — the "voice changer": keep the performance,
|
|
595
|
+
* swap the voice. `POST /v1/speech-to-speech/{voiceId}` with a multipart
|
|
596
|
+
* audio part. Returns the converted audio bytes.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```ts
|
|
600
|
+
* const { audio } = await el.speechToSpeech({
|
|
601
|
+
* voiceId: '21m00Tcm4TlvDq8ikWAM',
|
|
602
|
+
* audio: await fetch(myRecording).then((r) => r.blob()),
|
|
603
|
+
* modelId: 'eleven_multilingual_sts_v2',
|
|
604
|
+
* })
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
async speechToSpeech(params) {
|
|
608
|
+
if (!params.voiceId) throw new Error("speechToSpeech: voiceId is required");
|
|
609
|
+
if (!params.audio) throw new Error("speechToSpeech: audio is required");
|
|
610
|
+
const form = new FormData();
|
|
611
|
+
form.set("audio", params.audio, params.fileName ?? "audio");
|
|
612
|
+
form.set("model_id", params.modelId ?? DEFAULT_STS_MODEL);
|
|
613
|
+
if (params.voiceSettings) {
|
|
614
|
+
form.set("voice_settings", JSON.stringify(params.voiceSettings));
|
|
615
|
+
}
|
|
616
|
+
if (params.outputFormat) form.set("output_format", params.outputFormat);
|
|
617
|
+
if (params.fileFormat) form.set("file_format", params.fileFormat);
|
|
618
|
+
if (params.seed !== void 0) form.set("seed", String(params.seed));
|
|
619
|
+
if (params.removeBackgroundNoise !== void 0) {
|
|
620
|
+
form.set("remove_background_noise", String(params.removeBackgroundNoise));
|
|
621
|
+
}
|
|
622
|
+
const { audio, contentType } = await this.request({
|
|
623
|
+
method: "POST",
|
|
624
|
+
path: `/v1/speech-to-speech/${encodeURIComponent(params.voiceId)}`,
|
|
625
|
+
body: form,
|
|
626
|
+
accept: "audio/mpeg"
|
|
627
|
+
});
|
|
628
|
+
if (!audio) {
|
|
629
|
+
throw new ElevenLabsApiError(
|
|
630
|
+
"speechToSpeech: expected audio bytes but got a JSON response",
|
|
631
|
+
500,
|
|
632
|
+
""
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return { audio, contentType };
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Generate a sound effect from a text prompt. `POST /v1/sound-generation`.
|
|
639
|
+
*
|
|
640
|
+
* @example
|
|
641
|
+
* ```ts
|
|
642
|
+
* const { audio } = await el.soundEffects({ text: 'heavy wooden door creaking' })
|
|
643
|
+
* ```
|
|
644
|
+
*/
|
|
645
|
+
async soundEffects(params) {
|
|
646
|
+
if (!params.text || !params.text.trim()) {
|
|
647
|
+
throw new Error("soundEffects: text is required");
|
|
648
|
+
}
|
|
649
|
+
const body = {
|
|
650
|
+
text: params.text,
|
|
651
|
+
model_id: params.modelId ?? DEFAULT_SOUND_GENERATION_MODEL
|
|
652
|
+
};
|
|
653
|
+
if (params.durationSeconds !== void 0) body.duration_seconds = params.durationSeconds;
|
|
654
|
+
if (params.promptInfluence !== void 0) body.prompt_influence = params.promptInfluence;
|
|
655
|
+
if (params.loop !== void 0) body.loop = params.loop;
|
|
656
|
+
const { audio, contentType } = await this.request({
|
|
657
|
+
method: "POST",
|
|
658
|
+
path: "/v1/sound-generation",
|
|
659
|
+
body,
|
|
660
|
+
accept: "audio/mpeg",
|
|
661
|
+
...params.outputFormat ? { query: { output_format: params.outputFormat } } : {}
|
|
662
|
+
});
|
|
663
|
+
if (!audio) {
|
|
664
|
+
throw new ElevenLabsApiError(
|
|
665
|
+
"soundEffects: expected audio bytes but got a JSON response",
|
|
666
|
+
500,
|
|
667
|
+
""
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return { audio, contentType };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Transcribe audio with Scribe. `POST /v1/speech-to-text` (multipart).
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* ```ts
|
|
677
|
+
* const { text } = await el.transcribe({ file: recording })
|
|
678
|
+
* ```
|
|
679
|
+
*/
|
|
680
|
+
async transcribe(params) {
|
|
681
|
+
if (!params.file) throw new Error("transcribe: file is required");
|
|
682
|
+
const form = new FormData();
|
|
683
|
+
form.set("file", params.file, params.fileName ?? "audio");
|
|
684
|
+
form.set("model_id", params.modelId ?? DEFAULT_SPEECH_TO_TEXT_MODEL);
|
|
685
|
+
if (params.languageCode) form.set("language_code", params.languageCode);
|
|
686
|
+
if (params.diarize !== void 0) form.set("diarize", String(params.diarize));
|
|
687
|
+
if (params.tagAudioEvents !== void 0) {
|
|
688
|
+
form.set("tag_audio_events", String(params.tagAudioEvents));
|
|
689
|
+
}
|
|
690
|
+
if (params.numSpeakers !== void 0) form.set("num_speakers", String(params.numSpeakers));
|
|
691
|
+
const { json: json2 } = await this.request({
|
|
692
|
+
method: "POST",
|
|
693
|
+
path: "/v1/speech-to-text",
|
|
694
|
+
body: form,
|
|
695
|
+
accept: "application/json"
|
|
696
|
+
});
|
|
697
|
+
if (!json2 || typeof json2 !== "object") {
|
|
698
|
+
throw new ElevenLabsApiError(
|
|
699
|
+
"transcribe: expected a JSON transcription but got audio",
|
|
700
|
+
500,
|
|
701
|
+
""
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
return json2;
|
|
705
|
+
}
|
|
512
706
|
/** Probe whether a given voice id still exists on the caller's account. */
|
|
513
707
|
async voiceExists(voiceId) {
|
|
514
708
|
const res = await this.fetchImpl(
|
|
@@ -672,6 +866,312 @@ var MEMORY_CLIENT_TOOLS = [
|
|
|
672
866
|
}
|
|
673
867
|
];
|
|
674
868
|
|
|
869
|
+
// src/ops.ts
|
|
870
|
+
var MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
|
871
|
+
var MAX_JSON_BYTES = 8 * 1024;
|
|
872
|
+
var MAX_TEXT_BYTES = 256 * 1024;
|
|
873
|
+
var VOICE_OPS = [
|
|
874
|
+
{
|
|
875
|
+
id: "text-to-speech",
|
|
876
|
+
method: "POST",
|
|
877
|
+
hostedPath: "/voice/op/text-to-speech",
|
|
878
|
+
legacyHostedPath: "/voice/tts",
|
|
879
|
+
upstreamPath: "/v1/text-to-speech/{voiceId}",
|
|
880
|
+
pathParams: ["voiceId"],
|
|
881
|
+
transport: "json-binary",
|
|
882
|
+
call: "textToSpeech",
|
|
883
|
+
permission: "project:read",
|
|
884
|
+
maxBodyBytes: MAX_TEXT_BYTES,
|
|
885
|
+
cost: { unit: "character", per: 1 },
|
|
886
|
+
models: VOICE_TTS_MODEL_IDS,
|
|
887
|
+
defaultModel: CONVAI_TTS_MODEL_FALLBACK,
|
|
888
|
+
defaultVoiceId: true,
|
|
889
|
+
forward: {
|
|
890
|
+
text: "text",
|
|
891
|
+
voiceId: "voice_id",
|
|
892
|
+
modelId: "model_id",
|
|
893
|
+
voiceSettings: "voice_settings"
|
|
894
|
+
},
|
|
895
|
+
required: ["text"],
|
|
896
|
+
responseHeaders: ["x-tts-model-used", "x-tts-model-requested", "warning"],
|
|
897
|
+
accept: "audio/mpeg",
|
|
898
|
+
errorLabel: "TTS failed"
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
id: "text-to-speech-with-timestamps",
|
|
902
|
+
method: "POST",
|
|
903
|
+
hostedPath: "/voice/op/text-to-speech-with-timestamps",
|
|
904
|
+
upstreamPath: "/v1/text-to-speech/{voiceId}/with-timestamps",
|
|
905
|
+
pathParams: ["voiceId"],
|
|
906
|
+
transport: "json-json",
|
|
907
|
+
permission: "project:read",
|
|
908
|
+
maxBodyBytes: MAX_TEXT_BYTES,
|
|
909
|
+
cost: { unit: "character", per: 1 },
|
|
910
|
+
models: VOICE_TTS_MODEL_IDS,
|
|
911
|
+
defaultModel: CONVAI_TTS_MODEL_FALLBACK,
|
|
912
|
+
defaultVoiceId: true,
|
|
913
|
+
forward: {
|
|
914
|
+
text: "text",
|
|
915
|
+
voiceId: "voice_id",
|
|
916
|
+
modelId: "model_id",
|
|
917
|
+
voiceSettings: "voice_settings",
|
|
918
|
+
outputFormat: "output_format"
|
|
919
|
+
},
|
|
920
|
+
required: ["text"],
|
|
921
|
+
accept: "application/json"
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
id: "speech-to-speech",
|
|
925
|
+
method: "POST",
|
|
926
|
+
hostedPath: "/voice/op/speech-to-speech",
|
|
927
|
+
upstreamPath: "/v1/speech-to-speech/{voiceId}",
|
|
928
|
+
pathParams: ["voiceId"],
|
|
929
|
+
transport: "multipart-binary",
|
|
930
|
+
permission: "project:read",
|
|
931
|
+
maxBodyBytes: MAX_AUDIO_BYTES,
|
|
932
|
+
cost: { unit: "second", per: 1 },
|
|
933
|
+
models: VOICE_STS_MODEL_IDS,
|
|
934
|
+
defaultModel: DEFAULT_STS_MODEL,
|
|
935
|
+
defaultVoiceId: true,
|
|
936
|
+
forward: {
|
|
937
|
+
audio: "audio",
|
|
938
|
+
voiceId: "voice_id",
|
|
939
|
+
modelId: "model_id",
|
|
940
|
+
voiceSettings: "voice_settings",
|
|
941
|
+
outputFormat: "output_format",
|
|
942
|
+
fileFormat: "file_format",
|
|
943
|
+
seed: "seed",
|
|
944
|
+
removeBackgroundNoise: "remove_background_noise"
|
|
945
|
+
},
|
|
946
|
+
required: ["audio"],
|
|
947
|
+
accept: "audio/mpeg"
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
id: "speech-to-text",
|
|
951
|
+
method: "POST",
|
|
952
|
+
hostedPath: "/voice/op/speech-to-text",
|
|
953
|
+
upstreamPath: "/v1/speech-to-text",
|
|
954
|
+
transport: "multipart-json",
|
|
955
|
+
permission: "project:read",
|
|
956
|
+
maxBodyBytes: MAX_AUDIO_BYTES,
|
|
957
|
+
cost: { unit: "second", per: 1 },
|
|
958
|
+
models: VOICE_SPEECH_TO_TEXT_MODEL_IDS,
|
|
959
|
+
defaultModel: DEFAULT_SPEECH_TO_TEXT_MODEL,
|
|
960
|
+
forward: {
|
|
961
|
+
file: "file",
|
|
962
|
+
modelId: "model_id",
|
|
963
|
+
languageCode: "language_code",
|
|
964
|
+
diarize: "diarize",
|
|
965
|
+
tagAudioEvents: "tag_audio_events",
|
|
966
|
+
numSpeakers: "num_speakers"
|
|
967
|
+
},
|
|
968
|
+
required: ["file"],
|
|
969
|
+
accept: "application/json"
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
id: "sound-generation",
|
|
973
|
+
method: "POST",
|
|
974
|
+
hostedPath: "/voice/op/sound-generation",
|
|
975
|
+
upstreamPath: "/v1/sound-generation",
|
|
976
|
+
transport: "json-binary",
|
|
977
|
+
permission: "project:read",
|
|
978
|
+
maxBodyBytes: MAX_JSON_BYTES,
|
|
979
|
+
cost: { unit: "second", per: 1 },
|
|
980
|
+
models: VOICE_SOUND_GENERATION_MODEL_IDS,
|
|
981
|
+
defaultModel: DEFAULT_SOUND_GENERATION_MODEL,
|
|
982
|
+
forward: {
|
|
983
|
+
text: "text",
|
|
984
|
+
durationSeconds: "duration_seconds",
|
|
985
|
+
promptInfluence: "prompt_influence",
|
|
986
|
+
loop: "loop",
|
|
987
|
+
modelId: "model_id"
|
|
988
|
+
},
|
|
989
|
+
// `output_format` is a query param on this endpoint, not a body field — the
|
|
990
|
+
// typed `soundEffects()` client sends it there, and a body field is
|
|
991
|
+
// silently ignored upstream.
|
|
992
|
+
queryForward: ["output_format"],
|
|
993
|
+
required: ["text"],
|
|
994
|
+
accept: "audio/mpeg"
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
id: "music",
|
|
998
|
+
method: "POST",
|
|
999
|
+
hostedPath: "/voice/op/music",
|
|
1000
|
+
legacyHostedPath: "/voice/music",
|
|
1001
|
+
upstreamPath: "/v1/music",
|
|
1002
|
+
transport: "json-binary",
|
|
1003
|
+
call: "composeMusic",
|
|
1004
|
+
permission: "project:read",
|
|
1005
|
+
maxBodyBytes: MAX_JSON_BYTES,
|
|
1006
|
+
cost: { unit: "second", per: 1 },
|
|
1007
|
+
models: [DEFAULT_MUSIC_MODEL],
|
|
1008
|
+
defaultModel: DEFAULT_MUSIC_MODEL,
|
|
1009
|
+
forward: {
|
|
1010
|
+
prompt: "prompt",
|
|
1011
|
+
compositionPlan: "composition_plan",
|
|
1012
|
+
musicLengthMs: "music_length_ms",
|
|
1013
|
+
modelId: "model_id",
|
|
1014
|
+
forceInstrumental: "force_instrumental",
|
|
1015
|
+
outputFormat: "output_format"
|
|
1016
|
+
},
|
|
1017
|
+
accept: "audio/mpeg",
|
|
1018
|
+
errorLabel: "Music generation failed",
|
|
1019
|
+
validate: (fields) => {
|
|
1020
|
+
const hasPrompt = typeof fields.prompt === "string" && fields.prompt.trim().length > 0;
|
|
1021
|
+
const hasPlan = fields.compositionPlan != null && typeof fields.compositionPlan === "object";
|
|
1022
|
+
return hasPrompt === hasPlan ? "Provide exactly one of `prompt` or `compositionPlan`" : null;
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
id: "voices",
|
|
1027
|
+
method: "GET",
|
|
1028
|
+
hostedPath: "/voice/op/voices",
|
|
1029
|
+
upstreamPath: "/v1/voices",
|
|
1030
|
+
transport: "json-json",
|
|
1031
|
+
permission: "project:read",
|
|
1032
|
+
maxBodyBytes: 0,
|
|
1033
|
+
cost: { unit: "request", per: 1 },
|
|
1034
|
+
queryForward: ["page_size", "search", "voice_type", "category", "next_page_token"],
|
|
1035
|
+
accept: "application/json"
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
id: "models",
|
|
1039
|
+
method: "GET",
|
|
1040
|
+
hostedPath: "/voice/op/models",
|
|
1041
|
+
upstreamPath: "/v1/models",
|
|
1042
|
+
transport: "json-json",
|
|
1043
|
+
permission: "project:read",
|
|
1044
|
+
maxBodyBytes: 0,
|
|
1045
|
+
cost: { unit: "request", per: 1 },
|
|
1046
|
+
accept: "application/json"
|
|
1047
|
+
}
|
|
1048
|
+
];
|
|
1049
|
+
var VOICE_OP_IDS = VOICE_OPS.map((op) => op.id);
|
|
1050
|
+
function getVoiceOp(id) {
|
|
1051
|
+
if (!id) return void 0;
|
|
1052
|
+
return VOICE_OPS.find((op) => op.id === id);
|
|
1053
|
+
}
|
|
1054
|
+
function voiceOpForwardPath(op) {
|
|
1055
|
+
return op.legacyHostedPath ?? op.hostedPath;
|
|
1056
|
+
}
|
|
1057
|
+
function voiceOpReturnsAudio(op) {
|
|
1058
|
+
return op.transport === "json-binary" || op.transport === "multipart-binary";
|
|
1059
|
+
}
|
|
1060
|
+
function voiceOpUpstreamPath(op, params) {
|
|
1061
|
+
let path = op.upstreamPath;
|
|
1062
|
+
for (const name of op.pathParams ?? []) {
|
|
1063
|
+
const value = params[name];
|
|
1064
|
+
if (!value) {
|
|
1065
|
+
return { error: `Missing \`${name}\` (required path parameter for ${op.id})` };
|
|
1066
|
+
}
|
|
1067
|
+
path = path.split(`{${name}}`).join(encodeURIComponent(value));
|
|
1068
|
+
}
|
|
1069
|
+
return path;
|
|
1070
|
+
}
|
|
1071
|
+
var MODEL_FIELD = "modelId";
|
|
1072
|
+
function parseVoiceOpJsonBody(op, raw) {
|
|
1073
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1074
|
+
return { ok: false, status: 400, error: "Invalid JSON body" };
|
|
1075
|
+
}
|
|
1076
|
+
const body = raw;
|
|
1077
|
+
const fields = {};
|
|
1078
|
+
for (const [callerField, upstreamField] of Object.entries(op.forward ?? {})) {
|
|
1079
|
+
if (upstreamField && body[callerField] !== void 0) fields[callerField] = body[callerField];
|
|
1080
|
+
}
|
|
1081
|
+
for (const name of op.required ?? []) {
|
|
1082
|
+
const value = fields[name];
|
|
1083
|
+
if (value === void 0 || value === null || typeof value === "string" && !value.trim()) {
|
|
1084
|
+
return { ok: false, status: 400, error: `Missing \`${name}\`` };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const modelCheck = checkVoiceOpModel(op, fields);
|
|
1088
|
+
if ("error" in modelCheck) return { ok: false, status: 400, ...modelCheck };
|
|
1089
|
+
if (op.validate) {
|
|
1090
|
+
const message = op.validate(fields);
|
|
1091
|
+
if (message) return { ok: false, status: 400, error: message };
|
|
1092
|
+
}
|
|
1093
|
+
return { ok: true, fields };
|
|
1094
|
+
}
|
|
1095
|
+
function parseVoiceOpForm(op, form) {
|
|
1096
|
+
const fields = {};
|
|
1097
|
+
for (const callerField of Object.keys(op.forward ?? {})) {
|
|
1098
|
+
const value = form.get(callerField);
|
|
1099
|
+
if (value !== null) fields[callerField] = value;
|
|
1100
|
+
}
|
|
1101
|
+
for (const name of op.required ?? []) {
|
|
1102
|
+
const value = fields[name];
|
|
1103
|
+
if (!(value instanceof Blob)) {
|
|
1104
|
+
return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
status: 400,
|
|
1107
|
+
error: `Missing \`${name}\` file part`,
|
|
1108
|
+
detail: value === void 0 ? `send a multipart body with a \`${name}\` file part` : `\`${name}\` must be a file part, not a text field`
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
if (value.size === 0) {
|
|
1112
|
+
return { ok: false, status: 400, error: `Empty \`${name}\` file part` };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const modelCheck = checkVoiceOpModel(op, fields);
|
|
1116
|
+
if ("error" in modelCheck) return { ok: false, status: 400, ...modelCheck };
|
|
1117
|
+
const forward = op.forward ?? {};
|
|
1118
|
+
const pathParams = new Set(op.pathParams ?? []);
|
|
1119
|
+
const out = new FormData();
|
|
1120
|
+
for (const [callerField, upstreamField] of Object.entries(forward)) {
|
|
1121
|
+
if (pathParams.has(callerField)) continue;
|
|
1122
|
+
const value = fields[callerField];
|
|
1123
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
1124
|
+
if (value instanceof Blob) {
|
|
1125
|
+
const name = value instanceof File ? value.name : callerField;
|
|
1126
|
+
out.set(upstreamField, value, name);
|
|
1127
|
+
} else if (typeof value === "string") {
|
|
1128
|
+
out.set(upstreamField, value);
|
|
1129
|
+
} else {
|
|
1130
|
+
out.set(upstreamField, JSON.stringify(value));
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const modelField = forward[MODEL_FIELD];
|
|
1134
|
+
if (op.defaultModel && modelField && out.get(modelField) === null) {
|
|
1135
|
+
out.set(modelField, op.defaultModel);
|
|
1136
|
+
}
|
|
1137
|
+
return { ok: true, form: out, fields };
|
|
1138
|
+
}
|
|
1139
|
+
function voiceOpUpstreamBody(op, fields) {
|
|
1140
|
+
const pathParams = new Set(op.pathParams ?? []);
|
|
1141
|
+
const body = {};
|
|
1142
|
+
for (const [callerField, upstreamField] of Object.entries(op.forward ?? {})) {
|
|
1143
|
+
if (pathParams.has(callerField)) continue;
|
|
1144
|
+
const value = fields[callerField];
|
|
1145
|
+
if (value === void 0 || value === null) continue;
|
|
1146
|
+
body[upstreamField] = value;
|
|
1147
|
+
}
|
|
1148
|
+
return body;
|
|
1149
|
+
}
|
|
1150
|
+
function voiceOpPathParams(op, fields, query) {
|
|
1151
|
+
const out = {};
|
|
1152
|
+
for (const name of op.pathParams ?? []) {
|
|
1153
|
+
const fromBody = fields[name];
|
|
1154
|
+
const value = typeof fromBody === "string" && fromBody.length > 0 ? fromBody : query.get(name);
|
|
1155
|
+
if (value) out[name] = value;
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
1159
|
+
function checkVoiceOpModel(op, fields) {
|
|
1160
|
+
const requested = fields[MODEL_FIELD];
|
|
1161
|
+
if (requested === void 0 || requested === null || requested === "") return { ok: true };
|
|
1162
|
+
if (typeof requested !== "string") {
|
|
1163
|
+
return { error: `\`${MODEL_FIELD}\` must be a string` };
|
|
1164
|
+
}
|
|
1165
|
+
if (op.models && !op.models.includes(requested)) {
|
|
1166
|
+
return {
|
|
1167
|
+
error: "unknown_model",
|
|
1168
|
+
detail: `Unknown model '${requested}' for ${op.id}`,
|
|
1169
|
+
allowed: op.models
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
return { ok: true };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
675
1175
|
// src/server.ts
|
|
676
1176
|
function json(data, status = 200, extraHeaders = {}) {
|
|
677
1177
|
return new Response(JSON.stringify(data), {
|
|
@@ -712,26 +1212,112 @@ function parseMusicBody(raw) {
|
|
|
712
1212
|
}
|
|
713
1213
|
return { params };
|
|
714
1214
|
}
|
|
1215
|
+
var MISSING_API_HOST_MESSAGE = "createVoiceHandlers: runtime-token proxy mode needs SHOGO_API_URL (or SHOGO_CLOUD_URL) in env; refusing to guess an API host";
|
|
1216
|
+
function envApiUrl() {
|
|
1217
|
+
if (typeof process === "undefined" || !process.env) return void 0;
|
|
1218
|
+
return process.env.SHOGO_API_URL ?? process.env.SHOGO_CLOUD_URL;
|
|
1219
|
+
}
|
|
715
1220
|
function resolveProxyOptions(config) {
|
|
1221
|
+
if (config.mode === "byo") {
|
|
1222
|
+
if (config.proxy) {
|
|
1223
|
+
throw new Error(
|
|
1224
|
+
"createVoiceHandlers: mode 'byo' cannot be combined with an explicit proxy"
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
const envProxy = () => {
|
|
1230
|
+
if (config.mode === "proxy" && config.apiKey) {
|
|
1231
|
+
LOG_WARN_CACHE.apiKeyIgnored(config.logger);
|
|
1232
|
+
}
|
|
1233
|
+
if (typeof process === "undefined" || !process.env) return null;
|
|
1234
|
+
const runtimeToken = process.env.RUNTIME_AUTH_SECRET;
|
|
1235
|
+
const projectId = process.env.PROJECT_ID;
|
|
1236
|
+
if (!runtimeToken || !projectId) return null;
|
|
1237
|
+
return {
|
|
1238
|
+
runtimeToken,
|
|
1239
|
+
projectId,
|
|
1240
|
+
// Deliberately not required here: see `resolveApiBase`. Throwing at
|
|
1241
|
+
// construction made every local-only handler (400 validation, 405s,
|
|
1242
|
+
// the static audio-tags catalog) unusable on a host with a token but
|
|
1243
|
+
// no URL — which is exactly CI's env.
|
|
1244
|
+
apiUrl: envApiUrl() ?? "",
|
|
1245
|
+
...config.fetch ? { fetch: config.fetch } : {}
|
|
1246
|
+
};
|
|
1247
|
+
};
|
|
716
1248
|
if (config.proxy) {
|
|
717
1249
|
if (!config.proxy.runtimeToken || !config.proxy.projectId || !config.proxy.apiUrl) {
|
|
718
1250
|
throw new Error(
|
|
719
1251
|
"createVoiceHandlers: proxy requires runtimeToken, projectId, and apiUrl"
|
|
720
1252
|
);
|
|
721
1253
|
}
|
|
1254
|
+
if (config.apiKey) LOG_WARN_CACHE.apiKeyIgnored(config.logger);
|
|
722
1255
|
return config.proxy;
|
|
723
1256
|
}
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1257
|
+
if (config.mode === "proxy") return envProxy();
|
|
1258
|
+
if (config.apiKey) return null;
|
|
1259
|
+
return envProxy();
|
|
1260
|
+
}
|
|
1261
|
+
var LOG_WARN_CACHE = {
|
|
1262
|
+
warned: false,
|
|
1263
|
+
apiKeyIgnored(logger) {
|
|
1264
|
+
if (LOG_WARN_CACHE.warned) return;
|
|
1265
|
+
LOG_WARN_CACHE.warned = true;
|
|
1266
|
+
(logger ?? NOOP_LOGGER)(
|
|
1267
|
+
"warn",
|
|
1268
|
+
'createVoiceHandlers: apiKey provided but runtime-token proxy mode is active; apiKey is ignored (pass mode: "byo" to use it)'
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
function resolveOpId(req, opId) {
|
|
1273
|
+
if (opId) return opId;
|
|
1274
|
+
try {
|
|
1275
|
+
const url = new URL(req.url);
|
|
1276
|
+
const fromQuery = url.searchParams.get("op");
|
|
1277
|
+
if (fromQuery) return fromQuery;
|
|
1278
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
1279
|
+
const last = segments[segments.length - 1];
|
|
1280
|
+
if (last && segments[segments.length - 2] === "op") return last;
|
|
1281
|
+
return void 0;
|
|
1282
|
+
} catch {
|
|
1283
|
+
return void 0;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async function readVoiceOpBody(op, req) {
|
|
1287
|
+
if (op.method === "GET") return { kind: "none" };
|
|
1288
|
+
if (op.transport === "multipart-binary" || op.transport === "multipart-json") {
|
|
1289
|
+
let form;
|
|
1290
|
+
try {
|
|
1291
|
+
form = await req.formData();
|
|
1292
|
+
} catch {
|
|
1293
|
+
return json({ error: "Invalid multipart body", detail: "send a multipart/form-data body" }, 400);
|
|
1294
|
+
}
|
|
1295
|
+
const parsed2 = parseVoiceOpForm(op, form);
|
|
1296
|
+
if (!parsed2.ok) {
|
|
1297
|
+
return json(
|
|
1298
|
+
{
|
|
1299
|
+
error: parsed2.error,
|
|
1300
|
+
...parsed2.detail ? { detail: parsed2.detail } : {},
|
|
1301
|
+
...parsed2.allowed ? { allowed: parsed2.allowed } : {}
|
|
1302
|
+
},
|
|
1303
|
+
parsed2.status
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
return { kind: "form", form: parsed2.form, fields: parsed2.fields };
|
|
1307
|
+
}
|
|
1308
|
+
const raw = await readJson(req);
|
|
1309
|
+
const parsed = parseVoiceOpJsonBody(op, raw);
|
|
1310
|
+
if (!parsed.ok) {
|
|
1311
|
+
return json(
|
|
1312
|
+
{
|
|
1313
|
+
error: parsed.error,
|
|
1314
|
+
...parsed.detail ? { detail: parsed.detail } : {},
|
|
1315
|
+
...parsed.allowed ? { allowed: parsed.allowed } : {}
|
|
1316
|
+
},
|
|
1317
|
+
parsed.status
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
return { kind: "json", fields: parsed.fields };
|
|
735
1321
|
}
|
|
736
1322
|
function createProxyHandlers(proxy, log) {
|
|
737
1323
|
const fetchImpl = proxy.fetch ?? globalThis.fetch;
|
|
@@ -740,8 +1326,12 @@ function createProxyHandlers(proxy, log) {
|
|
|
740
1326
|
"createVoiceHandlers proxy: global fetch is unavailable; pass proxy.fetch"
|
|
741
1327
|
);
|
|
742
1328
|
}
|
|
743
|
-
|
|
744
|
-
|
|
1329
|
+
function resolveApiBase() {
|
|
1330
|
+
const raw = proxy.apiUrl || envApiUrl();
|
|
1331
|
+
if (!raw) throw new Error(MISSING_API_HOST_MESSAGE);
|
|
1332
|
+
return raw.replace(/\/+$/, "");
|
|
1333
|
+
}
|
|
1334
|
+
function proxyUrl(base, path, forward) {
|
|
745
1335
|
const tail = [`projectId=${encodeURIComponent(proxy.projectId)}`];
|
|
746
1336
|
for (const [k, v] of Object.entries(forward)) {
|
|
747
1337
|
if (typeof v === "string" && v.length > 0) {
|
|
@@ -749,11 +1339,12 @@ function createProxyHandlers(proxy, log) {
|
|
|
749
1339
|
}
|
|
750
1340
|
}
|
|
751
1341
|
const sep = path.includes("?") ? "&" : "?";
|
|
752
|
-
return `${
|
|
1342
|
+
return `${base}${path}${sep}${tail.join("&")}`;
|
|
753
1343
|
}
|
|
754
1344
|
const FORWARDED_SIGNED_URL_PARAMS = ["agentName"];
|
|
755
1345
|
async function doSignedUrl(req) {
|
|
756
1346
|
if (req.method !== "GET") return json({ error: "Method Not Allowed" }, 405);
|
|
1347
|
+
const base = resolveApiBase();
|
|
757
1348
|
try {
|
|
758
1349
|
let incomingUrl = null;
|
|
759
1350
|
try {
|
|
@@ -767,7 +1358,7 @@ function createProxyHandlers(proxy, log) {
|
|
|
767
1358
|
if (v != null) forward[key] = v;
|
|
768
1359
|
}
|
|
769
1360
|
}
|
|
770
|
-
const upstream = await fetchImpl(proxyUrl("/api/voice/signed-url", forward), {
|
|
1361
|
+
const upstream = await fetchImpl(proxyUrl(base, "/api/voice/signed-url", forward), {
|
|
771
1362
|
method: "GET",
|
|
772
1363
|
headers: { "x-runtime-token": proxy.runtimeToken },
|
|
773
1364
|
credentials: "omit"
|
|
@@ -797,34 +1388,80 @@ function createProxyHandlers(proxy, log) {
|
|
|
797
1388
|
}
|
|
798
1389
|
});
|
|
799
1390
|
}
|
|
800
|
-
async function
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
1391
|
+
async function doOp(req, opId) {
|
|
1392
|
+
const id = resolveOpId(req, opId);
|
|
1393
|
+
const op = getVoiceOp(id);
|
|
1394
|
+
if (!op) {
|
|
1395
|
+
return json(
|
|
1396
|
+
{ error: "unknown_op", detail: `Unknown voice op '${id ?? ""}'`, allowed: VOICE_OP_IDS },
|
|
1397
|
+
404
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
if (req.method !== op.method) return json({ error: "Method Not Allowed" }, 405);
|
|
1401
|
+
const parsed = await readVoiceOpBody(op, req);
|
|
1402
|
+
if (parsed instanceof Response) return parsed;
|
|
1403
|
+
const base = resolveApiBase();
|
|
1404
|
+
const forward = {};
|
|
1405
|
+
let incomingUrl = null;
|
|
805
1406
|
try {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1407
|
+
incomingUrl = new URL(req.url);
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
if (incomingUrl) {
|
|
1411
|
+
for (const key of [...op.pathParams ?? [], ...op.queryForward ?? []]) {
|
|
1412
|
+
const value = incomingUrl.searchParams.get(key);
|
|
1413
|
+
if (value != null) forward[key] = value;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (parsed.kind === "form") {
|
|
1417
|
+
for (const name of op.pathParams ?? []) {
|
|
1418
|
+
const value = parsed.fields[name];
|
|
1419
|
+
if (typeof value === "string" && value.length > 0) forward[name] = value;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const hostedPath = `/api${voiceOpForwardPath(op)}`;
|
|
1423
|
+
const headers = { "x-runtime-token": proxy.runtimeToken };
|
|
1424
|
+
let body;
|
|
1425
|
+
if (parsed.kind === "json") {
|
|
1426
|
+
headers["content-type"] = "application/json";
|
|
1427
|
+
body = JSON.stringify(parsed.fields);
|
|
1428
|
+
} else if (parsed.kind === "form") {
|
|
1429
|
+
body = parsed.form;
|
|
1430
|
+
}
|
|
1431
|
+
if (op.accept) headers.accept = op.accept;
|
|
1432
|
+
try {
|
|
1433
|
+
const upstream = await fetchImpl(proxyUrl(base, hostedPath, forward), {
|
|
1434
|
+
method: op.method,
|
|
1435
|
+
headers,
|
|
1436
|
+
...body !== void 0 ? { body } : {},
|
|
814
1437
|
credentials: "omit"
|
|
815
1438
|
});
|
|
1439
|
+
if (upstream.status === 404) {
|
|
1440
|
+
return json(
|
|
1441
|
+
{
|
|
1442
|
+
error: "Not Implemented",
|
|
1443
|
+
detail: `this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 ${hostedPath} returned 404 upstream`
|
|
1444
|
+
},
|
|
1445
|
+
501
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
816
1448
|
const buf = await upstream.arrayBuffer();
|
|
817
|
-
const
|
|
1449
|
+
const responseHeaders = {};
|
|
818
1450
|
const ct = upstream.headers.get("content-type");
|
|
819
|
-
if (ct)
|
|
820
|
-
|
|
1451
|
+
if (ct) responseHeaders["content-type"] = ct;
|
|
1452
|
+
for (const name of op.responseHeaders ?? []) {
|
|
1453
|
+
const value = upstream.headers.get(name);
|
|
1454
|
+
if (value) responseHeaders[name] = value;
|
|
1455
|
+
}
|
|
1456
|
+
return new Response(buf, { status: upstream.status, headers: responseHeaders });
|
|
821
1457
|
} catch (err) {
|
|
822
|
-
log("error",
|
|
1458
|
+
log("error", `proxy ${op.id} failed`, {
|
|
823
1459
|
error: err instanceof Error ? err.message : String(err)
|
|
824
1460
|
});
|
|
825
|
-
return json({ error:
|
|
1461
|
+
return json({ error: `${op.id} proxy failed` }, 502);
|
|
826
1462
|
}
|
|
827
1463
|
}
|
|
1464
|
+
const doMusic = (req) => doOp(req, "music");
|
|
828
1465
|
function notImplemented(label) {
|
|
829
1466
|
return async () => json(
|
|
830
1467
|
{
|
|
@@ -834,9 +1471,204 @@ function createProxyHandlers(proxy, log) {
|
|
|
834
1471
|
501
|
|
835
1472
|
);
|
|
836
1473
|
}
|
|
1474
|
+
async function doTts(req) {
|
|
1475
|
+
if (req.method === "POST") {
|
|
1476
|
+
let raw = null;
|
|
1477
|
+
try {
|
|
1478
|
+
const parsed = await req.clone().json();
|
|
1479
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1480
|
+
raw = parsed;
|
|
1481
|
+
}
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
if (raw && (typeof raw.text !== "string" || raw.text.trim().length === 0)) {
|
|
1485
|
+
const sentTags = Array.isArray(raw.audioTags) && raw.audioTags.length > 0;
|
|
1486
|
+
if (sentTags) {
|
|
1487
|
+
const tags = normalizeAudioTags(raw.audioTags) ?? [];
|
|
1488
|
+
const characterName = typeof raw.characterName === "string" ? raw.characterName : void 0;
|
|
1489
|
+
const text = buildPreviewLine(tags, characterName ?? "your companion");
|
|
1490
|
+
return doOp(
|
|
1491
|
+
new Request(req.url, {
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
headers: { "content-type": "application/json" },
|
|
1494
|
+
body: JSON.stringify({ ...raw, text })
|
|
1495
|
+
}),
|
|
1496
|
+
"text-to-speech"
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return doOp(req, "text-to-speech");
|
|
1502
|
+
}
|
|
1503
|
+
function createProxyElevenLabs() {
|
|
1504
|
+
const unsupported = (prop) => {
|
|
1505
|
+
throw new Error(
|
|
1506
|
+
`createVoiceHandlers: elevenLabs.${prop} is not available in runtime-token proxy mode (textToSpeech, composeMusic, speechToSpeech, soundEffects and transcribe are hosted); pass mode: 'byo' with an apiKey`
|
|
1507
|
+
);
|
|
1508
|
+
};
|
|
1509
|
+
const textToSpeech = async (params) => {
|
|
1510
|
+
const upstream = await fetchImpl(proxyUrl(resolveApiBase(), "/api/voice/tts", {}), {
|
|
1511
|
+
method: "POST",
|
|
1512
|
+
headers: {
|
|
1513
|
+
"x-runtime-token": proxy.runtimeToken,
|
|
1514
|
+
"content-type": "application/json",
|
|
1515
|
+
accept: params.accept ?? "audio/mpeg"
|
|
1516
|
+
},
|
|
1517
|
+
body: JSON.stringify({
|
|
1518
|
+
text: params.text,
|
|
1519
|
+
voiceId: params.voiceId,
|
|
1520
|
+
...params.modelId ? { modelId: params.modelId } : {},
|
|
1521
|
+
...params.voiceSettings ? { voiceSettings: params.voiceSettings } : {}
|
|
1522
|
+
}),
|
|
1523
|
+
credentials: "omit"
|
|
1524
|
+
});
|
|
1525
|
+
if (upstream.status === 404) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
"this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api/voice/tts returned 404 upstream"
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
if (!upstream.ok) {
|
|
1531
|
+
throw new ElevenLabsApiError(
|
|
1532
|
+
`textToSpeech failed: ${upstream.status}`,
|
|
1533
|
+
upstream.status,
|
|
1534
|
+
await upstream.text()
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
const requestedModel = params.modelId ?? DEFAULT_TTS_MODEL;
|
|
1538
|
+
const usedModel = upstream.headers.get("x-tts-model-used") ?? requestedModel;
|
|
1539
|
+
const requestedHeader = upstream.headers.get("x-tts-model-requested") ?? requestedModel;
|
|
1540
|
+
return {
|
|
1541
|
+
audio: await upstream.arrayBuffer(),
|
|
1542
|
+
modelId: usedModel,
|
|
1543
|
+
requestedModel: requestedHeader,
|
|
1544
|
+
fellBack: usedModel !== requestedHeader,
|
|
1545
|
+
contentType: upstream.headers.get("content-type") ?? params.accept ?? "audio/mpeg"
|
|
1546
|
+
};
|
|
1547
|
+
};
|
|
1548
|
+
const composeMusic = async (params) => {
|
|
1549
|
+
const upstream = await fetchImpl(proxyUrl(resolveApiBase(), "/api/voice/music", {}), {
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
headers: {
|
|
1552
|
+
"x-runtime-token": proxy.runtimeToken,
|
|
1553
|
+
"content-type": "application/json",
|
|
1554
|
+
accept: "audio/mpeg"
|
|
1555
|
+
},
|
|
1556
|
+
body: JSON.stringify(params),
|
|
1557
|
+
credentials: "omit"
|
|
1558
|
+
});
|
|
1559
|
+
if (upstream.status === 404) {
|
|
1560
|
+
throw new Error(
|
|
1561
|
+
"this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api/voice/music returned 404 upstream"
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
if (!upstream.ok) {
|
|
1565
|
+
throw new ElevenLabsApiError(
|
|
1566
|
+
`composeMusic failed: ${upstream.status}`,
|
|
1567
|
+
upstream.status,
|
|
1568
|
+
await upstream.text()
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
audio: await upstream.arrayBuffer(),
|
|
1573
|
+
contentType: upstream.headers.get("content-type") ?? "audio/mpeg"
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
const opResponse = async (opId, req) => {
|
|
1577
|
+
const op = getVoiceOp(opId);
|
|
1578
|
+
const res = await doOp(req, opId);
|
|
1579
|
+
if (res.status === 404) {
|
|
1580
|
+
throw new Error(
|
|
1581
|
+
`this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api${voiceOpForwardPath(op)} returned 404 upstream`
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
if (!res.ok) {
|
|
1585
|
+
throw new ElevenLabsApiError(
|
|
1586
|
+
`${opId} failed: ${res.status}`,
|
|
1587
|
+
res.status,
|
|
1588
|
+
await res.text()
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
return res;
|
|
1592
|
+
};
|
|
1593
|
+
const opAudio = async (opId, req) => {
|
|
1594
|
+
const res = await opResponse(opId, req);
|
|
1595
|
+
return {
|
|
1596
|
+
audio: await res.arrayBuffer(),
|
|
1597
|
+
contentType: res.headers.get("content-type") ?? "audio/mpeg"
|
|
1598
|
+
};
|
|
1599
|
+
};
|
|
1600
|
+
const speechToSpeech = (params) => {
|
|
1601
|
+
const form = new FormData();
|
|
1602
|
+
form.set("audio", params.audio, params.fileName ?? "audio");
|
|
1603
|
+
if (params.modelId) form.set("modelId", params.modelId);
|
|
1604
|
+
if (params.voiceSettings) form.set("voiceSettings", JSON.stringify(params.voiceSettings));
|
|
1605
|
+
if (params.outputFormat) form.set("outputFormat", params.outputFormat);
|
|
1606
|
+
if (params.fileFormat) form.set("fileFormat", params.fileFormat);
|
|
1607
|
+
if (params.seed !== void 0) form.set("seed", String(params.seed));
|
|
1608
|
+
if (params.removeBackgroundNoise !== void 0) {
|
|
1609
|
+
form.set("removeBackgroundNoise", String(params.removeBackgroundNoise));
|
|
1610
|
+
}
|
|
1611
|
+
const query = `?voiceId=${encodeURIComponent(params.voiceId)}`;
|
|
1612
|
+
return opAudio(
|
|
1613
|
+
"speech-to-speech",
|
|
1614
|
+
new Request(`http://voice.invalid/voice/op/speech-to-speech${query}`, {
|
|
1615
|
+
method: "POST",
|
|
1616
|
+
body: form
|
|
1617
|
+
})
|
|
1618
|
+
);
|
|
1619
|
+
};
|
|
1620
|
+
const soundEffects = (params) => opAudio(
|
|
1621
|
+
"sound-generation",
|
|
1622
|
+
new Request(
|
|
1623
|
+
// `output_format` is a query param on this endpoint (see the row's
|
|
1624
|
+
// `queryForward`); sending it in the body would be dropped at EL.
|
|
1625
|
+
`http://voice.invalid/voice/op/sound-generation${params.outputFormat ? `?output_format=${encodeURIComponent(params.outputFormat)}` : ""}`,
|
|
1626
|
+
{
|
|
1627
|
+
method: "POST",
|
|
1628
|
+
headers: { "content-type": "application/json" },
|
|
1629
|
+
body: JSON.stringify({
|
|
1630
|
+
text: params.text,
|
|
1631
|
+
...params.durationSeconds !== void 0 ? { durationSeconds: params.durationSeconds } : {},
|
|
1632
|
+
...params.promptInfluence !== void 0 ? { promptInfluence: params.promptInfluence } : {},
|
|
1633
|
+
...params.loop !== void 0 ? { loop: params.loop } : {},
|
|
1634
|
+
...params.modelId ? { modelId: params.modelId } : {}
|
|
1635
|
+
})
|
|
1636
|
+
}
|
|
1637
|
+
)
|
|
1638
|
+
);
|
|
1639
|
+
const transcribe = async (params) => {
|
|
1640
|
+
const form = new FormData();
|
|
1641
|
+
form.set("file", params.file, params.fileName ?? "audio");
|
|
1642
|
+
if (params.modelId) form.set("modelId", params.modelId);
|
|
1643
|
+
if (params.languageCode) form.set("languageCode", params.languageCode);
|
|
1644
|
+
if (params.diarize !== void 0) form.set("diarize", String(params.diarize));
|
|
1645
|
+
if (params.tagAudioEvents !== void 0) {
|
|
1646
|
+
form.set("tagAudioEvents", String(params.tagAudioEvents));
|
|
1647
|
+
}
|
|
1648
|
+
if (params.numSpeakers !== void 0) form.set("numSpeakers", String(params.numSpeakers));
|
|
1649
|
+
const res = await opResponse(
|
|
1650
|
+
"speech-to-text",
|
|
1651
|
+
new Request("http://voice.invalid/voice/op/speech-to-text", {
|
|
1652
|
+
method: "POST",
|
|
1653
|
+
body: form
|
|
1654
|
+
})
|
|
1655
|
+
);
|
|
1656
|
+
return await res.json();
|
|
1657
|
+
};
|
|
1658
|
+
return new Proxy({}, {
|
|
1659
|
+
get(_t, prop) {
|
|
1660
|
+
if (prop === "textToSpeech") return textToSpeech;
|
|
1661
|
+
if (prop === "composeMusic") return composeMusic;
|
|
1662
|
+
if (prop === "speechToSpeech") return speechToSpeech;
|
|
1663
|
+
if (prop === "soundEffects") return soundEffects;
|
|
1664
|
+
if (prop === "transcribe") return transcribe;
|
|
1665
|
+
return unsupported(String(prop));
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
837
1669
|
return {
|
|
838
1670
|
signedUrl: doSignedUrl,
|
|
839
|
-
tts:
|
|
1671
|
+
tts: doTts,
|
|
840
1672
|
music: doMusic,
|
|
841
1673
|
agent: {
|
|
842
1674
|
create: notImplemented("agent.create"),
|
|
@@ -844,30 +1676,23 @@ function createProxyHandlers(proxy, log) {
|
|
|
844
1676
|
delete: notImplemented("agent.delete")
|
|
845
1677
|
},
|
|
846
1678
|
audioTags: doAudioTags,
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
elevenLabs:
|
|
851
|
-
get(_t, prop) {
|
|
852
|
-
throw new Error(
|
|
853
|
-
`createVoiceHandlers: elevenLabs.${String(prop)} is not available in runtime-token proxy mode`
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
})
|
|
1679
|
+
op: doOp,
|
|
1680
|
+
// In proxy mode we don't own an EL client: media calls go through the
|
|
1681
|
+
// Shogo API, anything else is a loud error.
|
|
1682
|
+
elevenLabs: createProxyElevenLabs()
|
|
857
1683
|
};
|
|
858
1684
|
}
|
|
859
1685
|
function createVoiceHandlers(config) {
|
|
860
1686
|
const log = config.logger ?? NOOP_LOGGER;
|
|
861
1687
|
const proxy = resolveProxyOptions(config);
|
|
862
1688
|
if (proxy) {
|
|
863
|
-
if (config.apiKey) {
|
|
864
|
-
log("warn", "createVoiceHandlers: apiKey provided but runtime-token proxy mode is active; apiKey is ignored");
|
|
865
|
-
}
|
|
866
1689
|
return createProxyHandlers(proxy, log);
|
|
867
1690
|
}
|
|
868
|
-
if (!config.apiKey)
|
|
869
|
-
|
|
870
|
-
|
|
1691
|
+
if (!config.apiKey) {
|
|
1692
|
+
throw new Error(
|
|
1693
|
+
config.mode === "proxy" ? 'createVoiceHandlers: mode "proxy" needs RUNTIME_AUTH_SECRET + PROJECT_ID (+ SHOGO_API_URL) in env' : "createVoiceHandlers: apiKey is required (or run in a pod with RUNTIME_AUTH_SECRET + PROJECT_ID for proxy mode)"
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
871
1696
|
const getUser = config.getUser;
|
|
872
1697
|
const companionStore = config.companionStore;
|
|
873
1698
|
const includeMemoryTools = config.includeMemoryTools ?? true;
|
|
@@ -879,10 +1704,28 @@ function createVoiceHandlers(config) {
|
|
|
879
1704
|
};
|
|
880
1705
|
const el = new ElevenLabsClient(elConfig);
|
|
881
1706
|
async function resolveUser(req) {
|
|
1707
|
+
if (!getUser) {
|
|
1708
|
+
return json(
|
|
1709
|
+
{
|
|
1710
|
+
error: "Voice handler misconfigured",
|
|
1711
|
+
detail: "signedUrl / agent.* need getUser (and companionStore); tts and music do not."
|
|
1712
|
+
},
|
|
1713
|
+
500
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
882
1716
|
const u = await getUser(req);
|
|
883
1717
|
if (!u) return json({ error: "Unauthorized" }, 401);
|
|
884
1718
|
return u;
|
|
885
1719
|
}
|
|
1720
|
+
function missingCompanionStore() {
|
|
1721
|
+
return json(
|
|
1722
|
+
{
|
|
1723
|
+
error: "Voice handler misconfigured",
|
|
1724
|
+
detail: "companionStore is required for signedUrl / agent.*; tts and music do not need it."
|
|
1725
|
+
},
|
|
1726
|
+
500
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
886
1729
|
function handleElError(e, fallbackLabel) {
|
|
887
1730
|
if (e instanceof ElevenLabsApiError) {
|
|
888
1731
|
log("warn", `${fallbackLabel}: 11Labs ${e.status}`, { body: e.body });
|
|
@@ -896,6 +1739,7 @@ function createVoiceHandlers(config) {
|
|
|
896
1739
|
if (req.method !== "GET") return json({ error: "Method Not Allowed" }, 405);
|
|
897
1740
|
const u = await resolveUser(req);
|
|
898
1741
|
if (u instanceof Response) return u;
|
|
1742
|
+
if (!companionStore) return missingCompanionStore();
|
|
899
1743
|
const companion = await companionStore.findByUserId(u.id);
|
|
900
1744
|
if (!companion || !companion.agentId) return json({ error: "No companion" }, 404);
|
|
901
1745
|
const basePrompt = extractBasePrompt(companion.systemPrompt || "");
|
|
@@ -945,8 +1789,10 @@ function createVoiceHandlers(config) {
|
|
|
945
1789
|
}
|
|
946
1790
|
async function doTts(req) {
|
|
947
1791
|
if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
|
|
948
|
-
|
|
949
|
-
|
|
1792
|
+
if (getUser) {
|
|
1793
|
+
const u = await resolveUser(req);
|
|
1794
|
+
if (u instanceof Response) return u;
|
|
1795
|
+
}
|
|
950
1796
|
const raw = await readJson(req);
|
|
951
1797
|
if (raw === null || typeof raw !== "object") return json({ error: "Invalid JSON body" }, 400);
|
|
952
1798
|
const body = raw;
|
|
@@ -961,27 +1807,37 @@ function createVoiceHandlers(config) {
|
|
|
961
1807
|
text = buildPreviewLine(tags, body.characterName ?? "your companion").slice(0, 400);
|
|
962
1808
|
}
|
|
963
1809
|
try {
|
|
964
|
-
const
|
|
1810
|
+
const result = await el.textToSpeech({
|
|
965
1811
|
voiceId,
|
|
966
1812
|
text,
|
|
967
1813
|
...body.modelId ? { modelId: body.modelId } : {},
|
|
968
1814
|
voiceSettings
|
|
969
1815
|
});
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1816
|
+
const headers = {
|
|
1817
|
+
"content-type": result.contentType,
|
|
1818
|
+
"cache-control": "private, max-age=3600",
|
|
1819
|
+
"x-tts-model-used": result.modelId,
|
|
1820
|
+
"x-tts-model-requested": result.requestedModel
|
|
1821
|
+
};
|
|
1822
|
+
if (result.fellBack) {
|
|
1823
|
+
headers.warning = `199 - "model ${result.requestedModel} not available, used ${result.modelId}"`;
|
|
1824
|
+
log("warn", "tts fell back to another model", {
|
|
1825
|
+
requested: result.requestedModel,
|
|
1826
|
+
used: result.modelId,
|
|
1827
|
+
reason: result.fallbackReason
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
return new Response(result.audio, { headers });
|
|
977
1831
|
} catch (e) {
|
|
978
1832
|
return handleElError(e, "TTS failed");
|
|
979
1833
|
}
|
|
980
1834
|
}
|
|
981
1835
|
async function doMusic(req) {
|
|
982
1836
|
if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
|
|
983
|
-
|
|
984
|
-
|
|
1837
|
+
if (getUser) {
|
|
1838
|
+
const u = await resolveUser(req);
|
|
1839
|
+
if (u instanceof Response) return u;
|
|
1840
|
+
}
|
|
985
1841
|
const raw = await readJson(req);
|
|
986
1842
|
const parsed = parseMusicBody(raw);
|
|
987
1843
|
if ("error" in parsed) return json({ error: parsed.error }, 400);
|
|
@@ -997,10 +1853,104 @@ function createVoiceHandlers(config) {
|
|
|
997
1853
|
return handleElError(e, "Music generation failed");
|
|
998
1854
|
}
|
|
999
1855
|
}
|
|
1856
|
+
async function doOp(req, opId) {
|
|
1857
|
+
const id = resolveOpId(req, opId);
|
|
1858
|
+
const op = getVoiceOp(id);
|
|
1859
|
+
if (!op) {
|
|
1860
|
+
return json(
|
|
1861
|
+
{ error: "unknown_op", detail: `Unknown voice op '${id ?? ""}'`, allowed: VOICE_OP_IDS },
|
|
1862
|
+
404
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
if (req.method !== op.method) return json({ error: "Method Not Allowed" }, 405);
|
|
1866
|
+
const parsed = await readVoiceOpBody(op, req);
|
|
1867
|
+
if (parsed instanceof Response) return parsed;
|
|
1868
|
+
const fields = parsed.kind === "none" ? {} : parsed.fields;
|
|
1869
|
+
let query;
|
|
1870
|
+
try {
|
|
1871
|
+
query = new URL(req.url).searchParams;
|
|
1872
|
+
} catch {
|
|
1873
|
+
query = new URLSearchParams();
|
|
1874
|
+
}
|
|
1875
|
+
const pathParams = voiceOpPathParams(op, fields, query);
|
|
1876
|
+
const upstream = voiceOpUpstreamPath(op, pathParams);
|
|
1877
|
+
if (typeof upstream !== "string") {
|
|
1878
|
+
return json({ error: upstream.error, detail: `set it in the body or as ?${op.pathParams?.[0]}=` }, 400);
|
|
1879
|
+
}
|
|
1880
|
+
const forwardedQuery = {};
|
|
1881
|
+
for (const key of op.queryForward ?? []) {
|
|
1882
|
+
const value = query.get(key);
|
|
1883
|
+
if (value) forwardedQuery[key] = value;
|
|
1884
|
+
}
|
|
1885
|
+
if (op.defaultVoiceId && !pathParams.voiceId) {
|
|
1886
|
+
return json(
|
|
1887
|
+
{
|
|
1888
|
+
error: "Missing `voiceId`",
|
|
1889
|
+
detail: `BYO mode has no project default voice \u2014 pass voiceId (or ?voiceId=)`
|
|
1890
|
+
},
|
|
1891
|
+
400
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
if (op.call === "textToSpeech") {
|
|
1896
|
+
const result = await el.textToSpeech({
|
|
1897
|
+
voiceId: pathParams.voiceId ?? String(fields.voiceId ?? ""),
|
|
1898
|
+
text: String(fields.text ?? ""),
|
|
1899
|
+
...typeof fields.modelId === "string" ? { modelId: fields.modelId } : {},
|
|
1900
|
+
...fields.voiceSettings ? { voiceSettings: fields.voiceSettings } : {},
|
|
1901
|
+
...op.accept ? { accept: op.accept } : {}
|
|
1902
|
+
});
|
|
1903
|
+
const headers2 = {
|
|
1904
|
+
"content-type": result.contentType,
|
|
1905
|
+
"cache-control": "private, max-age=3600",
|
|
1906
|
+
"x-tts-model-used": result.modelId,
|
|
1907
|
+
"x-tts-model-requested": result.requestedModel
|
|
1908
|
+
};
|
|
1909
|
+
if (result.fellBack) {
|
|
1910
|
+
headers2.warning = `199 - "model ${result.requestedModel} not available, used ${result.modelId}"`;
|
|
1911
|
+
}
|
|
1912
|
+
return new Response(result.audio, { headers: headers2 });
|
|
1913
|
+
}
|
|
1914
|
+
if (op.call === "composeMusic") {
|
|
1915
|
+
const { audio, contentType } = await el.composeMusic({
|
|
1916
|
+
...typeof fields.prompt === "string" ? { prompt: fields.prompt } : {},
|
|
1917
|
+
...fields.compositionPlan ? { compositionPlan: fields.compositionPlan } : {},
|
|
1918
|
+
...typeof fields.musicLengthMs === "number" ? { musicLengthMs: fields.musicLengthMs } : {},
|
|
1919
|
+
...typeof fields.modelId === "string" ? { modelId: fields.modelId } : {},
|
|
1920
|
+
...typeof fields.forceInstrumental === "boolean" ? { forceInstrumental: fields.forceInstrumental } : {},
|
|
1921
|
+
...typeof fields.outputFormat === "string" ? { outputFormat: fields.outputFormat } : {}
|
|
1922
|
+
});
|
|
1923
|
+
return new Response(audio, {
|
|
1924
|
+
headers: {
|
|
1925
|
+
"content-type": contentType,
|
|
1926
|
+
"cache-control": "private, max-age=3600"
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
const upstreamBody = voiceOpUpstreamBody(op, fields);
|
|
1931
|
+
if (op.defaultModel && upstreamBody.model_id === void 0) {
|
|
1932
|
+
upstreamBody.model_id = op.defaultModel;
|
|
1933
|
+
}
|
|
1934
|
+
const response = await el.request({
|
|
1935
|
+
method: op.method,
|
|
1936
|
+
path: upstream,
|
|
1937
|
+
...parsed.kind === "form" ? { body: parsed.form } : parsed.kind === "json" ? { body: upstreamBody } : {},
|
|
1938
|
+
...Object.keys(forwardedQuery).length ? { query: forwardedQuery } : {},
|
|
1939
|
+
...op.accept ? { accept: op.accept } : {}
|
|
1940
|
+
});
|
|
1941
|
+
const headers = { "content-type": response.contentType };
|
|
1942
|
+
if (voiceOpReturnsAudio(op)) headers["cache-control"] = "private, max-age=3600";
|
|
1943
|
+
const body = response.audio ?? JSON.stringify(response.json ?? {});
|
|
1944
|
+
return new Response(body, { status: response.status, headers });
|
|
1945
|
+
} catch (e) {
|
|
1946
|
+
return handleElError(e, `${op.id} failed`);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1000
1949
|
async function doAgentCreate(req) {
|
|
1001
1950
|
if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
|
|
1002
1951
|
const u = await resolveUser(req);
|
|
1003
1952
|
if (u instanceof Response) return u;
|
|
1953
|
+
if (!companionStore) return missingCompanionStore();
|
|
1004
1954
|
const existing = await companionStore.findByUserId(u.id);
|
|
1005
1955
|
if (existing) return json({ error: "Companion already exists" }, 409);
|
|
1006
1956
|
const raw = await readJson(req);
|
|
@@ -1051,6 +2001,7 @@ function createVoiceHandlers(config) {
|
|
|
1051
2001
|
if (req.method !== "PATCH") return json({ error: "Method Not Allowed" }, 405);
|
|
1052
2002
|
const u = await resolveUser(req);
|
|
1053
2003
|
if (u instanceof Response) return u;
|
|
2004
|
+
if (!companionStore) return missingCompanionStore();
|
|
1054
2005
|
const existing = await companionStore.findByUserId(u.id);
|
|
1055
2006
|
if (!existing) return json({ error: "No companion" }, 404);
|
|
1056
2007
|
const raw = await readJson(req);
|
|
@@ -1105,6 +2056,7 @@ function createVoiceHandlers(config) {
|
|
|
1105
2056
|
if (req.method !== "DELETE") return json({ error: "Method Not Allowed" }, 405);
|
|
1106
2057
|
const u = await resolveUser(req);
|
|
1107
2058
|
if (u instanceof Response) return u;
|
|
2059
|
+
if (!companionStore) return missingCompanionStore();
|
|
1108
2060
|
const existing = await companionStore.findByUserId(u.id);
|
|
1109
2061
|
if (!existing) return json({ ok: true });
|
|
1110
2062
|
if (existing.agentId) await el.deleteAgent(existing.agentId);
|
|
@@ -1134,6 +2086,7 @@ function createVoiceHandlers(config) {
|
|
|
1134
2086
|
delete: doAgentDelete
|
|
1135
2087
|
},
|
|
1136
2088
|
audioTags: doAudioTags,
|
|
2089
|
+
op: doOp,
|
|
1137
2090
|
elevenLabs: el
|
|
1138
2091
|
};
|
|
1139
2092
|
}
|