@mingxy/ocosay 1.1.32 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ import { HowlerBackend } from './howler-backend';
18
18
  import { PlaySoundBackend } from './playsound-backend';
19
19
  import { SpeakerBackend } from './speaker-backend';
20
20
  import { logger } from '../../utils/logger';
21
+ import { notificationService } from '../notification';
21
22
  function execCmd(cmd) {
22
23
  try {
23
24
  const output = execSync(cmd, { stdio: 'pipe', encoding: 'utf8' });
@@ -44,21 +45,6 @@ export var BackendType;
44
45
  BackendType["SPEAKER"] = "speaker";
45
46
  BackendType["AUTO"] = "auto";
46
47
  })(BackendType || (BackendType = {}));
47
- let naudiodonCache = null;
48
- async function tryLoadNaudiodon() {
49
- if (naudiodonCache !== null) {
50
- return naudiodonCache;
51
- }
52
- try {
53
- naudiodonCache = await import('naudiodon');
54
- return naudiodonCache;
55
- }
56
- catch (err) {
57
- logger.warn({ err }, 'failed to load naudiodon module');
58
- naudiodonCache = false;
59
- return null;
60
- }
61
- }
62
48
  function isNaudiodonAvailable() {
63
49
  try {
64
50
  require.resolve('naudiodon');
@@ -79,12 +65,27 @@ function isSpeakerAvailable() {
79
65
  return false;
80
66
  }
81
67
  }
68
+ /**
69
+ * 检测是否运行在 WSL (Windows Subsystem for Linux) 环境中
70
+ */
71
+ function isWSL() {
72
+ if (process.platform !== 'linux')
73
+ return false;
74
+ try {
75
+ const output = execSync('uname -r', { stdio: 'pipe', encoding: 'utf8' });
76
+ return output.toLowerCase().includes('microsoft');
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
82
  export function createBackend(type = BackendType.AUTO, options = {}) {
83
83
  const platform = process.platform;
84
84
  if (type !== BackendType.AUTO) {
85
85
  return createBackendByType(type, options);
86
86
  }
87
- if (isNaudiodonAvailable()) {
87
+ // WSL 环境下 naudiodon 可能无法工作(无法访问 Windows 音频设备),跳过
88
+ if (!isWSL() && isNaudiodonAvailable()) {
88
89
  try {
89
90
  const naudiodon = require('naudiodon');
90
91
  if (naudiodon) {
@@ -97,13 +98,35 @@ export function createBackend(type = BackendType.AUTO, options = {}) {
97
98
  }
98
99
  catch (err) {
99
100
  logger.error({ err }, 'failed to initialize naudiodon backend');
101
+ notificationService.warning('naudiodon 初始化失败', '将使用其他音频后端', 5000);
100
102
  }
101
103
  }
102
104
  switch (platform) {
103
105
  case 'darwin':
104
106
  return new AfplayBackend(options);
105
- case 'linux':
107
+ case 'linux': {
108
+ const wsl = isWSL();
109
+ if (wsl) {
110
+ logger.debug('Running on WSL, skipping naudiodon (may not work with Windows audio)');
111
+ }
106
112
  // Linux 环境检测顺序:naudiodon → aplay → play-sound → speaker → Howler
113
+ // WSL 环境下 naudiodon 可能无法工作,直接尝试其他后端
114
+ if (!wsl && isNaudiodonAvailable()) {
115
+ try {
116
+ const naudiodon = require('naudiodon');
117
+ if (naudiodon) {
118
+ const devices = naudiodon.getDevices();
119
+ if (devices && devices.length > 0) {
120
+ return new NaudiodonBackend(options);
121
+ }
122
+ logger.debug('naudiodon has no audio devices, skipping');
123
+ }
124
+ }
125
+ catch (err) {
126
+ logger.error({ err }, 'failed to initialize naudiodon backend');
127
+ }
128
+ }
129
+ // aplay 后端
107
130
  if (isCommandAvailable('aplay')) {
108
131
  const test = execCmd('aplay -l');
109
132
  if (test.success && !test.output.includes('no soundcards')) {
@@ -121,6 +144,7 @@ export function createBackend(type = BackendType.AUTO, options = {}) {
121
144
  // 彻底失败,使用 Howler 作为最后的回退
122
145
  logger.warn('All Linux audio backends failed, using HowlerBackend as fallback');
123
146
  return new HowlerBackend(options);
147
+ }
124
148
  case 'win32':
125
149
  return new PowerShellBackend(options);
126
150
  default:
@@ -24,7 +24,7 @@ export declare class PlaySoundBackend implements AudioBackend {
24
24
  private chunks;
25
25
  private hasEnded;
26
26
  constructor(options?: BackendOptions);
27
- start(filePath: string): void;
27
+ start(filePath: string): Promise<void>;
28
28
  private playWithPlaySound;
29
29
  write(chunk: Buffer): void;
30
30
  end(): void;
@@ -7,6 +7,8 @@ import { execFile } from 'child_process';
7
7
  import { tmpdir } from 'os';
8
8
  import { join } from 'path';
9
9
  import { writeFileSync, unlinkSync, existsSync } from 'fs';
10
+ import { promisify } from 'util';
11
+ const execFileAsync = promisify(execFile);
10
12
  // 白名单:只允许特定路径格式(禁止 - 防止命令注入)
11
13
  const SAFE_PATH_REGEX = /^[\w\/\.]+$/;
12
14
  /**
@@ -32,7 +34,7 @@ export class PlaySoundBackend {
32
34
  constructor(options = {}) {
33
35
  this.events = options.events;
34
36
  }
35
- start(filePath) {
37
+ async start(filePath) {
36
38
  if (this._started)
37
39
  return;
38
40
  if (!SAFE_PATH_REGEX.test(filePath)) {
@@ -42,62 +44,94 @@ export class PlaySoundBackend {
42
44
  this._started = true;
43
45
  this._stopped = false;
44
46
  this.events?.onStart?.();
45
- // 动态导入 play-sound
46
- this.playWithPlaySound(filePath);
47
+ // 等待 play-sound 播放完成
48
+ await this.playWithPlaySound(filePath);
47
49
  }
48
- async playWithPlaySound(filePath) {
49
- try {
50
+ playWithPlaySound(filePath) {
51
+ return new Promise((resolve, reject) => {
50
52
  // 异步导入 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) {
53
+ import('play-sound').then((playModule) => {
54
+ const play = playModule.default;
55
+ // 对于 ffplay,使用无声模式
56
+ if (process.platform === 'linux') {
57
+ // ffplay 无声卡播放参数
58
+ execFileAsync('ffplay', [
59
+ '-nodisp', // 不显示窗口
60
+ '-autoexit', // 播放完自动退出
61
+ '-loglevel', 'error', // 减少日志输出
62
+ filePath
63
+ ]).then(() => {
64
+ if (this._stopped) {
65
+ resolve();
66
+ return;
67
+ }
68
+ this._started = false;
69
+ this.events?.onEnd?.();
70
+ resolve();
71
+ }).catch((error) => {
72
+ if (this._stopped) {
73
+ resolve();
74
+ return;
75
+ }
76
+ this._started = false;
68
77
  this.handleError(error);
69
- return;
78
+ reject(error);
79
+ });
80
+ }
81
+ else {
82
+ // 使用 play-sound 的 Promise API
83
+ // play-sound 的类型定义不完善,需要用 any 访问 .play() 方法
84
+ const playerSound = play;
85
+ const p = playerSound.play(filePath);
86
+ if (p && p.then) {
87
+ // 返回 Promise 的情况
88
+ p.then(() => {
89
+ if (this._stopped) {
90
+ resolve();
91
+ return;
92
+ }
93
+ this._started = false;
94
+ this.events?.onEnd?.();
95
+ resolve();
96
+ }).catch((err) => {
97
+ if (this._stopped) {
98
+ resolve();
99
+ return;
100
+ }
101
+ this._started = false;
102
+ this.handleError(err);
103
+ reject(err);
104
+ });
70
105
  }
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;
106
+ else if (p && p.kill) {
107
+ // 返回 ChildProcess 的情况
108
+ this.player = p;
109
+ p.on('error', (error) => {
110
+ this.handleError(error);
111
+ reject(error);
112
+ });
113
+ p.on('close', () => {
114
+ if (this._stopped) {
115
+ resolve();
116
+ return;
117
+ }
118
+ this._started = false;
119
+ this.events?.onEnd?.();
120
+ resolve();
121
+ });
122
+ }
123
+ else {
124
+ // 无法识别的返回类型,直接 resolve
125
+ resolve();
84
126
  }
85
- this._started = false;
86
- this.events?.onEnd?.();
87
- });
88
- if (p && p.kill) {
89
- this.player = p;
90
127
  }
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
- }
128
+ }).catch((err) => {
129
+ const error = err instanceof Error ? err : new Error(String(err));
130
+ error.message = `play-sound load failed: ${error.message}`;
131
+ this.handleError(error);
132
+ reject(error);
133
+ });
134
+ });
101
135
  }
102
136
  write(chunk) {
103
137
  if (this._stopped)
@@ -92,7 +92,8 @@ export class SpeakerBackend {
92
92
  }
93
93
  stripWavHeader(chunk) {
94
94
  // 跳过 44 字节的 WAV 头
95
- return chunk.slice(44);
95
+ // 使用 subarray 替代 deprecated 的 slice
96
+ return chunk.subarray(44);
96
97
  }
97
98
  createSpeaker() {
98
99
  try {
@@ -1,6 +1,6 @@
1
1
  export type ToastVariant = 'success' | 'error' | 'info' | 'warning';
2
2
  export interface ToastOptions {
3
- title: string;
3
+ title?: string;
4
4
  message: string;
5
5
  variant?: ToastVariant;
6
6
  duration?: number;
@@ -17,10 +17,16 @@ declare class NotificationService {
17
17
  showToast(options: ToastOptions): boolean;
18
18
  private scheduleRetry;
19
19
  private flushPending;
20
+ success(message: string, duration?: number): boolean;
20
21
  success(title: string, message: string, duration?: number): boolean;
22
+ error(message: string, duration?: number): boolean;
21
23
  error(title: string, message: string, duration?: number): boolean;
24
+ info(message: string, duration?: number): boolean;
22
25
  info(title: string, message: string, duration?: number): boolean;
26
+ warning(message: string, duration?: number): boolean;
23
27
  warning(title: string, message: string, duration?: number): boolean;
28
+ showSpinnerToast(title: string, message: string, duration?: number): Promise<void>;
29
+ private getTitleForVariant;
24
30
  }
25
31
  export declare const notificationService: NotificationService;
26
32
  export default notificationService;
@@ -1,6 +1,7 @@
1
1
  // src/core/notification.ts
2
2
  import { createModuleLogger } from '../utils/logger';
3
3
  const logger = createModuleLogger('NotificationService');
4
+ const SISYPHUS_SPINNER = ['·', '•', '●', '○', '◌', '◦', ' '];
4
5
  /**
5
6
  * NotificationService - 统一 Toast 通知管理
6
7
  * 参照 DCP 实现,不做防御性检查,直接调用并用 try-catch 处理
@@ -15,10 +16,11 @@ class NotificationService {
15
16
  this.flushPending();
16
17
  }
17
18
  showToast(options) {
18
- const { title, message, variant = 'info', duration = 5000 } = options;
19
+ const title = options.title || this.getTitleForVariant(options.variant || 'info');
20
+ const { message, variant = 'info', duration = 5000 } = options;
19
21
  if (!this.tui) {
20
22
  logger.debug({ title }, 'tui not ready, queueing toast');
21
- this.pendingToasts.push(options);
23
+ this.pendingToasts.push({ ...options, title });
22
24
  this.scheduleRetry();
23
25
  return false;
24
26
  }
@@ -38,7 +40,7 @@ class NotificationService {
38
40
  catch (err) {
39
41
  // 参照 DCP:捕获异常但不抛出
40
42
  logger.warn({ err, title }, 'toast call failed, queueing for retry');
41
- this.pendingToasts.push(options);
43
+ this.pendingToasts.push({ ...options, title });
42
44
  this.scheduleRetry();
43
45
  return false;
44
46
  }
@@ -68,17 +70,68 @@ class NotificationService {
68
70
  }
69
71
  }
70
72
  }
71
- success(title, message, duration) {
72
- return this.showToast({ title, message, variant: 'success', duration });
73
+ success(titleOrMessage, messageOrDuration, duration) {
74
+ if (typeof messageOrDuration === 'string') {
75
+ // 3参数: success(title, message, duration)
76
+ return this.showToast({ title: titleOrMessage, message: messageOrDuration, variant: 'success', duration });
77
+ }
78
+ // 2参数: success(message, duration) 或 success(message)
79
+ return this.showToast({ message: titleOrMessage, variant: 'success', duration: messageOrDuration });
73
80
  }
74
- error(title, message, duration) {
75
- return this.showToast({ title, message, variant: 'error', duration });
81
+ error(titleOrMessage, messageOrDuration, duration) {
82
+ if (typeof messageOrDuration === 'string') {
83
+ return this.showToast({ title: titleOrMessage, message: messageOrDuration, variant: 'error', duration });
84
+ }
85
+ return this.showToast({ message: titleOrMessage, variant: 'error', duration: messageOrDuration || 8000 });
76
86
  }
77
- info(title, message, duration) {
78
- return this.showToast({ title, message, variant: 'info', duration });
87
+ info(titleOrMessage, messageOrDuration, duration) {
88
+ if (typeof messageOrDuration === 'string') {
89
+ return this.showToast({ title: titleOrMessage, message: messageOrDuration, variant: 'info', duration });
90
+ }
91
+ return this.showToast({ message: titleOrMessage, variant: 'info', duration: messageOrDuration });
92
+ }
93
+ warning(titleOrMessage, messageOrDuration, duration) {
94
+ if (typeof messageOrDuration === 'string') {
95
+ return this.showToast({ title: titleOrMessage, message: messageOrDuration, variant: 'warning', duration });
96
+ }
97
+ return this.showToast({ message: titleOrMessage, variant: 'warning', duration: messageOrDuration });
98
+ }
99
+ async showSpinnerToast(title, message, duration = 2000) {
100
+ const frameInterval = 100;
101
+ const totalFrames = Math.ceil(duration / frameInterval);
102
+ for (let i = 0; i < totalFrames; i++) {
103
+ const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length];
104
+ const toastDuration = Math.min(frameInterval + 50, duration - i * frameInterval);
105
+ if (toastDuration <= 0)
106
+ break;
107
+ if (this.tui?.showToast) {
108
+ try {
109
+ await this.tui.showToast({
110
+ body: {
111
+ title: `${spinner} ${title}`,
112
+ message,
113
+ variant: 'info',
114
+ duration: toastDuration
115
+ },
116
+ });
117
+ }
118
+ catch (err) {
119
+ logger.warn({ err }, 'showSpinnerToast failed');
120
+ }
121
+ }
122
+ if (i < totalFrames - 1) {
123
+ await new Promise((resolve) => setTimeout(resolve, frameInterval));
124
+ }
125
+ }
79
126
  }
80
- warning(title, message, duration) {
81
- return this.showToast({ title, message, variant: 'warning', duration });
127
+ getTitleForVariant(variant) {
128
+ const titles = {
129
+ success: 'Success',
130
+ error: 'Error',
131
+ warning: 'Warning',
132
+ info: 'Info'
133
+ };
134
+ return titles[variant];
82
135
  }
83
136
  }
84
137
  export const notificationService = new NotificationService();
@@ -120,11 +120,8 @@ export class AudioPlayer extends EventEmitter {
120
120
  * 播放音频文件
121
121
  * 使用 AudioBackend 统一后端播放
122
122
  */
123
- playFile(filePath, _format) {
124
- return new Promise((resolve) => {
125
- this.backend.start(filePath);
126
- resolve(); // backend.start() 是同步的,立即 resolve
127
- });
123
+ async playFile(filePath, _format) {
124
+ await Promise.resolve(this.backend.start(filePath));
128
125
  }
129
126
  pause() {
130
127
  if (!this._playing || this._paused)
@@ -38,6 +38,7 @@ export declare class StreamPlayer extends EventEmitter {
38
38
  private _started;
39
39
  private _paused;
40
40
  private _stopped;
41
+ private _starting;
41
42
  private format;
42
43
  private events?;
43
44
  constructor(options?: StreamPlayerOptions);
@@ -45,12 +46,12 @@ export declare class StreamPlayer extends EventEmitter {
45
46
  * 开始播放
46
47
  * 初始化后端,准备接收音频数据
47
48
  */
48
- start(): void;
49
+ start(): Promise<void>;
49
50
  /**
50
51
  * 写入音频数据块(边收边播)
51
52
  * 如果尚未 start(),会自动调用
52
53
  */
53
- write(chunk: Buffer): void;
54
+ write(chunk: Buffer): Promise<void>;
54
55
  /**
55
56
  * 结束写入
56
57
  * 通知后端写入完成,但保持播放直到结束
@@ -18,14 +18,15 @@ export class StreamPlayer extends EventEmitter {
18
18
  _started = false;
19
19
  _paused = false;
20
20
  _stopped = false;
21
+ _starting = false;
21
22
  format = 'mp3';
22
23
  events;
23
24
  constructor(options = {}) {
24
25
  super();
25
26
  this.format = options.format || 'mp3';
26
27
  this.events = options.events;
27
- // 创建音频后端
28
- const backendType = options.backendType || BackendType.NAUDIODON;
28
+ // 创建音频后端,默认使用 AUTO 自动选择合适的后端
29
+ const backendType = options.backendType || BackendType.AUTO;
29
30
  this.backend = createBackend(backendType, {
30
31
  format: this.format,
31
32
  events: {
@@ -65,7 +66,7 @@ export class StreamPlayer extends EventEmitter {
65
66
  * 开始播放
66
67
  * 初始化后端,准备接收音频数据
67
68
  */
68
- start() {
69
+ async start() {
69
70
  if (this._started) {
70
71
  return;
71
72
  }
@@ -73,8 +74,8 @@ export class StreamPlayer extends EventEmitter {
73
74
  this.handleError(new Error('Audio backend not initialized'));
74
75
  return;
75
76
  }
76
- // 初始化后端
77
- this.backend.start('');
77
+ // 初始化后端(playsound-backend 是异步的,需 await)
78
+ await Promise.resolve(this.backend.start(''));
78
79
  this._started = true;
79
80
  this._stopped = false;
80
81
  this._paused = false;
@@ -84,18 +85,29 @@ export class StreamPlayer extends EventEmitter {
84
85
  * 写入音频数据块(边收边播)
85
86
  * 如果尚未 start(),会自动调用
86
87
  */
87
- write(chunk) {
88
+ async write(chunk) {
88
89
  // 如果已停止,直接忽略
89
90
  if (this._stopped) {
90
91
  return;
91
92
  }
92
- // 如果未启动,自动启动
93
+ // 如果未启动,防止竞态条件并自动启动
93
94
  if (!this._started) {
94
- this.start();
95
+ // 防止并发启动
96
+ while (this._starting) {
97
+ await new Promise(resolve => setTimeout(resolve, 10));
98
+ }
99
+ this._starting = true;
100
+ try {
101
+ await this.start();
102
+ }
103
+ finally {
104
+ this._starting = false;
105
+ }
95
106
  }
96
107
  // 写入数据到后端
97
108
  if (this.backend) {
98
- this.backend.write(chunk);
109
+ // backend.write() 可能返回 Promise 或 void,用 Promise.resolve 处理
110
+ await Promise.resolve(this.backend.write(chunk));
99
111
  this._bytesWritten += chunk.length;
100
112
  this.events?.onProgress?.(this._bytesWritten);
101
113
  this.emit('progress', this._bytesWritten);
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ function initializeStreamComponents(config) {
65
65
  const playerEvents = {
66
66
  onStart: () => { },
67
67
  onEnd: () => { },
68
- onProgress: (bytesWritten) => { },
68
+ onProgress: (_bytesWritten) => { },
69
69
  onError: (error) => logger.error({ error }, 'stream player error'),
70
70
  onStop: () => { }
71
71
  };
@@ -161,7 +161,7 @@ export async function destroy() {
161
161
  streamingSynthesizer = undefined;
162
162
  }
163
163
  if (streamPlayer) {
164
- await streamPlayer.stop();
164
+ streamPlayer.stop();
165
165
  streamPlayer = undefined;
166
166
  }
167
167
  if (speaker) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/ocosay",
3
- "version": "1.1.31",
3
+ "version": "1.2.0",
4
4
  "description": "OpenCode TTS 播放插件 - 支持豆包模式边接收边朗读",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",