@coeiro-operator/audio 1.0.3 → 1.1.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.
Files changed (55) hide show
  1. package/README.md +48 -0
  2. package/dist/audio-player.d.ts +25 -11
  3. package/dist/audio-player.d.ts.map +1 -1
  4. package/dist/audio-player.js +194 -210
  5. package/dist/audio-player.js.map +1 -1
  6. package/dist/audio-stream-controller.d.ts +1 -5
  7. package/dist/audio-stream-controller.d.ts.map +1 -1
  8. package/dist/audio-stream-controller.js +16 -43
  9. package/dist/audio-stream-controller.js.map +1 -1
  10. package/dist/audio-synthesizer.d.ts +1 -0
  11. package/dist/audio-synthesizer.d.ts.map +1 -1
  12. package/dist/audio-synthesizer.js +11 -4
  13. package/dist/audio-synthesizer.js.map +1 -1
  14. package/dist/chunk-generation-manager.d.ts +34 -2
  15. package/dist/chunk-generation-manager.d.ts.map +1 -1
  16. package/dist/chunk-generation-manager.js +237 -15
  17. package/dist/chunk-generation-manager.js.map +1 -1
  18. package/dist/debug-error-test.d.ts +5 -0
  19. package/dist/debug-error-test.d.ts.map +1 -0
  20. package/dist/debug-error-test.js +92 -0
  21. package/dist/debug-error-test.js.map +1 -0
  22. package/dist/index.d.ts +18 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +30 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/open-promise.d.ts +18 -0
  27. package/dist/open-promise.d.ts.map +1 -0
  28. package/dist/open-promise.js +42 -0
  29. package/dist/open-promise.js.map +1 -0
  30. package/dist/queue/speech-queue.d.ts +34 -0
  31. package/dist/queue/speech-queue.d.ts.map +1 -0
  32. package/dist/queue/speech-queue.js +36 -0
  33. package/dist/queue/speech-queue.js.map +1 -0
  34. package/dist/queue/task-queue.d.ts +52 -0
  35. package/dist/queue/task-queue.d.ts.map +1 -0
  36. package/dist/queue/task-queue.js +259 -0
  37. package/dist/queue/task-queue.js.map +1 -0
  38. package/dist/queue/types.d.ts +42 -0
  39. package/dist/queue/types.d.ts.map +1 -0
  40. package/dist/queue/types.js +5 -0
  41. package/dist/queue/types.js.map +1 -0
  42. package/dist/speech-queue.d.ts +4 -49
  43. package/dist/speech-queue.d.ts.map +1 -1
  44. package/dist/speech-queue.js +3 -140
  45. package/dist/speech-queue.js.map +1 -1
  46. package/dist/synthesis-processor.d.ts.map +1 -1
  47. package/dist/synthesis-processor.js +21 -8
  48. package/dist/synthesis-processor.js.map +1 -1
  49. package/dist/test-helpers.d.ts +0 -9
  50. package/dist/test-helpers.d.ts.map +1 -1
  51. package/dist/test-helpers.js +0 -77
  52. package/dist/test-helpers.js.map +1 -1
  53. package/dist/types.d.ts +24 -9
  54. package/dist/types.d.ts.map +1 -1
  55. package/package.json +4 -6
package/README.md CHANGED
@@ -6,6 +6,54 @@ COEIRO Operator音声合成・再生モジュール
6
6
 
7
7
  COEIROINK APIとの通信、音声ストリーミング、リサンプリング機能を提供します。
8
8
 
9
+ ## 機能
10
+
11
+ - COEIROINK APIとの音声合成通信
12
+ - 低レイテンシ音声ストリーミング
13
+ - 音声再生(@echogarden/audio-io使用)
14
+ - リアルタイムリサンプリング
15
+ - チャンク境界での停止制御
16
+ - クロスフェード処理
17
+ - ノイズリダクション(オプション)
18
+
19
+ ## 音声出力モジュール
20
+
21
+ 音声出力には`@echogarden/audio-io`を使用しています。以下の特徴があります:
22
+
23
+ - **プリコンパイル済みバイナリ**: node-gypによるビルドが不要
24
+ - **CI/CD対応**: GitHub ActionsなどのCI環境でも動作
25
+ - **低レイテンシ**: コールバックベースのAPIで効率的なバッファ管理
26
+ - **マルチプラットフォーム**: macOS、Windows、Linuxをサポート
27
+
28
+ ## パフォーマンス
29
+
30
+ @echogarden/audio-ioの採用により、以下のパフォーマンス改善を実現:
31
+
32
+ - 初期化時間: 約0.03ms(高速)
33
+ - チャンク処理: 平均1.47ms(低オーバーヘッド)
34
+ - メモリ使用量: 約130MB RSS(効率的)
35
+
36
+ ## ドキュメント
37
+
38
+ ### ドキュメント構成
39
+
40
+ ```
41
+ packages/audio/
42
+ ├── docs/
43
+ │ ├── architecture.md # アーキテクチャ概要
44
+ │ ├── task-orchestration.md # タスクキュー実装詳細
45
+ │ ├── chunk-generation-manager-spec.md # 並行生成仕様
46
+ │ └── architecture-improvement-proposal.md # 改善提案(未実施)
47
+ └── README.md # このファイル
48
+ ```
49
+
50
+ ### ドキュメント管理ルール
51
+
52
+ - **アーキテクチャドキュメント**: `docs/` に機能ごとに配置
53
+ - **複数ファイルにまたがる処理フロー**: 各ドキュメントに記述
54
+ - **コードで明らかな情報**: ドキュメント化しない
55
+ - **修正済みの内容**: ドキュメント内で ✅ マークで明示
56
+
9
57
  ## ライセンス
10
58
 
11
59
  MIT
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * src/say/audio-player.ts: 音声再生管理
3
- * speakerライブラリによるネイティブ音声出力を担当
3
+ * @echogarden/audio-ioによる音声出力を担当
4
4
  */
5
5
  import type { AudioResult, Config } from './types.js';
6
- export declare const forTests: {
7
- mockSpeakerInstance: any;
8
- };
9
6
  export declare class AudioPlayer {
10
7
  private synthesisRate;
11
8
  private playbackRate;
@@ -18,6 +15,11 @@ export declare class AudioPlayer {
18
15
  private isInitialized;
19
16
  private audioConfig;
20
17
  private config;
18
+ private shouldStop;
19
+ private audioOutput;
20
+ private chunkQueue;
21
+ private currentChunkOffset;
22
+ private completionPromise;
21
23
  /**
22
24
  * AudioPlayerの初期化
23
25
  */
@@ -57,7 +59,7 @@ export declare class AudioPlayer {
57
59
  playAudioStream(audioResult: AudioResult, bufferSize?: number): Promise<void>;
58
60
  /**
59
61
  * ストリーミング音声処理パイプライン
60
- * 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) Speaker出力
62
+ * 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) 出力
61
63
  */
62
64
  private processAudioStreamPipeline;
63
65
  /**
@@ -77,16 +79,23 @@ export declare class AudioPlayer {
77
79
  */
78
80
  playStreamingAudio(audioStream: AsyncGenerator<AudioResult>, bufferSize?: number): Promise<void>;
79
81
  /**
80
- * 並列ストリーミング再生(最初のチャンクから再生開始、以降は自動継続)
82
+ * キューが空になるまで待機(@echogarden/audio-io用)
81
83
  */
82
- playStreamingAudioParallel(audioStream: AsyncGenerator<AudioResult>): Promise<void>;
84
+ private waitForCompletion;
83
85
  /**
84
- * Speakerインスタンスを作成(環境変数によるモック対応)
86
+ * @echogarden/audio-io用のaudioOutputを遅延作成
85
87
  */
86
- private createSpeaker;
88
+ private ensureAudioOutput;
87
89
  /**
88
- * PCMデータを直接スピーカーに再生(改善版:Speakerインスタンス使い回し)
89
- * synthesis/playbackレートが異なる場合は適切なSpeaker設定を使用
90
+ * @echogarden/audio-io用のコールバックハンドラ
91
+ */
92
+ private audioOutputHandler;
93
+ /**
94
+ * Uint8ArrayからInt16Arrayに変換(リトルエンディアン)
95
+ */
96
+ private convertToInt16Array;
97
+ /**
98
+ * PCMデータを音声出力キューに追加
90
99
  */
91
100
  private playPCMData;
92
101
  /**
@@ -125,6 +134,11 @@ export declare class AudioPlayer {
125
134
  * 短い無音を再生してSpeakerドライバーを起動・安定化
126
135
  */
127
136
  warmupAudioDriver(): Promise<void>;
137
+ /**
138
+ * 音声再生の停止(チャンク境界で停止)
139
+ * 現在再生中のチャンクは最後まで再生され、次のチャンクから停止します
140
+ */
141
+ stopPlayback(): Promise<void>;
128
142
  /**
129
143
  * AudioPlayerのクリーンアップ(リソース解放)
130
144
  * 長時間運用やシャットダウン時に呼び出し
@@ -1 +1 @@
1
- {"version":3,"file":"audio-player.d.ts","sourceRoot":"","sources":["../src/audio-player.ts"],"names":[],"mappings":"AAAA;;;GAGG;AASH,OAAO,KAAK,EAAE,WAAW,EAAS,MAAM,EAAe,MAAM,YAAY,CAAC;AAY1E,eAAO,MAAM,QAAQ;yBACU,GAAG;CACjC,CAAC;AAEF,qBAAa,WAAW;IACtB,OAAO,CAAC,aAAa,CAAkC;IACvD,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,qBAAqB,CAAkB;IAC/C,OAAO,CAAC,qBAAqB,CAAoD;IACjF,OAAO,CAAC,oBAAoB,CAAmD;IAC/E,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,MAAM,CAAS;IAEvB;;OAEG;gBACS,MAAM,EAAE,MAAM;IAK1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IA0C7B;;OAEG;IACH,OAAO,CAAC,0BAA0B;IAgBlC;;OAEG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;IAI3C;;OAEG;IACH,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIzC;;OAEG;IACH,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,GAAE,MAAuC,GAAG,IAAI;IAK7F;;OAEG;YACW,oBAAoB;IAW5B,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAkBpC;;OAEG;IACG,eAAe,CAAC,WAAW,EAAE,WAAW,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiCnF;;;OAGG;YACW,0BAA0B;IAiDxC;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAwD/B;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAmB9B;;OAEG;IACH,OAAO,CAAC,6BAA6B;IAoBrC;;OAEG;IACG,kBAAkB,CACtB,WAAW,EAAE,cAAc,CAAC,WAAW,CAAC,EACxC,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC;IAehB;;OAEG;IACG,0BAA0B,CAAC,WAAW,EAAE,cAAc,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAkCzF;;OAEG;YACW,aAAa;IAuI3B;;;OAGG;YACW,WAAW;IA2CzB;;OAEG;IACG,SAAS,CAAC,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ5E;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAqCjC;;OAEG;YACW,0BAA0B;IAqCxC;;OAEG;IACH,iBAAiB,CAAC,SAAS,EAAE,WAAW,GAAG,UAAU;IA2BrD;;OAEG;IACH,cAAc,CACZ,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,MAAM,EACtB,YAAY,GAAE,OAAe,GAC5B,UAAU;IA4Bb;;OAEG;IACH,OAAO,CAAC,UAAU;IAMlB;;;;;OAKG;IACH,iBAAiB,CACf,UAAU,GAAE,MAAY,EACxB,UAAU,GAAE,MAA+B,GAC1C,UAAU;IAMb;;;OAGG;IACG,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBxC;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAgB/B"}
1
+ {"version":3,"file":"audio-player.d.ts","sourceRoot":"","sources":["../src/audio-player.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,OAAO,KAAK,EAAE,WAAW,EAAS,MAAM,EAAe,MAAM,YAAY,CAAC;AAY1E,qBAAa,WAAW;IACtB,OAAO,CAAC,aAAa,CAAkC;IACvD,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,qBAAqB,CAAkB;IAC/C,OAAO,CAAC,qBAAqB,CAAoD;IACjF,OAAO,CAAC,oBAAoB,CAAmD;IAC/E,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,MAAM,CAAS;IAGvB,OAAO,CAAC,UAAU,CAAkB;IAGpC,OAAO,CAAC,WAAW,CAA4B;IAC/C,OAAO,CAAC,UAAU,CAAoB;IACtC,OAAO,CAAC,kBAAkB,CAAa;IACvC,OAAO,CAAC,iBAAiB,CAAkC;IAE3D;;OAEG;gBACS,MAAM,EAAE,MAAM;IAK1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IA0C7B;;OAEG;IACH,OAAO,CAAC,0BAA0B;IAgBlC;;OAEG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;IAI3C;;OAEG;IACH,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIzC;;OAEG;IACH,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,GAAE,MAAuC,GAAG,IAAI;IAK7F;;OAEG;YACW,oBAAoB;IAW5B,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAmBpC;;OAEG;IACG,eAAe,CAAC,WAAW,EAAE,WAAW,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiCnF;;;OAGG;YACW,0BAA0B;IA6DxC;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAsD/B;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAmB9B;;OAEG;IACH,OAAO,CAAC,6BAA6B;IAoBrC;;OAEG;IACG,kBAAkB,CACtB,WAAW,EAAE,cAAc,CAAC,WAAW,CAAC,EACxC,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC;IA8BhB;;OAEG;YACW,iBAAiB;IAiB/B;;OAEG;YACW,iBAAiB;IA+B/B;;OAEG;IACH,OAAO,CAAC,kBAAkB,CA8CxB;IAEF;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAiB3B;;OAEG;YACW,WAAW;IA+BzB;;OAEG;IACG,SAAS,CAAC,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ5E;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAqCjC;;OAEG;YACW,0BAA0B;IAqCxC;;OAEG;IACH,iBAAiB,CAAC,SAAS,EAAE,WAAW,GAAG,UAAU;IA2BrD;;OAEG;IACH,cAAc,CACZ,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,MAAM,EACtB,YAAY,GAAE,OAAe,GAC5B,UAAU;IA4Bb;;OAEG;IACH,OAAO,CAAC,UAAU;IAMlB;;;;;OAKG;IACH,iBAAiB,CACf,UAAU,GAAE,MAAY,EACxB,UAAU,GAAE,MAA+B,GAC1C,UAAU;IAMb;;;OAGG;IACG,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBxC;;;OAGG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IASnC;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAwB/B"}
@@ -1,19 +1,16 @@
1
1
  /**
2
2
  * src/say/audio-player.ts: 音声再生管理
3
- * speakerライブラリによるネイティブ音声出力を担当
3
+ * @echogarden/audio-ioによる音声出力を担当
4
4
  */
5
5
  import { writeFile } from 'fs/promises';
6
6
  import * as Echogarden from 'echogarden';
7
- // @ts-ignore - dsp.jsには型定義がない
7
+ // @ts-expect-error - dsp.jsには型定義がない
8
8
  import DSP from 'dsp.js';
9
9
  import { Transform } from 'stream';
10
- import { EventEmitter } from 'events';
10
+ import { createAudioOutput } from '@echogarden/audio-io';
11
11
  import { logger } from '@coeiro-operator/common';
12
+ import { OpenPromise } from './open-promise.js';
12
13
  import { SAMPLE_RATES, AUDIO_FORMAT, BUFFER_SIZES, FILTER_SETTINGS, CROSSFADE_SETTINGS, PADDING_SETTINGS, } from './constants.js';
13
- // テスト用のモックインスタンスを外部から注入可能にする
14
- export const forTests = {
15
- mockSpeakerInstance: null,
16
- };
17
14
  export class AudioPlayer {
18
15
  synthesisRate = SAMPLE_RATES.SYNTHESIS;
19
16
  playbackRate = SAMPLE_RATES.PLAYBACK;
@@ -26,6 +23,13 @@ export class AudioPlayer {
26
23
  isInitialized = false;
27
24
  audioConfig;
28
25
  config;
26
+ // チャンク境界での停止制御フラグ
27
+ shouldStop = false;
28
+ // @echogarden/audio-io関連
29
+ audioOutput = null;
30
+ chunkQueue = [];
31
+ currentChunkOffset = 0;
32
+ completionPromise = null;
29
33
  /**
30
34
  * AudioPlayerの初期化
31
35
  */
@@ -137,7 +141,7 @@ export class AudioPlayer {
137
141
  if (this.noiseReductionEnabled) {
138
142
  await this.initializeEchogarden();
139
143
  }
140
- logger.info(`音声プレーヤー初期化: speakerライブラリ使用(ネイティブ出力)${this.noiseReductionEnabled ? ' + Echogarden' : ''} - Speaker遅延初期化`);
144
+ logger.info(`音声プレーヤー初期化: @echogarden/audio-io使用(プリコンパイル済みバイナリ)${this.noiseReductionEnabled ? ' + Echogarden' : ''}`);
141
145
  this.isInitialized = true;
142
146
  return true;
143
147
  }
@@ -170,7 +174,7 @@ export class AudioPlayer {
170
174
  }
171
175
  else {
172
176
  // シンプルな直接再生
173
- return this.playPCMData(pcmData, bufferSize);
177
+ return await this.playPCMData(pcmData, bufferSize);
174
178
  }
175
179
  }
176
180
  catch (error) {
@@ -179,45 +183,54 @@ export class AudioPlayer {
179
183
  }
180
184
  /**
181
185
  * ストリーミング音声処理パイプライン
182
- * 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) Speaker出力
186
+ * 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) 出力
183
187
  */
184
188
  async processAudioStreamPipeline(pcmData) {
185
189
  logger.log('高品質音声処理パイプライン開始');
186
- // Speakerインスタンスを新規作成
187
- const streamSpeaker = await this.createSpeaker(this.playbackRate, BUFFER_SIZES.DEFAULT);
188
- return new Promise((resolve, reject) => {
189
- streamSpeaker.on('close', () => {
190
- resolve();
191
- });
192
- streamSpeaker.on('error', (error) => {
193
- logger.error(`ストリームスピーカーエラー: ${error.message}`);
194
- reject(new Error(`音声再生エラー: ${error.message}`));
195
- });
196
- try {
197
- // 1. リサンプリング(Transform stream)
198
- const resampleTransform = this.createResampleTransform();
199
- // 2. ローパスフィルター(Transform stream)
200
- const lowpassTransform = this.createLowpassTransform();
201
- // 3. ノイズリダクション(Transform stream)
202
- const noiseReductionTransform = this.createNoiseReductionTransform();
203
- // パイプライン構築: リサンプリング → ローパス → ノイズ除去 → Speaker
204
- let pipeline = resampleTransform;
205
- if (this.lowpassFilterEnabled) {
206
- pipeline = pipeline.pipe(lowpassTransform);
207
- }
208
- if (this.noiseReductionEnabled) {
209
- pipeline = pipeline.pipe(noiseReductionTransform);
210
- }
211
- pipeline.pipe(streamSpeaker);
190
+ // Transform streamを通して処理し、最終的にキューに追加
191
+ const processedChunks = [];
192
+ try {
193
+ // 1. リサンプリング(Transform stream)
194
+ const resampleTransform = this.createResampleTransform();
195
+ // 2. ローパスフィルター(Transform stream)
196
+ const lowpassTransform = this.createLowpassTransform();
197
+ // 3. ノイズリダクション(Transform stream)
198
+ const noiseReductionTransform = this.createNoiseReductionTransform();
199
+ // パイプライン構築: リサンプリング → ローパス → ノイズ除去
200
+ let pipeline = resampleTransform;
201
+ if (this.lowpassFilterEnabled) {
202
+ pipeline = pipeline.pipe(lowpassTransform);
203
+ }
204
+ if (this.noiseReductionEnabled) {
205
+ pipeline = pipeline.pipe(noiseReductionTransform);
206
+ }
207
+ // パイプラインの出力を収集
208
+ return new Promise((resolve, reject) => {
209
+ pipeline.on('data', (chunk) => {
210
+ processedChunks.push(chunk);
211
+ });
212
+ pipeline.on('end', async () => {
213
+ // すべての処理済みデータを結合
214
+ const combinedBuffer = Buffer.concat(processedChunks);
215
+ const int16Data = this.convertToInt16Array(new Uint8Array(combinedBuffer));
216
+ // AudioOutputを遅延作成
217
+ await this.ensureAudioOutput();
218
+ // チャンクをキューに追加
219
+ this.chunkQueue.push(int16Data);
220
+ logger.debug(`処理済みチャンクをキューに追加、現在のキューサイズ: ${this.chunkQueue.length}`);
221
+ resolve();
222
+ });
223
+ pipeline.on('error', (error) => {
224
+ reject(new Error(`パイプライン処理エラー: ${error.message}`));
225
+ });
212
226
  // PCMデータをパイプラインに送信
213
227
  resampleTransform.write(Buffer.from(pcmData));
214
228
  resampleTransform.end();
215
- }
216
- catch (error) {
217
- logger.error(`パイプライン構築エラー: ${error.message}`);
218
- reject(new Error(`パイプライン構築エラー: ${error.message}`));
219
- }
220
- });
229
+ });
230
+ }
231
+ catch (error) {
232
+ throw new Error(`パイプライン構築エラー: ${error.message}`);
233
+ }
221
234
  }
222
235
  /**
223
236
  * リサンプリング用Transform streamを作成
@@ -234,7 +247,6 @@ export class AudioPlayer {
234
247
  // 簡易的な線形補間によるリサンプリング実装
235
248
  const ratio = this.playbackRate / this.synthesisRate;
236
249
  logger.log(`リサンプリング: ${this.synthesisRate}Hz → ${this.playbackRate}Hz (比率: ${ratio})`);
237
- let carryOverSample = null;
238
250
  return new Transform({
239
251
  transform: (chunk, encoding, callback) => {
240
252
  try {
@@ -324,200 +336,154 @@ export class AudioPlayer {
324
336
  if (!this.isInitialized) {
325
337
  throw new Error('AudioPlayer is not initialized');
326
338
  }
339
+ // 再生開始時に停止フラグをリセット
340
+ this.shouldStop = false;
341
+ // 新しい完了Promiseを作成
342
+ this.completionPromise = new OpenPromise();
327
343
  for await (const audioResult of audioStream) {
344
+ // チャンク境界で停止チェック
345
+ if (this.shouldStop) {
346
+ logger.info('チャンク境界で再生を停止しました');
347
+ break;
348
+ }
328
349
  // PCMデータを抽出して即座に再生
329
350
  await this.playAudioStream(audioResult, bufferSize);
330
351
  }
352
+ // すべてのチャンクが再生されるまで待機
353
+ await this.waitForCompletion();
331
354
  }
332
355
  catch (error) {
333
356
  throw new Error(`ストリーミング音声再生エラー: ${error.message}`);
334
357
  }
335
358
  }
336
359
  /**
337
- * 並列ストリーミング再生(最初のチャンクから再生開始、以降は自動継続)
360
+ * キューが空になるまで待機(@echogarden/audio-io用)
338
361
  */
339
- async playStreamingAudioParallel(audioStream) {
340
- try {
341
- if (!this.isInitialized) {
342
- throw new Error('AudioPlayer is not initialized');
362
+ async waitForCompletion() {
363
+ if (!this.audioOutput)
364
+ return;
365
+ // キューが既に空の場合は即座に完了
366
+ if (this.chunkQueue.length === 0 && this.currentChunkOffset === 0) {
367
+ return;
368
+ }
369
+ // OpenPromiseがまだない場合は作成
370
+ if (!this.completionPromise || !this.completionPromise.isPending) {
371
+ this.completionPromise = new OpenPromise();
372
+ }
373
+ return this.completionPromise.promise;
374
+ }
375
+ /**
376
+ * @echogarden/audio-io用のaudioOutputを遅延作成
377
+ */
378
+ async ensureAudioOutput() {
379
+ if (!this.audioOutput) {
380
+ logger.debug('AudioOutput作成中...');
381
+ // bufferDurationの計算(latencyModeに基づく)
382
+ let bufferDuration;
383
+ switch (this.audioConfig.latencyMode) {
384
+ case 'ultra-low':
385
+ bufferDuration = 20; // 20ms
386
+ break;
387
+ case 'quality':
388
+ bufferDuration = 200; // 200ms
389
+ break;
390
+ default: // balanced
391
+ bufferDuration = 100; // 100ms
343
392
  }
344
- const playQueue = [];
345
- for await (const audioResult of audioStream) {
346
- // 各チャンクを非同期で再生(順序は保たない、低レイテンシ優先)
347
- const playPromise = this.playAudioStream(audioResult).catch((error) => {
348
- logger.warn(`チャンク${audioResult.chunk.index}再生エラー:`, error);
349
- });
350
- playQueue.push(playPromise);
351
- // 最大3チャンクまでの並列再生
352
- if (playQueue.length >= 3) {
353
- await Promise.race(playQueue);
354
- // 完了したプロミスを削除
355
- const completedIndex = playQueue.findIndex(p => p === Promise.resolve());
356
- if (completedIndex !== -1) {
357
- playQueue.splice(completedIndex, 1);
358
- }
359
- }
393
+ try {
394
+ this.audioOutput = await createAudioOutput({
395
+ sampleRate: this.playbackRate,
396
+ channelCount: this.channels,
397
+ bufferDuration,
398
+ }, this.audioOutputHandler);
399
+ logger.info(`AudioOutput作成完了: ${this.playbackRate}Hz, ${this.channels}ch, buffer: ${bufferDuration}ms`);
400
+ }
401
+ catch (error) {
402
+ throw new Error(`AudioOutput作成エラー: ${error.message}`);
360
403
  }
361
- // 残りのチャンクの再生完了を待機
362
- await Promise.all(playQueue);
363
- }
364
- catch (error) {
365
- throw new Error(`並列ストリーミング音声再生エラー: ${error.message}`);
366
404
  }
367
405
  }
368
406
  /**
369
- * Speakerインスタンスを作成(環境変数によるモック対応)
407
+ * @echogarden/audio-io用のコールバックハンドラ
370
408
  */
371
- async createSpeaker(sampleRate, bufferSize) {
372
- logger.debug(`新しいSpeaker作成: ${sampleRate}Hz, ${this.channels}ch, ${this.bitDepth}bit, buffer:${bufferSize}`);
373
- const config = {
374
- channels: this.channels,
375
- bitDepth: this.bitDepth,
376
- sampleRate: sampleRate,
377
- highWaterMark: bufferSize,
378
- };
379
- // テスト用のモックインスタンスが設定されている場合はそれを返す
380
- if (forTests.mockSpeakerInstance) {
381
- logger.debug('Using forTests.mockSpeakerInstance');
382
- return forTests.mockSpeakerInstance;
383
- }
384
- // CI環境またはテスト環境でモックSpeakerを返す
385
- const isTestEnv = process.env.NODE_ENV === 'test';
386
- const isCIEnv = process.env.CI === 'true';
387
- logger.debug(`Environment check - NODE_ENV: ${process.env.NODE_ENV}, CI: ${process.env.CI}, isTestEnv: ${isTestEnv}, isCIEnv: ${isCIEnv}`);
388
- if (isTestEnv || isCIEnv) {
389
- logger.debug('Using mock Speaker for test/CI environment');
390
- const mockSpeaker = new EventEmitter();
391
- // Writable Streamの基本メソッド
392
- mockSpeaker.write = (chunk, encoding, callback) => {
393
- const cb = typeof encoding === 'function' ? encoding : callback;
394
- if (cb)
395
- cb();
396
- return true;
397
- };
398
- mockSpeaker.end = (chunk, encoding, callback) => {
399
- let cb;
400
- if (typeof chunk === 'function') {
401
- cb = chunk;
409
+ audioOutputHandler = (outputBuffer) => {
410
+ let bufferOffset = 0;
411
+ while (bufferOffset < outputBuffer.length && !this.shouldStop) {
412
+ if (this.chunkQueue.length > 0) {
413
+ const currentChunk = this.chunkQueue[0];
414
+ const remainingInChunk = currentChunk.length - this.currentChunkOffset;
415
+ const remainingInBuffer = outputBuffer.length - bufferOffset;
416
+ const copyLength = Math.min(remainingInChunk, remainingInBuffer);
417
+ // データをコピー
418
+ outputBuffer.set(currentChunk.subarray(this.currentChunkOffset, this.currentChunkOffset + copyLength), bufferOffset);
419
+ bufferOffset += copyLength;
420
+ this.currentChunkOffset += copyLength;
421
+ // チャンクを使い切ったら次へ
422
+ if (this.currentChunkOffset >= currentChunk.length) {
423
+ this.chunkQueue.shift();
424
+ this.currentChunkOffset = 0;
425
+ logger.debug(`チャンク消費完了、残りキュー: ${this.chunkQueue.length}`);
402
426
  }
403
- else if (typeof encoding === 'function') {
404
- cb = encoding;
405
- }
406
- else {
407
- cb = callback;
408
- }
409
- // endが呼ばれたら少し遅延してcloseイベントを発火
410
- setImmediate(() => {
411
- mockSpeaker.emit('close');
412
- });
413
- if (cb)
414
- cb();
415
- };
416
- // EventEmitterのメソッド
417
- mockSpeaker.removeAllListeners = () => mockSpeaker;
418
- // pipe関連のメソッド(Transform Streamとの連携用)
419
- mockSpeaker.pipe = (destination) => destination;
420
- mockSpeaker.unpipe = () => mockSpeaker;
421
- // Writable Streamの状態
422
- mockSpeaker._writableState = { ended: false };
423
- mockSpeaker.writable = true;
424
- mockSpeaker.destroyed = false;
425
- // destroyメソッド(リソース解放用)
426
- mockSpeaker.destroy = (error) => {
427
- mockSpeaker.destroyed = true;
428
- if (error) {
429
- mockSpeaker.emit('error', error);
427
+ }
428
+ else {
429
+ // キューが空なら無音で埋める
430
+ outputBuffer.fill(0, bufferOffset);
431
+ // キューが空になったら完了を通知
432
+ if (this.completionPromise && this.completionPromise.isPending) {
433
+ this.completionPromise.resolve();
430
434
  }
431
- mockSpeaker.emit('close');
432
- };
433
- return mockSpeaker;
435
+ break;
436
+ }
434
437
  }
435
- // 本番環境では実際のSpeakerを動的インポート
436
- try {
437
- logger.debug('Dynamically importing real Speaker (production mode)');
438
- const SpeakerModule = await import('speaker');
439
- const SpeakerClass = SpeakerModule.default || SpeakerModule;
440
- return new SpeakerClass(config);
438
+ // 停止フラグが立っていたら無音で埋める
439
+ if (this.shouldStop) {
440
+ outputBuffer.fill(0, bufferOffset);
441
+ // 停止時も完了を通知
442
+ if (this.completionPromise && this.completionPromise.isPending) {
443
+ this.completionPromise.resolve();
444
+ }
441
445
  }
442
- catch (error) {
443
- logger.error(`Failed to import Speaker module: ${error.message}`);
444
- // CI環境でSpeakerがロードできない場合はモックを返す
445
- logger.debug('Falling back to mock Speaker due to import failure');
446
- const mockSpeaker = new EventEmitter();
447
- // Writable Streamの基本メソッド
448
- mockSpeaker.write = (chunk, encoding, callback) => {
449
- const cb = typeof encoding === 'function' ? encoding : callback;
450
- if (cb)
451
- cb();
452
- return true;
453
- };
454
- mockSpeaker.end = (chunk, encoding, callback) => {
455
- let cb;
456
- if (typeof chunk === 'function') {
457
- cb = chunk;
458
- }
459
- else if (typeof encoding === 'function') {
460
- cb = encoding;
461
- }
462
- else {
463
- cb = callback;
464
- }
465
- // endが呼ばれたら少し遅延してcloseイベントを発火
466
- setImmediate(() => {
467
- mockSpeaker.emit('close');
468
- });
469
- if (cb)
470
- cb();
471
- };
472
- mockSpeaker.removeAllListeners = () => mockSpeaker;
473
- mockSpeaker.pipe = (destination) => destination;
474
- mockSpeaker.unpipe = () => mockSpeaker;
475
- mockSpeaker._writableState = { ended: false };
476
- mockSpeaker.writable = true;
477
- mockSpeaker.destroyed = false;
478
- mockSpeaker.destroy = (error) => {
479
- mockSpeaker.destroyed = true;
480
- if (error) {
481
- mockSpeaker.emit('error', error);
482
- }
483
- mockSpeaker.emit('close');
484
- };
485
- return mockSpeaker;
446
+ };
447
+ /**
448
+ * Uint8ArrayからInt16Arrayに変換(リトルエンディアン)
449
+ */
450
+ convertToInt16Array(pcmData) {
451
+ const int16Data = new Int16Array(pcmData.length / 2);
452
+ for (let i = 0; i < int16Data.length; i++) {
453
+ const low = pcmData[i * 2];
454
+ const high = pcmData[i * 2 + 1];
455
+ // リトルエンディアンで16bit整数を構築
456
+ int16Data[i] = (high << 8) | low;
457
+ // 符号付き整数に変換(-32768 ~ 32767)
458
+ if (int16Data[i] >= 32768) {
459
+ int16Data[i] -= 65536;
460
+ }
486
461
  }
462
+ return int16Data;
487
463
  }
488
464
  /**
489
- * PCMデータを直接スピーカーに再生(改善版:Speakerインスタンス使い回し)
490
- * synthesis/playbackレートが異なる場合は適切なSpeaker設定を使用
465
+ * PCMデータを音声出力キューに追加
491
466
  */
492
467
  async playPCMData(pcmData, bufferSize, chunk) {
493
- // synthesisRateとplaybackRateが異なる場合は、実際の生成レートを使用
494
- const actualSampleRate = this.synthesisRate === this.playbackRate ? this.playbackRate : this.synthesisRate;
495
- const finalBufferSize = bufferSize || BUFFER_SIZES.DEFAULT;
496
- // Speakerインスタンスを新規作成
497
- const speaker = await this.createSpeaker(actualSampleRate, finalBufferSize);
498
- return new Promise((resolve, reject) => {
499
- // 一時的なイベントリスナーを設定(既存リスナーと競合しないようonce使用)
500
- speaker.once('close', () => {
501
- logger.debug('Speaker close event received');
502
- resolve();
503
- });
504
- speaker.once('error', (error) => {
505
- logger.error(`Speaker再生エラー: ${error.message}`);
506
- reject(error);
507
- });
508
- // クロスフェード処理を適用(設定に基づく)
509
- let processedData = pcmData;
510
- const crossfadeEnabled = this.audioConfig.crossfadeSettings?.enabled && chunk;
511
- if (crossfadeEnabled) {
512
- const skipFirst = this.audioConfig.crossfadeSettings?.skipFirstChunk && chunk?.isFirst;
513
- if (!skipFirst) {
514
- const overlapSamples = this.audioConfig.crossfadeSettings?.overlapSamples || 24;
515
- processedData = this.applyCrossfade(pcmData, overlapSamples, chunk?.isFirst || false);
516
- }
468
+ // AudioOutputを遅延作成
469
+ await this.ensureAudioOutput();
470
+ // クロスフェード処理を適用(設定に基づく)
471
+ let processedData = pcmData;
472
+ const crossfadeEnabled = this.audioConfig.crossfadeSettings?.enabled && chunk;
473
+ if (crossfadeEnabled) {
474
+ const skipFirst = this.audioConfig.crossfadeSettings?.skipFirstChunk && chunk?.isFirst;
475
+ if (!skipFirst) {
476
+ const overlapSamples = this.audioConfig.crossfadeSettings?.overlapSamples || 24;
477
+ processedData = this.applyCrossfade(pcmData, overlapSamples, chunk?.isFirst || false);
517
478
  }
518
- // PCMデータをスピーカーに書き込み
519
- speaker.end(Buffer.from(processedData));
520
- });
479
+ }
480
+ // PCMデータをInt16Arrayに変換
481
+ const int16Data = this.convertToInt16Array(processedData);
482
+ // チャンクをキューに追加
483
+ this.chunkQueue.push(int16Data);
484
+ logger.debug(`チャンクをキューに追加、現在のキューサイズ: ${this.chunkQueue.length}`);
485
+ // コールバックベースなので、ここでは追加のみ
486
+ // 実際の再生はaudioOutputHandlerで処理される
521
487
  }
522
488
  /**
523
489
  * 音声ファイルを保存
@@ -684,6 +650,17 @@ export class AudioPlayer {
684
650
  // エラーが発生しても処理を継続(ウォームアップは補助機能)
685
651
  }
686
652
  }
653
+ /**
654
+ * 音声再生の停止(チャンク境界で停止)
655
+ * 現在再生中のチャンクは最後まで再生され、次のチャンクから停止します
656
+ */
657
+ async stopPlayback() {
658
+ logger.info('音声再生の停止要求を受信しました');
659
+ this.shouldStop = true;
660
+ // キューをクリア
661
+ this.chunkQueue = [];
662
+ this.currentChunkOffset = 0;
663
+ }
687
664
  /**
688
665
  * AudioPlayerのクリーンアップ(リソース解放)
689
666
  * 長時間運用やシャットダウン時に呼び出し
@@ -691,6 +668,13 @@ export class AudioPlayer {
691
668
  async cleanup() {
692
669
  logger.debug('AudioPlayer cleanup開始');
693
670
  try {
671
+ // AudioOutputの破棄
672
+ if (this.audioOutput) {
673
+ await this.audioOutput.dispose();
674
+ this.audioOutput = null;
675
+ this.chunkQueue = [];
676
+ this.currentChunkOffset = 0;
677
+ }
694
678
  // Echogardenリソースの解放
695
679
  if (this.echogardenInitialized) {
696
680
  // Echogardenには明示的なクリーンアップメソッドがないため、フラグのみリセット