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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:28:57 -04:00
co-authored by Claude Sonnet 5
parent 616ca5f259
commit 74d5c7c7ff
5 changed files with 281 additions and 85 deletions
+2 -1
View File
@@ -11,4 +11,5 @@ DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=mypassword
DB_NAME=dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
GOOGLE_TRANSLATE_API_KEY=
+2 -1
View File
@@ -10,4 +10,5 @@ DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=mypassword
DB_NAME=dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
GOOGLE_TRANSLATE_API_KEY=
+2
View File
@@ -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;
+50
View File
@@ -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<any> => {
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;
@@ -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<Side | null>(null);
const [translatingSide, setTranslatingSide] = useState<Side | null>(null);
const { toast } = useToast();
const recognitionRef = useRef<any>(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 (
<div className="flex flex-col gap-2">
<Select value={lang} onValueChange={setLang}>
<SelectTrigger data-testid={`select-translate-${side}-language`}>
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type or speak..."
className="min-h-[120px] resize-none pr-10"
data-testid={`textarea-translate-${side}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => speak(text, lang)}
disabled={!text.trim() || !ttsSupported.current}
className="absolute bottom-2 right-2 h-7 w-7 text-muted-foreground"
title="Play aloud"
data-testid={`button-translate-${side}-speak`}
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button
type="button"
variant={isListening ? "default" : "outline"}
size="sm"
onClick={() => toggleListening(side)}
className={isListening ? "flex-1 bg-red-600 hover:bg-red-700 text-white" : "flex-1"}
disabled={!speechSupported.current}
title={speechSupported.current ? "Voice input" : "Voice input not supported in this browser"}
data-testid={`button-translate-${side}-mic`}
>
{isListening ? <MicOff className="h-4 w-4 mr-2" /> : <Mic className="h-4 w-4 mr-2" />}
{isListening ? "Listening..." : "Speak"}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => translateSide(side)}
disabled={!text.trim() || isTranslating}
className="flex-1"
data-testid={`button-translate-${side}-go`}
>
{isTranslating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : side === "left" ? (
<ArrowLeftRight className="h-4 w-4 mr-2 rotate-0" />
) : (
<ArrowLeftRight className="h-4 w-4 mr-2 rotate-180" />
)}
Translate
</Button>
</div>
</div>
);
};
return (
<Card>
<CardHeader className="pb-3">
@@ -45,44 +253,10 @@ export function LiveTranslate() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-3 items-stretch">
{/* Left side */}
<div className="flex flex-col gap-2">
<Select value={leftLang} onValueChange={setLeftLang}>
<SelectTrigger data-testid="select-translate-left-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Textarea
value={leftText}
onChange={(e) => setLeftText(e.target.value)}
placeholder="Type or speak..."
className="min-h-[120px] resize-none pr-10"
data-testid="textarea-translate-left"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute bottom-2 right-2 h-7 w-7 text-muted-foreground"
title="Voice input (coming soon)"
disabled
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-3 items-start">
{renderSide("left")}
{/* Swap button */}
<div className="flex md:flex-col items-center justify-center">
<div className="flex md:flex-col items-center justify-center pt-2">
<Button
type="button"
variant="outline"
@@ -96,43 +270,11 @@ export function LiveTranslate() {
</Button>
</div>
{/* Right side */}
<div className="flex flex-col gap-2">
<Select value={rightLang} onValueChange={setRightLang}>
<SelectTrigger data-testid="select-translate-right-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Textarea
value={rightText}
onChange={(e) => setRightText(e.target.value)}
placeholder="Translation will appear here..."
className="min-h-[120px] resize-none pr-10"
data-testid="textarea-translate-right"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute bottom-2 right-2 h-7 w-7 text-muted-foreground"
title="Voice input (coming soon)"
disabled
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
{renderSide("right")}
</div>
<p className="text-xs text-muted-foreground mt-3">
Translation isn't connected to an AI provider yet — this is the UI shell for now.
Speak or type on either side, then tap Translate. Voice input needs Chrome (Android or
desktop) and a secure connection; playback works in any modern browser.
</p>
</CardContent>
</Card>