From 74d5c7c7ff828986843e40989f27d4cc83b54375 Mon Sep 17 00:00:00 2001 From: Gitead Date: Thu, 3 Sep 2026 21:28:57 -0400 Subject: [PATCH] feat: wire up mic/speaker and Google Translate backend for live translation Frontend uses the Web Speech API for speech-to-text and text-to-speech (no key needed). Adds POST /api/translate backed by Google Cloud Translation API v2, gated on GOOGLE_TRANSLATE_API_KEY (placeholder added to .env/.env.example, unset by default). Co-Authored-By: Claude Sonnet 5 --- apps/Backend/.env | 3 +- apps/Backend/.env.example | 3 +- apps/Backend/src/routes/index.ts | 2 + apps/Backend/src/routes/translate.ts | 50 +++ .../patient-connection/live-translate.tsx | 308 +++++++++++++----- 5 files changed, 281 insertions(+), 85 deletions(-) create mode 100644 apps/Backend/src/routes/translate.ts diff --git a/apps/Backend/.env b/apps/Backend/.env index b0c7f8f1..681ec518 100755 --- a/apps/Backend/.env +++ b/apps/Backend/.env @@ -11,4 +11,5 @@ DB_HOST=localhost DB_USER=postgres DB_PASSWORD=mypassword DB_NAME=dentalapp -DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp \ No newline at end of file +DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp +GOOGLE_TRANSLATE_API_KEY= \ No newline at end of file diff --git a/apps/Backend/.env.example b/apps/Backend/.env.example index 291729a2..70c80507 100755 --- a/apps/Backend/.env.example +++ b/apps/Backend/.env.example @@ -10,4 +10,5 @@ DB_HOST=localhost DB_USER=postgres DB_PASSWORD=mypassword DB_NAME=dentalapp -DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp \ No newline at end of file +DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp +GOOGLE_TRANSLATE_API_KEY= \ No newline at end of file diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 1838d519..15b70d9c 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -47,6 +47,7 @@ import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; import labRxRoutes from "./lab-rx"; import seleniumSettingsRoutes from "./selenium-settings"; import typeAgentRoutes from "./type-agent"; +import translateRoutes from "./translate"; const router = Router(); @@ -98,5 +99,6 @@ router.use("/fee-schedule", feeScheduleRoutes); router.use("/license", licenseRoutes); router.use("/lab-rx", labRxRoutes); router.use("/selenium-settings", seleniumSettingsRoutes); +router.use("/translate", translateRoutes); export default router; diff --git a/apps/Backend/src/routes/translate.ts b/apps/Backend/src/routes/translate.ts new file mode 100644 index 00000000..04524ed4 --- /dev/null +++ b/apps/Backend/src/routes/translate.ts @@ -0,0 +1,50 @@ +import express, { Request, Response } from "express"; + +const router = express.Router(); + +// POST /api/translate +router.post("/", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const apiKey = process.env.GOOGLE_TRANSLATE_API_KEY; + if (!apiKey) { + return res.status(503).json({ message: "Translation is not configured yet. Set GOOGLE_TRANSLATE_API_KEY." }); + } + + const { text, targetLang, sourceLang } = req.body as { text?: string; targetLang?: string; sourceLang?: string }; + if (!text?.trim() || !targetLang) { + return res.status(400).json({ message: "text and targetLang are required" }); + } + + const params = new URLSearchParams({ + key: apiKey, + q: text, + target: targetLang, + format: "text", + }); + if (sourceLang) params.set("source", sourceLang); + + const googleRes = await fetch(`https://translation.googleapis.com/language/translate/v2?${params.toString()}`, { + method: "POST", + }); + + if (!googleRes.ok) { + const errBody = await googleRes.json().catch(() => null); + const message = errBody?.error?.message || "Translation request failed"; + return res.status(googleRes.status).json({ message }); + } + + const data = await googleRes.json(); + const translation = data?.data?.translations?.[0]; + return res.status(200).json({ + translatedText: translation?.translatedText ?? "", + detectedSourceLanguage: translation?.detectedSourceLanguage, + }); + } catch (err) { + return res.status(500).json({ message: "Failed to translate text", details: String(err) }); + } +}); + +export default router; diff --git a/apps/Frontend/src/components/patient-connection/live-translate.tsx b/apps/Frontend/src/components/patient-connection/live-translate.tsx index fa7bdb4c..ec9ad0a6 100644 --- a/apps/Frontend/src/components/patient-connection/live-translate.tsx +++ b/apps/Frontend/src/components/patient-connection/live-translate.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -9,25 +9,153 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Languages, ArrowLeftRight, Mic } from "lucide-react"; +import { Languages, ArrowLeftRight, Mic, MicOff, Volume2, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; const LANGUAGES = [ - { code: "en", label: "English" }, - { code: "es", label: "Spanish" }, - { code: "pt", label: "Portuguese" }, - { code: "zh", label: "Chinese" }, - { code: "vi", label: "Vietnamese" }, - { code: "ht", label: "Haitian Creole" }, - { code: "fr", label: "French" }, - { code: "ar", label: "Arabic" }, - { code: "ru", label: "Russian" }, + { code: "en", label: "English", speech: "en-US" }, + { code: "es", label: "Spanish", speech: "es-ES" }, + { code: "pt", label: "Portuguese", speech: "pt-BR" }, + { code: "zh", label: "Chinese", speech: "zh-CN" }, + { code: "vi", label: "Vietnamese", speech: "vi-VN" }, + { code: "ht", label: "Haitian Creole", speech: "fr-HT" }, + { code: "fr", label: "French", speech: "fr-FR" }, + { code: "ar", label: "Arabic", speech: "ar-SA" }, + { code: "ru", label: "Russian", speech: "ru-RU" }, ]; +function speechLocale(code: string): string { + return LANGUAGES.find((l) => l.code === code)?.speech ?? code; +} + +type Side = "left" | "right"; + +// Chrome exposes SpeechRecognition under a vendor-prefixed name; not in TS lib.dom. +function getSpeechRecognitionCtor(): any { + if (typeof window === "undefined") return null; + return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; +} + export function LiveTranslate() { const [leftLang, setLeftLang] = useState("en"); const [rightLang, setRightLang] = useState("es"); const [leftText, setLeftText] = useState(""); const [rightText, setRightText] = useState(""); + const [listeningSide, setListeningSide] = useState(null); + const [translatingSide, setTranslatingSide] = useState(null); + const { toast } = useToast(); + + const recognitionRef = useRef(null); + const speechSupported = useRef(!!getSpeechRecognitionCtor()); + const ttsSupported = useRef(typeof window !== "undefined" && "speechSynthesis" in window); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + if (ttsSupported.current) window.speechSynthesis.cancel(); + }; + }, []); + + const speak = useCallback((text: string, langCode: string) => { + if (!ttsSupported.current || !text.trim()) return; + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = speechLocale(langCode); + window.speechSynthesis.speak(utterance); + }, []); + + const translateSide = useCallback( + async (side: Side, options?: { speakResult?: boolean }) => { + const sourceText = side === "left" ? leftText : rightText; + const sourceLang = side === "left" ? leftLang : rightLang; + const targetLang = side === "left" ? rightLang : leftLang; + + if (!sourceText.trim()) return; + + setTranslatingSide(side); + try { + const res = await apiRequest("POST", "/api/translate", { + text: sourceText, + sourceLang, + targetLang, + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Translation failed"); + } + const { translatedText } = await res.json(); + if (side === "left") setRightText(translatedText); + else setLeftText(translatedText); + + if (options?.speakResult !== false) { + speak(translatedText, targetLang); + } + } catch (err: any) { + toast({ + title: "Translation Failed", + description: err?.message || "Unable to translate text.", + variant: "destructive", + }); + } finally { + setTranslatingSide(null); + } + }, + [leftText, rightText, leftLang, rightLang, speak, toast] + ); + + const toggleListening = useCallback( + (side: Side) => { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) { + toast({ + title: "Not Supported", + description: "Voice input isn't supported in this browser. Try Chrome on Android or desktop.", + variant: "destructive", + }); + return; + } + + if (listeningSide === side) { + recognitionRef.current?.stop(); + return; + } + + recognitionRef.current?.stop(); + + const recognition = new Ctor(); + recognition.lang = speechLocale(side === "left" ? leftLang : rightLang); + recognition.interimResults = true; + recognition.continuous = false; + + recognition.onresult = (event: any) => { + let transcript = ""; + for (let i = 0; i < event.results.length; i++) { + transcript += event.results[i][0].transcript; + } + if (side === "left") setLeftText(transcript); + else setRightText(transcript); + }; + + recognition.onerror = (event: any) => { + toast({ + title: "Voice Input Error", + description: event?.error ? `Microphone error: ${event.error}` : "Could not capture audio.", + variant: "destructive", + }); + }; + + recognition.onend = () => { + setListeningSide((current) => (current === side ? null : current)); + recognitionRef.current = null; + }; + + recognitionRef.current = recognition; + setListeningSide(side); + recognition.start(); + }, + [leftLang, rightLang, listeningSide, toast] + ); const handleSwap = () => { setLeftLang(rightLang); @@ -36,6 +164,86 @@ export function LiveTranslate() { setRightText(leftText); }; + const renderSide = (side: Side) => { + const lang = side === "left" ? leftLang : rightLang; + const setLang = side === "left" ? setLeftLang : setRightLang; + const text = side === "left" ? leftText : rightText; + const setText = side === "left" ? setLeftText : setRightText; + const isListening = listeningSide === side; + const isTranslating = translatingSide === side; + + return ( +
+ +
+