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