@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aplay Backend - Linux 平台音频播放后端
|
|
3
|
+
* 使用 ALSA 的 aplay 命令
|
|
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
|
+
* AplayBackend - Linux ALSA 音频播放后端
|
|
17
|
+
* 不支持真正的流式播放,需要先将数据写入临时文件
|
|
18
|
+
*/
|
|
19
|
+
export class AplayBackend implements AudioBackend {
|
|
20
|
+
readonly name = 'aplay'
|
|
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('aplay', [filePath], (error) => {
|
|
52
|
+
if (this._stopped) return
|
|
53
|
+
|
|
54
|
+
if (error) {
|
|
55
|
+
this.handleError(error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this._started = false
|
|
60
|
+
this.events?.onEnd?.()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
this.process.on('error', (error) => {
|
|
64
|
+
this.handleError(error)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
write(chunk: Buffer): void {
|
|
69
|
+
if (this._stopped) return
|
|
70
|
+
// P0-4: 缓冲所有chunk,等end()时一次性写入
|
|
71
|
+
this.chunks.push(chunk)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
end(): void {
|
|
75
|
+
if (this._stopped || this.hasEnded) return
|
|
76
|
+
this.hasEnded = true
|
|
77
|
+
|
|
78
|
+
if (this.chunks.length === 0) return
|
|
79
|
+
|
|
80
|
+
// P0-4: 所有chunk缓冲完毕后,一次性写入文件并播放
|
|
81
|
+
this.tempFile = join(tmpdir(), `ocosay-${Date.now()}.wav`)
|
|
82
|
+
writeFileSync(this.tempFile, Buffer.concat(this.chunks))
|
|
83
|
+
this.chunks = []
|
|
84
|
+
this.start(this.tempFile)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pause(): void {
|
|
88
|
+
if (!this._started || this._paused || this._stopped) return
|
|
89
|
+
|
|
90
|
+
if (this.process) {
|
|
91
|
+
try {
|
|
92
|
+
this.process.kill('SIGSTOP')
|
|
93
|
+
this._paused = true
|
|
94
|
+
this.events?.onPause?.()
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// SIGSTOP 可能失败
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
resume(): void {
|
|
102
|
+
if (!this._paused || this._stopped) return
|
|
103
|
+
|
|
104
|
+
if (this.process) {
|
|
105
|
+
try {
|
|
106
|
+
this.process.kill('SIGCONT')
|
|
107
|
+
this._paused = false
|
|
108
|
+
this.events?.onResume?.()
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// SIGCONT 可能失败
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
stop(): void {
|
|
116
|
+
this._stopped = true
|
|
117
|
+
this._started = false
|
|
118
|
+
this._paused = false
|
|
119
|
+
|
|
120
|
+
if (this.process) {
|
|
121
|
+
try {
|
|
122
|
+
this.process.kill('SIGTERM')
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// 忽略错误
|
|
125
|
+
}
|
|
126
|
+
this.process = undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.cleanup()
|
|
130
|
+
this.chunks = []
|
|
131
|
+
this.hasEnded = false
|
|
132
|
+
|
|
133
|
+
this.events?.onStop?.()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setVolume(_volume: number): void {
|
|
137
|
+
// aplay 不支持命令行设置音量
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
destroy(): void {
|
|
141
|
+
this.stop()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private cleanup(): void {
|
|
145
|
+
if (this.tempFile && this.tempFile.startsWith(tmpdir())) {
|
|
146
|
+
try {
|
|
147
|
+
if (existsSync(this.tempFile)) {
|
|
148
|
+
unlinkSync(this.tempFile)
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// 忽略清理错误
|
|
152
|
+
}
|
|
153
|
+
this.tempFile = undefined
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private handleError(error: Error): void {
|
|
158
|
+
this.events?.onError?.(error)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Backend Interface
|
|
3
|
+
* 音频后端接口定义 - 统一各平台音频播放实现
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 音频后端接口
|
|
8
|
+
* 定义各平台音频后端必须实现的方法
|
|
9
|
+
*/
|
|
10
|
+
export interface AudioBackend {
|
|
11
|
+
/** 后端名称 */
|
|
12
|
+
readonly name: string
|
|
13
|
+
|
|
14
|
+
/** 是否支持真正的流式播放(边收边播) */
|
|
15
|
+
readonly supportsStreaming: boolean
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 开始播放音频文件
|
|
19
|
+
* @param filePath 音频文件路径
|
|
20
|
+
*/
|
|
21
|
+
start(filePath: string): void
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 写入音频数据块(用于流式播放)
|
|
25
|
+
* @param chunk 音频数据块
|
|
26
|
+
*/
|
|
27
|
+
write(chunk: Buffer): void
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 结束写入,关闭流
|
|
31
|
+
*/
|
|
32
|
+
end(): void
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 暂停播放
|
|
36
|
+
*/
|
|
37
|
+
pause(): void
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 恢复播放
|
|
41
|
+
*/
|
|
42
|
+
resume(): void
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 停止播放
|
|
46
|
+
*/
|
|
47
|
+
stop(): void
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 获取当前播放位置(秒)
|
|
51
|
+
* 如果不支持返回 undefined
|
|
52
|
+
*/
|
|
53
|
+
getCurrentTime?(): number | undefined
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 获取音频总时长(秒)
|
|
57
|
+
* 如果不支持返回 undefined
|
|
58
|
+
*/
|
|
59
|
+
getDuration?(): number | undefined
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 设置音量
|
|
63
|
+
* @param volume 音量 0.0 - 1.0
|
|
64
|
+
*/
|
|
65
|
+
setVolume?(volume: number): void
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 销毁后端,释放资源
|
|
69
|
+
*/
|
|
70
|
+
destroy(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 音频后端事件回调接口
|
|
75
|
+
*/
|
|
76
|
+
export interface AudioBackendEvents {
|
|
77
|
+
/** 开始播放回调 */
|
|
78
|
+
onStart?: () => void
|
|
79
|
+
|
|
80
|
+
/** 播放结束回调 */
|
|
81
|
+
onEnd?: () => void
|
|
82
|
+
|
|
83
|
+
/** 错误回调 */
|
|
84
|
+
onError?: (error: Error) => void
|
|
85
|
+
|
|
86
|
+
/** 暂停回调 */
|
|
87
|
+
onPause?: () => void
|
|
88
|
+
|
|
89
|
+
/** 恢复回调 */
|
|
90
|
+
onResume?: () => void
|
|
91
|
+
|
|
92
|
+
/** 停止回调 */
|
|
93
|
+
onStop?: () => void
|
|
94
|
+
|
|
95
|
+
/** 进度回调(已写入字节数) */
|
|
96
|
+
onProgress?: (bytesWritten: number) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 后端配置选项
|
|
101
|
+
*/
|
|
102
|
+
export interface BackendOptions {
|
|
103
|
+
/** 音频格式 (mp3, wav, flac) */
|
|
104
|
+
format?: 'mp3' | 'wav' | 'flac'
|
|
105
|
+
|
|
106
|
+
/** 采样率 (如 16000, 44100) */
|
|
107
|
+
sampleRate?: number
|
|
108
|
+
|
|
109
|
+
/** 声道数 (1 = 单声道, 2 = 立体声) */
|
|
110
|
+
channels?: number
|
|
111
|
+
|
|
112
|
+
/** 音量 0.0 - 1.0 */
|
|
113
|
+
volume?: number
|
|
114
|
+
|
|
115
|
+
/** 事件回调 */
|
|
116
|
+
events?: AudioBackendEvents
|
|
117
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Backends - 多平台音频后端统一导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// 接口和类型
|
|
6
|
+
export { AudioBackend, AudioBackendEvents, BackendOptions } from './base'
|
|
7
|
+
|
|
8
|
+
// 各平台后端实现
|
|
9
|
+
export { NaudiodonBackend } from './naudiodon-backend'
|
|
10
|
+
export { AfplayBackend } from './afplay-backend'
|
|
11
|
+
export { AplayBackend } from './aplay-backend'
|
|
12
|
+
export { PowerShellBackend } from './powershell-backend'
|
|
13
|
+
|
|
14
|
+
import { AudioBackend, BackendOptions } from './base'
|
|
15
|
+
import { NaudiodonBackend } from './naudiodon-backend'
|
|
16
|
+
import { AfplayBackend } from './afplay-backend'
|
|
17
|
+
import { AplayBackend } from './aplay-backend'
|
|
18
|
+
import { PowerShellBackend } from './powershell-backend'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 后端类型枚举
|
|
22
|
+
*/
|
|
23
|
+
export enum BackendType {
|
|
24
|
+
NAUDIODON = 'naudiodon',
|
|
25
|
+
AFPLAY = 'afplay',
|
|
26
|
+
APLAY = 'aplay',
|
|
27
|
+
POWERSHELL = 'powershell',
|
|
28
|
+
AUTO = 'auto'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let naudiodonCache: any = null
|
|
32
|
+
|
|
33
|
+
async function tryLoadNaudiodon(): Promise<any> {
|
|
34
|
+
if (naudiodonCache !== null) {
|
|
35
|
+
return naudiodonCache
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
naudiodonCache = await import('naudiodon')
|
|
39
|
+
return naudiodonCache
|
|
40
|
+
} catch (e) {
|
|
41
|
+
naudiodonCache = false
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isNaudiodonAvailable(): boolean {
|
|
47
|
+
try {
|
|
48
|
+
require.resolve('naudiodon')
|
|
49
|
+
return true
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 创建音频后端
|
|
57
|
+
* @param type 后端类型,默认 AUTO(自动选择)
|
|
58
|
+
* @param options 后端配置选项
|
|
59
|
+
* @returns 音频后端实例
|
|
60
|
+
*/
|
|
61
|
+
export function createBackend(type: BackendType = BackendType.AUTO, options: BackendOptions = {}): AudioBackend {
|
|
62
|
+
const platform = process.platform
|
|
63
|
+
|
|
64
|
+
if (type !== BackendType.AUTO) {
|
|
65
|
+
return createBackendByType(type, options)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isNaudiodonAvailable()) {
|
|
69
|
+
try {
|
|
70
|
+
const naudiodon = require('naudiodon')
|
|
71
|
+
if (naudiodon) {
|
|
72
|
+
return new NaudiodonBackend(options)
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
switch (platform) {
|
|
78
|
+
case 'darwin':
|
|
79
|
+
return new AfplayBackend(options)
|
|
80
|
+
case 'linux':
|
|
81
|
+
return new AplayBackend(options)
|
|
82
|
+
case 'win32':
|
|
83
|
+
return new PowerShellBackend(options)
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`Unsupported platform: ${platform}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createBackendByType(type: BackendType, options: BackendOptions): AudioBackend {
|
|
90
|
+
switch (type) {
|
|
91
|
+
case BackendType.NAUDIODON:
|
|
92
|
+
return new NaudiodonBackend(options)
|
|
93
|
+
case BackendType.AFPLAY:
|
|
94
|
+
return new AfplayBackend(options)
|
|
95
|
+
case BackendType.APLAY:
|
|
96
|
+
return new AplayBackend(options)
|
|
97
|
+
case BackendType.POWERSHELL:
|
|
98
|
+
return new PowerShellBackend(options)
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unknown backend type: ${type}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function supportsStreaming(type: BackendType): boolean {
|
|
105
|
+
if (type === BackendType.AUTO) {
|
|
106
|
+
return isNaudiodonAvailable()
|
|
107
|
+
}
|
|
108
|
+
return type === BackendType.NAUDIODON
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getDefaultBackendType(): BackendType {
|
|
112
|
+
const platform = process.platform
|
|
113
|
+
|
|
114
|
+
if (supportsStreaming(BackendType.AUTO)) {
|
|
115
|
+
return BackendType.NAUDIODON
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
switch (platform) {
|
|
119
|
+
case 'darwin':
|
|
120
|
+
return BackendType.AFPLAY
|
|
121
|
+
case 'linux':
|
|
122
|
+
return BackendType.APLAY
|
|
123
|
+
case 'win32':
|
|
124
|
+
return BackendType.POWERSHELL
|
|
125
|
+
default:
|
|
126
|
+
return BackendType.NAUDIODON
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naudiodon Backend - 基于 PortAudio 的跨平台音频播放后端
|
|
3
|
+
* 支持真正的流式播放(边收边播)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AudioBackend, AudioBackendEvents, BackendOptions } from './base'
|
|
7
|
+
|
|
8
|
+
class UnsupportedError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'UnsupportedError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface NaudiodonAudioOutput {
|
|
16
|
+
start(): void
|
|
17
|
+
write(chunk: Buffer): void
|
|
18
|
+
end(): void
|
|
19
|
+
quit(): void
|
|
20
|
+
on(event: string, callback: (error: Error) => void): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Naudiodon {
|
|
24
|
+
new (options: {
|
|
25
|
+
sampleRate?: number
|
|
26
|
+
channels?: number
|
|
27
|
+
bitDepth?: number
|
|
28
|
+
}): NaudiodonAudioOutput
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
interface NodeModule {
|
|
33
|
+
require(id: 'naudiodon'): Naudiodon
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class NaudiodonBackend implements AudioBackend {
|
|
38
|
+
readonly name = 'naudiodon'
|
|
39
|
+
readonly supportsStreaming = true
|
|
40
|
+
|
|
41
|
+
private audioOutput?: NaudiodonAudioOutput
|
|
42
|
+
private events?: AudioBackendEvents
|
|
43
|
+
private _started = false
|
|
44
|
+
private _paused = false
|
|
45
|
+
private _stopped = false
|
|
46
|
+
private sampleRate: number
|
|
47
|
+
private channels: number
|
|
48
|
+
private volume = 1.0
|
|
49
|
+
private bytesWritten = 0
|
|
50
|
+
|
|
51
|
+
constructor(options: BackendOptions = {}) {
|
|
52
|
+
this.sampleRate = options.sampleRate || 16000
|
|
53
|
+
this.channels = options.channels || 1
|
|
54
|
+
this.events = options.events
|
|
55
|
+
this.volume = options.volume ?? 1.0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start(_filePath: string): void {
|
|
59
|
+
if (this._started) return
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const naudiodon = require('naudiodon') as Naudiodon
|
|
63
|
+
const AudioOutput = naudiodon as unknown as { new(options: { sampleRate: number; channels: number; bitDepth: number }): NaudiodonAudioOutput }
|
|
64
|
+
|
|
65
|
+
this.audioOutput = new AudioOutput({
|
|
66
|
+
sampleRate: this.sampleRate,
|
|
67
|
+
channels: this.channels,
|
|
68
|
+
bitDepth: 16
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
this.audioOutput.on('error', (error: Error) => {
|
|
72
|
+
this.handleError(error)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
this.audioOutput.start()
|
|
76
|
+
this._started = true
|
|
77
|
+
this._stopped = false
|
|
78
|
+
|
|
79
|
+
this.events?.onStart?.()
|
|
80
|
+
} catch (error: any) {
|
|
81
|
+
if (error.code === 'MODULE_NOT_FOUND') {
|
|
82
|
+
throw new Error('naudiodon is not installed. Run: npm install naudiodon')
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
write(chunk: Buffer): void {
|
|
89
|
+
if (!this._started || this._stopped) return
|
|
90
|
+
|
|
91
|
+
if (this.audioOutput) {
|
|
92
|
+
const adjustedChunk = this.adjustVolume(chunk)
|
|
93
|
+
this.audioOutput.write(adjustedChunk)
|
|
94
|
+
this.bytesWritten += chunk.length
|
|
95
|
+
this.events?.onProgress?.(this.bytesWritten)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
end(): void {
|
|
100
|
+
if (this.audioOutput) {
|
|
101
|
+
this.audioOutput.end()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pause(): void {
|
|
106
|
+
if (!this._started || this._paused || this._stopped) return
|
|
107
|
+
throw new UnsupportedError('naudiodon backend does not support pause')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resume(): void {
|
|
111
|
+
if (!this._paused || this._stopped) return
|
|
112
|
+
this._paused = false
|
|
113
|
+
this.events?.onResume?.()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
stop(): void {
|
|
117
|
+
this._stopped = true
|
|
118
|
+
this._started = false
|
|
119
|
+
this._paused = false
|
|
120
|
+
|
|
121
|
+
if (this.audioOutput) {
|
|
122
|
+
try {
|
|
123
|
+
this.audioOutput.quit()
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// 忽略退出错误
|
|
126
|
+
}
|
|
127
|
+
this.audioOutput = undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.bytesWritten = 0
|
|
131
|
+
this.events?.onStop?.()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getCurrentTime(): number {
|
|
135
|
+
return this.bytesWritten / (this.sampleRate * this.channels * 2)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getDuration?(): number | undefined {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setVolume(volume: number): void {
|
|
143
|
+
this.volume = Math.max(0, Math.min(1, volume))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
destroy(): void {
|
|
147
|
+
this.stop()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private adjustVolume(chunk: Buffer): Buffer {
|
|
151
|
+
if (this.volume === 1.0) return chunk
|
|
152
|
+
|
|
153
|
+
const adjusted = Buffer.alloc(chunk.length)
|
|
154
|
+
for (let i = 0; i < chunk.length; i += 2) {
|
|
155
|
+
const sample = chunk.readInt16LE(i) * this.volume
|
|
156
|
+
adjusted.writeInt16LE(Math.round(sample), i)
|
|
157
|
+
}
|
|
158
|
+
return adjusted
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private handleError(error: Error): void {
|
|
162
|
+
this.events?.onError?.(error)
|
|
163
|
+
}
|
|
164
|
+
}
|