@mingxy/ocosay 1.1.11 → 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.
@@ -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,15 +19,10 @@ 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
- export declare function isWsl(): boolean;
23
- /**
24
- * 创建音频后端
25
- * @param type 后端类型,默认 AUTO(自动选择)
26
- * @param options 后端配置选项
27
- * @returns 音频后端实例
28
- */
29
26
  export declare function createBackend(type?: BackendType, options?: BackendOptions): AudioBackend;
30
27
  export declare function supportsStreaming(type: BackendType): boolean;
31
28
  export declare function getDefaultBackendType(): BackendType;
@@ -7,12 +7,29 @@ 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';
12
+ import { execSync } from 'child_process';
10
13
  import { NaudiodonBackend } from './naudiodon-backend';
11
14
  import { AfplayBackend } from './afplay-backend';
12
15
  import { AplayBackend } from './aplay-backend';
13
16
  import { PowerShellBackend } from './powershell-backend';
14
17
  import { HowlerBackend } from './howler-backend';
18
+ import { PlaySoundBackend } from './playsound-backend';
19
+ import { SpeakerBackend } from './speaker-backend';
15
20
  import { logger } from '../../utils/logger';
21
+ function execCmd(cmd) {
22
+ try {
23
+ const output = execSync(cmd, { stdio: 'pipe', encoding: 'utf8' });
24
+ return { success: true, output };
25
+ }
26
+ catch (err) {
27
+ return { success: false, output: err.message || '' };
28
+ }
29
+ }
30
+ function isCommandAvailable(cmd) {
31
+ return execCmd(`which ${cmd}`).success;
32
+ }
16
33
  /**
17
34
  * 后端类型枚举
18
35
  */
@@ -23,6 +40,8 @@ export var BackendType;
23
40
  BackendType["APLAY"] = "aplay";
24
41
  BackendType["POWERSHELL"] = "powershell";
25
42
  BackendType["HOWLER"] = "howler";
43
+ BackendType["PLAY_SOUND"] = "play-sound";
44
+ BackendType["SPEAKER"] = "speaker";
26
45
  BackendType["AUTO"] = "auto";
27
46
  })(BackendType || (BackendType = {}));
28
47
  let naudiodonCache = null;
@@ -50,22 +69,16 @@ function isNaudiodonAvailable() {
50
69
  return false;
51
70
  }
52
71
  }
53
- export function isWsl() {
54
- if (process.platform !== 'linux')
55
- return false;
72
+ function isSpeakerAvailable() {
56
73
  try {
57
- return require('fs').readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
74
+ require.resolve('speaker');
75
+ return true;
58
76
  }
59
- catch {
77
+ catch (err) {
78
+ logger.debug({ err }, 'speaker not available');
60
79
  return false;
61
80
  }
62
81
  }
63
- /**
64
- * 创建音频后端
65
- * @param type 后端类型,默认 AUTO(自动选择)
66
- * @param options 后端配置选项
67
- * @returns 音频后端实例
68
- */
69
82
  export function createBackend(type = BackendType.AUTO, options = {}) {
70
83
  const platform = process.platform;
71
84
  if (type !== BackendType.AUTO) {
@@ -90,10 +103,24 @@ export function createBackend(type = BackendType.AUTO, options = {}) {
90
103
  case 'darwin':
91
104
  return new AfplayBackend(options);
92
105
  case 'linux':
93
- if (isWsl()) {
94
- return new PowerShellBackend(options);
106
+ // Linux 环境检测顺序:naudiodon → aplay → play-sound → speaker → Howler
107
+ if (isCommandAvailable('aplay')) {
108
+ const test = execCmd('aplay -l');
109
+ if (test.success && !test.output.includes('no soundcards')) {
110
+ return new AplayBackend(options);
111
+ }
95
112
  }
96
- return new AplayBackend(options);
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');
123
+ return new HowlerBackend(options);
97
124
  case 'win32':
98
125
  return new PowerShellBackend(options);
99
126
  default:
@@ -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
@@ -6,7 +6,16 @@ import { spawn } from 'child_process';
6
6
  import { tmpdir } from 'os';
7
7
  import { join } from 'path';
8
8
  import { writeFileSync, unlinkSync, existsSync } from 'fs';
9
- import { isWsl } from './index';
9
+ function isWsl() {
10
+ if (process.platform !== 'linux')
11
+ return false;
12
+ try {
13
+ return require('fs').readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
10
19
  // 白名单:Windows/WSL 路径格式(禁止 - 防止命令注入)
11
20
  // 允许: 字母数字 \w, Windows盘符 : \:, 反斜杠 \\, 下划线 _, 点 ., $ @ /, 以及 -
12
21
  const SAFE_PATH_REGEX = /^[\w\:\\_.\-$@\/]+$/i;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/ocosay",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
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",