feat(server): add stepfun tts provider
Signed-off-by: RainbowBird <git@luoling.moe> Commit-Message-Assisted-by: Claude (via Claude Code)
This commit is contained in:
@@ -52,7 +52,7 @@ export const llmModelSchema = object({
|
||||
fallbackTriggers: fallbackTriggersSchema,
|
||||
})
|
||||
|
||||
const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'volcengine'])
|
||||
const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine'])
|
||||
|
||||
export const ttsUpstreamSchema = object({
|
||||
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const AUDIO_MIME_TYPES: Record<string, string> = {
|
||||
flac: 'audio/flac',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg_opus: 'audio/ogg',
|
||||
opus: 'audio/opus',
|
||||
pcm: 'audio/L16',
|
||||
wav: 'audio/wav',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps provider audio format keys to response MIME types.
|
||||
*
|
||||
* Use when:
|
||||
* - A TTS adapter forwards OpenAI-shaped `response_format` / provider
|
||||
* encoding keys through unspeech and needs a gateway fallback MIME type.
|
||||
*
|
||||
* Expects:
|
||||
* - `format` is the exact provider/OpenAI format key.
|
||||
*
|
||||
* Returns:
|
||||
* - A known audio MIME type, or `application/octet-stream` for unknown custom
|
||||
* formats so operators can still experiment through config.
|
||||
*/
|
||||
export function audioMimeFromFormat(format: string): string {
|
||||
return AUDIO_MIME_TYPES[format] ?? 'application/octet-stream'
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Voice } from 'unspeech'
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
input: input.text,
|
||||
voice,
|
||||
responseFormat: format,
|
||||
fallbackContentType: formatToMime(format),
|
||||
fallbackContentType: audioMimeFromFormat(format),
|
||||
providerLabel: 'dashscope-cosyvoice',
|
||||
})
|
||||
},
|
||||
@@ -89,16 +90,3 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps cosyvoice's `format` (`mp3` / `wav` / `pcm`) to a MIME type for the
|
||||
* client. Keeps the router contract symmetric with Azure / Volcengine.
|
||||
*/
|
||||
function formatToMime(format: string): string {
|
||||
switch (format) {
|
||||
case 'mp3': return 'audio/mpeg'
|
||||
case 'wav': return 'audio/wav'
|
||||
case 'pcm': return 'audio/L16'
|
||||
default: return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ describe('getAdapter', () => {
|
||||
expect(adapter.id).toBe('volcengine')
|
||||
})
|
||||
|
||||
it('returns the stepfun adapter by id', () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
expect(adapter.id).toBe('stepfun')
|
||||
})
|
||||
|
||||
it('throws BAD_REQUEST on unknown id with the available list in details', () => {
|
||||
expect(() => getAdapter('unknown-provider')).toThrow(ApiError)
|
||||
try {
|
||||
@@ -34,7 +39,7 @@ describe('getAdapter', () => {
|
||||
expect(apiErr.details).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'unknown-provider',
|
||||
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'volcengine']),
|
||||
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -292,6 +297,133 @@ describe('azureAdapter.send', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stepfunAdapter', () => {
|
||||
it('lists StepFun voices through unspeech provider=stepfun', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{
|
||||
id: 'cixingnansheng',
|
||||
name: '磁性男声',
|
||||
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
|
||||
}],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(voices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'cixingnansheng',
|
||||
name: '磁性男声',
|
||||
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=stepfun')
|
||||
})
|
||||
|
||||
it('posts OpenAI-compatible speech JSON to unspeech with model=stepfun/<model>', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
const result = await adapter.send(
|
||||
{
|
||||
text: '(轻声)你好',
|
||||
voice: 'cixingnansheng',
|
||||
responseFormat: 'mp3',
|
||||
speed: 1.2,
|
||||
extraOptions: {
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
volume: 1.1,
|
||||
sampleRate: 24000,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com/v1/audio/speech',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toMatchObject({
|
||||
'Authorization': 'Bearer step-key',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(body).toEqual({
|
||||
model: 'stepfun/stepaudio-2.5-tts',
|
||||
input: '(轻声)你好',
|
||||
voice: 'cixingnansheng',
|
||||
response_format: 'mp3',
|
||||
speed: 1.2,
|
||||
extra_body: {
|
||||
volume: 1.1,
|
||||
sample_rate: 24000,
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
},
|
||||
})
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
})
|
||||
|
||||
it('passes voice_label through to unspeech for provider-level validation', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
await adapter.send(
|
||||
{
|
||||
text: 'hi',
|
||||
extraOptions: {
|
||||
voice_label: { emotion: '高兴' },
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com/v1/audio/speech',
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
const body = JSON.parse(init.body as string) as Record<string, { voice_label?: unknown }>
|
||||
expect(body.extra_body.voice_label).toEqual({ emotion: '高兴' })
|
||||
})
|
||||
|
||||
it('throws Error with .status when unspeech returns non-2xx', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response('bad key', { status: 401 })) as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi', voice: 'cixingnansheng' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('bad-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com/v1/audio/speech',
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('volcengineAdapter.send', () => {
|
||||
it('posts to unspeech with model=volcengine/<api_resource_id> and app/cluster in extra_body', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { TtsAdapter, TtsAdapterId } from './types'
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
import { azureAdapter } from './azure'
|
||||
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
|
||||
import { stepfunAdapter } from './stepfun'
|
||||
import { volcengineAdapter } from './volcengine'
|
||||
|
||||
const ADAPTERS: Record<TtsAdapterId, TtsAdapter> = {
|
||||
'azure': azureAdapter,
|
||||
'dashscope-cosyvoice': dashscopeCosyvoiceAdapter,
|
||||
'stepfun': stepfunAdapter,
|
||||
'volcengine': volcengineAdapter,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
const STEPFUN_DEFAULT_MODEL = 'stepaudio-2.5-tts'
|
||||
const STEPFUN_DEFAULT_FORMAT = 'mp3'
|
||||
const STEPFUN_DEFAULT_VOICE = 'cixingnansheng'
|
||||
|
||||
/**
|
||||
* StepFun TTS adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing hosted speech synthesis to StepFun through unspeech's
|
||||
* OpenAI-compatible `stepfun/*` backend.
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.unspeechBaseURL` points at an unspeech deployment that includes the
|
||||
* StepFun backend.
|
||||
* - `ctx.keyPlaintext` is the StepFun API key.
|
||||
* - `ctx.adapterParams.model` optionally selects `stepaudio-2.5-tts`,
|
||||
* `step-tts-2`, or `step-tts-mini`.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the upstream audio body and content type.
|
||||
*/
|
||||
export const stepfunAdapter: TtsAdapter = {
|
||||
id: 'stepfun',
|
||||
|
||||
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
|
||||
const model = typeof ctx.adapterParams.model === 'string' && ctx.adapterParams.model
|
||||
? ctx.adapterParams.model
|
||||
: STEPFUN_DEFAULT_MODEL
|
||||
const voice = input.voice ?? (typeof ctx.adapterParams.defaultVoice === 'string' && ctx.adapterParams.defaultVoice
|
||||
? ctx.adapterParams.defaultVoice
|
||||
: STEPFUN_DEFAULT_VOICE)
|
||||
const responseFormat = input.responseFormat ?? (typeof ctx.adapterParams.responseFormat === 'string' && ctx.adapterParams.responseFormat
|
||||
? ctx.adapterParams.responseFormat
|
||||
: STEPFUN_DEFAULT_FORMAT)
|
||||
|
||||
return sendSpeechViaUnSpeech({
|
||||
ctx,
|
||||
model: `stepfun/${model}`,
|
||||
input: input.text,
|
||||
voice,
|
||||
speed: input.speed,
|
||||
responseFormat,
|
||||
extraBody: buildExtraBody(input, ctx),
|
||||
fallbackContentType: audioMimeFromFormat(responseFormat),
|
||||
providerLabel: 'stepfun',
|
||||
})
|
||||
},
|
||||
|
||||
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
|
||||
return listVoicesViaUnSpeech({
|
||||
ctx,
|
||||
query: 'provider=stepfun',
|
||||
providerLabel: 'stepfun',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function buildExtraBody(input: TtsInput, ctx: TtsAdapterContext): Record<string, unknown> {
|
||||
const extraOptions = input.extraOptions ?? {}
|
||||
const body: Record<string, unknown> = {}
|
||||
|
||||
if (typeof extraOptions.volume === 'number' && Number.isFinite(extraOptions.volume))
|
||||
body.volume = extraOptions.volume
|
||||
else if (typeof ctx.adapterParams.volume === 'number' && Number.isFinite(ctx.adapterParams.volume))
|
||||
body.volume = ctx.adapterParams.volume
|
||||
|
||||
if (typeof extraOptions.sample_rate === 'number' && Number.isFinite(extraOptions.sample_rate))
|
||||
body.sample_rate = extraOptions.sample_rate
|
||||
else if (typeof extraOptions.sampleRate === 'number' && Number.isFinite(extraOptions.sampleRate))
|
||||
body.sample_rate = extraOptions.sampleRate
|
||||
else if (typeof ctx.adapterParams.sampleRate === 'number' && Number.isFinite(ctx.adapterParams.sampleRate))
|
||||
body.sample_rate = ctx.adapterParams.sampleRate
|
||||
|
||||
if (isPlainObject(extraOptions.pronunciation_map))
|
||||
body.pronunciation_map = extraOptions.pronunciation_map
|
||||
else if (isPlainObject(extraOptions.pronunciationMap))
|
||||
body.pronunciation_map = extraOptions.pronunciationMap
|
||||
|
||||
if (typeof extraOptions.markdown_filter === 'boolean')
|
||||
body.markdown_filter = extraOptions.markdown_filter
|
||||
else if (typeof extraOptions.markdownFilter === 'boolean')
|
||||
body.markdown_filter = extraOptions.markdownFilter
|
||||
|
||||
if (typeof extraOptions.instruction === 'string' && extraOptions.instruction)
|
||||
body.instruction = extraOptions.instruction
|
||||
else if (typeof ctx.adapterParams.instruction === 'string' && ctx.adapterParams.instruction)
|
||||
body.instruction = ctx.adapterParams.instruction
|
||||
|
||||
if (isPlainObject(extraOptions.voice_label))
|
||||
body.voice_label = extraOptions.voice_label
|
||||
else if (isPlainObject(extraOptions.voiceLabel))
|
||||
body.voice_label = extraOptions.voiceLabel
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export interface TtsResult {
|
||||
* `./index.ts` — the union is intentionally tight so unknown ids fail at the
|
||||
* type level (router config validation handles runtime).
|
||||
*/
|
||||
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'volcengine'
|
||||
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcengine'
|
||||
|
||||
/**
|
||||
* Per-call context for {@link TtsAdapter.getVoiceCatalog}.
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SendSpeechOptions {
|
||||
model: string
|
||||
input: string
|
||||
voice: string
|
||||
speed?: number
|
||||
responseFormat: string
|
||||
extraBody?: Record<string, unknown>
|
||||
fallbackContentType: string
|
||||
@@ -41,6 +42,7 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
|
||||
model,
|
||||
providerLabel,
|
||||
responseFormat,
|
||||
speed,
|
||||
voice,
|
||||
} = options
|
||||
|
||||
@@ -52,6 +54,7 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
|
||||
input,
|
||||
model,
|
||||
responseFormat,
|
||||
speed,
|
||||
voice,
|
||||
abortSignal: ctx.abortSignal,
|
||||
extraBody,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalo
|
||||
|
||||
import { createBadRequestError, createInternalError } from '../../../utils/error'
|
||||
import { nanoid } from '../../../utils/id'
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ export const volcengineAdapter: TtsAdapter = {
|
||||
audio: { speed_ratio: speed },
|
||||
request: { reqid: nanoid(), operation: 'query' },
|
||||
},
|
||||
fallbackContentType: encodingToMime(encoding),
|
||||
fallbackContentType: audioMimeFromFormat(encoding),
|
||||
providerLabel: 'volcengine',
|
||||
})
|
||||
},
|
||||
@@ -111,18 +112,3 @@ export const volcengineAdapter: TtsAdapter = {
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Volcengine's `encoding` field to a MIME type for the gateway response.
|
||||
*/
|
||||
function encodingToMime(encoding: string): string {
|
||||
if (encoding === 'mp3')
|
||||
return 'audio/mpeg'
|
||||
if (encoding === 'wav')
|
||||
return 'audio/wav'
|
||||
if (encoding === 'pcm')
|
||||
return 'audio/L16'
|
||||
if (encoding === 'ogg_opus')
|
||||
return 'audio/ogg'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user