@@ -28,6 +28,7 @@ interface MockUpstream {
2828async 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