Feat: denoise recording & clean code (#473)
* denoise recording before saved * Refactor audio processing and recording logic * Remove unused code * use echogarden to transcode * remove ffmpeg-wasm * add echogarden decode * remove deprecated code * ensure use posix path * refactor echogarden transcode * refactor recording denoise * clean code * expose align error in toast * remove unused code
This commit is contained in:
@@ -6,10 +6,8 @@ import {
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { t } from "i18next";
|
||||
import { useTranscribe } from "@renderer/hooks";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { MediaRecordButton } from "@renderer/components";
|
||||
import { FFMPEG_CONVERT_WAV_OPTIONS } from "@/constants";
|
||||
|
||||
export const MediaRecorder = () => {
|
||||
const {
|
||||
@@ -23,7 +21,6 @@ export const MediaRecorder = () => {
|
||||
const [access, setAccess] = useState<boolean>(false);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { transcode } = useTranscribe();
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
@@ -45,12 +42,6 @@ export const MediaRecorder = () => {
|
||||
|
||||
toast.promise(
|
||||
async () => {
|
||||
let output: Blob;
|
||||
output = await transcode(blob, [
|
||||
// ...FFMPEG_TRIM_SILENCE_OPTIONS,
|
||||
...FFMPEG_CONVERT_WAV_OPTIONS,
|
||||
]);
|
||||
|
||||
const currentSegment =
|
||||
transcription?.result?.timeline?.[currentSegmentIndex];
|
||||
if (!currentSegment) return;
|
||||
@@ -59,8 +50,8 @@ export const MediaRecorder = () => {
|
||||
targetId: media.id,
|
||||
targetType: media.mediaType,
|
||||
blob: {
|
||||
type: output.type.split(";")[0],
|
||||
arrayBuffer: await output.arrayBuffer(),
|
||||
type: blob.type.split(";")[0],
|
||||
arrayBuffer: await blob.arrayBuffer(),
|
||||
},
|
||||
referenceId: currentSegmentIndex,
|
||||
referenceText: currentSegment.text,
|
||||
|
||||
@@ -252,7 +252,7 @@ export const AssistantMessageComponent = (props: {
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="message-download"
|
||||
data-testid="message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from "./recordings-list";
|
||||
export * from "./recording-card";
|
||||
export * from "./recording-player";
|
||||
export * from "./recording-calendar";
|
||||
export * from "./recording-activities";
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useState, useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
import { RecordingPlayer } from "@renderer/components";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
Trash2Icon,
|
||||
Share2Icon,
|
||||
GaugeCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const RecordingCard = (props: {
|
||||
recording: RecordingType;
|
||||
id?: string;
|
||||
onSelect?: () => void;
|
||||
}) => {
|
||||
const { recording, id, onSelect } = props;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
EnjoyApp.recordings.destroy(recording.id);
|
||||
};
|
||||
const handleShare = async () => {
|
||||
if (!recording.uploadedAt) {
|
||||
try {
|
||||
await EnjoyApp.recordings.upload(recording.id);
|
||||
} catch (error) {
|
||||
toast.error(t("shareFailed"), { description: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
webApi
|
||||
.createPost({
|
||||
targetId: recording.id,
|
||||
targetType: "Recording",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedRecording"),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={id} className="flex items-center justify-end px-4 transition-all">
|
||||
<div className="w-full">
|
||||
<div className="bg-background rounded-lg py-2 px-4 relative mb-1">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(recording.duration / 1000)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RecordingPlayer
|
||||
recording={recording}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("pronunciationAssessment")}
|
||||
data-tooltip-place="bottom"
|
||||
onClick={onSelect}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4
|
||||
${
|
||||
recording.pronunciationAssessment
|
||||
? recording.pronunciationAssessment
|
||||
.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: recording.pronunciationAssessment
|
||||
.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<Share2Icon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareRecording")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisRecordingToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={handleShare}>{t("share")}</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(recording.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(value) => setIsDeleteDialogOpen(value)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteRecording")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteRecordingConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import {
|
||||
RecordButton,
|
||||
RecordingCard,
|
||||
RecordingDetail,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetClose,
|
||||
} from "@renderer/components/ui";
|
||||
import { useEffect, useState, useRef, useContext, useReducer } from "react";
|
||||
import { LoaderIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
DbProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { recordingsReducer } from "@renderer/reducers";
|
||||
|
||||
export const RecordingsList = (props: {
|
||||
targetId: string;
|
||||
targetType: "Audio" | "Video";
|
||||
referenceId: number;
|
||||
referenceText: string;
|
||||
}) => {
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { targetId, targetType, referenceId, referenceText } = props;
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
|
||||
const [recordings, dispatchRecordings] = useReducer(recordingsReducer, []);
|
||||
const [selected, setSelected] = useState<RecordingType | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [offset, setOffest] = useState(0);
|
||||
|
||||
const scrollToRecording = (recording: RecordingType) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!recording) return;
|
||||
|
||||
setTimeout(() => {
|
||||
containerRef.current
|
||||
.querySelector(`#recording-${recording.id}`)
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
} as ScrollIntoViewOptions);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const onRecordingsUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
|
||||
if (model === "PronunciationAssessment" && action === "create") {
|
||||
const recording = recordings.find((r) => r.id === record.targetId);
|
||||
if (!recording) return;
|
||||
|
||||
recording.pronunciationAssessment = record;
|
||||
dispatchRecordings({
|
||||
type: "update",
|
||||
record: recording,
|
||||
});
|
||||
}
|
||||
|
||||
if (model != "Recording") return;
|
||||
|
||||
if (action === "destroy") {
|
||||
dispatchRecordings({
|
||||
type: "destroy",
|
||||
record,
|
||||
});
|
||||
} else if (action === "create") {
|
||||
if ((record as RecordingType).targetId !== targetId) return;
|
||||
dispatchRecordings({
|
||||
type: "create",
|
||||
record,
|
||||
});
|
||||
|
||||
scrollToRecording(record);
|
||||
}
|
||||
};
|
||||
|
||||
const createRecording = async (blob: Blob, duration: number) => {
|
||||
if (typeof referenceId !== "number") return;
|
||||
|
||||
EnjoyApp.recordings.create({
|
||||
targetId,
|
||||
targetType,
|
||||
blob: {
|
||||
type: blob.type.split(";")[0],
|
||||
arrayBuffer: await blob.arrayBuffer(),
|
||||
},
|
||||
referenceId,
|
||||
referenceText,
|
||||
duration,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onRecordingsUpdate);
|
||||
|
||||
return () => {
|
||||
removeDbListener(onRecordingsUpdate);
|
||||
};
|
||||
}, [recordings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecordings();
|
||||
}, [targetId, targetType, referenceId]);
|
||||
|
||||
const fetchRecordings = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const limit = 10;
|
||||
EnjoyApp.recordings
|
||||
.findAll({
|
||||
limit,
|
||||
offset,
|
||||
where: { targetId, targetType, referenceId },
|
||||
})
|
||||
.then((_recordings) => {
|
||||
if (_recordings.length === 0) {
|
||||
setOffest(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_recordings.length < limit) {
|
||||
setOffest(-1);
|
||||
} else {
|
||||
setOffest(offset + _recordings.length);
|
||||
}
|
||||
|
||||
dispatchRecordings({
|
||||
type: "append",
|
||||
records: _recordings,
|
||||
});
|
||||
|
||||
scrollToRecording(_recordings[0]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="">
|
||||
{offset > -1 && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="ghost" onClick={fetchRecordings}>
|
||||
{t("loadMore")}
|
||||
{loading && (
|
||||
<LoaderIcon className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse space-y-4">
|
||||
<div className="w-full h-24"></div>
|
||||
{recordings.map((recording) => (
|
||||
<RecordingCard
|
||||
id={`recording-${recording.id}`}
|
||||
key={recording.id}
|
||||
recording={recording}
|
||||
onSelect={() => setSelected(recording)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="z-50 bottom-16 left-1/2 w-0 h-0 absolute flex items-center justify-center">
|
||||
{referenceId !== undefined && Boolean(referenceText) && (
|
||||
<RecordButton
|
||||
disabled={referenceId == undefined || !referenceText}
|
||||
onRecordEnd={createRecording}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={!!selected}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-2xl shadow-lg"
|
||||
displayClose={false}
|
||||
>
|
||||
<SheetHeader className="flex items-center justify-center -mt-4 mb-2">
|
||||
<SheetClose>
|
||||
<ChevronDownIcon />
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
|
||||
<RecordingDetail recording={selected} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user