Skip to content

Commit b2e370c

Browse files
committed
feat(api-server): 接入 StepFun 流式 TTS
1 parent ec37f4a commit b2e370c

7 files changed

Lines changed: 696 additions & 44 deletions

File tree

server/apps/api/src/routes/admin/config/router/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Hono } from 'hono'
55
import {
66
array,
77
boolean,
8+
check,
89
literal,
910
maxLength,
11+
minLength,
1012
nonEmpty,
1113
object,
1214
optional,
@@ -104,6 +106,31 @@ const StepfunSliceSchema = object({
104106
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
105107
})
106108

109+
const STEPFUN_STREAMING_MODEL_ID = /^stepfun\/(?:stepaudio-2\.5-tts|step-tts-2|step-tts-mini)$/
110+
111+
const StepfunStreamingSliceSchema = pipe(object({
112+
kind: literal('stepfun-streaming'),
113+
enabled: boolean(),
114+
upstreamURL: pipe(string(), regex(/^wss?:\/\/\S+$/, 'upstreamURL must start with ws:// or wss://'), maxLength(500)),
115+
models: pipe(array(object({
116+
id: pipe(string(), regex(STEPFUN_STREAMING_MODEL_ID, 'models[].id must be a supported StepFun streaming model'), maxLength(200)),
117+
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
118+
description: optional(pipe(string(), nonEmpty(), maxLength(500))),
119+
})), minLength(1, 'models must not be empty')),
120+
defaultModel: pipe(string(), regex(STEPFUN_STREAMING_MODEL_ID, 'defaultModel must be a supported StepFun streaming model'), maxLength(200)),
121+
voices: pipe(array(object({
122+
id: pipe(string(), nonEmpty('voices[].id is required'), maxLength(200)),
123+
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
124+
description: optional(pipe(string(), nonEmpty(), maxLength(500))),
125+
labels: optional(record(string(), string())),
126+
languages: optional(array(object({ code: pipe(string(), nonEmpty()), title: pipe(string(), nonEmpty()) }))),
127+
})), minLength(1, 'voices must not be empty')),
128+
instruction: optional(pipe(string(), nonEmpty(), maxLength(200))),
129+
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
130+
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
131+
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
132+
}), check(config => new Set(config.models.map(model => model.id)).size === config.models.length, 'models[].id must be unique'), check(config => config.models.some(model => model.id === config.defaultModel), 'defaultModel must be present in models'))
133+
107134
const AliyunNlsAsrSliceSchema = object({
108135
kind: literal('aliyun-nls-asr'),
109136
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
@@ -162,6 +189,7 @@ const SliceSchema = variant('kind', [
162189
AzureSliceSchema,
163190
DashscopeSliceSchema,
164191
StepfunSliceSchema,
192+
StepfunStreamingSliceSchema,
165193
AliyunNlsAsrSliceSchema,
166194
UnspeechSliceSchema,
167195
])

server/apps/api/src/routes/audio-speech-ws/route.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface MockUpstream {
2828
async function startMockUpstream(
2929
scriptedResponses: MockUpstream['scriptedResponses'],
3030
voices: Array<{ id: string, name?: string }> = [{ id: 'mock', name: 'Mock Voice' }],
31+
protocol: 'unspeech' | 'stepfun' = 'unspeech',
3132
): Promise<MockUpstream> {
3233
const receivedFrames: MockUpstream['receivedFrames'] = []
3334
let observedAuth: string | undefined
@@ -45,6 +46,12 @@ async function startMockUpstream(
4546

4647
wss.on('connection', (ws, req) => {
4748
observedAuth = req.headers.authorization
49+
if (protocol === 'stepfun') {
50+
ws.send(JSON.stringify({
51+
type: 'tts.connection.done',
52+
data: { session_id: 'stepfun-session' },
53+
}))
54+
}
4855
let replayed = false
4956
ws.on('message', async (data, isBinary) => {
5057
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
@@ -63,11 +70,23 @@ async function startMockUpstream(
6370
return
6471

6572
let triggerReplay = false
73+
if (protocol === 'stepfun') {
74+
if (!isBinary) {
75+
const event = JSON.parse(decoded as string) as { type?: string }
76+
if (event.type === 'tts.create') {
77+
ws.send(JSON.stringify({ type: 'tts.response.created', data: { session_id: 'stepfun-session' } }))
78+
return
79+
}
80+
if (event.type === 'tts.text.done')
81+
triggerReplay = true
82+
}
83+
}
84+
6685
if (isBinary) {
6786
// Streaming protocol's only legal client→server binary frames
6887
// would be raw audio (we never send any in tests).
6988
}
70-
else {
89+
else if (protocol === 'unspeech') {
7190
try {
7291
const ev = JSON.parse(decoded as string) as { event?: string }
7392
if (ev.event === 'finish' || ev.event === 'cancel')
@@ -165,6 +184,13 @@ function makeFakeDeps(overrides: {
165184
fluxBalance: number
166185
decryptedKey?: string
167186
streamingModels?: Array<{ id: string, name?: string, description?: string }>
187+
stepfunStreaming?: {
188+
enabled: boolean
189+
baseURL: string
190+
models: Array<{ id: string, name?: string, description?: string }>
191+
defaultModel: string
192+
voices: Array<{ id: string, name?: string }>
193+
}
168194
}) {
169195
const ttsMeter = {
170196
assertCanAfford: vi.fn(async (_userId: string, _newUnits: number, currentBalance: number) => {
@@ -205,6 +231,16 @@ function makeFakeDeps(overrides: {
205231
},
206232
}
207233
}
234+
if (key === 'STEPFUN_STREAMING_TTS_UPSTREAM') {
235+
const config = overrides.stepfunStreaming
236+
return config
237+
? {
238+
...config,
239+
keys: [{ id: 'test-key-1', ciphertext: 'ENCRYPTED_PLACEHOLDER' }],
240+
voices: config.voices.map(voice => ({ ...voice, labels: {}, languages: [] })),
241+
}
242+
: null
243+
}
208244
return null
209245
}),
210246
}
@@ -312,6 +348,85 @@ describe('audio-speech-ws route', () => {
312348
}))
313349
})
314350

351+
it('translates the AIRI stream protocol to native StepFun websocket events', async () => {
352+
const audioPayload = Buffer.from('STEPFUN_AUDIO', 'utf8').toString('base64')
353+
const text = 'x'.repeat(2001)
354+
upstream = await startMockUpstream([
355+
{ kind: 'json', payload: { type: 'tts.response.sentence.start', data: { session_id: 'stepfun-session', text: 'hello' } } },
356+
{ kind: 'json', payload: { type: 'tts.response.audio.delta', data: { session_id: 'stepfun-session', audio: audioPayload } } },
357+
{ kind: 'json', payload: { type: 'tts.response.sentence.end', data: { session_id: 'stepfun-session', text: 'hello' } } },
358+
{ kind: 'json', payload: { type: 'tts.response.audio.done', data: { session_id: 'stepfun-session', audio: '' } } },
359+
], [{ id: 'lively-girl', name: 'Lively Girl' }], 'stepfun')
360+
361+
const deps = makeFakeDeps({
362+
upstreamURL: upstream.url,
363+
restBaseURL: upstream.restBaseURL,
364+
fluxBalance: 100,
365+
stepfunStreaming: {
366+
enabled: true,
367+
baseURL: upstream.url,
368+
models: [{ id: 'stepfun/step-tts-2', name: 'Step TTS 2' }],
369+
defaultModel: 'stepfun/step-tts-2',
370+
voices: [{ id: 'lively-girl', name: 'Lively Girl' }],
371+
},
372+
})
373+
const handlers = createAudioSpeechWsHandlers(deps as any)
374+
const events = handlers('user-stepfun')
375+
const client = makeMockClientWs()
376+
377+
await driveClientSession(events, client, [
378+
JSON.stringify({ event: 'start', model: 'stepfun/step-tts-2', voice: 'lively-girl' }),
379+
JSON.stringify({ event: 'text', text }),
380+
JSON.stringify({ event: 'finish' }),
381+
])
382+
await new Promise(r => setTimeout(r, 200))
383+
384+
const upstreamFrames = upstream.receivedFrames.map(frame => JSON.parse(frame.data as string))
385+
expect(upstreamFrames.map(frame => frame.type)).toEqual(['tts.create', 'tts.text.delta', 'tts.text.delta', 'tts.text.delta', 'tts.text.done'])
386+
expect(upstreamFrames[0].data).toMatchObject({
387+
session_id: 'stepfun-session',
388+
voice_id: 'lively-girl',
389+
mode: 'default',
390+
response_format: 'mp3_stream',
391+
})
392+
expect(upstreamFrames.slice(1, 4).map(frame => frame.data.text.length)).toEqual([1000, 1000, 1])
393+
394+
const clientTextFrames = client.sent.filter(s => s.kind === 'text').map(s => JSON.parse(s.data as string))
395+
expect(clientTextFrames.map(frame => frame.event)).toEqual(['session.started', 'sentence.start', 'sentence.end', 'session.finished'])
396+
expect(client.sent.filter(s => s.kind === 'binary')).toHaveLength(1)
397+
expect(deps.ttsMeter.accumulate).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-stepfun', units: 2001 }))
398+
})
399+
400+
it('settles StepFun text usage when the client cancels before audio completion', async () => {
401+
upstream = await startMockUpstream([], [{ id: 'lively-girl', name: 'Lively Girl' }], 'stepfun')
402+
const deps = makeFakeDeps({
403+
upstreamURL: upstream.url,
404+
restBaseURL: upstream.restBaseURL,
405+
fluxBalance: 100,
406+
stepfunStreaming: {
407+
enabled: true,
408+
baseURL: upstream.url,
409+
models: [{ id: 'stepfun/step-tts-2', name: 'Step TTS 2' }],
410+
defaultModel: 'stepfun/step-tts-2',
411+
voices: [{ id: 'lively-girl', name: 'Lively Girl' }],
412+
},
413+
})
414+
const events = createAudioSpeechWsHandlers(deps as any)('user-stepfun-cancel')
415+
const client = makeMockClientWs()
416+
417+
await driveClientSession(events, client, [
418+
JSON.stringify({ event: 'start', model: 'stepfun/step-tts-2', voice: 'lively-girl' }),
419+
JSON.stringify({ event: 'text', text: 'paid text' }),
420+
])
421+
events.onClose?.(new Event('close') as any, client.ctx)
422+
await new Promise(r => setTimeout(r, 100))
423+
424+
expect(deps.ttsMeter.accumulate).toHaveBeenCalledWith(expect.objectContaining({
425+
userId: 'user-stepfun-cancel',
426+
units: 9,
427+
}))
428+
})
429+
315430
it('refuses the session with insufficient_flux when the user is broke', async () => {
316431
upstream = await startMockUpstream([])
317432
const deps = makeFakeDeps({ upstreamURL: upstream.url, restBaseURL: upstream.restBaseURL, fluxBalance: 0 })

0 commit comments

Comments
 (0)