@mingxy/ocosay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +556 -0
- package/TECH_PLAN.md +352 -0
- package/__mocks__/@opencode-ai/plugin.ts +32 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +95 -0
- package/dist/config.js.map +1 -0
- package/dist/core/backends/afplay-backend.d.ts +33 -0
- package/dist/core/backends/afplay-backend.d.ts.map +1 -0
- package/dist/core/backends/afplay-backend.js +144 -0
- package/dist/core/backends/afplay-backend.js.map +1 -0
- package/dist/core/backends/aplay-backend.d.ts +33 -0
- package/dist/core/backends/aplay-backend.d.ts.map +1 -0
- package/dist/core/backends/aplay-backend.js +142 -0
- package/dist/core/backends/aplay-backend.js.map +1 -0
- package/dist/core/backends/base.d.ts +94 -0
- package/dist/core/backends/base.d.ts.map +1 -0
- package/dist/core/backends/base.js +6 -0
- package/dist/core/backends/base.js.map +1 -0
- package/dist/core/backends/index.d.ts +29 -0
- package/dist/core/backends/index.d.ts.map +1 -0
- package/dist/core/backends/index.js +114 -0
- package/dist/core/backends/index.js.map +1 -0
- package/dist/core/backends/naudiodon-backend.d.ts +52 -0
- package/dist/core/backends/naudiodon-backend.d.ts.map +1 -0
- package/dist/core/backends/naudiodon-backend.js +123 -0
- package/dist/core/backends/naudiodon-backend.js.map +1 -0
- package/dist/core/backends/powershell-backend.d.ts +34 -0
- package/dist/core/backends/powershell-backend.d.ts.map +1 -0
- package/dist/core/backends/powershell-backend.js +154 -0
- package/dist/core/backends/powershell-backend.js.map +1 -0
- package/dist/core/player.d.ts +97 -0
- package/dist/core/player.d.ts.map +1 -0
- package/dist/core/player.js +268 -0
- package/dist/core/player.js.map +1 -0
- package/dist/core/speaker.d.ts +97 -0
- package/dist/core/speaker.d.ts.map +1 -0
- package/dist/core/speaker.js +218 -0
- package/dist/core/speaker.js.map +1 -0
- package/dist/core/stream-player.d.ts +107 -0
- package/dist/core/stream-player.d.ts.map +1 -0
- package/dist/core/stream-player.js +272 -0
- package/dist/core/stream-player.js.map +1 -0
- package/dist/core/stream-reader.d.ts +86 -0
- package/dist/core/stream-reader.d.ts.map +1 -0
- package/dist/core/stream-reader.js +172 -0
- package/dist/core/stream-reader.js.map +1 -0
- package/dist/core/streaming-synthesizer.d.ts +51 -0
- package/dist/core/streaming-synthesizer.d.ts.map +1 -0
- package/dist/core/streaming-synthesizer.js +103 -0
- package/dist/core/streaming-synthesizer.js.map +1 -0
- package/dist/core/types.d.ts +141 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +37 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +151 -0
- package/dist/plugin.js.map +1 -0
- package/dist/providers/base.d.ts +55 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +95 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/minimax.d.ts +84 -0
- package/dist/providers/minimax.d.ts.map +1 -0
- package/dist/providers/minimax.js +387 -0
- package/dist/providers/minimax.js.map +1 -0
- package/dist/tools/tts.d.ts +147 -0
- package/dist/tools/tts.d.ts.map +1 -0
- package/dist/tools/tts.js +232 -0
- package/dist/tools/tts.js.map +1 -0
- package/jest.config.js +15 -0
- package/package.json +49 -0
- package/src/config.ts +121 -0
- package/src/core/backends/afplay-backend.ts +162 -0
- package/src/core/backends/aplay-backend.ts +160 -0
- package/src/core/backends/base.ts +117 -0
- package/src/core/backends/index.ts +128 -0
- package/src/core/backends/naudiodon-backend.ts +164 -0
- package/src/core/backends/powershell-backend.ts +173 -0
- package/src/core/player.ts +322 -0
- package/src/core/speaker.ts +283 -0
- package/src/core/stream-player.ts +326 -0
- package/src/core/stream-reader.ts +190 -0
- package/src/core/streaming-synthesizer.ts +123 -0
- package/src/core/types.ts +185 -0
- package/src/index.ts +233 -0
- package/src/plugin.ts +166 -0
- package/src/providers/base.ts +150 -0
- package/src/providers/minimax.ts +515 -0
- package/src/tools/tts.ts +277 -0
- package/src/types/naudiodon.d.ts +19 -0
- package/tests/__mocks__/@opencode-ai/plugin.ts +32 -0
- package/tests/backends.test.ts +831 -0
- package/tests/index.test.ts +201 -0
- package/tests/integration-test.d.ts +6 -0
- package/tests/integration-test.d.ts.map +1 -0
- package/tests/integration-test.js +84 -0
- package/tests/integration-test.js.map +1 -0
- package/tests/integration-test.ts +93 -0
- package/tests/p1-fixes.test.ts +160 -0
- package/tests/plugin.test.ts +311 -0
- package/tests/provider.test.d.ts +2 -0
- package/tests/provider.test.d.ts.map +1 -0
- package/tests/provider.test.js +69 -0
- package/tests/provider.test.js.map +1 -0
- package/tests/provider.test.ts +87 -0
- package/tests/speaker.test.d.ts +2 -0
- package/tests/speaker.test.d.ts.map +1 -0
- package/tests/speaker.test.js +63 -0
- package/tests/speaker.test.js.map +1 -0
- package/tests/speaker.test.ts +232 -0
- package/tests/stream-player.test.ts +303 -0
- package/tests/stream-reader.test.ts +269 -0
- package/tests/streaming-synthesizer.test.ts +225 -0
- package/tests/tts-tools.test.ts +270 -0
- package/tests/types.test.d.ts +2 -0
- package/tests/types.test.d.ts.map +1 -0
- package/tests/types.test.js +61 -0
- package/tests/types.test.js.map +1 -0
- package/tests/types.test.ts +63 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { StreamReader } from '../src/core/stream-reader'
|
|
2
|
+
import { StreamState } from '../src/core/types'
|
|
3
|
+
|
|
4
|
+
describe('StreamReader', () => {
|
|
5
|
+
let streamReader: StreamReader
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
streamReader = new StreamReader(30, 2000)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
streamReader.reset()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('initialization', () => {
|
|
16
|
+
it('should create StreamReader with default options', () => {
|
|
17
|
+
const reader = new StreamReader()
|
|
18
|
+
expect(reader).toBeInstanceOf(StreamReader)
|
|
19
|
+
reader.reset()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should create StreamReader with custom buffer size and timeout', () => {
|
|
23
|
+
const reader = new StreamReader(50, 3000)
|
|
24
|
+
expect(reader).toBeInstanceOf(StreamReader)
|
|
25
|
+
reader.reset()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('handleDelta', () => {
|
|
30
|
+
it('should transition from IDLE to BUFFERING on first delta', () => {
|
|
31
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好')
|
|
32
|
+
expect(streamReader.getState()).toBe(StreamState.BUFFERING)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should accumulate delta in buffer', () => {
|
|
36
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好')
|
|
37
|
+
expect(streamReader.getBuffer()).toBe('你好')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should update session ID and message ID', () => {
|
|
41
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好')
|
|
42
|
+
expect(streamReader.getSessionID()).toBe('session1')
|
|
43
|
+
expect(streamReader.getMessageID()).toBe('msg1')
|
|
44
|
+
expect(streamReader.getPartID()).toBe('part1')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should emit streamStart event on first delta', () => {
|
|
48
|
+
const startCallback = jest.fn()
|
|
49
|
+
streamReader.on('streamStart', startCallback)
|
|
50
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好')
|
|
51
|
+
expect(startCallback).toHaveBeenCalledTimes(1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should accumulate multiple deltas', () => {
|
|
55
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好')
|
|
56
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '世界')
|
|
57
|
+
expect(streamReader.getBuffer()).toBe('你好世界')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('handleEnd', () => {
|
|
62
|
+
it('should emit streamEnd event', () => {
|
|
63
|
+
const endCallback = jest.fn()
|
|
64
|
+
streamReader.on('streamEnd', endCallback)
|
|
65
|
+
streamReader.handleEnd()
|
|
66
|
+
expect(endCallback).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should flush remaining buffer before ending', () => {
|
|
70
|
+
const textReadyCallback = jest.fn()
|
|
71
|
+
streamReader.on('textReady', textReadyCallback)
|
|
72
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试文本')
|
|
73
|
+
streamReader.handleEnd()
|
|
74
|
+
expect(textReadyCallback).toHaveBeenCalledWith('测试文本')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should not emit textReady for empty buffer', () => {
|
|
78
|
+
const textReadyCallback = jest.fn()
|
|
79
|
+
streamReader.on('textReady', textReadyCallback)
|
|
80
|
+
streamReader.handleEnd()
|
|
81
|
+
expect(textReadyCallback).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should transition to ENDED state', () => {
|
|
85
|
+
streamReader.handleEnd()
|
|
86
|
+
expect(streamReader.getState()).toBe(StreamState.ENDED)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should handle multiple handleEnd calls gracefully', () => {
|
|
90
|
+
const textReadyCallback = jest.fn()
|
|
91
|
+
streamReader.on('textReady', textReadyCallback)
|
|
92
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
93
|
+
streamReader.handleEnd()
|
|
94
|
+
streamReader.handleEnd() // Second call should be ignored
|
|
95
|
+
expect(textReadyCallback).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('sentence boundary detection (shouldFlush)', () => {
|
|
100
|
+
it('should flush on Chinese period 。', () => {
|
|
101
|
+
const textReadyCallback = jest.fn()
|
|
102
|
+
streamReader.on('textReady', textReadyCallback)
|
|
103
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好。')
|
|
104
|
+
expect(textReadyCallback).toHaveBeenCalledWith('你好。')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should flush on Chinese exclamation !', () => {
|
|
108
|
+
const textReadyCallback = jest.fn()
|
|
109
|
+
streamReader.on('textReady', textReadyCallback)
|
|
110
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好!')
|
|
111
|
+
expect(textReadyCallback).toHaveBeenCalledWith('你好!')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should flush on Chinese question ?', () => {
|
|
115
|
+
const textReadyCallback = jest.fn()
|
|
116
|
+
streamReader.on('textReady', textReadyCallback)
|
|
117
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好?')
|
|
118
|
+
expect(textReadyCallback).toHaveBeenCalledWith('你好?')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should flush on English period .', () => {
|
|
122
|
+
const textReadyCallback = jest.fn()
|
|
123
|
+
streamReader.on('textReady', textReadyCallback)
|
|
124
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', 'Hello.')
|
|
125
|
+
expect(textReadyCallback).toHaveBeenCalledWith('Hello.')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should flush on English exclamation !', () => {
|
|
129
|
+
const textReadyCallback = jest.fn()
|
|
130
|
+
streamReader.on('textReady', textReadyCallback)
|
|
131
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', 'Hello!')
|
|
132
|
+
expect(textReadyCallback).toHaveBeenCalledWith('Hello!')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should flush on English question ?', () => {
|
|
136
|
+
const textReadyCallback = jest.fn()
|
|
137
|
+
streamReader.on('textReady', textReadyCallback)
|
|
138
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', 'Hello?')
|
|
139
|
+
expect(textReadyCallback).toHaveBeenCalledWith('Hello?')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should flush on Chinese ellipsis ……', () => {
|
|
143
|
+
const textReadyCallback = jest.fn()
|
|
144
|
+
streamReader.on('textReady', textReadyCallback)
|
|
145
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '你好……')
|
|
146
|
+
expect(textReadyCallback).toHaveBeenCalledWith('你好……')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should flush when buffer reaches bufferSize', () => {
|
|
150
|
+
const smallBufferReader = new StreamReader(3, 2000)
|
|
151
|
+
const textReadyCallback = jest.fn()
|
|
152
|
+
smallBufferReader.on('textReady', textReadyCallback)
|
|
153
|
+
smallBufferReader.handleDelta('session1', 'msg1', 'part1', '你好世')
|
|
154
|
+
expect(textReadyCallback).toHaveBeenCalled()
|
|
155
|
+
smallBufferReader.reset()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('timeout mechanism', () => {
|
|
160
|
+
it('should flush buffer after timeout', async () => {
|
|
161
|
+
const shortTimeoutReader = new StreamReader(100, 50)
|
|
162
|
+
const textReadyCallback = jest.fn()
|
|
163
|
+
shortTimeoutReader.on('textReady', textReadyCallback)
|
|
164
|
+
shortTimeoutReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
165
|
+
expect(textReadyCallback).not.toHaveBeenCalled()
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
167
|
+
expect(textReadyCallback).toHaveBeenCalledWith('测试')
|
|
168
|
+
shortTimeoutReader.reset()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should reset timeout on new delta', async () => {
|
|
172
|
+
const shortTimeoutReader = new StreamReader(100, 100)
|
|
173
|
+
const textReadyCallback = jest.fn()
|
|
174
|
+
shortTimeoutReader.on('textReady', textReadyCallback)
|
|
175
|
+
shortTimeoutReader.handleDelta('session1', 'msg1', 'part1', '第一')
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
177
|
+
shortTimeoutReader.handleDelta('session1', 'msg1', 'part1', '第二')
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
179
|
+
expect(textReadyCallback).not.toHaveBeenCalled()
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
181
|
+
expect(textReadyCallback).toHaveBeenCalledWith('第一第二')
|
|
182
|
+
shortTimeoutReader.reset()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('reset', () => {
|
|
187
|
+
it('should clear buffer', () => {
|
|
188
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
189
|
+
streamReader.reset()
|
|
190
|
+
expect(streamReader.getBuffer()).toBe('')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should transition to IDLE state', () => {
|
|
194
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
195
|
+
streamReader.reset()
|
|
196
|
+
expect(streamReader.getState()).toBe(StreamState.IDLE)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should clear session/message/part IDs', () => {
|
|
200
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
201
|
+
streamReader.reset()
|
|
202
|
+
expect(streamReader.getSessionID()).toBeUndefined()
|
|
203
|
+
expect(streamReader.getMessageID()).toBeUndefined()
|
|
204
|
+
expect(streamReader.getPartID()).toBeUndefined()
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('isActive', () => {
|
|
209
|
+
it('should return true when BUFFERING', () => {
|
|
210
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
211
|
+
expect(streamReader.isActive()).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should return false when IDLE', () => {
|
|
215
|
+
expect(streamReader.isActive()).toBe(false)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should return false when ENDED', () => {
|
|
219
|
+
streamReader.handleEnd()
|
|
220
|
+
expect(streamReader.isActive()).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('start', () => {
|
|
225
|
+
it('should transition from IDLE to BUFFERING', () => {
|
|
226
|
+
streamReader.start()
|
|
227
|
+
expect(streamReader.getState()).toBe(StreamState.BUFFERING)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should emit streamStart event', () => {
|
|
231
|
+
const startCallback = jest.fn()
|
|
232
|
+
streamReader.on('streamStart', startCallback)
|
|
233
|
+
streamReader.start()
|
|
234
|
+
expect(startCallback).toHaveBeenCalledTimes(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should not emit streamStart if already BUFFERING', () => {
|
|
238
|
+
const startCallback = jest.fn()
|
|
239
|
+
streamReader.on('streamStart', startCallback)
|
|
240
|
+
streamReader.start()
|
|
241
|
+
streamReader.start()
|
|
242
|
+
expect(startCallback).toHaveBeenCalledTimes(1)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('handleError', () => {
|
|
247
|
+
it('should emit streamError event', () => {
|
|
248
|
+
const errorCallback = jest.fn()
|
|
249
|
+
streamReader.on('streamError', errorCallback)
|
|
250
|
+
const { TTSError, TTSErrorCode } = require('../src/core/types')
|
|
251
|
+
streamReader.handleError(new TTSError('Test error', TTSErrorCode.UNKNOWN, 'test'))
|
|
252
|
+
expect(errorCallback).toHaveBeenCalled()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('should reset to IDLE state', () => {
|
|
256
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
257
|
+
const { TTSError, TTSErrorCode } = require('../src/core/types')
|
|
258
|
+
streamReader.handleError(new TTSError('Test error', TTSErrorCode.UNKNOWN, 'test'))
|
|
259
|
+
expect(streamReader.getState()).toBe(StreamState.IDLE)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('should clear buffer', () => {
|
|
263
|
+
streamReader.handleDelta('session1', 'msg1', 'part1', '测试')
|
|
264
|
+
const { TTSError, TTSErrorCode } = require('../src/core/types')
|
|
265
|
+
streamReader.handleError(new TTSError('Test error', TTSErrorCode.UNKNOWN, 'test'))
|
|
266
|
+
expect(streamReader.getBuffer()).toBe('')
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { StreamingSynthesizer } from '../src/core/streaming-synthesizer'
|
|
2
|
+
import { TTSProvider, TTSError, TTSErrorCode, AudioResult, SpeakOptions, TTSCapabilities } from '../src/core/types'
|
|
3
|
+
|
|
4
|
+
class MockTTSProvider implements TTSProvider {
|
|
5
|
+
name = 'mock-stream'
|
|
6
|
+
capabilities: TTSCapabilities = { speak: true, stream: true }
|
|
7
|
+
|
|
8
|
+
async initialize(): Promise<void> {}
|
|
9
|
+
async destroy(): Promise<void> {}
|
|
10
|
+
async pause(): Promise<void> {}
|
|
11
|
+
async resume(): Promise<void> {}
|
|
12
|
+
async stop(): Promise<void> {}
|
|
13
|
+
async listVoices() { return [] }
|
|
14
|
+
getCapabilities(): TTSCapabilities { return this.capabilities }
|
|
15
|
+
|
|
16
|
+
async speak(text: string, options?: SpeakOptions): Promise<AudioResult> {
|
|
17
|
+
if (text === 'error') {
|
|
18
|
+
throw new TTSError('Synthesize error', TTSErrorCode.UNKNOWN, this.name)
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
audioData: Buffer.from([1, 2, 3]),
|
|
22
|
+
format: 'mp3',
|
|
23
|
+
isStream: false,
|
|
24
|
+
duration: 1.0
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class MockStreamingProvider implements TTSProvider {
|
|
30
|
+
name = 'mock-streaming'
|
|
31
|
+
capabilities: TTSCapabilities = { speak: true, stream: true }
|
|
32
|
+
|
|
33
|
+
async initialize(): Promise<void> {}
|
|
34
|
+
async destroy(): Promise<void> {}
|
|
35
|
+
async pause(): Promise<void> {}
|
|
36
|
+
async resume(): Promise<void> {}
|
|
37
|
+
async stop(): Promise<void> {}
|
|
38
|
+
async listVoices() { return [] }
|
|
39
|
+
getCapabilities(): TTSCapabilities { return this.capabilities }
|
|
40
|
+
|
|
41
|
+
async speak(text: string, options?: SpeakOptions): Promise<AudioResult> {
|
|
42
|
+
if (text === 'error') {
|
|
43
|
+
throw new TTSError('Synthesize error', TTSErrorCode.UNKNOWN, this.name)
|
|
44
|
+
}
|
|
45
|
+
const chunks = ['chunk1', 'chunk2', 'chunk3']
|
|
46
|
+
const stream = new ReadableStream({
|
|
47
|
+
pull(controller) {
|
|
48
|
+
for (const chunk of chunks) {
|
|
49
|
+
controller.enqueue(Buffer.from(chunk))
|
|
50
|
+
}
|
|
51
|
+
controller.close()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
return {
|
|
55
|
+
audioData: stream,
|
|
56
|
+
format: 'mp3',
|
|
57
|
+
isStream: true,
|
|
58
|
+
duration: 1.0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('StreamingSynthesizer', () => {
|
|
64
|
+
let synthesizer: StreamingSynthesizer
|
|
65
|
+
let mockProvider: MockTTSProvider
|
|
66
|
+
let mockStreamingProvider: MockStreamingProvider
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
mockProvider = new MockTTSProvider()
|
|
70
|
+
mockStreamingProvider = new MockStreamingProvider()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
synthesizer?.reset()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('initialization', () => {
|
|
78
|
+
it('should create synthesizer with options', () => {
|
|
79
|
+
synthesizer = new StreamingSynthesizer({
|
|
80
|
+
provider: mockProvider,
|
|
81
|
+
voice: 'test-voice',
|
|
82
|
+
speed: 1.0,
|
|
83
|
+
volume: 1.0,
|
|
84
|
+
pitch: 1.0
|
|
85
|
+
})
|
|
86
|
+
expect(synthesizer).toBeInstanceOf(StreamingSynthesizer)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should create synthesizer with minimal options', () => {
|
|
90
|
+
synthesizer = new StreamingSynthesizer({
|
|
91
|
+
provider: mockProvider
|
|
92
|
+
})
|
|
93
|
+
expect(synthesizer).toBeInstanceOf(StreamingSynthesizer)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('synthesize', () => {
|
|
98
|
+
it('should call provider.speak with correct options', async () => {
|
|
99
|
+
synthesizer = new StreamingSynthesizer({
|
|
100
|
+
provider: mockProvider,
|
|
101
|
+
voice: 'test-voice',
|
|
102
|
+
speed: 1.5,
|
|
103
|
+
volume: 0.8,
|
|
104
|
+
pitch: 1.2
|
|
105
|
+
})
|
|
106
|
+
const speakSpy = jest.spyOn(mockProvider, 'speak')
|
|
107
|
+
await synthesizer.synthesize('test text')
|
|
108
|
+
expect(speakSpy).toHaveBeenCalledWith('test text', expect.objectContaining({
|
|
109
|
+
model: 'stream',
|
|
110
|
+
voice: 'test-voice',
|
|
111
|
+
speed: 1.5,
|
|
112
|
+
volume: 0.8,
|
|
113
|
+
pitch: 1.2
|
|
114
|
+
}))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should return early for empty text', async () => {
|
|
118
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
119
|
+
const speakSpy = jest.spyOn(mockProvider, 'speak')
|
|
120
|
+
await synthesizer.synthesize('')
|
|
121
|
+
expect(speakSpy).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should return early for whitespace-only text', async () => {
|
|
125
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
126
|
+
const speakSpy = jest.spyOn(mockProvider, 'speak')
|
|
127
|
+
await synthesizer.synthesize(' ')
|
|
128
|
+
expect(speakSpy).not.toHaveBeenCalled()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should emit done event on success', async () => {
|
|
132
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
133
|
+
const doneCallback = jest.fn()
|
|
134
|
+
synthesizer.on('done', doneCallback)
|
|
135
|
+
await synthesizer.synthesize('test text')
|
|
136
|
+
expect(doneCallback).toHaveBeenCalledTimes(1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should emit error event on provider error', async () => {
|
|
140
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
141
|
+
const errorCallback = jest.fn()
|
|
142
|
+
synthesizer.on('error', errorCallback)
|
|
143
|
+
await synthesizer.synthesize('error')
|
|
144
|
+
expect(errorCallback).toHaveBeenCalledTimes(1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should accumulate audio chunks', async () => {
|
|
148
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
149
|
+
const chunkCallback = jest.fn()
|
|
150
|
+
synthesizer.on('chunk', chunkCallback)
|
|
151
|
+
await synthesizer.synthesize('test text')
|
|
152
|
+
expect(chunkCallback).toHaveBeenCalled()
|
|
153
|
+
expect(synthesizer.getAudioChunks().length).toBeGreaterThan(0)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('processAudioResult with Buffer', () => {
|
|
158
|
+
it('should emit chunk for Buffer audioData', async () => {
|
|
159
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
160
|
+
const chunkCallback = jest.fn()
|
|
161
|
+
synthesizer.on('chunk', chunkCallback)
|
|
162
|
+
await synthesizer.synthesize('test text')
|
|
163
|
+
expect(chunkCallback).toHaveBeenCalledWith(Buffer.from([1, 2, 3]))
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('processAudioResult with ReadableStream', () => {
|
|
168
|
+
it('should emit multiple chunks for stream audioData', async () => {
|
|
169
|
+
synthesizer = new StreamingSynthesizer({ provider: mockStreamingProvider })
|
|
170
|
+
const chunks: Buffer[] = []
|
|
171
|
+
synthesizer.on('chunk', (chunk: Buffer) => chunks.push(chunk))
|
|
172
|
+
await synthesizer.synthesize('test text')
|
|
173
|
+
expect(chunks.length).toBe(3)
|
|
174
|
+
expect(chunks[0].toString()).toBe('chunk1')
|
|
175
|
+
expect(chunks[1].toString()).toBe('chunk2')
|
|
176
|
+
expect(chunks[2].toString()).toBe('chunk3')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('reset', () => {
|
|
181
|
+
it('should clear accumulated audio chunks', async () => {
|
|
182
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
183
|
+
await synthesizer.synthesize('test text')
|
|
184
|
+
expect(synthesizer.getAudioChunks().length).toBeGreaterThan(0)
|
|
185
|
+
synthesizer.reset()
|
|
186
|
+
expect(synthesizer.getAudioChunks()).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('getAudioChunks', () => {
|
|
191
|
+
it('should return copy of chunks array', async () => {
|
|
192
|
+
synthesizer = new StreamingSynthesizer({ provider: mockProvider })
|
|
193
|
+
await synthesizer.synthesize('test text')
|
|
194
|
+
const chunks1 = synthesizer.getAudioChunks()
|
|
195
|
+
const chunks2 = synthesizer.getAudioChunks()
|
|
196
|
+
expect(chunks1).toEqual(chunks2)
|
|
197
|
+
chunks1.push(Buffer.from([99]))
|
|
198
|
+
expect(chunks2.length).toBe(synthesizer.getAudioChunks().length)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('error handling', () => {
|
|
203
|
+
it('should wrap non-TTSError in TTSError', async () => {
|
|
204
|
+
class ErrorProvider implements TTSProvider {
|
|
205
|
+
name = 'error-provider'
|
|
206
|
+
capabilities: TTSCapabilities = { speak: true }
|
|
207
|
+
async initialize() {}
|
|
208
|
+
async destroy() {}
|
|
209
|
+
async speak(): Promise<AudioResult> { throw new Error('Raw error') }
|
|
210
|
+
async pause() {}
|
|
211
|
+
async resume() {}
|
|
212
|
+
async stop() {}
|
|
213
|
+
async listVoices() { return [] }
|
|
214
|
+
getCapabilities(): TTSCapabilities { return this.capabilities }
|
|
215
|
+
}
|
|
216
|
+
synthesizer = new StreamingSynthesizer({ provider: new ErrorProvider() })
|
|
217
|
+
const errorCallback = jest.fn()
|
|
218
|
+
synthesizer.on('error', errorCallback)
|
|
219
|
+
await synthesizer.synthesize('test')
|
|
220
|
+
expect(errorCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
221
|
+
name: 'TTSError'
|
|
222
|
+
}))
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|