UNPKG

@kajidog/mcp-tts-voicevox

Version:

VOICEVOX integration for MCP - Text-to-Speech server using VOICEVOX engine

349 lines (348 loc) 13.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.VoicevoxQueueManager = void 0; const uuid_1 = require("uuid"); const types_1 = require("./types"); const file_manager_1 = require("./file-manager"); const event_manager_1 = require("./event-manager"); const audio_generator_1 = require("./audio-generator"); const audio_player_1 = require("./audio-player"); /** * VOICEVOXキュー管理クラス * 音声合成タスクのキュー管理と実行を担当 */ class VoicevoxQueueManager { /** * コンストラクタ * @param apiInstance VOICEVOX APIクライアントのインスタンス * @param prefetchSize 事前生成するアイテム数 */ constructor(apiInstance, prefetchSize = 2) { this.queue = []; this.isPlaying = false; this.isPaused = false; this.prefetchSize = 2; this.currentPlayingItem = null; this.api = apiInstance; this.prefetchSize = prefetchSize; // 依存コンポーネントを初期化 this.fileManager = new file_manager_1.AudioFileManager(); this.eventManager = new event_manager_1.EventManager(); this.audioGenerator = new audio_generator_1.AudioGenerator(this.api, this.fileManager); this.audioPlayer = new audio_player_1.AudioPlayer(); } /** * キューに新しいテキストを追加 */ async enqueueText(text, speaker) { const item = { id: (0, uuid_1.v4)(), text: text, speaker, status: types_1.QueueItemStatus.PENDING, createdAt: new Date(), }; this.queue.push(item); this.eventManager.emitEvent(types_1.QueueEventType.ITEM_ADDED, item); try { // 非同期で音声生成を開始 this.updateItemStatus(item, types_1.QueueItemStatus.GENERATING); const query = await this.audioGenerator.generateQuery(text, speaker); item.query = query; await this.audioGenerator.generateAudioFromQuery(item, this.updateItemStatus.bind(this)); return item; } catch (error) { // エラー発生時の処理 item.error = error instanceof Error ? error : new Error(String(error)); this.updateItemStatus(item, types_1.QueueItemStatus.ERROR); this.eventManager.emitEvent(types_1.QueueEventType.ERROR, item); // エラー発生時はキューからアイテムを削除 const itemIndex = this.queue.findIndex((i) => i.id === item.id); if (itemIndex !== -1) { this.queue.splice(itemIndex, 1); } this.eventManager.emitEvent(types_1.QueueEventType.ITEM_REMOVED, item); throw error; // エラーを再スロー } } /** * キューに音声合成用クエリを追加 * @param query 音声合成用クエリ * @param speaker 話者ID * @returns 作成されたキューアイテム */ async enqueueQuery(query, speaker) { const item = { id: (0, uuid_1.v4)(), text: "(クエリから生成)", speaker, status: types_1.QueueItemStatus.PENDING, createdAt: new Date(), query, }; this.queue.push(item); this.eventManager.emitEvent(types_1.QueueEventType.ITEM_ADDED, item); // 非同期で音声生成を開始 this.audioGenerator .generateAudioFromQuery(item, this.updateItemStatus.bind(this)) .catch((e) => { console.error("Unhandled error during generateAudioFromQuery:", e); }); // キュー処理開始 this.processQueue(); return item; } /** * キューからアイテムを削除 * @param itemId 削除するアイテムのID * @returns 削除に成功したかどうか */ async removeItem(itemId) { const index = this.queue.findIndex((item) => item.id === itemId); if (index === -1) { return false; } const item = this.queue[index]; // 一時ファイルがあれば削除 if (item.tempFile) { await this.fileManager.deleteTempFile(item.tempFile); } // キューから削除 const removedItem = this.queue.splice(index, 1)[0]; this.eventManager.emitEvent(types_1.QueueEventType.ITEM_REMOVED, removedItem); // もし削除されたアイテムが再生中だったら停止 if (this.currentPlayingItem?.id === itemId) { // TODO: 再生停止処理 this.currentPlayingItem = null; } return true; } /** * キューをクリア */ async clearQueue() { // 削除処理中にキューが変更される可能性があるので、先にIDリストを取得 const itemIdsToDelete = this.queue.map((item) => item.id); // 各アイテムに対して削除処理(一時ファイル削除含む)を実行 await Promise.all(itemIdsToDelete.map((id) => this.removeItem(id))); // 念のためキューを空にする(removeItemで空になっているはずだが) this.queue = []; // 再生状態をリセット this.isPlaying = false; this.isPaused = false; if (this.currentPlayingItem) { // TODO: 再生停止処理 this.currentPlayingItem = null; } this.eventManager.emitEvent(types_1.QueueEventType.QUEUE_CLEARED); } // --- 再生制御 --- async startPlayback() { if (!this.isPlaying) { this.isPlaying = true; this.isPaused = false; this.eventManager.emitEvent(types_1.QueueEventType.PLAYBACK_STARTED); this.processQueue(); } } async pausePlayback() { if (this.isPlaying && !this.isPaused) { this.isPaused = true; // 再生中のアイテムがあれば状態をPAUSEDに変更 if (this.currentPlayingItem) { this.updateItemStatus(this.currentPlayingItem, types_1.QueueItemStatus.PAUSED); } this.eventManager.emitEvent(types_1.QueueEventType.PLAYBACK_PAUSED); } } async resumePlayback() { if (this.isPlaying && this.isPaused) { this.isPaused = false; // 一時停止中のアイテムがあれば状態をPLAYINGに戻す if (this.currentPlayingItem) { this.updateItemStatus(this.currentPlayingItem, types_1.QueueItemStatus.PLAYING); } this.eventManager.emitEvent(types_1.QueueEventType.PLAYBACK_RESUMED); this.processQueue(); } } /** * 次のアイテムを再生する * 最初のREADY状態のアイテムを再生します */ async playNext() { if (!this.isPlaying) { this.isPlaying = true; } this.isPaused = false; // キュー処理を開始 this.processQueue(); } // --- 再生制御ここまで --- /** * イベントリスナーを追加 */ addEventListener(event, listener) { this.eventManager.addEventListener(event, listener); } /** * イベントリスナーを削除 */ removeEventListener(event, listener) { this.eventManager.removeEventListener(event, listener); } /** * 現在のキュー内のアイテムを取得 */ getQueue() { // 不変性を保つためにコピーを返す return [...this.queue]; } /** * 特定のアイテムの状態を取得 */ getItemStatus(itemId) { const item = this.queue.find((item) => item.id === itemId); return item ? item.status : null; } /** * キュー処理実行 * キューにある音声の生成と再生を処理 */ async processQueue() { // 再生を停止中またはポーズ中は何もしない if (!this.isPlaying || this.isPaused) { return; } // 現在再生中のアイテムがあれば再生中のまま if (this.currentPlayingItem?.status === types_1.QueueItemStatus.PLAYING) { return; } // 前回再生したアイテムの後処理 if (this.currentPlayingItem && this.currentPlayingItem.status === types_1.QueueItemStatus.DONE) { // 再生完了したアイテムの一時ファイルを削除 if (this.currentPlayingItem.tempFile) { await this.fileManager.deleteTempFile(this.currentPlayingItem.tempFile); this.currentPlayingItem.tempFile = undefined; // 削除後に参照を消す } this.currentPlayingItem = null; } // 再生可能なアイテムを探す const nextItem = this.queue.find((item) => item.status === types_1.QueueItemStatus.READY); if (!nextItem) { // 再生可能なアイテムがなければ、事前生成を開始して終了 this.prefetchAudio(); return; } this.currentPlayingItem = nextItem; this.updateItemStatus(nextItem, types_1.QueueItemStatus.PLAYING); try { if (!nextItem.tempFile) { throw new Error("再生対象の一時ファイルが見つかりません"); } await this.audioPlayer.playAudio(nextItem.tempFile); // 再生完了 this.updateItemStatus(nextItem, types_1.QueueItemStatus.DONE); this.eventManager.emitEvent(types_1.QueueEventType.ITEM_COMPLETED, nextItem); // キューから削除 const itemIndex = this.queue.findIndex((i) => i.id === nextItem.id); if (itemIndex !== -1) { this.queue.splice(itemIndex, 1); } // 続けて次のアイテムを再生 this.currentPlayingItem = null; this.processQueue(); } catch (error) { console.error(`Error playing audio:`, error); this.updateItemStatus(nextItem, types_1.QueueItemStatus.ERROR); nextItem.error = error instanceof Error ? error : new Error(String(error)); this.eventManager.emitEvent(types_1.QueueEventType.ERROR, nextItem); // エラー発生時もキューからアイテムを削除 const itemIndex = this.queue.findIndex((i) => i.id === nextItem.id); if (itemIndex !== -1) { this.queue.splice(itemIndex, 1); } // エラー発生時でも次のアイテムを再生 this.currentPlayingItem = null; this.processQueue(); } // 次回の音声を事前生成 this.prefetchAudio(); } /** * 次のアイテムの音声を事前に生成 (プリフェッチ) * @private */ async prefetchAudio() { const pendingItems = this.queue.filter((item) => item.status === types_1.QueueItemStatus.PENDING); const processingOrReadyCount = this.queue.filter((item) => item.status === types_1.QueueItemStatus.READY || item.status === types_1.QueueItemStatus.GENERATING).length; const prefetchNeeded = this.prefetchSize - processingOrReadyCount; if (prefetchNeeded > 0 && pendingItems.length > 0) { const itemsToPrefetch = pendingItems.slice(0, prefetchNeeded); await Promise.all(itemsToPrefetch.map((item) => { if (item.query) { return this.audioGenerator .generateAudioFromQuery(item, this.updateItemStatus.bind(this)) .catch((e) => console.error("Prefetch error:", e)); } else if (item.text) { return this.audioGenerator .generateAudio(item, this.updateItemStatus.bind(this)) .catch((e) => console.error("Prefetch error:", e)); } return Promise.resolve(); })); } } /** * アイテムの状態を更新し、イベントを発火 * @param item 状態を更新するアイテム * @param status 新しい状態 * @private */ updateItemStatus(item, status) { item.status = status; // 状態変更イベントを発火 this.eventManager.emitEvent(types_1.QueueEventType.ITEM_STATUS_CHANGED, item); // READYになったらプリフェッチとキュー処理をトリガー if (status === types_1.QueueItemStatus.READY) { this.prefetchAudio(); this.processQueue(); } } /** * バイナリーデータを一時ファイルに保存 * @param audioData 音声バイナリーデータ * @returns 保存した一時ファイルのパス */ async saveTempAudioFile(audioData) { return this.fileManager.saveTempAudioFile(audioData); } /** * AudioGeneratorインスタンスを取得 * @returns AudioGeneratorインスタンス */ getAudioGenerator() { return this.audioGenerator; } /** * FileManagerインスタンスを取得 * @returns AudioFileManagerインスタンス */ getFileManager() { return this.fileManager; } /** * API インスタンスを取得 * @returns VoicevoxApi インスタンス */ getApi() { return this.api; } } exports.VoicevoxQueueManager = VoicevoxQueueManager;