Improve assessment (#967)

* add zh-HK

* refactor assessment form

* refactor assessment recorder

* share recording from assessment list

* fix post recording segment
This commit is contained in:
an-lee
2024-08-14 16:36:28 +08:00
committed by GitHub
parent dba24c5a57
commit 52c94afa3f
5 changed files with 205 additions and 103 deletions

View File

@@ -11,6 +11,10 @@
"code": "zh-CN",
"name": "简体中文"
},
{
"code": "zh-HK",
"name": "粤语"
},
{
"code": "ja-JP",
"name": "日本語"

View File

@@ -15,6 +15,7 @@ export const PostRecording = (props: {
const fetchSegment = async () => {
if (segment) return;
if (!recording.targetId) return;
webApi
.mineSegments({

View File

@@ -17,8 +17,14 @@ export const PronunciationAssessmentCard = (props: {
pronunciationAssessment: PronunciationAssessmentType;
onSelect: (assessment: PronunciationAssessmentType) => void;
onDelete: (assessment: PronunciationAssessmentType) => void;
onSharing: (recording: RecordingType) => void;
}) => {
const { pronunciationAssessment: assessment, onSelect, onDelete } = props;
const {
pronunciationAssessment: assessment,
onSelect,
onDelete,
onSharing,
} = props;
return (
<div
@@ -78,14 +84,21 @@ export const PronunciationAssessmentCard = (props: {
progress={assessment.pronunciationScore || 0}
fontSize={24}
/>
<div className="flex justify-center">
<div className="flex items-center justify-center gap-2">
<Button
onClick={() => onSelect(assessment)}
variant="secondary"
variant="outline"
size="sm"
>
{t("detail")}
</Button>
<Button
onClick={() => onSharing(assessment.target)}
variant="outline"
size="sm"
>
{t("share")}
</Button>
</div>
</div>
</div>

View File

@@ -26,10 +26,18 @@ import { LANGUAGES } from "@/constants";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoaderIcon, MicIcon, SquareIcon } from "lucide-react";
import WaveSurfer from "wavesurfer.js";
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
import {
CheckIcon,
LoaderIcon,
MicIcon,
PauseIcon,
PlayIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import { usePronunciationAssessments } from "@/renderer/hooks";
import { useAudioRecorder } from "react-audio-voice-recorder";
import { LiveAudioVisualizer } from "react-audio-visualize";
const pronunciationAssessmentSchema = z.object({
file: z.instanceof(FileList).optional(),
@@ -135,6 +143,7 @@ export const PronunciationAssessmentForm = () => {
render={() => (
<FormItem className="grid w-full items-center gap-1.5">
<Input
disabled={submitting}
placeholder={t("upload")}
type="file"
className="cursor-pointer"
@@ -155,6 +164,7 @@ export const PronunciationAssessmentForm = () => {
render={({ field }) => (
<FormItem className="grid w-full items-center gap-1.5">
<Input
disabled={submitting}
placeholder={t("recording")}
type="file"
className="hidden"
@@ -191,7 +201,11 @@ export const PronunciationAssessmentForm = () => {
render={({ field }) => (
<FormItem className="grid w-full items-center gap-1.5">
<FormLabel>{t("language")}</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<Select
disabled={submitting}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -216,6 +230,7 @@ export const PronunciationAssessmentForm = () => {
<FormItem className="grid w-full items-center gap-1.5">
<FormLabel>{t("referenceText")}</FormLabel>
<Textarea
disabled={submitting}
placeholder={t("inputReferenceTextOrLeaveItBlank")}
className="h-64"
{...field}
@@ -243,19 +258,24 @@ export const PronunciationAssessmentForm = () => {
);
};
const TEN_MINUTES = 60 * 10;
let interval: NodeJS.Timeout;
const RecorderButton = (props: {
submitting?: boolean;
onStart?: () => void;
onFinish: (blob: Blob) => void;
}) => {
const { onStart, onFinish } = props;
const { submitting, onStart, onFinish } = props;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [isRecording, setIsRecording] = useState(false);
const [recorder, setRecorder] = useState<RecordPlugin>();
const [access, setAccess] = useState<boolean>(false);
const [duration, setDuration] = useState<number>(0);
const ref = useRef(null);
const {
startRecording,
stopRecording,
togglePauseResume,
recordingBlob,
isRecording,
isPaused,
recordingTime,
mediaRecorder,
} = useAudioRecorder();
const askForMediaAccess = () => {
EnjoyApp.system.preferences.mediaAccess("microphone").then((access) => {
@@ -268,99 +288,102 @@ const RecorderButton = (props: {
});
};
const startRecord = () => {
if (isRecording) return;
if (!recorder) {
toast.warning(t("noMicrophoneAccess"));
return;
}
onStart();
RecordPlugin.getAvailableAudioDevices()
.then((devices) => devices.find((d) => d.kind === "audioinput"))
.then((device) => {
if (device) {
recorder.startRecording({ deviceId: device.deviceId });
setIsRecording(true);
setDuration(0);
interval = setInterval(() => {
setDuration((duration) => {
if (duration >= TEN_MINUTES) {
recorder.stopRecording();
}
return duration + 0.1;
});
}, 100);
} else {
toast.error(t("cannotFindMicrophone"));
}
});
};
useEffect(() => {
if (!access) return;
if (!ref?.current) return;
const ws = WaveSurfer.create({
container: ref.current,
fillParent: true,
height: 40,
autoCenter: false,
normalize: false,
});
const record = ws.registerPlugin(RecordPlugin.create());
setRecorder(record);
record.on("record-end", async (blob: Blob) => {
if (interval) clearInterval(interval);
onFinish(blob);
setIsRecording(false);
});
return () => {
if (interval) clearInterval(interval);
recorder?.stopRecording();
ws?.destroy();
};
}, [access, ref]);
useEffect(() => {
askForMediaAccess();
}, []);
useEffect(() => {
if (recordingBlob) {
onFinish(recordingBlob);
}
}, [recordingBlob]);
useEffect(() => {
if (!isRecording) return;
if (recordingTime >= 60 * 5) {
stopRecording();
}
}, [recordingTime]);
if (isRecording) {
return (
<div className="w-full flex justify-center">
<div className="flex items-center space-x-2">
<LiveAudioVisualizer
mediaRecorder={mediaRecorder}
barWidth={2}
gap={2}
width={140}
height={30}
fftSize={512}
maxDecibels={-10}
minDecibels={-80}
smoothingTimeConstant={0.4}
/>
<span className="text-sm text-muted-foreground">
{Math.floor(recordingTime / 60)}:
{String(recordingTime % 60).padStart(2, "0")}
</span>
<Button
onClick={togglePauseResume}
className="rounded-full shadow w-8 h-8"
size="icon"
>
{isPaused ? (
<PlayIcon
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("continue")}
fill="white"
className="w-4 h-4"
/>
) : (
<PauseIcon
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("pause")}
fill="white"
className="w-4 h-4"
/>
)}
</Button>
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("finish")}
onClick={stopRecording}
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
size="icon"
>
<CheckIcon className="w-4 h-4 text-white" />
</Button>
</div>
</div>
);
}
return (
<div className="w-full">
<div className="flex items-center justify-center">
<Button
type="button"
variant="ghost"
className="aspect-square p-0 h-12 rounded-full bg-red-500 hover:bg-red-500/90"
onClick={() => {
if (isRecording) {
recorder?.stopRecording();
} else {
startRecord();
}
}}
>
{isRecording ? (
<SquareIcon fill="white" className="w-6 h-6 text-white" />
) : (
<MicIcon className="w-6 h-6 text-white" />
)}
</Button>
</div>
<div className="w-full flex items-center">
<div
ref={ref}
className={isRecording ? "w-full mr-4" : "h-0 overflow-hidden"}
></div>
{isRecording && (
<div className="text-muted-foreground text-sm w-24">
{duration.toFixed(1)} / {TEN_MINUTES}
</div>
<div className="w-full flex items-center gap-4 justify-center">
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("record")}
disabled={submitting}
onClick={(event) => {
event.preventDefault();
onStart && onStart();
if (access) {
startRecording();
} else {
askForMediaAccess();
}
}}
className="rounded-full shadow w-10 h-10"
size="icon"
>
{submitting ? (
<LoaderIcon className="w-6 h-6 animate-spin" />
) : (
<MicIcon className="w-6 h-6" />
)}
</div>
</Button>
</div>
);
};

View File

@@ -34,7 +34,7 @@ import {
export default () => {
const navigate = useNavigate();
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [assessments, setAssessments] = useState<PronunciationAssessmentType[]>(
[]
);
@@ -45,6 +45,7 @@ export default () => {
const [deleting, setDeleting] = useState<PronunciationAssessmentType | null>(
null
);
const [sharing, setSharing] = useState<RecordingType | null>(null);
const handleDelete = async (assessment: PronunciationAssessmentType) => {
try {
@@ -56,6 +57,43 @@ export default () => {
}
};
const handleShare = async () => {
if (!sharing) return;
if (!sharing.isSynced) {
try {
await EnjoyApp.recordings.sync(sharing.id);
} catch (error) {
toast.error(t("shareFailed"), { description: error.message });
return;
}
}
if (!sharing.uploadedAt) {
try {
await EnjoyApp.recordings.upload(sharing.id);
} catch (error) {
toast.error(t("shareFailed"), { description: error.message });
return;
}
}
webApi
.createPost({
targetId: sharing.id,
targetType: "Recording",
})
.then(() => {
toast.success(t("sharedSuccessfully"), {
description: t("sharedRecording"),
});
})
.catch((error) => {
toast.error(t("shareFailed"), {
description: error.message,
});
});
};
const fetchAssessments = (params?: { offset: number; limit?: number }) => {
const { offset = 0, limit = 10 } = params || {};
if (offset > 0 && !hasMore) return;
@@ -144,6 +182,7 @@ export default () => {
pronunciationAssessment={assessment}
onSelect={setSelecting}
onDelete={setDeleting}
onSharing={setSharing}
/>
))}
</div>
@@ -208,6 +247,28 @@ export default () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(sharing)}
onOpenChange={(value) => {
if (!value) setSharing(null);
}}
>
<AlertDialogContent aria-describedby={undefined}>
<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>
</div>
);
};