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} > { export const Caption = (props: { caption: TimelineEntry; - tab: string; language?: string; selectedIndices?: number[]; currentSegmentIndex: number; diff --git a/enjoy/src/renderer/components/medias/media-recordings.tsx b/enjoy/src/renderer/components/medias/media-recordings.tsx index 2d9fe002..3103f2ef 100644 --- a/enjoy/src/renderer/components/medias/media-recordings.tsx +++ b/enjoy/src/renderer/components/medias/media-recordings.tsx @@ -8,12 +8,12 @@ import { AlertDialogFooter, AlertDialogCancel, AlertDialogAction, + AlertDialogTrigger, Button, DropdownMenu, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuContent, - ScrollArea, toast, } from "@renderer/components/ui"; import { @@ -26,6 +26,7 @@ import { LoaderIcon, MicIcon, MoreHorizontalIcon, + SquareMenuIcon, Trash2Icon, } from "lucide-react"; import { t } from "i18next"; @@ -42,6 +43,8 @@ export const MediaRecordings = () => { currentRecording, setCurrentRecording, currentSegmentIndex, + transcription, + media, } = useContext(MediaPlayerProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); @@ -55,12 +58,83 @@ export const MediaRecordings = () => { }); }; + const handleExport = async () => { + try { + const url = await EnjoyApp.recordings.export(media.id, media.mediaType); + const filename = `Recording(${media.name}).mp3`; + + EnjoyApp.dialog + .showSaveDialog({ + title: t("download"), + defaultPath: filename, + filters: [ + { + name: "Audio", + extensions: ["mp3"], + }, + ], + }) + .then((savePath) => { + if (!savePath) return; + + toast.promise(EnjoyApp.download.start(url, savePath as string), { + loading: t("downloading", { file: filename }), + success: () => t("downloadedSuccessfully"), + error: t("downloadFailed"), + position: "bottom-right", + }); + }) + .catch((err) => { + if (err) toast.error(err.message); + }); + } catch (error) { + toast.error(error.message); + } + }; + useEffect(() => { setCurrentRecording(recordings[0]); }, [currentSegmentIndex, recordings]); return (
+
+
+ #{currentSegmentIndex + 1}/{transcription?.result?.timeline?.length} +
+ + + + + + + + + + + + + {t("exportRecordings")} + + {t("exportRecordingsConfirmation")} + + + + {t("cancel")} + + + + + + + + + +
{recordings.length == 0 && (
{ const disconnect = () => { console.info("--- disconnecting db ---"); + setState("disconnected"); return EnjoyApp.db.disconnect().then(() => { setState("disconnected"); setPath(undefined); @@ -58,6 +64,12 @@ export const DbProvider = ({ children }: { children: React.ReactNode }) => { `path: ${path};\n`, `error: ${error};\n` ); + + if (state === "disconnected") { + setTimeout(() => { + connect(); + }, 1000); + } }, [state]); const addDblistener = (callback: (event: CustomEvent) => void) => { diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index 8cedc34e..da12301c 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -215,6 +215,7 @@ type EnjoyAppType = { targetId: string, targetType ) => Promise; + export: (targetId: string, targetType: string) => Promise; }; pronunciationAssessments: { findAll: (params: any) => Promise;