@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.
Files changed (62) hide show
  1. package/README.md +21 -0
  2. package/dist/config.d.ts +21 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +14 -3
  5. package/dist/config.js.map +1 -1
  6. package/dist/plugin.js +1907 -138
  7. package/dist/plugin.js.map +7 -1
  8. package/package.json +4 -2
  9. package/TECH_PLAN.md +0 -352
  10. package/__mocks__/@opencode-ai/plugin.ts +0 -32
  11. package/jest.config.js +0 -15
  12. package/src/config.ts +0 -149
  13. package/src/core/backends/afplay-backend.ts +0 -162
  14. package/src/core/backends/aplay-backend.ts +0 -160
  15. package/src/core/backends/base.ts +0 -117
  16. package/src/core/backends/index.ts +0 -128
  17. package/src/core/backends/naudiodon-backend.ts +0 -164
  18. package/src/core/backends/powershell-backend.ts +0 -173
  19. package/src/core/player.ts +0 -322
  20. package/src/core/speaker.ts +0 -283
  21. package/src/core/stream-player.ts +0 -326
  22. package/src/core/stream-reader.ts +0 -190
  23. package/src/core/streaming-synthesizer.ts +0 -123
  24. package/src/core/types.ts +0 -185
  25. package/src/index.ts +0 -236
  26. package/src/plugin.ts +0 -178
  27. package/src/providers/base.ts +0 -150
  28. package/src/providers/minimax.ts +0 -515
  29. package/src/tools/tts.ts +0 -277
  30. package/src/types/config.ts +0 -38
  31. package/src/types/naudiodon.d.ts +0 -19
  32. package/tests/__mocks__/@opencode-ai/plugin.ts +0 -32
  33. package/tests/backends.test.ts +0 -831
  34. package/tests/config.test.ts +0 -327
  35. package/tests/index.test.ts +0 -201
  36. package/tests/integration-test.d.ts +0 -6
  37. package/tests/integration-test.d.ts.map +0 -1
  38. package/tests/integration-test.js +0 -84
  39. package/tests/integration-test.js.map +0 -1
  40. package/tests/integration-test.ts +0 -93
  41. package/tests/p1-fixes.test.ts +0 -160
  42. package/tests/plugin.test.ts +0 -312
  43. package/tests/provider.test.d.ts +0 -2
  44. package/tests/provider.test.d.ts.map +0 -1
  45. package/tests/provider.test.js +0 -69
  46. package/tests/provider.test.js.map +0 -1
  47. package/tests/provider.test.ts +0 -87
  48. package/tests/speaker.test.d.ts +0 -2
  49. package/tests/speaker.test.d.ts.map +0 -1
  50. package/tests/speaker.test.js +0 -63
  51. package/tests/speaker.test.js.map +0 -1
  52. package/tests/speaker.test.ts +0 -232
  53. package/tests/stream-player.test.ts +0 -303
  54. package/tests/stream-reader.test.ts +0 -269
  55. package/tests/streaming-synthesizer.test.ts +0 -225
  56. package/tests/tts-tools.test.ts +0 -270
  57. package/tests/types.test.d.ts +0 -2
  58. package/tests/types.test.d.ts.map +0 -1
  59. package/tests/types.test.js +0 -61
  60. package/tests/types.test.js.map +0 -1
  61. package/tests/types.test.ts +0 -63
  62. 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
- }