FarmerHelp / frontend /src /services /voiceService.js
Gankit12's picture
AAA
c5a7b7d
Raw
History Blame Contribute Delete
47.5 kB
/**
* VoiceService - Voice command processing and text-to-speech.
*
* Provides:
* - Intent recognition from voice transcripts (Hindi + English)
* - Advanced intents with entity extraction (crops, locations, dates, diseases)
* - ConversationEngine for multi-turn, context-aware dialogues
* - Confidence scoring for matched commands
* - Voice settings management with localStorage persistence
* - Text-to-speech synthesis with configurable rate and voice
* - Browser compatibility checks
* - Microphone permission management
* - Offline support for all client-side command processing
*/
import { LANGUAGES, ROUTES, SUPPORTED_CROPS, STORAGE_KEYS } from "@utils/constants";
import storage from "@utils/storage";
// ---------------------------------------------------------------------------
// Intent Definitions
// ---------------------------------------------------------------------------
export const INTENTS = {
// Basic navigation (from Prompt 17)
NAVIGATE_HOME: "navigate_home",
NAVIGATE_WEATHER: "navigate_weather",
NAVIGATE_DISEASE: "navigate_disease",
NAVIGATE_APMC: "navigate_apmc",
SHOW_HELP: "show_help",
// Advanced queries (Prompt 18)
QUERY_CROP_PRICE: "query_crop_price",
QUERY_WEATHER_TIME: "query_weather_time",
QUERY_DISEASE_TREATMENT: "query_disease_treatment",
COMPARE_PRICES: "compare_prices",
READ_ALERTS: "read_alerts",
BEST_APMC: "best_apmc",
UNKNOWN: "unknown",
};
// ---------------------------------------------------------------------------
// Action Types
// ---------------------------------------------------------------------------
export const ACTIONS = {
NAVIGATE: "navigate",
SPEAK: "speak",
SHOW_HELP: "show_help",
CONFIRM: "confirm",
NONE: "none",
};
// ---------------------------------------------------------------------------
// Basic Intent Keyword Patterns (one-shot commands)
// ---------------------------------------------------------------------------
const BASIC_INTENT_PATTERNS = {
[INTENTS.NAVIGATE_WEATHER]: {
[LANGUAGES.EN]: [
"weather", "forecast", "temperature", "rain", "climate", "show weather",
],
[LANGUAGES.HI]: [
"मौसम", "मौसम बताओ", "बारिश", "तापमान", "मौसम दिखाओ",
],
route: ROUTES.WEATHER,
},
[INTENTS.NAVIGATE_DISEASE]: {
[LANGUAGES.EN]: [
"disease", "detect disease", "crop disease", "plant disease", "scan", "diagnose",
],
[LANGUAGES.HI]: [
"रोग", "रोग पहचानो", "बीमारी", "फसल रोग", "रोग बताओ",
],
route: ROUTES.DISEASE_DETECTION,
},
[INTENTS.NAVIGATE_APMC]: {
[LANGUAGES.EN]: [
"apmc", "mandi", "prices", "show prices", "market", "market price", "crop price",
],
[LANGUAGES.HI]: [
"भाव", "भाव बताओ", "मंडी", "मंडी भाव", "बाजार भाव", "दाम", "APMC",
],
route: ROUTES.APMC,
},
[INTENTS.NAVIGATE_HOME]: {
[LANGUAGES.EN]: ["home", "go home", "main page", "start"],
[LANGUAGES.HI]: ["होम", "घर", "मुख्य पेज", "शुरू"],
route: ROUTES.HOME,
},
[INTENTS.SHOW_HELP]: {
[LANGUAGES.EN]: ["help", "commands", "what can you do", "voice commands"],
[LANGUAGES.HI]: ["मदद", "मदद चाहिए", "सहायता", "कमांड"],
route: null,
},
};
// ---------------------------------------------------------------------------
// Advanced Intent Keyword Patterns (parameterized queries)
// ---------------------------------------------------------------------------
const ADVANCED_INTENT_PATTERNS = {
[INTENTS.QUERY_CROP_PRICE]: {
[LANGUAGES.EN]: [
"price of", "prices of", "show .* prices", "how much is",
".* price in", "cost of", "rate of",
],
[LANGUAGES.HI]: [
"का भाव", "का दाम", "की कीमत", "भाव बताओ", "भाव दिखाओ",
],
route: ROUTES.APMC,
},
[INTENTS.QUERY_WEATHER_TIME]: {
[LANGUAGES.EN]: [
"weather tomorrow", "weather today", "weather next week",
"forecast for", "temperature tomorrow", "rain tomorrow",
"will it rain", "weather this week",
],
[LANGUAGES.HI]: [
"कल का मौसम", "आज का मौसम", "कल बारिश", "अगले हफ्ते का मौसम",
"कल तापमान",
],
route: ROUTES.WEATHER,
},
[INTENTS.QUERY_DISEASE_TREATMENT]: {
[LANGUAGES.EN]: [
"how to treat", "treatment for", "cure for", "remedy for",
"how to fix", "solution for", "what is .* disease",
],
[LANGUAGES.HI]: [
"का इलाज", "कैसे ठीक करें", "का उपचार", "का उपाय",
"रोग का इलाज",
],
route: ROUTES.DISEASE_DETECTION,
},
[INTENTS.COMPARE_PRICES]: {
[LANGUAGES.EN]: [
"compare prices", "price comparison", "compare .* and",
"which apmc", "which mandi", "difference in price",
],
[LANGUAGES.HI]: [
"भाव तुलना", "कीमत तुलना", "मंडी तुलना", "किस मंडी में", "किस APMC में",
],
route: ROUTES.APMC,
},
[INTENTS.READ_ALERTS]: {
[LANGUAGES.EN]: [
"read alerts", "weather alerts", "any alerts", "show alerts",
"warnings", "read warnings",
],
[LANGUAGES.HI]: [
"अलर्ट पढ़ो", "चेतावनी", "अलर्ट बताओ", "चेतावनी बताओ",
],
route: ROUTES.WEATHER,
},
[INTENTS.BEST_APMC]: {
[LANGUAGES.EN]: [
"best apmc", "best mandi", "best market", "highest price", "best price",
"where to sell", "best place to sell",
],
[LANGUAGES.HI]: [
"सबसे अच्छी मंडी", "सबसे ज्यादा भाव", "कहाँ बेचें",
"बेहतरीन मंडी", "सबसे अच्छा APMC",
],
route: ROUTES.APMC,
},
};
// ---------------------------------------------------------------------------
// Voice Response Texts
// ---------------------------------------------------------------------------
const RESPONSES = {
[INTENTS.NAVIGATE_WEATHER]: {
[LANGUAGES.EN]: "Opening weather forecast.",
[LANGUAGES.HI]: "मौसम की जानकारी दिखा रहे हैं।",
},
[INTENTS.NAVIGATE_DISEASE]: {
[LANGUAGES.EN]: "Opening disease detection.",
[LANGUAGES.HI]: "रोग पहचान खोल रहे हैं।",
},
[INTENTS.NAVIGATE_APMC]: {
[LANGUAGES.EN]: "Opening APMC prices.",
[LANGUAGES.HI]: "APMC भाव दिखा रहे हैं।",
},
[INTENTS.NAVIGATE_HOME]: {
[LANGUAGES.EN]: "Going to home page.",
[LANGUAGES.HI]: "मुख्य पेज पर जा रहे हैं।",
},
[INTENTS.SHOW_HELP]: {
[LANGUAGES.EN]: "Here are the available commands.",
[LANGUAGES.HI]: "यहाँ उपलब्ध कमांड हैं।",
},
[INTENTS.QUERY_CROP_PRICE]: {
[LANGUAGES.EN]: "Opening APMC prices. You can search for {crop} there.",
[LANGUAGES.HI]: "APMC भाव खोल रहे हैं। वहाँ {crop} खोजें।",
},
[INTENTS.QUERY_WEATHER_TIME]: {
[LANGUAGES.EN]: "Opening weather forecast. Check {time} forecast.",
[LANGUAGES.HI]: "मौसम की जानकारी दिखा रहे हैं। {time} का पूर्वानुमान देखें।",
},
[INTENTS.QUERY_DISEASE_TREATMENT]: {
[LANGUAGES.EN]: "Opening disease detection. You can look up {disease} treatment there.",
[LANGUAGES.HI]: "रोग पहचान खोल रहे हैं। वहाँ {disease} का इलाज देखें।",
},
[INTENTS.COMPARE_PRICES]: {
[LANGUAGES.EN]: "Opening APMC prices for comparison.",
[LANGUAGES.HI]: "तुलना के लिए APMC भाव खोल रहे हैं।",
},
[INTENTS.READ_ALERTS]: {
[LANGUAGES.EN]: "Opening weather alerts.",
[LANGUAGES.HI]: "मौसम अलर्ट दिखा रहे हैं।",
},
[INTENTS.BEST_APMC]: {
[LANGUAGES.EN]: "Opening APMC prices to find the best market.",
[LANGUAGES.HI]: "सबसे अच्छा APMC खोजने के लिए भाव दिखा रहे हैं।",
},
[INTENTS.UNKNOWN]: {
[LANGUAGES.EN]: "Sorry, I did not understand that command.",
[LANGUAGES.HI]: "क्षमा करें, यह कमांड समझ नहीं आई।",
},
};
// ---------------------------------------------------------------------------
// Conversation Follow-up Responses
// ---------------------------------------------------------------------------
const FOLLOW_UP_RESPONSES = {
askLocation: {
[LANGUAGES.EN]: "For which location?",
[LANGUAGES.HI]: "किस जगह के लिए?",
},
askCrop: {
[LANGUAGES.EN]: "Which crop are you looking for?",
[LANGUAGES.HI]: "कौन सी फसल?",
},
confirmAction: {
[LANGUAGES.EN]: "Do you want me to proceed? Say yes or no.",
[LANGUAGES.HI]: "क्या आगे बढ़ूँ? हाँ या नहीं बोलें।",
},
confirmed: {
[LANGUAGES.EN]: "Done.",
[LANGUAGES.HI]: "हो गया।",
},
cancelled: {
[LANGUAGES.EN]: "Cancelled.",
[LANGUAGES.HI]: "रद्द कर दिया।",
},
greeting: {
[LANGUAGES.EN]: "Hello! How can I help you? Say 'help' to see available commands.",
[LANGUAGES.HI]: "नमस्ते! मैं कैसे मदद कर सकता हूँ? 'मदद' बोलें उपलब्ध कमांड देखने के लिए।",
},
};
// ---------------------------------------------------------------------------
// Available Commands (for help display - basic + advanced)
// ---------------------------------------------------------------------------
export const AVAILABLE_COMMANDS = [
{
intent: INTENTS.NAVIGATE_WEATHER,
label: { [LANGUAGES.EN]: "Weather Forecast", [LANGUAGES.HI]: "मौसम की जानकारी" },
examples: {
[LANGUAGES.EN]: ["weather", "show weather", "forecast"],
[LANGUAGES.HI]: ["मौसम बताओ", "मौसम दिखाओ"],
},
offline: true,
},
{
intent: INTENTS.NAVIGATE_DISEASE,
label: { [LANGUAGES.EN]: "Disease Detection", [LANGUAGES.HI]: "रोग पहचान" },
examples: {
[LANGUAGES.EN]: ["detect disease", "scan crop", "diagnose"],
[LANGUAGES.HI]: ["रोग पहचानो", "रोग बताओ"],
},
offline: true,
},
{
intent: INTENTS.NAVIGATE_APMC,
label: { [LANGUAGES.EN]: "APMC Price", [LANGUAGES.HI]: "APMC भाव" },
examples: {
[LANGUAGES.EN]: ["show prices", "apmc prices", "market price"],
[LANGUAGES.HI]: ["भाव बताओ", "APMC भाव"],
},
offline: true,
},
{
intent: INTENTS.NAVIGATE_HOME,
label: { [LANGUAGES.EN]: "Home Page", [LANGUAGES.HI]: "मुख्य पेज" },
examples: {
[LANGUAGES.EN]: ["home", "go home"],
[LANGUAGES.HI]: ["होम", "घर"],
},
offline: true,
},
{
intent: INTENTS.SHOW_HELP,
label: { [LANGUAGES.EN]: "Help", [LANGUAGES.HI]: "सहायता" },
examples: {
[LANGUAGES.EN]: ["help", "commands"],
[LANGUAGES.HI]: ["मदद", "सहायता"],
},
offline: true,
},
];
export const ADVANCED_COMMANDS = [
{
intent: INTENTS.QUERY_CROP_PRICE,
label: { [LANGUAGES.EN]: "Crop Prices", [LANGUAGES.HI]: "फसल भाव" },
examples: {
[LANGUAGES.EN]: ["show wheat prices", "price of rice in Punjab"],
[LANGUAGES.HI]: ["गेहूं का भाव", "पंजाब में चावल का दाम"],
},
offline: true,
},
{
intent: INTENTS.QUERY_WEATHER_TIME,
label: { [LANGUAGES.EN]: "Weather by Time", [LANGUAGES.HI]: "समय अनुसार मौसम" },
examples: {
[LANGUAGES.EN]: ["weather tomorrow", "will it rain today"],
[LANGUAGES.HI]: ["कल का मौसम", "आज बारिश होगी"],
},
offline: true,
},
{
intent: INTENTS.QUERY_DISEASE_TREATMENT,
label: { [LANGUAGES.EN]: "Disease Treatment", [LANGUAGES.HI]: "रोग उपचार" },
examples: {
[LANGUAGES.EN]: ["how to treat paddy blast", "cure for leaf blight"],
[LANGUAGES.HI]: ["धान ब्लास्ट का इलाज", "पत्ती झुलसा का उपचार"],
},
offline: true,
},
{
intent: INTENTS.COMPARE_PRICES,
label: { [LANGUAGES.EN]: "Compare Prices", [LANGUAGES.HI]: "भाव तुलना" },
examples: {
[LANGUAGES.EN]: ["compare prices in Ludhiana and Amritsar"],
[LANGUAGES.HI]: ["लुधियाना और अमृतसर में भाव तुलना"],
},
offline: true,
},
{
intent: INTENTS.READ_ALERTS,
label: { [LANGUAGES.EN]: "Weather Alerts", [LANGUAGES.HI]: "मौसम अलर्ट" },
examples: {
[LANGUAGES.EN]: ["read alerts", "any warnings"],
[LANGUAGES.HI]: ["अलर्ट पढ़ो", "चेतावनी बताओ"],
},
offline: true,
},
{
intent: INTENTS.BEST_APMC,
label: { [LANGUAGES.EN]: "Best APMC", [LANGUAGES.HI]: "सबसे अच्छा APMC" },
examples: {
[LANGUAGES.EN]: ["best apmc", "where to sell"],
[LANGUAGES.HI]: ["सबसे अच्छा APMC", "कहाँ बेचें"],
},
offline: true,
},
];
// ---------------------------------------------------------------------------
// Entity Data - Locations
// ---------------------------------------------------------------------------
const INDIAN_STATES = [
"Punjab", "Haryana", "Uttar Pradesh", "Madhya Pradesh", "Rajasthan",
"Maharashtra", "Gujarat", "Karnataka", "Tamil Nadu", "Andhra Pradesh",
"Telangana", "Bihar", "West Bengal", "Odisha", "Chhattisgarh",
"Jharkhand", "Assam", "Kerala", "Himachal Pradesh", "Uttarakhand",
];
const INDIAN_STATES_HI = [
"पंजाब", "हरियाणा", "उत्तर प्रदेश", "मध्य प्रदेश", "राजस्थान",
"महाराष्ट्र", "गुजरात", "कर्नाटक", "तमिलनाडु", "आंध्र प्रदेश",
"तेलंगाना", "बिहार", "पश्चिम बंगाल", "ओडिशा", "छत्तीसगढ़",
"झारखंड", "असम", "केरल", "हिमाचल प्रदेश", "उत्तराखंड",
];
const MANDI_CITIES = [
"Ludhiana", "Amritsar", "Jalandhar", "Chandigarh", "Karnal", "Hisar",
"Lucknow", "Kanpur", "Agra", "Varanasi", "Jaipur", "Jodhpur", "Kota",
"Indore", "Bhopal", "Nagpur", "Pune", "Mumbai", "Nashik",
"Ahmedabad", "Surat", "Rajkot", "Bengaluru", "Hyderabad", "Chennai",
"Kolkata", "Patna", "Bhubaneswar", "Raipur", "Ranchi", "Guwahati",
"Dehradun", "Shimla", "Thiruvananthapuram",
];
const MANDI_CITIES_HI = [
"लुधियाना", "अमृतसर", "जालंधर", "चंडीगढ़", "करनाल", "हिसार",
"लखनऊ", "कानपुर", "आगरा", "वाराणसी", "जयपुर", "जोधपुर", "कोटा",
"इंदौर", "भोपाल", "नागपुर", "पुणे", "मुंबई", "नासिक",
"अहमदाबाद", "सूरत", "राजकोट", "बेंगलुरु", "हैदराबाद", "चेन्नई",
"कोलकाता", "पटना", "भुवनेश्वर", "रायपुर", "रांची", "गुवाहाटी",
"देहरादून", "शिमला", "तिरुवनंतपुरम",
];
// ---------------------------------------------------------------------------
// Entity Data - Diseases
// ---------------------------------------------------------------------------
const CROP_DISEASES_EN = [
"paddy blast", "rice blast", "leaf blight", "bacterial leaf blight",
"brown spot", "sheath blight", "stem rot", "root rot",
"rust", "wheat rust", "yellow rust", "black rust",
"powdery mildew", "downy mildew",
"late blight", "early blight",
"wilt", "fusarium wilt",
"anthracnose", "smut", "mosaic virus", "leaf curl",
"tikka disease", "blast", "blight",
];
const CROP_DISEASES_HI = [
"धान ब्लास्ट", "पत्ती झुलसा", "भूरा धब्बा", "तना सड़न",
"जड़ सड़न", "गेरुई", "चूर्णिल आसिता", "मृदुरोमिल आसिता",
"उकठा", "कंडुआ", "मोजेक", "पत्ती मोड़",
"ब्लास्ट", "झुलसा",
];
// ---------------------------------------------------------------------------
// Entity Data - Time References
// ---------------------------------------------------------------------------
const TIME_REFERENCES_EN = {
today: "today",
tomorrow: "tomorrow",
yesterday: "yesterday",
"day after tomorrow": "day after tomorrow",
"next week": "next week",
"this week": "this week",
};
const TIME_REFERENCES_HI = {
"आज": "today",
"कल": "tomorrow",
"परसों": "day after tomorrow",
"अगले हफ्ते": "next week",
"इस हफ्ते": "this week",
};
// ---------------------------------------------------------------------------
// Entity Data - Crops (derived from constants)
// ---------------------------------------------------------------------------
const CROPS_EN = SUPPORTED_CROPS.map((c) => c.toLowerCase());
const CROPS_HI_MAP = {
"धान": "Paddy",
"चावल": "Paddy",
"गेहूं": "Wheat",
"कपास": "Cotton",
"गन्ना": "Sugarcane",
"टमाटर": "Tomato",
"आलू": "Potato",
"प्याज": "Onion",
"मिर्च": "Chilli",
"मक्का": "Maize",
"दालें": "Pulses",
"दाल": "Pulses",
"तिलहन": "Oilseeds",
"बाजरा": "Millets",
"ज्वार": "Millets",
"रागी": "Millets",
};
// ---------------------------------------------------------------------------
// Confirmation Keywords
// ---------------------------------------------------------------------------
const CONFIRM_KEYWORDS = {
yes: ["yes", "yeah", "yep", "sure", "ok", "okay", "confirm", "proceed", "go ahead"],
no: ["no", "nope", "cancel", "stop", "nevermind", "never mind"],
yesHi: ["हाँ", "हां", "जी", "ठीक", "ठीक है", "चलो", "आगे बढ़ो"],
noHi: ["नहीं", "मत", "रुको", "बंद करो", "रद्द"],
};
// ---------------------------------------------------------------------------
// Entity Extraction Functions
// ---------------------------------------------------------------------------
/**
* Extract crop names from text.
*
* @param {string} text - Normalized input text.
* @returns {string[]} Matched crop names (canonical English form).
*/
export function extractCrops(text) {
const lower = text.toLowerCase();
const found = [];
for (const crop of CROPS_EN) {
if (lower.includes(crop)) {
const canonical = SUPPORTED_CROPS.find((c) => c.toLowerCase() === crop);
if (canonical && !found.includes(canonical)) {
found.push(canonical);
}
}
}
for (const [hindi, english] of Object.entries(CROPS_HI_MAP)) {
if (text.includes(hindi) && !found.includes(english)) {
found.push(english);
}
}
return found;
}
/**
* Extract location names from text.
*
* @param {string} text - Input text.
* @returns {string[]} Matched location names.
*/
export function extractLocations(text) {
const lower = text.toLowerCase();
const found = [];
const allLocationsEn = [...INDIAN_STATES, ...MANDI_CITIES];
for (const loc of allLocationsEn) {
if (lower.includes(loc.toLowerCase()) && !found.includes(loc)) {
found.push(loc);
}
}
const allLocationsHi = [...INDIAN_STATES_HI, ...MANDI_CITIES_HI];
for (let i = 0; i < allLocationsHi.length; i++) {
if (text.includes(allLocationsHi[i])) {
const enEquiv = i < INDIAN_STATES_HI.length
? INDIAN_STATES[i]
: MANDI_CITIES[i - INDIAN_STATES_HI.length];
if (enEquiv && !found.includes(enEquiv)) {
found.push(enEquiv);
}
}
}
return found;
}
/**
* Extract time references from text.
*
* @param {string} text - Input text.
* @returns {string|null} Normalized time reference or null.
*/
export function extractTimeReference(text) {
const lower = text.toLowerCase();
for (const [phrase, normalized] of Object.entries(TIME_REFERENCES_EN)) {
if (lower.includes(phrase)) {
return normalized;
}
}
for (const [phrase, normalized] of Object.entries(TIME_REFERENCES_HI)) {
if (text.includes(phrase)) {
return normalized;
}
}
return null;
}
/**
* Extract disease names from text.
*
* @param {string} text - Input text.
* @returns {string[]} Matched disease names.
*/
export function extractDiseases(text) {
const lower = text.toLowerCase();
const found = [];
for (const disease of CROP_DISEASES_EN) {
if (lower.includes(disease) && !found.includes(disease)) {
found.push(disease);
}
}
for (const disease of CROP_DISEASES_HI) {
if (text.includes(disease) && !found.includes(disease)) {
found.push(disease);
}
}
return found;
}
/**
* Extract all entities from text.
*
* @param {string} text - Input text.
* @returns {{ crops: string[], locations: string[], timeRef: string|null, diseases: string[] }}
*/
export function extractEntities(text) {
if (!text || typeof text !== "string") {
return { crops: [], locations: [], timeRef: null, diseases: [] };
}
return {
crops: extractCrops(text),
locations: extractLocations(text),
timeRef: extractTimeReference(text),
diseases: extractDiseases(text),
};
}
// ---------------------------------------------------------------------------
// Intent Recognition Helpers
// ---------------------------------------------------------------------------
const CONFIDENCE_THRESHOLD = 0.5;
/**
* Calculate match score between user input and a keyword.
*
* @param {string} input - Normalized user input.
* @param {string} keyword - Normalized keyword to match against.
* @returns {number} Match confidence from 0 to 1.
*/
function calculateMatchScore(input, keyword) {
const normalizedInput = input.toLowerCase().trim();
const normalizedKeyword = keyword.toLowerCase().trim();
if (!normalizedInput || !normalizedKeyword) {
return 0;
}
if (normalizedInput === normalizedKeyword) {
return 1.0;
}
if (normalizedInput.includes(normalizedKeyword)) {
return 0.9;
}
if (normalizedKeyword.includes(normalizedInput)) {
return 0.7;
}
const inputWords = normalizedInput.split(/\s+/);
const keywordWords = normalizedKeyword.split(/\s+/);
const matchCount = inputWords.filter((word) =>
keywordWords.some((kw) => kw.includes(word) || word.includes(kw)),
).length;
if (matchCount > 0) {
const maxLen = Math.max(inputWords.length, keywordWords.length);
return (matchCount / maxLen) * 0.8;
}
return 0;
}
/**
* Try to match a regex-style pattern against input text.
* Patterns may contain .* for wildcards.
*
* @param {string} input - User input.
* @param {string} pattern - Pattern (may contain .*).
* @returns {number} Match confidence from 0 to 1.
*/
function calculateAdvancedMatchScore(input, pattern) {
const normalizedInput = input.toLowerCase().trim();
if (pattern.includes(".*")) {
try {
const regex = new RegExp(pattern.toLowerCase(), "i");
if (regex.test(normalizedInput)) {
return 0.85;
}
} catch {
// Fallback to simple match if regex is invalid
}
}
return calculateMatchScore(input, pattern);
}
// ---------------------------------------------------------------------------
// Basic Command Parsing (one-shot, used by VoiceButton)
// ---------------------------------------------------------------------------
/**
* Parse a voice transcript for basic navigation commands.
* This is the simple, stateless parser for one-shot commands.
*
* @param {string} text - The voice transcript text.
* @param {string} language - Current UI language.
* @returns {{ intent: string, confidence: number, route: string|null, response: string }}
*/
export function parseCommand(text, language = LANGUAGES.EN) {
if (!text || typeof text !== "string") {
return {
intent: INTENTS.UNKNOWN,
confidence: 0,
route: null,
response: getResponse(INTENTS.UNKNOWN, language),
};
}
const normalizedText = text.toLowerCase().trim();
let bestMatch = { intent: INTENTS.UNKNOWN, confidence: 0, route: null };
// Check basic patterns
for (const [intent, config] of Object.entries(BASIC_INTENT_PATTERNS)) {
for (const lang of [LANGUAGES.EN, LANGUAGES.HI]) {
const keywords = config[lang] || [];
for (const keyword of keywords) {
const score = calculateMatchScore(normalizedText, keyword);
if (score > bestMatch.confidence) {
bestMatch = { intent, confidence: score, route: config.route };
}
}
}
}
// Check advanced patterns if basic didn't match well
if (bestMatch.confidence < 0.8) {
for (const [intent, config] of Object.entries(ADVANCED_INTENT_PATTERNS)) {
for (const lang of [LANGUAGES.EN, LANGUAGES.HI]) {
const patterns = config[lang] || [];
for (const pattern of patterns) {
const score = calculateAdvancedMatchScore(normalizedText, pattern);
if (score > bestMatch.confidence) {
bestMatch = { intent, confidence: score, route: config.route };
}
}
}
}
}
if (bestMatch.confidence < CONFIDENCE_THRESHOLD) {
bestMatch = { intent: INTENTS.UNKNOWN, confidence: bestMatch.confidence, route: null };
}
const entities = extractEntities(text);
const response = buildResponse(bestMatch.intent, language, entities);
return {
intent: bestMatch.intent,
confidence: bestMatch.confidence,
route: bestMatch.route,
response,
entities,
};
}
// ---------------------------------------------------------------------------
// Response Builder
// ---------------------------------------------------------------------------
/**
* Get the raw response template for an intent.
*/
function getResponse(intent, language) {
const templates = RESPONSES[intent];
if (!templates) {
return RESPONSES[INTENTS.UNKNOWN][language] || RESPONSES[INTENTS.UNKNOWN][LANGUAGES.EN];
}
return templates[language] || templates[LANGUAGES.EN];
}
/**
* Build a response string, substituting extracted entity placeholders.
*/
function buildResponse(intent, language, entities) {
let response = getResponse(intent, language);
if (entities) {
const crop = entities.crops.length > 0 ? entities.crops[0] : "";
const location = entities.locations.length > 0 ? entities.locations[0] : "";
const time = entities.timeRef || "";
const disease = entities.diseases.length > 0 ? entities.diseases[0] : "";
response = response
.replace("{crop}", crop || (language === LANGUAGES.HI ? "फसल" : "the crop"))
.replace("{location}", location || "")
.replace("{time}", time || (language === LANGUAGES.HI ? "" : "the"))
.replace("{disease}", disease || (language === LANGUAGES.HI ? "रोग" : "the disease"));
}
return response;
}
// ---------------------------------------------------------------------------
// Conversation Engine (multi-turn, context-aware)
// ---------------------------------------------------------------------------
const CONVERSATION_STATES = {
IDLE: "idle",
AWAITING_LOCATION: "awaiting_location",
AWAITING_CROP: "awaiting_crop",
AWAITING_CONFIRMATION: "awaiting_confirmation",
};
let messageIdCounter = 0;
function generateMessageId() {
messageIdCounter += 1;
return `msg_${Date.now()}_${messageIdCounter}`;
}
/**
* ConversationEngine - Stateful, context-aware command processor.
*
* Maintains conversation history, accumulated entities,
* and supports multi-turn slot-filling dialogues.
*/
export class ConversationEngine {
/**
* @param {string} language - Initial language preference.
*/
constructor(language = LANGUAGES.EN) {
this.messages = [];
this.context = {
crop: null,
location: null,
timeRef: null,
disease: null,
lastIntent: null,
};
this.conversationState = CONVERSATION_STATES.IDLE;
this.pendingAction = null;
this.language = language;
this._loadHistory();
}
/**
* Process a user message and return a structured result.
*
* @param {string} text - User input (voice transcript or typed text).
* @param {string} language - Current UI language.
* @returns {{ intent: string, confidence: number, route: string|null, response: string, action: string, entities: object }}
*/
process(text, language = this.language) {
this.language = language;
if (!text || typeof text !== "string" || !text.trim()) {
return this._buildResult(INTENTS.UNKNOWN, 0, null, ACTIONS.NONE, {});
}
const trimmed = text.trim();
// Add user message to history
const entities = extractEntities(trimmed);
this._mergeEntities(entities);
// Handle conversation state (slot-filling)
if (this.conversationState !== CONVERSATION_STATES.IDLE) {
return this._handleFollowUp(trimmed, entities, language);
}
// Check for confirmation keywords (standalone "yes"/"no")
if (this._isConfirmation(trimmed)) {
return this._handleConfirmation(trimmed, language);
}
// Parse intent
const parsed = parseCommand(trimmed, language);
// Store user message
this._addMessage("user", trimmed, parsed.intent, entities, parsed.confidence);
// Determine if we need more info (slot-filling)
if (this._needsMoreInfo(parsed.intent, entities)) {
return this._askForMoreInfo(parsed.intent, entities, language);
}
// Build response with entity context
const response = buildResponse(parsed.intent, language, {
crops: entities.crops.length > 0 ? entities.crops : (this.context.crop ? [this.context.crop] : []),
locations: entities.locations.length > 0 ? entities.locations : (this.context.location ? [this.context.location] : []),
timeRef: entities.timeRef || this.context.timeRef,
diseases: entities.diseases.length > 0 ? entities.diseases : (this.context.disease ? [this.context.disease] : []),
});
// Store assistant response
this._addMessage("assistant", response);
// Update context
this.context.lastIntent = parsed.intent;
const action = parsed.intent === INTENTS.SHOW_HELP
? ACTIONS.SHOW_HELP
: parsed.route
? ACTIONS.NAVIGATE
: ACTIONS.SPEAK;
this._saveHistory();
return {
intent: parsed.intent,
confidence: parsed.confidence,
route: parsed.route,
response,
action,
entities: {
crops: entities.crops,
locations: entities.locations,
timeRef: entities.timeRef,
diseases: entities.diseases,
},
};
}
/**
* Add an assistant-only message (e.g., greeting, error).
*/
addAssistantMessage(text) {
this._addMessage("assistant", text);
this._saveHistory();
}
/**
* Get all conversation messages.
*
* @returns {Array<{ id: string, role: string, text: string, timestamp: string, intent?: string, entities?: object, confidence?: number }>}
*/
getMessages() {
return [...this.messages];
}
/**
* Get the current accumulated context.
*/
getContext() {
return { ...this.context };
}
/**
* Clear the conversation context (keep messages).
*/
clearContext() {
this.context = {
crop: null,
location: null,
timeRef: null,
disease: null,
lastIntent: null,
};
this.conversationState = CONVERSATION_STATES.IDLE;
this.pendingAction = null;
}
/**
* Clear all messages and reset context.
*/
clear() {
this.messages = [];
this.clearContext();
storage.remove(STORAGE_KEYS.VOICE_CHAT_HISTORY);
}
/**
* Update the engine language.
*/
setLanguage(language) {
this.language = language;
}
// --- Private Methods -------------------------------------------------------
_addMessage(role, text, intent = null, entities = null, confidence = null) {
const message = {
id: generateMessageId(),
role,
text,
timestamp: new Date().toISOString(),
};
if (intent) message.intent = intent;
if (entities) message.entities = entities;
if (confidence !== null) message.confidence = confidence;
this.messages.push(message);
}
_mergeEntities(entities) {
if (entities.crops.length > 0) {
this.context.crop = entities.crops[0];
}
if (entities.locations.length > 0) {
this.context.location = entities.locations[0];
}
if (entities.timeRef) {
this.context.timeRef = entities.timeRef;
}
if (entities.diseases.length > 0) {
this.context.disease = entities.diseases[0];
}
}
_needsMoreInfo(intent, entities) {
if (intent === INTENTS.QUERY_CROP_PRICE && entities.crops.length === 0 && !this.context.crop) {
return true;
}
if (intent === INTENTS.COMPARE_PRICES && entities.locations.length < 2 && !this.context.location) {
return true;
}
return false;
}
_askForMoreInfo(intent, entities, language) {
let followUp;
if (intent === INTENTS.QUERY_CROP_PRICE && entities.crops.length === 0) {
this.conversationState = CONVERSATION_STATES.AWAITING_CROP;
followUp = FOLLOW_UP_RESPONSES.askCrop[language] || FOLLOW_UP_RESPONSES.askCrop[LANGUAGES.EN];
} else if (intent === INTENTS.COMPARE_PRICES && entities.locations.length < 2) {
this.conversationState = CONVERSATION_STATES.AWAITING_LOCATION;
followUp = FOLLOW_UP_RESPONSES.askLocation[language] || FOLLOW_UP_RESPONSES.askLocation[LANGUAGES.EN];
} else {
followUp = FOLLOW_UP_RESPONSES.askCrop[language] || FOLLOW_UP_RESPONSES.askCrop[LANGUAGES.EN];
this.conversationState = CONVERSATION_STATES.AWAITING_CROP;
}
this.pendingAction = { intent, entities };
this._addMessage("assistant", followUp);
this._saveHistory();
return {
intent,
confidence: 0.6,
route: null,
response: followUp,
action: ACTIONS.SPEAK,
entities: { crops: entities.crops, locations: entities.locations, timeRef: entities.timeRef, diseases: entities.diseases },
};
}
_handleFollowUp(text, entities, language) {
this._addMessage("user", text, null, entities);
const prevIntent = this.pendingAction ? this.pendingAction.intent : this.context.lastIntent;
const route = prevIntent
? (ADVANCED_INTENT_PATTERNS[prevIntent]?.route || BASIC_INTENT_PATTERNS[prevIntent]?.route || null)
: null;
// Merge newly extracted entities
const mergedEntities = {
crops: entities.crops.length > 0 ? entities.crops : (this.context.crop ? [this.context.crop] : []),
locations: entities.locations.length > 0 ? entities.locations : (this.context.location ? [this.context.location] : []),
timeRef: entities.timeRef || this.context.timeRef,
diseases: entities.diseases.length > 0 ? entities.diseases : (this.context.disease ? [this.context.disease] : []),
};
// If the user just gave a bare noun that could be a crop
if (this.conversationState === CONVERSATION_STATES.AWAITING_CROP && entities.crops.length === 0) {
const possibleCrop = this._tryMatchCrop(text);
if (possibleCrop) {
mergedEntities.crops = [possibleCrop];
this.context.crop = possibleCrop;
}
}
// If the user just gave a bare location
if (this.conversationState === CONVERSATION_STATES.AWAITING_LOCATION && entities.locations.length === 0) {
const possibleLoc = this._tryMatchLocation(text);
if (possibleLoc) {
mergedEntities.locations = [possibleLoc];
this.context.location = possibleLoc;
}
}
const response = buildResponse(prevIntent || INTENTS.UNKNOWN, language, mergedEntities);
this._addMessage("assistant", response);
this.context.lastIntent = prevIntent;
this.conversationState = CONVERSATION_STATES.IDLE;
this.pendingAction = null;
this._saveHistory();
return {
intent: prevIntent || INTENTS.UNKNOWN,
confidence: 0.75,
route,
response,
action: route ? ACTIONS.NAVIGATE : ACTIONS.SPEAK,
entities: mergedEntities,
};
}
_isConfirmation(text) {
const lower = text.toLowerCase().trim();
const allYes = [...CONFIRM_KEYWORDS.yes, ...CONFIRM_KEYWORDS.yesHi];
const allNo = [...CONFIRM_KEYWORDS.no, ...CONFIRM_KEYWORDS.noHi];
return allYes.includes(lower) || allNo.includes(lower);
}
_handleConfirmation(text, language) {
const lower = text.toLowerCase().trim();
const isYes = [...CONFIRM_KEYWORDS.yes, ...CONFIRM_KEYWORDS.yesHi].includes(lower);
this._addMessage("user", text);
if (isYes && this.pendingAction) {
const response = FOLLOW_UP_RESPONSES.confirmed[language] || FOLLOW_UP_RESPONSES.confirmed[LANGUAGES.EN];
this._addMessage("assistant", response);
const result = {
intent: this.pendingAction.intent,
confidence: 1.0,
route: ADVANCED_INTENT_PATTERNS[this.pendingAction.intent]?.route
|| BASIC_INTENT_PATTERNS[this.pendingAction.intent]?.route
|| null,
response,
action: ACTIONS.NAVIGATE,
entities: this.pendingAction.entities || {},
};
this.conversationState = CONVERSATION_STATES.IDLE;
this.pendingAction = null;
this._saveHistory();
return result;
}
const response = FOLLOW_UP_RESPONSES.cancelled[language] || FOLLOW_UP_RESPONSES.cancelled[LANGUAGES.EN];
this._addMessage("assistant", response);
this.conversationState = CONVERSATION_STATES.IDLE;
this.pendingAction = null;
this._saveHistory();
return {
intent: INTENTS.UNKNOWN,
confidence: 1.0,
route: null,
response,
action: ACTIONS.NONE,
entities: {},
};
}
_tryMatchCrop(text) {
const lower = text.toLowerCase().trim();
for (const crop of SUPPORTED_CROPS) {
if (lower.includes(crop.toLowerCase())) {
return crop;
}
}
for (const [hindi, english] of Object.entries(CROPS_HI_MAP)) {
if (text.includes(hindi)) {
return english;
}
}
// Try fuzzy: if the whole input is a single word, check starts-with
const singleWord = lower.replace(/\s+/g, "");
for (const crop of SUPPORTED_CROPS) {
if (crop.toLowerCase().startsWith(singleWord) && singleWord.length >= 3) {
return crop;
}
}
return null;
}
_tryMatchLocation(text) {
const lower = text.toLowerCase().trim();
for (const loc of [...INDIAN_STATES, ...MANDI_CITIES]) {
if (lower.includes(loc.toLowerCase())) {
return loc;
}
}
for (let i = 0; i < INDIAN_STATES_HI.length; i++) {
if (text.includes(INDIAN_STATES_HI[i])) {
return INDIAN_STATES[i];
}
}
for (let i = 0; i < MANDI_CITIES_HI.length; i++) {
if (text.includes(MANDI_CITIES_HI[i])) {
return MANDI_CITIES[i];
}
}
return null;
}
_saveHistory() {
try {
const toSave = this.messages.slice(-100);
storage.set(STORAGE_KEYS.VOICE_CHAT_HISTORY, toSave);
} catch {
// Storage write failure is non-critical
}
}
_loadHistory() {
try {
const saved = storage.get(STORAGE_KEYS.VOICE_CHAT_HISTORY, []);
if (Array.isArray(saved)) {
this.messages = saved;
}
} catch {
this.messages = [];
}
}
}
// ---------------------------------------------------------------------------
// Voice Settings Management
// ---------------------------------------------------------------------------
export const DEFAULT_VOICE_SETTINGS = {
speechRate: 0.9,
voiceGender: "female",
language: LANGUAGES.EN,
autoSpeak: true,
};
/**
* Load voice settings from localStorage, merging with defaults.
*
* @returns {typeof DEFAULT_VOICE_SETTINGS}
*/
export function getVoiceSettings() {
const saved = storage.get(STORAGE_KEYS.VOICE_SETTINGS, null);
if (!saved || typeof saved !== "object") {
return { ...DEFAULT_VOICE_SETTINGS };
}
return { ...DEFAULT_VOICE_SETTINGS, ...saved };
}
/**
* Persist voice settings to localStorage.
*
* @param {Partial<typeof DEFAULT_VOICE_SETTINGS>} settings
*/
export function saveVoiceSettings(settings) {
const current = getVoiceSettings();
const merged = { ...current, ...settings };
storage.set(STORAGE_KEYS.VOICE_SETTINGS, merged);
return merged;
}
// ---------------------------------------------------------------------------
// Tutorial State
// ---------------------------------------------------------------------------
/**
* Check whether the voice tutorial has been shown.
*/
export function isTutorialShown() {
return storage.get(STORAGE_KEYS.VOICE_TUTORIAL_SHOWN, false) === true;
}
/**
* Mark the voice tutorial as shown.
*/
export function markTutorialShown() {
storage.set(STORAGE_KEYS.VOICE_TUTORIAL_SHOWN, true);
}
// ---------------------------------------------------------------------------
// Text-to-Speech
// ---------------------------------------------------------------------------
const VOICE_LANG_MAP = {
[LANGUAGES.EN]: "en-IN",
[LANGUAGES.HI]: "hi-IN",
};
let currentUtterance = null;
/**
* Check if speech synthesis is supported.
*
* @returns {boolean}
*/
export function isSpeechSynthesisSupported() {
return typeof window !== "undefined" && Boolean(window.speechSynthesis);
}
/**
* Find a suitable voice for the given language and gender preference.
*
* @param {string} langCode - BCP-47 language code (e.g., "en-IN").
* @param {string} gender - Preferred gender: "male" or "female".
* @returns {SpeechSynthesisVoice|null}
*/
function findVoice(langCode, gender) {
if (!isSpeechSynthesisSupported()) return null;
const voices = window.speechSynthesis.getVoices();
if (!voices || voices.length === 0) return null;
// Try exact language + gender match
const genderKeyword = gender === "male" ? /male/i : /female/i;
const exactMatch = voices.find(
(v) => v.lang === langCode && genderKeyword.test(v.name),
);
if (exactMatch) return exactMatch;
// Try exact language match (any gender)
const langMatch = voices.find((v) => v.lang === langCode);
if (langMatch) return langMatch;
// Try language prefix match (e.g., "en" for "en-IN")
const prefix = langCode.split("-")[0];
const prefixMatch = voices.find((v) => v.lang.startsWith(prefix));
return prefixMatch || null;
}
/**
* Speak the given text using the Web Speech Synthesis API.
*
* @param {string} text - Text to speak.
* @param {string} language - Language code (LANGUAGES.EN or LANGUAGES.HI).
* @param {object} [settings] - Optional voice settings override.
* @returns {Promise<void>} Resolves when speech ends.
*/
export function speak(text, language = LANGUAGES.EN, settings = null) {
return new Promise((resolve, reject) => {
if (!isSpeechSynthesisSupported()) {
reject(new Error("Speech synthesis is not supported in this browser."));
return;
}
const voiceSettings = settings || getVoiceSettings();
cancelSpeech();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = VOICE_LANG_MAP[language] || "en-IN";
utterance.rate = voiceSettings.speechRate || 0.9;
utterance.pitch = 1;
utterance.volume = 1;
const voice = findVoice(utterance.lang, voiceSettings.voiceGender);
if (voice) {
utterance.voice = voice;
}
utterance.onend = () => {
currentUtterance = null;
resolve();
};
utterance.onerror = (event) => {
currentUtterance = null;
if (event.error === "canceled") {
resolve();
return;
}
reject(new Error(`Speech synthesis error: ${event.error}`));
};
currentUtterance = utterance;
window.speechSynthesis.speak(utterance);
});
}
/**
* Cancel any ongoing speech synthesis.
*/
export function cancelSpeech() {
if (typeof window !== "undefined" && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
currentUtterance = null;
}
/**
* Check if speech synthesis is currently speaking.
*
* @returns {boolean}
*/
export function isSpeaking() {
return (
typeof window !== "undefined" &&
window.speechSynthesis &&
window.speechSynthesis.speaking
);
}
// ---------------------------------------------------------------------------
// Browser Compatibility
// ---------------------------------------------------------------------------
/**
* Check browser compatibility for voice features.
*
* @returns {{ speechRecognition: boolean, speechSynthesis: boolean, microphone: boolean, isFullySupported: boolean }}
*/
export function checkCompatibility() {
const hasSpeechRecognition =
typeof window !== "undefined" &&
Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
const hasSpeechSynthesis =
typeof window !== "undefined" && Boolean(window.speechSynthesis);
const hasMicrophone =
typeof navigator !== "undefined" &&
Boolean(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
return {
speechRecognition: hasSpeechRecognition,
speechSynthesis: hasSpeechSynthesis,
microphone: hasMicrophone,
isFullySupported:
hasSpeechRecognition && hasSpeechSynthesis && hasMicrophone,
};
}
// ---------------------------------------------------------------------------
// Microphone Permission
// ---------------------------------------------------------------------------
/**
* Request microphone permission from the user.
*
* @returns {Promise<"granted"|"denied">}
*/
export async function requestMicrophonePermission() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return "denied";
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
return "granted";
} catch {
return "denied";
}
}
/**
* Query the current microphone permission state without prompting.
*
* @returns {Promise<"granted"|"denied"|"prompt">}
*/
export async function getMicrophonePermissionState() {
if (!navigator.permissions) {
return "prompt";
}
try {
const result = await navigator.permissions.query({ name: "microphone" });
return result.state;
} catch {
return "prompt";
}
}