koishi-plugin-vox 0.0.1
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/lib/index.d.ts +18 -0
- package/lib/index.js +229 -0
- package/package.json +40 -0
- package/readme.md +5 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import SilkService from 'koishi-plugin-silk';
|
|
3
|
+
declare module 'koishi' {
|
|
4
|
+
interface Context {
|
|
5
|
+
silk: SilkService;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export declare const name = "vox";
|
|
9
|
+
export declare const usage = "\u8DEF\u5F84\u4E0B\u7684\u6587\u4EF6\u5939\u5C06\u4F1A\u88AB\u8BA4\u4E3A\u4E00\u4E2A\u97F3\u8272\uFF0C\u5B50\u6587\u4EF6\u5939\u5185\u6240\u6709\u97F3\u9891\u5C06\u4F1A\u8BFB\u53D6\u4E3A\u53EF\u7528\u97F3\u9891\uFF0C\u6587\u4EF6\u540D\uFF08\u4E0D\u542B\u6269\u5C55\u540D\uFF09\u5373\u4E3A\u89E6\u53D1\u540D\u79F0\u3002";
|
|
10
|
+
export declare const inject: {
|
|
11
|
+
optional: string[];
|
|
12
|
+
};
|
|
13
|
+
export interface Config {
|
|
14
|
+
soundPath: string[];
|
|
15
|
+
audioType: "mp3" | "silk";
|
|
16
|
+
}
|
|
17
|
+
export declare const Config: Schema<Config>;
|
|
18
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
Config: () => Config,
|
|
24
|
+
apply: () => apply,
|
|
25
|
+
inject: () => inject,
|
|
26
|
+
name: () => name,
|
|
27
|
+
usage: () => usage
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(src_exports);
|
|
30
|
+
var import_koishi = require("koishi");
|
|
31
|
+
var import_promises = require("fs/promises");
|
|
32
|
+
var import_path = require("path");
|
|
33
|
+
var import_child_process = require("child_process");
|
|
34
|
+
var name = "vox";
|
|
35
|
+
var usage = "路径下的文件夹将会被认为一个音色,子文件夹内所有音频将会读取为可用音频,文件名(不含扩展名)即为触发名称。";
|
|
36
|
+
var inject = {
|
|
37
|
+
optional: ["silk"]
|
|
38
|
+
};
|
|
39
|
+
var Config = import_koishi.Schema.object({
|
|
40
|
+
soundPath: import_koishi.Schema.array(import_koishi.Schema.string()).description("用于搜索音频的*绝对*路径,支持搜索mp3 wav ogg flac m4a aac格式"),
|
|
41
|
+
audioType: import_koishi.Schema.union(["mp3", "silk"]).default("mp3").description("最终发送的类型,QQ及微信选择SILK")
|
|
42
|
+
}).description("Vox");
|
|
43
|
+
var AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"];
|
|
44
|
+
var voicePath = /* @__PURE__ */ new Map();
|
|
45
|
+
async function buildVoicePath(soundPaths) {
|
|
46
|
+
voicePath.clear();
|
|
47
|
+
for (const basePath of soundPaths) {
|
|
48
|
+
try {
|
|
49
|
+
const entries = await (0, import_promises.readdir)(basePath, { withFileTypes: true });
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
const voiceName = entry.name;
|
|
54
|
+
const voiceDir = (0, import_path.join)(basePath, voiceName);
|
|
55
|
+
const audioMap = /* @__PURE__ */ new Map();
|
|
56
|
+
try {
|
|
57
|
+
const files = await (0, import_promises.readdir)(voiceDir, { withFileTypes: true });
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
if (!file.isFile())
|
|
60
|
+
continue;
|
|
61
|
+
const ext = (0, import_path.extname)(file.name).toLowerCase();
|
|
62
|
+
if (!AUDIO_EXTENSIONS.includes(ext))
|
|
63
|
+
continue;
|
|
64
|
+
const triggerName = file.name.slice(0, -ext.length);
|
|
65
|
+
const fullPath = (0, import_path.join)(voiceDir, file.name);
|
|
66
|
+
audioMap.set(triggerName, fullPath);
|
|
67
|
+
}
|
|
68
|
+
if (audioMap.size > 0) {
|
|
69
|
+
voicePath.set(voiceName, audioMap);
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
__name(buildVoicePath, "buildVoicePath");
|
|
81
|
+
var ffmpegAvailable = null;
|
|
82
|
+
var ffmpegCheckPromise = null;
|
|
83
|
+
async function checkFFmpeg() {
|
|
84
|
+
if (ffmpegAvailable !== null)
|
|
85
|
+
return ffmpegAvailable;
|
|
86
|
+
if (ffmpegCheckPromise)
|
|
87
|
+
return ffmpegCheckPromise;
|
|
88
|
+
ffmpegCheckPromise = new Promise((resolve) => {
|
|
89
|
+
const { spawn: spawn2 } = require("child_process");
|
|
90
|
+
let resolved = false;
|
|
91
|
+
const timeout = setTimeout(() => {
|
|
92
|
+
if (!resolved) {
|
|
93
|
+
resolved = true;
|
|
94
|
+
resolve(false);
|
|
95
|
+
}
|
|
96
|
+
}, 3e3);
|
|
97
|
+
try {
|
|
98
|
+
const ffmpeg = spawn2("ffmpeg", ["-version"]);
|
|
99
|
+
ffmpeg.on("error", () => {
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
resolved = true;
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
resolve(false);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
ffmpeg.on("close", (code) => {
|
|
107
|
+
if (!resolved) {
|
|
108
|
+
resolved = true;
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
resolve(code === 0);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
resolve(false);
|
|
116
|
+
}
|
|
117
|
+
}).then((result) => {
|
|
118
|
+
ffmpegAvailable = result;
|
|
119
|
+
return result;
|
|
120
|
+
});
|
|
121
|
+
return ffmpegCheckPromise;
|
|
122
|
+
}
|
|
123
|
+
__name(checkFFmpeg, "checkFFmpeg");
|
|
124
|
+
async function runFFmpeg(commandArgs, ctx) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const ffmpeg = (0, import_child_process.spawn)("ffmpeg", commandArgs);
|
|
127
|
+
const chunks = [];
|
|
128
|
+
const stderrChunks = [];
|
|
129
|
+
ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
130
|
+
ffmpeg.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
131
|
+
ffmpeg.on("close", (code) => {
|
|
132
|
+
if (code === 0) {
|
|
133
|
+
resolve(Buffer.concat(chunks));
|
|
134
|
+
} else {
|
|
135
|
+
const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
136
|
+
ctx.logger.error("FFmpeg stderr output:", stderrOutput);
|
|
137
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
ffmpeg.on("error", reject);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
__name(runFFmpeg, "runFFmpeg");
|
|
144
|
+
async function concatSound(ctx, input, audioType) {
|
|
145
|
+
let args = [];
|
|
146
|
+
for (const inputPath of input) {
|
|
147
|
+
args.push("-i", inputPath);
|
|
148
|
+
}
|
|
149
|
+
args.push("-filter_complex");
|
|
150
|
+
const inputCount = input.length;
|
|
151
|
+
let filterChain = "";
|
|
152
|
+
for (let i = 0; i < inputCount; i++) {
|
|
153
|
+
filterChain += `[${i}:0]`;
|
|
154
|
+
}
|
|
155
|
+
filterChain += `concat=n=${inputCount}:v=0:a=1[out]`;
|
|
156
|
+
args.push(filterChain);
|
|
157
|
+
args.push("-map", "[out]");
|
|
158
|
+
if (audioType === "silk") {
|
|
159
|
+
args.push("-ar", "24000", "-ac", "1", "-f", "s16le", "pipe:1");
|
|
160
|
+
const data = await runFFmpeg(args, ctx);
|
|
161
|
+
return { data, mimeType: "audio/pcm" };
|
|
162
|
+
} else {
|
|
163
|
+
args.push("-f", "mp3", "pipe:1");
|
|
164
|
+
const data = await runFFmpeg(args, ctx);
|
|
165
|
+
return { data, mimeType: "audio/mp3" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
__name(concatSound, "concatSound");
|
|
169
|
+
function apply(ctx, config) {
|
|
170
|
+
buildVoicePath(config.soundPath).then(() => {
|
|
171
|
+
ctx.logger.debug(`Loaded ${voicePath.size} voices: ${Array.from(voicePath.keys()).join(", ")}`);
|
|
172
|
+
}).catch((err) => {
|
|
173
|
+
ctx.logger.error("Failed to build voice path:", err);
|
|
174
|
+
});
|
|
175
|
+
checkFFmpeg().then((available) => {
|
|
176
|
+
if (!available) {
|
|
177
|
+
ctx.logger.warn("FFmpeg not found! Audio processing will fail.");
|
|
178
|
+
} else {
|
|
179
|
+
ctx.logger.debug("FFmpeg is available.");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
ctx.command("vox <voice:string> <...rest:string> 合成一个vox").action(async ({ session }, voice, ...rest) => {
|
|
183
|
+
if (!voice) {
|
|
184
|
+
return "至少给个音色吧, 可用的音色有: " + Array.from(voicePath.keys()).join(", ");
|
|
185
|
+
}
|
|
186
|
+
const voiceMap = voicePath.get(voice);
|
|
187
|
+
if (!voiceMap) {
|
|
188
|
+
return `音色 ${voice} 不存在,可用的音色有: ${Array.from(voicePath.keys()).join(", ")}`;
|
|
189
|
+
}
|
|
190
|
+
if (!rest || rest.length === 0) {
|
|
191
|
+
return `至少给点东西拼接吧!`;
|
|
192
|
+
}
|
|
193
|
+
const audioPaths = [];
|
|
194
|
+
for (const trigger of rest) {
|
|
195
|
+
const path = voiceMap.get(trigger);
|
|
196
|
+
if (path) {
|
|
197
|
+
audioPaths.push(path);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (audioPaths.length === 0) {
|
|
201
|
+
return `未找到任何音频!`;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const { data, mimeType } = await concatSound(ctx, audioPaths, config.audioType);
|
|
205
|
+
if (config.audioType === "silk") {
|
|
206
|
+
if (ctx.silk) {
|
|
207
|
+
const silkResult = await ctx.silk.encode(data, 24e3);
|
|
208
|
+
await session.send(import_koishi.h.audio(silkResult.data, "audio/silk"));
|
|
209
|
+
} else {
|
|
210
|
+
return "没有安装必要的SILK插件";
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
await session.send(import_koishi.h.audio(data, mimeType));
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
ctx.logger.error("拼接音频失败:", error);
|
|
217
|
+
return "拼接音频失败";
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
__name(apply, "apply");
|
|
222
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
223
|
+
0 && (module.exports = {
|
|
224
|
+
Config,
|
|
225
|
+
apply,
|
|
226
|
+
inject,
|
|
227
|
+
name,
|
|
228
|
+
usage
|
|
229
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-vox",
|
|
3
|
+
"description": "Vox合成器,依赖FFmpeg进行音频处理。",
|
|
4
|
+
"contributors": [
|
|
5
|
+
"Dr.Abc <me@drabc.net>"
|
|
6
|
+
],
|
|
7
|
+
"version": "0.0.1",
|
|
8
|
+
"homepage": "https://github.com/DrAbcOfficial/koishi-plugin-vox",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/DrAbcOfficial/koishi-plugin-vox.git"
|
|
12
|
+
},
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"typings": "lib/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"scripts": {},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"chatbot",
|
|
23
|
+
"koishi",
|
|
24
|
+
"plugin"
|
|
25
|
+
],
|
|
26
|
+
"devDependencies": {},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"koishi": "^4.18.7"
|
|
29
|
+
},
|
|
30
|
+
"koishi": {
|
|
31
|
+
"description": {
|
|
32
|
+
"en": "A Vox synthesizer relies on FFmpeg for audio processing.",
|
|
33
|
+
"zh": "Vox合成器,依赖FFmpeg进行音频处理。"
|
|
34
|
+
},
|
|
35
|
+
"service":{
|
|
36
|
+
"optional": ["silk"]
|
|
37
|
+
},
|
|
38
|
+
"preview": false
|
|
39
|
+
}
|
|
40
|
+
}
|