From 3a561f7fda05134e903eabe161a9b3628056c616 Mon Sep 17 00:00:00 2001 From: an-lee Date: Tue, 20 Aug 2024 17:43:24 +0800 Subject: [PATCH] Improve assessment result display (#998) * update media caption tabs * refactor recording detail * word play in assessment result * update style --- .../media-captions/media-caption-tabs.tsx | 8 +- .../components/misc/wavesurfer-player.tsx | 4 +- ...onunciation-assessment-fulltext-result.tsx | 8 +- .../pronunciation-assessment-word-result.tsx | 148 ++++++++++-------- .../renderer/components/recordings/index.ts | 1 - .../recordings/recording-detail.tsx | 27 +--- .../recordings/recording-player.tsx | 147 ----------------- 7 files changed, 102 insertions(+), 241 deletions(-) delete mode 100644 enjoy/src/renderer/components/recordings/recording-player.tsx diff --git a/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx b/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx index 336fa861..6436cd17 100644 --- a/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx +++ b/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx @@ -26,7 +26,7 @@ export const MediaCaptionTabs = (props: { children, } = props; - const [tab, setTab] = useState("note"); + const [tab, setTab] = useState("translation"); if (!caption) return null; @@ -51,12 +51,12 @@ export const MediaCaptionTabs = (props: { - - {t("captionTabs.note")} - {t("captionTabs.translation")} + + {t("captionTabs.note")} + {t("captionTabs.analysis")} diff --git a/enjoy/src/renderer/components/misc/wavesurfer-player.tsx b/enjoy/src/renderer/components/misc/wavesurfer-player.tsx index 00ee90dd..b2d7578f 100644 --- a/enjoy/src/renderer/components/misc/wavesurfer-player.tsx +++ b/enjoy/src/renderer/components/misc/wavesurfer-player.tsx @@ -158,7 +158,7 @@ export const WavesurferPlayer = (props: { } return ( - <> +
{secondsToTimestamp(duration)} @@ -198,6 +198,6 @@ export const WavesurferPlayer = (props: { ref={containerRef} >
- + ); }; diff --git a/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-fulltext-result.tsx b/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-fulltext-result.tsx index 0181e3d7..b6c5451c 100644 --- a/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-fulltext-result.tsx +++ b/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-fulltext-result.tsx @@ -7,9 +7,9 @@ import { InfoIcon } from "lucide-react"; export const PronunciationAssessmentFulltextResult = (props: { words: PronunciationAssessmentWordResultType[]; currentTime?: number; - onSeek?: (time: number) => void; + src?: string; }) => { - const { words, currentTime, onSeek } = props; + const { words, currentTime, src } = props; const [errorStats, setErrorStats] = useState({ mispronunciation: 0, omission: 0, @@ -57,14 +57,14 @@ export const PronunciationAssessmentFulltextResult = (props: { return (
-
+
{words.map((result, index: number) => ( ))}
diff --git a/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-word-result.tsx b/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-word-result.tsx index 272b5bd6..d64faa26 100644 --- a/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-word-result.tsx +++ b/enjoy/src/renderer/components/pronunciation-assessments/pronunciation-assessment-word-result.tsx @@ -1,12 +1,15 @@ import { t } from "i18next"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, + Popover, + PopoverTrigger, + PopoverContent, + Button, } from "@renderer/components/ui"; +import { Volume2Icon } from "lucide-react"; +import { useEffect, useRef } from "react"; export const PronunciationAssessmentWordResult = (props: { + src?: string; result: PronunciationAssessmentWordResultType; errorDisplay?: { mispronunciation: boolean; @@ -17,7 +20,6 @@ export const PronunciationAssessmentWordResult = (props: { monotone: boolean; }; currentTime?: number; - onSeek?: (time: number) => void; }) => { const { result, @@ -30,9 +32,10 @@ export const PronunciationAssessmentWordResult = (props: { monotone: true, }, currentTime = 0, - onSeek, } = props; + const audio = useRef(null); + const WordDisplay = { None: , Mispronunciation: errorDisplay.mispronunciation ? ( @@ -67,71 +70,90 @@ export const PronunciationAssessmentWordResult = (props: { ), }[result.pronunciationAssessment.errorType]; + const play = () => { + const { offset, duration } = result; + + // create a new audio element and play the segment + audio.current.src = `${props.src}#t=${(offset * 1.0) / 1e7},${ + ((offset + duration) * 1.0) / 1e7 + }`; + audio.current.play(); + }; + + useEffect(() => { + if (!audio.current) { + audio.current = new Audio(); + } + }, []); + return ( - - - + + +
{ - onSeek && onSeek(result.offset / 1e7); - }} - className="text-center mb-3" + className={`${ + currentTime * 1e7 >= result.offset && + currentTime * 1e7 < result.offset + result.duration + ? "underline" + : "" + } underline-offset-4`} > -
+ {WordDisplay} +
+
+ {result.phonemes.map((phoneme, index) => ( + + {phoneme.phoneme} + + ))} +
+
+ + + + {result.phonemes.length > 0 ? ( + <> +
+ {t("score")}: + + {result.pronunciationAssessment.accuracyScore} + +
+
{result.phonemes.map((phoneme, index) => ( - - {phoneme.phoneme} - +
+
{phoneme.phoneme}
+
+ {phoneme.pronunciationAssessment.accuracyScore} +
+
))}
-
= result.offset && - currentTime * 1e7 < result.offset + result.duration - ? "underline" - : "" - } underline-offset-4`} - > - {WordDisplay} -
+ + ) : ( +
+ {t( + `models.pronunciationAssessment.errors.${result.pronunciationAssessment.errorType.toLowerCase()}` + )}
- + )} - - {result.phonemes.length > 0 ? ( - <> -
- {t("score")}: - - {result.pronunciationAssessment.accuracyScore} - -
-
- {result.phonemes.map((phoneme, index) => ( -
-
{phoneme.phoneme}
-
- {phoneme.pronunciationAssessment.accuracyScore} -
-
- ))} -
- - ) : ( -
- {t( - `models.pronunciationAssessment.errors.${result.pronunciationAssessment.errorType.toLowerCase()}` - )} -
- )} -
- - +
+ +
+
+ ); }; diff --git a/enjoy/src/renderer/components/recordings/index.ts b/enjoy/src/renderer/components/recordings/index.ts index d470d0b9..d9abf31c 100644 --- a/enjoy/src/renderer/components/recordings/index.ts +++ b/enjoy/src/renderer/components/recordings/index.ts @@ -1,4 +1,3 @@ -export * from "./recording-player"; export * from "./recording-calendar"; export * from "./recording-activities"; export * from "./recording-stats"; diff --git a/enjoy/src/renderer/components/recordings/recording-detail.tsx b/enjoy/src/renderer/components/recordings/recording-detail.tsx index 998c5647..ef2fb332 100644 --- a/enjoy/src/renderer/components/recordings/recording-detail.tsx +++ b/enjoy/src/renderer/components/recordings/recording-detail.tsx @@ -1,7 +1,7 @@ import { - RecordingPlayer, PronunciationAssessmentFulltextResult, PronunciationAssessmentScoreResult, + WavesurferPlayer, } from "@renderer/components"; import { Separator, ScrollArea, toast } from "@renderer/components/ui"; import { useState, useContext, useEffect } from "react"; @@ -23,11 +23,6 @@ export const RecordingDetail = (props: { ); const { result } = pronunciationAssessment || {}; const [currentTime, setCurrentTime] = useState(0); - const [seek, setSeek] = useState<{ - seekTo: number; - timestamp: number; - }>(); - const [isPlaying, setIsPlaying] = useState(false); const { learningLanguage } = useContext(AppSettingsProviderContext); const { createAssessment } = usePronunciationAssessments(); @@ -61,13 +56,11 @@ export const RecordingDetail = (props: { return (
-
- setCurrentTime(time)} - seek={seek} +
+
@@ -77,13 +70,7 @@ export const RecordingDetail = (props: { { - setSeek({ - seekTo: time, - timestamp: Date.now(), - }); - setIsPlaying(true); - }} + src={recording.src} /> ) : ( diff --git a/enjoy/src/renderer/components/recordings/recording-player.tsx b/enjoy/src/renderer/components/recordings/recording-player.tsx deleted file mode 100644 index 18062556..00000000 --- a/enjoy/src/renderer/components/recordings/recording-player.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useEffect, useState, useRef, useCallback } from "react"; -import WaveSurfer from "wavesurfer.js"; -import { renderPitchContour } from "@renderer/lib/utils"; -import { extractFrequencies } from "@/utils"; -import { Button, Skeleton } from "@renderer/components/ui"; -import { PlayIcon, PauseIcon } from "lucide-react"; -import { useIntersectionObserver } from "@uidotdev/usehooks"; - -export const RecordingPlayer = (props: { - recording: RecordingType; - isPlaying: boolean; - setIsPlaying: (isPlaying: boolean) => void; - id?: string; - height?: number; - onCurrentTimeChange?: (time: number) => void; - seek?: { - seekTo: number; - timestamp: number; - }; -}) => { - const { - recording, - height = 100, - onCurrentTimeChange, - seek, - isPlaying, - setIsPlaying, - } = props; - const [wavesurfer, setWavesurfer] = useState(null); - const containerRef = useRef(); - const [ref, entry] = useIntersectionObserver({ - threshold: 0, - }); - const [initialized, setInitialized] = useState(false); - - const onPlayClick = useCallback(() => { - wavesurfer.playPause(); - }, [wavesurfer]); - - useEffect(() => { - // use the intersection observer to only create the wavesurfer instance - // when the player is visible - if (!entry?.isIntersecting) return; - if (!recording?.src) return; - if (wavesurfer) return; - - const ws = WaveSurfer.create({ - container: containerRef.current, - url: recording.src, - height, - barWidth: 1, - cursorWidth: 0, - autoCenter: false, - autoScroll: true, - hideScrollbar: true, - minPxPerSec: 100, - waveColor: "#ddd", - normalize: true, - progressColor: "rgba(0, 0, 0, 0.25)", - }); - - setWavesurfer(ws); - }, [recording, entry]); - - useEffect(() => { - if (!wavesurfer) return; - - const subscriptions = [ - wavesurfer.on("play", () => setIsPlaying(true)), - wavesurfer.on("pause", () => setIsPlaying(false)), - wavesurfer.on("timeupdate", (time: number) => { - onCurrentTimeChange?.(time); - }), - wavesurfer.on("ready", () => { - const peaks = wavesurfer.getDecodedData().getChannelData(0); - const sampleRate = wavesurfer.options.sampleRate; - const data = extractFrequencies({ peaks, sampleRate }); - setTimeout(() => { - renderPitchContour({ - wrapper: wavesurfer.getWrapper(), - canvasId: `pitch-contour-${recording.id}-canvas`, - labels: new Array(data.length).fill(""), - datasets: [ - { - data, - cubicInterpolationMode: "monotone", - }, - ], - }); - }, 1000); - setInitialized(true); - }), - ]; - - return () => { - subscriptions.forEach((unsub) => unsub()); - wavesurfer?.destroy(); - }; - }, [wavesurfer]); - - useEffect(() => { - if (!wavesurfer) return; - if (!seek?.seekTo) return; - - wavesurfer.setTime(parseFloat(seek.seekTo.toFixed(6))); - }, [seek, wavesurfer]); - - useEffect(() => { - if (!wavesurfer) return; - - if (isPlaying) { - wavesurfer.play(); - } else { - wavesurfer.pause(); - } - }, [isPlaying, wavesurfer]); - - return ( -
- {!initialized && ( -
- - - -
- )} - -
- -
- -
-
- ); -};