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:
an-lee
2024-04-02 11:10:19 +08:00
committed by GitHub
parent 69258e0e7d
commit 265429a24e
21 changed files with 185 additions and 777 deletions

View File

@@ -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,

View File

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

View File

@@ -1,5 +1,3 @@
export * from "./recordings-list";
export * from "./recording-card";
export * from "./recording-player";
export * from "./recording-calendar";
export * from "./recording-activities";

View File

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

View File

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