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