@kajidog/mcp-tts-voicevox
Version:
VOICEVOX integration for MCP - Text-to-Speech server using VOICEVOX engine
349 lines (348 loc) • 13.7 kB
JavaScript
"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;