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:
RainbowBird
2026-06-11 00:23:04 +08:00
parent 5bf6deab89
commit 8e572ad93c
9 changed files with 274 additions and 33 deletions
@@ -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'
}