@coeiro-operator/audio 1.0.1 → 1.0.3
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/LICENSE +21 -0
- package/dist/audio-player.d.ts +134 -0
- package/dist/audio-player.d.ts.map +1 -0
- package/dist/audio-player.js +707 -0
- package/dist/audio-player.js.map +1 -0
- package/dist/audio-stream-controller.d.ts +52 -0
- package/dist/audio-stream-controller.d.ts.map +1 -0
- package/dist/audio-stream-controller.js +121 -0
- package/dist/audio-stream-controller.js.map +1 -0
- package/dist/audio-synthesizer.d.ts +86 -0
- package/dist/audio-synthesizer.d.ts.map +1 -0
- package/dist/audio-synthesizer.js +437 -0
- package/dist/audio-synthesizer.js.map +1 -0
- package/dist/chunk-generation-manager.d.ts +77 -0
- package/dist/chunk-generation-manager.d.ts.map +1 -0
- package/dist/chunk-generation-manager.js +178 -0
- package/dist/chunk-generation-manager.js.map +1 -0
- package/dist/constants.d.ts +180 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +219 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +194 -0
- package/dist/index.js.map +1 -0
- package/dist/speech-queue.d.ts +52 -0
- package/dist/speech-queue.d.ts.map +1 -0
- package/dist/speech-queue.js +143 -0
- package/dist/speech-queue.js.map +1 -0
- package/dist/synthesis-processor.d.ts +39 -0
- package/dist/synthesis-processor.d.ts.map +1 -0
- package/dist/synthesis-processor.js +131 -0
- package/dist/synthesis-processor.js.map +1 -0
- package/dist/test-helpers.d.ts +19 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +167 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/voice-resolver.d.ts +25 -0
- package/dist/voice-resolver.d.ts.map +1 -0
- package/dist/voice-resolver.js +141 -0
- package/dist/voice-resolver.js.map +1 -0
- package/package.json +16 -13
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/say/audio-player.ts: 音声再生管理
|
|
3
|
+
* speakerライブラリによるネイティブ音声出力を担当
|
|
4
|
+
*/
|
|
5
|
+
import { writeFile } from 'fs/promises';
|
|
6
|
+
import * as Echogarden from 'echogarden';
|
|
7
|
+
// @ts-ignore - dsp.jsには型定義がない
|
|
8
|
+
import DSP from 'dsp.js';
|
|
9
|
+
import { Transform } from 'stream';
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import { logger } from '@coeiro-operator/common';
|
|
12
|
+
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
|
+
export class AudioPlayer {
|
|
18
|
+
synthesisRate = SAMPLE_RATES.SYNTHESIS;
|
|
19
|
+
playbackRate = SAMPLE_RATES.PLAYBACK;
|
|
20
|
+
channels = AUDIO_FORMAT.CHANNELS;
|
|
21
|
+
bitDepth = AUDIO_FORMAT.BIT_DEPTH;
|
|
22
|
+
echogardenInitialized = false;
|
|
23
|
+
noiseReductionEnabled = FILTER_SETTINGS.NOISE_REDUCTION_DEFAULT;
|
|
24
|
+
lowpassFilterEnabled = FILTER_SETTINGS.LOWPASS_FILTER_DEFAULT;
|
|
25
|
+
lowpassCutoff = FILTER_SETTINGS.LOWPASS_CUTOFF;
|
|
26
|
+
isInitialized = false;
|
|
27
|
+
audioConfig;
|
|
28
|
+
config;
|
|
29
|
+
/**
|
|
30
|
+
* AudioPlayerの初期化
|
|
31
|
+
*/
|
|
32
|
+
constructor(config) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.audioConfig = this.getDefaultAudioConfig();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* デフォルトのaudio設定を取得
|
|
38
|
+
*/
|
|
39
|
+
getDefaultAudioConfig() {
|
|
40
|
+
const latencyMode = this.config.audio?.latencyMode || 'balanced';
|
|
41
|
+
const presets = {
|
|
42
|
+
'ultra-low': {
|
|
43
|
+
bufferSettings: {
|
|
44
|
+
highWaterMark: BUFFER_SIZES.PRESETS.ULTRA_LOW.HIGH_WATER_MARK,
|
|
45
|
+
lowWaterMark: BUFFER_SIZES.PRESETS.ULTRA_LOW.LOW_WATER_MARK,
|
|
46
|
+
dynamicAdjustment: true,
|
|
47
|
+
},
|
|
48
|
+
paddingSettings: PADDING_SETTINGS.PRESETS.ULTRA_LOW,
|
|
49
|
+
crossfadeSettings: CROSSFADE_SETTINGS.PRESETS.ULTRA_LOW,
|
|
50
|
+
},
|
|
51
|
+
balanced: {
|
|
52
|
+
bufferSettings: {
|
|
53
|
+
highWaterMark: BUFFER_SIZES.PRESETS.BALANCED.HIGH_WATER_MARK,
|
|
54
|
+
lowWaterMark: BUFFER_SIZES.PRESETS.BALANCED.LOW_WATER_MARK,
|
|
55
|
+
dynamicAdjustment: true,
|
|
56
|
+
},
|
|
57
|
+
paddingSettings: PADDING_SETTINGS.PRESETS.BALANCED,
|
|
58
|
+
crossfadeSettings: CROSSFADE_SETTINGS.PRESETS.BALANCED,
|
|
59
|
+
},
|
|
60
|
+
quality: {
|
|
61
|
+
bufferSettings: {
|
|
62
|
+
highWaterMark: BUFFER_SIZES.PRESETS.QUALITY.HIGH_WATER_MARK,
|
|
63
|
+
lowWaterMark: BUFFER_SIZES.PRESETS.QUALITY.LOW_WATER_MARK,
|
|
64
|
+
dynamicAdjustment: false,
|
|
65
|
+
},
|
|
66
|
+
paddingSettings: PADDING_SETTINGS.PRESETS.QUALITY,
|
|
67
|
+
crossfadeSettings: CROSSFADE_SETTINGS.PRESETS.QUALITY,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const preset = presets[latencyMode];
|
|
71
|
+
return {
|
|
72
|
+
latencyMode,
|
|
73
|
+
bufferSettings: { ...preset.bufferSettings, ...this.config.audio?.bufferSettings },
|
|
74
|
+
paddingSettings: { ...preset.paddingSettings, ...this.config.audio?.paddingSettings },
|
|
75
|
+
crossfadeSettings: { ...preset.crossfadeSettings, ...this.config.audio?.crossfadeSettings },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 動的バッファサイズの計算
|
|
80
|
+
*/
|
|
81
|
+
calculateOptimalBufferSize(audioLength, chunkIndex = 0) {
|
|
82
|
+
if (!this.audioConfig.bufferSettings?.dynamicAdjustment) {
|
|
83
|
+
return this.audioConfig.bufferSettings?.highWaterMark || 256;
|
|
84
|
+
}
|
|
85
|
+
// 音声長とチャンク位置に基づく動的調整
|
|
86
|
+
if (chunkIndex === 0 && audioLength < 1000) {
|
|
87
|
+
return Math.min(this.audioConfig.bufferSettings?.highWaterMark || 256, 64);
|
|
88
|
+
}
|
|
89
|
+
if (audioLength < 1000)
|
|
90
|
+
return this.audioConfig.bufferSettings?.highWaterMark || 256;
|
|
91
|
+
if (audioLength < 5000)
|
|
92
|
+
return Math.max(this.audioConfig.bufferSettings?.highWaterMark || 256, 128);
|
|
93
|
+
return Math.max(this.audioConfig.bufferSettings?.highWaterMark || 256, 256);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 音声生成時のサンプルレートを設定
|
|
97
|
+
*/
|
|
98
|
+
setSynthesisRate(synthesisRate) {
|
|
99
|
+
this.synthesisRate = synthesisRate;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 再生時のサンプルレートを設定
|
|
103
|
+
*/
|
|
104
|
+
setPlaybackRate(playbackRate) {
|
|
105
|
+
this.playbackRate = playbackRate;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* ノイズ除去機能を有効/無効に設定
|
|
109
|
+
*/
|
|
110
|
+
setNoiseReduction(enabled) {
|
|
111
|
+
this.noiseReductionEnabled = enabled;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* ローパスフィルターを有効/無効に設定
|
|
115
|
+
*/
|
|
116
|
+
setLowpassFilter(enabled, cutoffFreq = FILTER_SETTINGS.LOWPASS_CUTOFF) {
|
|
117
|
+
this.lowpassFilterEnabled = enabled;
|
|
118
|
+
this.lowpassCutoff = cutoffFreq;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Echogardenの初期化
|
|
122
|
+
*/
|
|
123
|
+
async initializeEchogarden() {
|
|
124
|
+
try {
|
|
125
|
+
this.echogardenInitialized = true;
|
|
126
|
+
logger.debug('Echogarden初期化完了');
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
logger.warn(`Echogarden初期化エラー: ${error.message}`);
|
|
130
|
+
this.noiseReductionEnabled = false;
|
|
131
|
+
this.echogardenInitialized = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async initialize() {
|
|
135
|
+
try {
|
|
136
|
+
// ノイズ除去が有効な場合はEchogardenを初期化
|
|
137
|
+
if (this.noiseReductionEnabled) {
|
|
138
|
+
await this.initializeEchogarden();
|
|
139
|
+
}
|
|
140
|
+
logger.info(`音声プレーヤー初期化: speakerライブラリ使用(ネイティブ出力)${this.noiseReductionEnabled ? ' + Echogarden' : ''} - Speaker遅延初期化`);
|
|
141
|
+
this.isInitialized = true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logger.error(`音声プレーヤー初期化エラー: ${error.message}`);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 音声ストリームを再生(ストリーミング処理パイプライン)
|
|
151
|
+
*/
|
|
152
|
+
async playAudioStream(audioResult, bufferSize) {
|
|
153
|
+
if (!this.isInitialized) {
|
|
154
|
+
throw new Error('音声プレーヤーが初期化されていません');
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
logger.log(`音声再生: チャンク${audioResult.chunk.index}`);
|
|
158
|
+
// WAVデータからPCMデータを抽出
|
|
159
|
+
const pcmData = this.extractPCMFromWAV(audioResult.audioBuffer);
|
|
160
|
+
// PCMデータのサイズ確認・調整
|
|
161
|
+
if (pcmData.length === 0) {
|
|
162
|
+
logger.warn('PCMデータが空です');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// 高品質処理が有効な場合はストリーミング処理パイプラインを使用
|
|
166
|
+
if (this.noiseReductionEnabled ||
|
|
167
|
+
this.lowpassFilterEnabled ||
|
|
168
|
+
this.synthesisRate !== this.playbackRate) {
|
|
169
|
+
return await this.processAudioStreamPipeline(pcmData);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// シンプルな直接再生
|
|
173
|
+
return this.playPCMData(pcmData, bufferSize);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new Error(`音声再生エラー: ${error.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* ストリーミング音声処理パイプライン
|
|
182
|
+
* 処理順序: 1) リサンプリング 2) ローパスフィルター 3) ノイズリダクション 4) Speaker出力
|
|
183
|
+
*/
|
|
184
|
+
async processAudioStreamPipeline(pcmData) {
|
|
185
|
+
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);
|
|
212
|
+
// PCMデータをパイプラインに送信
|
|
213
|
+
resampleTransform.write(Buffer.from(pcmData));
|
|
214
|
+
resampleTransform.end();
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
logger.error(`パイプライン構築エラー: ${error.message}`);
|
|
218
|
+
reject(new Error(`パイプライン構築エラー: ${error.message}`));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* リサンプリング用Transform streamを作成
|
|
224
|
+
*/
|
|
225
|
+
createResampleTransform() {
|
|
226
|
+
// サンプルレートが同じ場合はパススルー
|
|
227
|
+
if (this.synthesisRate === this.playbackRate) {
|
|
228
|
+
return new Transform({
|
|
229
|
+
transform(chunk, encoding, callback) {
|
|
230
|
+
callback(null, chunk);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// 簡易的な線形補間によるリサンプリング実装
|
|
235
|
+
const ratio = this.playbackRate / this.synthesisRate;
|
|
236
|
+
logger.log(`リサンプリング: ${this.synthesisRate}Hz → ${this.playbackRate}Hz (比率: ${ratio})`);
|
|
237
|
+
let carryOverSample = null;
|
|
238
|
+
return new Transform({
|
|
239
|
+
transform: (chunk, encoding, callback) => {
|
|
240
|
+
try {
|
|
241
|
+
const inputBuffer = Buffer.from(chunk);
|
|
242
|
+
const inputSamples = new Int16Array(inputBuffer.buffer, inputBuffer.byteOffset, inputBuffer.length / 2);
|
|
243
|
+
// 出力サンプル数を計算
|
|
244
|
+
const outputLength = Math.floor(inputSamples.length * ratio);
|
|
245
|
+
const outputSamples = new Int16Array(outputLength);
|
|
246
|
+
// 線形補間によるリサンプリング
|
|
247
|
+
for (let i = 0; i < outputLength; i++) {
|
|
248
|
+
const srcIndex = i / ratio;
|
|
249
|
+
const srcIndexInt = Math.floor(srcIndex);
|
|
250
|
+
const fraction = srcIndex - srcIndexInt;
|
|
251
|
+
if (srcIndexInt < inputSamples.length - 1) {
|
|
252
|
+
// 線形補間
|
|
253
|
+
const sample1 = inputSamples[srcIndexInt];
|
|
254
|
+
const sample2 = inputSamples[srcIndexInt + 1];
|
|
255
|
+
outputSamples[i] = Math.round(sample1 * (1 - fraction) + sample2 * fraction);
|
|
256
|
+
}
|
|
257
|
+
else if (srcIndexInt < inputSamples.length) {
|
|
258
|
+
// 最後のサンプル
|
|
259
|
+
outputSamples[i] = inputSamples[srcIndexInt];
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// パディング(無音)
|
|
263
|
+
outputSamples[i] = 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const outputBuffer = Buffer.from(outputSamples.buffer);
|
|
267
|
+
callback(null, outputBuffer);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
logger.error(`リサンプリングエラー: ${error.message}`);
|
|
271
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* ローパスフィルター用Transform streamを作成
|
|
278
|
+
*/
|
|
279
|
+
createLowpassTransform() {
|
|
280
|
+
return new Transform({
|
|
281
|
+
transform: (chunk, encoding, callback) => {
|
|
282
|
+
try {
|
|
283
|
+
if (!this.lowpassFilterEnabled) {
|
|
284
|
+
callback(null, chunk);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const pcmData = new Uint8Array(chunk);
|
|
288
|
+
const filteredData = this.applyLowpassFilterToChunk(pcmData);
|
|
289
|
+
callback(null, Buffer.from(filteredData));
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* ノイズリダクション用Transform streamを作成
|
|
299
|
+
*/
|
|
300
|
+
createNoiseReductionTransform() {
|
|
301
|
+
return new Transform({
|
|
302
|
+
transform: async (chunk, encoding, callback) => {
|
|
303
|
+
try {
|
|
304
|
+
if (!this.noiseReductionEnabled || !this.echogardenInitialized) {
|
|
305
|
+
callback(null, chunk);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const pcmData = new Uint8Array(chunk);
|
|
309
|
+
const denoisedData = await this.applyNoiseReductionToChunk(pcmData);
|
|
310
|
+
callback(null, Buffer.from(denoisedData));
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
logger.warn(`ノイズリダクション処理エラー: ${error.message}`);
|
|
314
|
+
callback(null, chunk); // エラー時は元データを返す
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* 真のストリーミング音声再生(非同期ジェネレータから直接再生)
|
|
321
|
+
*/
|
|
322
|
+
async playStreamingAudio(audioStream, bufferSize) {
|
|
323
|
+
try {
|
|
324
|
+
if (!this.isInitialized) {
|
|
325
|
+
throw new Error('AudioPlayer is not initialized');
|
|
326
|
+
}
|
|
327
|
+
for await (const audioResult of audioStream) {
|
|
328
|
+
// PCMデータを抽出して即座に再生
|
|
329
|
+
await this.playAudioStream(audioResult, bufferSize);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
throw new Error(`ストリーミング音声再生エラー: ${error.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* 並列ストリーミング再生(最初のチャンクから再生開始、以降は自動継続)
|
|
338
|
+
*/
|
|
339
|
+
async playStreamingAudioParallel(audioStream) {
|
|
340
|
+
try {
|
|
341
|
+
if (!this.isInitialized) {
|
|
342
|
+
throw new Error('AudioPlayer is not initialized');
|
|
343
|
+
}
|
|
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
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// 残りのチャンクの再生完了を待機
|
|
362
|
+
await Promise.all(playQueue);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
throw new Error(`並列ストリーミング音声再生エラー: ${error.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Speakerインスタンスを作成(環境変数によるモック対応)
|
|
370
|
+
*/
|
|
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;
|
|
402
|
+
}
|
|
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);
|
|
430
|
+
}
|
|
431
|
+
mockSpeaker.emit('close');
|
|
432
|
+
};
|
|
433
|
+
return mockSpeaker;
|
|
434
|
+
}
|
|
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);
|
|
441
|
+
}
|
|
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;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* PCMデータを直接スピーカーに再生(改善版:Speakerインスタンス使い回し)
|
|
490
|
+
* synthesis/playbackレートが異なる場合は適切なSpeaker設定を使用
|
|
491
|
+
*/
|
|
492
|
+
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
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// PCMデータをスピーカーに書き込み
|
|
519
|
+
speaker.end(Buffer.from(processedData));
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* 音声ファイルを保存
|
|
524
|
+
*/
|
|
525
|
+
async saveAudio(audioBuffer, outputFile) {
|
|
526
|
+
try {
|
|
527
|
+
await writeFile(outputFile, Buffer.from(audioBuffer));
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
throw new Error(`音声ファイル保存エラー: ${error.message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* チャンク単位のローパスフィルター処理
|
|
535
|
+
*/
|
|
536
|
+
applyLowpassFilterToChunk(pcmData) {
|
|
537
|
+
try {
|
|
538
|
+
const samplesCount = pcmData.length / 2;
|
|
539
|
+
const audioSamples = new Float32Array(samplesCount);
|
|
540
|
+
for (let i = 0; i < samplesCount; i++) {
|
|
541
|
+
const sampleIndex = i * 2;
|
|
542
|
+
const sample = pcmData[sampleIndex] | (pcmData[sampleIndex + 1] << 8);
|
|
543
|
+
audioSamples[i] = (sample < 32768 ? sample : sample - 65536) / 32768.0;
|
|
544
|
+
}
|
|
545
|
+
// ローパスフィルターを適用
|
|
546
|
+
const lowpassFilter = new DSP.IIRFilter(DSP.LOWPASS, this.lowpassCutoff, this.playbackRate, 1);
|
|
547
|
+
lowpassFilter.process(audioSamples);
|
|
548
|
+
// Float32ArrayをPCMデータに戻す
|
|
549
|
+
const processedData = new Uint8Array(pcmData.length);
|
|
550
|
+
for (let i = 0; i < samplesCount; i++) {
|
|
551
|
+
const sample = Math.max(-1.0, Math.min(1.0, audioSamples[i]));
|
|
552
|
+
const intSample = Math.floor(sample * 32767);
|
|
553
|
+
const outputIndex = i * 2;
|
|
554
|
+
processedData[outputIndex] = intSample & 0xff;
|
|
555
|
+
processedData[outputIndex + 1] = (intSample >> 8) & 0xff;
|
|
556
|
+
}
|
|
557
|
+
return processedData;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
logger.warn(`ローパスフィルター処理エラー: ${error.message}`);
|
|
561
|
+
return pcmData;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* チャンク単位のノイズリダクション処理
|
|
566
|
+
*/
|
|
567
|
+
async applyNoiseReductionToChunk(pcmData) {
|
|
568
|
+
try {
|
|
569
|
+
const samplesCount = pcmData.length / 2;
|
|
570
|
+
const audioSamples = new Float32Array(samplesCount);
|
|
571
|
+
for (let i = 0; i < samplesCount; i++) {
|
|
572
|
+
const sampleIndex = i * 2;
|
|
573
|
+
const sample = pcmData[sampleIndex] | (pcmData[sampleIndex + 1] << 8);
|
|
574
|
+
audioSamples[i] = (sample < 32768 ? sample : sample - 65536) / 32768.0;
|
|
575
|
+
}
|
|
576
|
+
const rawAudio = {
|
|
577
|
+
audioChannels: [audioSamples],
|
|
578
|
+
sampleRate: this.playbackRate,
|
|
579
|
+
};
|
|
580
|
+
const result = await Echogarden.denoise(rawAudio, {
|
|
581
|
+
engine: 'rnnoise',
|
|
582
|
+
});
|
|
583
|
+
const processedData = new Uint8Array(pcmData.length);
|
|
584
|
+
const denoisedSamples = result.denoisedAudio.audioChannels[0];
|
|
585
|
+
for (let i = 0; i < samplesCount; i++) {
|
|
586
|
+
const sample = Math.max(-1.0, Math.min(1.0, denoisedSamples[i]));
|
|
587
|
+
const intSample = Math.floor(sample * 32767);
|
|
588
|
+
const outputIndex = i * 2;
|
|
589
|
+
processedData[outputIndex] = intSample & 0xff;
|
|
590
|
+
processedData[outputIndex + 1] = (intSample >> 8) & 0xff;
|
|
591
|
+
}
|
|
592
|
+
return processedData;
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
console.warn(`ノイズリダクション処理エラー: ${error.message}`);
|
|
596
|
+
return pcmData;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* WAVヘッダーを除去してPCMデータを抽出
|
|
601
|
+
*/
|
|
602
|
+
extractPCMFromWAV(wavBuffer) {
|
|
603
|
+
const view = new DataView(wavBuffer);
|
|
604
|
+
// WAVヘッダーの検証とデータ位置の特定
|
|
605
|
+
if (view.getUint32(0, false) !== 0x52494646) {
|
|
606
|
+
// "RIFF"
|
|
607
|
+
throw new Error('Invalid WAV file');
|
|
608
|
+
}
|
|
609
|
+
let dataOffset = 12; // RIFFヘッダー後
|
|
610
|
+
while (dataOffset < wavBuffer.byteLength - 8) {
|
|
611
|
+
const chunkType = view.getUint32(dataOffset, false);
|
|
612
|
+
const chunkSize = view.getUint32(dataOffset + 4, true);
|
|
613
|
+
if (chunkType === 0x64617461) {
|
|
614
|
+
// "data"
|
|
615
|
+
// データチャンクが見つかった
|
|
616
|
+
const pcmData = wavBuffer.slice(dataOffset + 8, dataOffset + 8 + chunkSize);
|
|
617
|
+
return new Uint8Array(pcmData);
|
|
618
|
+
}
|
|
619
|
+
dataOffset += 8 + chunkSize;
|
|
620
|
+
}
|
|
621
|
+
throw new Error('WAV data chunk not found');
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* クロスフェード処理を適用(改良版)
|
|
625
|
+
*/
|
|
626
|
+
applyCrossfade(pcmData, overlapSamples, isFirstChunk = false) {
|
|
627
|
+
// 副作用を避けるため、新しい配列を作成して返す
|
|
628
|
+
const result = new Uint8Array(pcmData);
|
|
629
|
+
// 先頭チャンクで音声途切れを防ぐため、条件を厳格化
|
|
630
|
+
if (overlapSamples > 0 && overlapSamples < pcmData.length / 2 && !isFirstChunk) {
|
|
631
|
+
for (let i = 0; i < overlapSamples * 2; i += 2) {
|
|
632
|
+
// より自然なフェード曲線(smoothstep)を使用
|
|
633
|
+
const t = i / (overlapSamples * 2);
|
|
634
|
+
const factor = this.smoothstep(t);
|
|
635
|
+
const sample = pcmData[i] | (pcmData[i + 1] << 8);
|
|
636
|
+
// 符号付き16bit整数として扱う
|
|
637
|
+
const signedSample = sample < 32768 ? sample : sample - 65536;
|
|
638
|
+
const fadedSample = Math.floor(signedSample * factor);
|
|
639
|
+
// 16bit範囲でクランプして無符号に戻す
|
|
640
|
+
const clampedSample = Math.max(-32768, Math.min(32767, fadedSample));
|
|
641
|
+
const unsignedSample = clampedSample < 0 ? clampedSample + 65536 : clampedSample;
|
|
642
|
+
result[i] = unsignedSample & 0xff;
|
|
643
|
+
result[i + 1] = (unsignedSample >> 8) & 0xff;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* より自然なフェード曲線(smoothstep関数)
|
|
650
|
+
*/
|
|
651
|
+
smoothstep(t) {
|
|
652
|
+
// t * t * (3 - 2 * t) でイーズイン・イーズアウト
|
|
653
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
654
|
+
return clamped * clamped * (3 - 2 * clamped);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* 無音PCMデータを生成
|
|
658
|
+
* @param durationMs 無音の長さ(ミリ秒)
|
|
659
|
+
* @param sampleRate サンプルレート(デフォルト:24000Hz)
|
|
660
|
+
* @returns 無音のPCMデータ
|
|
661
|
+
*/
|
|
662
|
+
generateSilentPCM(durationMs = 100, sampleRate = SAMPLE_RATES.SYNTHESIS) {
|
|
663
|
+
const samplesCount = Math.floor((sampleRate * durationMs) / 1000);
|
|
664
|
+
const bytesCount = samplesCount * 2; // 16bit = 2bytes per sample
|
|
665
|
+
return new Uint8Array(bytesCount); // すべて0で初期化される(無音)
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* ドライバーウォームアップ用の無音再生
|
|
669
|
+
* 短い無音を再生してSpeakerドライバーを起動・安定化
|
|
670
|
+
*/
|
|
671
|
+
async warmupAudioDriver() {
|
|
672
|
+
if (!this.isInitialized) {
|
|
673
|
+
logger.warn('AudioPlayer未初期化のためウォームアップをスキップ');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
logger.debug('ドライバーウォームアップ開始(無音再生)');
|
|
678
|
+
const silentPCM = this.generateSilentPCM(100); // 100ms無音
|
|
679
|
+
await this.playPCMData(silentPCM, BUFFER_SIZES.PRESETS.ULTRA_LOW.HIGH_WATER_MARK);
|
|
680
|
+
logger.debug('ドライバーウォームアップ完了');
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
logger.warn(`ドライバーウォームアップエラー: ${error.message}`);
|
|
684
|
+
// エラーが発生しても処理を継続(ウォームアップは補助機能)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* AudioPlayerのクリーンアップ(リソース解放)
|
|
689
|
+
* 長時間運用やシャットダウン時に呼び出し
|
|
690
|
+
*/
|
|
691
|
+
async cleanup() {
|
|
692
|
+
logger.debug('AudioPlayer cleanup開始');
|
|
693
|
+
try {
|
|
694
|
+
// Echogardenリソースの解放
|
|
695
|
+
if (this.echogardenInitialized) {
|
|
696
|
+
// Echogardenには明示的なクリーンアップメソッドがないため、フラグのみリセット
|
|
697
|
+
this.echogardenInitialized = false;
|
|
698
|
+
}
|
|
699
|
+
this.isInitialized = false;
|
|
700
|
+
logger.info('AudioPlayer cleanup完了');
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
logger.warn(`AudioPlayer cleanup warning: ${error.message}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
//# sourceMappingURL=audio-player.js.map
|