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:
+2
-1
@@ -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=
|
||||
@@ -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=
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user