@mingxy/ocosay 1.0.2 → 1.0.4
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 +21 -0
- package/dist/config.d.ts +21 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/plugin.js +1907 -138
- package/dist/plugin.js.map +7 -1
- package/package.json +4 -2
- package/TECH_PLAN.md +0 -352
- package/__mocks__/@opencode-ai/plugin.ts +0 -32
- package/jest.config.js +0 -15
- package/src/config.ts +0 -149
- package/src/core/backends/afplay-backend.ts +0 -162
- package/src/core/backends/aplay-backend.ts +0 -160
- package/src/core/backends/base.ts +0 -117
- package/src/core/backends/index.ts +0 -128
- package/src/core/backends/naudiodon-backend.ts +0 -164
- package/src/core/backends/powershell-backend.ts +0 -173
- package/src/core/player.ts +0 -322
- package/src/core/speaker.ts +0 -283
- package/src/core/stream-player.ts +0 -326
- package/src/core/stream-reader.ts +0 -190
- package/src/core/streaming-synthesizer.ts +0 -123
- package/src/core/types.ts +0 -185
- package/src/index.ts +0 -236
- package/src/plugin.ts +0 -178
- package/src/providers/base.ts +0 -150
- package/src/providers/minimax.ts +0 -515
- package/src/tools/tts.ts +0 -277
- package/src/types/config.ts +0 -38
- package/src/types/naudiodon.d.ts +0 -19
- package/tests/__mocks__/@opencode-ai/plugin.ts +0 -32
- package/tests/backends.test.ts +0 -831
- package/tests/config.test.ts +0 -327
- package/tests/index.test.ts +0 -201
- package/tests/integration-test.d.ts +0 -6
- package/tests/integration-test.d.ts.map +0 -1
- package/tests/integration-test.js +0 -84
- package/tests/integration-test.js.map +0 -1
- package/tests/integration-test.ts +0 -93
- package/tests/p1-fixes.test.ts +0 -160
- package/tests/plugin.test.ts +0 -312
- package/tests/provider.test.d.ts +0 -2
- package/tests/provider.test.d.ts.map +0 -1
- package/tests/provider.test.js +0 -69
- package/tests/provider.test.js.map +0 -1
- package/tests/provider.test.ts +0 -87
- package/tests/speaker.test.d.ts +0 -2
- package/tests/speaker.test.d.ts.map +0 -1
- package/tests/speaker.test.js +0 -63
- package/tests/speaker.test.js.map +0 -1
- package/tests/speaker.test.ts +0 -232
- package/tests/stream-player.test.ts +0 -303
- package/tests/stream-reader.test.ts +0 -269
- package/tests/streaming-synthesizer.test.ts +0 -225
- package/tests/tts-tools.test.ts +0 -270
- package/tests/types.test.d.ts +0 -2
- package/tests/types.test.d.ts.map +0 -1
- package/tests/types.test.js +0 -61
- package/tests/types.test.js.map +0 -1
- package/tests/types.test.ts +0 -63
- package/tsconfig.json +0 -22
package/TECH_PLAN.md
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
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. 文档编写
|
|
@@ -1,32 +0,0 @@
|
|
|
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()
|
package/jest.config.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export default {
|
|
2
|
-
preset: 'ts-jest',
|
|
3
|
-
testEnvironment: 'node',
|
|
4
|
-
roots: ['<rootDir>/tests', '<rootDir>/src'],
|
|
5
|
-
testMatch: ['**/*.test.ts'],
|
|
6
|
-
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
7
|
-
collectCoverageFrom: ['src/**/*.ts'],
|
|
8
|
-
coverageDirectory: 'coverage',
|
|
9
|
-
transform: {
|
|
10
|
-
'^.+\\.tsx?$': ['ts-jest', {
|
|
11
|
-
useESM: true
|
|
12
|
-
}]
|
|
13
|
-
},
|
|
14
|
-
extensionsToTreatAsEsm: ['.ts']
|
|
15
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs'
|
|
2
|
-
import * as path from 'path'
|
|
3
|
-
import * as os from 'os'
|
|
4
|
-
import type { OcosayConfig } from './types/config'
|
|
5
|
-
import { DEFAULT_CONFIG } from './types/config'
|
|
6
|
-
|
|
7
|
-
const CONFIG_PATH = path.join(os.homedir(), '.config', 'opencode', 'ocosay.jsonc')
|
|
8
|
-
|
|
9
|
-
export function generateDefaultConfig(): OcosayConfig {
|
|
10
|
-
return {
|
|
11
|
-
...DEFAULT_CONFIG,
|
|
12
|
-
providers: {
|
|
13
|
-
minimax: {
|
|
14
|
-
apiKey: '',
|
|
15
|
-
baseURL: '',
|
|
16
|
-
voiceId: '',
|
|
17
|
-
model: 'stream',
|
|
18
|
-
ttsModel: 'speech-2.8-hd',
|
|
19
|
-
audioFormat: 'mp3'
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function stripComments(jsonc: string): string {
|
|
26
|
-
let result = ''
|
|
27
|
-
let inString = false
|
|
28
|
-
let stringChar = ''
|
|
29
|
-
let i = 0
|
|
30
|
-
|
|
31
|
-
while (i < jsonc.length) {
|
|
32
|
-
const char = jsonc[i]
|
|
33
|
-
|
|
34
|
-
if (!inString && (char === '"' || char === "'" || char === '`')) {
|
|
35
|
-
inString = true
|
|
36
|
-
stringChar = char
|
|
37
|
-
result += char
|
|
38
|
-
i++
|
|
39
|
-
continue
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (inString && char === stringChar && jsonc[i - 1] !== '\\') {
|
|
43
|
-
inString = false
|
|
44
|
-
result += char
|
|
45
|
-
i++
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (inString) {
|
|
50
|
-
result += char
|
|
51
|
-
i++
|
|
52
|
-
continue
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (char === '/' && jsonc[i + 1] === '/') {
|
|
56
|
-
while (i < jsonc.length && jsonc[i] !== '\n') {
|
|
57
|
-
i++
|
|
58
|
-
}
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (char === '/' && jsonc[i + 1] === '*') {
|
|
63
|
-
i += 2
|
|
64
|
-
while (i < jsonc.length - 1 && !(jsonc[i] === '*' && jsonc[i + 1] === '/')) {
|
|
65
|
-
i++
|
|
66
|
-
}
|
|
67
|
-
i += 2
|
|
68
|
-
continue
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
result += char
|
|
72
|
-
i++
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return result
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function mergeWithDefaults(
|
|
79
|
-
loaded: Partial<OcosayConfig>,
|
|
80
|
-
defaults: Omit<OcosayConfig, 'providers'>
|
|
81
|
-
): Omit<OcosayConfig, 'providers'> {
|
|
82
|
-
return {
|
|
83
|
-
enabled: loaded.enabled ?? defaults.enabled,
|
|
84
|
-
autoPlay: loaded.autoPlay ?? defaults.autoPlay,
|
|
85
|
-
autoRead: loaded.autoRead ?? defaults.autoRead,
|
|
86
|
-
streamMode: loaded.streamMode ?? defaults.streamMode,
|
|
87
|
-
streamBufferSize: loaded.streamBufferSize ?? defaults.streamBufferSize,
|
|
88
|
-
streamBufferTimeout: loaded.streamBufferTimeout ?? defaults.streamBufferTimeout,
|
|
89
|
-
speed: loaded.speed ?? defaults.speed,
|
|
90
|
-
volume: loaded.volume ?? defaults.volume,
|
|
91
|
-
pitch: loaded.pitch ?? defaults.pitch
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function loadOrCreateConfig(): OcosayConfig {
|
|
96
|
-
const configDir = path.dirname(CONFIG_PATH)
|
|
97
|
-
|
|
98
|
-
if (!fs.existsSync(configDir)) {
|
|
99
|
-
try {
|
|
100
|
-
fs.mkdirSync(configDir, { recursive: true })
|
|
101
|
-
} catch (err) {
|
|
102
|
-
throw new Error(`[ocosay] 无法创建配置目录 ${configDir}: ${err}`)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!fs.existsSync(CONFIG_PATH)) {
|
|
107
|
-
console.info('[ocosay] 配置文件不存在,正在创建默认配置...')
|
|
108
|
-
const defaultConfig = generateDefaultConfig()
|
|
109
|
-
const configContent = JSON.stringify(defaultConfig, null, 2)
|
|
110
|
-
try {
|
|
111
|
-
fs.writeFileSync(CONFIG_PATH, configContent, 'utf-8')
|
|
112
|
-
try {
|
|
113
|
-
fs.chmodSync(CONFIG_PATH, 0o600)
|
|
114
|
-
} catch (err) {
|
|
115
|
-
console.warn(`[ocosay] 无法设置配置文件权限: ${err}`)
|
|
116
|
-
}
|
|
117
|
-
} catch (err) {
|
|
118
|
-
throw new Error(`[ocosay] 无法写入配置文件 ${CONFIG_PATH}: ${err}`)
|
|
119
|
-
}
|
|
120
|
-
console.info(`[ocosay] 配置文件已创建: ${CONFIG_PATH}`)
|
|
121
|
-
console.info('[ocosay] 请编辑配置文件填入 API Key 和 Base URL')
|
|
122
|
-
return defaultConfig
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const content = fs.readFileSync(CONFIG_PATH, 'utf-8')
|
|
127
|
-
const stripped = stripComments(content)
|
|
128
|
-
const loaded = JSON.parse(stripped) as Partial<OcosayConfig>
|
|
129
|
-
|
|
130
|
-
const merged = mergeWithDefaults(loaded, DEFAULT_CONFIG)
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
...merged,
|
|
134
|
-
providers: {
|
|
135
|
-
minimax: {
|
|
136
|
-
apiKey: loaded.providers?.minimax?.apiKey ?? '',
|
|
137
|
-
baseURL: loaded.providers?.minimax?.baseURL ?? '',
|
|
138
|
-
voiceId: loaded.providers?.minimax?.voiceId ?? '',
|
|
139
|
-
model: loaded.providers?.minimax?.model ?? 'stream',
|
|
140
|
-
ttsModel: loaded.providers?.minimax?.ttsModel ?? 'speech-2.8-hd',
|
|
141
|
-
audioFormat: loaded.providers?.minimax?.audioFormat ?? 'mp3'
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
} catch (error) {
|
|
146
|
-
console.error('[ocosay] 配置文件读取失败,使用默认配置:', error)
|
|
147
|
-
return generateDefaultConfig()
|
|
148
|
-
}
|
|
149
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Afplay Backend - macOS 平台音频播放后端
|
|
3
|
-
* 使用系统内置的 afplay 命令
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { execFile, 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
|
-
// 白名单:只允许特定路径格式(禁止 - 防止命令注入)
|
|
13
|
-
const SAFE_PATH_REGEX = /^[\w\/\.]+$/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* AfplayBackend - macOS 原生音频播放后端
|
|
17
|
-
* 不支持真正的流式播放,需要先将数据写入临时文件
|
|
18
|
-
*/
|
|
19
|
-
export class AfplayBackend implements AudioBackend {
|
|
20
|
-
readonly name = 'afplay'
|
|
21
|
-
readonly supportsStreaming = false
|
|
22
|
-
|
|
23
|
-
private process?: ChildProcess
|
|
24
|
-
private tempFile?: string
|
|
25
|
-
private events?: AudioBackendEvents
|
|
26
|
-
private _started = false
|
|
27
|
-
private _paused = false
|
|
28
|
-
private _stopped = false
|
|
29
|
-
// P0-4: 缓冲所有chunk,等end()时一次性写入文件
|
|
30
|
-
private chunks: Buffer[] = []
|
|
31
|
-
private hasEnded = false
|
|
32
|
-
|
|
33
|
-
constructor(options: BackendOptions = {}) {
|
|
34
|
-
this.events = options.events
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
start(filePath: string): void {
|
|
38
|
-
if (this._started) return
|
|
39
|
-
|
|
40
|
-
if (!SAFE_PATH_REGEX.test(filePath)) {
|
|
41
|
-
throw new Error(`Invalid file path: ${filePath}`)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
this.tempFile = filePath
|
|
45
|
-
this._started = true
|
|
46
|
-
this._stopped = false
|
|
47
|
-
|
|
48
|
-
this.events?.onStart?.()
|
|
49
|
-
|
|
50
|
-
// 启动播放进程
|
|
51
|
-
this.process = execFile('afplay', [filePath], (error) => {
|
|
52
|
-
if (this._stopped) return
|
|
53
|
-
|
|
54
|
-
if (error) {
|
|
55
|
-
this.handleError(error)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 播放正常结束
|
|
60
|
-
this._started = false
|
|
61
|
-
this.events?.onEnd?.()
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
this.process.on('error', (error) => {
|
|
65
|
-
this.handleError(error)
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
write(chunk: Buffer): void {
|
|
70
|
-
if (this._stopped) return
|
|
71
|
-
// P0-4: 缓冲所有chunk,等end()时一次性写入
|
|
72
|
-
this.chunks.push(chunk)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
end(): void {
|
|
76
|
-
if (this._stopped || this.hasEnded) return
|
|
77
|
-
this.hasEnded = true
|
|
78
|
-
|
|
79
|
-
if (this.chunks.length === 0) return
|
|
80
|
-
|
|
81
|
-
// P0-4: 所有chunk缓冲完毕后,一次性写入文件并播放
|
|
82
|
-
this.tempFile = join(tmpdir(), `ocosay-${Date.now()}.wav`)
|
|
83
|
-
writeFileSync(this.tempFile, Buffer.concat(this.chunks))
|
|
84
|
-
this.chunks = []
|
|
85
|
-
this.start(this.tempFile)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
pause(): void {
|
|
89
|
-
if (!this._started || this._paused || this._stopped) return
|
|
90
|
-
|
|
91
|
-
if (this.process) {
|
|
92
|
-
try {
|
|
93
|
-
this.process.kill('SIGSTOP')
|
|
94
|
-
this._paused = true
|
|
95
|
-
this.events?.onPause?.()
|
|
96
|
-
} catch (e) {
|
|
97
|
-
// SIGSTOP 可能失败
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
resume(): void {
|
|
103
|
-
if (!this._paused || this._stopped) return
|
|
104
|
-
|
|
105
|
-
if (this.process) {
|
|
106
|
-
try {
|
|
107
|
-
this.process.kill('SIGCONT')
|
|
108
|
-
this._paused = false
|
|
109
|
-
this.events?.onResume?.()
|
|
110
|
-
} catch (e) {
|
|
111
|
-
// SIGCONT 可能失败
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
stop(): void {
|
|
117
|
-
this._stopped = true
|
|
118
|
-
this._started = false
|
|
119
|
-
this._paused = false
|
|
120
|
-
|
|
121
|
-
if (this.process) {
|
|
122
|
-
try {
|
|
123
|
-
this.process.kill('SIGTERM')
|
|
124
|
-
} catch (e) {
|
|
125
|
-
// 忽略错误
|
|
126
|
-
}
|
|
127
|
-
this.process = undefined
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 清理临时文件
|
|
131
|
-
this.cleanup()
|
|
132
|
-
this.chunks = []
|
|
133
|
-
this.hasEnded = false
|
|
134
|
-
|
|
135
|
-
this.events?.onStop?.()
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
setVolume(_volume: number): void {
|
|
139
|
-
// afplay 不支持命令行设置音量
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
destroy(): void {
|
|
143
|
-
this.stop()
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private cleanup(): void {
|
|
147
|
-
if (this.tempFile && this.tempFile.startsWith(tmpdir())) {
|
|
148
|
-
try {
|
|
149
|
-
if (existsSync(this.tempFile)) {
|
|
150
|
-
unlinkSync(this.tempFile)
|
|
151
|
-
}
|
|
152
|
-
} catch (e) {
|
|
153
|
-
// 忽略清理错误
|
|
154
|
-
}
|
|
155
|
-
this.tempFile = undefined
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private handleError(error: Error): void {
|
|
160
|
-
this.events?.onError?.(error)
|
|
161
|
-
}
|
|
162
|
-
}
|