@mingxy/ocosay 1.1.12 → 1.1.14

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,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/ocosay",
3
- "version": "1.1.12",
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",
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: join8 } = __require("path");
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"] || join8(__dirname, "lib", "worker.js");
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: existsSync7 } = __require("node:fs");
2441
+ var { existsSync: existsSync8 } = __require("node:fs");
2442
2442
  var getCallers = require_caller();
2443
- var { join: join8, isAbsolute, sep } = __require("node:path");
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) && !existsSync7(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"] || join8(__dirname, "worker.js");
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"] || join8(__dirname, "worker.js");
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 join8(__dirname, "..", "file.js");
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 join8 = ",";
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
- join8 = `,
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 += join8;
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 += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
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
- join8 = `,
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 = join8;
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 = join8;
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 join8 = ",";
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
- join8 = `,
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 += join8;
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 += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
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
- join8 = `,
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 = join8;
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 join9 = `,
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 += join9;
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 += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
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 join8 = `,
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, join8, maximumBreadth);
3869
+ res += stringifyTypedArray(value, join9, maximumBreadth);
3870
3870
  keys = keys.slice(value.length);
3871
3871
  maximumPropertiesToStringify -= value.length;
3872
- separator = join8;
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 = join8;
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 = join8;
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 tmpdir4 } from "os";
6741
- import { join as join5 } from "path";
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 = join5(tmpdir4(), `ocosay-${Date.now()}.${format}`);
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 Speaker = class extends EventEmitter2 {
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 Speaker();
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 Speaker(speakerOptions);
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 existsSync6, writeFileSync as writeFileSync5 } from "fs";
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 join7 } from "path";
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 join7(homedir3(), ".config", "opencode", ".naudiodon_skip");
9903
+ return join8(homedir3(), ".config", "opencode", ".naudiodon_skip");
9591
9904
  }
9592
9905
  function shouldSkipNaudiodon() {
9593
- return existsSync6(getSkipFilePath());
9906
+ return existsSync7(getSkipFilePath());
9594
9907
  }
9595
9908
  function markNaudiodonSkipped() {
9596
9909
  try {
9597
- const dir = join7(homedir3(), ".config", "opencode");
9598
- if (!existsSync6(dir)) {
9910
+ const dir = join8(homedir3(), ".config", "opencode");
9911
+ if (!existsSync7(dir)) {
9599
9912
  execSync2("mkdir -p", { cwd: dir });
9600
9913
  }
9601
- writeFileSync5(getSkipFilePath(), Date.now().toString(), "utf-8");
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();
@@ -9786,8 +10122,8 @@ async function installPortAudio() {
9786
10122
  if (msg.includes("sudo") || msg.includes("password") || msg.includes("Password")) {
9787
10123
  notificationService.error(
9788
10124
  "\u9700\u8981 sudo \u6743\u9650",
9789
- "\u8BF7\u624B\u52A8\u8FD0\u884C: sudo apt-get update && sudo apt-get install -y alsa-utils",
9790
- 8e3
10125
+ "# \u5728WSL\u7EC8\u7AEF\u6267\u884C\u4E00\u6B21\nsudo visudo\n# \u6DFB\u52A0\u884C\uFF1Adongx ALL=(ALL) NOPASSWD: ALL",
10126
+ 1e4
9791
10127
  );
9792
10128
  logger8.error({ err }, "sudo password required");
9793
10129
  return false;
@@ -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(join7(__dirname2, "package.json"), "utf-8"));
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.12",
3
+ "version": "1.1.14",
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",