Initial commit: CureSelect AI Voice Onboarding Web Frontend
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
// Force relative paths on Netlify to route through Netlify proxy and eliminate CORS errors
|
||||
const isNetlify = typeof window !== 'undefined' && window.location.hostname.includes('netlify.app');
|
||||
const API_BASE_URL = isNetlify ? '' : (import.meta.env.VITE_API_BASE_URL || 'https://services-api.a2zhealth.in/onboarding').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* Health check endpoint ping
|
||||
*/
|
||||
export async function checkHealth() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/health`);
|
||||
if (!res.ok) throw new Error(`Health check failed: ${res.statusText}`);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.warn('Health check error (Backend offline or starting up):', err);
|
||||
return {
|
||||
success: false,
|
||||
service: 'AI Patient Onboarding',
|
||||
status: 'offline',
|
||||
models: {
|
||||
speech: 'Sarvam AI / Whisper',
|
||||
conversation: 'OpenAI GPT-4o-mini',
|
||||
validation: 'Claude 3.5 Sonnet',
|
||||
vision_documents: 'Gemini 1.5 Flash',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Voice Onboarding Session
|
||||
*/
|
||||
export async function startVoiceSession({ patient, category, language }) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s timeout safeguard for LLM + Sarvam TTS initialization
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/v1/voice-onboarding/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patient, category, language }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to start voice session: ${errText}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
console.warn('Backend startVoiceSession fetch error or timeout, proceeding with client session initialization:', err);
|
||||
return {
|
||||
success: true,
|
||||
session_id: `VOICE-${Math.random().toString(36).substring(2, 9).toUpperCase()}`,
|
||||
greeting_text: `Hello ${patient?.first_name || patient?.name || 'Patient'}, welcome to CureSelect Healthcare. I am your AI clinical assistant. What symptoms or medical concerns bring you in today?`,
|
||||
stage: 'greeting',
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Voice Turn (Audio Blob or Text Fallback)
|
||||
*/
|
||||
export async function sendVoiceTurn(sessionId, audioBlob = null, textFallback = null) {
|
||||
const formData = new FormData();
|
||||
let hasData = false;
|
||||
|
||||
if (audioBlob && audioBlob.size > 0) {
|
||||
const filename = audioBlob.type.includes('wav') ? 'speech.wav' : 'speech.webm';
|
||||
formData.append('audio', audioBlob, filename);
|
||||
hasData = true;
|
||||
}
|
||||
if (textFallback) {
|
||||
formData.append('text_fallback', textFallback);
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
throw new Error('No audio recording or text input provided');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s timeout safeguard for LLM reasoning & TTS audio synthesis
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/turn`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Server status ${res.status}: ${errText}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Request timed out while processing speech turn.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Text Onboarding Session
|
||||
*/
|
||||
export async function startTextSession({ patient, category, language }) {
|
||||
const res = await fetch(`${API_BASE_URL}/v1/onboarding/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patient, category, language }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to start text session: ${errText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Text Message Turn
|
||||
*/
|
||||
export async function sendTextMessage(sessionId, message) {
|
||||
const res = await fetch(`${API_BASE_URL}/v1/onboarding/${sessionId}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to send message: ${errText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload Medical Document / Prescription (Gemini Vision OCR)
|
||||
*/
|
||||
export async function uploadDocument(sessionId, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/v1/documents/${sessionId}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to upload document: ${errText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete & Confirm Session (Step A: confirmed=false for preview, Step B: confirmed=true to save)
|
||||
*/
|
||||
export async function completeSession(sessionId, confirmed = false, isVoice = true) {
|
||||
const endpoint = isVoice
|
||||
? `${API_BASE_URL}/v1/voice-onboarding/${sessionId}/complete`
|
||||
: `${API_BASE_URL}/v1/onboarding/${sessionId}/complete`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirmed }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to complete onboarding session: ${errText}`);
|
||||
}
|
||||
const result = await res.json();
|
||||
|
||||
if (confirmed) {
|
||||
emitToParentWindow('ONBOARDING_COMPLETED', result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to resolve full audio URL for playback
|
||||
*/
|
||||
export function resolveAudioUrl(audioUrl) {
|
||||
if (!audioUrl) return null;
|
||||
if (audioUrl.startsWith('http://') || audioUrl.startsWith('https://') || audioUrl.startsWith('data:')) {
|
||||
return audioUrl;
|
||||
}
|
||||
const cleanPath = audioUrl.startsWith('/') ? audioUrl : `/${audioUrl}`;
|
||||
return `${API_BASE_URL}${cleanPath}`;
|
||||
}
|
||||
|
||||
export const getAudioFullUrl = resolveAudioUrl;
|
||||
|
||||
|
||||
/**
|
||||
* Unified helper to emit data back to Parent App (WebView / iFrame / React Native / Flutter)
|
||||
*/
|
||||
export function emitToParentWindow(eventType, data) {
|
||||
const payload = {
|
||||
type: eventType,
|
||||
source: 'CURESELECT_ONBOARDING_APP',
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
try {
|
||||
// Channel 1: Web iFrame parent
|
||||
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(payload, '*');
|
||||
}
|
||||
|
||||
// Channel 2: React Native WebView
|
||||
if (typeof window !== 'undefined' && window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Channel 3: Android Native JavaScript Interface
|
||||
if (typeof window !== 'undefined' && window.AndroidBridge && typeof window.AndroidBridge.postMessage === 'function') {
|
||||
window.AndroidBridge.postMessage(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Channel 4: iOS WKWebView MessageHandler
|
||||
if (typeof window !== 'undefined' && window.webkit?.messageHandlers?.onboardingBridge) {
|
||||
window.webkit.messageHandlers.onboardingBridge.postMessage(payload);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Bridge emission error:', e);
|
||||
}
|
||||
|
||||
console.log(`[WebView Bridge Emitted ${eventType}]:`, payload);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { CategoryInfo, PatientInfo, VoiceTurnResult } from "../types/voice";
|
||||
import { DocumentExtractionResult, PatientOnboardingResult } from "../types/onboarding";
|
||||
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
const API_BASE_URL = (envUrl !== undefined && envUrl !== "" ? envUrl : "https://services-api.a2zhealth.in/onboarding").replace(/\/$/, "");
|
||||
|
||||
export async function startVoiceOnboarding(
|
||||
patient: PatientInfo,
|
||||
category: CategoryInfo,
|
||||
language: string = "en-IN"
|
||||
): Promise<{
|
||||
session_id: string;
|
||||
greeting_text: string;
|
||||
audio_url: string;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/voice-onboarding/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
patient,
|
||||
category,
|
||||
language,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to start voice onboarding session");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function sendVoiceTurn(
|
||||
sessionId: string,
|
||||
audioBlob?: Blob,
|
||||
textFallback?: string
|
||||
): Promise<VoiceTurnResult> {
|
||||
const formData = new FormData();
|
||||
|
||||
if (audioBlob) {
|
||||
formData.append("audio", audioBlob, "patient_speech.webm");
|
||||
}
|
||||
|
||||
if (textFallback) {
|
||||
formData.append("text_fallback", textFallback);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/turn`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Voice turn failed");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function uploadMedicalDocument(
|
||||
sessionId: string,
|
||||
file: File
|
||||
): Promise<DocumentExtractionResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/v1/documents/${sessionId}/upload`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Document analysis failed");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function completeVoiceOnboarding(
|
||||
sessionId: string,
|
||||
confirmed: boolean
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status: string;
|
||||
result?: PatientOnboardingResult;
|
||||
message: string;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirmed }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to complete onboarding");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function getAudioFullUrl(relativePath: string): string {
|
||||
if (!relativePath) return "";
|
||||
if (relativePath.startsWith("http://") || relativePath.startsWith("https://") || relativePath.startsWith("data:")) return relativePath;
|
||||
const cleanPath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
|
||||
return `${API_BASE_URL}${cleanPath}`;
|
||||
}
|
||||
|
||||
export const resolveAudioUrl = getAudioFullUrl;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// High-Definition Neural Speech Synthesizer with Authentic Local Indian Accent Support
|
||||
|
||||
let cachedVoices: SpeechSynthesisVoice[] = [];
|
||||
let customSelectedVoiceURI: string | null = null;
|
||||
let customRate: number = 0.94;
|
||||
let customPitch: number = 1.0;
|
||||
|
||||
export function initVoiceEngine() {
|
||||
if (typeof window !== "undefined" && window.speechSynthesis) {
|
||||
cachedVoices = window.speechSynthesis.getVoices();
|
||||
window.speechSynthesis.onvoiceschanged = () => {
|
||||
cachedVoices = window.speechSynthesis.getVoices();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Immediately warm up voices
|
||||
initVoiceEngine();
|
||||
|
||||
|
||||
// Indian voice priority keywords & specific local names
|
||||
const INDIAN_VOICE_KEYWORDS = [
|
||||
"neerja", "prabhat", "heera", "ravi", "swara", "madhur", "kalpana", "hemant",
|
||||
"valluvar", "pallavi", "mohan", "gagan", "sapna", "rishi", "veena", "lekha",
|
||||
"kavya", "vani", "meera", "shruti", "ananya", "india", "indian", "hindi",
|
||||
"tamil", "telugu", "kannada", "bengali", "marathi", "gujarati"
|
||||
];
|
||||
|
||||
export function getAvailableIndianVoices(languageCode?: string): SpeechSynthesisVoice[] {
|
||||
if (typeof window === "undefined" || !window.speechSynthesis) return [];
|
||||
const voices = cachedVoices.length > 0 ? cachedVoices : window.speechSynthesis.getVoices();
|
||||
if (!voices || voices.length === 0) return [];
|
||||
|
||||
if (!languageCode) {
|
||||
return voices.filter((v) => {
|
||||
const vLang = v.lang.toLowerCase().replace("_", "-");
|
||||
const vName = v.name.toLowerCase();
|
||||
return (
|
||||
vLang.includes("in") ||
|
||||
INDIAN_VOICE_KEYWORDS.some((kw) => vName.includes(kw) || vLang.includes(kw))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const targetLang = languageCode.toLowerCase().replace("_", "-");
|
||||
const targetPrefix = targetLang.split("-")[0];
|
||||
|
||||
const matched = voices.filter((v) => {
|
||||
const vLang = v.lang.toLowerCase().replace("_", "-");
|
||||
const vName = v.name.toLowerCase();
|
||||
return (
|
||||
vLang === targetLang ||
|
||||
vLang.startsWith(targetPrefix) ||
|
||||
(targetLang === "en-in" && (vLang.includes("en") || vName.includes("india")))
|
||||
);
|
||||
});
|
||||
|
||||
// Sort matched voices so Indian accents and natural neural voices appear first
|
||||
return matched.sort((a, b) => {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
const aLang = a.lang.toLowerCase().replace("_", "-");
|
||||
const bLang = b.lang.toLowerCase().replace("_", "-");
|
||||
|
||||
const aScore =
|
||||
(aLang.includes("in") ? 50 : 0) +
|
||||
(INDIAN_VOICE_KEYWORDS.some((k) => aName.includes(k)) ? 40 : 0) +
|
||||
(aName.includes("natural") || aName.includes("online") ? 30 : 0) +
|
||||
(aName.includes("neural") ? 20 : 0);
|
||||
|
||||
const bScore =
|
||||
(bLang.includes("in") ? 50 : 0) +
|
||||
(INDIAN_VOICE_KEYWORDS.some((k) => bName.includes(k)) ? 40 : 0) +
|
||||
(bName.includes("natural") || bName.includes("online") ? 30 : 0) +
|
||||
(bName.includes("neural") ? 20 : 0);
|
||||
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
|
||||
export function setSelectedVoiceURI(uri: string | null) {
|
||||
customSelectedVoiceURI = uri;
|
||||
}
|
||||
|
||||
export function getSelectedVoiceURI(): string | null {
|
||||
return customSelectedVoiceURI;
|
||||
}
|
||||
|
||||
export function setVoiceRate(rate: number) {
|
||||
customRate = Math.max(0.6, Math.min(1.5, rate));
|
||||
}
|
||||
|
||||
export function getVoiceRate(): number {
|
||||
return customRate;
|
||||
}
|
||||
|
||||
export function setVoicePitch(pitch: number) {
|
||||
customPitch = Math.max(0.7, Math.min(1.4, pitch));
|
||||
}
|
||||
|
||||
export function getVoicePitch(): number {
|
||||
return customPitch;
|
||||
}
|
||||
|
||||
export function getBestIndianVoice(languageCode: string): SpeechSynthesisVoice | null {
|
||||
if (typeof window === "undefined" || !window.speechSynthesis) return null;
|
||||
|
||||
const voices = cachedVoices.length > 0 ? cachedVoices : window.speechSynthesis.getVoices();
|
||||
if (!voices || voices.length === 0) return null;
|
||||
|
||||
// If user selected a custom voice URI, respect it
|
||||
if (customSelectedVoiceURI) {
|
||||
const chosen = voices.find((v) => v.voiceURI === customSelectedVoiceURI);
|
||||
if (chosen) return chosen;
|
||||
}
|
||||
|
||||
const targetLang = languageCode.toLowerCase().replace("_", "-");
|
||||
const targetPrefix = targetLang.split("-")[0]; // "en", "ta", "hi", "te", "kn"
|
||||
|
||||
let bestVoice: SpeechSynthesisVoice | null = null;
|
||||
let bestScore = -100;
|
||||
|
||||
for (const voice of voices) {
|
||||
const vLang = voice.lang.toLowerCase().replace("_", "-");
|
||||
const vName = voice.name.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
const isExactLang = vLang === targetLang || (targetLang === "en-in" && (vLang === "en-in" || vLang.includes("en_in")));
|
||||
const isPrefixLang = vLang.startsWith(targetPrefix);
|
||||
|
||||
if (isExactLang) {
|
||||
score += 50;
|
||||
} else if (isPrefixLang) {
|
||||
score += 20;
|
||||
} else if (targetLang === "en-in" && vLang.startsWith("en")) {
|
||||
// English requested, check if voice has Indian name
|
||||
score += 5;
|
||||
} else {
|
||||
continue; // Language doesn't match at all
|
||||
}
|
||||
|
||||
// Check for natural / neural quality
|
||||
if (vName.includes("natural") || vName.includes("online")) {
|
||||
score += 30;
|
||||
}
|
||||
if (vName.includes("neural")) {
|
||||
score += 25;
|
||||
}
|
||||
if (vName.includes("google")) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
// Check for Indian regional identifiers & names
|
||||
const hasIndianKeyword = INDIAN_VOICE_KEYWORDS.some((kw) => vName.includes(kw) || vLang.includes("in"));
|
||||
if (hasIndianKeyword) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
// If English (India) is requested, heavily prioritize Indian English voices
|
||||
if (targetLang === "en-in") {
|
||||
if (vLang.includes("en-in") || vLang.includes("en_in") || vName.includes("india") || vName.includes("neerja") || vName.includes("prabhat") || vName.includes("heera") || vName.includes("ravi")) {
|
||||
score += 60;
|
||||
}
|
||||
// Penalize US/UK voices if an Indian voice can be found
|
||||
if ((vLang.includes("en-us") || vLang.includes("en-gb")) && !hasIndianKeyword) {
|
||||
score -= 40;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestVoice = voice;
|
||||
}
|
||||
}
|
||||
|
||||
return bestVoice;
|
||||
}
|
||||
|
||||
// Clean and normalize text for smooth natural Indian pronunciation
|
||||
export function normalizeTextForSpeech(text: string): string {
|
||||
return text
|
||||
.replace(/[()#*_\-–—]/g, " ")
|
||||
.replace(/\bBP\b/gi, "Blood Pressure")
|
||||
.replace(/\bDr\b\.?/gi, "Doctor")
|
||||
.replace(/\bmg\b/gi, "milligrams")
|
||||
.replace(/\bkg\b/gi, "kilograms")
|
||||
.replace(/\bhrs\b/gi, "hours")
|
||||
.replace(/\bmin\b/gi, "minutes")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function playNeuralIndianSpeech(
|
||||
text: string,
|
||||
languageCode: string = "ta-IN",
|
||||
onEnd?: () => void
|
||||
): boolean {
|
||||
if (typeof window === "undefined" || !window.speechSynthesis) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any active speech synthesis
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const cleanText = normalizeTextForSpeech(text);
|
||||
if (!cleanText) return false;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(cleanText);
|
||||
utterance.lang = languageCode;
|
||||
|
||||
// Optimized rate & pitch for natural Indian conversational healthcare tone
|
||||
utterance.rate = customRate || 0.94;
|
||||
utterance.pitch = customPitch || 1.0;
|
||||
|
||||
// Select the best authentic local Indian voice
|
||||
const preferredVoice = getBestIndianVoice(languageCode);
|
||||
if (preferredVoice) {
|
||||
utterance.voice = preferredVoice;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finishOnce = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
if (onEnd) onEnd();
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onend = finishOnce;
|
||||
utterance.onerror = (e) => {
|
||||
console.warn("SpeechSynthesis error:", e);
|
||||
finishOnce();
|
||||
};
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stopNeuralSpeech() {
|
||||
if (typeof window !== "undefined" && window.speechSynthesis) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
export function testIndianAccentSample(languageCode: string = "en-IN", onEnd?: () => void): boolean {
|
||||
const samples: Record<string, string> = {
|
||||
"en-IN": "Hello. Welcome to CureSelect Healthcare. How can I assist you with your health intake today?",
|
||||
"ta-IN": "வணக்கம், CureSelect மருத்துவமனைக்கு நல்வரவு. இன்று தங்களுக்கு ஏற்பட்டுள்ள உடல்நல பாதிப்பு பற்றி கூற முடியுமா?",
|
||||
"hi-IN": "नमस्ते, CureSelect अस्पताल में आपका स्वागत है। आज आप किस स्वास्थ्य समस्या के लिए परामर्श लेना चाहते हैं?",
|
||||
"te-IN": "నమస్కారం, CureSelect హాస్పిటల్కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్య గురించి చెప్పగలరా?",
|
||||
"kn-IN": "ನಮಸ್ಕಾರ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ತಿಳಿಸಬಹುದೇ?",
|
||||
};
|
||||
|
||||
const sample = samples[languageCode] || samples["en-IN"];
|
||||
return playNeuralIndianSpeech(sample, languageCode, onEnd);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user