@coeiro-operator/audio 1.0.3 → 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.
- package/README.md +48 -0
- package/dist/audio-player.d.ts +32 -11
- package/dist/audio-player.d.ts.map +1 -1
- package/dist/audio-player.js +236 -210
- package/dist/audio-player.js.map +1 -1
- package/dist/audio-stream-controller.d.ts +27 -7
- package/dist/audio-stream-controller.d.ts.map +1 -1
- package/dist/audio-stream-controller.js +143 -40
- package/dist/audio-stream-controller.js.map +1 -1
- package/dist/audio-synthesizer.d.ts +5 -4
- package/dist/audio-synthesizer.d.ts.map +1 -1
- package/dist/audio-synthesizer.js +36 -23
- 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 +91 -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/speed-utils.d.ts +25 -0
- package/dist/speed-utils.d.ts.map +1 -0
- package/dist/speed-utils.js +77 -0
- package/dist/speed-utils.js.map +1 -0
- package/dist/synthesis-processor.d.ts.map +1 -1
- package/dist/synthesis-processor.js +34 -10
- 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 +1 -80
- package/dist/test-helpers.js.map +1 -1
- package/dist/types.d.ts +41 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/voice-resolver.d.ts.map +1 -1
- package/dist/voice-resolver.js +33 -3
- package/dist/voice-resolver.js.map +1 -1
- package/package.json +4 -6
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;
|
|
402
|
-
}
|
|
403
|
-
else if (typeof encoding === 'function') {
|
|
404
|
-
cb = encoding;
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
cb = callback;
|
|
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}`);
|
|
408
426
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
* 音声ファイルを保存
|
|
@@ -664,6 +630,48 @@ export class AudioPlayer {
|
|
|
664
630
|
const bytesCount = samplesCount * 2; // 16bit = 2bytes per sample
|
|
665
631
|
return new Uint8Array(bytesCount); // すべて0で初期化される(無音)
|
|
666
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* 無音WAVデータを生成(句読点ポーズ用)
|
|
635
|
+
* @param durationMs 無音の長さ(ミリ秒)
|
|
636
|
+
* @param sampleRate サンプルレート(デフォルト:24000Hz)
|
|
637
|
+
* @returns WAVフォーマットの無音データ
|
|
638
|
+
*/
|
|
639
|
+
generateSilenceWAV(durationMs, sampleRate = SAMPLE_RATES.SYNTHESIS) {
|
|
640
|
+
const numSamples = Math.floor((sampleRate * durationMs) / 1000);
|
|
641
|
+
const numBytes = numSamples * 2; // 16bit = 2bytes per sample
|
|
642
|
+
const arrayBuffer = new ArrayBuffer(44 + numBytes); // WAVヘッダー(44bytes) + データ
|
|
643
|
+
const view = new DataView(arrayBuffer);
|
|
644
|
+
// WAVヘッダーを書き込み
|
|
645
|
+
// "RIFF"
|
|
646
|
+
view.setUint32(0, 0x52494646, false);
|
|
647
|
+
// ファイルサイズ - 8
|
|
648
|
+
view.setUint32(4, 36 + numBytes, true);
|
|
649
|
+
// "WAVE"
|
|
650
|
+
view.setUint32(8, 0x57415645, false);
|
|
651
|
+
// "fmt "
|
|
652
|
+
view.setUint32(12, 0x666d7420, false);
|
|
653
|
+
// fmt チャンクサイズ
|
|
654
|
+
view.setUint32(16, 16, true);
|
|
655
|
+
// フォーマットタイプ (1 = PCM)
|
|
656
|
+
view.setUint16(20, 1, true);
|
|
657
|
+
// チャンネル数 (1 = モノラル)
|
|
658
|
+
view.setUint16(22, 1, true);
|
|
659
|
+
// サンプルレート
|
|
660
|
+
view.setUint32(24, sampleRate, true);
|
|
661
|
+
// バイトレート
|
|
662
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
663
|
+
// ブロックアライン
|
|
664
|
+
view.setUint16(32, 2, true);
|
|
665
|
+
// ビット深度
|
|
666
|
+
view.setUint16(34, 16, true);
|
|
667
|
+
// "data"
|
|
668
|
+
view.setUint32(36, 0x64617461, false);
|
|
669
|
+
// データチャンクサイズ
|
|
670
|
+
view.setUint32(40, numBytes, true);
|
|
671
|
+
// 無音データ(すべて0)
|
|
672
|
+
// ArrayBufferは初期化時に0で埋められるので追加の処理は不要
|
|
673
|
+
return arrayBuffer;
|
|
674
|
+
}
|
|
667
675
|
/**
|
|
668
676
|
* ドライバーウォームアップ用の無音再生
|
|
669
677
|
* 短い無音を再生してSpeakerドライバーを起動・安定化
|
|
@@ -684,6 +692,17 @@ export class AudioPlayer {
|
|
|
684
692
|
// エラーが発生しても処理を継続(ウォームアップは補助機能)
|
|
685
693
|
}
|
|
686
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* 音声再生の停止(チャンク境界で停止)
|
|
697
|
+
* 現在再生中のチャンクは最後まで再生され、次のチャンクから停止します
|
|
698
|
+
*/
|
|
699
|
+
async stopPlayback() {
|
|
700
|
+
logger.info('音声再生の停止要求を受信しました');
|
|
701
|
+
this.shouldStop = true;
|
|
702
|
+
// キューをクリア
|
|
703
|
+
this.chunkQueue = [];
|
|
704
|
+
this.currentChunkOffset = 0;
|
|
705
|
+
}
|
|
687
706
|
/**
|
|
688
707
|
* AudioPlayerのクリーンアップ(リソース解放)
|
|
689
708
|
* 長時間運用やシャットダウン時に呼び出し
|
|
@@ -691,6 +710,13 @@ export class AudioPlayer {
|
|
|
691
710
|
async cleanup() {
|
|
692
711
|
logger.debug('AudioPlayer cleanup開始');
|
|
693
712
|
try {
|
|
713
|
+
// AudioOutputの破棄
|
|
714
|
+
if (this.audioOutput) {
|
|
715
|
+
await this.audioOutput.dispose();
|
|
716
|
+
this.audioOutput = null;
|
|
717
|
+
this.chunkQueue = [];
|
|
718
|
+
this.currentChunkOffset = 0;
|
|
719
|
+
}
|
|
694
720
|
// Echogardenリソースの解放
|
|
695
721
|
if (this.echogardenInitialized) {
|
|
696
722
|
// Echogardenには明示的なクリーンアップメソッドがないため、フラグのみリセット
|