Feat: export recordings in one file (#1050)
* auto reconnect db * may export & merge recordings
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -784,5 +784,9 @@
|
||||
"importMdictFile": "导入原词典文件",
|
||||
"mdictFileTip": "直接导入 .mdx .mdd 格式的文件 (.mdx 文件是必须的,.mdd 文件是可选的且可以有多个),不过样式和可用性可能存在问题。",
|
||||
"authorizationExpired": "您的登录授权已过期,请重新登录。",
|
||||
"selectUser": "选择用户"
|
||||
"selectUser": "选择用户",
|
||||
"export": "导出",
|
||||
"exportRecordings": "导出录音",
|
||||
"exportRecordingsConfirmation": "选择每个段落最高分的录音,导出为单个文件。",
|
||||
"exportRecordingsSuccess": "导出录音成功"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
1
enjoy/src/types/enjoy-app.d.ts
vendored
1
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -215,6 +215,7 @@ type EnjoyAppType = {
|
||||
targetId: string,
|
||||
targetType
|
||||
) => Promise<SegementRecordingStatsType>;
|
||||
export: (targetId: string, targetType: string) => Promise<string>;
|
||||
};
|
||||
pronunciationAssessments: {
|
||||
findAll: (params: any) => Promise<PronunciationAssessmentType[]>;
|
||||
|
||||
Reference in New Issue
Block a user