@coeiro-operator/audio 1.0.2 → 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.
- package/README.md +48 -0
- package/dist/audio-player.d.ts +25 -11
- package/dist/audio-player.d.ts.map +1 -1
- package/dist/audio-player.js +194 -210
- package/dist/audio-player.js.map +1 -1
- package/dist/audio-stream-controller.d.ts +1 -5
- package/dist/audio-stream-controller.d.ts.map +1 -1
- package/dist/audio-stream-controller.js +16 -43
- package/dist/audio-stream-controller.js.map +1 -1
- package/dist/audio-synthesizer.d.ts +1 -0
- package/dist/audio-synthesizer.d.ts.map +1 -1
- package/dist/audio-synthesizer.js +11 -4
- package/dist/audio-synthesizer.js.map +1 -1
- package/dist/chunk-generation-manager.d.ts +34 -2
- package/dist/chunk-generation-manager.d.ts.map +1 -1
- package/dist/chunk-generation-manager.js +237 -15
- package/dist/chunk-generation-manager.js.map +1 -1
- package/dist/debug-error-test.d.ts +5 -0
- package/dist/debug-error-test.d.ts.map +1 -0
- package/dist/debug-error-test.js +92 -0
- package/dist/debug-error-test.js.map +1 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/open-promise.d.ts +18 -0
- package/dist/open-promise.d.ts.map +1 -0
- package/dist/open-promise.js +42 -0
- package/dist/open-promise.js.map +1 -0
- package/dist/queue/speech-queue.d.ts +34 -0
- package/dist/queue/speech-queue.d.ts.map +1 -0
- package/dist/queue/speech-queue.js +36 -0
- package/dist/queue/speech-queue.js.map +1 -0
- package/dist/queue/task-queue.d.ts +52 -0
- package/dist/queue/task-queue.d.ts.map +1 -0
- package/dist/queue/task-queue.js +259 -0
- package/dist/queue/task-queue.js.map +1 -0
- package/dist/queue/types.d.ts +42 -0
- package/dist/queue/types.d.ts.map +1 -0
- package/dist/queue/types.js +5 -0
- package/dist/queue/types.js.map +1 -0
- package/dist/speech-queue.d.ts +4 -49
- package/dist/speech-queue.d.ts.map +1 -1
- package/dist/speech-queue.js +3 -140
- package/dist/speech-queue.js.map +1 -1
- package/dist/synthesis-processor.d.ts.map +1 -1
- package/dist/synthesis-processor.js +21 -8
- package/dist/synthesis-processor.js.map +1 -1
- package/dist/test-helpers.d.ts +0 -9
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +0 -77
- package/dist/test-helpers.js.map +1 -1
- package/dist/types.d.ts +24 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -7
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
|
package/dist/audio-player.d.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/say/audio-player.ts: 音声再生管理
|
|
3
|
-
*
|
|
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)
|
|
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
|
-
|
|
84
|
+
private waitForCompletion;
|
|
83
85
|
/**
|
|
84
|
-
*
|
|
86
|
+
* @echogarden/audio-io用のaudioOutputを遅延作成
|
|
85
87
|
*/
|
|
86
|
-
private
|
|
88
|
+
private ensureAudioOutput;
|
|
87
89
|
/**
|
|
88
|
-
*
|
|
89
|
-
|
|
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;
|
|
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"}
|
package/dist/audio-player.js
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/say/audio-player.ts: 音声再生管理
|
|
3
|
-
*
|
|
3
|
+
* @echogarden/audio-ioによる音声出力を担当
|
|
4
4
|
*/
|
|
5
5
|
import { writeFile } from 'fs/promises';
|
|
6
6
|
import * as Echogarden from 'echogarden';
|
|
7
|
-
// @ts-
|
|
7
|
+
// @ts-expect-error - dsp.jsには型定義がない
|
|
8
8
|
import DSP from 'dsp.js';
|
|
9
9
|
import { Transform } from 'stream';
|
|
10
|
-
import {
|
|
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(`音声プレーヤー初期化:
|
|
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)
|
|
186
|
+
* 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) 出力
|
|
183
187
|
*/
|
|
184
188
|
async processAudioStreamPipeline(pcmData) {
|
|
185
189
|
logger.log('高品質音声処理パイプライン開始');
|
|
186
|
-
//
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
*
|
|
407
|
+
* @echogarden/audio-io用のコールバックハンドラ
|
|
370
408
|
*/
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
432
|
-
}
|
|
433
|
-
return mockSpeaker;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
434
437
|
}
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
490
|
-
* synthesis/playbackレートが異なる場合は適切なSpeaker設定を使用
|
|
465
|
+
* PCMデータを音声出力キューに追加
|
|
491
466
|
*/
|
|
492
467
|
async playPCMData(pcmData, bufferSize, chunk) {
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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には明示的なクリーンアップメソッドがないため、フラグのみリセット
|