@kajidog/mcp-tts-voicevox 0.0.2 → 0.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/README.md +46 -1
- package/dist/index.js +68 -9
- package/dist/test.js +182 -7
- package/dist/voicevox/index.js +127 -40
- package/dist/voicevox/player.js +247 -49
- package/package.json +4 -3
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.
|
|
10
|
+
version: "0.0.3",
|
|
11
11
|
description: "Voicevoxで音声を生成します。",
|
|
12
12
|
});
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
23
|
+
content: [{ type: "text", text: `エラー: ${errorMessage}` }],
|
|
21
24
|
};
|
|
25
|
+
};
|
|
26
|
+
// テキストを音声に変換して再生
|
|
27
|
+
server.tool("speak", {
|
|
28
|
+
text: zod_1.z.string().describe("読み上げるテキスト"),
|
|
29
|
+
speaker: zod_1.z.number().optional().describe("話者ID"),
|
|
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", {
|
|
43
|
+
text: zod_1.z.string().describe("音声合成するテキスト"),
|
|
44
|
+
speaker: zod_1.z.number().optional().describe("話者ID"),
|
|
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", {
|
|
58
|
+
text: zod_1.z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("音声合成するテキスト(queryパラメータと同時に指定する場合はqueryが優先されます)"),
|
|
62
|
+
query: zod_1.z.any().optional().describe("音声合成用クエリ"),
|
|
63
|
+
output: zod_1.z.string().describe("音声ファイルの保存先パス"),
|
|
64
|
+
speaker: zod_1.z.number().optional().describe("話者ID"),
|
|
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
|
-
|
|
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("
|
|
7
|
-
const
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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);
|
package/dist/voicevox/index.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 = /([。!?])
|
|
112
|
+
const sentenceEndings = /([。!?])/;
|
|
40
113
|
// 自然な区切りとなる接続詞や助詞
|
|
41
|
-
const naturalBreaks = /([
|
|
114
|
+
const naturalBreaks = /([、しかし、でも、けれど、そして、また、または、それで、だから、ですから、そのため、したがって、ゆえに])/;
|
|
42
115
|
const segments = [];
|
|
43
116
|
let currentSegment = "";
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
129
|
+
return acc;
|
|
130
|
+
}, []);
|
|
131
|
+
// 文ごとに処理
|
|
132
|
+
for (const sentence of sentences) {
|
|
133
|
+
if (!sentence.trim())
|
|
59
134
|
continue;
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
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()) {
|
package/dist/voicevox/player.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
/**
|
|
166
|
+
* クエリから音声生成処理
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
async generateAudioFromQuery(item) {
|
|
53
170
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
180
|
+
item.audioData = audioData;
|
|
77
181
|
// 一時ファイルに保存
|
|
78
182
|
if (item.audioData) {
|
|
79
|
-
const tempFile =
|
|
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
|
-
|
|
100
|
-
await
|
|
223
|
+
if (!item.tempFile) {
|
|
224
|
+
await this.waitForAudio(item);
|
|
101
225
|
}
|
|
102
|
-
// 音声を再生
|
|
103
226
|
if (item.tempFile) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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
|
+
}
|