From f6001d22e5f7eda5afd7b309c4420cd6dccf6d80 Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 08:45:21 -0500 Subject: [PATCH 1/9] Grammar correction tweaks --- koala/grammar.ts | 100 ++++++++++++++++++++++++++++++ koala/openai.ts | 57 ----------------- koala/quiz-evaluators/speaking.ts | 4 +- 3 files changed, 102 insertions(+), 59 deletions(-) create mode 100644 koala/grammar.ts diff --git a/koala/grammar.ts b/koala/grammar.ts new file mode 100644 index 0000000..08637c3 --- /dev/null +++ b/koala/grammar.ts @@ -0,0 +1,100 @@ +import OpenAI from "openai"; +import { ChatCompletionCreateParamsNonStreaming } from "openai/resources"; +import { errorReport } from "./error-report"; +import { YesNo } from "./shared-types"; +import { z } from "zod"; +import { zodResponseFormat } from "openai/helpers/zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + errorReport("Missing ENV Var: OPENAI_API_KEY"); +} + +const configuration = { apiKey }; + +export const openai = new OpenAI(configuration); + +export async function gptCall(opts: ChatCompletionCreateParamsNonStreaming) { + return await openai.chat.completions.create(opts); +} + +const zodYesOrNo = z.object({ + response: z.union([ + z.object({ + userWasCorrect: z.literal(true), + }), + z.object({ + userWasCorrect: z.literal(false), + correctedSentence: z.string(), + }), + ]), +}); + +export type Explanation = { response: YesNo; whyNot?: string }; + +type GrammarCorrrectionProps = { + /** The Korean phrase. */ + term: string; + /** An English translation */ + definition: string; + /** Language code like KO */ + langCode: string; + /** What the user said. */ + userInput: string; +}; + +const getLangcode = (lang: string) => { + const names: Record = { + EN: "English", + IT: "Italian", + FR: "French", + ES: "Spanish", + KO: "Korean", + }; + const key = lang.slice(0, 2).toUpperCase(); + return names[key] || lang; +}; + +export const grammarCorrection = async ( + props: GrammarCorrrectionProps, +): Promise => { + // Latest snapshot that supports Structured Outputs + // TODO: Get on mainline 4o when it supports Structured Outputs + const model = "gpt-4o-2024-08-06"; + const { userInput } = props; + const lang = getLangcode(props.langCode); + const prompt = [ + `You are a language assistant helping users improve their ${lang} sentences.`, + `The user wants to say: '${props.definition}' in ${lang}.`, + `They provided: '${userInput}'.`, + `Your task is to determine if the user's input is an acceptable way to express the intended meaning in ${lang}.`, + `If the response is acceptable by ${lang} native speakers, respond with:`, + `{ "response": { "userWasCorrect": true } }`, + `If it is not, respond with:`, + `{ "response": { "userWasCorrect": false, "correctedSentence": "corrected sentence here" } }`, + `Do not include any additional commentary or explanations.`, + `Ensure your response is in valid JSON format.`, + ].join("\n"); + + const resp = await openai.beta.chat.completions.parse({ + messages: [ + { + role: "user", + content: prompt, + }, + ], + model, + max_tokens: 125, + // top_p: 1, + // frequency_penalty: 0, + temperature: 0.1, + response_format: zodResponseFormat(zodYesOrNo, "correct_sentence"), + }); + const correct_sentence = resp.choices[0].message.parsed; + if (correct_sentence) { + if (!correct_sentence.response.userWasCorrect) { + return correct_sentence.response.correctedSentence; + } + } +}; diff --git a/koala/openai.ts b/koala/openai.ts index b6a587b..ff99b49 100644 --- a/koala/openai.ts +++ b/koala/openai.ts @@ -2,8 +2,6 @@ import OpenAI from "openai"; import { ChatCompletionCreateParamsNonStreaming } from "openai/resources"; import { errorReport } from "./error-report"; import { YesNo } from "./shared-types"; -import { z } from "zod"; -import { zodResponseFormat } from "openai/helpers/zod"; const apiKey = process.env.OPENAI_API_KEY; @@ -49,18 +47,6 @@ const SIMPLE_YES_OR_NO = { description: "Answer a yes or no question.", }; -const zodYesOrNo = z.object({ - response: z.union([ - z.object({ - userWasCorrect: z.literal(true), - }), - z.object({ - userWasCorrect: z.literal(false), - correctedSentence: z.string(), - }), - ]), -}); - export type Explanation = { response: YesNo; whyNot?: string }; export const testEquivalence = async ( @@ -97,49 +83,6 @@ export const testEquivalence = async ( return raw.response as YesNo; }; -type GrammarCorrrectionProps = { - term: string; - definition: string; - langCode: string; - userInput: string; -}; - -export const grammarCorrection = async ( - props: GrammarCorrrectionProps, -): Promise => { - // Latest snapshot that supports Structured Outputs - // TODO: Get on mainline 4o when it supports Structured Outputs - const model = "gpt-4o-2024-08-06"; - const { userInput } = props; - const prompt = [ - `I want to say '${props.definition}' in language: ${props.langCode}.`, - `Is '${userInput}' OK?`, - `Correct awkwardness or major grammatical issues, if any.`, - ].join("\n"); - - const resp = await openai.beta.chat.completions.parse({ - messages: [ - { - role: "user", - content: prompt, - }, - ], - model, - max_tokens: 150, - top_p: 1, - frequency_penalty: 0, - stop: ["\n"], - temperature: 0.2, - response_format: zodResponseFormat(zodYesOrNo, "correct_sentence"), - }); - const correct_sentence = resp.choices[0].message.parsed; - if (correct_sentence) { - if (!correct_sentence.response.userWasCorrect) { - return correct_sentence.response.correctedSentence; - } - } -}; - export const translateToEnglish = async (content: string, langCode: string) => { const prompt = `You will be provided with a foreign language sentence (lang code: ${langCode}), and your task is to translate it into English.`; const hm = await gptCall({ diff --git a/koala/quiz-evaluators/speaking.ts b/koala/quiz-evaluators/speaking.ts index 4fe786d..8937cdc 100644 --- a/koala/quiz-evaluators/speaking.ts +++ b/koala/quiz-evaluators/speaking.ts @@ -1,6 +1,5 @@ import { Explanation, - grammarCorrection, testEquivalence, translateToEnglish, } from "@/koala/openai"; @@ -8,6 +7,7 @@ import { QuizEvaluator, QuizEvaluatorOutput } from "./types"; import { strip } from "./evaluator-utils"; import { captureTrainingData } from "./capture-training-data"; import { prismaClient } from "../prisma-client"; +import { grammarCorrection } from "../grammar"; const doGrade = async ( userInput: string, @@ -63,7 +63,7 @@ function gradeWithGrammarCorrection(i: X, what: 1 | 2): QuizEvaluatorOutput { } else { return { result: "fail", - userMessage: `Correct, but say "${i.correction}" instead of "${i.userInput}" (${what}).`, + userMessage: `Say "${i.correction}" instead of "${i.userInput}" (${what}).`, }; } } From f8b4a6cb0449b8b57285555238638391722fd752 Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 09:43:59 -0500 Subject: [PATCH 2/9] Dictation exercise experiment, part II --- koala/play-audio.tsx | 56 +++------------ pages/mirror.tsx | 159 +++++++++++++++++++++++++++---------------- 2 files changed, 113 insertions(+), 102 deletions(-) diff --git a/koala/play-audio.tsx b/koala/play-audio.tsx index 7d9e9fb..c72a9dd 100644 --- a/koala/play-audio.tsx +++ b/koala/play-audio.tsx @@ -1,22 +1,6 @@ let currentlyPlaying = false; -const playAudioBuffer = ( - buffer: AudioBuffer, - context: AudioContext, -): Promise => { - return new Promise((resolve) => { - const source = context.createBufferSource(); - source.buffer = buffer; - source.connect(context.destination); - source.onended = () => { - currentlyPlaying = false; - resolve(); - }; - source.start(0); - }); -}; - -export const playAudio = async (urlOrDataURI: string): Promise => { +export const playAudio = (urlOrDataURI: string): void => { if (!urlOrDataURI) { return; } @@ -26,34 +10,16 @@ export const playAudio = async (urlOrDataURI: string): Promise => { } currentlyPlaying = true; - const audioContext = new AudioContext(); - - try { - let arrayBuffer: ArrayBuffer; - - if (urlOrDataURI.startsWith("data:")) { - // Handle Base64 Data URI - const base64Data = urlOrDataURI.split(",")[1]; - const binaryString = atob(base64Data); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - arrayBuffer = bytes.buffer; - } else { - // Handle external URL - const response = await fetch(urlOrDataURI); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - arrayBuffer = await response.arrayBuffer(); - } - const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - await playAudioBuffer(audioBuffer, audioContext); - } catch (e) { + const audio = new Audio(urlOrDataURI); + audio.onended = () => { currentlyPlaying = false; - throw e; - } + }; + audio.onerror = () => { + currentlyPlaying = false; + }; + audio.play().catch((e) => { + currentlyPlaying = false; + console.error("Audio playback failed:", e); + }); }; diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 042e869..3ccfc24 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -1,95 +1,140 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useVoiceRecorder } from "@/koala/use-recorder"; import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; -import { useVoiceRecorder } from "@/koala/use-recorder"; import { trpc } from "@/koala/trpc-config"; +const TARGET_SENTENCES = ["사람들이 한국말로 말해요.", "한국어로 말해주세요."]; +const TARGET_SENTENCE = TARGET_SENTENCES[0]; // Just an example + export default function Mirror() { - const [transcription, setTranscription] = useState(null); - const [translation, setTranslation] = useState(null); + const [attempts, setAttempts] = useState(0); + const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + const [showTranslation, setShowTranslation] = useState(false); + const [error, setError] = useState(null); + const [targetAudioUrl, setTargetAudioUrl] = useState(null); + const transcribeAudio = trpc.transcribeAudio.useMutation(); const translateText = trpc.translateText.useMutation(); const speakText = trpc.speakText.useMutation(); - const vr = useVoiceRecorder(async (result: Blob) => { - setIsProcessing(true); + useEffect(() => { + const fetchTargetAudio = async () => { + try { + const { url: audioUrl } = await speakText.mutateAsync({ + lang: "ko", + text: TARGET_SENTENCE, + }); + setTargetAudioUrl(audioUrl); + } catch (err) { + setError("Failed to fetch target audio."); + } + }; + fetchTargetAudio(); + }, []); + const handleRecordingResult = async (audioBlob: Blob) => { + setIsProcessing(true); try { // Convert blob to base64 WAV format - const wavBlob = await convertBlobToWav(result); + const wavBlob = await convertBlobToWav(audioBlob); const base64Audio = await blobToBase64(wavBlob); // Transcribe the audio - const { result: korean } = await transcribeAudio.mutateAsync({ + const { result: transcription } = await transcribeAudio.mutateAsync({ audio: base64Audio, lang: "ko", - targetText: "사람들이 한국말로 말해요.", - }); - setTranscription(korean); - - // Translate the transcription to Korean - const { result: translatedText } = await translateText.mutateAsync({ - text: korean, - lang: "ko", - }); - - setTranslation(translatedText); - - // Speak the Korean translation out loud - const { url: audioKO } = await speakText.mutateAsync({ - lang: "ko", - text: korean, - }); - - // Speak the original English transcription via TTS - const { url: audioEN } = await speakText.mutateAsync({ - lang: "en", - text: translatedText, + targetText: TARGET_SENTENCE, }); - // Play back the original voice recording - await playAudio(base64Audio); - await playAudio(audioKO); - await playAudio(audioEN); - } catch (error) { - console.error("Error processing voice recording:", error); + console.log("Transcription:", transcription); + console.log("Target sentence:", TARGET_SENTENCE); + // Compare transcription with target sentence + if (transcription.trim() === TARGET_SENTENCE.trim()) { + setAttempts((prev) => prev + 1); + } + } catch (err) { + setError("Error processing the recording."); } finally { setIsProcessing(false); } - }); + }; + + const vr = useVoiceRecorder(handleRecordingResult); + + const handleClick = async () => { + if (attempts >= 3) { + // User has completed the task + setShowTranslation(true); + return; + } - const handleClick = () => { - if (vr.isRecording) { + if (isRecording) { + // Stop recording vr.stop(); + setIsRecording(false); } else { + // Play the target audio (don't wait for it to finish) + if (targetAudioUrl) { + playAudio(targetAudioUrl); + } else { + setError("Target audio is not available."); + } + setIsRecording(true); vr.start(); } }; - if (vr.error) { - return
Recording error: {JSON.stringify(vr.error)}
; - } + const TranslationDisplay = () => { + const [translation, setTranslation] = useState(null); - return ( -
- + useEffect(() => { + const fetchTranslation = async () => { + try { + const { result: translatedText } = await translateText.mutateAsync({ + text: TARGET_SENTENCE, + lang: "ko", + }); + setTranslation(translatedText); + } catch (err) { + setError("Failed to fetch translation."); + } + }; + fetchTranslation(); + }, []); - {isProcessing &&
Processing your voice...
} + if (error) { + return
Error: {error}
; + } - {transcription && ( -
-

Transcription:

-

{transcription}

-
- )} + if (!translation) { + return
Loading translation...
; + } - {translation && ( + return ( +
+

Translation

+

{translation}

+
+ ); + }; + + return ( +
+ {error &&
Recording error: {error}
} + {showTranslation ? ( + + ) : (
-

Translation (Korean):

-

{translation}

+ +

{attempts} of 3 attempts OK

)}
From be263cbd1799e01cf7b0aa49508bddde02e9706a Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 09:52:36 -0500 Subject: [PATCH 3/9] More refactorings. --- pages/mirror.tsx | 170 ++++++++++++++++++++++++++++------------------- 1 file changed, 103 insertions(+), 67 deletions(-) diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 3ccfc24..d38bf58 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -1,24 +1,89 @@ -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { useVoiceRecorder } from "@/koala/use-recorder"; import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; +// List of target sentences const TARGET_SENTENCES = ["사람들이 한국말로 말해요.", "한국어로 말해주세요."]; -const TARGET_SENTENCE = TARGET_SENTENCES[0]; // Just an example +const TARGET_SENTENCE = TARGET_SENTENCES[0]; // Current target sentence + +// Component to display the translation +const TranslationDisplay = ({ + text, + setError, +}: { + text: string; + setError: (error: string) => void; +}) => { + const [translation, setTranslation] = useState(null); + const translateText = trpc.translateText.useMutation(); + + useEffect(() => { + const fetchTranslation = async () => { + try { + const { result: translatedText } = await translateText.mutateAsync({ + text, + lang: "ko", + }); + setTranslation(translatedText); + } catch (err) { + setError("Failed to fetch translation."); + } + }; + fetchTranslation(); + }, [text]); + + if (!translation) { + return
Loading translation...
; + } + + return ( +
+

Translation

+

{translation}

+
+ ); +}; + +// Component to handle recording controls +const RecordingControls = ({ + isRecording, + isProcessing, + successfulAttempts, + handleClick, +}: { + isRecording: boolean; + isProcessing: boolean; + successfulAttempts: number; + handleClick: () => void; +}) => ( +
+ +

{successfulAttempts} of 3 attempts OK

+
+); export default function Mirror() { - const [attempts, setAttempts] = useState(0); + // State variables + const [successfulAttempts, setSuccessfulAttempts] = useState(0); const [isRecording, setIsRecording] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [showTranslation, setShowTranslation] = useState(false); - const [error, setError] = useState(null); + const [isProcessingRecording, setIsProcessingRecording] = useState(false); + const [hasCompletedAttempts, setHasCompletedAttempts] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const [targetAudioUrl, setTargetAudioUrl] = useState(null); + // TRPC mutations const transcribeAudio = trpc.transcribeAudio.useMutation(); - const translateText = trpc.translateText.useMutation(); const speakText = trpc.speakText.useMutation(); + // Fetch target audio on component mount useEffect(() => { const fetchTargetAudio = async () => { try { @@ -28,16 +93,17 @@ export default function Mirror() { }); setTargetAudioUrl(audioUrl); } catch (err) { - setError("Failed to fetch target audio."); + setErrorMessage("Failed to fetch target audio."); } }; fetchTargetAudio(); }, []); + // Handle the result after recording is finished const handleRecordingResult = async (audioBlob: Blob) => { - setIsProcessing(true); + setIsProcessingRecording(true); try { - // Convert blob to base64 WAV format + // Convert the recorded audio blob to WAV and then to base64 const wavBlob = await convertBlobToWav(audioBlob); const base64Audio = await blobToBase64(wavBlob); @@ -48,95 +114,65 @@ export default function Mirror() { targetText: TARGET_SENTENCE, }); - console.log("Transcription:", transcription); - console.log("Target sentence:", TARGET_SENTENCE); - // Compare transcription with target sentence + // Compare the transcription with the target sentence if (transcription.trim() === TARGET_SENTENCE.trim()) { - setAttempts((prev) => prev + 1); + setSuccessfulAttempts((prev) => prev + 1); } } catch (err) { - setError("Error processing the recording."); + setErrorMessage("Error processing the recording."); } finally { - setIsProcessing(false); + setIsProcessingRecording(false); } }; - const vr = useVoiceRecorder(handleRecordingResult); + // Voice recorder hook + const voiceRecorder = useVoiceRecorder(handleRecordingResult); + // Handle button click const handleClick = async () => { - if (attempts >= 3) { - // User has completed the task - setShowTranslation(true); + if (successfulAttempts >= 3) { + // User has completed the required number of attempts + setHasCompletedAttempts(true); return; } if (isRecording) { // Stop recording - vr.stop(); + voiceRecorder.stop(); setIsRecording(false); } else { - // Play the target audio (don't wait for it to finish) + // Play the target audio if (targetAudioUrl) { playAudio(targetAudioUrl); } else { - setError("Target audio is not available."); + setErrorMessage("Target audio is not available."); } + // Start recording setIsRecording(true); - vr.start(); + voiceRecorder.start(); } }; - const TranslationDisplay = () => { - const [translation, setTranslation] = useState(null); - - useEffect(() => { - const fetchTranslation = async () => { - try { - const { result: translatedText } = await translateText.mutateAsync({ - text: TARGET_SENTENCE, - lang: "ko", - }); - setTranslation(translatedText); - } catch (err) { - setError("Failed to fetch translation."); - } - }; - fetchTranslation(); - }, []); - - if (error) { - return
Error: {error}
; - } - - if (!translation) { - return
Loading translation...
; - } + if (errorMessage) { + return
Error: {errorMessage}
; + } + if (hasCompletedAttempts) { return (
-

Translation

-

{translation}

+
); - }; + } return (
- {error &&
Recording error: {error}
} - {showTranslation ? ( - - ) : ( -
- -

{attempts} of 3 attempts OK

-
- )} +
); } From 06d0b97e442f8dbd7d1c7555911d3b3b3d240d2e Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 10:03:22 -0500 Subject: [PATCH 4/9] Process mirroring items as a list. --- pages/mirror.tsx | 147 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 100 insertions(+), 47 deletions(-) diff --git a/pages/mirror.tsx b/pages/mirror.tsx index d38bf58..746a293 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -5,46 +5,25 @@ import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; // List of target sentences -const TARGET_SENTENCES = ["사람들이 한국말로 말해요.", "한국어로 말해주세요."]; -const TARGET_SENTENCE = TARGET_SENTENCES[0]; // Current target sentence +const TARGET_SENTENCES = [ + "사람들이 한국말로 말해요.", + "한국어로 말해주세요.", + // Add more sentences as needed +]; // Component to display the translation const TranslationDisplay = ({ - text, - setError, + // text, + translation, }: { text: string; - setError: (error: string) => void; -}) => { - const [translation, setTranslation] = useState(null); - const translateText = trpc.translateText.useMutation(); - - useEffect(() => { - const fetchTranslation = async () => { - try { - const { result: translatedText } = await translateText.mutateAsync({ - text, - lang: "ko", - }); - setTranslation(translatedText); - } catch (err) { - setError("Failed to fetch translation."); - } - }; - fetchTranslation(); - }, [text]); - - if (!translation) { - return
Loading translation...
; - } - - return ( -
-

Translation

-

{translation}

-
- ); -}; + translation: string; +}) => ( +
+

Translation

+

{translation}

+
+); // Component to handle recording controls const RecordingControls = ({ @@ -70,26 +49,43 @@ const RecordingControls = ({
); -export default function Mirror() { +// Component to handle quizzing for a single sentence +export const SentenceQuiz = ({ + term, + onNextSentence, + setErrorMessage, +}: { + term: { term: string; definition: string }; + onNextSentence: () => void; + setErrorMessage: (error: string) => void; +}) => { // State variables const [successfulAttempts, setSuccessfulAttempts] = useState(0); const [isRecording, setIsRecording] = useState(false); const [isProcessingRecording, setIsProcessingRecording] = useState(false); const [hasCompletedAttempts, setHasCompletedAttempts] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); const [targetAudioUrl, setTargetAudioUrl] = useState(null); // TRPC mutations const transcribeAudio = trpc.transcribeAudio.useMutation(); const speakText = trpc.speakText.useMutation(); - // Fetch target audio on component mount + // Reset state variables when the term changes + useEffect(() => { + setSuccessfulAttempts(0); + setIsRecording(false); + setIsProcessingRecording(false); + setHasCompletedAttempts(false); + setTargetAudioUrl(null); + }, [term.term]); + + // Fetch target audio when the component mounts or when the term changes useEffect(() => { const fetchTargetAudio = async () => { try { const { url: audioUrl } = await speakText.mutateAsync({ lang: "ko", - text: TARGET_SENTENCE, + text: term.term, }); setTargetAudioUrl(audioUrl); } catch (err) { @@ -97,7 +93,7 @@ export default function Mirror() { } }; fetchTargetAudio(); - }, []); + }, [term.term]); // Handle the result after recording is finished const handleRecordingResult = async (audioBlob: Blob) => { @@ -111,11 +107,11 @@ export default function Mirror() { const { result: transcription } = await transcribeAudio.mutateAsync({ audio: base64Audio, lang: "ko", - targetText: TARGET_SENTENCE, + targetText: term.term, }); // Compare the transcription with the target sentence - if (transcription.trim() === TARGET_SENTENCE.trim()) { + if (transcription.trim() === term.term.trim()) { setSuccessfulAttempts((prev) => prev + 1); } } catch (err) { @@ -153,14 +149,11 @@ export default function Mirror() { } }; - if (errorMessage) { - return
Error: {errorMessage}
; - } - if (hasCompletedAttempts) { return (
- + +
); } @@ -175,4 +168,64 @@ export default function Mirror() { /> ); +}; + +export default function Mirror() { + // State variables + const [terms, setTerms] = useState<{ term: string; definition: string }[]>( + [], + ); + const [currentIndex, setCurrentIndex] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + + // TRPC mutation + const translateText = trpc.translateText.useMutation(); + + // Fetch translations for all sentences + useEffect(() => { + const translateSentences = async () => { + try { + const translatedTerms = await Promise.all( + TARGET_SENTENCES.map(async (sentence) => { + const { result: translation } = await translateText.mutateAsync({ + text: sentence, + lang: "ko", + }); + return { term: sentence, definition: translation }; + }), + ); + setTerms(translatedTerms); + } catch (err) { + setErrorMessage("Failed to translate sentences."); + } + }; + translateSentences(); + }, []); + + if (errorMessage) { + return
Error: {errorMessage}
; + } + + if (terms.length === 0) { + return
Loading sentences...
; + } + + if (currentIndex >= terms.length) { + return ( +
+ +

All sentences completed!

+
+ ); + } + + const currentTerm = terms[currentIndex]; + + return ( + setCurrentIndex(currentIndex + 1)} + setErrorMessage={setErrorMessage} + /> + ); } From 4b458890e4820cf069e420e6fb9bcc26f56c783e Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 10:27:49 -0500 Subject: [PATCH 5/9] Fetch sentences from server. --- koala/routes/get-mirroring-cards.ts | 40 +++++++++++++++ koala/routes/main.ts | 2 + pages/mirror.tsx | 80 +++++++---------------------- 3 files changed, 61 insertions(+), 61 deletions(-) create mode 100644 koala/routes/get-mirroring-cards.ts diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts new file mode 100644 index 0000000..fcf8e7f --- /dev/null +++ b/koala/routes/get-mirroring-cards.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { prismaClient } from "../prisma-client"; +import { procedure } from "../trpc-procedure"; +import { generateLessonAudio } from "../speech"; +import { map } from "radash"; + +export const getMirrorCards = procedure + .input(z.object({})) + .output( + z.array( + z.object({ + id: z.number(), + term: z.string(), + definition: z.string(), + audioUrl: z.string(), + langCode: z.string(), + }), + ), + ) + .mutation(async ({ ctx }) => { + const cards = await prismaClient.card.findMany({ + where: { userId: ctx.user?.id || "000", flagged: false }, + orderBy: [{ createdAt: "desc" }], + take: 200, + }); + // Order by length of 'term' field: + cards.sort((a, b) => a.term.length - b.term.length); + return await map(cards.slice(0, 50), async (card) => { + return { + id: card.id, + term: card.term, + definition: card.definition, + langCode: card.langCode, + audioUrl: await generateLessonAudio({ + card, + lessonType: "listening", + }), + }; + }); + }); diff --git a/koala/routes/main.ts b/koala/routes/main.ts index 34ddbf6..abf5cee 100644 --- a/koala/routes/main.ts +++ b/koala/routes/main.ts @@ -8,6 +8,7 @@ import { exportCards } from "./export-cards"; import { faucet } from "./faucet"; import { flagCard } from "./flag-card"; import { getAllCards } from "./get-all-cards"; +import { getMirrorCards } from "./get-mirroring-cards"; import { getNextQuizzes } from "./get-next-quizzes"; import { getOneCard } from "./get-one-card"; import { getPlaybackAudio } from "./get-playback-audio"; @@ -47,6 +48,7 @@ export const appRouter = router({ transcribeAudio, translateText, viewTrainingData, + getMirrorCards, }); export type AppRouter = typeof appRouter; diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 746a293..7a21f98 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -4,18 +4,7 @@ import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; -// List of target sentences -const TARGET_SENTENCES = [ - "사람들이 한국말로 말해요.", - "한국어로 말해주세요.", - // Add more sentences as needed -]; - -// Component to display the translation -const TranslationDisplay = ({ - // text, - translation, -}: { +const TranslationDisplay = ({ translation}: { text: string; translation: string; }) => ( @@ -51,11 +40,11 @@ const RecordingControls = ({ // Component to handle quizzing for a single sentence export const SentenceQuiz = ({ - term, + card, onNextSentence, setErrorMessage, }: { - term: { term: string; definition: string }; + card: { term: string; definition: string; audioUrl: string }; onNextSentence: () => void; setErrorMessage: (error: string) => void; }) => { @@ -64,11 +53,9 @@ export const SentenceQuiz = ({ const [isRecording, setIsRecording] = useState(false); const [isProcessingRecording, setIsProcessingRecording] = useState(false); const [hasCompletedAttempts, setHasCompletedAttempts] = useState(false); - const [targetAudioUrl, setTargetAudioUrl] = useState(null); // TRPC mutations const transcribeAudio = trpc.transcribeAudio.useMutation(); - const speakText = trpc.speakText.useMutation(); // Reset state variables when the term changes useEffect(() => { @@ -76,24 +63,7 @@ export const SentenceQuiz = ({ setIsRecording(false); setIsProcessingRecording(false); setHasCompletedAttempts(false); - setTargetAudioUrl(null); - }, [term.term]); - - // Fetch target audio when the component mounts or when the term changes - useEffect(() => { - const fetchTargetAudio = async () => { - try { - const { url: audioUrl } = await speakText.mutateAsync({ - lang: "ko", - text: term.term, - }); - setTargetAudioUrl(audioUrl); - } catch (err) { - setErrorMessage("Failed to fetch target audio."); - } - }; - fetchTargetAudio(); - }, [term.term]); + }, [card.term]); // Handle the result after recording is finished const handleRecordingResult = async (audioBlob: Blob) => { @@ -107,11 +77,11 @@ export const SentenceQuiz = ({ const { result: transcription } = await transcribeAudio.mutateAsync({ audio: base64Audio, lang: "ko", - targetText: term.term, + targetText: card.term, }); // Compare the transcription with the target sentence - if (transcription.trim() === term.term.trim()) { + if (transcription.trim() === card.term.trim()) { setSuccessfulAttempts((prev) => prev + 1); } } catch (err) { @@ -137,12 +107,7 @@ export const SentenceQuiz = ({ voiceRecorder.stop(); setIsRecording(false); } else { - // Play the target audio - if (targetAudioUrl) { - playAudio(targetAudioUrl); - } else { - setErrorMessage("Target audio is not available."); - } + playAudio(card.audioUrl); // Start recording setIsRecording(true); voiceRecorder.start(); @@ -153,7 +118,7 @@ export const SentenceQuiz = ({ return (
- +
); } @@ -169,37 +134,30 @@ export const SentenceQuiz = ({ ); }; +type Card = { + term: string; + definition: string; + audioUrl: string; +}; export default function Mirror() { // State variables - const [terms, setTerms] = useState<{ term: string; definition: string }[]>( - [], - ); + const [terms, setTerms] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [errorMessage, setErrorMessage] = useState(null); - // TRPC mutation - const translateText = trpc.translateText.useMutation(); - + const getMirrorCards = trpc.getMirrorCards.useMutation({}); // Fetch translations for all sentences useEffect(() => { - const translateSentences = async () => { + const getSentences = async () => { try { - const translatedTerms = await Promise.all( - TARGET_SENTENCES.map(async (sentence) => { - const { result: translation } = await translateText.mutateAsync({ - text: sentence, - lang: "ko", - }); - return { term: sentence, definition: translation }; - }), - ); + const translatedTerms = await getMirrorCards.mutateAsync({}); setTerms(translatedTerms); } catch (err) { setErrorMessage("Failed to translate sentences."); } }; - translateSentences(); + getSentences(); }, []); if (errorMessage) { @@ -223,7 +181,7 @@ export default function Mirror() { return ( setCurrentIndex(currentIndex + 1)} setErrorMessage={setErrorMessage} /> From d176b7cec2bb8afe331e05e3d4f0b9c916a76b2e Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 10:43:48 -0500 Subject: [PATCH 6/9] Fix length order. --- koala/routes/get-mirroring-cards.ts | 7 +++--- pages/mirror.tsx | 38 ++++++++++++++++++----------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts index fcf8e7f..a62d8da 100644 --- a/koala/routes/get-mirroring-cards.ts +++ b/koala/routes/get-mirroring-cards.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { prismaClient } from "../prisma-client"; import { procedure } from "../trpc-procedure"; import { generateLessonAudio } from "../speech"; -import { map } from "radash"; +import { map, shuffle } from "radash"; export const getMirrorCards = procedure .input(z.object({})) @@ -24,8 +24,9 @@ export const getMirrorCards = procedure take: 200, }); // Order by length of 'term' field: - cards.sort((a, b) => a.term.length - b.term.length); - return await map(cards.slice(0, 50), async (card) => { + cards.sort((a, b) => b.term.length - a.term.length); + const shortList = shuffle(cards.slice(0, 100)).slice(0, 10); + return await map(shortList, async (card) => { return { id: card.id, term: card.term, diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 7a21f98..fb660c5 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -4,7 +4,9 @@ import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; -const TranslationDisplay = ({ translation}: { +const TranslationDisplay = ({ + translation, +}: { text: string; translation: string; }) => ( @@ -17,7 +19,6 @@ const TranslationDisplay = ({ translation}: { // Component to handle recording controls const RecordingControls = ({ isRecording, - isProcessing, successfulAttempts, handleClick, }: { @@ -25,18 +26,26 @@ const RecordingControls = ({ isProcessing: boolean; successfulAttempts: number; handleClick: () => void; -}) => ( -
- -

{successfulAttempts} of 3 attempts OK

-
-); +}) => { + let message: string; + if (isRecording) { + message = "Recording..."; + } else { + message = "Start recording"; + } + if (successfulAttempts >= 3) { + message = "Next"; + } + + return ( +
+ +

{successfulAttempts} of 3 attempts OK

+
+ ); +}; // Component to handle quizzing for a single sentence export const SentenceQuiz = ({ @@ -131,6 +140,7 @@ export const SentenceQuiz = ({ successfulAttempts={successfulAttempts} handleClick={handleClick} /> + {successfulAttempts < 3 && card.term} ); }; From 77e803e561af8913c222d295a2522da9fb1da3d9 Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 16:29:14 -0500 Subject: [PATCH 7/9] Experiment => prototype. --- koala/routes/get-mirroring-cards.ts | 5 +++++ pages/mirror.tsx | 27 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts index a62d8da..e348fdd 100644 --- a/koala/routes/get-mirroring-cards.ts +++ b/koala/routes/get-mirroring-cards.ts @@ -13,6 +13,7 @@ export const getMirrorCards = procedure term: z.string(), definition: z.string(), audioUrl: z.string(), + translationAudioUrl: z.string(), langCode: z.string(), }), ), @@ -32,6 +33,10 @@ export const getMirrorCards = procedure term: card.term, definition: card.definition, langCode: card.langCode, + translationAudioUrl: await generateLessonAudio({ + card, + lessonType: "speaking", + }), audioUrl: await generateLessonAudio({ card, lessonType: "listening", diff --git a/pages/mirror.tsx b/pages/mirror.tsx index fb660c5..26389f6 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -4,11 +4,20 @@ import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; +type Card = { + term: string; + definition: string; + translationAudioUrl: string; + audioUrl: string; +}; + const TranslationDisplay = ({ translation, + // Not used yet - please auto play the audio. + translationAudioUrl, }: { - text: string; translation: string; + translationAudioUrl: string; }) => (

Translation

@@ -39,9 +48,7 @@ const RecordingControls = ({ return (
- +

{successfulAttempts} of 3 attempts OK

); @@ -53,7 +60,7 @@ export const SentenceQuiz = ({ onNextSentence, setErrorMessage, }: { - card: { term: string; definition: string; audioUrl: string }; + card: Card; onNextSentence: () => void; setErrorMessage: (error: string) => void; }) => { @@ -127,7 +134,10 @@ export const SentenceQuiz = ({ return (
- +
); } @@ -144,11 +154,6 @@ export const SentenceQuiz = ({
); }; -type Card = { - term: string; - definition: string; - audioUrl: string; -}; export default function Mirror() { // State variables From 096244b0408d32bb2ee0989ae717dbaf6b580c8a Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Sun, 29 Sep 2024 16:34:03 -0500 Subject: [PATCH 8/9] Attempt 1 --- koala/play-audio.tsx | 39 +++++----- koala/routes/get-mirroring-cards.ts | 2 +- pages/mirror.tsx | 114 ++++++++++++++-------------- 3 files changed, 77 insertions(+), 78 deletions(-) diff --git a/koala/play-audio.tsx b/koala/play-audio.tsx index c72a9dd..b1f1513 100644 --- a/koala/play-audio.tsx +++ b/koala/play-audio.tsx @@ -1,25 +1,28 @@ let currentlyPlaying = false; -export const playAudio = (urlOrDataURI: string): void => { - if (!urlOrDataURI) { - return; - } +export const playAudio = (urlOrDataURI: string) => { + return new Promise((resolve, reject) => { + if (!urlOrDataURI) { + return; + } - if (currentlyPlaying) { - return; - } + if (currentlyPlaying) { + return; + } - currentlyPlaying = true; + currentlyPlaying = true; - const audio = new Audio(urlOrDataURI); - audio.onended = () => { - currentlyPlaying = false; - }; - audio.onerror = () => { - currentlyPlaying = false; - }; - audio.play().catch((e) => { - currentlyPlaying = false; - console.error("Audio playback failed:", e); + const ok = () => { + currentlyPlaying = false; + resolve(""); + }; + + const audio = new Audio(urlOrDataURI); + audio.onended = ok; + audio.onerror = ok; + audio.play().catch((e) => { + reject(e); + console.error("Audio playback failed:", e); + }); }); }; diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts index e348fdd..cd8770e 100644 --- a/koala/routes/get-mirroring-cards.ts +++ b/koala/routes/get-mirroring-cards.ts @@ -26,7 +26,7 @@ export const getMirrorCards = procedure }); // Order by length of 'term' field: cards.sort((a, b) => b.term.length - a.term.length); - const shortList = shuffle(cards.slice(0, 100)).slice(0, 10); + const shortList = shuffle(cards.slice(0, 100)).slice(0, 5); return await map(shortList, async (card) => { return { id: card.id, diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 26389f6..92aba87 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -11,29 +11,17 @@ type Card = { audioUrl: string; }; -const TranslationDisplay = ({ - translation, - // Not used yet - please auto play the audio. - translationAudioUrl, -}: { - translation: string; - translationAudioUrl: string; -}) => ( -
-

Translation

-

{translation}

-
-); - -// Component to handle recording controls const RecordingControls = ({ isRecording, successfulAttempts, + failedAttempts, + isProcessingRecording, handleClick, }: { isRecording: boolean; - isProcessing: boolean; successfulAttempts: number; + failedAttempts: number; + isProcessingRecording: boolean; handleClick: () => void; }) => { let message: string; @@ -42,14 +30,16 @@ const RecordingControls = ({ } else { message = "Start recording"; } - if (successfulAttempts >= 3) { - message = "Next"; - } return (
- -

{successfulAttempts} of 3 attempts OK

+ +

{successfulAttempts} repetitions correct.

+

{isProcessingRecording ? 1 : 0} repetitions awaiting grade.

+

{failedAttempts} repetitions failed.

+

{3 - successfulAttempts} repetitions left.

); }; @@ -57,18 +47,18 @@ const RecordingControls = ({ // Component to handle quizzing for a single sentence export const SentenceQuiz = ({ card, - onNextSentence, setErrorMessage, + onCardCompleted, }: { card: Card; - onNextSentence: () => void; setErrorMessage: (error: string) => void; + onCardCompleted: () => void; }) => { // State variables const [successfulAttempts, setSuccessfulAttempts] = useState(0); + const [failedAttempts, setFailedAttempts] = useState(0); const [isRecording, setIsRecording] = useState(false); const [isProcessingRecording, setIsProcessingRecording] = useState(false); - const [hasCompletedAttempts, setHasCompletedAttempts] = useState(false); // TRPC mutations const transcribeAudio = trpc.transcribeAudio.useMutation(); @@ -76,9 +66,9 @@ export const SentenceQuiz = ({ // Reset state variables when the term changes useEffect(() => { setSuccessfulAttempts(0); + setFailedAttempts(0); setIsRecording(false); setIsProcessingRecording(false); - setHasCompletedAttempts(false); }, [card.term]); // Handle the result after recording is finished @@ -99,6 +89,8 @@ export const SentenceQuiz = ({ // Compare the transcription with the target sentence if (transcription.trim() === card.term.trim()) { setSuccessfulAttempts((prev) => prev + 1); + } else { + setFailedAttempts((prev) => prev + 1); } } catch (err) { setErrorMessage("Error processing the recording."); @@ -113,8 +105,7 @@ export const SentenceQuiz = ({ // Handle button click const handleClick = async () => { if (successfulAttempts >= 3) { - // User has completed the required number of attempts - setHasCompletedAttempts(true); + // Do nothing if already completed return; } @@ -130,27 +121,27 @@ export const SentenceQuiz = ({ } }; - if (hasCompletedAttempts) { - return ( -
- - -
- ); - } + // Effect to handle successful completion + useEffect(() => { + if (successfulAttempts >= 3) { + // Play the translation audio + playAudio(card.translationAudioUrl).then(() => { + // After audio finishes, proceed to next sentence + onCardCompleted(); + }); + } + }, [successfulAttempts]); return (
- {successfulAttempts < 3 && card.term} + {successfulAttempts < 3 &&

{card.term}

}
); }; @@ -163,18 +154,32 @@ export default function Mirror() { const getMirrorCards = trpc.getMirrorCards.useMutation({}); // Fetch translations for all sentences + const fetchCards = async () => { + try { + const translatedTerms = await getMirrorCards.mutateAsync({}); + setTerms(translatedTerms); + setCurrentIndex(0); + } catch (err) { + setErrorMessage("Failed to fetch sentences."); + } + }; + useEffect(() => { - const getSentences = async () => { - try { - const translatedTerms = await getMirrorCards.mutateAsync({}); - setTerms(translatedTerms); - } catch (err) { - setErrorMessage("Failed to translate sentences."); - } - }; - getSentences(); + fetchCards(); }, []); + const handleCardCompleted = () => { + setCurrentIndex((prevIndex) => prevIndex + 1); + }; + + // When the list is emptied, re-fetch more cards from the server + useEffect(() => { + if (currentIndex >= terms.length) { + // Re-fetch more cards from the server + fetchCards(); + } + }, [currentIndex, terms.length]); + if (errorMessage) { return
Error: {errorMessage}
; } @@ -183,21 +188,12 @@ export default function Mirror() { return
Loading sentences...
; } - if (currentIndex >= terms.length) { - return ( -
- -

All sentences completed!

-
- ); - } - const currentTerm = terms[currentIndex]; return ( setCurrentIndex(currentIndex + 1)} + onCardCompleted={handleCardCompleted} setErrorMessage={setErrorMessage} /> ); From 41187898efddabe14d9165922138b27bd21a0de4 Mon Sep 17 00:00:00 2001 From: Rick Carlino Date: Mon, 30 Sep 2024 20:25:21 -0500 Subject: [PATCH 9/9] Track mirror repetitions. --- koala/routes/get-mirroring-cards.ts | 2 +- pages/mirror.tsx | 54 ++++++++++--------- .../migration.sql | 2 + prisma/schema.prisma | 43 +++++++-------- 4 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 prisma/migrations/20241001010920_add_mirror_repetition_count/migration.sql diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts index cd8770e..1893522 100644 --- a/koala/routes/get-mirroring-cards.ts +++ b/koala/routes/get-mirroring-cards.ts @@ -21,7 +21,7 @@ export const getMirrorCards = procedure .mutation(async ({ ctx }) => { const cards = await prismaClient.card.findMany({ where: { userId: ctx.user?.id || "000", flagged: false }, - orderBy: [{ createdAt: "desc" }], + orderBy: [{ mirrorRepetitionCount: "asc" }], take: 200, }); // Order by length of 'term' field: diff --git a/pages/mirror.tsx b/pages/mirror.tsx index 92aba87..2aa813f 100644 --- a/pages/mirror.tsx +++ b/pages/mirror.tsx @@ -3,6 +3,7 @@ import { useVoiceRecorder } from "@/koala/use-recorder"; import { playAudio } from "@/koala/play-audio"; import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; import { trpc } from "@/koala/trpc-config"; +import { useHotkeys } from "@mantine/hooks"; type Card = { term: string; @@ -63,6 +64,31 @@ export const SentenceQuiz = ({ // TRPC mutations const transcribeAudio = trpc.transcribeAudio.useMutation(); + // Voice recorder hook + const voiceRecorder = useVoiceRecorder(handleRecordingResult); + + // Handle button click + const handleClick = async () => { + if (successfulAttempts >= 3) { + // Do nothing if already completed + return; + } + + if (isRecording) { + // Stop recording + voiceRecorder.stop(); + setIsRecording(false); + } else { + playAudio(card.audioUrl); + // Start recording + setIsRecording(true); + voiceRecorder.start(); + } + }; + + // Use hotkeys to trigger handleClick on space bar press + useHotkeys([["space", handleClick]]); + // Reset state variables when the term changes useEffect(() => { setSuccessfulAttempts(0); @@ -72,7 +98,7 @@ export const SentenceQuiz = ({ }, [card.term]); // Handle the result after recording is finished - const handleRecordingResult = async (audioBlob: Blob) => { + async function handleRecordingResult(audioBlob: Blob) { setIsProcessingRecording(true); try { // Convert the recorded audio blob to WAV and then to base64 @@ -97,29 +123,7 @@ export const SentenceQuiz = ({ } finally { setIsProcessingRecording(false); } - }; - - // Voice recorder hook - const voiceRecorder = useVoiceRecorder(handleRecordingResult); - - // Handle button click - const handleClick = async () => { - if (successfulAttempts >= 3) { - // Do nothing if already completed - return; - } - - if (isRecording) { - // Stop recording - voiceRecorder.stop(); - setIsRecording(false); - } else { - playAudio(card.audioUrl); - // Start recording - setIsRecording(true); - voiceRecorder.start(); - } - }; + } // Effect to handle successful completion useEffect(() => { @@ -141,7 +145,7 @@ export const SentenceQuiz = ({ failedAttempts={failedAttempts} handleClick={handleClick} /> - {successfulAttempts < 3 &&

{card.term}

} + {successfulAttempts === 0 &&

{card.term}

} ); }; diff --git a/prisma/migrations/20241001010920_add_mirror_repetition_count/migration.sql b/prisma/migrations/20241001010920_add_mirror_repetition_count/migration.sql new file mode 100644 index 0000000..0b4fa90 --- /dev/null +++ b/prisma/migrations/20241001010920_add_mirror_repetition_count/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Card" ADD COLUMN "mirrorRepetitionCount" INTEGER DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7cdf453..05a63b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,19 +75,20 @@ model VerificationToken { } model Card { - id Int @id @default(autoincrement()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String - flagged Boolean @default(false) - term String - definition String - langCode String + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + flagged Boolean @default(false) + term String + definition String + langCode String // Gender can be "M"ale, "F"emale, or "N"eutral - gender String @default("N") - createdAt DateTime @default(now()) - Quiz Quiz[] - imageBlobId String? - SpeakingCorrection SpeakingCorrection[] + gender String @default("N") + createdAt DateTime @default(now()) + Quiz Quiz[] + imageBlobId String? + SpeakingCorrection SpeakingCorrection[] + mirrorRepetitionCount Int? @default(0) @@unique([userId, term]) } @@ -137,13 +138,13 @@ model TrainingData { } model SpeakingCorrection { - id Int @id @default(autoincrement()) - cardId Int - Card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - isCorrect Boolean - term String - definition String - userInput String - correction String @default("") + id Int @id @default(autoincrement()) + cardId Int + Card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + isCorrect Boolean + term String + definition String + userInput String + correction String @default("") }