@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
package/TECH_PLAN.md ADDED
@@ -0,0 +1,352 @@
1
+ # ocosay - OpenCode TTS 播放插件技术方案
2
+
3
+ ## 项目概述
4
+
5
+ **ocosay** 是 OpenCode 的 TTS(文本转语音)播放插件,支持:
6
+ - 文本转语音自动播放
7
+ - 暂停/停止/恢复控制
8
+ - 多 TTS 模型可扩展架构
9
+ - 同步/异步/流式 多种合成模式
10
+
11
+ ## 架构设计
12
+
13
+ ### 核心原则
14
+ - **可扩展架构**:抽象 TTS Provider 接口,MiniMax 作为第一个实现,未来可扩展其他提供商
15
+ - **Provider 隔离**:每个 TTS 模型独立 Provider 实现,核心逻辑与模型无关
16
+ - **合成模式可配置**:通过 `model` 参数指定 `sync` | `async` | `stream`
17
+
18
+ ### 目录结构
19
+
20
+ ```
21
+ ocosay/
22
+ ├── src/
23
+ │ ├── providers/
24
+ │ │ ├── base.ts # TTS Provider 接口定义
25
+ │ │ ├── minimax.ts # MiniMax TTS Provider
26
+ │ │ └── index.ts # Provider 导出
27
+ │ ├── core/
28
+ │ │ ├── player.ts # 音频播放引擎
29
+ │ │ ├── speaker.ts # 统一调用入口
30
+ │ │ └── types.ts # 公共类型定义
31
+ │ ├── tools/
32
+ │ │ └── tts.ts # OpenCode 自定义工具
33
+ │ ├── index.ts # Plugin 入口
34
+ │ └── config.ts # 配置管理
35
+ ├── package.json
36
+ ├── tsconfig.json
37
+ └── README.md
38
+ ```
39
+
40
+ ---
41
+
42
+ ## 核心接口设计
43
+
44
+ ### 错误类型
45
+
46
+ ```typescript
47
+ // 错误类型定义
48
+ enum TTSErrorCode {
49
+ NETWORK = 'NETWORK',
50
+ AUTH = 'AUTH',
51
+ QUOTA = 'QUOTA',
52
+ INVALID_VOICE = 'INVALID_VOICE',
53
+ INVALID_PARAMS = 'INVALID_PARAMS',
54
+ PLAYER_ERROR = 'PLAYER_ERROR',
55
+ UNKNOWN = 'UNKNOWN'
56
+ }
57
+
58
+ class TTSError extends Error {
59
+ constructor(
60
+ message: string,
61
+ code: TTSErrorCode,
62
+ provider: string,
63
+ details?: unknown
64
+ ) {
65
+ super(message)
66
+ this.name = 'TTSError'
67
+ this.code = code
68
+ this.provider = provider
69
+ this.details = details
70
+ }
71
+
72
+ code: TTSErrorCode
73
+ provider: string
74
+ details?: unknown
75
+ }
76
+ ```
77
+
78
+ ### 音色定义
79
+
80
+ ```typescript
81
+ interface Voice {
82
+ id: string
83
+ name: string
84
+ language?: string // 'zh-CN' | 'en-US' 等
85
+ gender?: 'male' | 'female' | 'neutral'
86
+ previewUrl?: string // 音色预览 URL
87
+ }
88
+ ```
89
+
90
+ ### 能力定义
91
+
92
+ ```typescript
93
+ interface TTSCapabilities {
94
+ // 基础能力(必有)
95
+ speak: true
96
+
97
+ // 高级能力
98
+ voiceClone?: boolean // 音色克隆支持
99
+ stream?: boolean // 流式合成支持
100
+ voiceList?: boolean // 音色列表支持
101
+
102
+ // 合成模式支持
103
+ sync?: boolean // 同步合成支持
104
+ async?: boolean // 异步合成支持
105
+ }
106
+ ```
107
+
108
+ ### 合成配置
109
+
110
+ ```typescript
111
+ // 合成模式枚举
112
+ type SynthesisModel = 'sync' | 'async' | 'stream'
113
+
114
+ // Speak 配置参数
115
+ interface SpeakOptions {
116
+ voice?: string // 音色 ID
117
+ model?: SynthesisModel // 合成模式,默认 stream
118
+ speed?: number // 语速 0.5-2.0
119
+ volume?: number // 音量 0-100
120
+ pitch?: number // 音调 0.5-2.0
121
+
122
+ // 音色克隆参数(可选)
123
+ sourceVoice?: string // 克隆源音色 URL
124
+ }
125
+
126
+ interface AudioResult {
127
+ audioData: Buffer | ReadableStream
128
+ sampleRate?: number
129
+ channels?: number
130
+ duration?: number
131
+ format: string // 'mp3' | 'wav' | 'flac'
132
+ isStream: boolean
133
+ }
134
+ ```
135
+
136
+ ### TTSProvider 接口
137
+
138
+ ```typescript
139
+ interface TTSProvider {
140
+ name: string
141
+ capabilities: TTSCapabilities
142
+
143
+ // 生命周期
144
+ initialize(): Promise<void>
145
+ destroy(): Promise<void>
146
+
147
+ // 核心能力
148
+ speak(text: string, options?: SpeakOptions): Promise<AudioResult>
149
+ pause(): Promise<void>
150
+ resume(): Promise<void>
151
+ stop(): Promise<void>
152
+ listVoices(): Promise<Voice[]>
153
+
154
+ // 查询能力
155
+ getCapabilities(): TTSCapabilities
156
+ }
157
+ ```
158
+
159
+ ### Provider 注册机制
160
+
161
+ ```typescript
162
+ // 静态注册
163
+ const providers = new Map<string, TTSProvider>()
164
+
165
+ export function registerProvider(name: string, provider: TTSProvider): void {
166
+ providers.set(name, provider)
167
+ }
168
+
169
+ export function getProvider(name: string): TTSProvider {
170
+ const provider = providers.get(name)
171
+ if (!provider) {
172
+ throw new TTSError(
173
+ `TTS Provider "${name}" not found`,
174
+ TTSErrorCode.UNKNOWN,
175
+ 'system'
176
+ )
177
+ }
178
+ return provider
179
+ }
180
+
181
+ export function listProviders(): string[] {
182
+ return Array.from(providers.keys())
183
+ }
184
+
185
+ export function hasProvider(name: string): boolean {
186
+ return providers.has(name)
187
+ }
188
+ ```
189
+
190
+ ---
191
+
192
+ ## 事件系统
193
+
194
+ ```typescript
195
+ // 事件类型
196
+ type TTSEvent =
197
+ | 'start' // 开始播放
198
+ | 'end' // 播放结束
199
+ | 'error' // 错误发生
200
+ | 'progress' // 播放进度(流式场景)
201
+ | 'pause' // 暂停
202
+ | 'resume' // 恢复
203
+ | 'stop' // 停止
204
+
205
+ // 事件处理器
206
+ interface SpeakerEvents {
207
+ on(event: 'start', handler: (text: string) => void): void
208
+ on(event: 'end', handler: (text: string) => void): void
209
+ on(event: 'error', handler: (error: TTSError) => void): void
210
+ on(event: 'progress', handler: (progress: { current: number; total: number }) => void): void
211
+ on(event: 'pause', handler: () => void): void
212
+ on(event: 'resume', handler: () => void): void
213
+ on(event: 'stop', handler: () => void): void
214
+
215
+ off(event: TTSEvent, handler: Function): void
216
+ }
217
+ ```
218
+
219
+ ---
220
+
221
+ ## OpenCode Plugin 集成
222
+
223
+ ```typescript
224
+ // src/index.ts
225
+ export default {
226
+ name: 'ocosay',
227
+ version: '1.0.0',
228
+
229
+ tools: [
230
+ {
231
+ name: 'tts_speak',
232
+ description: '将文本转换为语音并播放',
233
+ input: {
234
+ text: { type: 'string', required: true },
235
+ provider: { type: 'string', default: 'minimax' },
236
+ voice: { type: 'string', optional: true },
237
+ model: { type: 'string', enum: ['sync', 'async', 'stream'], default: 'stream' },
238
+ speed: { type: 'number', optional: true },
239
+ volume: { type: 'number', optional: true },
240
+ pitch: { type: 'number', optional: true }
241
+ }
242
+ },
243
+ {
244
+ name: 'tts_stop',
245
+ description: '停止当前播放'
246
+ },
247
+ {
248
+ name: 'tts_pause',
249
+ description: '暂停当前播放'
250
+ },
251
+ {
252
+ name: 'tts_resume',
253
+ description: '恢复暂停的播放'
254
+ },
255
+ {
256
+ name: 'tts_list_voices',
257
+ description: '列出可用音色',
258
+ input: {
259
+ provider: { type: 'string', default: 'minimax' }
260
+ }
261
+ },
262
+ {
263
+ name: 'tts_list_providers',
264
+ description: '列出所有已注册的 TTS 提供商'
265
+ }
266
+ ],
267
+
268
+ session: {
269
+ idle: () => { /* 清理资源 - 调用所有 Provider.destroy() */ }
270
+ }
271
+ }
272
+ ```
273
+
274
+ ---
275
+
276
+ ## MiniMax Provider 实现
277
+
278
+ ### API 能力映射
279
+
280
+ | MiniMax API | 映射到 ocosay | 说明 |
281
+ |-------------|--------------|------|
282
+ | T2A v2.5 (WebSocket) | `model: 'stream'` | 流式播放 |
283
+ | T2A v3 (同步) | `model: 'sync'` | 同步返回 |
284
+ | T2A v2 (异步) | `model: 'async'` | 异步轮询 |
285
+ | 音色列表 | `listVoices()` | |
286
+ | 音色克隆 | `voiceClone()` (via sourceVoice) | 通过 sourceVoice 参数指定克隆源 |
287
+
288
+ ### 配置项
289
+
290
+ ```typescript
291
+ interface MiniMaxConfig {
292
+ apiKey: string
293
+ appId?: string
294
+ voiceId?: string // 默认音色
295
+ model?: SynthesisModel // 默认合成模式
296
+ audioFormat?: 'mp3' | 'wav' | 'flac'
297
+ speed?: number // 0.5 - 2.0
298
+ volume?: number // 0 - 100
299
+ pitch?: number // 0.5 - 2.0
300
+ }
301
+
302
+ function validateConfig(config: MiniMaxConfig): void {
303
+ if (!config.apiKey) {
304
+ throw new TTSError('API key is required', TTSErrorCode.AUTH, 'minimax')
305
+ }
306
+ if (config.speed !== undefined && (config.speed < 0.5 || config.speed > 2.0)) {
307
+ throw new TTSError('Speed must be between 0.5 and 2.0', TTSErrorCode.INVALID_PARAMS, 'minimax')
308
+ }
309
+ if (config.volume !== undefined && (config.volume < 0 || config.volume > 100)) {
310
+ throw new TTSError('Volume must be between 0 and 100', TTSErrorCode.INVALID_PARAMS, 'minimax')
311
+ }
312
+ }
313
+ ```
314
+
315
+ ---
316
+
317
+ ## 评审修复记录
318
+
319
+ | 日期 | 修复内容 | 评审来源 |
320
+ |------|---------|---------|
321
+ | 2026-04-04 | speak() 添加 SpeakOptions 配置参数 | 萍萍 |
322
+ | 2026-04-04 | pause()/stop()/resume() 改为返回 Promise | 凤雏 |
323
+ | 2026-04-04 | 添加 TTSError 类型定义 | 凤雏 |
324
+ | 2026-04-04 | 同步/异步/流式 改为 model 参数可配置 | 老爷确认 |
325
+ | 2026-04-04 | 添加 Speaker 事件系统 | 小猪 |
326
+ | 2026-04-04 | 添加 listProviders() 方法 | 萍萍 |
327
+ | 2026-04-04 | 添加 Provider 生命周期 initialize()/destroy() | 凤雏 |
328
+
329
+ ---
330
+
331
+ ## 技术栈
332
+
333
+ - **语言**: TypeScript
334
+ - **运行时**: Node.js
335
+ - **音频播放**: node-simplespeaker / node-player / play-sound
336
+ - **HTTP 客户端**: axios
337
+ - **WebSocket**: ws (for MiniMax 流式)
338
+ - **构建**: npm
339
+
340
+ ## 下一步计划
341
+
342
+ 1. 创建项目结构
343
+ 2. 实现 types.ts - 核心类型定义
344
+ 3. 实现 base.ts - Provider 接口
345
+ 4. 实现 config.ts - 配置管理
346
+ 5. 实现 MiniMax Provider
347
+ 6. 实现 player.ts - 音频播放引擎
348
+ 7. 实现 speaker.ts - 统一调用入口
349
+ 8. 实现 tools/tts.ts - OpenCode 工具
350
+ 9. 实现 index.ts - Plugin 入口
351
+ 10. 单元测试
352
+ 11. 文档编写
@@ -0,0 +1,32 @@
1
+ const schema = {
2
+ string: () => ({
3
+ describe: (desc: string) => ({ describe: desc }),
4
+ optional: () => ({
5
+ describe: (desc: string) => ({ describe: desc })
6
+ }),
7
+ number: () => ({
8
+ describe: (desc: string) => ({ describe: desc }),
9
+ optional: () => ({
10
+ describe: (desc: string) => ({ describe: desc })
11
+ })
12
+ }),
13
+ enum: (values: string[]) => ({
14
+ describe: (desc: string) => ({ describe: desc }),
15
+ optional: () => ({
16
+ describe: (desc: string) => ({ describe: desc })
17
+ })
18
+ })
19
+ })
20
+ }
21
+
22
+ function tool({ description, args, execute }: any) {
23
+ return {
24
+ description,
25
+ args,
26
+ execute,
27
+ schema
28
+ }
29
+ }
30
+
31
+ export { tool, schema }
32
+ export const Plugin = jest.fn()
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Configuration Management
3
+ * 配置管理模块
4
+ */
5
+ import { GlobalConfig, ProviderConfig, OcosayConfig } from './core/types';
6
+ declare const DEFAULT_CONFIG: OcosayConfig;
7
+ declare class ConfigManager {
8
+ private config;
9
+ private configPath;
10
+ private providers;
11
+ constructor(configPath?: string);
12
+ private loadConfig;
13
+ saveConfig(): void;
14
+ getGlobal(): OcosayConfig;
15
+ getProviderConfig(providerName: string): ProviderConfig | undefined;
16
+ setProviderConfig(providerName: string, config: ProviderConfig): void;
17
+ getDefaultProvider(): string;
18
+ setDefaultProvider(provider: string): void;
19
+ validateApiKey(provider: string, apiKey: string): void;
20
+ validateStreamConfig(): void;
21
+ }
22
+ export declare const configManager: ConfigManager;
23
+ export { ConfigManager, DEFAULT_CONFIG };
24
+ export type { OcosayConfig, GlobalConfig, ProviderConfig };
25
+ export default configManager;
26
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAKzE,QAAA,MAAM,cAAc,EAAE,YAarB,CAAA;AAED,cAAM,aAAa;IACjB,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,SAAS,CAAqC;gBAE1C,UAAU,CAAC,EAAE,MAAM;IAO/B,OAAO,CAAC,UAAU;IAelB,UAAU,IAAI,IAAI;IAalB,SAAS,IAAI,YAAY;IAIzB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAInE,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI;IAKrE,kBAAkB,IAAI,MAAM;IAI5B,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK1C,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAUtD,oBAAoB,IAAI,IAAI;CAgB7B;AAGD,eAAO,MAAM,aAAa,eAAsB,CAAA;AAEhD,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,CAAA;AACxC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,CAAA;AAC1D,eAAe,aAAa,CAAA"}
package/dist/config.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Configuration Management
3
+ * 配置管理模块
4
+ */
5
+ import { TTSError, TTSErrorCode } from './core/types';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ const DEFAULT_CONFIG = {
9
+ enabled: true,
10
+ autoPlay: false,
11
+ autoRead: false, // 豆包模式开关
12
+ streamMode: true, // 流式朗读模式
13
+ streamBufferSize: 30, // 缓冲字符数
14
+ streamBufferTimeout: 2000, // 缓冲区超时(ms)
15
+ provider: 'minimax',
16
+ ttsModel: 'speech-02-turbo',
17
+ baseURL: 'https://api.minimax.io',
18
+ speed: 1.0,
19
+ volume: 80,
20
+ pitch: 1.0
21
+ };
22
+ class ConfigManager {
23
+ config;
24
+ configPath;
25
+ providers = {};
26
+ constructor(configPath) {
27
+ this.configPath = configPath || './config.json';
28
+ const loaded = this.loadConfig();
29
+ this.config = loaded;
30
+ this.providers = loaded.providers || {};
31
+ }
32
+ loadConfig() {
33
+ try {
34
+ if (fs.existsSync(this.configPath)) {
35
+ const content = fs.readFileSync(this.configPath, 'utf-8');
36
+ const parsed = JSON.parse(content);
37
+ this.providers = parsed.providers || {};
38
+ const { providers, ...config } = parsed;
39
+ return { ...DEFAULT_CONFIG, ...config };
40
+ }
41
+ }
42
+ catch (error) {
43
+ console.warn('Failed to load config, using defaults');
44
+ }
45
+ return { ...DEFAULT_CONFIG };
46
+ }
47
+ saveConfig() {
48
+ try {
49
+ const dir = path.dirname(this.configPath);
50
+ if (!fs.existsSync(dir)) {
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ }
53
+ const configWithProviders = { ...this.config, providers: this.providers };
54
+ fs.writeFileSync(this.configPath, JSON.stringify(configWithProviders, null, 2));
55
+ }
56
+ catch (error) {
57
+ console.error('Failed to save config:', error);
58
+ }
59
+ }
60
+ getGlobal() {
61
+ return this.config;
62
+ }
63
+ getProviderConfig(providerName) {
64
+ return this.providers[providerName];
65
+ }
66
+ setProviderConfig(providerName, config) {
67
+ this.providers[providerName] = { enabled: true, ...config };
68
+ this.saveConfig();
69
+ }
70
+ getDefaultProvider() {
71
+ return this.config.provider || 'minimax';
72
+ }
73
+ setDefaultProvider(provider) {
74
+ this.config.provider = provider;
75
+ this.saveConfig();
76
+ }
77
+ validateApiKey(provider, apiKey) {
78
+ if (!apiKey) {
79
+ throw new TTSError(`API key is required for provider "${provider}"`, TTSErrorCode.AUTH, provider);
80
+ }
81
+ }
82
+ validateStreamConfig() {
83
+ if ((this.config.streamBufferSize ?? 30) < 5) {
84
+ throw new TTSError('streamBufferSize must be at least 5', TTSErrorCode.INVALID_PARAMS, 'config');
85
+ }
86
+ if ((this.config.streamBufferTimeout ?? 2000) < 500) {
87
+ throw new TTSError('streamBufferTimeout must be at least 500ms', TTSErrorCode.INVALID_PARAMS, 'config');
88
+ }
89
+ }
90
+ }
91
+ // 单例导出
92
+ export const configManager = new ConfigManager();
93
+ export { ConfigManager, DEFAULT_CONFIG };
94
+ export default configManager;
95
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Afplay Backend - macOS 平台音频播放后端
3
+ * 使用系统内置的 afplay 命令
4
+ */
5
+ import { AudioBackend, BackendOptions } from './base';
6
+ /**
7
+ * AfplayBackend - macOS 原生音频播放后端
8
+ * 不支持真正的流式播放,需要先将数据写入临时文件
9
+ */
10
+ export declare class AfplayBackend implements AudioBackend {
11
+ readonly name = "afplay";
12
+ readonly supportsStreaming = false;
13
+ private process?;
14
+ private tempFile?;
15
+ private events?;
16
+ private _started;
17
+ private _paused;
18
+ private _stopped;
19
+ private chunks;
20
+ private hasEnded;
21
+ constructor(options?: BackendOptions);
22
+ start(filePath: string): void;
23
+ write(chunk: Buffer): void;
24
+ end(): void;
25
+ pause(): void;
26
+ resume(): void;
27
+ stop(): void;
28
+ setVolume(_volume: number): void;
29
+ destroy(): void;
30
+ private cleanup;
31
+ private handleError;
32
+ }
33
+ //# sourceMappingURL=afplay-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"afplay-backend.d.ts","sourceRoot":"","sources":["../../../src/core/backends/afplay-backend.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAE,YAAY,EAAsB,cAAc,EAAE,MAAM,QAAQ,CAAA;AAQzE;;;GAGG;AACH,qBAAa,aAAc,YAAW,YAAY;IAChD,QAAQ,CAAC,IAAI,YAAW;IACxB,QAAQ,CAAC,iBAAiB,SAAQ;IAElC,OAAO,CAAC,OAAO,CAAC,CAAc;IAC9B,OAAO,CAAC,QAAQ,CAAC,CAAQ;IACzB,OAAO,CAAC,MAAM,CAAC,CAAoB;IACnC,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAQ;IAExB,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,QAAQ,CAAQ;gBAEZ,OAAO,GAAE,cAAmB;IAIxC,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAgC7B,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAM1B,GAAG,IAAI,IAAI;IAaX,KAAK,IAAI,IAAI;IAcb,MAAM,IAAI,IAAI;IAcd,IAAI,IAAI,IAAI;IAsBZ,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAIhC,OAAO,IAAI,IAAI;IAIf,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,WAAW;CAGpB"}
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Afplay Backend - macOS 平台音频播放后端
3
+ * 使用系统内置的 afplay 命令
4
+ */
5
+ import { execFile } from 'child_process';
6
+ import { tmpdir } from 'os';
7
+ import { join } from 'path';
8
+ import { writeFileSync, unlinkSync, existsSync } from 'fs';
9
+ // 白名单:只允许特定路径格式(禁止 - 防止命令注入)
10
+ const SAFE_PATH_REGEX = /^[\w\/\.]+$/;
11
+ /**
12
+ * AfplayBackend - macOS 原生音频播放后端
13
+ * 不支持真正的流式播放,需要先将数据写入临时文件
14
+ */
15
+ export class AfplayBackend {
16
+ name = 'afplay';
17
+ supportsStreaming = false;
18
+ process;
19
+ tempFile;
20
+ events;
21
+ _started = false;
22
+ _paused = false;
23
+ _stopped = false;
24
+ // P0-4: 缓冲所有chunk,等end()时一次性写入文件
25
+ chunks = [];
26
+ hasEnded = false;
27
+ constructor(options = {}) {
28
+ this.events = options.events;
29
+ }
30
+ start(filePath) {
31
+ if (this._started)
32
+ return;
33
+ if (!SAFE_PATH_REGEX.test(filePath)) {
34
+ throw new Error(`Invalid file path: ${filePath}`);
35
+ }
36
+ this.tempFile = filePath;
37
+ this._started = true;
38
+ this._stopped = false;
39
+ this.events?.onStart?.();
40
+ // 启动播放进程
41
+ this.process = execFile('afplay', [filePath], (error) => {
42
+ if (this._stopped)
43
+ return;
44
+ if (error) {
45
+ this.handleError(error);
46
+ return;
47
+ }
48
+ // 播放正常结束
49
+ this._started = false;
50
+ this.events?.onEnd?.();
51
+ });
52
+ this.process.on('error', (error) => {
53
+ this.handleError(error);
54
+ });
55
+ }
56
+ write(chunk) {
57
+ if (this._stopped)
58
+ return;
59
+ // P0-4: 缓冲所有chunk,等end()时一次性写入
60
+ this.chunks.push(chunk);
61
+ }
62
+ end() {
63
+ if (this._stopped || this.hasEnded)
64
+ return;
65
+ this.hasEnded = true;
66
+ if (this.chunks.length === 0)
67
+ return;
68
+ // P0-4: 所有chunk缓冲完毕后,一次性写入文件并播放
69
+ this.tempFile = join(tmpdir(), `ocosay-${Date.now()}.wav`);
70
+ writeFileSync(this.tempFile, Buffer.concat(this.chunks));
71
+ this.chunks = [];
72
+ this.start(this.tempFile);
73
+ }
74
+ pause() {
75
+ if (!this._started || this._paused || this._stopped)
76
+ return;
77
+ if (this.process) {
78
+ try {
79
+ this.process.kill('SIGSTOP');
80
+ this._paused = true;
81
+ this.events?.onPause?.();
82
+ }
83
+ catch (e) {
84
+ // SIGSTOP 可能失败
85
+ }
86
+ }
87
+ }
88
+ resume() {
89
+ if (!this._paused || this._stopped)
90
+ return;
91
+ if (this.process) {
92
+ try {
93
+ this.process.kill('SIGCONT');
94
+ this._paused = false;
95
+ this.events?.onResume?.();
96
+ }
97
+ catch (e) {
98
+ // SIGCONT 可能失败
99
+ }
100
+ }
101
+ }
102
+ stop() {
103
+ this._stopped = true;
104
+ this._started = false;
105
+ this._paused = false;
106
+ if (this.process) {
107
+ try {
108
+ this.process.kill('SIGTERM');
109
+ }
110
+ catch (e) {
111
+ // 忽略错误
112
+ }
113
+ this.process = undefined;
114
+ }
115
+ // 清理临时文件
116
+ this.cleanup();
117
+ this.chunks = [];
118
+ this.hasEnded = false;
119
+ this.events?.onStop?.();
120
+ }
121
+ setVolume(_volume) {
122
+ // afplay 不支持命令行设置音量
123
+ }
124
+ destroy() {
125
+ this.stop();
126
+ }
127
+ cleanup() {
128
+ if (this.tempFile && this.tempFile.startsWith(tmpdir())) {
129
+ try {
130
+ if (existsSync(this.tempFile)) {
131
+ unlinkSync(this.tempFile);
132
+ }
133
+ }
134
+ catch (e) {
135
+ // 忽略清理错误
136
+ }
137
+ this.tempFile = undefined;
138
+ }
139
+ }
140
+ handleError(error) {
141
+ this.events?.onError?.(error);
142
+ }
143
+ }
144
+ //# sourceMappingURL=afplay-backend.js.map