@kajidog/mcp-tts-voicevox 0.0.2 → 0.0.4

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 CHANGED
@@ -7,6 +7,13 @@ VOICEVOXを使用した音声合成MCPサーバー
7
7
  - Node.js
8
8
  - [VOICEVOXエンジン](https://voicevox.hiroshiba.jp/)
9
9
 
10
+ ## 機能概要
11
+
12
+ - テキストから音声を合成して再生
13
+ - テキストから音声合成用クエリを生成
14
+ - 音声合成用クエリから音声ファイルを生成
15
+ - テキストまたはクエリを音声生成キューに追加
16
+
10
17
  ## 使い方
11
18
 
12
19
  ### インストール
@@ -26,13 +33,51 @@ npx @kajidog/mcp-tts-voicevox
26
33
 
27
34
  ### MCPツールとして使用
28
35
 
36
+ #### 1. テキストを音声に変換して再生
37
+
29
38
  ```typescript
30
39
  await mcp.invoke("speak", {
31
- text: "こんにちは!",
40
+ text: "こんにちは!", // 読み上げるテキスト
32
41
  speaker: 1 // 話者ID(オプション)
33
42
  });
34
43
  ```
35
44
 
45
+ #### 2. テキストから音声合成用クエリを生成
46
+
47
+ ```typescript
48
+ const queryResult = await mcp.invoke("generate_query", {
49
+ text: "こんにちは!", // 音声合成するテキスト
50
+ speaker: 1 // 話者ID(オプション)
51
+ });
52
+
53
+ // 返されたテキストをJSONにパース
54
+ const query = JSON.parse(queryResult.content[0].text);
55
+ ```
56
+
57
+ #### 3. 音声合成用クエリから音声ファイルを生成
58
+
59
+ ```typescript
60
+ const fileResult = await mcp.invoke("synthesize_file", {
61
+ query: query, // 音声合成用クエリ
62
+ output: "/path/to/output.wav", // 出力ファイルパス
63
+ speaker: 1 // 話者ID(オプション)
64
+ });
65
+
66
+ // 生成された音声ファイルのパス
67
+ const filePath = fileResult.content[0].text;
68
+ ```
69
+
70
+ ## 活用例
71
+
72
+ 1. **テキストを直接音声に変換**
73
+ - `speak` - 短いテキストをすぐに読み上げたいとき
74
+
75
+ 2. **細かい音声設定をカスタマイズ**
76
+ - `generate_query` → クエリ編集 → `synthesize_file` の流れで高度な調整が可能
77
+
78
+ 3. **バッチ処理で大量の音声ファイルを作成**
79
+ - テキストをクエリに変換してから一括で音声ファイルを生成
80
+
36
81
  ## 環境変数
37
82
 
38
83
  - `VOICEVOX_URL`: VOICEVOXエンジンのURL(デフォルト: `http://localhost:50021`)
package/dist/index.js CHANGED
@@ -7,17 +7,76 @@ const zod_1 = require("zod");
7
7
  const index_js_1 = require("./voicevox/index.js");
8
8
  const server = new mcp_js_1.McpServer({
9
9
  name: "MCP TTS Voicevox",
10
- version: "0.0.2",
11
- description: "Voicevoxで音声を生成します。",
10
+ version: "0.0.4",
11
+ description: "A Voicevox server that converts text to speech for playback and saving.",
12
12
  });
13
- server.tool("speak", { text: zod_1.z.string(), speaker: zod_1.z.number().optional() }, async ({ text, speaker }) => {
14
- const voicevoxClient = new index_js_1.VoicevoxClient({
15
- url: process.env.VOICEVOX_URL ?? "http://localhost:50021",
16
- defaultSpeaker: 1,
17
- });
18
- const result = await voicevoxClient.speak(text, speaker);
13
+ // VoicevoxClientを一度だけインスタンス化
14
+ const voicevoxClient = new index_js_1.VoicevoxClient({
15
+ url: process.env.VOICEVOX_URL ?? "http://localhost:50021",
16
+ defaultSpeaker: 1,
17
+ });
18
+ // 共通のエラーハンドリング関数
19
+ const handleError = (error) => {
20
+ const errorMessage = error instanceof Error ? error.message : String(error);
21
+ console.error("エラーが発生しました:", error);
19
22
  return {
20
- content: [{ type: "text", text: result }],
23
+ content: [{ type: "text", text: `エラー: ${errorMessage}` }],
21
24
  };
25
+ };
26
+ // テキストを音声に変換して再生
27
+ server.tool("speak", "Convert text to speech and play it", {
28
+ text: zod_1.z.string().describe("Text to be spoken"),
29
+ speaker: zod_1.z.number().optional().describe("Speaker ID (optional)"),
30
+ }, async ({ text, speaker }) => {
31
+ try {
32
+ const result = await voicevoxClient.enqueueAudioGeneration(text, speaker);
33
+ return {
34
+ content: [{ type: "text", text: result }],
35
+ };
36
+ }
37
+ catch (error) {
38
+ return handleError(error);
39
+ }
40
+ });
41
+ // クエリ生成ツール
42
+ server.tool("generate_query", "Generate a query for voice synthesis", {
43
+ text: zod_1.z.string().describe("Text for voice synthesis"),
44
+ speaker: zod_1.z.number().optional().describe("Speaker ID (optional)"),
45
+ }, async ({ text, speaker }) => {
46
+ try {
47
+ const query = await voicevoxClient.generateQuery(text, speaker);
48
+ return {
49
+ content: [{ type: "text", text: JSON.stringify(query) }],
50
+ };
51
+ }
52
+ catch (error) {
53
+ return handleError(error);
54
+ }
55
+ });
56
+ // 音声ファイル生成ツール - クエリまたはテキストを受け付ける
57
+ server.tool("synthesize_file", "Generate an audio file and return its absolute path", {
58
+ text: zod_1.z
59
+ .string()
60
+ .optional()
61
+ .describe("Text for voice synthesis (if both query and text are provided, query takes precedence)"),
62
+ query: zod_1.z.any().optional().describe("Voice synthesis query"),
63
+ output: zod_1.z.string().describe("Output path for the audio file"),
64
+ speaker: zod_1.z.number().optional().describe("Speaker ID (optional)"),
65
+ }, async ({ text, query, output, speaker }) => {
66
+ try {
67
+ if (!query && !text) {
68
+ throw new Error("queryパラメータとtextパラメータのどちらかを指定してください");
69
+ }
70
+ const filePath = await voicevoxClient.generateAudioFile(query ?? text, output, speaker);
71
+ return {
72
+ content: [{ type: "text", text: filePath }],
73
+ };
74
+ }
75
+ catch (error) {
76
+ return handleError(error);
77
+ }
78
+ });
79
+ server.connect(new stdio_js_1.StdioServerTransport()).catch((error) => {
80
+ console.error(error);
81
+ process.exit(1);
22
82
  });
23
- server.connect(new stdio_js_1.StdioServerTransport()).catch(console.error);
package/dist/test.js CHANGED
@@ -1,16 +1,191 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  const voicevox_1 = require("./voicevox");
4
- async function main() {
37
+ const path_1 = require("path");
38
+ const os_1 = require("os");
39
+ const fs = __importStar(require("fs/promises"));
40
+ // クライアントテスト
41
+ async function testClient() {
5
42
  try {
6
- console.log("VOICEVOXテストを開始します...");
7
- const player = new voicevox_1.VoicevoxClient({
43
+ console.log("=== VoicevoxClient直接テスト ===");
44
+ const client = new voicevox_1.VoicevoxClient({
8
45
  url: "http://localhost:50021",
46
+ defaultSpeaker: 1,
47
+ });
48
+ // テスト用のテキスト
49
+ const testText = "これはテストです。VOICEVOXの機能を検証します。";
50
+ const speaker = 1; // 四国めたん (ノーマル)
51
+ // 1. speak テスト - テキストからの音声再生
52
+ console.log("\n----- テキストから音声再生のテスト -----");
53
+ const speakResult = await client.speak(testText, speaker);
54
+ console.log(speakResult);
55
+ // 2. generateQuery テスト - テキストから音声合成用クエリを生成
56
+ console.log("\n----- テキストから音声合成用クエリ生成のテスト -----");
57
+ const query = await client.generateQuery(testText, speaker);
58
+ console.log("クエリ生成結果 (一部):", JSON.stringify(query).substring(0, 100) + "...");
59
+ // 3. synthesizeToFile テスト - クエリから音声ファイルを生成
60
+ console.log("\n----- クエリから音声ファイル生成のテスト -----");
61
+ const outputPath = (0, path_1.join)((0, os_1.tmpdir)(), `voicevox-test-${Date.now()}.wav`);
62
+ const filePath = await client.generateAudioFile(query, outputPath, speaker);
63
+ console.log(`音声ファイル生成結果: ${filePath}`);
64
+ // ファイルが存在するか確認
65
+ const fileExists = await fs
66
+ .stat(filePath)
67
+ .then(() => true)
68
+ .catch(() => false);
69
+ console.log(`ファイルが存在するか: ${fileExists}`);
70
+ // 4. generateAudioFile テスト - テキストから直接音声ファイルを生成
71
+ console.log("\n----- テキストから直接音声ファイル生成のテスト -----");
72
+ const directFilePath = await client.generateAudioFile("直接ファイルに変換するテスト。", (0, path_1.join)((0, os_1.tmpdir)(), `voicevox-direct-${Date.now()}.wav`), speaker);
73
+ console.log(`直接音声ファイル生成結果: ${directFilePath}`);
74
+ // 5. enqueueAudioGeneration テスト - クエリを使って音声生成キューに追加
75
+ console.log("\n----- クエリを使って音声生成キューへの追加テスト -----");
76
+ const enqueueResult = await client.enqueueAudioGeneration(query, speaker);
77
+ console.log(enqueueResult);
78
+ // 待機してキューの処理が完了するのを待つ
79
+ console.log("\n音声再生を待機しています...");
80
+ await new Promise((resolve) => setTimeout(resolve, 3000));
81
+ return query; // 後のテストで使用するためにクエリを返す
82
+ }
83
+ catch (error) {
84
+ console.error("クライアントテスト中にエラーが発生しました:", error);
85
+ throw error;
86
+ }
87
+ }
88
+ // MCPツールテスト
89
+ async function testMcpTools(query) {
90
+ try {
91
+ console.log("\n=== MCPツールテスト ===");
92
+ // VoicevoxClientを一度だけインスタンス化
93
+ const voicevoxClient = new voicevox_1.VoicevoxClient({
94
+ url: process.env.VOICEVOX_URL ?? "http://localhost:50021",
95
+ defaultSpeaker: 1,
9
96
  });
10
- // 複数のテキストをキューに追加
11
- const result = await player.speak("おはようございます!今日の天気はあめ!", // 長い文章
12
- 1);
13
- console.log(result);
97
+ // 1. speak ツールテスト
98
+ console.log("\n----- speak ツールのテスト -----");
99
+ const speakHandler = async (args) => {
100
+ try {
101
+ const { text, speaker } = args;
102
+ const result = await voicevoxClient.enqueueAudioGeneration(text, speaker);
103
+ console.log("speak 結果:", result);
104
+ return {
105
+ content: [{ type: "text", text: result }],
106
+ };
107
+ }
108
+ catch (error) {
109
+ const errorMessage = error instanceof Error ? error.message : String(error);
110
+ console.error("エラーが発生しました:", error);
111
+ return {
112
+ content: [{ type: "text", text: `エラー: ${errorMessage}` }],
113
+ };
114
+ }
115
+ };
116
+ // 実行
117
+ await speakHandler({ text: "MCPツールからのテスト発話です。", speaker: 1 });
118
+ // 2. generate_query ツールテスト
119
+ console.log("\n----- generate_query ツールのテスト -----");
120
+ const generateQueryHandler = async (args) => {
121
+ try {
122
+ const { text, speaker } = args;
123
+ const generatedQuery = await voicevoxClient.generateQuery(text, speaker);
124
+ const queryJson = JSON.stringify(generatedQuery);
125
+ console.log("generate_query 結果 (一部):", queryJson.substring(0, 100) + "...");
126
+ return {
127
+ content: [{ type: "text", text: queryJson }],
128
+ };
129
+ }
130
+ catch (error) {
131
+ const errorMessage = error instanceof Error ? error.message : String(error);
132
+ console.error("エラーが発生しました:", error);
133
+ return {
134
+ content: [{ type: "text", text: `エラー: ${errorMessage}` }],
135
+ };
136
+ }
137
+ };
138
+ // 実行
139
+ const queryResponse = await generateQueryHandler({
140
+ text: "MCPツールからのクエリ生成テスト。",
141
+ speaker: 1,
142
+ });
143
+ // テキストからJSONに変換
144
+ const generatedQuery = JSON.parse(queryResponse.content[0].text);
145
+ // 3. synthesize_file ツールテスト
146
+ console.log("\n----- synthesize_file ツールのテスト -----");
147
+ const synthesizeFileHandler = async (args) => {
148
+ try {
149
+ const { query: testQuery, output, speaker } = args;
150
+ const filePath = await voicevoxClient.generateAudioFile(testQuery, output, speaker);
151
+ console.log("synthesize_file 結果:", filePath);
152
+ return {
153
+ content: [{ type: "text", text: filePath }],
154
+ };
155
+ }
156
+ catch (error) {
157
+ const errorMessage = error instanceof Error ? error.message : String(error);
158
+ console.error("エラーが発生しました:", error);
159
+ return {
160
+ content: [{ type: "text", text: `エラー: ${errorMessage}` }],
161
+ };
162
+ }
163
+ };
164
+ // 実行
165
+ const testOutputPath = (0, path_1.join)((0, os_1.tmpdir)(), `voicevox-mcp-final-test-${Date.now()}.wav`);
166
+ await synthesizeFileHandler({
167
+ query: generatedQuery,
168
+ output: testOutputPath,
169
+ speaker: 1,
170
+ });
171
+ // 待機してキューの処理が完了するのを待つ
172
+ console.log("\nMCP音声再生を待機しています...");
173
+ await new Promise((resolve) => setTimeout(resolve, 3000));
174
+ }
175
+ catch (error) {
176
+ console.error("MCPツールテスト中にエラーが発生しました:", error);
177
+ throw error;
178
+ }
179
+ }
180
+ // メイン実行関数
181
+ async function main() {
182
+ try {
183
+ console.log("VOICEVOXテストを開始します...");
184
+ // クライアントテスト実行
185
+ const query = await testClient();
186
+ // MCPツールテスト実行
187
+ await testMcpTools(query);
188
+ console.log("\nすべてのテストが完了しました!");
14
189
  }
15
190
  catch (error) {
16
191
  console.error("テスト中にエラーが発生しました:", error);
@@ -4,9 +4,9 @@ exports.VoicevoxClient = void 0;
4
4
  const player_1 = require("./player");
5
5
  class VoicevoxClient {
6
6
  constructor(config) {
7
- this.maxSegmentLength = 150; // より長い区切りを許容
8
7
  this.validateConfig(config);
9
8
  this.defaultSpeaker = config.defaultSpeaker ?? 1;
9
+ this.maxSegmentLength = config.maxSegmentLength ?? 150;
10
10
  this.player = new player_1.VoicevoxPlayer(config.url);
11
11
  }
12
12
  /**
@@ -17,7 +17,7 @@ class VoicevoxClient {
17
17
  */
18
18
  async speak(text, speaker) {
19
19
  try {
20
- const speakerId = speaker ?? this.defaultSpeaker;
20
+ const speakerId = this.getSpeakerId(speaker);
21
21
  const segments = this.splitText(text);
22
22
  for (const segment of segments) {
23
23
  await this.player.enqueue(segment, speakerId);
@@ -25,10 +25,83 @@ class VoicevoxClient {
25
25
  return `音声生成キューに追加しました: ${text}`;
26
26
  }
27
27
  catch (error) {
28
- console.error("音声生成中にエラーが発生しました:", error);
29
- throw new Error(`音声生成に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
28
+ return this.handleError("音声生成中にエラーが発生しました", error);
30
29
  }
31
30
  }
31
+ /**
32
+ * テキストから音声合成用クエリを生成します
33
+ * @param text 変換するテキスト
34
+ * @param speaker 話者ID(オプション)
35
+ * @returns 音声合成用クエリ
36
+ */
37
+ async generateQuery(text, speaker) {
38
+ try {
39
+ const speakerId = this.getSpeakerId(speaker);
40
+ return await this.player.generateQuery(text, speakerId);
41
+ }
42
+ catch (error) {
43
+ throw this.handleError("クエリ生成中にエラーが発生しました", error);
44
+ }
45
+ }
46
+ /**
47
+ * テキストから直接音声ファイルを生成します
48
+ * @param textOrQuery テキストまたは音声合成用クエリ
49
+ * @param outputPath 出力ファイルパス(オプション、省略時は一時ファイル)
50
+ * @param speaker 話者ID(オプション)
51
+ * @returns 生成した音声ファイルのパス
52
+ */
53
+ async generateAudioFile(textOrQuery, outputPath, speaker) {
54
+ try {
55
+ const speakerId = this.getSpeakerId(speaker);
56
+ // テキストかクエリかを判断
57
+ if (typeof textOrQuery === "string") {
58
+ // テキストの場合は分割せずに直接クエリを生成
59
+ const query = await this.generateQuery(textOrQuery, speakerId);
60
+ return await this.player.synthesizeToFile(query, outputPath ?? "", speakerId);
61
+ }
62
+ else {
63
+ // クエリの場合はそのまま音声ファイルを生成
64
+ return await this.player.synthesizeToFile(textOrQuery, outputPath ?? "", speakerId);
65
+ }
66
+ }
67
+ catch (error) {
68
+ throw this.handleError("音声ファイル生成中にエラーが発生しました", error);
69
+ }
70
+ }
71
+ // 実装
72
+ async enqueueAudioGeneration(textOrQuery, speaker) {
73
+ try {
74
+ const speakerId = this.getSpeakerId(speaker);
75
+ if (typeof textOrQuery === "string") {
76
+ // テキストの場合
77
+ return await this.speak(textOrQuery, speakerId);
78
+ }
79
+ else {
80
+ // クエリの場合
81
+ await this.player.enqueueWithQuery(textOrQuery, speakerId);
82
+ return "クエリをキューに追加しました";
83
+ }
84
+ }
85
+ catch (error) {
86
+ return this.handleError("音声生成中にエラーが発生しました", error);
87
+ }
88
+ }
89
+ /**
90
+ * 話者IDを取得(指定がない場合はデフォルト値を使用)
91
+ * @private
92
+ */
93
+ getSpeakerId(speaker) {
94
+ return speaker ?? this.defaultSpeaker;
95
+ }
96
+ /**
97
+ * エラーハンドリング
98
+ * @private
99
+ */
100
+ handleError(message, error) {
101
+ const errorMsg = error instanceof Error ? error.message : String(error);
102
+ console.error(`${message}: ${errorMsg}`, error);
103
+ throw new Error(`${message}: ${errorMsg}`);
104
+ }
32
105
  /**
33
106
  * テキストを自然な区切りで分割します
34
107
  * @param text 分割するテキスト
@@ -36,50 +109,64 @@ class VoicevoxClient {
36
109
  */
37
110
  splitText(text) {
38
111
  // 文の区切りとなるパターン
39
- const sentenceEndings = /([。!?])/g;
112
+ const sentenceEndings = /([。!?])/;
40
113
  // 自然な区切りとなる接続詞や助詞
41
- const naturalBreaks = /([が、しかし、でも、けれど、そして、また、または、それで、だから、ですから、そのため、したがって、ゆえに、])/g;
114
+ const naturalBreaks = /([、しかし、でも、けれど、そして、また、または、それで、だから、ですから、そのため、したがって、ゆえに])/;
42
115
  const segments = [];
43
116
  let currentSegment = "";
44
- // まず文末で分割
45
- const parts = text.split(sentenceEndings);
46
- for (let i = 0; i < parts.length; i++) {
47
- const part = parts[i];
48
- // 文末記号の場合は現在のセグメントに追加
49
- if (sentenceEndings.test(part)) {
50
- currentSegment += part;
51
- if (currentSegment.trim()) {
52
- segments.push(currentSegment.trim());
53
- currentSegment = "";
54
- }
55
- continue;
117
+ // 文を句読点で分割
118
+ const sentences = text
119
+ .split(sentenceEndings)
120
+ .reduce((acc, part, i, arr) => {
121
+ if (i % 2 === 0) {
122
+ // テキスト部分
123
+ acc.push(part);
124
+ }
125
+ else {
126
+ // 句読点部分 - 前のテキストと結合
127
+ acc[acc.length - 1] += part;
56
128
  }
57
- // 空の部分はスキップ
58
- if (!part.trim())
129
+ return acc;
130
+ }, []);
131
+ // 文ごとに処理
132
+ for (const sentence of sentences) {
133
+ if (!sentence.trim())
59
134
  continue;
60
- // 自然な区切りで分割
61
- const subParts = part.split(naturalBreaks);
62
- for (let j = 0; j < subParts.length; j++) {
63
- const subPart = subParts[j];
64
- // 接続詞や助詞の場合は現在のセグメントに追加
65
- if (naturalBreaks.test(subPart)) {
66
- currentSegment += subPart;
67
- continue;
68
- }
69
- // 空の部分はスキップ
70
- if (!subPart.trim())
71
- continue;
72
- // 現在のセグメントに追加
73
- currentSegment += subPart;
74
- // セグメントが最大長を超えた場合、または最後の部分の場合
75
- if (currentSegment.length >= this.maxSegmentLength ||
76
- (i === parts.length - 1 && j === subParts.length - 1)) {
77
- if (currentSegment.trim()) {
78
- segments.push(currentSegment.trim());
135
+ // 現在のセグメントに追加してみる
136
+ const potentialSegment = currentSegment + sentence;
137
+ // 最大長を超えないならそのまま追加
138
+ if (potentialSegment.length <= this.maxSegmentLength) {
139
+ currentSegment = potentialSegment;
140
+ }
141
+ else {
142
+ // 最大長を超える場合、naturalBreaksで分割を試みる
143
+ const parts = sentence.split(naturalBreaks);
144
+ // 分割パーツを処理
145
+ for (let i = 0; i < parts.length; i++) {
146
+ const part = parts[i];
147
+ if (!part.trim())
148
+ continue;
149
+ // 現在のセグメントに追加してみる
150
+ const newSegment = currentSegment + part;
151
+ // 最大長を超えるかチェック
152
+ if (newSegment.length <= this.maxSegmentLength) {
153
+ currentSegment = newSegment;
154
+ }
155
+ else {
156
+ // 既存のセグメントがあれば保存
157
+ if (currentSegment.trim()) {
158
+ segments.push(currentSegment.trim());
159
+ }
160
+ currentSegment = part;
79
161
  }
80
- currentSegment = "";
81
162
  }
82
163
  }
164
+ // 一文が終わったら保存判定
165
+ if (currentSegment.length >= this.maxSegmentLength &&
166
+ currentSegment.trim()) {
167
+ segments.push(currentSegment.trim());
168
+ currentSegment = "";
169
+ }
83
170
  }
84
171
  // 残りのテキストがあれば追加
85
172
  if (currentSegment.trim()) {
@@ -15,21 +15,130 @@ class VoicevoxPlayer {
15
15
  this.isPlaying = false;
16
16
  this.isGenerating = false;
17
17
  this.prefetchSize = 2; // プリフェッチするアイテム数
18
- this.voicevoxUrl = voicevoxUrl;
18
+ this.voicevoxUrl = this.normalizeUrl(voicevoxUrl);
19
19
  }
20
- // キューに追加
20
+ /**
21
+ * テキストをキューに追加
22
+ */
21
23
  async enqueue(text, speaker = 1) {
22
- const item = { text, speaker };
23
- this.queue.push(item);
24
- await this.generateAudio(item); // 音声データの生成を待つ
25
- this.prefetchAudio(); // 次の音声の事前生成を開始
26
- this.processQueue(); // 再生キューの処理を開始
24
+ try {
25
+ const item = { text, speaker };
26
+ this.queue.push(item);
27
+ await this.generateAudio(item); // 音声データの生成を待つ
28
+ this.prefetchAudio(); // 次の音声の事前生成を開始
29
+ this.processQueue(); // 再生キューの処理を開始
30
+ }
31
+ catch (error) {
32
+ this.handleError("キューへの追加中にエラーが発生しました", error);
33
+ }
34
+ }
35
+ /**
36
+ * クエリを使ってキューに追加
37
+ */
38
+ async enqueueWithQuery(query, speaker = 1) {
39
+ try {
40
+ const item = { text: "クエリから生成", speaker, query };
41
+ this.queue.push(item);
42
+ await this.generateAudioFromQuery(item); // 音声データの生成を待つ
43
+ this.prefetchAudio(); // 次の音声の事前生成を開始
44
+ this.processQueue(); // 再生キューの処理を開始
45
+ }
46
+ catch (error) {
47
+ this.handleError("クエリからの音声生成中にエラーが発生しました", error);
48
+ }
49
+ }
50
+ /**
51
+ * テキストから音声合成用クエリを生成
52
+ */
53
+ async generateQuery(text, speaker = 1) {
54
+ try {
55
+ const endpoint = `/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`;
56
+ return await this.makeRequest("post", endpoint, null, {
57
+ "Content-Type": "application/json",
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw this.handleError("音声クエリ生成中にエラーが発生しました", error);
62
+ }
27
63
  }
28
- // キューをクリア
64
+ /**
65
+ * 音声合成用クエリから音声ファイルを生成
66
+ */
67
+ async synthesizeToFile(query, output, speaker = 1) {
68
+ try {
69
+ // 音声を合成
70
+ const audioData = await this.makeRequest("post", `/synthesis?speaker=${speaker}`, query, {
71
+ "Content-Type": "application/json",
72
+ Accept: "audio/wav",
73
+ }, "arraybuffer");
74
+ // 出力パスが指定されていなければ一時ファイルを作成
75
+ const filePath = output || this.createTempFilePath();
76
+ await (0, promises_1.writeFile)(filePath, Buffer.from(audioData));
77
+ return filePath;
78
+ }
79
+ catch (error) {
80
+ throw this.handleError("音声ファイル生成中にエラーが発生しました", error);
81
+ }
82
+ }
83
+ /**
84
+ * キューをクリア
85
+ */
29
86
  clearQueue() {
87
+ // 一時ファイルの削除処理を追加
88
+ this.queue.forEach((item) => {
89
+ if (item.tempFile) {
90
+ this.deleteTempFile(item.tempFile).catch(console.error);
91
+ }
92
+ });
30
93
  this.queue = [];
31
94
  }
32
- // 音声の事前生成
95
+ /**
96
+ * APIリクエストを実行
97
+ * @private
98
+ */
99
+ async makeRequest(method, endpoint, data = null, headers = {}, responseType = "json") {
100
+ try {
101
+ const url = `${this.voicevoxUrl}${endpoint}`;
102
+ const config = {
103
+ method,
104
+ url,
105
+ data,
106
+ headers,
107
+ responseType,
108
+ timeout: 30000, // 30秒タイムアウト
109
+ };
110
+ const response = await (0, axios_1.default)(config);
111
+ if (response.status !== 200) {
112
+ const error = new Error(`APIリクエストに失敗しました: ${response.status}`);
113
+ error.statusCode = response.status;
114
+ error.response = response.data;
115
+ throw error;
116
+ }
117
+ return response.data;
118
+ }
119
+ catch (error) {
120
+ if (axios_1.default.isAxiosError(error)) {
121
+ const voicevoxError = new Error(`APIリクエストに失敗しました: ${error.message}`);
122
+ voicevoxError.statusCode = error.response?.status;
123
+ voicevoxError.response = error.response?.data;
124
+ throw voicevoxError;
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+ /**
130
+ * エラーハンドリング
131
+ * @private
132
+ */
133
+ handleError(message, error) {
134
+ const errorDetails = error instanceof Error ? error.message : String(error);
135
+ console.error(`${message}: ${errorDetails}`, error);
136
+ throw new Error(`${message}: ${errorDetails}`);
137
+ }
138
+ /**
139
+ * 音声の事前生成
140
+ * @private
141
+ */
33
142
  async prefetchAudio() {
34
143
  if (this.isGenerating)
35
144
  return;
@@ -39,7 +148,12 @@ class VoicevoxPlayer {
39
148
  const itemsToGenerate = this.queue
40
149
  .filter((item) => !item.audioData)
41
150
  .slice(0, this.prefetchSize);
42
- await Promise.all(itemsToGenerate.map((item) => this.generateAudio(item)));
151
+ if (itemsToGenerate.length === 0) {
152
+ return;
153
+ }
154
+ await Promise.all(itemsToGenerate.map((item) => item.query
155
+ ? this.generateAudioFromQuery(item)
156
+ : this.generateAudio(item)));
43
157
  }
44
158
  catch (error) {
45
159
  console.error("音声の事前生成中にエラーが発生しました:", error);
@@ -48,35 +162,25 @@ class VoicevoxPlayer {
48
162
  this.isGenerating = false;
49
163
  }
50
164
  }
51
- // 音声生成処理
52
- async generateAudio(item) {
165
+ /**
166
+ * クエリから音声生成処理
167
+ * @private
168
+ */
169
+ async generateAudioFromQuery(item) {
53
170
  try {
54
- // 音声クエリを生成
55
- const queryResponse = await axios_1.default.post(`${this.voicevoxUrl}/audio_query?text=${encodeURIComponent(item.text)}&speaker=${item.speaker}`, null, {
56
- headers: {
57
- "Content-Type": "application/json",
58
- },
59
- });
60
- if (queryResponse.status !== 200) {
61
- throw new Error(`音声クエリの生成に失敗しました: ${queryResponse.status}`);
171
+ if (!item.query) {
172
+ throw new Error("音声合成用クエリが指定されていません");
62
173
  }
63
- const query = queryResponse.data;
64
174
  // 音声を合成
65
- const synthesisResponse = await axios_1.default.post(`${this.voicevoxUrl}/synthesis?speaker=${item.speaker}`, query, {
66
- responseType: "arraybuffer",
67
- headers: {
68
- "Content-Type": "application/json",
69
- Accept: "audio/wav",
70
- },
71
- });
72
- if (synthesisResponse.status !== 200) {
73
- throw new Error(`音声合成に失敗しました: ${synthesisResponse.status}`);
74
- }
175
+ const audioData = await this.makeRequest("post", `/synthesis?speaker=${item.speaker}`, item.query, {
176
+ "Content-Type": "application/json",
177
+ Accept: "audio/wav",
178
+ }, "arraybuffer");
75
179
  // 音声データを保存
76
- item.audioData = synthesisResponse.data;
180
+ item.audioData = audioData;
77
181
  // 一時ファイルに保存
78
182
  if (item.audioData) {
79
- const tempFile = (0, path_1.join)((0, os_1.tmpdir)(), `voicevox-${Date.now()}.wav`);
183
+ const tempFile = this.createTempFilePath();
80
184
  await (0, promises_1.writeFile)(tempFile, Buffer.from(item.audioData));
81
185
  item.tempFile = tempFile;
82
186
  }
@@ -86,7 +190,27 @@ class VoicevoxPlayer {
86
190
  throw error;
87
191
  }
88
192
  }
89
- // キューの処理
193
+ /**
194
+ * 音声生成処理
195
+ * @private
196
+ */
197
+ async generateAudio(item) {
198
+ try {
199
+ // 音声クエリを生成
200
+ const query = await this.generateQuery(item.text, item.speaker);
201
+ item.query = query;
202
+ // クエリから音声を生成
203
+ await this.generateAudioFromQuery(item);
204
+ }
205
+ catch (error) {
206
+ console.error("音声生成中にエラーが発生しました:", error);
207
+ throw error;
208
+ }
209
+ }
210
+ /**
211
+ * キュー処理
212
+ * @private
213
+ */
90
214
  async processQueue() {
91
215
  if (this.isPlaying || this.queue.length === 0) {
92
216
  return;
@@ -96,32 +220,106 @@ class VoicevoxPlayer {
96
220
  while (this.queue.length > 0) {
97
221
  const item = this.queue[0];
98
222
  // 音声データが生成されるまで待機
99
- while (!item.audioData) {
100
- await new Promise((resolve) => setTimeout(resolve, 100));
223
+ if (!item.tempFile) {
224
+ await this.waitForAudio(item);
101
225
  }
102
- // 音声を再生
103
226
  if (item.tempFile) {
104
- await sound.play(item.tempFile);
105
- // 再生が完了したら一時ファイルを削除
106
- try {
107
- await (0, promises_1.unlink)(item.tempFile);
108
- }
109
- catch (error) {
110
- console.warn("一時ファイルの削除に失敗しました:", error);
111
- }
227
+ // 音声再生
228
+ await this.playAudio(item.tempFile);
229
+ // 再生後にキューから削除
230
+ this.queue.shift();
231
+ // 一時ファイルを削除
232
+ await this.deleteTempFile(item.tempFile);
112
233
  }
113
- // キューから削除
114
- this.queue.shift();
115
- // 次の音声の事前生成を開始
234
+ else {
235
+ // 音声生成に失敗した場合はスキップ
236
+ console.error("音声生成に失敗したためスキップします");
237
+ this.queue.shift();
238
+ }
239
+ // 次の音声のプリフェッチを開始
116
240
  this.prefetchAudio();
117
241
  }
118
242
  }
119
243
  catch (error) {
120
- console.error("キュー処理中にエラーが発生しました:", error);
244
+ console.error("音声再生中にエラーが発生しました:", error);
121
245
  }
122
246
  finally {
123
247
  this.isPlaying = false;
124
248
  }
125
249
  }
250
+ /**
251
+ * 音声再生
252
+ * @private
253
+ */
254
+ async playAudio(filePath) {
255
+ return new Promise((resolve, reject) => {
256
+ try {
257
+ sound
258
+ .play(filePath)
259
+ .then(() => {
260
+ resolve();
261
+ })
262
+ .catch((error) => {
263
+ console.error("音声再生中にエラーが発生しました:", error);
264
+ reject(error);
265
+ });
266
+ }
267
+ catch (error) {
268
+ console.error("音声再生中にエラーが発生しました:", error);
269
+ reject(error);
270
+ }
271
+ });
272
+ }
273
+ /**
274
+ * 音声データが生成されるまで待機
275
+ * @private
276
+ */
277
+ async waitForAudio(item) {
278
+ let retryCount = 0;
279
+ const maxRetry = 10;
280
+ const retryInterval = 500; // ms
281
+ return new Promise((resolve, reject) => {
282
+ const checkAudio = () => {
283
+ if (item.tempFile) {
284
+ resolve();
285
+ return;
286
+ }
287
+ if (retryCount >= maxRetry) {
288
+ reject(new Error("音声データの生成がタイムアウトしました"));
289
+ return;
290
+ }
291
+ retryCount++;
292
+ setTimeout(checkAudio, retryInterval);
293
+ };
294
+ checkAudio();
295
+ });
296
+ }
297
+ /**
298
+ * 一時ファイルパスの生成
299
+ * @private
300
+ */
301
+ createTempFilePath() {
302
+ return (0, path_1.join)((0, os_1.tmpdir)(), `voicevox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}.wav`);
303
+ }
304
+ /**
305
+ * 一時ファイルの削除
306
+ * @private
307
+ */
308
+ async deleteTempFile(filePath) {
309
+ try {
310
+ await (0, promises_1.unlink)(filePath);
311
+ }
312
+ catch (error) {
313
+ console.error("一時ファイルの削除に失敗しました:", error);
314
+ }
315
+ }
316
+ /**
317
+ * URLの正規化
318
+ * @private
319
+ */
320
+ normalizeUrl(url) {
321
+ // 末尾のスラッシュを削除
322
+ return url.endsWith("/") ? url.slice(0, -1) : url;
323
+ }
126
324
  }
127
325
  exports.VoicevoxPlayer = VoicevoxPlayer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kajidog/mcp-tts-voicevox",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "VOICEVOX integration for MCP - Text-to-Speech server using VOICEVOX engine",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -22,7 +22,8 @@
22
22
  "keywords": [
23
23
  "voicevox",
24
24
  "tts",
25
- "mcp"
25
+ "mcp",
26
+ "mcp-server"
26
27
  ],
27
28
  "author": {
28
29
  "name": "kajidog",
@@ -48,4 +49,4 @@
48
49
  "ts-node": "^10.9.0",
49
50
  "typescript": "^5.0.0"
50
51
  }
51
- }
52
+ }