@mingxy/ocosay 1.1.12 → 1.1.13
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/dist/core/backends/index.d.ts +4 -0
- package/dist/core/backends/index.js +32 -1
- package/dist/core/backends/playsound-backend.d.ts +39 -0
- package/dist/core/backends/playsound-backend.js +189 -0
- package/dist/core/backends/speaker-backend.d.ts +34 -0
- package/dist/core/backends/speaker-backend.js +163 -0
- package/dist/package.json +4 -2
- package/dist/plugin.js +397 -43
- package/package.json +5 -3
|
@@ -7,6 +7,8 @@ export { AfplayBackend } from './afplay-backend';
|
|
|
7
7
|
export { AplayBackend } from './aplay-backend';
|
|
8
8
|
export { PowerShellBackend } from './powershell-backend';
|
|
9
9
|
export { HowlerBackend } from './howler-backend';
|
|
10
|
+
export { PlaySoundBackend } from './playsound-backend';
|
|
11
|
+
export { SpeakerBackend } from './speaker-backend';
|
|
10
12
|
import { AudioBackend, BackendOptions } from './base';
|
|
11
13
|
/**
|
|
12
14
|
* 后端类型枚举
|
|
@@ -17,6 +19,8 @@ export declare enum BackendType {
|
|
|
17
19
|
APLAY = "aplay",
|
|
18
20
|
POWERSHELL = "powershell",
|
|
19
21
|
HOWLER = "howler",
|
|
22
|
+
PLAY_SOUND = "play-sound",
|
|
23
|
+
SPEAKER = "speaker",
|
|
20
24
|
AUTO = "auto"
|
|
21
25
|
}
|
|
22
26
|
export declare function createBackend(type?: BackendType, options?: BackendOptions): AudioBackend;
|
|
@@ -7,12 +7,16 @@ export { AfplayBackend } from './afplay-backend';
|
|
|
7
7
|
export { AplayBackend } from './aplay-backend';
|
|
8
8
|
export { PowerShellBackend } from './powershell-backend';
|
|
9
9
|
export { HowlerBackend } from './howler-backend';
|
|
10
|
+
export { PlaySoundBackend } from './playsound-backend';
|
|
11
|
+
export { SpeakerBackend } from './speaker-backend';
|
|
10
12
|
import { execSync } from 'child_process';
|
|
11
13
|
import { NaudiodonBackend } from './naudiodon-backend';
|
|
12
14
|
import { AfplayBackend } from './afplay-backend';
|
|
13
15
|
import { AplayBackend } from './aplay-backend';
|
|
14
16
|
import { PowerShellBackend } from './powershell-backend';
|
|
15
17
|
import { HowlerBackend } from './howler-backend';
|
|
18
|
+
import { PlaySoundBackend } from './playsound-backend';
|
|
19
|
+
import { SpeakerBackend } from './speaker-backend';
|
|
16
20
|
import { logger } from '../../utils/logger';
|
|
17
21
|
function execCmd(cmd) {
|
|
18
22
|
try {
|
|
@@ -36,6 +40,8 @@ export var BackendType;
|
|
|
36
40
|
BackendType["APLAY"] = "aplay";
|
|
37
41
|
BackendType["POWERSHELL"] = "powershell";
|
|
38
42
|
BackendType["HOWLER"] = "howler";
|
|
43
|
+
BackendType["PLAY_SOUND"] = "play-sound";
|
|
44
|
+
BackendType["SPEAKER"] = "speaker";
|
|
39
45
|
BackendType["AUTO"] = "auto";
|
|
40
46
|
})(BackendType || (BackendType = {}));
|
|
41
47
|
let naudiodonCache = null;
|
|
@@ -63,6 +69,16 @@ function isNaudiodonAvailable() {
|
|
|
63
69
|
return false;
|
|
64
70
|
}
|
|
65
71
|
}
|
|
72
|
+
function isSpeakerAvailable() {
|
|
73
|
+
try {
|
|
74
|
+
require.resolve('speaker');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.debug({ err }, 'speaker not available');
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
66
82
|
export function createBackend(type = BackendType.AUTO, options = {}) {
|
|
67
83
|
const platform = process.platform;
|
|
68
84
|
if (type !== BackendType.AUTO) {
|
|
@@ -87,12 +103,23 @@ export function createBackend(type = BackendType.AUTO, options = {}) {
|
|
|
87
103
|
case 'darwin':
|
|
88
104
|
return new AfplayBackend(options);
|
|
89
105
|
case 'linux':
|
|
106
|
+
// Linux 环境检测顺序:naudiodon → aplay → play-sound → speaker → Howler
|
|
90
107
|
if (isCommandAvailable('aplay')) {
|
|
91
108
|
const test = execCmd('aplay -l');
|
|
92
109
|
if (test.success && !test.output.includes('no soundcards')) {
|
|
93
110
|
return new AplayBackend(options);
|
|
94
111
|
}
|
|
95
112
|
}
|
|
113
|
+
// 检测 play-sound (ffplay)
|
|
114
|
+
if (isCommandAvailable('ffplay')) {
|
|
115
|
+
return new PlaySoundBackend(options);
|
|
116
|
+
}
|
|
117
|
+
// 检测 speaker (需要 speaker npm 包)
|
|
118
|
+
if (isSpeakerAvailable()) {
|
|
119
|
+
return new SpeakerBackend(options);
|
|
120
|
+
}
|
|
121
|
+
// 彻底失败,使用 Howler 作为最后的回退
|
|
122
|
+
logger.warn('All Linux audio backends failed, using HowlerBackend as fallback');
|
|
96
123
|
return new HowlerBackend(options);
|
|
97
124
|
case 'win32':
|
|
98
125
|
return new PowerShellBackend(options);
|
|
@@ -112,6 +139,10 @@ function createBackendByType(type, options) {
|
|
|
112
139
|
return new PowerShellBackend(options);
|
|
113
140
|
case BackendType.HOWLER:
|
|
114
141
|
return new HowlerBackend(options);
|
|
142
|
+
case BackendType.PLAY_SOUND:
|
|
143
|
+
return new PlaySoundBackend(options);
|
|
144
|
+
case BackendType.SPEAKER:
|
|
145
|
+
return new SpeakerBackend(options);
|
|
115
146
|
default:
|
|
116
147
|
throw new Error(`Unknown backend type: ${type}`);
|
|
117
148
|
}
|
|
@@ -120,7 +151,7 @@ export function supportsStreaming(type) {
|
|
|
120
151
|
if (type === BackendType.AUTO) {
|
|
121
152
|
return isNaudiodonAvailable();
|
|
122
153
|
}
|
|
123
|
-
return type === BackendType.NAUDIODON;
|
|
154
|
+
return type === BackendType.NAUDIODON || type === BackendType.SPEAKER;
|
|
124
155
|
}
|
|
125
156
|
export function getDefaultBackendType() {
|
|
126
157
|
const platform = process.platform;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlaySound Backend - 跨平台音频播放后端
|
|
3
|
+
* 使用 play-sound npm 包调用系统工具(ffplay/aplay/mpg123)
|
|
4
|
+
* 支持 Linux/macOS/Windows,可无声卡播放(ffplay)
|
|
5
|
+
*/
|
|
6
|
+
import { AudioBackend, BackendOptions } from './base';
|
|
7
|
+
/**
|
|
8
|
+
* PlaySoundBackend - 使用 play-sound 包的后端
|
|
9
|
+
* play-sound 会自动选择最佳播放器:
|
|
10
|
+
* - Linux: ffplay > aplay > mpg123
|
|
11
|
+
* - macOS: afplay > aplay > mpg123
|
|
12
|
+
* - Windows: Powershell > vlc > afplay
|
|
13
|
+
* ffplay 支持无声卡播放(-nodisp -autoexit)
|
|
14
|
+
*/
|
|
15
|
+
export declare class PlaySoundBackend implements AudioBackend {
|
|
16
|
+
readonly name = "play-sound";
|
|
17
|
+
readonly supportsStreaming = false;
|
|
18
|
+
private player?;
|
|
19
|
+
private tempFile?;
|
|
20
|
+
private events?;
|
|
21
|
+
private _started;
|
|
22
|
+
private _paused;
|
|
23
|
+
private _stopped;
|
|
24
|
+
private chunks;
|
|
25
|
+
private hasEnded;
|
|
26
|
+
constructor(options?: BackendOptions);
|
|
27
|
+
start(filePath: string): void;
|
|
28
|
+
private playWithPlaySound;
|
|
29
|
+
write(chunk: Buffer): void;
|
|
30
|
+
end(): void;
|
|
31
|
+
pause(): void;
|
|
32
|
+
resume(): void;
|
|
33
|
+
stop(): void;
|
|
34
|
+
setVolume(_volume: number): void;
|
|
35
|
+
destroy(): void;
|
|
36
|
+
private cleanup;
|
|
37
|
+
private handleError;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=playsound-backend.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlaySound Backend - 跨平台音频播放后端
|
|
3
|
+
* 使用 play-sound npm 包调用系统工具(ffplay/aplay/mpg123)
|
|
4
|
+
* 支持 Linux/macOS/Windows,可无声卡播放(ffplay)
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
10
|
+
// 白名单:只允许特定路径格式(禁止 - 防止命令注入)
|
|
11
|
+
const SAFE_PATH_REGEX = /^[\w\/\.]+$/;
|
|
12
|
+
/**
|
|
13
|
+
* PlaySoundBackend - 使用 play-sound 包的后端
|
|
14
|
+
* play-sound 会自动选择最佳播放器:
|
|
15
|
+
* - Linux: ffplay > aplay > mpg123
|
|
16
|
+
* - macOS: afplay > aplay > mpg123
|
|
17
|
+
* - Windows: Powershell > vlc > afplay
|
|
18
|
+
* ffplay 支持无声卡播放(-nodisp -autoexit)
|
|
19
|
+
*/
|
|
20
|
+
export class PlaySoundBackend {
|
|
21
|
+
name = 'play-sound';
|
|
22
|
+
supportsStreaming = false;
|
|
23
|
+
player;
|
|
24
|
+
tempFile;
|
|
25
|
+
events;
|
|
26
|
+
_started = false;
|
|
27
|
+
_paused = false;
|
|
28
|
+
_stopped = false;
|
|
29
|
+
// P0-4: 缓冲所有chunk,等end()时一次性写入文件
|
|
30
|
+
chunks = [];
|
|
31
|
+
hasEnded = false;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.events = options.events;
|
|
34
|
+
}
|
|
35
|
+
start(filePath) {
|
|
36
|
+
if (this._started)
|
|
37
|
+
return;
|
|
38
|
+
if (!SAFE_PATH_REGEX.test(filePath)) {
|
|
39
|
+
throw new Error(`Invalid file path: ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
this.tempFile = filePath;
|
|
42
|
+
this._started = true;
|
|
43
|
+
this._stopped = false;
|
|
44
|
+
this.events?.onStart?.();
|
|
45
|
+
// 动态导入 play-sound
|
|
46
|
+
this.playWithPlaySound(filePath);
|
|
47
|
+
}
|
|
48
|
+
async playWithPlaySound(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
// 异步导入 play-sound
|
|
51
|
+
const play = (await import('play-sound')).default;
|
|
52
|
+
// 配置播放器选项
|
|
53
|
+
const opts = {
|
|
54
|
+
players: ['ffplay', 'aplay', 'mpg123', 'afplay'] // 优先级
|
|
55
|
+
};
|
|
56
|
+
// 对于 ffplay,使用无声模式
|
|
57
|
+
if (process.platform === 'linux') {
|
|
58
|
+
// ffplay 无声卡播放参数
|
|
59
|
+
this.player = execFile('ffplay', [
|
|
60
|
+
'-nodisp', // 不显示窗口
|
|
61
|
+
'-autoexit', // 播放完自动退出
|
|
62
|
+
'-loglevel', 'error', // 减少日志输出
|
|
63
|
+
filePath
|
|
64
|
+
], (error) => {
|
|
65
|
+
if (this._stopped)
|
|
66
|
+
return;
|
|
67
|
+
if (error) {
|
|
68
|
+
this.handleError(error);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this._started = false;
|
|
72
|
+
this.events?.onEnd?.();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// 使用 play-sound 的默认行为
|
|
77
|
+
const audio = play;
|
|
78
|
+
const p = audio.play(filePath, (err) => {
|
|
79
|
+
if (this._stopped)
|
|
80
|
+
return;
|
|
81
|
+
if (err) {
|
|
82
|
+
this.handleError(err);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this._started = false;
|
|
86
|
+
this.events?.onEnd?.();
|
|
87
|
+
});
|
|
88
|
+
if (p && p.kill) {
|
|
89
|
+
this.player = p;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (this.player) {
|
|
93
|
+
this.player.on('error', (error) => {
|
|
94
|
+
this.handleError(error);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
write(chunk) {
|
|
103
|
+
if (this._stopped)
|
|
104
|
+
return;
|
|
105
|
+
// P0-4: 缓冲所有chunk,等end()时一次性写入
|
|
106
|
+
this.chunks.push(chunk);
|
|
107
|
+
}
|
|
108
|
+
end() {
|
|
109
|
+
if (this._stopped || this.hasEnded)
|
|
110
|
+
return;
|
|
111
|
+
this.hasEnded = true;
|
|
112
|
+
if (this.chunks.length === 0)
|
|
113
|
+
return;
|
|
114
|
+
// P0-4: 所有chunk缓冲完毕后,一次性写入文件并播放
|
|
115
|
+
this.tempFile = join(tmpdir(), `ocosay-${Date.now()}.wav`);
|
|
116
|
+
writeFileSync(this.tempFile, Buffer.concat(this.chunks));
|
|
117
|
+
this.chunks = [];
|
|
118
|
+
this.start(this.tempFile);
|
|
119
|
+
}
|
|
120
|
+
pause() {
|
|
121
|
+
if (!this._started || this._paused || this._stopped)
|
|
122
|
+
return;
|
|
123
|
+
if (this.player) {
|
|
124
|
+
try {
|
|
125
|
+
this.player.kill('SIGSTOP');
|
|
126
|
+
this._paused = true;
|
|
127
|
+
this.events?.onPause?.();
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// SIGSTOP 可能失败
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
resume() {
|
|
135
|
+
if (!this._paused || this._stopped)
|
|
136
|
+
return;
|
|
137
|
+
if (this.player) {
|
|
138
|
+
try {
|
|
139
|
+
this.player.kill('SIGCONT');
|
|
140
|
+
this._paused = false;
|
|
141
|
+
this.events?.onResume?.();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
// SIGCONT 可能失败
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
stop() {
|
|
149
|
+
this._stopped = true;
|
|
150
|
+
this._started = false;
|
|
151
|
+
this._paused = false;
|
|
152
|
+
if (this.player) {
|
|
153
|
+
try {
|
|
154
|
+
this.player.kill('SIGTERM');
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
// 忽略错误
|
|
158
|
+
}
|
|
159
|
+
this.player = undefined;
|
|
160
|
+
}
|
|
161
|
+
this.cleanup();
|
|
162
|
+
this.chunks = [];
|
|
163
|
+
this.hasEnded = false;
|
|
164
|
+
this.events?.onStop?.();
|
|
165
|
+
}
|
|
166
|
+
setVolume(_volume) {
|
|
167
|
+
// play-sound/ffplay 不支持命令行设置音量
|
|
168
|
+
}
|
|
169
|
+
destroy() {
|
|
170
|
+
this.stop();
|
|
171
|
+
}
|
|
172
|
+
cleanup() {
|
|
173
|
+
if (this.tempFile && this.tempFile.startsWith(tmpdir())) {
|
|
174
|
+
try {
|
|
175
|
+
if (existsSync(this.tempFile)) {
|
|
176
|
+
unlinkSync(this.tempFile);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
// 忽略清理错误
|
|
181
|
+
}
|
|
182
|
+
this.tempFile = undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
handleError(error) {
|
|
186
|
+
this.events?.onError?.(error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=playsound-backend.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker Backend - Linux/macOS 音频播放后端
|
|
3
|
+
* 使用 speaker npm 包直接输出 PCM 到 ALSA/PulseAudio
|
|
4
|
+
* 支持流式播放,但需要完整的音频头信息
|
|
5
|
+
*/
|
|
6
|
+
import { AudioBackend, BackendOptions } from './base';
|
|
7
|
+
/**
|
|
8
|
+
* SpeakerBackend - 使用 speaker 包的后端
|
|
9
|
+
* speaker 直接将 PCM 数据输出到系统音频设备
|
|
10
|
+
* 支持流式播放,但需要正确的音频格式参数
|
|
11
|
+
*/
|
|
12
|
+
export declare class SpeakerBackend implements AudioBackend {
|
|
13
|
+
readonly name = "speaker";
|
|
14
|
+
readonly supportsStreaming = true;
|
|
15
|
+
private speaker?;
|
|
16
|
+
private events?;
|
|
17
|
+
private _started;
|
|
18
|
+
private _paused;
|
|
19
|
+
private _stopped;
|
|
20
|
+
private _format;
|
|
21
|
+
constructor(options?: BackendOptions);
|
|
22
|
+
start(_filePath: string): void;
|
|
23
|
+
write(chunk: Buffer): void;
|
|
24
|
+
private isWavHeader;
|
|
25
|
+
private stripWavHeader;
|
|
26
|
+
private createSpeaker;
|
|
27
|
+
end(): void;
|
|
28
|
+
pause(): void;
|
|
29
|
+
resume(): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
private handleError;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=speaker-backend.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker Backend - Linux/macOS 音频播放后端
|
|
3
|
+
* 使用 speaker npm 包直接输出 PCM 到 ALSA/PulseAudio
|
|
4
|
+
* 支持流式播放,但需要完整的音频头信息
|
|
5
|
+
*/
|
|
6
|
+
import Speaker from 'speaker';
|
|
7
|
+
/**
|
|
8
|
+
* SpeakerBackend - 使用 speaker 包的后端
|
|
9
|
+
* speaker 直接将 PCM 数据输出到系统音频设备
|
|
10
|
+
* 支持流式播放,但需要正确的音频格式参数
|
|
11
|
+
*/
|
|
12
|
+
export class SpeakerBackend {
|
|
13
|
+
name = 'speaker';
|
|
14
|
+
supportsStreaming = true;
|
|
15
|
+
speaker;
|
|
16
|
+
events;
|
|
17
|
+
_started = false;
|
|
18
|
+
_paused = false;
|
|
19
|
+
_stopped = false;
|
|
20
|
+
_format = {
|
|
21
|
+
channels: 1,
|
|
22
|
+
sampleRate: 16000,
|
|
23
|
+
bitDepth: 16,
|
|
24
|
+
signed: true,
|
|
25
|
+
float: false
|
|
26
|
+
};
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.events = options.events;
|
|
29
|
+
if (options.sampleRate) {
|
|
30
|
+
this._format.sampleRate = options.sampleRate;
|
|
31
|
+
}
|
|
32
|
+
if (options.channels) {
|
|
33
|
+
this._format.channels = options.channels;
|
|
34
|
+
}
|
|
35
|
+
if (options.format === 'wav') {
|
|
36
|
+
this._format.bitDepth = 16;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
start(_filePath) {
|
|
40
|
+
if (this._started)
|
|
41
|
+
return;
|
|
42
|
+
this._started = true;
|
|
43
|
+
this._stopped = false;
|
|
44
|
+
this._paused = false;
|
|
45
|
+
this.events?.onStart?.();
|
|
46
|
+
}
|
|
47
|
+
write(chunk) {
|
|
48
|
+
if (this._stopped || this._paused)
|
|
49
|
+
return;
|
|
50
|
+
if (!this._started) {
|
|
51
|
+
this.start('');
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if (!this.speaker) {
|
|
55
|
+
this.createSpeaker();
|
|
56
|
+
}
|
|
57
|
+
if (this.speaker) {
|
|
58
|
+
// 检查是否是 WAV 文件头
|
|
59
|
+
if (this.isWavHeader(chunk)) {
|
|
60
|
+
// 跳过 WAV 头,只播放数据
|
|
61
|
+
const audioData = this.stripWavHeader(chunk);
|
|
62
|
+
if (audioData.length > 0) {
|
|
63
|
+
this.speaker.write(audioData);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.speaker.write(chunk);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
isWavHeader(chunk) {
|
|
76
|
+
// 检查 RIFF 头
|
|
77
|
+
if (chunk.length >= 44) {
|
|
78
|
+
const riff = chunk.toString('ascii', 0, 4);
|
|
79
|
+
const wave = chunk.toString('ascii', 8, 12);
|
|
80
|
+
return riff === 'RIFF' && wave === 'WAVE';
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
stripWavHeader(chunk) {
|
|
85
|
+
// 跳过 44 字节的 WAV 头
|
|
86
|
+
return chunk.slice(44);
|
|
87
|
+
}
|
|
88
|
+
createSpeaker() {
|
|
89
|
+
try {
|
|
90
|
+
const format = {
|
|
91
|
+
channels: this._format.channels,
|
|
92
|
+
sampleRate: this._format.sampleRate,
|
|
93
|
+
bitDepth: this._format.bitDepth,
|
|
94
|
+
signed: this._format.signed,
|
|
95
|
+
float: this._format.float
|
|
96
|
+
};
|
|
97
|
+
this.speaker = new Speaker(format);
|
|
98
|
+
this.speaker.on('close', () => {
|
|
99
|
+
if (!this._stopped) {
|
|
100
|
+
this._started = false;
|
|
101
|
+
this.events?.onEnd?.();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
this.speaker.on('error', (err) => {
|
|
105
|
+
this.handleError(err);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
end() {
|
|
113
|
+
if (this._stopped)
|
|
114
|
+
return;
|
|
115
|
+
if (this.speaker) {
|
|
116
|
+
try {
|
|
117
|
+
this.speaker.close();
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
// 忽略关闭错误
|
|
121
|
+
}
|
|
122
|
+
this.speaker = undefined;
|
|
123
|
+
}
|
|
124
|
+
this._started = false;
|
|
125
|
+
this._stopped = true;
|
|
126
|
+
this.events?.onEnd?.();
|
|
127
|
+
}
|
|
128
|
+
pause() {
|
|
129
|
+
if (this._started && !this._paused) {
|
|
130
|
+
this._paused = true;
|
|
131
|
+
this.events?.onPause?.();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
resume() {
|
|
135
|
+
// speaker 不支持暂停恢复
|
|
136
|
+
if (this._paused) {
|
|
137
|
+
this._paused = false;
|
|
138
|
+
this.events?.onResume?.();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
stop() {
|
|
142
|
+
this._stopped = true;
|
|
143
|
+
this._started = false;
|
|
144
|
+
this._paused = false;
|
|
145
|
+
if (this.speaker) {
|
|
146
|
+
try {
|
|
147
|
+
this.speaker.close();
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// 忽略关闭错误
|
|
151
|
+
}
|
|
152
|
+
this.speaker = undefined;
|
|
153
|
+
}
|
|
154
|
+
this.events?.onStop?.();
|
|
155
|
+
}
|
|
156
|
+
destroy() {
|
|
157
|
+
this.stop();
|
|
158
|
+
}
|
|
159
|
+
handleError(error) {
|
|
160
|
+
this.events?.onError?.(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=speaker-backend.js.map
|
package/dist/package.json
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc && npm run build:plugin && node -e \"require('fs').copyFileSync('package.json', 'dist/package.json')\"",
|
|
29
|
-
"build:plugin": "esbuild src/plugin.ts --bundle --platform=node --format=esm --outdir=dist --sourcemap --external:@opencode-ai/plugin --external:axios --external:ws --external:zod",
|
|
29
|
+
"build:plugin": "esbuild src/plugin.ts --bundle --platform=node --format=esm --outdir=dist --sourcemap --external:@opencode-ai/plugin --external:axios --external:ws --external:zod --external:naudiodon --external:play-sound --external:speaker",
|
|
30
30
|
"watch": "tsc --watch",
|
|
31
31
|
"test": "jest",
|
|
32
32
|
"lint": "eslint src --ext .ts"
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
"zod": "^4.3.6"
|
|
49
49
|
},
|
|
50
50
|
"optionalDependencies": {
|
|
51
|
-
"naudiodon": "^2.3.6"
|
|
51
|
+
"naudiodon": "^2.3.6",
|
|
52
|
+
"play-sound": "^1.1.5",
|
|
53
|
+
"speaker": "^1.4.2"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@types/howler": "^2.2.12",
|
package/dist/plugin.js
CHANGED
|
@@ -2013,7 +2013,7 @@ var require_thread_stream = __commonJS({
|
|
|
2013
2013
|
var { version } = require_package();
|
|
2014
2014
|
var { EventEmitter: EventEmitter7 } = __require("events");
|
|
2015
2015
|
var { Worker } = __require("worker_threads");
|
|
2016
|
-
var { join:
|
|
2016
|
+
var { join: join9 } = __require("path");
|
|
2017
2017
|
var { pathToFileURL } = __require("url");
|
|
2018
2018
|
var { wait } = require_wait();
|
|
2019
2019
|
var {
|
|
@@ -2049,7 +2049,7 @@ var require_thread_stream = __commonJS({
|
|
|
2049
2049
|
function createWorker(stream2, opts) {
|
|
2050
2050
|
const { filename, workerData } = opts;
|
|
2051
2051
|
const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
|
|
2052
|
-
const toExecute = bundlerOverrides["thread-stream-worker"] ||
|
|
2052
|
+
const toExecute = bundlerOverrides["thread-stream-worker"] || join9(__dirname, "lib", "worker.js");
|
|
2053
2053
|
const worker = new Worker(toExecute, {
|
|
2054
2054
|
...opts.workerOpts,
|
|
2055
2055
|
trackUnmanagedFds: false,
|
|
@@ -2438,9 +2438,9 @@ var require_transport = __commonJS({
|
|
|
2438
2438
|
"node_modules/pino/lib/transport.js"(exports, module) {
|
|
2439
2439
|
"use strict";
|
|
2440
2440
|
var { createRequire: createRequire2 } = __require("module");
|
|
2441
|
-
var { existsSync:
|
|
2441
|
+
var { existsSync: existsSync8 } = __require("node:fs");
|
|
2442
2442
|
var getCallers = require_caller();
|
|
2443
|
-
var { join:
|
|
2443
|
+
var { join: join9, isAbsolute, sep } = __require("node:path");
|
|
2444
2444
|
var { fileURLToPath: fileURLToPath2 } = __require("node:url");
|
|
2445
2445
|
var sleep = require_atomic_sleep();
|
|
2446
2446
|
var onExit = require_on_exit_leak_free();
|
|
@@ -2512,7 +2512,7 @@ var require_transport = __commonJS({
|
|
|
2512
2512
|
return false;
|
|
2513
2513
|
}
|
|
2514
2514
|
}
|
|
2515
|
-
return isAbsolute(path2) && !
|
|
2515
|
+
return isAbsolute(path2) && !existsSync8(path2);
|
|
2516
2516
|
}
|
|
2517
2517
|
function stripQuotes(value) {
|
|
2518
2518
|
const first = value[0];
|
|
@@ -2593,7 +2593,7 @@ var require_transport = __commonJS({
|
|
|
2593
2593
|
throw new Error("only one of target or targets can be specified");
|
|
2594
2594
|
}
|
|
2595
2595
|
if (targets) {
|
|
2596
|
-
target = bundlerOverrides["pino-worker"] ||
|
|
2596
|
+
target = bundlerOverrides["pino-worker"] || join9(__dirname, "worker.js");
|
|
2597
2597
|
options.targets = targets.filter((dest) => dest.target).map((dest) => {
|
|
2598
2598
|
return {
|
|
2599
2599
|
...dest,
|
|
@@ -2611,7 +2611,7 @@ var require_transport = __commonJS({
|
|
|
2611
2611
|
});
|
|
2612
2612
|
});
|
|
2613
2613
|
} else if (pipeline) {
|
|
2614
|
-
target = bundlerOverrides["pino-worker"] ||
|
|
2614
|
+
target = bundlerOverrides["pino-worker"] || join9(__dirname, "worker.js");
|
|
2615
2615
|
options.pipelines = [pipeline.map((dest) => {
|
|
2616
2616
|
return {
|
|
2617
2617
|
...dest,
|
|
@@ -2634,7 +2634,7 @@ var require_transport = __commonJS({
|
|
|
2634
2634
|
return origin;
|
|
2635
2635
|
}
|
|
2636
2636
|
if (origin === "pino/file") {
|
|
2637
|
-
return
|
|
2637
|
+
return join9(__dirname, "..", "file.js");
|
|
2638
2638
|
}
|
|
2639
2639
|
let fixTarget2;
|
|
2640
2640
|
for (const filePath of callers) {
|
|
@@ -3614,7 +3614,7 @@ var require_safe_stable_stringify = __commonJS({
|
|
|
3614
3614
|
return circularValue;
|
|
3615
3615
|
}
|
|
3616
3616
|
let res = "";
|
|
3617
|
-
let
|
|
3617
|
+
let join9 = ",";
|
|
3618
3618
|
const originalIndentation = indentation;
|
|
3619
3619
|
if (Array.isArray(value)) {
|
|
3620
3620
|
if (value.length === 0) {
|
|
@@ -3628,7 +3628,7 @@ var require_safe_stable_stringify = __commonJS({
|
|
|
3628
3628
|
indentation += spacer;
|
|
3629
3629
|
res += `
|
|
3630
3630
|
${indentation}`;
|
|
3631
|
-
|
|
3631
|
+
join9 = `,
|
|
3632
3632
|
${indentation}`;
|
|
3633
3633
|
}
|
|
3634
3634
|
const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
|
|
@@ -3636,13 +3636,13 @@ ${indentation}`;
|
|
|
3636
3636
|
for (; i < maximumValuesToStringify - 1; i++) {
|
|
3637
3637
|
const tmp2 = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
|
|
3638
3638
|
res += tmp2 !== void 0 ? tmp2 : "null";
|
|
3639
|
-
res +=
|
|
3639
|
+
res += join9;
|
|
3640
3640
|
}
|
|
3641
3641
|
const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
|
|
3642
3642
|
res += tmp !== void 0 ? tmp : "null";
|
|
3643
3643
|
if (value.length - 1 > maximumBreadth) {
|
|
3644
3644
|
const removedKeys = value.length - maximumBreadth - 1;
|
|
3645
|
-
res += `${
|
|
3645
|
+
res += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
|
|
3646
3646
|
}
|
|
3647
3647
|
if (spacer !== "") {
|
|
3648
3648
|
res += `
|
|
@@ -3663,7 +3663,7 @@ ${originalIndentation}`;
|
|
|
3663
3663
|
let separator = "";
|
|
3664
3664
|
if (spacer !== "") {
|
|
3665
3665
|
indentation += spacer;
|
|
3666
|
-
|
|
3666
|
+
join9 = `,
|
|
3667
3667
|
${indentation}`;
|
|
3668
3668
|
whitespace = " ";
|
|
3669
3669
|
}
|
|
@@ -3677,13 +3677,13 @@ ${indentation}`;
|
|
|
3677
3677
|
const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation);
|
|
3678
3678
|
if (tmp !== void 0) {
|
|
3679
3679
|
res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
|
|
3680
|
-
separator =
|
|
3680
|
+
separator = join9;
|
|
3681
3681
|
}
|
|
3682
3682
|
}
|
|
3683
3683
|
if (keyLength > maximumBreadth) {
|
|
3684
3684
|
const removedKeys = keyLength - maximumBreadth;
|
|
3685
3685
|
res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`;
|
|
3686
|
-
separator =
|
|
3686
|
+
separator = join9;
|
|
3687
3687
|
}
|
|
3688
3688
|
if (spacer !== "" && separator.length > 1) {
|
|
3689
3689
|
res = `
|
|
@@ -3724,7 +3724,7 @@ ${originalIndentation}`;
|
|
|
3724
3724
|
}
|
|
3725
3725
|
const originalIndentation = indentation;
|
|
3726
3726
|
let res = "";
|
|
3727
|
-
let
|
|
3727
|
+
let join9 = ",";
|
|
3728
3728
|
if (Array.isArray(value)) {
|
|
3729
3729
|
if (value.length === 0) {
|
|
3730
3730
|
return "[]";
|
|
@@ -3737,7 +3737,7 @@ ${originalIndentation}`;
|
|
|
3737
3737
|
indentation += spacer;
|
|
3738
3738
|
res += `
|
|
3739
3739
|
${indentation}`;
|
|
3740
|
-
|
|
3740
|
+
join9 = `,
|
|
3741
3741
|
${indentation}`;
|
|
3742
3742
|
}
|
|
3743
3743
|
const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
|
|
@@ -3745,13 +3745,13 @@ ${indentation}`;
|
|
|
3745
3745
|
for (; i < maximumValuesToStringify - 1; i++) {
|
|
3746
3746
|
const tmp2 = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
|
|
3747
3747
|
res += tmp2 !== void 0 ? tmp2 : "null";
|
|
3748
|
-
res +=
|
|
3748
|
+
res += join9;
|
|
3749
3749
|
}
|
|
3750
3750
|
const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
|
|
3751
3751
|
res += tmp !== void 0 ? tmp : "null";
|
|
3752
3752
|
if (value.length - 1 > maximumBreadth) {
|
|
3753
3753
|
const removedKeys = value.length - maximumBreadth - 1;
|
|
3754
|
-
res += `${
|
|
3754
|
+
res += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
|
|
3755
3755
|
}
|
|
3756
3756
|
if (spacer !== "") {
|
|
3757
3757
|
res += `
|
|
@@ -3764,7 +3764,7 @@ ${originalIndentation}`;
|
|
|
3764
3764
|
let whitespace = "";
|
|
3765
3765
|
if (spacer !== "") {
|
|
3766
3766
|
indentation += spacer;
|
|
3767
|
-
|
|
3767
|
+
join9 = `,
|
|
3768
3768
|
${indentation}`;
|
|
3769
3769
|
whitespace = " ";
|
|
3770
3770
|
}
|
|
@@ -3773,7 +3773,7 @@ ${indentation}`;
|
|
|
3773
3773
|
const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation);
|
|
3774
3774
|
if (tmp !== void 0) {
|
|
3775
3775
|
res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
|
|
3776
|
-
separator =
|
|
3776
|
+
separator = join9;
|
|
3777
3777
|
}
|
|
3778
3778
|
}
|
|
3779
3779
|
if (spacer !== "" && separator.length > 1) {
|
|
@@ -3831,20 +3831,20 @@ ${originalIndentation}`;
|
|
|
3831
3831
|
indentation += spacer;
|
|
3832
3832
|
let res2 = `
|
|
3833
3833
|
${indentation}`;
|
|
3834
|
-
const
|
|
3834
|
+
const join10 = `,
|
|
3835
3835
|
${indentation}`;
|
|
3836
3836
|
const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
|
|
3837
3837
|
let i = 0;
|
|
3838
3838
|
for (; i < maximumValuesToStringify - 1; i++) {
|
|
3839
3839
|
const tmp2 = stringifyIndent(String(i), value[i], stack, spacer, indentation);
|
|
3840
3840
|
res2 += tmp2 !== void 0 ? tmp2 : "null";
|
|
3841
|
-
res2 +=
|
|
3841
|
+
res2 += join10;
|
|
3842
3842
|
}
|
|
3843
3843
|
const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation);
|
|
3844
3844
|
res2 += tmp !== void 0 ? tmp : "null";
|
|
3845
3845
|
if (value.length - 1 > maximumBreadth) {
|
|
3846
3846
|
const removedKeys = value.length - maximumBreadth - 1;
|
|
3847
|
-
res2 += `${
|
|
3847
|
+
res2 += `${join10}"... ${getItemCount(removedKeys)} not stringified"`;
|
|
3848
3848
|
}
|
|
3849
3849
|
res2 += `
|
|
3850
3850
|
${originalIndentation}`;
|
|
@@ -3860,16 +3860,16 @@ ${originalIndentation}`;
|
|
|
3860
3860
|
return '"[Object]"';
|
|
3861
3861
|
}
|
|
3862
3862
|
indentation += spacer;
|
|
3863
|
-
const
|
|
3863
|
+
const join9 = `,
|
|
3864
3864
|
${indentation}`;
|
|
3865
3865
|
let res = "";
|
|
3866
3866
|
let separator = "";
|
|
3867
3867
|
let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth);
|
|
3868
3868
|
if (isTypedArrayWithEntries(value)) {
|
|
3869
|
-
res += stringifyTypedArray(value,
|
|
3869
|
+
res += stringifyTypedArray(value, join9, maximumBreadth);
|
|
3870
3870
|
keys = keys.slice(value.length);
|
|
3871
3871
|
maximumPropertiesToStringify -= value.length;
|
|
3872
|
-
separator =
|
|
3872
|
+
separator = join9;
|
|
3873
3873
|
}
|
|
3874
3874
|
if (deterministic) {
|
|
3875
3875
|
keys = sort(keys, comparator);
|
|
@@ -3880,13 +3880,13 @@ ${indentation}`;
|
|
|
3880
3880
|
const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation);
|
|
3881
3881
|
if (tmp !== void 0) {
|
|
3882
3882
|
res += `${separator}${strEscape(key2)}: ${tmp}`;
|
|
3883
|
-
separator =
|
|
3883
|
+
separator = join9;
|
|
3884
3884
|
}
|
|
3885
3885
|
}
|
|
3886
3886
|
if (keyLength > maximumBreadth) {
|
|
3887
3887
|
const removedKeys = keyLength - maximumBreadth;
|
|
3888
3888
|
res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`;
|
|
3889
|
-
separator =
|
|
3889
|
+
separator = join9;
|
|
3890
3890
|
}
|
|
3891
3891
|
if (separator !== "") {
|
|
3892
3892
|
res = `
|
|
@@ -6737,8 +6737,8 @@ var BaseTTSProvider = class {
|
|
|
6737
6737
|
import { EventEmitter } from "events";
|
|
6738
6738
|
import fs from "fs";
|
|
6739
6739
|
import { createWriteStream } from "fs";
|
|
6740
|
-
import { tmpdir as
|
|
6741
|
-
import { join as
|
|
6740
|
+
import { tmpdir as tmpdir5 } from "os";
|
|
6741
|
+
import { join as join6 } from "path";
|
|
6742
6742
|
|
|
6743
6743
|
// src/utils/logger.ts
|
|
6744
6744
|
var import_pino = __toESM(require_pino(), 1);
|
|
@@ -7466,6 +7466,299 @@ var HowlerBackend = class {
|
|
|
7466
7466
|
}
|
|
7467
7467
|
};
|
|
7468
7468
|
|
|
7469
|
+
// src/core/backends/playsound-backend.ts
|
|
7470
|
+
import { execFile as execFile3 } from "child_process";
|
|
7471
|
+
import { tmpdir as tmpdir4 } from "os";
|
|
7472
|
+
import { join as join5 } from "path";
|
|
7473
|
+
import { writeFileSync as writeFileSync4, unlinkSync as unlinkSync4, existsSync as existsSync5 } from "fs";
|
|
7474
|
+
var SAFE_PATH_REGEX4 = /^[\w\/\.]+$/;
|
|
7475
|
+
var PlaySoundBackend = class {
|
|
7476
|
+
name = "play-sound";
|
|
7477
|
+
supportsStreaming = false;
|
|
7478
|
+
player;
|
|
7479
|
+
tempFile;
|
|
7480
|
+
events;
|
|
7481
|
+
_started = false;
|
|
7482
|
+
_paused = false;
|
|
7483
|
+
_stopped = false;
|
|
7484
|
+
// P0-4: 缓冲所有chunk,等end()时一次性写入文件
|
|
7485
|
+
chunks = [];
|
|
7486
|
+
hasEnded = false;
|
|
7487
|
+
constructor(options = {}) {
|
|
7488
|
+
this.events = options.events;
|
|
7489
|
+
}
|
|
7490
|
+
start(filePath) {
|
|
7491
|
+
if (this._started) return;
|
|
7492
|
+
if (!SAFE_PATH_REGEX4.test(filePath)) {
|
|
7493
|
+
throw new Error(`Invalid file path: ${filePath}`);
|
|
7494
|
+
}
|
|
7495
|
+
this.tempFile = filePath;
|
|
7496
|
+
this._started = true;
|
|
7497
|
+
this._stopped = false;
|
|
7498
|
+
this.events?.onStart?.();
|
|
7499
|
+
this.playWithPlaySound(filePath);
|
|
7500
|
+
}
|
|
7501
|
+
async playWithPlaySound(filePath) {
|
|
7502
|
+
try {
|
|
7503
|
+
const play = (await import("play-sound")).default;
|
|
7504
|
+
const opts = {
|
|
7505
|
+
players: ["ffplay", "aplay", "mpg123", "afplay"]
|
|
7506
|
+
// 优先级
|
|
7507
|
+
};
|
|
7508
|
+
if (process.platform === "linux") {
|
|
7509
|
+
this.player = execFile3("ffplay", [
|
|
7510
|
+
"-nodisp",
|
|
7511
|
+
// 不显示窗口
|
|
7512
|
+
"-autoexit",
|
|
7513
|
+
// 播放完自动退出
|
|
7514
|
+
"-loglevel",
|
|
7515
|
+
"error",
|
|
7516
|
+
// 减少日志输出
|
|
7517
|
+
filePath
|
|
7518
|
+
], (error) => {
|
|
7519
|
+
if (this._stopped) return;
|
|
7520
|
+
if (error) {
|
|
7521
|
+
this.handleError(error);
|
|
7522
|
+
return;
|
|
7523
|
+
}
|
|
7524
|
+
this._started = false;
|
|
7525
|
+
this.events?.onEnd?.();
|
|
7526
|
+
});
|
|
7527
|
+
} else {
|
|
7528
|
+
const audio = play;
|
|
7529
|
+
const p = audio.play(filePath, (err) => {
|
|
7530
|
+
if (this._stopped) return;
|
|
7531
|
+
if (err) {
|
|
7532
|
+
this.handleError(err);
|
|
7533
|
+
return;
|
|
7534
|
+
}
|
|
7535
|
+
this._started = false;
|
|
7536
|
+
this.events?.onEnd?.();
|
|
7537
|
+
});
|
|
7538
|
+
if (p && p.kill) {
|
|
7539
|
+
this.player = p;
|
|
7540
|
+
}
|
|
7541
|
+
}
|
|
7542
|
+
if (this.player) {
|
|
7543
|
+
this.player.on("error", (error) => {
|
|
7544
|
+
this.handleError(error);
|
|
7545
|
+
});
|
|
7546
|
+
}
|
|
7547
|
+
} catch (err) {
|
|
7548
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
7549
|
+
}
|
|
7550
|
+
}
|
|
7551
|
+
write(chunk) {
|
|
7552
|
+
if (this._stopped) return;
|
|
7553
|
+
this.chunks.push(chunk);
|
|
7554
|
+
}
|
|
7555
|
+
end() {
|
|
7556
|
+
if (this._stopped || this.hasEnded) return;
|
|
7557
|
+
this.hasEnded = true;
|
|
7558
|
+
if (this.chunks.length === 0) return;
|
|
7559
|
+
this.tempFile = join5(tmpdir4(), `ocosay-${Date.now()}.wav`);
|
|
7560
|
+
writeFileSync4(this.tempFile, Buffer.concat(this.chunks));
|
|
7561
|
+
this.chunks = [];
|
|
7562
|
+
this.start(this.tempFile);
|
|
7563
|
+
}
|
|
7564
|
+
pause() {
|
|
7565
|
+
if (!this._started || this._paused || this._stopped) return;
|
|
7566
|
+
if (this.player) {
|
|
7567
|
+
try {
|
|
7568
|
+
this.player.kill("SIGSTOP");
|
|
7569
|
+
this._paused = true;
|
|
7570
|
+
this.events?.onPause?.();
|
|
7571
|
+
} catch (e) {
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7574
|
+
}
|
|
7575
|
+
resume() {
|
|
7576
|
+
if (!this._paused || this._stopped) return;
|
|
7577
|
+
if (this.player) {
|
|
7578
|
+
try {
|
|
7579
|
+
this.player.kill("SIGCONT");
|
|
7580
|
+
this._paused = false;
|
|
7581
|
+
this.events?.onResume?.();
|
|
7582
|
+
} catch (e) {
|
|
7583
|
+
}
|
|
7584
|
+
}
|
|
7585
|
+
}
|
|
7586
|
+
stop() {
|
|
7587
|
+
this._stopped = true;
|
|
7588
|
+
this._started = false;
|
|
7589
|
+
this._paused = false;
|
|
7590
|
+
if (this.player) {
|
|
7591
|
+
try {
|
|
7592
|
+
this.player.kill("SIGTERM");
|
|
7593
|
+
} catch (e) {
|
|
7594
|
+
}
|
|
7595
|
+
this.player = void 0;
|
|
7596
|
+
}
|
|
7597
|
+
this.cleanup();
|
|
7598
|
+
this.chunks = [];
|
|
7599
|
+
this.hasEnded = false;
|
|
7600
|
+
this.events?.onStop?.();
|
|
7601
|
+
}
|
|
7602
|
+
setVolume(_volume) {
|
|
7603
|
+
}
|
|
7604
|
+
destroy() {
|
|
7605
|
+
this.stop();
|
|
7606
|
+
}
|
|
7607
|
+
cleanup() {
|
|
7608
|
+
if (this.tempFile && this.tempFile.startsWith(tmpdir4())) {
|
|
7609
|
+
try {
|
|
7610
|
+
if (existsSync5(this.tempFile)) {
|
|
7611
|
+
unlinkSync4(this.tempFile);
|
|
7612
|
+
}
|
|
7613
|
+
} catch (e) {
|
|
7614
|
+
}
|
|
7615
|
+
this.tempFile = void 0;
|
|
7616
|
+
}
|
|
7617
|
+
}
|
|
7618
|
+
handleError(error) {
|
|
7619
|
+
this.events?.onError?.(error);
|
|
7620
|
+
}
|
|
7621
|
+
};
|
|
7622
|
+
|
|
7623
|
+
// src/core/backends/speaker-backend.ts
|
|
7624
|
+
import Speaker from "speaker";
|
|
7625
|
+
var SpeakerBackend = class {
|
|
7626
|
+
name = "speaker";
|
|
7627
|
+
supportsStreaming = true;
|
|
7628
|
+
speaker;
|
|
7629
|
+
events;
|
|
7630
|
+
_started = false;
|
|
7631
|
+
_paused = false;
|
|
7632
|
+
_stopped = false;
|
|
7633
|
+
_format = {
|
|
7634
|
+
channels: 1,
|
|
7635
|
+
sampleRate: 16e3,
|
|
7636
|
+
bitDepth: 16,
|
|
7637
|
+
signed: true,
|
|
7638
|
+
float: false
|
|
7639
|
+
};
|
|
7640
|
+
constructor(options = {}) {
|
|
7641
|
+
this.events = options.events;
|
|
7642
|
+
if (options.sampleRate) {
|
|
7643
|
+
this._format.sampleRate = options.sampleRate;
|
|
7644
|
+
}
|
|
7645
|
+
if (options.channels) {
|
|
7646
|
+
this._format.channels = options.channels;
|
|
7647
|
+
}
|
|
7648
|
+
if (options.format === "wav") {
|
|
7649
|
+
this._format.bitDepth = 16;
|
|
7650
|
+
}
|
|
7651
|
+
}
|
|
7652
|
+
start(_filePath) {
|
|
7653
|
+
if (this._started) return;
|
|
7654
|
+
this._started = true;
|
|
7655
|
+
this._stopped = false;
|
|
7656
|
+
this._paused = false;
|
|
7657
|
+
this.events?.onStart?.();
|
|
7658
|
+
}
|
|
7659
|
+
write(chunk) {
|
|
7660
|
+
if (this._stopped || this._paused) return;
|
|
7661
|
+
if (!this._started) {
|
|
7662
|
+
this.start("");
|
|
7663
|
+
}
|
|
7664
|
+
try {
|
|
7665
|
+
if (!this.speaker) {
|
|
7666
|
+
this.createSpeaker();
|
|
7667
|
+
}
|
|
7668
|
+
if (this.speaker) {
|
|
7669
|
+
if (this.isWavHeader(chunk)) {
|
|
7670
|
+
const audioData = this.stripWavHeader(chunk);
|
|
7671
|
+
if (audioData.length > 0) {
|
|
7672
|
+
this.speaker.write(audioData);
|
|
7673
|
+
}
|
|
7674
|
+
} else {
|
|
7675
|
+
this.speaker.write(chunk);
|
|
7676
|
+
}
|
|
7677
|
+
}
|
|
7678
|
+
} catch (err) {
|
|
7679
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
isWavHeader(chunk) {
|
|
7683
|
+
if (chunk.length >= 44) {
|
|
7684
|
+
const riff = chunk.toString("ascii", 0, 4);
|
|
7685
|
+
const wave = chunk.toString("ascii", 8, 12);
|
|
7686
|
+
return riff === "RIFF" && wave === "WAVE";
|
|
7687
|
+
}
|
|
7688
|
+
return false;
|
|
7689
|
+
}
|
|
7690
|
+
stripWavHeader(chunk) {
|
|
7691
|
+
return chunk.slice(44);
|
|
7692
|
+
}
|
|
7693
|
+
createSpeaker() {
|
|
7694
|
+
try {
|
|
7695
|
+
const format = {
|
|
7696
|
+
channels: this._format.channels,
|
|
7697
|
+
sampleRate: this._format.sampleRate,
|
|
7698
|
+
bitDepth: this._format.bitDepth,
|
|
7699
|
+
signed: this._format.signed,
|
|
7700
|
+
float: this._format.float
|
|
7701
|
+
};
|
|
7702
|
+
this.speaker = new Speaker(format);
|
|
7703
|
+
this.speaker.on("close", () => {
|
|
7704
|
+
if (!this._stopped) {
|
|
7705
|
+
this._started = false;
|
|
7706
|
+
this.events?.onEnd?.();
|
|
7707
|
+
}
|
|
7708
|
+
});
|
|
7709
|
+
this.speaker.on("error", (err) => {
|
|
7710
|
+
this.handleError(err);
|
|
7711
|
+
});
|
|
7712
|
+
} catch (err) {
|
|
7713
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)));
|
|
7714
|
+
}
|
|
7715
|
+
}
|
|
7716
|
+
end() {
|
|
7717
|
+
if (this._stopped) return;
|
|
7718
|
+
if (this.speaker) {
|
|
7719
|
+
try {
|
|
7720
|
+
this.speaker.close();
|
|
7721
|
+
} catch (e) {
|
|
7722
|
+
}
|
|
7723
|
+
this.speaker = void 0;
|
|
7724
|
+
}
|
|
7725
|
+
this._started = false;
|
|
7726
|
+
this._stopped = true;
|
|
7727
|
+
this.events?.onEnd?.();
|
|
7728
|
+
}
|
|
7729
|
+
pause() {
|
|
7730
|
+
if (this._started && !this._paused) {
|
|
7731
|
+
this._paused = true;
|
|
7732
|
+
this.events?.onPause?.();
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
resume() {
|
|
7736
|
+
if (this._paused) {
|
|
7737
|
+
this._paused = false;
|
|
7738
|
+
this.events?.onResume?.();
|
|
7739
|
+
}
|
|
7740
|
+
}
|
|
7741
|
+
stop() {
|
|
7742
|
+
this._stopped = true;
|
|
7743
|
+
this._started = false;
|
|
7744
|
+
this._paused = false;
|
|
7745
|
+
if (this.speaker) {
|
|
7746
|
+
try {
|
|
7747
|
+
this.speaker.close();
|
|
7748
|
+
} catch (e) {
|
|
7749
|
+
}
|
|
7750
|
+
this.speaker = void 0;
|
|
7751
|
+
}
|
|
7752
|
+
this.events?.onStop?.();
|
|
7753
|
+
}
|
|
7754
|
+
destroy() {
|
|
7755
|
+
this.stop();
|
|
7756
|
+
}
|
|
7757
|
+
handleError(error) {
|
|
7758
|
+
this.events?.onError?.(error);
|
|
7759
|
+
}
|
|
7760
|
+
};
|
|
7761
|
+
|
|
7469
7762
|
// src/core/backends/index.ts
|
|
7470
7763
|
import { execSync } from "child_process";
|
|
7471
7764
|
function execCmd(cmd) {
|
|
@@ -7488,6 +7781,15 @@ function isNaudiodonAvailable() {
|
|
|
7488
7781
|
return false;
|
|
7489
7782
|
}
|
|
7490
7783
|
}
|
|
7784
|
+
function isSpeakerAvailable() {
|
|
7785
|
+
try {
|
|
7786
|
+
__require.resolve("speaker");
|
|
7787
|
+
return true;
|
|
7788
|
+
} catch (err) {
|
|
7789
|
+
logger.debug({ err }, "speaker not available");
|
|
7790
|
+
return false;
|
|
7791
|
+
}
|
|
7792
|
+
}
|
|
7491
7793
|
function createBackend(type = "auto" /* AUTO */, options = {}) {
|
|
7492
7794
|
const platform = process.platform;
|
|
7493
7795
|
if (type !== "auto" /* AUTO */) {
|
|
@@ -7517,6 +7819,13 @@ function createBackend(type = "auto" /* AUTO */, options = {}) {
|
|
|
7517
7819
|
return new AplayBackend(options);
|
|
7518
7820
|
}
|
|
7519
7821
|
}
|
|
7822
|
+
if (isCommandAvailable("ffplay")) {
|
|
7823
|
+
return new PlaySoundBackend(options);
|
|
7824
|
+
}
|
|
7825
|
+
if (isSpeakerAvailable()) {
|
|
7826
|
+
return new SpeakerBackend(options);
|
|
7827
|
+
}
|
|
7828
|
+
logger.warn("All Linux audio backends failed, using HowlerBackend as fallback");
|
|
7520
7829
|
return new HowlerBackend(options);
|
|
7521
7830
|
case "win32":
|
|
7522
7831
|
return new PowerShellBackend(options);
|
|
@@ -7536,6 +7845,10 @@ function createBackendByType(type, options) {
|
|
|
7536
7845
|
return new PowerShellBackend(options);
|
|
7537
7846
|
case "howler" /* HOWLER */:
|
|
7538
7847
|
return new HowlerBackend(options);
|
|
7848
|
+
case "play-sound" /* PLAY_SOUND */:
|
|
7849
|
+
return new PlaySoundBackend(options);
|
|
7850
|
+
case "speaker" /* SPEAKER */:
|
|
7851
|
+
return new SpeakerBackend(options);
|
|
7539
7852
|
default:
|
|
7540
7853
|
throw new Error(`Unknown backend type: ${type}`);
|
|
7541
7854
|
}
|
|
@@ -7600,7 +7913,7 @@ var AudioPlayer = class extends EventEmitter {
|
|
|
7600
7913
|
this._playing = true;
|
|
7601
7914
|
this._paused = false;
|
|
7602
7915
|
try {
|
|
7603
|
-
const tempFile =
|
|
7916
|
+
const tempFile = join6(tmpdir5(), `ocosay-${Date.now()}.${format}`);
|
|
7604
7917
|
this.currentFile = tempFile;
|
|
7605
7918
|
if (Buffer.isBuffer(audioData)) {
|
|
7606
7919
|
fs.writeFileSync(tempFile, audioData);
|
|
@@ -7772,7 +8085,7 @@ var notificationService = new NotificationService();
|
|
|
7772
8085
|
|
|
7773
8086
|
// src/core/speaker.ts
|
|
7774
8087
|
var logger4 = createModuleLogger("Speaker");
|
|
7775
|
-
var
|
|
8088
|
+
var Speaker2 = class extends EventEmitter2 {
|
|
7776
8089
|
constructor(options = {}) {
|
|
7777
8090
|
super();
|
|
7778
8091
|
this.options = options;
|
|
@@ -7948,7 +8261,7 @@ var Speaker = class extends EventEmitter2 {
|
|
|
7948
8261
|
var defaultSpeaker;
|
|
7949
8262
|
function getDefaultSpeaker() {
|
|
7950
8263
|
if (!defaultSpeaker) {
|
|
7951
|
-
defaultSpeaker = new
|
|
8264
|
+
defaultSpeaker = new Speaker2();
|
|
7952
8265
|
}
|
|
7953
8266
|
return defaultSpeaker;
|
|
7954
8267
|
}
|
|
@@ -9319,7 +9632,7 @@ async function initialize(config) {
|
|
|
9319
9632
|
defaultModel: config?.defaultModel || "stream",
|
|
9320
9633
|
defaultVoice: config?.defaultVoice
|
|
9321
9634
|
};
|
|
9322
|
-
speaker = new
|
|
9635
|
+
speaker = new Speaker2(speakerOptions);
|
|
9323
9636
|
if (config?.autoRead) {
|
|
9324
9637
|
autoReadEnabled = true;
|
|
9325
9638
|
initializeStreamComponents(config);
|
|
@@ -9578,27 +9891,27 @@ function loadOrCreateConfig() {
|
|
|
9578
9891
|
}
|
|
9579
9892
|
|
|
9580
9893
|
// src/plugin.ts
|
|
9581
|
-
import { readFileSync as readFileSync2, existsSync as
|
|
9894
|
+
import { readFileSync as readFileSync2, existsSync as existsSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
9582
9895
|
import { fileURLToPath } from "url";
|
|
9583
|
-
import { dirname as dirname2, join as
|
|
9896
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
9584
9897
|
import { homedir as homedir3 } from "os";
|
|
9585
9898
|
import { execSync as execSync2 } from "child_process";
|
|
9586
9899
|
import { createRequire } from "module";
|
|
9587
9900
|
var logger8 = createModuleLogger("Plugin");
|
|
9588
9901
|
var require2 = createRequire(import.meta.url);
|
|
9589
9902
|
function getSkipFilePath() {
|
|
9590
|
-
return
|
|
9903
|
+
return join8(homedir3(), ".config", "opencode", ".naudiodon_skip");
|
|
9591
9904
|
}
|
|
9592
9905
|
function shouldSkipNaudiodon() {
|
|
9593
|
-
return
|
|
9906
|
+
return existsSync7(getSkipFilePath());
|
|
9594
9907
|
}
|
|
9595
9908
|
function markNaudiodonSkipped() {
|
|
9596
9909
|
try {
|
|
9597
|
-
const dir =
|
|
9598
|
-
if (!
|
|
9910
|
+
const dir = join8(homedir3(), ".config", "opencode");
|
|
9911
|
+
if (!existsSync7(dir)) {
|
|
9599
9912
|
execSync2("mkdir -p", { cwd: dir });
|
|
9600
9913
|
}
|
|
9601
|
-
|
|
9914
|
+
writeFileSync6(getSkipFilePath(), Date.now().toString(), "utf-8");
|
|
9602
9915
|
} catch {
|
|
9603
9916
|
}
|
|
9604
9917
|
}
|
|
@@ -9771,6 +10084,29 @@ function checkAlsa() {
|
|
|
9771
10084
|
const test = execCmd2("aplay -l");
|
|
9772
10085
|
return test.success && !test.output.includes("no soundcards");
|
|
9773
10086
|
}
|
|
10087
|
+
function checkFFplay() {
|
|
10088
|
+
return execCmd2("which ffplay").success;
|
|
10089
|
+
}
|
|
10090
|
+
async function checkAudioEnvironmentForBackend() {
|
|
10091
|
+
const platform = process.platform;
|
|
10092
|
+
if (platform === "linux") {
|
|
10093
|
+
const hasAlsa = checkAlsa();
|
|
10094
|
+
const hasFFplay = checkFFplay();
|
|
10095
|
+
if (!hasAlsa && !hasFFplay) {
|
|
10096
|
+
notificationService.warning(
|
|
10097
|
+
"\u672A\u68C0\u6D4B\u5230\u97F3\u9891\u8BBE\u5907",
|
|
10098
|
+
"\u8BF7\u5B89\u88C5 ffmpeg \u6216\u914D\u7F6E PulseAudio",
|
|
10099
|
+
8e3
|
|
10100
|
+
);
|
|
10101
|
+
} else if (hasFFplay && !hasAlsa) {
|
|
10102
|
+
notificationService.info(
|
|
10103
|
+
"ffplay \u53EF\u7528",
|
|
10104
|
+
"\u5C06\u4F7F\u7528 ffplay \u8FDB\u884C\u65E0\u58F0\u5361\u64AD\u653E",
|
|
10105
|
+
5e3
|
|
10106
|
+
);
|
|
10107
|
+
}
|
|
10108
|
+
}
|
|
10109
|
+
}
|
|
9774
10110
|
async function installPortAudio() {
|
|
9775
10111
|
const platform = process.platform;
|
|
9776
10112
|
const wsl = isWsl2();
|
|
@@ -9802,6 +10138,23 @@ async function installPortAudio() {
|
|
|
9802
10138
|
}
|
|
9803
10139
|
};
|
|
9804
10140
|
if (platform === "linux" || wsl) {
|
|
10141
|
+
notificationService.info("\u68C0\u6D4B ffmpeg...", "\u97F3\u9891\u540E\u7AEF", 5e3);
|
|
10142
|
+
const ffmpegCheck = execCmd2("which ffplay");
|
|
10143
|
+
if (ffmpegCheck.success) {
|
|
10144
|
+
logger8.info("ffmpeg already available");
|
|
10145
|
+
notificationService.success("ffmpeg \u5C31\u7EEA", "ffplay \u53EF\u7528\u4E8E\u65E0\u58F0\u5361\u64AD\u653E", 5e3);
|
|
10146
|
+
} else {
|
|
10147
|
+
notificationService.info("\u5B89\u88C5 ffmpeg...", "\u97F3\u9891\u540E\u7AEF", 5e3);
|
|
10148
|
+
const ffmpegInstalled = await runInstall(
|
|
10149
|
+
"sudo apt-get update && sudo apt-get install -y ffmpeg",
|
|
10150
|
+
"\u5B89\u88C5 ffmpeg"
|
|
10151
|
+
);
|
|
10152
|
+
if (ffmpegInstalled) {
|
|
10153
|
+
notificationService.success("ffmpeg \u5B89\u88C5\u6210\u529F", "ffplay \u53EF\u7528\u4E8E\u65E0\u58F0\u5361\u64AD\u653E", 5e3);
|
|
10154
|
+
} else {
|
|
10155
|
+
notificationService.warning("ffmpeg \u5B89\u88C5\u5931\u8D25", "play-sound \u540E\u7AEF\u53EF\u80FD\u65E0\u6CD5\u5DE5\u4F5C", 5e3);
|
|
10156
|
+
}
|
|
10157
|
+
}
|
|
9805
10158
|
notificationService.info("\u68C0\u6D4B\u97F3\u9891\u8BBE\u5907...", "\u97F3\u9891\u540E\u7AEF", 5e3);
|
|
9806
10159
|
if (checkAlsa()) {
|
|
9807
10160
|
logger8.info("alsa-utils already available and working");
|
|
@@ -9858,7 +10211,7 @@ var __dirname2 = dirname2(__filename);
|
|
|
9858
10211
|
var id = "ocosay";
|
|
9859
10212
|
var pluginVersion = "0.0.0";
|
|
9860
10213
|
try {
|
|
9861
|
-
const pkg = JSON.parse(readFileSync2(
|
|
10214
|
+
const pkg = JSON.parse(readFileSync2(join8(__dirname2, "package.json"), "utf-8"));
|
|
9862
10215
|
pluginVersion = pkg.version || "0.0.0";
|
|
9863
10216
|
} catch {
|
|
9864
10217
|
}
|
|
@@ -9998,6 +10351,7 @@ var server = (async (input, _options) => {
|
|
|
9998
10351
|
logger8.error({ error: initError }, "initialization failed");
|
|
9999
10352
|
}
|
|
10000
10353
|
await ensureNaudiodonCompiled();
|
|
10354
|
+
await checkAudioEnvironmentForBackend();
|
|
10001
10355
|
setTimeout(() => {
|
|
10002
10356
|
if (initError) {
|
|
10003
10357
|
notificationService.error(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/ocosay",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "OpenCode TTS 播放插件 - 支持豆包模式边接收边朗读",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/plugin.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc && npm run build:plugin && node -e \"require('fs').copyFileSync('package.json', 'dist/package.json')\"",
|
|
29
|
-
"build:plugin": "esbuild src/plugin.ts --bundle --platform=node --format=esm --outdir=dist --sourcemap --external:@opencode-ai/plugin --external:axios --external:ws --external:zod",
|
|
29
|
+
"build:plugin": "esbuild src/plugin.ts --bundle --platform=node --format=esm --outdir=dist --sourcemap --external:@opencode-ai/plugin --external:axios --external:ws --external:zod --external:naudiodon --external:play-sound --external:speaker",
|
|
30
30
|
"watch": "tsc --watch",
|
|
31
31
|
"test": "jest",
|
|
32
32
|
"lint": "eslint src --ext .ts"
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
"zod": "^4.3.6"
|
|
49
49
|
},
|
|
50
50
|
"optionalDependencies": {
|
|
51
|
-
"naudiodon": "^2.3.6"
|
|
51
|
+
"naudiodon": "^2.3.6",
|
|
52
|
+
"play-sound": "^1.1.5",
|
|
53
|
+
"speaker": "^1.4.2"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@types/howler": "^2.2.12",
|