@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker - TTS 统一调用入口
|
|
3
|
+
* 提供简洁的 API 和便捷函数
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events'
|
|
7
|
+
import {
|
|
8
|
+
TTSProvider,
|
|
9
|
+
TTSError,
|
|
10
|
+
TTSErrorCode,
|
|
11
|
+
SpeakOptions,
|
|
12
|
+
AudioResult,
|
|
13
|
+
Voice,
|
|
14
|
+
TTSEvent,
|
|
15
|
+
SynthesisModel
|
|
16
|
+
} from './types'
|
|
17
|
+
import { getProvider, listProviders, hasProvider } from '../providers/base'
|
|
18
|
+
import { AudioPlayer, PlayerEvents } from './player'
|
|
19
|
+
|
|
20
|
+
export interface SpeakerOptions {
|
|
21
|
+
defaultProvider?: string
|
|
22
|
+
defaultModel?: SynthesisModel
|
|
23
|
+
defaultVoice?: string
|
|
24
|
+
onEvent?: (event: TTSEvent, data?: any) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Speaker - TTS 统一调用入口类
|
|
29
|
+
* 封装 Provider 和 Player,提供简洁的 speak/pause/resume/stop API
|
|
30
|
+
*/
|
|
31
|
+
export class Speaker extends EventEmitter {
|
|
32
|
+
private currentProvider?: TTSProvider
|
|
33
|
+
private player?: AudioPlayer
|
|
34
|
+
private currentText?: string
|
|
35
|
+
private isSpeaking = false
|
|
36
|
+
private isPaused = false
|
|
37
|
+
|
|
38
|
+
constructor(private options: SpeakerOptions = {}) {
|
|
39
|
+
super()
|
|
40
|
+
|
|
41
|
+
// 初始化播放器
|
|
42
|
+
const playerEvents: PlayerEvents = {
|
|
43
|
+
onStart: () => this.emit('start', this.currentText),
|
|
44
|
+
onEnd: () => {
|
|
45
|
+
this.isSpeaking = false
|
|
46
|
+
this.emit('end', this.currentText)
|
|
47
|
+
},
|
|
48
|
+
onError: (error) => this.emit('error', error),
|
|
49
|
+
onPause: () => {
|
|
50
|
+
this.isPaused = true
|
|
51
|
+
this.emit('pause')
|
|
52
|
+
},
|
|
53
|
+
onResume: () => {
|
|
54
|
+
this.isPaused = false
|
|
55
|
+
this.emit('resume')
|
|
56
|
+
},
|
|
57
|
+
onStop: () => {
|
|
58
|
+
this.isSpeaking = false
|
|
59
|
+
this.isPaused = false
|
|
60
|
+
this.emit('stop')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.player = new AudioPlayer(playerEvents)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 说话 - 核心方法
|
|
69
|
+
* @param text 要说的文本
|
|
70
|
+
* @param options 可选参数
|
|
71
|
+
*/
|
|
72
|
+
async speak(
|
|
73
|
+
text: string,
|
|
74
|
+
options: SpeakOptions & { provider?: string } = {}
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
// 参数校验
|
|
77
|
+
if (!text || text.trim().length === 0) {
|
|
78
|
+
throw new TTSError(
|
|
79
|
+
'Text cannot be empty',
|
|
80
|
+
TTSErrorCode.INVALID_PARAMS,
|
|
81
|
+
'speaker'
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 停止当前播放
|
|
86
|
+
if (this.isSpeaking) {
|
|
87
|
+
await this.stop()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.isSpeaking = true
|
|
91
|
+
this.currentText = text
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// 获取 provider
|
|
95
|
+
const providerName = options.provider || this.options.defaultProvider || 'minimax'
|
|
96
|
+
if (!hasProvider(providerName)) {
|
|
97
|
+
throw new TTSError(
|
|
98
|
+
`Provider "${providerName}" not found`,
|
|
99
|
+
TTSErrorCode.UNKNOWN,
|
|
100
|
+
'speaker'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.currentProvider = getProvider(providerName)
|
|
105
|
+
|
|
106
|
+
// 调用 provider 生成音频
|
|
107
|
+
const result = await this.currentProvider.speak(text, {
|
|
108
|
+
model: options.model || this.options.defaultModel || 'stream',
|
|
109
|
+
voice: options.voice || this.options.defaultVoice,
|
|
110
|
+
speed: options.speed,
|
|
111
|
+
volume: options.volume,
|
|
112
|
+
pitch: options.pitch,
|
|
113
|
+
sourceVoice: options.sourceVoice
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// 播放音频
|
|
117
|
+
if (this.player) {
|
|
118
|
+
await this.player.play(result.audioData, result.format)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.isSpeaking = false
|
|
123
|
+
if (error instanceof TTSError) {
|
|
124
|
+
this.emit('error', error)
|
|
125
|
+
throw error
|
|
126
|
+
}
|
|
127
|
+
const ttsError = new TTSError(
|
|
128
|
+
'Speak failed',
|
|
129
|
+
TTSErrorCode.UNKNOWN,
|
|
130
|
+
'speaker',
|
|
131
|
+
error
|
|
132
|
+
)
|
|
133
|
+
this.emit('error', ttsError)
|
|
134
|
+
throw ttsError
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 暂停播放
|
|
140
|
+
*/
|
|
141
|
+
pause(): void {
|
|
142
|
+
if (this.player && this.isSpeaking && !this.isPaused) {
|
|
143
|
+
this.player.pause()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 恢复播放
|
|
149
|
+
*/
|
|
150
|
+
resume(): void {
|
|
151
|
+
if (this.player && this.isPaused) {
|
|
152
|
+
this.player.resume()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 停止播放
|
|
158
|
+
*/
|
|
159
|
+
async stop(): Promise<void> {
|
|
160
|
+
this.isSpeaking = false
|
|
161
|
+
this.isPaused = false
|
|
162
|
+
|
|
163
|
+
if (this.player) {
|
|
164
|
+
await this.player.stop()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 销毁 Speaker,释放资源
|
|
170
|
+
*/
|
|
171
|
+
async destroy(): Promise<void> {
|
|
172
|
+
this.isSpeaking = false
|
|
173
|
+
this.isPaused = false
|
|
174
|
+
|
|
175
|
+
if (this.player) {
|
|
176
|
+
await this.player.stop()
|
|
177
|
+
this.player = undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.currentProvider = undefined
|
|
181
|
+
this.currentText = undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 列出可用音色
|
|
186
|
+
*/
|
|
187
|
+
async listVoices(providerName?: string): Promise<Voice[]> {
|
|
188
|
+
const name = providerName || this.options.defaultProvider || 'minimax'
|
|
189
|
+
const provider = getProvider(name)
|
|
190
|
+
return provider.listVoices()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 获取 Provider 能力
|
|
195
|
+
*/
|
|
196
|
+
getCapabilities(providerName?: string) {
|
|
197
|
+
const name = providerName || this.options.defaultProvider || 'minimax'
|
|
198
|
+
const provider = getProvider(name)
|
|
199
|
+
return provider.getCapabilities()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 获取所有已注册的 Provider
|
|
204
|
+
*/
|
|
205
|
+
getProviders(): string[] {
|
|
206
|
+
return listProviders()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 是否正在播放
|
|
211
|
+
*/
|
|
212
|
+
isPlaying(): boolean {
|
|
213
|
+
return this.isSpeaking && !this.isPaused
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 是否暂停
|
|
218
|
+
*/
|
|
219
|
+
isPausedState(): boolean {
|
|
220
|
+
return this.isPaused
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// 便捷函数 - 默认 Speaker 实例
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
let defaultSpeaker: Speaker | undefined
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 获取默认 Speaker 实例(单例)
|
|
232
|
+
*/
|
|
233
|
+
export function getDefaultSpeaker(): Speaker {
|
|
234
|
+
if (!defaultSpeaker) {
|
|
235
|
+
defaultSpeaker = new Speaker()
|
|
236
|
+
}
|
|
237
|
+
return defaultSpeaker
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 说话(便捷函数)
|
|
242
|
+
*/
|
|
243
|
+
export async function speak(
|
|
244
|
+
text: string,
|
|
245
|
+
options?: SpeakOptions & { provider?: string }
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const speaker = getDefaultSpeaker()
|
|
248
|
+
return speaker.speak(text, options)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 停止(便捷函数)
|
|
253
|
+
*/
|
|
254
|
+
export async function stop(): Promise<void> {
|
|
255
|
+
const speaker = getDefaultSpeaker()
|
|
256
|
+
return speaker.stop()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 暂停(便捷函数)
|
|
261
|
+
*/
|
|
262
|
+
export function pause(): void {
|
|
263
|
+
const speaker = getDefaultSpeaker()
|
|
264
|
+
speaker.pause()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 恢复(便捷函数)
|
|
269
|
+
*/
|
|
270
|
+
export function resume(): void {
|
|
271
|
+
const speaker = getDefaultSpeaker()
|
|
272
|
+
speaker.resume()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 列出音色(便捷函数)
|
|
277
|
+
*/
|
|
278
|
+
export async function listVoices(providerName?: string): Promise<Voice[]> {
|
|
279
|
+
const speaker = getDefaultSpeaker()
|
|
280
|
+
return speaker.listVoices(providerName)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default Speaker
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StreamPlayer - 真正的边收边播流式音频播放器
|
|
3
|
+
* 接收音频 chunk,同时写入临时文件并立即启动播放器播放
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events'
|
|
7
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
8
|
+
import fs from 'fs'
|
|
9
|
+
import { createWriteStream, WriteStream } from 'fs'
|
|
10
|
+
import { tmpdir } from 'os'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* StreamPlayer Events - 流式播放器事件回调接口
|
|
15
|
+
*/
|
|
16
|
+
export interface StreamPlayerEvents {
|
|
17
|
+
onProgress?: (bytesWritten: number) => void
|
|
18
|
+
onStart?: () => void
|
|
19
|
+
onEnd?: () => void
|
|
20
|
+
onError?: (error: Error) => void
|
|
21
|
+
onPause?: () => void
|
|
22
|
+
onResume?: () => void
|
|
23
|
+
onStop?: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* StreamPlayer Options - 流式播放器配置选项
|
|
28
|
+
*/
|
|
29
|
+
export interface StreamPlayerOptions {
|
|
30
|
+
format?: 'mp3' | 'wav' | 'flac'
|
|
31
|
+
events?: StreamPlayerEvents
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* StreamPlayer - 边收边播的流式音频播放器
|
|
36
|
+
*
|
|
37
|
+
* 特性:
|
|
38
|
+
* - 写入临时文件的同时立即启动播放器
|
|
39
|
+
* - 支持 pause/resume/stop 控制
|
|
40
|
+
* - 跨平台支持:macOS (afplay), Linux (aplay), Windows (PowerShell)
|
|
41
|
+
*/
|
|
42
|
+
export class StreamPlayer extends EventEmitter {
|
|
43
|
+
private tempFile: string = ''
|
|
44
|
+
private writeStream?: WriteStream
|
|
45
|
+
private playerProcess?: ChildProcess
|
|
46
|
+
private _bytesWritten = 0
|
|
47
|
+
private _started = false
|
|
48
|
+
private _paused = false
|
|
49
|
+
private _stopped = false
|
|
50
|
+
private format: 'mp3' | 'wav' | 'flac' = 'mp3'
|
|
51
|
+
private events?: StreamPlayerEvents
|
|
52
|
+
|
|
53
|
+
constructor(options: StreamPlayerOptions = {}) {
|
|
54
|
+
super()
|
|
55
|
+
this.format = options.format || 'mp3'
|
|
56
|
+
this.events = options.events
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 开始播放
|
|
61
|
+
* 创建临时文件,创建写入流,启动播放器进程
|
|
62
|
+
*/
|
|
63
|
+
start(): void {
|
|
64
|
+
if (this._started) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 创建临时文件
|
|
69
|
+
this.tempFile = join(tmpdir(), `ocosay-stream-${Date.now()}.${this.format}`)
|
|
70
|
+
|
|
71
|
+
// 创建写入流
|
|
72
|
+
this.writeStream = createWriteStream(this.tempFile, { highWaterMark: 64 * 1024 })
|
|
73
|
+
|
|
74
|
+
this.writeStream.on('error', (error: Error) => {
|
|
75
|
+
this.handleError(error)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
this.writeStream.on('finish', () => {
|
|
79
|
+
// 写入完成,但播放器可能还在播放
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 启动播放器进程
|
|
83
|
+
this.startPlayer()
|
|
84
|
+
|
|
85
|
+
this._started = true
|
|
86
|
+
this._stopped = false
|
|
87
|
+
|
|
88
|
+
this.events?.onStart?.()
|
|
89
|
+
this.emit('start')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 启动播放器进程
|
|
94
|
+
*/
|
|
95
|
+
private startPlayer(): void {
|
|
96
|
+
const platform = process.platform
|
|
97
|
+
let command: string
|
|
98
|
+
let args: string[]
|
|
99
|
+
|
|
100
|
+
if (platform === 'darwin') {
|
|
101
|
+
// macOS
|
|
102
|
+
command = 'afplay'
|
|
103
|
+
args = [this.tempFile]
|
|
104
|
+
} else if (platform === 'linux') {
|
|
105
|
+
// Linux
|
|
106
|
+
command = 'aplay'
|
|
107
|
+
args = [this.tempFile]
|
|
108
|
+
} else {
|
|
109
|
+
// Windows - PlaySync is synchronous and blocks the event loop
|
|
110
|
+
// Return error to indicate Windows is not supported for streaming
|
|
111
|
+
this.handleError(new Error('Windows platform is not supported for stream playback. PlaySync() blocks the Node.js event loop.'))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.playerProcess = spawn(command, args, {
|
|
116
|
+
stdio: 'ignore',
|
|
117
|
+
detached: false
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.playerProcess.on('exit', (code: number | null, signal: string | null) => {
|
|
121
|
+
// 如果是正常结束或被信号终止,不当作错误
|
|
122
|
+
if (this._stopped) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
|
127
|
+
// 被主动停止
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (code === 0 || code === null) {
|
|
132
|
+
// 正常播放结束
|
|
133
|
+
this.events?.onEnd?.()
|
|
134
|
+
this.emit('end')
|
|
135
|
+
} else {
|
|
136
|
+
this.handleError(new Error(`Player exited with code ${code}`))
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
this.playerProcess.on('error', (error: Error) => {
|
|
141
|
+
this.handleError(error)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 写入音频数据块(边收边写)
|
|
147
|
+
* 如果尚未 start(),会自动调用
|
|
148
|
+
*/
|
|
149
|
+
write(chunk: Buffer): void {
|
|
150
|
+
// 如果已停止,直接忽略
|
|
151
|
+
if (this._stopped) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 如果未启动,自动启动
|
|
156
|
+
if (!this._started) {
|
|
157
|
+
this.start()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 写入数据到文件
|
|
161
|
+
if (this.writeStream) {
|
|
162
|
+
const canContinue = this.writeStream.write(chunk)
|
|
163
|
+
|
|
164
|
+
if (!canContinue) {
|
|
165
|
+
// 写入缓冲区满了,等待 drain 事件
|
|
166
|
+
this.writeStream.once('drain', () => {
|
|
167
|
+
// 可以继续写入
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this._bytesWritten += chunk.length
|
|
172
|
+
|
|
173
|
+
this.events?.onProgress?.(this._bytesWritten)
|
|
174
|
+
this.emit('progress', this._bytesWritten)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 结束写入
|
|
180
|
+
* 关闭写入流,但不杀播放器进程,让它播完
|
|
181
|
+
*/
|
|
182
|
+
end(): void {
|
|
183
|
+
if (this.writeStream) {
|
|
184
|
+
this.writeStream.end()
|
|
185
|
+
this.writeStream = undefined
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 停止播放
|
|
191
|
+
* 杀死播放器进程,删除临时文件
|
|
192
|
+
*/
|
|
193
|
+
stop(): void {
|
|
194
|
+
this._stopped = true
|
|
195
|
+
this._started = false
|
|
196
|
+
this._paused = false
|
|
197
|
+
|
|
198
|
+
// 杀死播放器进程
|
|
199
|
+
if (this.playerProcess) {
|
|
200
|
+
try {
|
|
201
|
+
this.playerProcess.kill('SIGTERM')
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// 忽略错误
|
|
204
|
+
}
|
|
205
|
+
this.playerProcess = undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 关闭写入流
|
|
209
|
+
if (this.writeStream) {
|
|
210
|
+
try {
|
|
211
|
+
this.writeStream.destroy()
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// 忽略错误
|
|
214
|
+
}
|
|
215
|
+
this.writeStream = undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 删除临时文件
|
|
219
|
+
this.deleteTempFile()
|
|
220
|
+
|
|
221
|
+
this.events?.onStop?.()
|
|
222
|
+
this.emit('stop')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 暂停播放
|
|
227
|
+
* 使用 SIGSTOP 暂停播放器进程
|
|
228
|
+
*/
|
|
229
|
+
pause(): void {
|
|
230
|
+
if (!this._started || this._paused || this._stopped) {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (this.playerProcess) {
|
|
235
|
+
try {
|
|
236
|
+
this.playerProcess.kill('SIGSTOP')
|
|
237
|
+
this._paused = true
|
|
238
|
+
this.events?.onPause?.()
|
|
239
|
+
this.emit('pause')
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// 如果 kill 失败,忽略
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 恢复播放
|
|
248
|
+
*/
|
|
249
|
+
resume(): void {
|
|
250
|
+
if (!this._paused || this._stopped) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (this.playerProcess) {
|
|
255
|
+
try {
|
|
256
|
+
this.playerProcess.kill('SIGCONT')
|
|
257
|
+
this._paused = false
|
|
258
|
+
this.events?.onResume?.()
|
|
259
|
+
this.emit('resume')
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// 如果 kill 失败,忽略
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 是否已启动
|
|
268
|
+
*/
|
|
269
|
+
isStarted(): boolean {
|
|
270
|
+
return this._started
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 是否暂停
|
|
275
|
+
*/
|
|
276
|
+
isPaused(): boolean {
|
|
277
|
+
return this._paused
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 是否已停止
|
|
282
|
+
*/
|
|
283
|
+
isStopped(): boolean {
|
|
284
|
+
return this._stopped
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 获取已写入的字节数
|
|
289
|
+
*/
|
|
290
|
+
getBytesWritten(): number {
|
|
291
|
+
return this._bytesWritten
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 获取临时文件路径
|
|
296
|
+
*/
|
|
297
|
+
getTempFile(): string {
|
|
298
|
+
return this.tempFile
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 处理错误
|
|
303
|
+
*/
|
|
304
|
+
private handleError(error: Error): void {
|
|
305
|
+
this.events?.onError?.(error)
|
|
306
|
+
this.emit('error', error)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 删除临时文件
|
|
311
|
+
*/
|
|
312
|
+
private deleteTempFile(): void {
|
|
313
|
+
if (this.tempFile) {
|
|
314
|
+
try {
|
|
315
|
+
if (fs.existsSync(this.tempFile)) {
|
|
316
|
+
fs.unlinkSync(this.tempFile)
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
// 忽略删除错误
|
|
320
|
+
}
|
|
321
|
+
this.tempFile = ''
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export default StreamPlayer
|