diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 19e4ab75..100e5f83 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -784,5 +784,9 @@ "importMdictFile": "Import the original dictionary file", "mdictFileTip": "Directly import .mdx .mdd format files (.mdx files are required, .mdd files are optional and can have multiple), but there may be problems with style and usability.", "authorizationExpired": "Your authorization has expired. Please login again.", - "selectUser": "Select user" + "selectUser": "Select user", + "export": "Export", + "exportRecordings": "Export recording", + "exportRecordingsConfirmation": "Select the highest score of the recordings of each segment to export as a single file.", + "exportRecordingsSuccess": "Export recordings successfully" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index e803e698..689444d7 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -784,5 +784,9 @@ "importMdictFile": "导入原词典文件", "mdictFileTip": "直接导入 .mdx .mdd 格式的文件 (.mdx 文件是必须的,.mdd 文件是可选的且可以有多个),不过样式和可用性可能存在问题。", "authorizationExpired": "您的登录授权已过期,请重新登录。", - "selectUser": "选择用户" + "selectUser": "选择用户", + "export": "导出", + "exportRecordings": "导出录音", + "exportRecordingsConfirmation": "选择每个段落最高分的录音,导出为单个文件。", + "exportRecordingsSuccess": "导出录音成功" } diff --git a/enjoy/src/main/db/handlers/recordings-handler.ts b/enjoy/src/main/db/handlers/recordings-handler.ts index f98127fb..9500b112 100644 --- a/enjoy/src/main/db/handlers/recordings-handler.ts +++ b/enjoy/src/main/db/handlers/recordings-handler.ts @@ -16,6 +16,10 @@ import dayjs from "dayjs"; import { t } from "i18next"; import log from "@main/logger"; import { NIL as NIL_UUID } from "uuid"; +import FfmpegWrapper from "@main/ffmpeg"; +import path from "path"; +import settings from "@main/settings"; +import { enjoyUrlToPath, pathToEnjoyUrl } from "@main/utils"; const logger = log.scope("db/handlers/recordings-handler"); @@ -329,6 +333,71 @@ class RecordingsHandler { }); } + // Select the highest score of the recordings of each referenceId from the + // recordings of the target and export as a single file. + private async export( + event: IpcMainEvent, + targetId: string, + targetType: string + ) { + let target: Audio | Video; + if (targetType === "Audio") { + target = await Audio.findOne({ + where: { + id: targetId, + }, + }); + } else { + target = await Video.findOne({ + where: { + id: targetId, + }, + }); + } + + if (!target) { + throw new Error(t("models.recording.notFound")); + } + + // query all recordings of the target + const recordings = await Recording.findAll({ + where: { + targetId, + targetType, + }, + include: [ + { + model: PronunciationAssessment, + attributes: [ + [ + Sequelize.fn("MAX", Sequelize.col("pronunciation_score")), + "pronunciationScore", + ], + ], + }, + ], + group: ["referenceId"], + order: [["referenceId", "ASC"]], + }); + + if (!recordings || recordings.length === 0) { + throw new Error(t("models.recording.notFound")); + } + + // export the recordings to a single file + // using ffmpeg concat + const ffmpeg = new FfmpegWrapper(); + const outputFilePath = path.join( + settings.cachePath(), + `${targetType}-${target.id}.mp3` + ); + const inputFiles = recordings.map((recording) => + enjoyUrlToPath(recording.src) + ); + await ffmpeg.concat(inputFiles, outputFilePath); + return pathToEnjoyUrl(outputFilePath); + } + register() { ipcMain.handle("recordings-find-all", this.findAll); ipcMain.handle("recordings-find-one", this.findOne); @@ -341,6 +410,7 @@ class RecordingsHandler { ipcMain.handle("recordings-group-by-date", this.groupByDate); ipcMain.handle("recordings-group-by-target", this.groupByTarget); ipcMain.handle("recordings-group-by-segment", this.groupBySegment); + ipcMain.handle("recordings-export", this.export); } unregister() { @@ -355,6 +425,7 @@ class RecordingsHandler { ipcMain.removeHandler("recordings-group-by-date"); ipcMain.removeHandler("recordings-group-by-target"); ipcMain.removeHandler("recordings-group-by-segment"); + ipcMain.removeHandler("recordings-export"); } } diff --git a/enjoy/src/main/ffmpeg.ts b/enjoy/src/main/ffmpeg.ts index 1d28a262..2c3a5444 100644 --- a/enjoy/src/main/ffmpeg.ts +++ b/enjoy/src/main/ffmpeg.ts @@ -249,18 +249,13 @@ export default class FfmpegWrapper { return new Promise((resolve, reject) => { ffmpeg .input(input) - .outputOptions( - "-ss", - startTime.toString(), - "-to", - endTime.toString() - ) + .outputOptions("-ss", startTime.toString(), "-to", endTime.toString()) .on("start", (commandLine) => { logger.info("Spawned FFmpeg with command: " + commandLine); fs.ensureDirSync(path.dirname(output)); }) .on("end", () => { - logger.info(`File ${output} created`); + logger.info(`File "${output}" created`); resolve(output); }) .on("error", (err) => { @@ -271,6 +266,30 @@ export default class FfmpegWrapper { }); } + // Concatenate videos or audios into a single file + concat(inputs: string[], output: string) { + let command = Ffmpeg(); + inputs.forEach((input) => { + command = command.input(input); + }); + return new Promise((resolve, reject) => { + command + .on("start", (commandLine) => { + logger.info("Spawned FFmpeg with command: " + commandLine); + fs.ensureDirSync(path.dirname(output)); + }) + .on("end", () => { + logger.info(`File "${output}" created`); + resolve(output); + }) + .on("error", (err) => { + logger.error(err); + reject(err); + }) + .mergeToFile(output, settings.cachePath()); + }); + } + registerIpcHandlers() { ipcMain.handle("ffmpeg-check-command", async (_event) => { return await this.checkCommand(); diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index d2e15e9b..136f7f0b 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -362,6 +362,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { targetType ); }, + export: (targetId: string, targetType: string) => { + return ipcRenderer.invoke("recordings-export", targetId, targetType); + }, }, conversations: { findAll: (params: { where?: any; offset?: number; limit?: number }) => { diff --git a/enjoy/src/renderer/components/medias/media-caption.tsx b/enjoy/src/renderer/components/medias/media-caption.tsx index 26b3d9c3..fa4fa07d 100644 --- a/enjoy/src/renderer/components/medias/media-caption.tsx +++ b/enjoy/src/renderer/components/medias/media-caption.tsx @@ -371,7 +371,6 @@ export const MediaCaption = () => { setSelectedIndices={setSelectedIndices} >