Feat: export recordings in one file (#1050)

* auto reconnect db

* may export & merge recordings
This commit is contained in:
an-lee
2024-09-07 19:33:00 +08:00
committed by GitHub
parent f8915815d7
commit f7d02f8edd
10 changed files with 200 additions and 13 deletions

View File

@@ -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"
}

View File

@@ -784,5 +784,9 @@
"importMdictFile": "导入原词典文件",
"mdictFileTip": "直接导入 .mdx .mdd 格式的文件 (.mdx 文件是必须的,.mdd 文件是可选的且可以有多个),不过样式和可用性可能存在问题。",
"authorizationExpired": "您的登录授权已过期,请重新登录。",
"selectUser": "选择用户"
"selectUser": "选择用户",
"export": "导出",
"exportRecordings": "导出录音",
"exportRecordingsConfirmation": "选择每个段落最高分的录音,导出为单个文件。",
"exportRecordingsSuccess": "导出录音成功"
}

View File

@@ -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");
}
}

View File

@@ -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();

View File

@@ -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 }) => {

View File

@@ -371,7 +371,6 @@ export const MediaCaption = () => {
setSelectedIndices={setSelectedIndices}
>
<Caption
tab={tab}
caption={caption}
language={transcription.language}
selectedIndices={selectedIndices}
@@ -485,7 +484,6 @@ export const MediaCaption = () => {
export const Caption = (props: {
caption: TimelineEntry;
tab: string;
language?: string;
selectedIndices?: number[];
currentSegmentIndex: number;

View File

@@ -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 (
<div ref={containerRef} data-testid="media-recordings-result">
<div className="flex items-center justify-between mb-2 px-4">
<div className="text-sm text-muted-foreground">
#{currentSegmentIndex + 1}/{transcription?.result?.timeline?.length}
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="sm">
<SquareMenuIcon className="w-5 h-5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" className="block w-full">
{t("export")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("exportRecordings")}</AlertDialogTitle>
<AlertDialogDescription>
{t("exportRecordingsConfirmation")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={handleExport}>{t("export")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{recordings.length == 0 && (
<div
className="text-center px-6 py-8 text-sm text-muted-foreground"

View File

@@ -290,6 +290,7 @@ export const AppSettingsProvider = ({
// Login via API, update profile to DB
if (user.accessToken) {
EnjoyApp.userSettings.set(UserSettingKeyEnum.PROFILE, user);
createCable(user.accessToken);
} else {
// Auto login from local settings, get full profile from DB
const profile = await EnjoyApp.userSettings.get(

View File

@@ -1,7 +1,12 @@
import { createContext, useState, useEffect, useContext } from "react";
import log from "electron-log/renderer";
type DbStateEnum = "connected" | "connecting" | "error" | "disconnected";
type DbStateEnum =
| "connected"
| "connecting"
| "error"
| "disconnected"
| "reconnecting";
type DbProviderState = {
state: DbStateEnum;
path?: string;
@@ -44,6 +49,7 @@ export const DbProvider = ({ children }: { children: React.ReactNode }) => {
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) => {

View File

@@ -215,6 +215,7 @@ type EnjoyAppType = {
targetId: string,
targetType
) => Promise<SegementRecordingStatsType>;
export: (targetId: string, targetType: string) => Promise<string>;
};
pronunciationAssessments: {
findAll: (params: any) => Promise<PronunciationAssessmentType[]>;