feat(server/tts): upgrade dashscope-cosyvoice adapter to v2 two-step REST
DashScope dropped cosyvoice-v1 from its REST-supported model list. v2 (and v3+) speak a different shape: voice / format / sample_rate live under `input`, not `parameters`; non-streaming responses return `output.audio.url` (signed OSS URL) instead of inline `output.audio.data` base64. The previous adapter sent v1-shaped bodies to a bare `https://dashscope-intl.aliyuncs.com/api/v1` baseURL and parsed `audio.data`, which 404'd before the migration and would 200-with-no- audio after — both invisible regressions for the gateway. Adapter changes: - Rewrite request body to v2 schema (voice/format under input). - Add follow-up GET against `output.audio.url`; stream into ArrayBuffer with a 25 MB hard cap and explicit drain-tracking finally, so a misbehaving URL cannot exhaust memory and a half-read body cannot hang a connection. - Re-document baseURL contract: adapters do NOT append path; ops must configure the FULL endpoint URL (root cause of the original 404 storm). DEFAULT_COSYVOICE_MODEL bumped to `cosyvoice-v2`, default voice to `longxiaochun_v2`. Voice catalog: regenerated with 19 representative cosyvoice-v2 voices (assistant / customer-service / child / en-US / en-GB / ja-JP / ko-KR) so the frontend voice picker is no longer a 2-entry stub. Full catalog (100+) remains on the Alibaba docs page — we'll sync on demand rather than scrape. Seed script: `--dashscope-region intl|cn` (default `intl`), `--dashscope-upstream-model cosyvoice-v2`, baseURL now resolves to `https://<host>/api/v1/services/audio/tts/SpeechSynthesizer` so a mis-typed region or path cannot reintroduce the 404. Tests: new dashscope-cosyvoice.test.ts covers v2 body shape (asserts `parameters` absent — regression), audio.url follow-up fetch, 401 propagation with `.status`, empty-envelope falling back into the router's recoverable-error path, and catalog freshness (no leftover v1 ids). Verified locally against the staging DashScope key: 200 + playable mp3 end to end.
This commit is contained in:
@@ -49,6 +49,10 @@ interface Args {
|
||||
azureRegion: string
|
||||
azureTtsModel: string
|
||||
dashscopeTtsModel: string
|
||||
/** `intl` → dashscope-intl.aliyuncs.com (Singapore); `cn` → dashscope.aliyuncs.com (Beijing). */
|
||||
dashscopeRegion: string
|
||||
/** Concrete cosyvoice variant the adapter calls upstream. Independent from `dashscopeTtsModel` (the gateway-facing alias). */
|
||||
dashscopeUpstreamModel: string
|
||||
defaultTtsModel: string | undefined
|
||||
}
|
||||
|
||||
@@ -76,7 +80,9 @@ function parseArgs(argv: string[]): Args {
|
||||
defaultChatModel: values['default-chat-model'] ?? 'chat-default',
|
||||
azureRegion: values['azure-region'] ?? 'eastasia',
|
||||
azureTtsModel: values['azure-tts-model'] ?? 'microsoft/v1',
|
||||
dashscopeTtsModel: values['dashscope-tts-model'] ?? 'alibaba/cosyvoice-v1',
|
||||
dashscopeTtsModel: values['dashscope-tts-model'] ?? 'alibaba/cosyvoice-v2',
|
||||
dashscopeRegion: values['dashscope-region'] ?? 'intl',
|
||||
dashscopeUpstreamModel: values['dashscope-upstream-model'] ?? 'cosyvoice-v2',
|
||||
defaultTtsModel: values['default-tts-model'],
|
||||
}
|
||||
}
|
||||
@@ -132,14 +138,21 @@ function buildDashscope(args: Args, plaintext: string, envelope: EnvelopeCrypto)
|
||||
modelName: args.dashscopeTtsModel,
|
||||
keyEntryId,
|
||||
})
|
||||
// dashscope-cosyvoice adapter expects the FULL non-streaming endpoint path —
|
||||
// it does not append `/services/audio/tts/SpeechSynthesizer` itself. A bare
|
||||
// `/api/v1` baseURL was the root cause of the 404 storm during the v1→v2
|
||||
// migration; do not regress here.
|
||||
const host = args.dashscopeRegion === 'cn'
|
||||
? 'dashscope.aliyuncs.com'
|
||||
: 'dashscope-intl.aliyuncs.com'
|
||||
return {
|
||||
ttsModelName: args.dashscopeTtsModel,
|
||||
ttsModel: {
|
||||
provider: 'dashscope-cosyvoice',
|
||||
upstreams: [{
|
||||
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1',
|
||||
baseURL: `https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`,
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
adapterParams: {},
|
||||
adapterParams: { model: args.dashscopeUpstreamModel },
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
|
||||
|
||||
const FULL_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer'
|
||||
const AUDIO_URL = 'https://dashscope-internal.aliyuncs.com/audio/abc.mp3'
|
||||
|
||||
function jsonResponse(body: object, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function binaryResponse(bytes: Uint8Array, status = 200) {
|
||||
return new Response(bytes, {
|
||||
status,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('dashscopeCosyvoiceAdapter', () => {
|
||||
it('sends v2-shaped body: voice / format under input, not parameters (regression — old shape returned no audio.data)', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ output: { audio: { url: AUDIO_URL } } }))
|
||||
.mockResolvedValueOnce(binaryResponse(new Uint8Array([1, 2, 3, 4])))
|
||||
|
||||
await dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi there', voice: 'longxiaochun_v2', responseFormat: 'mp3' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: FULL_ENDPOINT,
|
||||
adapterParams: { model: 'cosyvoice-v2' },
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
const [synthesizeUrl, synthesizeInit] = fetchImpl.mock.calls[0]
|
||||
expect(synthesizeUrl).toBe(FULL_ENDPOINT)
|
||||
expect(synthesizeInit.method).toBe('POST')
|
||||
|
||||
const body = JSON.parse(synthesizeInit.body as string)
|
||||
expect(body).toEqual({
|
||||
model: 'cosyvoice-v2',
|
||||
input: {
|
||||
text: 'hi there',
|
||||
voice: 'longxiaochun_v2',
|
||||
format: 'mp3',
|
||||
},
|
||||
})
|
||||
// Critical: voice / format must NOT leak into a top-level `parameters` block.
|
||||
expect(body.parameters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('follows output.audio.url to fetch the actual audio bytes and returns them as ArrayBuffer (regression — v1 parsed base64 from output.audio.data, v2 returns a URL instead)', async () => {
|
||||
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00, 0x00]) // ID3v2 mp3 header
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ output: { audio: { url: AUDIO_URL, data: '' } } }))
|
||||
.mockResolvedValueOnce(binaryResponse(audioBytes))
|
||||
|
||||
const result = await dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi', responseFormat: 'mp3' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: FULL_ENDPOINT,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)
|
||||
|
||||
const [audioFetchUrl, audioFetchInit] = fetchImpl.mock.calls[1]
|
||||
expect(audioFetchUrl).toBe(AUDIO_URL)
|
||||
expect(audioFetchInit.method).toBe('GET')
|
||||
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
const out = new Uint8Array(result.body as ArrayBuffer)
|
||||
expect(Array.from(out)).toEqual(Array.from(audioBytes))
|
||||
})
|
||||
|
||||
it('throws Error with .status when synthesis endpoint returns non-2xx (router maps to fallback chain)', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 'InvalidApiKey', message: 'bad key' }, 401))
|
||||
|
||||
await expect(
|
||||
dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: FULL_ENDPOINT,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ status: 401, message: expect.stringContaining('401') })
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws Error with .status when output envelope contains no audio.url (treat as recoverable upstream error)', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// CosyVoice v2 non-streaming returns audio.url; if upstream returns 200
|
||||
// with an empty envelope (rare — policy reject / region edge case), the
|
||||
// v1 adapter silently returned "no audio data" while modeled status was
|
||||
// 200. Router can't decide whether to fall back without a status. We
|
||||
// attach the response status to the error so the router treats it as a
|
||||
// recoverable upstream failure and walks to the next key.
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ output: {} }))
|
||||
|
||||
await expect(
|
||||
dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: FULL_ENDPOINT,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ status: 200, message: expect.stringContaining('no audio.url') })
|
||||
})
|
||||
|
||||
it('uses cosyvoice-v2 + longxiaochun_v2 as defaults when caller omits model / voice (regression — v1 defaults broke against the v2 endpoint)', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ output: { audio: { url: AUDIO_URL } } }))
|
||||
.mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
|
||||
|
||||
await dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: FULL_ENDPOINT,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)
|
||||
|
||||
const body = JSON.parse(fetchImpl.mock.calls[0][1].body as string)
|
||||
expect(body.model).toBe('cosyvoice-v2')
|
||||
expect(body.input.voice).toBe('longxiaochun_v2')
|
||||
expect(body.input.format).toBe('mp3')
|
||||
})
|
||||
|
||||
it('voice catalog contains v2-suffixed ids and is non-empty', () => {
|
||||
const catalog = dashscopeCosyvoiceAdapter.getVoiceCatalog()
|
||||
expect(catalog.length).toBeGreaterThan(0)
|
||||
expect(catalog.some(v => v.id === 'longxiaochun_v2')).toBe(true)
|
||||
// No bare v1 ids should survive the migration.
|
||||
expect(catalog.find(v => v.id === 'longxiaochun')).toBeUndefined()
|
||||
expect(catalog.find(v => v.id === 'longxiaobai')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -11,13 +11,14 @@ import cosyvoiceVoices from './voices/dashscope-cosyvoice.json' with { type: 'js
|
||||
import { createInternalError } from '../../utils/error'
|
||||
|
||||
/**
|
||||
* Default DashScope cosyvoice voice id. `longxiaochun` is the most commonly
|
||||
* referenced general-purpose Chinese voice in Alibaba's docs.
|
||||
* Default DashScope cosyvoice voice id. v2 voice ids carry an explicit `_v2`
|
||||
* suffix; `longxiaochun_v2` is the general-purpose Chinese assistant voice
|
||||
* called out in Alibaba's voice list.
|
||||
*
|
||||
* NOTICE:
|
||||
* Hardcoded near use; promote when ops want per-tenant defaults.
|
||||
*/
|
||||
const DEFAULT_COSYVOICE_VOICE = 'longxiaochun'
|
||||
const DEFAULT_COSYVOICE_VOICE = 'longxiaochun_v2'
|
||||
|
||||
/**
|
||||
* Default cosyvoice audio format. Mirrors the OpenAI `mp3` default expected by
|
||||
@@ -26,27 +27,46 @@ const DEFAULT_COSYVOICE_VOICE = 'longxiaochun'
|
||||
const DEFAULT_COSYVOICE_FORMAT = 'mp3'
|
||||
|
||||
/**
|
||||
* Default cosyvoice model id targeted by v1. Adapters can be retargeted via
|
||||
* `adapterParams.model` if ops want to A/B between cosyvoice variants without
|
||||
* deploying a code change.
|
||||
* Default cosyvoice model id. v1 was dropped from the official "REST-supported
|
||||
* models" list (the official list now starts at v2 and runs through v3.5);
|
||||
* v2 is the most conservative current default and shares a request body shape
|
||||
* with v3/v3.5 so ops can retarget via `adapterParams.model` without code.
|
||||
* NOTICE:
|
||||
* If you bump this past v2, verify the chosen `DEFAULT_COSYVOICE_VOICE` exists
|
||||
* for that model — voice catalogs differ between v2 (`*_v2`) and v3 (`*_v3`).
|
||||
*/
|
||||
const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v1'
|
||||
const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2'
|
||||
|
||||
/**
|
||||
* Hard cap on the audio bytes we will pull from the `output.audio.url`
|
||||
* follow-up fetch. CosyVoice non-streaming responses for normal TTS prompts
|
||||
* stay well under this; the limit exists so a misbehaving / hijacked URL
|
||||
* cannot exhaust memory on the gateway instance.
|
||||
*/
|
||||
const MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* DashScope cosyvoice non-streaming REST adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing a hosted TTS request to Alibaba DashScope's cosyvoice family of
|
||||
* models (Chinese + English speech synthesis).
|
||||
* - Routing a hosted TTS request to Alibaba DashScope's cosyvoice v2 / v3
|
||||
* family of models (Chinese + English + selected multilingual voices).
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.baseURL` points at the DashScope multimodal-generation endpoint, e.g.
|
||||
* `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`.
|
||||
* - `ctx.baseURL` is the **full** non-streaming endpoint, e.g.
|
||||
* `https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer`
|
||||
* (or `dashscope-intl.aliyuncs.com` for the Singapore region). The adapter
|
||||
* does not append a path — pointing at a bare `/api/v1` will 404.
|
||||
* - `ctx.keyPlaintext` is the DashScope API key (sent as `Bearer ...`).
|
||||
* - `ctx.adapterParams.model` (optional) names the cosyvoice variant; defaults
|
||||
* to {@link DEFAULT_COSYVOICE_MODEL}.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. Body is
|
||||
* decoded from the upstream JSON's `output.audio.data` base64 payload.
|
||||
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. The non-
|
||||
* streaming endpoint returns a JSON envelope whose `output.audio.url` is
|
||||
* a short-lived signed URL; this adapter performs the follow-up GET and
|
||||
* surfaces the final bytes so router callers get the same single-shot
|
||||
* contract as the Azure / Volcengine paths.
|
||||
*/
|
||||
export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
id: 'dashscope-cosyvoice',
|
||||
@@ -58,16 +78,17 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
const voice = input.voice ?? DEFAULT_COSYVOICE_VOICE
|
||||
const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT
|
||||
|
||||
// DashScope multimodal-generation body: `input.text` for the prompt and
|
||||
// `parameters` for synthesis options. cosyvoice v1 accepts a `rate`
|
||||
// multiplier (defaults to 1.0 server-side when omitted).
|
||||
// v2 / v3 request shape: voice / format / sample_rate live under `input`
|
||||
// (NOT `parameters`, which was the v1 multimodal-generation schema). Speed
|
||||
// is currently dropped on v2 non-streaming — there is no documented field
|
||||
// for it on this endpoint; SSML rate is the supported substitute on
|
||||
// SSML-enabled voices.
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
input: { text: input.text },
|
||||
parameters: {
|
||||
input: {
|
||||
text: input.text,
|
||||
voice,
|
||||
format,
|
||||
...(input.speed != null ? { rate: input.speed } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,9 +117,6 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
throw err
|
||||
}
|
||||
|
||||
// cosyvoice v1 synthesize mode returns JSON. The audio payload sits under
|
||||
// `output.audio.data` as a base64 string. We decode to ArrayBuffer here so
|
||||
// the router/handler can re-stream identically to the Azure path.
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await response.json()
|
||||
@@ -107,19 +125,27 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
throw createInternalError(`dashscope-cosyvoice tts response parse failed: ${errorMessageFrom(error) ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
const audioData = extractCosyvoiceAudioBase64(payload)
|
||||
if (!audioData) {
|
||||
const err = new Error('dashscope-cosyvoice tts upstream returned no audio data') as Error & { status?: number }
|
||||
const audioUrl = extractCosyvoiceAudioUrl(payload)
|
||||
if (!audioUrl) {
|
||||
// Could be a request-mode mismatch (SSE was enabled, response shape
|
||||
// changed) or an upstream-policy reject that returned 200 with an empty
|
||||
// envelope. Treat as a recoverable upstream error so the router can
|
||||
// try the next key / upstream.
|
||||
const err = new Error(`dashscope-cosyvoice tts upstream returned no audio.url (envelope: ${stringifyEnvelope(payload)})`) as Error & { status?: number }
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
|
||||
const buf = Buffer.from(audioData, 'base64')
|
||||
// Slice avoids returning a view over the larger pooled Node buffer.
|
||||
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
const contentType = formatToMime(format)
|
||||
let audioBytes: ArrayBuffer
|
||||
try {
|
||||
audioBytes = await fetchAudioBytes(ctx.fetchImpl, audioUrl, ctx.abortSignal)
|
||||
}
|
||||
catch (error) {
|
||||
throw createInternalError(`dashscope-cosyvoice tts audio download failed: ${errorMessageFrom(error) ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
return { contentType, body: arrayBuffer }
|
||||
const contentType = formatToMime(format)
|
||||
return { contentType, body: audioBytes }
|
||||
},
|
||||
|
||||
getVoiceCatalog() {
|
||||
@@ -128,33 +154,95 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the base64 audio string out of a cosyvoice JSON response. Returns
|
||||
* `null` if the response shape doesn't match (e.g. error envelope) so the
|
||||
* caller can surface a clear upstream error.
|
||||
* Pulls the `output.audio.url` short-lived signed URL out of a cosyvoice
|
||||
* non-streaming JSON envelope. Returns `null` if the response shape doesn't
|
||||
* match (e.g. error envelope, SSE leak, or v1-style `audio.data` payload),
|
||||
* so the caller can surface a clear upstream error.
|
||||
*/
|
||||
function extractCosyvoiceAudioBase64(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object')
|
||||
function extractCosyvoiceAudioUrl(payload: unknown): string | null {
|
||||
if (payload == null || typeof payload !== 'object')
|
||||
return null
|
||||
const output = (payload as Record<string, unknown>).output
|
||||
if (!output || typeof output !== 'object')
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (output == null || typeof output !== 'object')
|
||||
return null
|
||||
const audio = (output as Record<string, unknown>).audio
|
||||
if (!audio || typeof audio !== 'object')
|
||||
const audio = (output as { audio?: unknown }).audio
|
||||
if (audio == null || typeof audio !== 'object')
|
||||
return null
|
||||
const data = (audio as Record<string, unknown>).data
|
||||
return typeof data === 'string' ? data : null
|
||||
const url = (audio as { url?: unknown }).url
|
||||
if (typeof url !== 'string' || url.length === 0)
|
||||
return null
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify just enough of the upstream JSON to make the "no audio.url" error
|
||||
* actionable, without leaking secrets like API keys. Keeps the snippet small
|
||||
* so it fits inside the router's bodySnippet propagation path.
|
||||
*/
|
||||
function stringifyEnvelope(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload).slice(0, 256)
|
||||
}
|
||||
catch {
|
||||
return '<unserializable>'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-up GET against the short-lived signed URL the cosyvoice endpoint
|
||||
* returns. Streamed into an ArrayBuffer with a hard size cap so a misbehaving
|
||||
* URL cannot exhaust memory on the gateway.
|
||||
*/
|
||||
async function fetchAudioBytes(fetchImpl: typeof fetch, url: string, abortSignal: AbortSignal | undefined): Promise<ArrayBuffer> {
|
||||
const audioResp = await fetchImpl(url, { method: 'GET', signal: abortSignal })
|
||||
if (!audioResp.ok) {
|
||||
const err = new Error(`audio.url responded ${audioResp.status}`) as Error & { status?: number }
|
||||
err.status = audioResp.status
|
||||
throw err
|
||||
}
|
||||
|
||||
if (audioResp.body == null) {
|
||||
throw new Error('audio.url response had no body')
|
||||
}
|
||||
|
||||
const reader = audioResp.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
let drained = false
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
drained = true
|
||||
break
|
||||
}
|
||||
total += value.length
|
||||
if (total > MAX_AUDIO_BYTES)
|
||||
throw new Error(`audio payload exceeded ${MAX_AUDIO_BYTES} bytes`)
|
||||
chunks.push(value)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
// Cancel only if we exited early; double-cancel after a clean drain is
|
||||
// a no-op in spec but `reader.closed` is a Promise (always truthy), so
|
||||
// we track the drain explicitly instead of testing `closed`.
|
||||
if (!drained)
|
||||
reader.cancel().catch(() => {})
|
||||
}
|
||||
|
||||
const buf = Buffer.concat(chunks.map(c => Buffer.from(c)))
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps cosyvoice's `format` (`mp3` / `wav` / `pcm`) to a MIME type for the
|
||||
* gateway response.
|
||||
* client. Keeps the router contract symmetric with Azure / Volcengine.
|
||||
*/
|
||||
function formatToMime(format: string): string {
|
||||
if (format === 'mp3')
|
||||
return 'audio/mpeg'
|
||||
if (format === 'wav')
|
||||
return 'audio/wav'
|
||||
if (format === 'pcm')
|
||||
return 'audio/L16'
|
||||
return 'application/octet-stream'
|
||||
switch (format) {
|
||||
case 'mp3': return 'audio/mpeg'
|
||||
case 'wav': return 'audio/wav'
|
||||
case 'pcm': return 'audio/L16'
|
||||
default: return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,265 @@
|
||||
[
|
||||
{
|
||||
"id": "longxiaochun",
|
||||
"name": "Longxiaochun",
|
||||
"description": "DashScope CosyVoice default female voice",
|
||||
"compatible_models": ["cosyvoice-v1"],
|
||||
"id": "longxiaochun_v2",
|
||||
"name": "Longxiaochun (龙小淳)",
|
||||
"description": "Knowing and energetic female assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural" },
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" }
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["chinese", "neural"]
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longxiaobai",
|
||||
"name": "Longxiaobai",
|
||||
"description": "DashScope CosyVoice male voice",
|
||||
"compatible_models": ["cosyvoice-v1"],
|
||||
"id": "longxiaoxia_v2",
|
||||
"name": "Longxiaoxia (龙小夏)",
|
||||
"description": "Calm authoritative female assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural" },
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" }
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["chinese", "neural"]
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longyumi_v2",
|
||||
"name": "Yumi",
|
||||
"description": "Composed young female assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longanli",
|
||||
"name": "Longanli (龙安莉)",
|
||||
"description": "Crisp confident female assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longanlang",
|
||||
"name": "Longanlang (龙安朗)",
|
||||
"description": "Cool and clean male assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longanwen",
|
||||
"name": "Longanwen (龙安温)",
|
||||
"description": "Elegant and graceful female assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longanyun",
|
||||
"name": "Longanyun (龙安昂)",
|
||||
"description": "Warm at-home male assistant voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "assistant" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["assistant", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longyingmu",
|
||||
"name": "Longyingmu (龙应沐)",
|
||||
"description": "Polished service representative female voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "customer_service" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["customer-service", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longyingtian",
|
||||
"name": "Longyingtian (龙应甜)",
|
||||
"description": "Warm sweet service representative female voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "customer_service" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["customer-service", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longhuhu",
|
||||
"name": "Longhuhu (龙呼呼)",
|
||||
"description": "Bright young girl voice for child-facing content",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "child" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["child", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "longniuniu",
|
||||
"name": "Longniuniu (龙牛牛)",
|
||||
"description": "Sunny young boy voice for child-facing content",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "child" },
|
||||
"languages": [
|
||||
{ "code": "zh-CN", "title": "Chinese (Simplified)" },
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["child", "chinese", "english", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongabby_v2",
|
||||
"name": "Abby",
|
||||
"description": "American English female voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["english", "us", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongdavid_v2",
|
||||
"name": "David",
|
||||
"description": "American English male voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "en-US", "title": "English (United States)" }
|
||||
],
|
||||
"tags": ["english", "us", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongbrian_v2",
|
||||
"name": "Brian",
|
||||
"description": "British English male voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "en-GB", "title": "English (United Kingdom)" }
|
||||
],
|
||||
"tags": ["english", "uk", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongeva_v2",
|
||||
"name": "Eva",
|
||||
"description": "British English female voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "en-GB", "title": "English (United Kingdom)" }
|
||||
],
|
||||
"tags": ["english", "uk", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongyuuna_v2",
|
||||
"name": "Yuuna",
|
||||
"description": "Genki Japanese female voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Female", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "ja-JP", "title": "Japanese (Japan)" }
|
||||
],
|
||||
"tags": ["japanese", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongyuuma_v2",
|
||||
"name": "Yuuma",
|
||||
"description": "Steady Japanese male voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "ja-JP", "title": "Japanese (Japan)" }
|
||||
],
|
||||
"tags": ["japanese", "neural"]
|
||||
},
|
||||
{
|
||||
"id": "loongjihun_v2",
|
||||
"name": "Jihun",
|
||||
"description": "Bright Korean male voice",
|
||||
"compatible_models": ["cosyvoice-v2"],
|
||||
"formats": [
|
||||
{ "name": "mp3", "format_code": "mp3", "mime_type": "audio/mpeg", "extension": "mp3", "bitrate": 64, "sample_rate": 22050 }
|
||||
],
|
||||
"labels": { "gender": "Male", "type": "neural", "scenario": "narration" },
|
||||
"languages": [
|
||||
{ "code": "ko-KR", "title": "Korean (Korea)" }
|
||||
],
|
||||
"tags": ["korean", "neural"]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user