bililive-cli 3.8.2 → 3.9.0
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.
|
@@ -4,7 +4,7 @@ var path$7 = require('node:path');
|
|
|
4
4
|
var http$3 = require('node:http');
|
|
5
5
|
var require$$0$4 = require('node:url');
|
|
6
6
|
var a = require('node:https');
|
|
7
|
-
var index = require('./index-
|
|
7
|
+
var index = require('./index-Ba32bNTL.cjs');
|
|
8
8
|
var require$$0$5 = require('tty');
|
|
9
9
|
var require$$1$2 = require('util');
|
|
10
10
|
var require$$0$c = require('assert');
|
|
@@ -32,6 +32,7 @@ require('node:readline');
|
|
|
32
32
|
require('@napi-rs/canvas');
|
|
33
33
|
require('better-sqlite3');
|
|
34
34
|
var musicSegmentDetector = require('music-segment-detector');
|
|
35
|
+
var require$$0$e = require('child_process');
|
|
35
36
|
var shazamioCore = require('shazamio-core');
|
|
36
37
|
var require$$1$6 = require('https');
|
|
37
38
|
require('node:dns');
|
|
@@ -39,7 +40,6 @@ require('constants');
|
|
|
39
40
|
require('node:fs/promises');
|
|
40
41
|
require('node:process');
|
|
41
42
|
require('node:util');
|
|
42
|
-
require('child_process');
|
|
43
43
|
require('dns');
|
|
44
44
|
require('tls');
|
|
45
45
|
require('punycode');
|
|
@@ -30748,6 +30748,21 @@ async function getRecorders(params) {
|
|
|
30748
30748
|
},
|
|
30749
30749
|
};
|
|
30750
30750
|
}
|
|
30751
|
+
/**
|
|
30752
|
+
* 正在录制中直播间数量
|
|
30753
|
+
* @param recording 是否只计算正在录制的直播间,默认为true
|
|
30754
|
+
* @returns
|
|
30755
|
+
*/
|
|
30756
|
+
function getRecorderNum(recording) {
|
|
30757
|
+
const recorderManager = exports.container.resolve("recorderManager");
|
|
30758
|
+
const recorders = recorderManager.manager.recorders;
|
|
30759
|
+
if (recording) {
|
|
30760
|
+
return recorders.filter((recorder) => recorder.recordHandle != null).length;
|
|
30761
|
+
}
|
|
30762
|
+
else {
|
|
30763
|
+
return recorders.length;
|
|
30764
|
+
}
|
|
30765
|
+
}
|
|
30751
30766
|
function getRecorder(args) {
|
|
30752
30767
|
const recorderManager = exports.container.resolve("recorderManager");
|
|
30753
30768
|
const recorder = recorderManager.manager.recorders.find((item) => item.id === args.id);
|
|
@@ -30941,6 +30956,7 @@ async function getLiveInfo(ids) {
|
|
|
30941
30956
|
}
|
|
30942
30957
|
var recorderService = {
|
|
30943
30958
|
getRecorders,
|
|
30959
|
+
getRecorderNum,
|
|
30944
30960
|
getRecorder,
|
|
30945
30961
|
addRecorder,
|
|
30946
30962
|
updateRecorder,
|
|
@@ -56596,6 +56612,18 @@ router$d.get("/appStartTime", async (ctx) => {
|
|
|
56596
56612
|
const data = index.statisticsService.query("start_time");
|
|
56597
56613
|
ctx.body = data?.value;
|
|
56598
56614
|
});
|
|
56615
|
+
router$d.get("/statistics", async (ctx) => {
|
|
56616
|
+
const startTime = index.statisticsService.query("start_time");
|
|
56617
|
+
const videoTotalDuaration = index.recordHistoryService.getTotalDuration();
|
|
56618
|
+
const recordingNum = recorderService.getRecorderNum(true);
|
|
56619
|
+
const recorderNum = recorderService.getRecorderNum(false);
|
|
56620
|
+
ctx.body = {
|
|
56621
|
+
startTime: startTime?.value || null,
|
|
56622
|
+
videoTotalDuaration,
|
|
56623
|
+
recordingNum,
|
|
56624
|
+
recorderNum,
|
|
56625
|
+
};
|
|
56626
|
+
});
|
|
56599
56627
|
router$d.get("/exportLogs", async (ctx) => {
|
|
56600
56628
|
const logFilePath = exports.config.logPath;
|
|
56601
56629
|
ctx.body = index.fs.createReadStream(logFilePath);
|
|
@@ -57034,6 +57062,47 @@ router$d.get("/tempPath", async (ctx) => {
|
|
|
57034
57062
|
ctx.body = "获取缓存路径失败";
|
|
57035
57063
|
}
|
|
57036
57064
|
});
|
|
57065
|
+
/**
|
|
57066
|
+
* @api {get} /common/diskSpace 获取磁盘空间信息
|
|
57067
|
+
* @apiDescription 获取录播姬文件夹所在磁盘的空间信息
|
|
57068
|
+
* @apiSuccess {number} total 总空间(GB)
|
|
57069
|
+
* @apiSuccess {number} free 可用空间(GB)
|
|
57070
|
+
* @apiSuccess {number} used 已用空间(GB)
|
|
57071
|
+
* @apiSuccess {number} usedPercentage 使用百分比
|
|
57072
|
+
*/
|
|
57073
|
+
router$d.get("/diskSpace", async (ctx) => {
|
|
57074
|
+
try {
|
|
57075
|
+
const config = exports.appConfig.getAll();
|
|
57076
|
+
const recoderFolder = config?.recorder?.savePath;
|
|
57077
|
+
if (!recoderFolder) {
|
|
57078
|
+
ctx.status = 400;
|
|
57079
|
+
ctx.body = "未配置文件夹路径";
|
|
57080
|
+
return;
|
|
57081
|
+
}
|
|
57082
|
+
if (!(await index.fs.pathExists(recoderFolder))) {
|
|
57083
|
+
ctx.status = 404;
|
|
57084
|
+
ctx.body = "文件夹不存在";
|
|
57085
|
+
return;
|
|
57086
|
+
}
|
|
57087
|
+
// @ts-ignore
|
|
57088
|
+
const diskInfo = await index.checkDiskSpace(recoderFolder);
|
|
57089
|
+
const totalGB = diskInfo.size / (1024 * 1024 * 1024);
|
|
57090
|
+
const freeGB = diskInfo.free / (1024 * 1024 * 1024);
|
|
57091
|
+
const usedGB = totalGB - freeGB;
|
|
57092
|
+
const usedPercentage = (usedGB / totalGB) * 100;
|
|
57093
|
+
ctx.body = {
|
|
57094
|
+
total: Number(totalGB.toFixed(2)),
|
|
57095
|
+
free: Number(freeGB.toFixed(2)),
|
|
57096
|
+
used: Number(usedGB.toFixed(2)),
|
|
57097
|
+
usedPercentage: Number(usedPercentage.toFixed(2)),
|
|
57098
|
+
};
|
|
57099
|
+
}
|
|
57100
|
+
catch (error) {
|
|
57101
|
+
console.error("获取磁盘空间失败:", error);
|
|
57102
|
+
ctx.status = 500;
|
|
57103
|
+
ctx.body = "获取磁盘空间失败";
|
|
57104
|
+
}
|
|
57105
|
+
});
|
|
57037
57106
|
|
|
57038
57107
|
const router$c = new Router$1({
|
|
57039
57108
|
prefix: "/user",
|
|
@@ -64464,6 +64533,8 @@ class AliyunASR {
|
|
|
64464
64533
|
model: this.model,
|
|
64465
64534
|
input: {
|
|
64466
64535
|
file_urls: [params.fileUrl],
|
|
64536
|
+
// qwen3-ASR-Flash-Filetrans 兼容
|
|
64537
|
+
file_url: params.fileUrl,
|
|
64467
64538
|
},
|
|
64468
64539
|
parameters: {
|
|
64469
64540
|
...(params.vocabularyId && { vocabulary_id: params.vocabularyId }),
|
|
@@ -64474,9 +64545,10 @@ class AliyunASR {
|
|
|
64474
64545
|
}),
|
|
64475
64546
|
...(params.speakerCount && { speaker_count: params.speakerCount }),
|
|
64476
64547
|
...(params.languageHints && { language_hints: params.languageHints }),
|
|
64548
|
+
// qwen3-ASR-Flash-Filetran 词级时间戳
|
|
64549
|
+
enable_words: true,
|
|
64477
64550
|
},
|
|
64478
64551
|
};
|
|
64479
|
-
this.logger.info("提交ASR任务", { fileUrl: params.fileUrl });
|
|
64480
64552
|
const response = await this.client.post("/api/v1/services/audio/asr/transcription", requestData, {
|
|
64481
64553
|
headers: {
|
|
64482
64554
|
"X-DashScope-Async": "enable",
|
|
@@ -64504,13 +64576,18 @@ class AliyunASR {
|
|
|
64504
64576
|
"X-DashScope-Async": "enable",
|
|
64505
64577
|
},
|
|
64506
64578
|
});
|
|
64579
|
+
let results = response.data.output.results || [];
|
|
64580
|
+
if (!results || results.length === 0) {
|
|
64581
|
+
// qwen3-ASR-Flash-Filetrans 兼容
|
|
64582
|
+
results = [{ ...response.data.output.result, subtask_status: "SUCCEEDED" }];
|
|
64583
|
+
}
|
|
64507
64584
|
return {
|
|
64508
64585
|
taskId: response.data.output.task_id,
|
|
64509
64586
|
taskStatus: response.data.output.task_status,
|
|
64510
64587
|
submitTime: response.data.output.submit_time,
|
|
64511
64588
|
scheduledTime: response.data.output.scheduled_time,
|
|
64512
64589
|
endTime: response.data.output.end_time,
|
|
64513
|
-
results:
|
|
64590
|
+
results: results,
|
|
64514
64591
|
};
|
|
64515
64592
|
}
|
|
64516
64593
|
catch (error) {
|
|
@@ -64682,6 +64759,454 @@ class AliyunASR {
|
|
|
64682
64759
|
}
|
|
64683
64760
|
}
|
|
64684
64761
|
|
|
64762
|
+
/**
|
|
64763
|
+
* OpenAI Whisper ASR 语音识别类
|
|
64764
|
+
* 使用 OpenAI Whisper API 进行录音文件识别
|
|
64765
|
+
*/
|
|
64766
|
+
class OpenAIWhisperASR {
|
|
64767
|
+
client;
|
|
64768
|
+
model;
|
|
64769
|
+
logger;
|
|
64770
|
+
/**
|
|
64771
|
+
* 创建 OpenAI Whisper ASR 实例
|
|
64772
|
+
*/
|
|
64773
|
+
constructor(options) {
|
|
64774
|
+
const { apiKey, baseURL = "https://api.openai.com/v1", model = "whisper-1" } = options;
|
|
64775
|
+
this.model = model;
|
|
64776
|
+
this.logger = options.logger || index.logObj;
|
|
64777
|
+
this.client = index.axios.create({
|
|
64778
|
+
baseURL,
|
|
64779
|
+
headers: {
|
|
64780
|
+
Authorization: `Bearer ${apiKey}`,
|
|
64781
|
+
},
|
|
64782
|
+
timeout: 300000, // 5分钟超时
|
|
64783
|
+
});
|
|
64784
|
+
}
|
|
64785
|
+
/**
|
|
64786
|
+
* 识别音频文件(通过文件URL)
|
|
64787
|
+
* 注意:OpenAI Whisper API 不支持直接传URL,需要先下载文件
|
|
64788
|
+
*
|
|
64789
|
+
* @param fileUrl 音频文件URL
|
|
64790
|
+
* @returns 转写结果
|
|
64791
|
+
*/
|
|
64792
|
+
async recognize(fileUrl) {
|
|
64793
|
+
this.logger.info(`开始下载文件: ${fileUrl}`);
|
|
64794
|
+
// 下载文件到临时位置
|
|
64795
|
+
const response = await index.axios.get(fileUrl, {
|
|
64796
|
+
responseType: "arraybuffer",
|
|
64797
|
+
});
|
|
64798
|
+
const tempFilePath = `/tmp/whisper-temp-${Date.now()}.audio`;
|
|
64799
|
+
await index.fs.writeFile(tempFilePath, response.data);
|
|
64800
|
+
try {
|
|
64801
|
+
const result = await this.recognizeLocalFile(tempFilePath);
|
|
64802
|
+
return result;
|
|
64803
|
+
}
|
|
64804
|
+
finally {
|
|
64805
|
+
// 清理临时文件
|
|
64806
|
+
await index.fs.remove(tempFilePath).catch((err) => {
|
|
64807
|
+
this.logger.warn(`清理临时文件失败: ${err.message}`);
|
|
64808
|
+
});
|
|
64809
|
+
}
|
|
64810
|
+
}
|
|
64811
|
+
/**
|
|
64812
|
+
* 识别本地音频文件
|
|
64813
|
+
*
|
|
64814
|
+
* @param filePath 本地音频文件路径
|
|
64815
|
+
* @param options 可选参数
|
|
64816
|
+
* @returns 转写结果
|
|
64817
|
+
*/
|
|
64818
|
+
async recognizeLocalFile(filePath, options) {
|
|
64819
|
+
this.logger.info(`开始识别本地文件: ${filePath}`);
|
|
64820
|
+
// 检查文件是否存在
|
|
64821
|
+
if (!(await index.fs.pathExists(filePath))) {
|
|
64822
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
64823
|
+
}
|
|
64824
|
+
// 创建 FormData
|
|
64825
|
+
const formData = new index.FormData();
|
|
64826
|
+
formData.append("file", index.fs.createReadStream(filePath));
|
|
64827
|
+
formData.append("model", this.model);
|
|
64828
|
+
formData.append("response_format", "verbose_json"); // 使用 verbose_json 获取时间戳
|
|
64829
|
+
if (options?.prompt) {
|
|
64830
|
+
formData.append("prompt", options.prompt);
|
|
64831
|
+
}
|
|
64832
|
+
if (options?.language) {
|
|
64833
|
+
formData.append("language", options.language);
|
|
64834
|
+
}
|
|
64835
|
+
if (options?.temperature !== undefined) {
|
|
64836
|
+
formData.append("temperature", options.temperature.toString());
|
|
64837
|
+
}
|
|
64838
|
+
try {
|
|
64839
|
+
const response = await this.client.post("/audio/transcriptions", formData, {
|
|
64840
|
+
headers: {
|
|
64841
|
+
...formData.getHeaders(),
|
|
64842
|
+
},
|
|
64843
|
+
});
|
|
64844
|
+
this.logger.info(`识别完成,文本长度: ${response.data.text.length}`);
|
|
64845
|
+
return response.data;
|
|
64846
|
+
}
|
|
64847
|
+
catch (error) {
|
|
64848
|
+
const errorMessage = error.response?.data?.error?.message || error.message || "未知错误";
|
|
64849
|
+
this.logger.error(`OpenAI Whisper 识别失败: ${errorMessage}`);
|
|
64850
|
+
throw new Error(`OpenAI Whisper 识别失败: ${errorMessage}`);
|
|
64851
|
+
}
|
|
64852
|
+
}
|
|
64853
|
+
}
|
|
64854
|
+
|
|
64855
|
+
/**
|
|
64856
|
+
* FFmpeg Whisper ASR 实现
|
|
64857
|
+
* 使用 spawn 直接调用 ffmpeg 的 whisper 滤镜
|
|
64858
|
+
*/
|
|
64859
|
+
class FFmpegWhisperASR {
|
|
64860
|
+
options;
|
|
64861
|
+
constructor(options) {
|
|
64862
|
+
this.options = {
|
|
64863
|
+
logger: index.logObj,
|
|
64864
|
+
language: "zh",
|
|
64865
|
+
queue: 20,
|
|
64866
|
+
...options,
|
|
64867
|
+
};
|
|
64868
|
+
// 验证 ffmpeg 路径
|
|
64869
|
+
if (!index.fs.existsSync(this.options.ffmpegPath)) {
|
|
64870
|
+
throw new Error(`FFmpeg 路径不存在: ${this.options.ffmpegPath}`);
|
|
64871
|
+
}
|
|
64872
|
+
// 验证模型文件路径
|
|
64873
|
+
if (!index.fs.existsSync(this.options.model)) {
|
|
64874
|
+
throw new Error(`Whisper 模型文件不存在: ${this.options.model}`);
|
|
64875
|
+
}
|
|
64876
|
+
}
|
|
64877
|
+
/**
|
|
64878
|
+
* 识别本地音频文件
|
|
64879
|
+
* @param filePath 本地文件路径
|
|
64880
|
+
* @returns Whisper 转写结果
|
|
64881
|
+
*/
|
|
64882
|
+
async recognizeLocalFile(filePath) {
|
|
64883
|
+
if (!index.fs.existsSync(filePath)) {
|
|
64884
|
+
throw new Error(`音频文件不存在: ${filePath}`);
|
|
64885
|
+
}
|
|
64886
|
+
// 创建临时目录存放结果
|
|
64887
|
+
const tempDir = require$$0$7.join(require$$1$5.tmpdir(), "whisper-asr");
|
|
64888
|
+
await index.fs.ensureDir(tempDir);
|
|
64889
|
+
// 生成唯一的输出文件名
|
|
64890
|
+
const outputFile = require$$0$7.join(tempDir, `${v4()}.json`);
|
|
64891
|
+
try {
|
|
64892
|
+
// 执行 ffmpeg 命令
|
|
64893
|
+
await this.executeFFmpeg(filePath, outputFile);
|
|
64894
|
+
// 读取结果文件
|
|
64895
|
+
if (!index.fs.existsSync(outputFile)) {
|
|
64896
|
+
throw new Error(`Whisper 未生成结果文件: ${outputFile}`);
|
|
64897
|
+
}
|
|
64898
|
+
// 读取 JSONL 文件(每行一个 JSON 对象)
|
|
64899
|
+
const resultContent = await index.fs.readFile(outputFile, "utf-8");
|
|
64900
|
+
const segments = resultContent
|
|
64901
|
+
.split("\n")
|
|
64902
|
+
.filter((line) => line.trim())
|
|
64903
|
+
.map((line) => {
|
|
64904
|
+
try {
|
|
64905
|
+
return JSON.parse(line);
|
|
64906
|
+
}
|
|
64907
|
+
catch (error) {
|
|
64908
|
+
this.options.logger.warn(`解析 JSONL 行失败: ${line}`);
|
|
64909
|
+
return null;
|
|
64910
|
+
}
|
|
64911
|
+
})
|
|
64912
|
+
.filter((seg) => seg !== null);
|
|
64913
|
+
this.options.logger.info(`Whisper 识别完成: ${filePath}, 共 ${segments.length} 个片段`);
|
|
64914
|
+
return { segments };
|
|
64915
|
+
}
|
|
64916
|
+
catch (error) {
|
|
64917
|
+
this.options.logger.error(`Whisper 识别失败: ${error}`);
|
|
64918
|
+
throw error;
|
|
64919
|
+
}
|
|
64920
|
+
finally {
|
|
64921
|
+
console.log("清理临时文件:", outputFile);
|
|
64922
|
+
// 清理临时文件
|
|
64923
|
+
// if (fs.existsSync(outputFile)) {
|
|
64924
|
+
// await fs.remove(outputFile);
|
|
64925
|
+
// }
|
|
64926
|
+
}
|
|
64927
|
+
}
|
|
64928
|
+
/**
|
|
64929
|
+
* 执行 ffmpeg whisper 命令
|
|
64930
|
+
* @param inputFile 输入音频文件
|
|
64931
|
+
* @param outputFile 输出 JSON 文件
|
|
64932
|
+
*/
|
|
64933
|
+
async executeFFmpeg(inputFile, outputFile) {
|
|
64934
|
+
return new Promise((resolve, reject) => {
|
|
64935
|
+
// 构建 whisper 滤镜参数(使用 jsonl 格式)
|
|
64936
|
+
const whisperFilter = `whisper=model=${index.escaped(this.options.model)}:language=${index.escaped(this.options.language)}:destination=${index.escaped(outputFile)}:format=json:queue=${this.options.queue}`;
|
|
64937
|
+
// ffmpeg 命令参数
|
|
64938
|
+
const args = [
|
|
64939
|
+
"-i",
|
|
64940
|
+
inputFile,
|
|
64941
|
+
"-af",
|
|
64942
|
+
whisperFilter,
|
|
64943
|
+
"-f",
|
|
64944
|
+
"null",
|
|
64945
|
+
"-", // 输出到 null
|
|
64946
|
+
];
|
|
64947
|
+
this.options.logger.info(`执行 FFmpeg Whisper 命令: ${this.options.ffmpegPath} ${args.join(" ")}`);
|
|
64948
|
+
// 使用 spawn 执行命令
|
|
64949
|
+
const process = require$$0$e.spawn(this.options.ffmpegPath, args);
|
|
64950
|
+
let stderr = "";
|
|
64951
|
+
// 收集标准错误输出(ffmpeg 的日志)
|
|
64952
|
+
process.stderr.on("data", (data) => {
|
|
64953
|
+
const text = data.toString();
|
|
64954
|
+
stderr += text;
|
|
64955
|
+
// this.options.logger.debug(`FFmpeg stderr: ${text}`);
|
|
64956
|
+
});
|
|
64957
|
+
// 处理进程退出
|
|
64958
|
+
process.on("close", (code) => {
|
|
64959
|
+
if (code === 0) {
|
|
64960
|
+
this.options.logger.info("FFmpeg Whisper 执行成功");
|
|
64961
|
+
resolve();
|
|
64962
|
+
}
|
|
64963
|
+
else {
|
|
64964
|
+
this.options.logger.error(`FFmpeg 执行失败,退出码: ${code}`);
|
|
64965
|
+
this.options.logger.error(`错误信息: ${stderr}`);
|
|
64966
|
+
reject(new Error(`FFmpeg 执行失败,退出码: ${code}\n${stderr}`));
|
|
64967
|
+
}
|
|
64968
|
+
});
|
|
64969
|
+
// 处理错误
|
|
64970
|
+
process.on("error", (error) => {
|
|
64971
|
+
this.options.logger.error(`FFmpeg 进程错误: ${error}`);
|
|
64972
|
+
reject(error);
|
|
64973
|
+
});
|
|
64974
|
+
});
|
|
64975
|
+
}
|
|
64976
|
+
/**
|
|
64977
|
+
* 识别音频 URL(不支持)
|
|
64978
|
+
* 由于 ffmpeg whisper 需要本地文件,URL 需要先下载
|
|
64979
|
+
*/
|
|
64980
|
+
async recognize(_fileUrl) {
|
|
64981
|
+
throw new Error("FFmpeg Whisper 不支持直接识别 URL,请先下载音频文件后使用 recognizeLocalFile");
|
|
64982
|
+
}
|
|
64983
|
+
}
|
|
64984
|
+
|
|
64985
|
+
/**
|
|
64986
|
+
* 获取模型参数
|
|
64987
|
+
* @returns 模型参数对象
|
|
64988
|
+
*/
|
|
64989
|
+
function getModel(modelId, iConfig) {
|
|
64990
|
+
if (!modelId) {
|
|
64991
|
+
throw new Error("模型ID未定义");
|
|
64992
|
+
}
|
|
64993
|
+
let config = iConfig;
|
|
64994
|
+
if (!config) {
|
|
64995
|
+
config = index.appConfig.getAll();
|
|
64996
|
+
}
|
|
64997
|
+
const model = config.ai.models.find((m) => m.modelId === modelId);
|
|
64998
|
+
if (!model) {
|
|
64999
|
+
throw new Error(`找不到模型:${modelId}`);
|
|
65000
|
+
}
|
|
65001
|
+
return model;
|
|
65002
|
+
}
|
|
65003
|
+
|
|
65004
|
+
/**
|
|
65005
|
+
* 阿里云 ASR 适配器
|
|
65006
|
+
*/
|
|
65007
|
+
class AliyunASRAdapter {
|
|
65008
|
+
client;
|
|
65009
|
+
constructor(config) {
|
|
65010
|
+
this.client = new AliyunASR({
|
|
65011
|
+
apiKey: config.apiKey,
|
|
65012
|
+
baseURL: config.baseURL,
|
|
65013
|
+
model: config.model,
|
|
65014
|
+
logger: index.logObj,
|
|
65015
|
+
});
|
|
65016
|
+
}
|
|
65017
|
+
async recognize(fileUrl) {
|
|
65018
|
+
const result = await this.client.recognize(fileUrl);
|
|
65019
|
+
return this.transformAliyunResult(result);
|
|
65020
|
+
}
|
|
65021
|
+
async recognizeLocalFile(filePath) {
|
|
65022
|
+
const result = await this.client.recognizeLocalFile(filePath);
|
|
65023
|
+
return this.transformAliyunResult(result);
|
|
65024
|
+
}
|
|
65025
|
+
/**
|
|
65026
|
+
* 转换阿里云格式为标准格式
|
|
65027
|
+
*/
|
|
65028
|
+
transformAliyunResult(result) {
|
|
65029
|
+
const transcript = result.transcripts?.[0];
|
|
65030
|
+
if (!transcript) {
|
|
65031
|
+
throw new Error("阿里云 ASR 返回结果为空");
|
|
65032
|
+
}
|
|
65033
|
+
const segments = [];
|
|
65034
|
+
const words = [];
|
|
65035
|
+
// 转换句子级别数据
|
|
65036
|
+
transcript.sentences?.forEach((sentence, index) => {
|
|
65037
|
+
segments.push({
|
|
65038
|
+
id: index,
|
|
65039
|
+
start: sentence.begin_time / 1000, // 毫秒转秒
|
|
65040
|
+
end: sentence.end_time / 1000,
|
|
65041
|
+
text: sentence.text,
|
|
65042
|
+
speaker: sentence.speakerId?.toString(),
|
|
65043
|
+
});
|
|
65044
|
+
// 转换词级别数据
|
|
65045
|
+
sentence.words?.forEach((word) => {
|
|
65046
|
+
words.push({
|
|
65047
|
+
start: word.begin_time / 1000,
|
|
65048
|
+
end: word.end_time / 1000,
|
|
65049
|
+
word: word.text,
|
|
65050
|
+
punctuation: word.punctuation,
|
|
65051
|
+
speaker: word.speakerId?.toString(),
|
|
65052
|
+
});
|
|
65053
|
+
});
|
|
65054
|
+
});
|
|
65055
|
+
return {
|
|
65056
|
+
text: transcript.text,
|
|
65057
|
+
duration: result.properties.original_duration_in_milliseconds
|
|
65058
|
+
? result.properties.original_duration_in_milliseconds / 1000
|
|
65059
|
+
: undefined,
|
|
65060
|
+
language: undefined, // 阿里云不返回语言信息
|
|
65061
|
+
segments,
|
|
65062
|
+
words: words.length > 0 ? words : undefined,
|
|
65063
|
+
};
|
|
65064
|
+
}
|
|
65065
|
+
}
|
|
65066
|
+
/**
|
|
65067
|
+
* OpenAI Whisper ASR 适配器
|
|
65068
|
+
*/
|
|
65069
|
+
class OpenAIASRAdapter {
|
|
65070
|
+
client;
|
|
65071
|
+
constructor(config) {
|
|
65072
|
+
this.client = new OpenAIWhisperASR({
|
|
65073
|
+
apiKey: config.apiKey,
|
|
65074
|
+
baseURL: config.baseURL,
|
|
65075
|
+
model: config.model,
|
|
65076
|
+
logger: index.logObj,
|
|
65077
|
+
});
|
|
65078
|
+
}
|
|
65079
|
+
async recognize(fileUrl) {
|
|
65080
|
+
const result = await this.client.recognize(fileUrl);
|
|
65081
|
+
return this.transformOpenAIResult(result);
|
|
65082
|
+
}
|
|
65083
|
+
async recognizeLocalFile(filePath) {
|
|
65084
|
+
const result = await this.client.recognizeLocalFile(filePath);
|
|
65085
|
+
return this.transformOpenAIResult(result);
|
|
65086
|
+
}
|
|
65087
|
+
/**
|
|
65088
|
+
* 转换 OpenAI Whisper 格式为标准格式
|
|
65089
|
+
*/
|
|
65090
|
+
transformOpenAIResult(result) {
|
|
65091
|
+
const segments = result.segments.map((segment) => ({
|
|
65092
|
+
id: segment.id,
|
|
65093
|
+
start: segment.start,
|
|
65094
|
+
end: segment.end,
|
|
65095
|
+
text: segment.text.trim(),
|
|
65096
|
+
speaker: undefined, // Whisper 不支持说话人分离
|
|
65097
|
+
}));
|
|
65098
|
+
return {
|
|
65099
|
+
text: result.text,
|
|
65100
|
+
duration: result.duration,
|
|
65101
|
+
language: result.language,
|
|
65102
|
+
segments,
|
|
65103
|
+
words: undefined, // Whisper API 不返回词级别时间戳
|
|
65104
|
+
};
|
|
65105
|
+
}
|
|
65106
|
+
}
|
|
65107
|
+
/**
|
|
65108
|
+
* FFmpeg Whisper ASR 适配器
|
|
65109
|
+
*/
|
|
65110
|
+
class FFmpegWhisperASRAdapter {
|
|
65111
|
+
client;
|
|
65112
|
+
constructor(config) {
|
|
65113
|
+
this.client = new FFmpegWhisperASR({
|
|
65114
|
+
ffmpegPath: config.ffmpegPath,
|
|
65115
|
+
model: config.model,
|
|
65116
|
+
language: config.language || "zh",
|
|
65117
|
+
queue: config.queue || 20,
|
|
65118
|
+
logger: index.logObj,
|
|
65119
|
+
});
|
|
65120
|
+
}
|
|
65121
|
+
async recognize() {
|
|
65122
|
+
throw new Error("FFmpeg Whisper 不支持直接识别 URL,请使用 recognizeLocalFile");
|
|
65123
|
+
}
|
|
65124
|
+
async recognizeLocalFile(filePath) {
|
|
65125
|
+
const result = await this.client.recognizeLocalFile(filePath);
|
|
65126
|
+
return this.transformWhisperResult(result);
|
|
65127
|
+
}
|
|
65128
|
+
/**
|
|
65129
|
+
* 转换 FFmpeg Whisper 格式为标准格式
|
|
65130
|
+
*/
|
|
65131
|
+
transformWhisperResult(result) {
|
|
65132
|
+
if (!result.segments || result.segments.length === 0) {
|
|
65133
|
+
throw new Error("Whisper 返回结果为空");
|
|
65134
|
+
}
|
|
65135
|
+
// 转换段落格式(毫秒转秒)
|
|
65136
|
+
const segments = result.segments.map((segment, index) => ({
|
|
65137
|
+
id: index,
|
|
65138
|
+
start: segment.start / 1000, // 毫秒转秒
|
|
65139
|
+
end: segment.end / 1000, // 毫秒转秒
|
|
65140
|
+
text: segment.text,
|
|
65141
|
+
}));
|
|
65142
|
+
// 构建完整文本
|
|
65143
|
+
const text = segments.map((s) => s.text).join("");
|
|
65144
|
+
// 计算总时长
|
|
65145
|
+
const duration = segments.length > 0 ? segments[segments.length - 1].end : 0;
|
|
65146
|
+
return {
|
|
65147
|
+
text,
|
|
65148
|
+
duration,
|
|
65149
|
+
language: undefined, // Whisper 在 JSONL 输出中不包含语言信息
|
|
65150
|
+
segments,
|
|
65151
|
+
words: undefined, // JSONL 格式不提供词级别时间戳
|
|
65152
|
+
};
|
|
65153
|
+
}
|
|
65154
|
+
}
|
|
65155
|
+
/**
|
|
65156
|
+
* 获取供应商配置
|
|
65157
|
+
*/
|
|
65158
|
+
function getVendor$1(vendorId) {
|
|
65159
|
+
const data = index.appConfig.get("ai") || {};
|
|
65160
|
+
const vendor = data.vendors.find((v) => v.id === vendorId);
|
|
65161
|
+
if (!vendor) {
|
|
65162
|
+
throw new Error(`未找到 ID 为 ${vendorId} 的供应商配置`);
|
|
65163
|
+
}
|
|
65164
|
+
return vendor;
|
|
65165
|
+
}
|
|
65166
|
+
/**
|
|
65167
|
+
* 创建 ASR 提供商实例
|
|
65168
|
+
* @param modelId 模型 ID
|
|
65169
|
+
* @returns ASR 提供商实例
|
|
65170
|
+
*/
|
|
65171
|
+
function createASRProvider(modelId) {
|
|
65172
|
+
// 获取模型配置
|
|
65173
|
+
const model = getModel(modelId);
|
|
65174
|
+
// 获取供应商配置
|
|
65175
|
+
const vendor = getVendor$1(model.vendorId);
|
|
65176
|
+
// 根据 provider 类型创建对应的适配器
|
|
65177
|
+
const config = {
|
|
65178
|
+
apiKey: vendor.apiKey,
|
|
65179
|
+
baseURL: vendor.baseURL,
|
|
65180
|
+
model: model.modelName,
|
|
65181
|
+
};
|
|
65182
|
+
switch (vendor.provider) {
|
|
65183
|
+
case "aliyun":
|
|
65184
|
+
return new AliyunASRAdapter(config);
|
|
65185
|
+
case "openai":
|
|
65186
|
+
return new OpenAIASRAdapter(config);
|
|
65187
|
+
case "ffmpeg":
|
|
65188
|
+
if (!vendor.baseURL) {
|
|
65189
|
+
throw new Error("FFmpeg provider 需要配置 baseURL(ffmpeg 执行文件路径)");
|
|
65190
|
+
}
|
|
65191
|
+
return new FFmpegWhisperASRAdapter({
|
|
65192
|
+
ffmpegPath: vendor.baseURL, // baseURL 存储 ffmpeg 路径
|
|
65193
|
+
model: model.modelName, // modelName 存储模型文件路径
|
|
65194
|
+
});
|
|
65195
|
+
default:
|
|
65196
|
+
throw new Error(`不支持的 ASR 提供商: ${vendor.provider}`);
|
|
65197
|
+
}
|
|
65198
|
+
}
|
|
65199
|
+
/**
|
|
65200
|
+
* 识别音频文件(返回标准格式)
|
|
65201
|
+
* @param file 音频文件
|
|
65202
|
+
* @param modelId 模型id
|
|
65203
|
+
* @returns
|
|
65204
|
+
*/
|
|
65205
|
+
function recognize$1(file, modelId) {
|
|
65206
|
+
const asrProvider = createASRProvider(modelId);
|
|
65207
|
+
return asrProvider.recognizeLocalFile(file);
|
|
65208
|
+
}
|
|
65209
|
+
|
|
64685
65210
|
const default_format = 'RFC3986';
|
|
64686
65211
|
const formatters = {
|
|
64687
65212
|
RFC1738: (v) => String(v).replace(/%20/g, '+'),
|
|
@@ -75823,131 +76348,18 @@ async function musicDetect(videoPath, iConfig, onProgress) {
|
|
|
75823
76348
|
onProgress?.({ stage: "complete", percentage: 100, message: "分析完成" });
|
|
75824
76349
|
return segments;
|
|
75825
76350
|
}
|
|
75826
|
-
/**
|
|
75827
|
-
* 音乐字幕优化
|
|
75828
|
-
*/
|
|
75829
|
-
function optimizeMusicSubtitles(results) {
|
|
75830
|
-
// 去除","、“。”等标点符号
|
|
75831
|
-
const transcripts = results.transcripts?.map((transcript) => {
|
|
75832
|
-
const optimizedSentences = transcript.sentences?.map((sentence) => {
|
|
75833
|
-
let optimizedText = sentence.text.replace(/[,。、“”‘’!?]/g, " ");
|
|
75834
|
-
return {
|
|
75835
|
-
...sentence,
|
|
75836
|
-
text: optimizedText,
|
|
75837
|
-
};
|
|
75838
|
-
});
|
|
75839
|
-
return {
|
|
75840
|
-
...transcript,
|
|
75841
|
-
sentences: optimizedSentences,
|
|
75842
|
-
};
|
|
75843
|
-
});
|
|
75844
|
-
return {
|
|
75845
|
-
...results,
|
|
75846
|
-
transcripts: transcripts,
|
|
75847
|
-
};
|
|
75848
|
-
}
|
|
75849
76351
|
function convert2Srt(detail, offset) {
|
|
75850
|
-
|
|
75851
|
-
|
|
75852
|
-
|
|
75853
|
-
|
|
75854
|
-
|
|
75855
|
-
|
|
75856
|
-
|
|
75857
|
-
|
|
75858
|
-
|
|
75859
|
-
|
|
75860
|
-
|
|
75861
|
-
.replace(".", ",");
|
|
75862
|
-
srt += `${index}\n${start} --> ${end}\n${sentence.text}\n\n`;
|
|
75863
|
-
index++;
|
|
75864
|
-
}
|
|
75865
|
-
}
|
|
75866
|
-
return srt;
|
|
75867
|
-
}
|
|
75868
|
-
function convert2Srt2(detail, offset) {
|
|
75869
|
-
let srt = "";
|
|
75870
|
-
let index = 1;
|
|
75871
|
-
for (const sentence of detail || []) {
|
|
75872
|
-
const start = new Date(sentence.st + offset).toISOString().substr(11, 12).replace(".", ",");
|
|
75873
|
-
const end = new Date(sentence.et + offset).toISOString().substr(11, 12).replace(".", ",");
|
|
75874
|
-
const text = (sentence.t || "").replace(/[,。、“”‘’!?]/g, " ");
|
|
75875
|
-
srt += `${index}\n${start} --> ${end}\n${text}\n\n`;
|
|
75876
|
-
index++;
|
|
75877
|
-
}
|
|
75878
|
-
return srt;
|
|
75879
|
-
}
|
|
75880
|
-
/**
|
|
75881
|
-
* 获取 AI 服务商的 API Key,现在是随便写,只支持阿里
|
|
75882
|
-
* @returns
|
|
75883
|
-
*/
|
|
75884
|
-
function getApiKey() {
|
|
75885
|
-
const data = index.appConfig.get("ai") || {};
|
|
75886
|
-
if (data?.vendors.length === 0) {
|
|
75887
|
-
throw new Error("请先在配置中设置 AI 服务商的 API Key");
|
|
75888
|
-
}
|
|
75889
|
-
return data.vendors[0].apiKey || "";
|
|
75890
|
-
}
|
|
75891
|
-
/**
|
|
75892
|
-
* 音频识别
|
|
75893
|
-
* @param file
|
|
75894
|
-
* @param key
|
|
75895
|
-
* @returns
|
|
75896
|
-
*/
|
|
75897
|
-
async function asrRecognize(file, vendorId) {
|
|
75898
|
-
const { apiKey } = getVendor(vendorId);
|
|
75899
|
-
const asr = new AliyunASR({
|
|
75900
|
-
apiKey: apiKey,
|
|
75901
|
-
});
|
|
75902
|
-
try {
|
|
75903
|
-
const results = await asr.recognizeLocalFile(file);
|
|
75904
|
-
// console.log("识别结果:", results.transcripts?.[0]);
|
|
75905
|
-
// console.log("已将字幕保存到 output.srt 文件");
|
|
75906
|
-
return results;
|
|
75907
|
-
}
|
|
75908
|
-
catch (error) {
|
|
75909
|
-
index.logObj.error("ASR 识别失败:", error);
|
|
75910
|
-
throw error;
|
|
75911
|
-
}
|
|
75912
|
-
}
|
|
75913
|
-
/**
|
|
75914
|
-
* 使用通义千问 LLM 示例
|
|
75915
|
-
*/
|
|
75916
|
-
async function llm(message, systemPrompt, opts = {}) {
|
|
75917
|
-
console.log("=== 示例: 使用通义千问 LLM ===");
|
|
75918
|
-
const apiKey = opts.key ?? getApiKey();
|
|
75919
|
-
const llm = new QwenLLM({
|
|
75920
|
-
apiKey: apiKey,
|
|
75921
|
-
model: "qwen-plus",
|
|
75922
|
-
});
|
|
75923
|
-
try {
|
|
75924
|
-
// const testData = fs.readFileSync(
|
|
75925
|
-
// "C:\\Users\\renmu\\Downloads\\新建文件夹 (2)\\cleaned_data.json",
|
|
75926
|
-
// );
|
|
75927
|
-
// console.log("读取测试数据,长度:", message + testData, testData.length);
|
|
75928
|
-
const response = await llm.sendMessage(message, systemPrompt, {
|
|
75929
|
-
// responseFormat: zodResponseFormat(Song, "song"),
|
|
75930
|
-
enableSearch: opts.enableSearch ?? false,
|
|
75931
|
-
responseFormat: opts.jsonResponse ? { type: "json_object" } : undefined,
|
|
75932
|
-
// @ts-ignore
|
|
75933
|
-
stream: opts.stream ?? undefined,
|
|
75934
|
-
searchOptions: {
|
|
75935
|
-
forcedSearch: opts.enableSearch ?? false,
|
|
75936
|
-
},
|
|
75937
|
-
});
|
|
75938
|
-
if ("content" in response) {
|
|
75939
|
-
index.logObj.info("LLM 请求成功", JSON.stringify(response));
|
|
75940
|
-
// console.log("提问:", response);
|
|
75941
|
-
// console.log("回复:", response.content);
|
|
75942
|
-
// console.log("Token 使用:", response.usage);
|
|
75943
|
-
return response;
|
|
75944
|
-
}
|
|
75945
|
-
throw new Error("LLM 未返回预期的响应内容");
|
|
75946
|
-
}
|
|
75947
|
-
catch (error) {
|
|
75948
|
-
console.error("LLM 请求失败:", error);
|
|
75949
|
-
throw error;
|
|
75950
|
-
}
|
|
76352
|
+
const srtNodes = detail
|
|
76353
|
+
.filter((word) => word.t.trim() !== "")
|
|
76354
|
+
.map((word) => ({
|
|
76355
|
+
type: "cue",
|
|
76356
|
+
data: {
|
|
76357
|
+
start: word.st + offset,
|
|
76358
|
+
end: word.et + offset,
|
|
76359
|
+
text: word.t,
|
|
76360
|
+
},
|
|
76361
|
+
}));
|
|
76362
|
+
return stringifySync(srtNodes, { format: "SRT" });
|
|
75951
76363
|
}
|
|
75952
76364
|
function getVendor(vendorId) {
|
|
75953
76365
|
const data = index.appConfig.get("ai") || {};
|
|
@@ -75957,22 +76369,11 @@ function getVendor(vendorId) {
|
|
|
75957
76369
|
}
|
|
75958
76370
|
return vendor;
|
|
75959
76371
|
}
|
|
75960
|
-
/**
|
|
75961
|
-
* 获取歌词优化配置
|
|
75962
|
-
*/
|
|
75963
|
-
function getLyricOptimizeConfig() {
|
|
75964
|
-
const { data, llmVendorId, llmModel } = getSongRecognizeConfig();
|
|
75965
|
-
return {
|
|
75966
|
-
vendorId: data?.songLyricOptimize?.vendorId || llmVendorId,
|
|
75967
|
-
prompt: data?.songLyricOptimize?.prompt,
|
|
75968
|
-
model: data?.songLyricOptimize?.model || llmModel,
|
|
75969
|
-
enableStructuredOutput: data?.songLyricOptimize?.enableStructuredOutput ?? true,
|
|
75970
|
-
};
|
|
75971
|
-
}
|
|
75972
76372
|
/**
|
|
75973
76373
|
* 歌词优化
|
|
75974
76374
|
* @param lyrics - 原始歌词文本
|
|
75975
76375
|
* @param offset - 偏移时间,单位毫秒
|
|
76376
|
+
* @returns 优化后的 ASRWord 数组
|
|
75976
76377
|
*/
|
|
75977
76378
|
async function optimizeLyrics(asrData, lyrics, offset) {
|
|
75978
76379
|
const { vendorId, prompt, model, enableStructuredOutput } = getLyricOptimizeConfig();
|
|
@@ -75983,15 +76384,24 @@ async function optimizeLyrics(asrData, lyrics, offset) {
|
|
|
75983
76384
|
baseURL: baseURL,
|
|
75984
76385
|
});
|
|
75985
76386
|
const asrCleanedSentences = [];
|
|
75986
|
-
|
|
75987
|
-
|
|
75988
|
-
|
|
75989
|
-
|
|
75990
|
-
|
|
75991
|
-
|
|
75992
|
-
|
|
75993
|
-
|
|
75994
|
-
|
|
76387
|
+
if (asrData.words && asrData.words.length !== 0) {
|
|
76388
|
+
// 优先使用词级时间戳
|
|
76389
|
+
for (const word of asrData.words) {
|
|
76390
|
+
asrCleanedSentences.push({
|
|
76391
|
+
st: word.start * 1000, // 秒转毫秒
|
|
76392
|
+
et: word.end * 1000,
|
|
76393
|
+
t: word.word,
|
|
76394
|
+
});
|
|
76395
|
+
}
|
|
76396
|
+
}
|
|
76397
|
+
if (asrCleanedSentences.length === 0) {
|
|
76398
|
+
// 回退到分段级时间戳
|
|
76399
|
+
for (const segment of asrData.segments) {
|
|
76400
|
+
asrCleanedSentences.push({
|
|
76401
|
+
st: segment.start * 1000, // 秒转毫秒
|
|
76402
|
+
et: segment.end * 1000,
|
|
76403
|
+
t: segment.text,
|
|
76404
|
+
});
|
|
75995
76405
|
}
|
|
75996
76406
|
}
|
|
75997
76407
|
index.logObj.info("使用 LLM 进行歌词优化...", {
|
|
@@ -76011,8 +76421,13 @@ async function optimizeLyrics(asrData, lyrics, offset) {
|
|
|
76011
76421
|
try {
|
|
76012
76422
|
const json = JSON.parse(response.content);
|
|
76013
76423
|
const aSRWords = Array.isArray(json) ? json : json.data;
|
|
76014
|
-
|
|
76015
|
-
|
|
76424
|
+
// 应用偏移时间
|
|
76425
|
+
const wordsWithOffset = aSRWords.map((word) => ({
|
|
76426
|
+
st: word.st + offset,
|
|
76427
|
+
et: word.et + offset,
|
|
76428
|
+
t: word.t,
|
|
76429
|
+
}));
|
|
76430
|
+
return wordsWithOffset;
|
|
76016
76431
|
}
|
|
76017
76432
|
catch (e) {
|
|
76018
76433
|
index.logObj.error("LLM 返回内容非 JSON 格式,尝试按纯文本处理", e);
|
|
@@ -76023,30 +76438,48 @@ async function optimizeLyrics(asrData, lyrics, offset) {
|
|
|
76023
76438
|
* 获取歌曲识别配置
|
|
76024
76439
|
*/
|
|
76025
76440
|
function getSongRecognizeConfig() {
|
|
76026
|
-
const
|
|
76027
|
-
|
|
76028
|
-
|
|
76029
|
-
|
|
76030
|
-
|
|
76031
|
-
if (!asrVendorId) {
|
|
76032
|
-
throw new Error("请先在配置中设置 阿里云 ASR 服务商的 API Key");
|
|
76441
|
+
const config = index.appConfig.getAll();
|
|
76442
|
+
const data = config.ai;
|
|
76443
|
+
const asrModelId = data.songRecognizeAsr?.modelId;
|
|
76444
|
+
if (!asrModelId) {
|
|
76445
|
+
throw new Error("请先在配置中设置歌曲识别 ASR 模型");
|
|
76033
76446
|
}
|
|
76034
|
-
|
|
76035
|
-
|
|
76036
|
-
|
|
76447
|
+
const llmModel = getModel(data?.songRecognizeLlm?.modelId, config);
|
|
76448
|
+
const llmVendor = data.vendors.find((v) => v.id === llmModel.vendorId);
|
|
76449
|
+
if (!llmVendor) {
|
|
76450
|
+
throw new Error("找不到LLM模型关联的供应商");
|
|
76037
76451
|
}
|
|
76038
76452
|
return {
|
|
76039
76453
|
data,
|
|
76040
|
-
|
|
76041
|
-
llmVendorId,
|
|
76454
|
+
asrModelId,
|
|
76455
|
+
llmVendorId: llmVendor.id,
|
|
76456
|
+
llmModel: llmModel.modelName,
|
|
76042
76457
|
llmPrompt: data?.songRecognizeLlm?.prompt,
|
|
76043
|
-
llmModel: data?.songRecognizeLlm?.model || "qwen-plus",
|
|
76044
76458
|
enableSearch: data?.songRecognizeLlm?.enableSearch ?? false,
|
|
76045
76459
|
maxInputLength: data?.songRecognizeLlm?.maxInputLength || 300,
|
|
76046
76460
|
enableStructuredOutput: data?.songRecognizeLlm?.enableStructuredOutput ?? true,
|
|
76047
76461
|
lyricOptimize: data?.songRecognizeLlm?.lyricOptimize ?? true,
|
|
76048
76462
|
};
|
|
76049
76463
|
}
|
|
76464
|
+
/**
|
|
76465
|
+
* 获取歌词优化配置
|
|
76466
|
+
*/
|
|
76467
|
+
function getLyricOptimizeConfig() {
|
|
76468
|
+
const config = index.appConfig.getAll();
|
|
76469
|
+
const data = config.ai;
|
|
76470
|
+
let modelId = data?.songLyricOptimize?.modelId;
|
|
76471
|
+
const model = getModel(modelId);
|
|
76472
|
+
const vendor = data.vendors.find((v) => v.id === model.vendorId);
|
|
76473
|
+
if (!vendor) {
|
|
76474
|
+
throw new Error("找不到LLM模型关联的供应商");
|
|
76475
|
+
}
|
|
76476
|
+
return {
|
|
76477
|
+
vendorId: vendor.id,
|
|
76478
|
+
prompt: data?.songLyricOptimize?.prompt,
|
|
76479
|
+
model: model.modelName,
|
|
76480
|
+
enableStructuredOutput: data?.songLyricOptimize?.enableStructuredOutput ?? true,
|
|
76481
|
+
};
|
|
76482
|
+
}
|
|
76050
76483
|
/**
|
|
76051
76484
|
* 使用 LLM 识别歌曲名称
|
|
76052
76485
|
* @param asrText - ASR 识别的文本内容
|
|
@@ -76100,7 +76533,7 @@ async function recognizeSongNameWithLLM(asrText, vendorId, options) {
|
|
|
76100
76533
|
* @param audioStartTime - 音频开始时间(秒)
|
|
76101
76534
|
*/
|
|
76102
76535
|
async function songRecognize(file, audioStartTime = 0) {
|
|
76103
|
-
const {
|
|
76536
|
+
const { asrModelId, llmVendorId, llmPrompt, llmModel, enableSearch, maxInputLength, enableStructuredOutput, lyricOptimize, } = getSongRecognizeConfig();
|
|
76104
76537
|
let info = await recognize(file, lyricOptimize);
|
|
76105
76538
|
if (!info) {
|
|
76106
76539
|
index.logObj.warn("Shazam 未识别到任何歌曲信息");
|
|
@@ -76114,8 +76547,8 @@ async function songRecognize(file, audioStartTime = 0) {
|
|
|
76114
76547
|
};
|
|
76115
76548
|
}
|
|
76116
76549
|
// 如果开启了歌词优化,首先要asr识别
|
|
76117
|
-
const data = await
|
|
76118
|
-
const messages = data.
|
|
76550
|
+
const data = await recognize$1(file, asrModelId);
|
|
76551
|
+
const messages = data.text || "";
|
|
76119
76552
|
if (!messages) {
|
|
76120
76553
|
index.logObj.warn("没有识别到任何文本内容,无法进行歌曲识别");
|
|
76121
76554
|
return;
|
|
@@ -76135,15 +76568,30 @@ async function songRecognize(file, audioStartTime = 0) {
|
|
|
76135
76568
|
return;
|
|
76136
76569
|
}
|
|
76137
76570
|
const rawLyrics = info?.lyrics;
|
|
76138
|
-
let
|
|
76139
|
-
if (rawLyrics) {
|
|
76140
|
-
|
|
76141
|
-
|
|
76571
|
+
let words = [];
|
|
76572
|
+
if (rawLyrics && lyricOptimize) {
|
|
76573
|
+
try {
|
|
76574
|
+
words = await optimizeLyrics(data, rawLyrics, audioStartTime * 1000);
|
|
76142
76575
|
}
|
|
76143
|
-
|
|
76144
|
-
|
|
76576
|
+
catch (error) {
|
|
76577
|
+
index.logObj.error("歌词优化失败:", error);
|
|
76145
76578
|
}
|
|
76146
76579
|
}
|
|
76580
|
+
if (words.length === 0) {
|
|
76581
|
+
// 如果没有歌词优化结果,使用原始 ASR 结果
|
|
76582
|
+
const segments = data.segments || [];
|
|
76583
|
+
// 使用词级时间戳生成 ASRWord 数组
|
|
76584
|
+
words = segments.map((sentence) => {
|
|
76585
|
+
let text = sentence.text.replace(/[,。、“”‘’!?]/g, " ");
|
|
76586
|
+
return {
|
|
76587
|
+
st: sentence.start * 1000 + audioStartTime * 1000,
|
|
76588
|
+
et: sentence.end * 1000 + audioStartTime * 1000,
|
|
76589
|
+
t: text,
|
|
76590
|
+
};
|
|
76591
|
+
});
|
|
76592
|
+
}
|
|
76593
|
+
// 统一生成 SRT
|
|
76594
|
+
const srtData = convert2Srt(words, 0);
|
|
76147
76595
|
const name = info?.name;
|
|
76148
76596
|
return {
|
|
76149
76597
|
name: name,
|
|
@@ -76621,7 +77069,7 @@ router$8.post("/formatTitle", async (ctx) => {
|
|
|
76621
77069
|
router$8.post("/formatPartTitle", async (ctx) => {
|
|
76622
77070
|
const data = ctx.request.body;
|
|
76623
77071
|
const template = (data.template || "");
|
|
76624
|
-
const title = formatPartTitle({
|
|
77072
|
+
const title = formatPartTitle(data.options ?? {
|
|
76625
77073
|
title: "标题",
|
|
76626
77074
|
username: "主播名",
|
|
76627
77075
|
time: new Date().toISOString(),
|
|
@@ -78315,24 +78763,197 @@ router$2.post("/sync", async (ctx) => {
|
|
|
78315
78763
|
ctx.body = task.taskId;
|
|
78316
78764
|
});
|
|
78317
78765
|
|
|
78766
|
+
/**
|
|
78767
|
+
* 根据标准 ASR 结果生成优化的 SRT 字幕
|
|
78768
|
+
* @param result - 标准 ASR 识别结果
|
|
78769
|
+
* @param options - 配置选项
|
|
78770
|
+
* @returns SRT 格式的字幕字符串
|
|
78771
|
+
*/
|
|
78772
|
+
function convertStandardResultToSrt(result, options) {
|
|
78773
|
+
const offset = options?.offset ?? 0;
|
|
78774
|
+
const maxLength = options?.maxLength ?? 20;
|
|
78775
|
+
const fillGap = options?.fillGap ?? 500;
|
|
78776
|
+
// 如果有词级别数据,使用词级别生成(更精确)
|
|
78777
|
+
if (result.words && result.words.length > 0) {
|
|
78778
|
+
return convertWordsToSrt(result.words, { offset, maxLength, fillGap });
|
|
78779
|
+
}
|
|
78780
|
+
// 否则直接使用分段数据,不做分割和补全
|
|
78781
|
+
const srtNodes = result.segments
|
|
78782
|
+
.filter((segment) => segment.text.trim())
|
|
78783
|
+
.map((segment) => ({
|
|
78784
|
+
type: "cue",
|
|
78785
|
+
data: {
|
|
78786
|
+
start: segment.start * 1000 + offset, // 转换为毫秒
|
|
78787
|
+
end: segment.end * 1000 + offset,
|
|
78788
|
+
text: segment.text.trim(),
|
|
78789
|
+
},
|
|
78790
|
+
}));
|
|
78791
|
+
return stringifySync(srtNodes, { format: "SRT" });
|
|
78792
|
+
}
|
|
78793
|
+
/**
|
|
78794
|
+
* 根据词级别数据生成 SRT 字幕
|
|
78795
|
+
*/
|
|
78796
|
+
function convertWordsToSrt(words, options) {
|
|
78797
|
+
const { offset, maxLength, fillGap } = options;
|
|
78798
|
+
const subtitles = [];
|
|
78799
|
+
let accumulatedText = "";
|
|
78800
|
+
let startTime = 0;
|
|
78801
|
+
let endTime = 0;
|
|
78802
|
+
for (let i = 0; i < words.length; i++) {
|
|
78803
|
+
const word = words[i];
|
|
78804
|
+
if (accumulatedText === "") {
|
|
78805
|
+
startTime = word.start;
|
|
78806
|
+
}
|
|
78807
|
+
// 累积词文本
|
|
78808
|
+
accumulatedText += word.word;
|
|
78809
|
+
endTime = word.end;
|
|
78810
|
+
// 检查是否需要断句
|
|
78811
|
+
let shouldBreak = false;
|
|
78812
|
+
// 1. 如果有标点符号且字符数大于4,断句
|
|
78813
|
+
if (word.punctuation && accumulatedText.length > 4) {
|
|
78814
|
+
accumulatedText += word.punctuation;
|
|
78815
|
+
shouldBreak = true;
|
|
78816
|
+
}
|
|
78817
|
+
// 如果有标点但字符数不够,只添加标点继续累积
|
|
78818
|
+
else if (word.punctuation) {
|
|
78819
|
+
accumulatedText += word.punctuation;
|
|
78820
|
+
}
|
|
78821
|
+
// 2. 长度超过最大限制,强制断句
|
|
78822
|
+
else if (accumulatedText.length >= maxLength) {
|
|
78823
|
+
shouldBreak = true;
|
|
78824
|
+
}
|
|
78825
|
+
if (shouldBreak) {
|
|
78826
|
+
const cleanedText = accumulatedText.replace(/[\p{P}\p{S}]+$/u, "").trim();
|
|
78827
|
+
if (cleanedText) {
|
|
78828
|
+
subtitles.push({
|
|
78829
|
+
start: startTime * 1000 + offset, // 转换为毫秒
|
|
78830
|
+
end: endTime * 1000 + offset,
|
|
78831
|
+
text: cleanedText,
|
|
78832
|
+
});
|
|
78833
|
+
}
|
|
78834
|
+
accumulatedText = "";
|
|
78835
|
+
startTime = 0;
|
|
78836
|
+
endTime = 0;
|
|
78837
|
+
}
|
|
78838
|
+
}
|
|
78839
|
+
// 处理剩余文本
|
|
78840
|
+
if (accumulatedText.trim()) {
|
|
78841
|
+
const cleanedText = accumulatedText.replace(/[\p{P}\p{S}]+$/u, "").trim();
|
|
78842
|
+
if (cleanedText) {
|
|
78843
|
+
subtitles.push({
|
|
78844
|
+
start: startTime * 1000 + offset,
|
|
78845
|
+
end: endTime * 1000 + offset,
|
|
78846
|
+
text: cleanedText,
|
|
78847
|
+
});
|
|
78848
|
+
}
|
|
78849
|
+
}
|
|
78850
|
+
// 补齐字幕间隔
|
|
78851
|
+
if (fillGap > 0) {
|
|
78852
|
+
for (let i = 0; i < subtitles.length - 1; i++) {
|
|
78853
|
+
const current = subtitles[i];
|
|
78854
|
+
const next = subtitles[i + 1];
|
|
78855
|
+
const gap = next.start - current.end;
|
|
78856
|
+
if (gap > 0 && gap <= fillGap) {
|
|
78857
|
+
current.end += gap * 0.6;
|
|
78858
|
+
next.start -= gap * 0.4;
|
|
78859
|
+
}
|
|
78860
|
+
}
|
|
78861
|
+
}
|
|
78862
|
+
// 生成 SRT
|
|
78863
|
+
const srtNodes = subtitles.map((sub) => ({
|
|
78864
|
+
type: "cue",
|
|
78865
|
+
data: {
|
|
78866
|
+
start: sub.start,
|
|
78867
|
+
end: sub.end,
|
|
78868
|
+
text: sub.text,
|
|
78869
|
+
},
|
|
78870
|
+
}));
|
|
78871
|
+
return stringifySync(srtNodes, { format: "SRT" });
|
|
78872
|
+
}
|
|
78873
|
+
/**
|
|
78874
|
+
* 字幕识别 - 将音频/视频文件转换为 SRT 字幕
|
|
78875
|
+
* @param file - 音频或视频文件路径
|
|
78876
|
+
* @param vendorId - AI 服务商 ID
|
|
78877
|
+
* @param options - 配置选项
|
|
78878
|
+
* @returns SRT 格式的字幕字符串
|
|
78879
|
+
*/
|
|
78880
|
+
async function subtitleRecognize(file, modelId, options) {
|
|
78881
|
+
const offset = (options?.offset ?? 0) * 1000; // 转换为毫秒
|
|
78882
|
+
const maxLength = options?.maxLength ?? 26;
|
|
78883
|
+
const timeGapThreshold = options?.timeGapThreshold ?? 300;
|
|
78884
|
+
const fillGap = options?.fillGap ?? 1000;
|
|
78885
|
+
const disableCache = options?.disableCache;
|
|
78886
|
+
index.logObj.info("开始字幕识别", {
|
|
78887
|
+
file,
|
|
78888
|
+
offset,
|
|
78889
|
+
maxLength,
|
|
78890
|
+
timeGapThreshold,
|
|
78891
|
+
fillGap,
|
|
78892
|
+
disableCache,
|
|
78893
|
+
});
|
|
78894
|
+
try {
|
|
78895
|
+
// 生成缓存路径
|
|
78896
|
+
const cachePath = index.getTempPath();
|
|
78897
|
+
const fileHash = await index.calculateFileQuickHash(file);
|
|
78898
|
+
// TODO:缓存有bug,第一个是多模型切换、第二个是不同参数热词参数不同
|
|
78899
|
+
const cacheFileName = `asr_subtitle_cache_${fileHash}_${modelId}.json`;
|
|
78900
|
+
const cacheFilePath = path$7.join(cachePath, cacheFileName);
|
|
78901
|
+
let asrResult = null;
|
|
78902
|
+
// 尝试从缓存读取
|
|
78903
|
+
if (!disableCache && (await index.fs.pathExists(cacheFilePath))) ;
|
|
78904
|
+
if (!asrResult) {
|
|
78905
|
+
// 调用 ASR 识别(使用新的统一接口)
|
|
78906
|
+
asrResult = await recognize$1(file, modelId);
|
|
78907
|
+
// 保存到缓存(如果未禁用缓存)
|
|
78908
|
+
if (!disableCache) ;
|
|
78909
|
+
}
|
|
78910
|
+
if (!asrResult.segments || asrResult.segments.length === 0) {
|
|
78911
|
+
index.logObj.warn("ASR 未识别到任何内容");
|
|
78912
|
+
return "";
|
|
78913
|
+
}
|
|
78914
|
+
// 使用新的标准格式转换函数
|
|
78915
|
+
const srt = convertStandardResultToSrt(asrResult, {
|
|
78916
|
+
offset,
|
|
78917
|
+
maxLength,
|
|
78918
|
+
fillGap,
|
|
78919
|
+
});
|
|
78920
|
+
index.logObj.info("字幕识别完成", { subtitleCount: srt.split("\n\n").length - 1 });
|
|
78921
|
+
return srt;
|
|
78922
|
+
}
|
|
78923
|
+
catch (error) {
|
|
78924
|
+
index.logObj.error("字幕识别失败:", error);
|
|
78925
|
+
throw error;
|
|
78926
|
+
}
|
|
78927
|
+
}
|
|
78928
|
+
|
|
78318
78929
|
const router$1 = new Router$1({
|
|
78319
78930
|
prefix: "/ai",
|
|
78320
78931
|
});
|
|
78321
|
-
router
|
|
78322
|
-
|
|
78323
|
-
|
|
78324
|
-
|
|
78325
|
-
|
|
78326
|
-
|
|
78327
|
-
|
|
78328
|
-
|
|
78329
|
-
|
|
78330
|
-
|
|
78331
|
-
|
|
78332
|
-
|
|
78333
|
-
|
|
78334
|
-
|
|
78335
|
-
|
|
78932
|
+
// router.post("/asr", async (ctx) => {
|
|
78933
|
+
// const data = ctx.request.body as {
|
|
78934
|
+
// file: string;
|
|
78935
|
+
// vendorId: string;
|
|
78936
|
+
// model: string;
|
|
78937
|
+
// };
|
|
78938
|
+
// const result = await asrRecognize(data.file, { vendorId: data.vendorId, model: data.model });
|
|
78939
|
+
// ctx.body = result;
|
|
78940
|
+
// });
|
|
78941
|
+
// router.post("/llm", async (ctx) => {
|
|
78942
|
+
// const data = ctx.request.body as {
|
|
78943
|
+
// message: string;
|
|
78944
|
+
// systemPrompt?: string;
|
|
78945
|
+
// enableSearch?: boolean;
|
|
78946
|
+
// jsonResponse?: boolean;
|
|
78947
|
+
// stream?: boolean;
|
|
78948
|
+
// };
|
|
78949
|
+
// const result = await llm(data.message, data.systemPrompt, {
|
|
78950
|
+
// enableSearch: data.enableSearch,
|
|
78951
|
+
// key: undefined,
|
|
78952
|
+
// jsonResponse: data.jsonResponse,
|
|
78953
|
+
// stream: data.stream,
|
|
78954
|
+
// });
|
|
78955
|
+
// ctx.body = result;
|
|
78956
|
+
// });
|
|
78336
78957
|
router$1.post("/song_recognize", async (ctx) => {
|
|
78337
78958
|
const data = ctx.request.body;
|
|
78338
78959
|
if (!data.file || data.startTime == null || data.endTime == null) {
|
|
@@ -78367,6 +78988,66 @@ router$1.post("/song_recognize", async (ctx) => {
|
|
|
78367
78988
|
index.fs.remove(outputFile);
|
|
78368
78989
|
ctx.body = result;
|
|
78369
78990
|
});
|
|
78991
|
+
router$1.post("/subtitle", async (ctx) => {
|
|
78992
|
+
const data = ctx.request.body;
|
|
78993
|
+
if (!data.file) {
|
|
78994
|
+
ctx.status = 400;
|
|
78995
|
+
ctx.body = {
|
|
78996
|
+
error: "参数错误,必须包含 file 字段",
|
|
78997
|
+
};
|
|
78998
|
+
return;
|
|
78999
|
+
}
|
|
79000
|
+
const config = index.appConfig.get("ai") || {};
|
|
79001
|
+
const asrModelId = config.subtitleRecognize.modelId;
|
|
79002
|
+
if (!asrModelId) {
|
|
79003
|
+
throw new Error("请先在配置中设置字幕识别ASR模型");
|
|
79004
|
+
}
|
|
79005
|
+
try {
|
|
79006
|
+
let audioFile = data.file;
|
|
79007
|
+
let needCleanup = false;
|
|
79008
|
+
// 如果指定了时间范围,需要先提取音频片段
|
|
79009
|
+
if (data.startTime != null && data.endTime != null) {
|
|
79010
|
+
const cachePath = index.getTempPath();
|
|
79011
|
+
const fileName = `${index.uuid()}.mp3`;
|
|
79012
|
+
const task = await index.addExtractAudioTask(data.file, fileName, {
|
|
79013
|
+
startTime: data.startTime,
|
|
79014
|
+
endTime: data.endTime,
|
|
79015
|
+
saveType: 2,
|
|
79016
|
+
savePath: cachePath,
|
|
79017
|
+
autoRun: true,
|
|
79018
|
+
addQueue: false,
|
|
79019
|
+
format: "libmp3lame",
|
|
79020
|
+
audioBitrate: "192k",
|
|
79021
|
+
});
|
|
79022
|
+
audioFile = await new Promise((resolve, reject) => {
|
|
79023
|
+
task.on("task-end", () => {
|
|
79024
|
+
resolve(task.output);
|
|
79025
|
+
});
|
|
79026
|
+
task.on("task-error", (err) => {
|
|
79027
|
+
reject(err);
|
|
79028
|
+
});
|
|
79029
|
+
});
|
|
79030
|
+
needCleanup = true;
|
|
79031
|
+
}
|
|
79032
|
+
const srt = await subtitleRecognize(audioFile, asrModelId, {
|
|
79033
|
+
offset: data.offset,
|
|
79034
|
+
disableCache: true,
|
|
79035
|
+
});
|
|
79036
|
+
// 清理临时音频文件
|
|
79037
|
+
if (needCleanup) {
|
|
79038
|
+
index.fs.remove(audioFile);
|
|
79039
|
+
}
|
|
79040
|
+
ctx.body = {
|
|
79041
|
+
srt,
|
|
79042
|
+
};
|
|
79043
|
+
}
|
|
79044
|
+
catch (error) {
|
|
79045
|
+
ctx.status = 500;
|
|
79046
|
+
ctx.body = {
|
|
79047
|
+
error: error.message || "字幕识别失败",
|
|
79048
|
+
};
|
|
79049
|
+
}
|
|
79050
|
+
});
|
|
78370
79051
|
|
|
78371
79052
|
/**
|
|
78372
79053
|
* 文件引用计数管理器
|
|
@@ -79593,8 +80274,8 @@ class WebhookHandler {
|
|
|
79593
80274
|
// 5. 处理弹幕和视频压制
|
|
79594
80275
|
const processingResult = await this.processMediaFiles(context, options, config, xmlFilePath);
|
|
79595
80276
|
index.logObj.debug("processingResult", processingResult, options.filePath, context.part.filePath);
|
|
79596
|
-
this.collectTasks(context.part.filePath, "handledVideo", config);
|
|
79597
80277
|
if (processingResult.conversionSuccessful) {
|
|
80278
|
+
this.collectTasks(context.part.filePath, "handledVideo", config);
|
|
79598
80279
|
this.fileRefManager.releaseRef(options.filePath);
|
|
79599
80280
|
}
|
|
79600
80281
|
if (xmlFilePath && processingResult.danmuConversionSuccessful) {
|
|
@@ -80292,7 +80973,7 @@ class WebhookHandler {
|
|
|
80292
80973
|
await this.addEditMediaTask(config.uid, aid, filePaths.map((item) => ({
|
|
80293
80974
|
path: item.path,
|
|
80294
80975
|
title: item.title,
|
|
80295
|
-
})), limitedUploadTime,
|
|
80976
|
+
})), limitedUploadTime, config.afterUploadDeletAction);
|
|
80296
80977
|
live.batchUpdateUploadStatus(filePaths.map((item) => item.part), "uploaded", type);
|
|
80297
80978
|
}
|
|
80298
80979
|
/**
|
|
@@ -80308,7 +80989,7 @@ class WebhookHandler {
|
|
|
80308
80989
|
const aid = (await this.addUploadTask(config.uid, filePaths.map((item) => ({
|
|
80309
80990
|
path: item.path,
|
|
80310
80991
|
title: item.title,
|
|
80311
|
-
})), uploadPreset, limitedUploadTime,
|
|
80992
|
+
})), uploadPreset, limitedUploadTime, config.afterUploadDeletAction));
|
|
80312
80993
|
live[aidField] = Number(aid);
|
|
80313
80994
|
live.batchUpdateUploadStatus(filePaths.map((item) => item.part), "uploaded", type);
|
|
80314
80995
|
}
|
|
@@ -80413,7 +81094,7 @@ exports.handler = void 0;
|
|
|
80413
81094
|
exports.appConfig = void 0;
|
|
80414
81095
|
exports.container = void 0;
|
|
80415
81096
|
const fileCache = createFileCache();
|
|
80416
|
-
path$7.dirname(require$$0$4.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index-
|
|
81097
|
+
path$7.dirname(require$$0$4.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index-DTf_Oo8U.cjs', document.baseURI).href))));
|
|
80417
81098
|
const authMiddleware = (passKey) => {
|
|
80418
81099
|
return async (ctx, next) => {
|
|
80419
81100
|
const authHeader = ctx.headers["authorization"] || ctx.request.query.auth;
|