@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.
Files changed (126) hide show
  1. package/README.md +556 -0
  2. package/TECH_PLAN.md +352 -0
  3. package/__mocks__/@opencode-ai/plugin.ts +32 -0
  4. package/dist/config.d.ts +26 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +95 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/backends/afplay-backend.d.ts +33 -0
  9. package/dist/core/backends/afplay-backend.d.ts.map +1 -0
  10. package/dist/core/backends/afplay-backend.js +144 -0
  11. package/dist/core/backends/afplay-backend.js.map +1 -0
  12. package/dist/core/backends/aplay-backend.d.ts +33 -0
  13. package/dist/core/backends/aplay-backend.d.ts.map +1 -0
  14. package/dist/core/backends/aplay-backend.js +142 -0
  15. package/dist/core/backends/aplay-backend.js.map +1 -0
  16. package/dist/core/backends/base.d.ts +94 -0
  17. package/dist/core/backends/base.d.ts.map +1 -0
  18. package/dist/core/backends/base.js +6 -0
  19. package/dist/core/backends/base.js.map +1 -0
  20. package/dist/core/backends/index.d.ts +29 -0
  21. package/dist/core/backends/index.d.ts.map +1 -0
  22. package/dist/core/backends/index.js +114 -0
  23. package/dist/core/backends/index.js.map +1 -0
  24. package/dist/core/backends/naudiodon-backend.d.ts +52 -0
  25. package/dist/core/backends/naudiodon-backend.d.ts.map +1 -0
  26. package/dist/core/backends/naudiodon-backend.js +123 -0
  27. package/dist/core/backends/naudiodon-backend.js.map +1 -0
  28. package/dist/core/backends/powershell-backend.d.ts +34 -0
  29. package/dist/core/backends/powershell-backend.d.ts.map +1 -0
  30. package/dist/core/backends/powershell-backend.js +154 -0
  31. package/dist/core/backends/powershell-backend.js.map +1 -0
  32. package/dist/core/player.d.ts +97 -0
  33. package/dist/core/player.d.ts.map +1 -0
  34. package/dist/core/player.js +268 -0
  35. package/dist/core/player.js.map +1 -0
  36. package/dist/core/speaker.d.ts +97 -0
  37. package/dist/core/speaker.d.ts.map +1 -0
  38. package/dist/core/speaker.js +218 -0
  39. package/dist/core/speaker.js.map +1 -0
  40. package/dist/core/stream-player.d.ts +107 -0
  41. package/dist/core/stream-player.d.ts.map +1 -0
  42. package/dist/core/stream-player.js +272 -0
  43. package/dist/core/stream-player.js.map +1 -0
  44. package/dist/core/stream-reader.d.ts +86 -0
  45. package/dist/core/stream-reader.d.ts.map +1 -0
  46. package/dist/core/stream-reader.js +172 -0
  47. package/dist/core/stream-reader.js.map +1 -0
  48. package/dist/core/streaming-synthesizer.d.ts +51 -0
  49. package/dist/core/streaming-synthesizer.d.ts.map +1 -0
  50. package/dist/core/streaming-synthesizer.js +103 -0
  51. package/dist/core/streaming-synthesizer.js.map +1 -0
  52. package/dist/core/types.d.ts +141 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +37 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/index.d.ts +40 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +179 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/plugin.d.ts +4 -0
  61. package/dist/plugin.d.ts.map +1 -0
  62. package/dist/plugin.js +151 -0
  63. package/dist/plugin.js.map +1 -0
  64. package/dist/providers/base.d.ts +55 -0
  65. package/dist/providers/base.d.ts.map +1 -0
  66. package/dist/providers/base.js +95 -0
  67. package/dist/providers/base.js.map +1 -0
  68. package/dist/providers/minimax.d.ts +84 -0
  69. package/dist/providers/minimax.d.ts.map +1 -0
  70. package/dist/providers/minimax.js +387 -0
  71. package/dist/providers/minimax.js.map +1 -0
  72. package/dist/tools/tts.d.ts +147 -0
  73. package/dist/tools/tts.d.ts.map +1 -0
  74. package/dist/tools/tts.js +232 -0
  75. package/dist/tools/tts.js.map +1 -0
  76. package/jest.config.js +15 -0
  77. package/package.json +49 -0
  78. package/src/config.ts +121 -0
  79. package/src/core/backends/afplay-backend.ts +162 -0
  80. package/src/core/backends/aplay-backend.ts +160 -0
  81. package/src/core/backends/base.ts +117 -0
  82. package/src/core/backends/index.ts +128 -0
  83. package/src/core/backends/naudiodon-backend.ts +164 -0
  84. package/src/core/backends/powershell-backend.ts +173 -0
  85. package/src/core/player.ts +322 -0
  86. package/src/core/speaker.ts +283 -0
  87. package/src/core/stream-player.ts +326 -0
  88. package/src/core/stream-reader.ts +190 -0
  89. package/src/core/streaming-synthesizer.ts +123 -0
  90. package/src/core/types.ts +185 -0
  91. package/src/index.ts +233 -0
  92. package/src/plugin.ts +166 -0
  93. package/src/providers/base.ts +150 -0
  94. package/src/providers/minimax.ts +515 -0
  95. package/src/tools/tts.ts +277 -0
  96. package/src/types/naudiodon.d.ts +19 -0
  97. package/tests/__mocks__/@opencode-ai/plugin.ts +32 -0
  98. package/tests/backends.test.ts +831 -0
  99. package/tests/index.test.ts +201 -0
  100. package/tests/integration-test.d.ts +6 -0
  101. package/tests/integration-test.d.ts.map +1 -0
  102. package/tests/integration-test.js +84 -0
  103. package/tests/integration-test.js.map +1 -0
  104. package/tests/integration-test.ts +93 -0
  105. package/tests/p1-fixes.test.ts +160 -0
  106. package/tests/plugin.test.ts +311 -0
  107. package/tests/provider.test.d.ts +2 -0
  108. package/tests/provider.test.d.ts.map +1 -0
  109. package/tests/provider.test.js +69 -0
  110. package/tests/provider.test.js.map +1 -0
  111. package/tests/provider.test.ts +87 -0
  112. package/tests/speaker.test.d.ts +2 -0
  113. package/tests/speaker.test.d.ts.map +1 -0
  114. package/tests/speaker.test.js +63 -0
  115. package/tests/speaker.test.js.map +1 -0
  116. package/tests/speaker.test.ts +232 -0
  117. package/tests/stream-player.test.ts +303 -0
  118. package/tests/stream-reader.test.ts +269 -0
  119. package/tests/streaming-synthesizer.test.ts +225 -0
  120. package/tests/tts-tools.test.ts +270 -0
  121. package/tests/types.test.d.ts +2 -0
  122. package/tests/types.test.d.ts.map +1 -0
  123. package/tests/types.test.js +61 -0
  124. package/tests/types.test.js.map +1 -0
  125. package/tests/types.test.ts +63 -0
  126. package/tsconfig.json +22 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * PowerShell Backend - Windows 平台音频播放后端
3
+ * 使用 PowerShell 的 [System.Media.SoundPlayer] 异步 API
4
+ */
5
+
6
+ import { spawn, ChildProcess } from 'child_process'
7
+ import { AudioBackend, AudioBackendEvents, BackendOptions } from './base'
8
+ import { tmpdir } from 'os'
9
+ import { join } from 'path'
10
+ import { writeFileSync, unlinkSync, existsSync } from 'fs'
11
+
12
+ // 白名单:Windows 路径格式(禁止 - 防止命令注入)
13
+ const SAFE_PATH_REGEX = /^[\w\:\\_.]+$/i
14
+
15
+ // 定义不支持操作的错误类
16
+ class UnsupportedError extends Error {
17
+ constructor(message: string) {
18
+ super(message)
19
+ this.name = 'UnsupportedError'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * PowerShellBackend - Windows 原生音频播放后端
25
+ * 使用 PowerShell 异步方式避免阻塞事件循环
26
+ * 不支持真正的流式播放
27
+ */
28
+ export class PowerShellBackend implements AudioBackend {
29
+ readonly name = 'powershell'
30
+ readonly supportsStreaming = false
31
+
32
+ private process?: ChildProcess
33
+ private tempFile?: string
34
+ private events?: AudioBackendEvents
35
+ private _started = false
36
+ private _paused = false
37
+ private _stopped = false
38
+ // P0-4: 缓冲所有chunk,等end()时一次性写入文件
39
+ private chunks: Buffer[] = []
40
+ private hasEnded = false
41
+
42
+ constructor(options: BackendOptions = {}) {
43
+ this.events = options.events
44
+ }
45
+
46
+ start(filePath: string): void {
47
+ if (this._started) return
48
+
49
+ if (!SAFE_PATH_REGEX.test(filePath)) {
50
+ throw new Error(`Invalid file path: ${filePath}`)
51
+ }
52
+
53
+ this.tempFile = filePath
54
+ this._started = true
55
+ this._stopped = false
56
+
57
+ this.events?.onStart?.()
58
+
59
+ // 安全方案:将路径写入临时脚本文件,避免命令行注入
60
+ const escapedPath = filePath.replace(/'/g, "''")
61
+ const psScript = `$sound = New-Object System.Media.SoundPlayer('${escapedPath}'); $sound.PlayAsync()`
62
+ const scriptFile = join(tmpdir(), `ocosay-${Date.now()}.ps1`)
63
+ writeFileSync(scriptFile, psScript, { encoding: 'utf8' })
64
+
65
+ this.process = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptFile], {
66
+ stdio: 'ignore',
67
+ detached: false
68
+ })
69
+
70
+ // 清理脚本文件
71
+ this.process.on('exit', () => {
72
+ try {
73
+ if (existsSync(scriptFile)) {
74
+ unlinkSync(scriptFile)
75
+ }
76
+ } catch (e) {
77
+ // 忽略清理错误
78
+ }
79
+ })
80
+
81
+ this.process.on('exit', (code) => {
82
+ if (this._stopped) return
83
+
84
+ if (code === 0 || code === null) {
85
+ this._started = false
86
+ this.events?.onEnd?.()
87
+ } else {
88
+ this.handleError(new Error(`PowerShell playback exited with code ${code}`))
89
+ }
90
+ })
91
+
92
+ this.process.on('error', (error) => {
93
+ this.handleError(error)
94
+ })
95
+ }
96
+
97
+ write(chunk: Buffer): void {
98
+ if (this._stopped) return
99
+ // P0-4: 缓冲所有chunk,等end()时一次性写入
100
+ this.chunks.push(chunk)
101
+ }
102
+
103
+ end(): void {
104
+ if (this._stopped || this.hasEnded) return
105
+ this.hasEnded = true
106
+
107
+ if (this.chunks.length === 0) return
108
+
109
+ // P0-4: 所有chunk缓冲完毕后,一次性写入文件并播放
110
+ this.tempFile = join(tmpdir(), `ocosay-${Date.now()}.wav`)
111
+ writeFileSync(this.tempFile, Buffer.concat(this.chunks))
112
+ this.chunks = []
113
+ this.start(this.tempFile)
114
+ }
115
+
116
+ pause(): void {
117
+ if (!this._started || this._paused || this._stopped) return
118
+ // PowerShell SoundPlayer.PlayAsync() 不支持暂停,抛错明确告知
119
+ throw new UnsupportedError('pause is not supported by PowerShell SoundPlayer')
120
+ }
121
+
122
+ resume(): void {
123
+ if (!this._paused || this._stopped) return
124
+ // PowerShell SoundPlayer.PlayAsync() 不支持恢复,抛错明确告知
125
+ throw new UnsupportedError('resume is not supported by PowerShell SoundPlayer')
126
+ }
127
+
128
+ stop(): void {
129
+ this._stopped = true
130
+ this._started = false
131
+ this._paused = false
132
+
133
+ if (this.process) {
134
+ try {
135
+ this.process.kill('SIGTERM')
136
+ } catch (e) {
137
+ // 忽略错误
138
+ }
139
+ this.process = undefined
140
+ }
141
+
142
+ this.cleanup()
143
+ this.chunks = []
144
+ this.hasEnded = false
145
+
146
+ this.events?.onStop?.()
147
+ }
148
+
149
+ setVolume(_volume: number): void {
150
+ // PowerShell SoundPlayer 不支持音量控制
151
+ }
152
+
153
+ destroy(): void {
154
+ this.stop()
155
+ }
156
+
157
+ private cleanup(): void {
158
+ if (this.tempFile && this.tempFile.startsWith(tmpdir())) {
159
+ try {
160
+ if (existsSync(this.tempFile)) {
161
+ unlinkSync(this.tempFile)
162
+ }
163
+ } catch (e) {
164
+ // 忽略清理错误
165
+ }
166
+ this.tempFile = undefined
167
+ }
168
+ }
169
+
170
+ private handleError(error: Error): void {
171
+ this.events?.onError?.(error)
172
+ }
173
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Audio Player Module
3
+ * 音频播放引擎 - 支持 pause/resume/stop 和流式播放
4
+ */
5
+
6
+ import { EventEmitter } from 'events'
7
+ import { spawn, ChildProcess } from 'child_process'
8
+ import fs from 'fs'
9
+ import { createWriteStream } from 'fs'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+ import { TTSError, TTSErrorCode } from './types'
13
+
14
+ /**
15
+ * Player Events - 播放事件回调接口
16
+ */
17
+ export interface PlayerEvents {
18
+ onStart?: () => void
19
+ onEnd?: () => void
20
+ onError?: (error: Error) => void
21
+ onProgress?: (progress: { current: number; total: number }) => void
22
+ onPause?: () => void
23
+ onResume?: () => void
24
+ onStop?: () => void
25
+ }
26
+
27
+ /**
28
+ * Player 接口 - 音频播放器标准接口
29
+ */
30
+ export interface Player {
31
+ play(audioData: Buffer | ReadableStream, format: string): Promise<void>
32
+ pause(): void
33
+ resume(): void
34
+ stop(): void
35
+ isPlaying(): boolean
36
+ isPaused(): boolean
37
+ }
38
+
39
+ /**
40
+ * AudioPlayer - 基于系统播放器的音频播放实现
41
+ * 支持 macOS (afplay), Linux (aplay), Windows (PowerShell)
42
+ */
43
+ export class AudioPlayer extends EventEmitter implements Player {
44
+ private _playing = false
45
+ private _paused = false
46
+ private currentProcess?: ChildProcess
47
+ private currentFile?: string
48
+
49
+ constructor(protected events?: PlayerEvents) {
50
+ super()
51
+ }
52
+
53
+ /**
54
+ * 播放音频
55
+ * @param audioData 音频数据 (Buffer 或 ReadableStream)
56
+ * @param format 音频格式 (mp3, wav, etc.)
57
+ */
58
+ async play(audioData: Buffer | ReadableStream, format: string): Promise<void> {
59
+ // 如果正在播放,先停止
60
+ if (this._playing) {
61
+ await this.stop()
62
+ }
63
+
64
+ this._playing = true
65
+ this._paused = false
66
+
67
+ try {
68
+ // 将音频数据写入临时文件
69
+ const tempFile = join(tmpdir(), `ocosay-${Date.now()}.${format}`)
70
+ this.currentFile = tempFile
71
+
72
+ if (Buffer.isBuffer(audioData)) {
73
+ fs.writeFileSync(tempFile, audioData)
74
+ } else {
75
+ const writeStream = createWriteStream(tempFile)
76
+ const reader = audioData.getReader()
77
+
78
+ try {
79
+ while (true) {
80
+ const { done, value } = await reader.read()
81
+ if (done) break
82
+ if (value instanceof Uint8Array) {
83
+ writeStream.write(Buffer.from(value))
84
+ }
85
+ }
86
+ writeStream.end()
87
+ } finally {
88
+ reader.releaseLock()
89
+ }
90
+
91
+ await new Promise<void>((resolve, reject) => {
92
+ writeStream.on('finish', resolve)
93
+ writeStream.on('error', reject)
94
+ })
95
+ }
96
+
97
+ // 触发开始事件
98
+ this.events?.onStart?.()
99
+ this.emit('start')
100
+
101
+ // 根据格式选择播放器
102
+ await this.playFile(tempFile, format)
103
+
104
+ // 播放完成
105
+ this._playing = false
106
+ this.events?.onEnd?.()
107
+ this.emit('end')
108
+
109
+ // 清理临时文件
110
+ this.cleanup()
111
+
112
+ } catch (error: any) {
113
+ this._playing = false
114
+ const ttsError = new TTSError(
115
+ error.message || 'Playback failed',
116
+ TTSErrorCode.PLAYER_ERROR,
117
+ 'player'
118
+ )
119
+ this.events?.onError?.(ttsError)
120
+ this.emit('error', ttsError)
121
+ throw ttsError
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 播放音频文件
127
+ * 优先使用 afplay (macOS), aplay (Linux), 否则用 PowerShell (Windows)
128
+ */
129
+ private playFile(filePath: string, format: string): Promise<void> {
130
+ const platform = process.platform
131
+
132
+ return new Promise((resolve, reject) => {
133
+ let command: string
134
+ let args: string[]
135
+
136
+ if (platform === 'darwin') {
137
+ // macOS
138
+ command = 'afplay'
139
+ args = [filePath]
140
+ } else if (platform === 'linux') {
141
+ // Linux
142
+ command = 'aplay'
143
+ args = [filePath]
144
+ } else {
145
+ // Windows 或其他
146
+ command = 'powershell'
147
+ args = ['-c', `(New-Object System.Media.SoundPlayer('${filePath.replace(/\\/g, '\\\\')}')).PlaySync()`]
148
+ }
149
+
150
+ this.currentProcess = spawn(command, args, {
151
+ stdio: 'ignore',
152
+ detached: false
153
+ })
154
+
155
+ this.currentProcess.on('exit', (code: number | null, signal: string | null) => {
156
+ // 如果是被信号终止(如 SIGTERM),不当作错误
157
+ if (signal === 'SIGTERM' || signal === 'SIGINT') {
158
+ resolve()
159
+ } else if (code === 0) {
160
+ resolve()
161
+ } else {
162
+ reject(new Error(`Player exited with code ${code}`))
163
+ }
164
+ })
165
+
166
+ this.currentProcess.on('error', (error: Error) => {
167
+ reject(error)
168
+ })
169
+ })
170
+ }
171
+
172
+ /**
173
+ * 暂停播放
174
+ * 注意: 目前通过 SIGSTOP 实现,真正的 pause 需要支持暂停的音频库
175
+ */
176
+ pause(): void {
177
+ if (!this._playing || this._paused) return
178
+
179
+ if (this.currentProcess) {
180
+ try {
181
+ this.currentProcess.kill('SIGSTOP')
182
+ this._paused = true
183
+ this.events?.onPause?.()
184
+ this.emit('pause')
185
+ } catch (e) {
186
+ // 如果 kill 失败,忽略
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 恢复播放
193
+ */
194
+ resume(): void {
195
+ if (!this._playing || !this._paused) return
196
+
197
+ if (this.currentProcess) {
198
+ try {
199
+ this.currentProcess.kill('SIGCONT')
200
+ this._paused = false
201
+ this.events?.onResume?.()
202
+ this.emit('resume')
203
+ } catch (e) {
204
+ // 如果 kill 失败,忽略
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 停止播放
211
+ */
212
+ async stop(): Promise<void> {
213
+ this._playing = false
214
+ this._paused = false
215
+
216
+ if (this.currentProcess) {
217
+ try {
218
+ this.currentProcess.kill('SIGTERM')
219
+ } catch (e) {
220
+ // 忽略错误
221
+ }
222
+ this.currentProcess = undefined
223
+ }
224
+
225
+ this.cleanup()
226
+
227
+ this.events?.onStop?.()
228
+ this.emit('stop')
229
+ }
230
+
231
+ /**
232
+ * 是否正在播放
233
+ */
234
+ isPlaying(): boolean {
235
+ return this._playing
236
+ }
237
+
238
+ /**
239
+ * 是否暂停
240
+ */
241
+ isPaused(): boolean {
242
+ return this._paused
243
+ }
244
+
245
+ /**
246
+ * 清理临时文件
247
+ */
248
+ private cleanup(): void {
249
+ if (this.currentFile) {
250
+ try {
251
+ if (fs.existsSync(this.currentFile)) {
252
+ fs.unlinkSync(this.currentFile)
253
+ }
254
+ } catch (e) {
255
+ // 忽略清理错误
256
+ }
257
+ this.currentFile = undefined
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * StreamingPlayer - 流式播放器,支持边下载边播放
264
+ * 注意: 当前实现是下载完毕后再播放,onProgress 事件用于报告下载进度
265
+ */
266
+ export class StreamingPlayer extends AudioPlayer {
267
+ private chunks: Buffer[] = []
268
+ private downloadedSize = 0
269
+
270
+ constructor(events?: PlayerEvents) {
271
+ super(events)
272
+ }
273
+
274
+ /**
275
+ * 流式下载并播放
276
+ * @param stream 可读流
277
+ * @param format 音频格式
278
+ * @param expectedSize 预期大小(可选),用于进度报告
279
+ */
280
+ async streamAndPlay(
281
+ stream: ReadableStream,
282
+ format: string,
283
+ expectedSize?: number
284
+ ): Promise<void> {
285
+ const reader = stream.getReader()
286
+
287
+ try {
288
+ while (true) {
289
+ const { done, value } = await reader.read()
290
+ if (done) break
291
+
292
+ if (value instanceof Buffer) {
293
+ this.chunks.push(value)
294
+ this.downloadedSize += value.length
295
+
296
+ // 触发下载进度
297
+ if (expectedSize) {
298
+ this.emit('progress', {
299
+ current: this.downloadedSize,
300
+ total: expectedSize
301
+ })
302
+ this.events?.onProgress?.({
303
+ current: this.downloadedSize,
304
+ total: expectedSize
305
+ })
306
+ }
307
+ }
308
+ }
309
+
310
+ // 合并所有 chunk 并播放
311
+ const fullAudio = Buffer.concat(this.chunks)
312
+ await this.play(fullAudio, format)
313
+
314
+ } finally {
315
+ reader.releaseLock()
316
+ this.chunks = []
317
+ this.downloadedSize = 0
318
+ }
319
+ }
320
+ }
321
+
322
+ export default AudioPlayer