UNPKG

wake-word-command

Version:

A library for detecting wake words and extracting commands from speech

815 lines (711 loc) 25.3 kB
/** * WakeWordDetection - A library for detecting wake words and extracting commands from speech */ /** * Log levels for controlling console output * @enum {string} */ export const LogLevel = { NONE: "none", ERROR: "error", WARN: "warn", INFO: "info", DEBUG: "debug", ALL: "all", }; /** * Create a new WakeWordDetection instance * @param {Object} options - Configuration options * @param {string} options.wakeWord - The wake word to detect (mandatory) * @param {string} [options.language="en-US"] - The language to use for speech recognition * @param {Function} [options.onWakeWordDetected] - Callback when wake word is detected * @param {Function} [options.onTranscription] - Callback with current transcription * @param {Function} [options.onCommand] - Callback with extracted command * @param {Function} [options.onError] - Callback when an error occurs * @param {Function} [options.onCommandTimeout] - Callback when command timeout occurs * @param {string} [options.logLevel="info"] - Log level for console output (none, error, warn, info, debug, all) * @returns {Object} WakeWordDetection instance * @throws {Error} If wakeWord is not provided */ export function createWakeWordDetection(options = {}) { // Validate required options if (!options.wakeWord) { throw new Error("Wake word is required"); } // Default options const config = { wakeWord: options.wakeWord.toLowerCase(), language: options.language || "en-US", onWakeWordDetected: options.onWakeWordDetected || (() => {}), onTranscription: options.onTranscription || (() => {}), onCommand: options.onCommand || (() => {}), onError: options.onError || (() => {}), onCommandTimeout: options.onCommandTimeout || (() => {}), logLevel: options.logLevel || LogLevel.INFO, commandTimeoutMs: options.commandTimeoutMs || 3000, }; // Internal state let recognition = null; let isListening = false; let isPaused = false; let lastWakeWordTime = 0; let currentCommand = ""; let isCommandComplete = false; let wakeWordDetected = false; let commandTimeout = null; let fullTranscript = ""; let lastErrorTime = 0; let isProcessingCommand = false; let interimTranscript = ""; let restartTimeout = null; let isStopping = false; // Track if we're in the process of stopping let pendingRestart = false; // Track if we need to restart after stopping let inactivityTimeout = null; // Track inactivity timeout let commandBuffer = ""; // Buffer for quick commands let commandStartTime = 0; // Track when command listening started let commandMode = false; // Explicitly track if we're in command mode let lastTranscript = ""; // Store the last transcript for comparison let countdownInterval = null; // Track the countdown interval let waitingForNextFinal = false; // Track if we're waiting for the next isFinal event const WAKE_WORD_COOLDOWN_MS = 2000; const ERROR_COOLDOWN_MS = 1000; const MIN_COMMAND_LENGTH = 1; // Allow single-word commands like "Hi" or "Hello" const MAX_RESTART_ATTEMPTS = 5; const RESTART_DELAY_MS = 1000; const INACTIVITY_TIMEOUT_MS = 30000; // 30 seconds of inactivity before auto-restart const QUICK_COMMAND_BUFFER_MS = 1000; // Buffer time for quick commands let restartAttempts = 0; /** * Logger function that respects the configured log level * @param {string} level - The log level (error, warn, info, debug) * @param {string} message - The message to log * @param {any} [data] - Optional data to log */ function log(level, message, data) { // Map log levels to console methods const logMethods = { error: console.error, warn: console.warn, info: console.info, debug: console.debug, }; // Check if we should log based on configured level const shouldLog = shouldLogLevel(level, config.logLevel); if (shouldLog && logMethods[level]) { if (data !== undefined) { logMethods[level](message, data); } else { logMethods[level](message); } } } /** * Determine if a log level should be displayed based on the configured level * @param {string} level - The log level to check * @param {string} configuredLevel - The configured log level * @returns {boolean} Whether the log should be displayed */ function shouldLogLevel(level, configuredLevel) { if (configuredLevel === LogLevel.NONE) return false; if (configuredLevel === LogLevel.ALL) return true; const levels = [ LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, ]; const configuredIndex = levels.indexOf(configuredLevel); const levelIndex = levels.indexOf(level); return levelIndex <= configuredIndex; } /** * Initialize speech recognition */ function initializeSpeechRecognition() { try { // Check if browser supports speech recognition if ( !("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window) ) { throw new Error("Speech recognition not supported in this browser"); } // Create speech recognition instance const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SpeechRecognition(); // Configure recognition settings recognition.continuous = true; recognition.interimResults = true; recognition.lang = config.language; // Handle recognition results recognition.onresult = (event) => { // Reset restart attempts on successful result restartAttempts = 0; // Reset inactivity timeout resetInactivityTimeout(); const results = Array.from(event.results); const lastResult = results[results.length - 1]; const transcript = lastResult[0].transcript; const isFinal = lastResult.isFinal; const now = Date.now(); // Normalize the transcript const normalizedTranscript = transcript.trim().toLowerCase(); const normalizedWakeWord = config.wakeWord.toLowerCase(); log("debug", `Transcript: "${transcript}" (isFinal: ${isFinal})`); // Check if the transcript contains the wake word const containsWakeWord = normalizedTranscript.includes(normalizedWakeWord); // Trigger wake word detected callback if (containsWakeWord) config.onWakeWordDetected(); log( "debug", `Contains wake word "${config.wakeWord}": ${containsWakeWord}` ); // CASE 1: Wake word detected in a final result if (containsWakeWord && isFinal && !isProcessingCommand) { // Only process if we're not already handling a command and enough time has passed if (now - lastWakeWordTime > WAKE_WORD_COOLDOWN_MS) { log("info", "New wake word detected!"); lastWakeWordTime = now; isCommandComplete = false; wakeWordDetected = true; isProcessingCommand = true; commandMode = true; // // Call the wake word detected callback // config.onWakeWordDetected(); // Check if the transcript is ONLY the wake word const isOnlyWakeWord = normalizedTranscript.trim() === normalizedWakeWord; if (isOnlyWakeWord) { // If it's only the wake word, wait for the next isFinal event log( "info", "Wake word only detected, waiting for next command..." ); waitingForNextFinal = true; startCommandListening(); } else { // If it contains more than just the wake word, extract the command const commandText = extractCommandText(transcript); log("info", `Extracted command: "${commandText}"`); if (commandText && commandText.length >= MIN_COMMAND_LENGTH) { currentCommand = commandText; interimTranscript = commandText; config.onTranscription(commandText); // Process the command directly processCommand(commandText); } else { // No valid command, go back to listening for wake word log("info", "No valid command detected after wake word"); resetToWakeWordListening(); } } } } // CASE 2: We're waiting for the next isFinal event after wake word else if (waitingForNextFinal && isFinal) { log("debug", "Received next isFinal event after wake word"); // Store the transcript fullTranscript = transcript; lastTranscript = transcript; // Use this transcript as the command const commandText = transcript.trim(); if (commandText && commandText.length >= MIN_COMMAND_LENGTH) { log("info", `Command detected: "${commandText}"`); currentCommand = commandText; interimTranscript = commandText; config.onTranscription(commandText); // Process the command directly instead of calling finalizeCommand processCommand(commandText); } else { // No valid command, go back to listening for wake word log("info", "No valid command detected after wake word"); resetToWakeWordListening(); } } // CASE 3: We're in command mode and received a new transcript (not final) else if (wakeWordDetected && !isCommandComplete && !isFinal) { // Update the interim transcript for display interimTranscript = transcript; config.onTranscription(transcript); // If we're waiting for a command and the user starts talking, pause the timeout if (waitingForNextFinal) { log("debug", "User started talking, pausing command timeout"); pauseCommandTimeout(); } } }; // Handle recognition errors recognition.onerror = (event) => { log("error", "Speech recognition error:", event.error); // Handle no-speech errors differently if (event.error === "no-speech") { const now = Date.now(); if (now - lastErrorTime < ERROR_COOLDOWN_MS) { log("debug", "Ignoring frequent no-speech error"); return; } lastErrorTime = now; // For no-speech errors, we'll let the onend handler restart it // This is more reliable than treating it as a critical error log("info", "No speech detected, will restart on end"); return; } config.onError(`Error: ${event.error}`); // Handle specific errors that require restart if (["audio-capture", "network"].includes(event.error)) { log("info", "Restarting recognition:", event.info); restartRecognition(); } }; // Handle recognition end recognition.onend = () => { log("debug", "Recognition ended"); // If we have a pending restart, start again if (pendingRestart) { log("debug", "Executing pending restart"); pendingRestart = false; start(); } else if (isListening && !isPaused) { // For normal operation, restart after a short delay // This handles both errors and normal end events setTimeout(() => { if (isListening && !isPaused) { log("info", "Restarting recognition after end"); start(); } }, 100); } }; return true; } catch (error) { log("error", "Error initializing speech recognition:", error); config.onError(`Error initializing speech recognition: ${error.message}`); return false; } } /** * Reset the inactivity timeout */ function resetInactivityTimeout() { // Clear any existing timeout if (inactivityTimeout) { clearTimeout(inactivityTimeout); inactivityTimeout = null; } // Set a new timeout inactivityTimeout = setTimeout(() => { if (isListening && !isPaused) { log("info", "No activity detected, restarting recognition"); stop(); setTimeout(() => { if (isListening && !isPaused) { start(); } }, 100); } }, INACTIVITY_TIMEOUT_MS); } /** * Restart speech recognition with exponential backoff */ function restartRecognition() { if (restartTimeout) { clearTimeout(restartTimeout); } if (restartAttempts >= MAX_RESTART_ATTEMPTS) { log("warn", "Max restart attempts reached, stopping recognition"); stop(); return; } const delay = RESTART_DELAY_MS * Math.pow(2, restartAttempts); log( "info", `Restarting recognition in ${delay}ms (attempt ${ restartAttempts + 1 }/${MAX_RESTART_ATTEMPTS})` ); restartTimeout = setTimeout(() => { try { if (recognition) { recognition.stop(); } if (initializeSpeechRecognition()) { recognition.start(); restartAttempts++; } } catch (error) { log("error", "Error restarting recognition:", error); config.onError(`Error restarting recognition: ${error.message}`); } }, delay); } /** * Reset to wake word listening mode */ function resetToWakeWordListening() { log("debug", "Resetting to wake word listening mode"); isCommandComplete = true; wakeWordDetected = false; isProcessingCommand = false; commandMode = false; waitingForNextFinal = false; // Stop command listening stopCommandListening(); // Reset state currentCommand = ""; fullTranscript = ""; interimTranscript = ""; // Notify that we're returning to wake word listening config.onCommandTimeout(); } /** * Start listening for a command after wake word detection */ function startCommandListening() { log("debug", "Starting command listening"); // Clear any existing command timeout if (commandTimeout) { clearTimeout(commandTimeout); commandTimeout = null; } // Clear any existing countdown interval if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } // Set the command start time commandStartTime = Date.now(); log( "debug", `Command listening started at ${commandStartTime}, will timeout after ${config.commandTimeoutMs}ms` ); // Start a countdown timer that updates every second let remainingTime = config.commandTimeoutMs; countdownInterval = setInterval(() => { remainingTime -= 1000; const secondsLeft = Math.ceil(remainingTime / 1000); log("info", `Waiting for command... ${secondsLeft}s remaining`); if (remainingTime <= 0) { clearInterval(countdownInterval); countdownInterval = null; } }, 1000); // Set a single timeout for the full duration commandTimeout = setTimeout(() => { log( "debug", `Command timeout triggered after ${config.commandTimeoutMs}ms` ); // Clear the countdown interval if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } if (!isCommandComplete && wakeWordDetected) { log("info", "Command finalized by timeout!"); resetToWakeWordListening(); } }, config.commandTimeoutMs); } /** * Pause the command timeout when the user starts talking */ function pauseCommandTimeout() { log("debug", "Pausing command timeout"); // Clear the existing timeout if (commandTimeout) { clearTimeout(commandTimeout); commandTimeout = null; } // Clear the countdown interval if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } // We'll restart the timeout when we get the next isFinal event // or when we detect silence again } /** * Stop listening for a command */ function stopCommandListening() { log("debug", "Stopping command listening"); // Clear the command timeout if (commandTimeout) { clearTimeout(commandTimeout); commandTimeout = null; } // Clear the countdown interval if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } } /** * Process a command * @param {string} commandText - The command text to process */ function processCommand(commandText) { if (!isCommandComplete && wakeWordDetected) { log("debug", "Processing command..."); isCommandComplete = true; wakeWordDetected = false; isProcessingCommand = false; commandMode = false; // Exit command mode waitingForNextFinal = false; // Reset waiting for command flag // Stop command listening stopCommandListening(); log("debug", `Command text: "${commandText}"`); // Only process if this is a valid command (not empty and meets minimum length) if (commandText && commandText.length >= MIN_COMMAND_LENGTH) { log("debug", "Calling onCommand callback"); config.onCommand(commandText); } else { // If no valid command was detected, notify that we're returning to wake word listening log("info", `Last transcript: "${lastTranscript}"`); log("info", `Full transcript: "${fullTranscript}"`); log("info", `commandText: "${commandText}"`); log( "info", "No valid command detected, returning to wake word listening ❌" ); log("debug", "Calling onCommandTimeout callback"); config.onCommandTimeout(); } // Reset state currentCommand = ""; fullTranscript = ""; interimTranscript = ""; } } /** * Finalize the current command */ function finalizeCommand() { if (!isCommandComplete && wakeWordDetected) { log("debug", "Finalizing command..."); isCommandComplete = true; wakeWordDetected = false; isProcessingCommand = false; commandMode = false; // Exit command mode waitingForNextFinal = false; // Reset waiting for command flag // Stop command listening stopCommandListening(); // Get the command text - either from the current command or extract it let finalCommand = currentCommand; if (!finalCommand || finalCommand.length < MIN_COMMAND_LENGTH) { finalCommand = extractCommandText(fullTranscript); } log("debug", `Final command text: "${finalCommand}"`); // Only process if this is a valid command (not empty and meets minimum length) if (finalCommand && finalCommand.length >= MIN_COMMAND_LENGTH) { log("debug", "Calling onCommand callback"); config.onCommand(finalCommand); } else { // If no valid command was detected, notify that we're returning to wake word listening log("info", `Last transcript: "${lastTranscript}"`); log("info", `Full transcript: "${fullTranscript}"`); log( "info", "No valid command detected, returning to wake word listening ❌" ); log("debug", "Calling onCommandTimeout callback"); config.onCommandTimeout(); } // Reset state currentCommand = ""; fullTranscript = ""; interimTranscript = ""; } } /** * Extract command text (remove wake word and everything before it) * @param {string} text - The text to extract the command from * @returns {string} The extracted command */ function extractCommandText(text) { // Normalize the text by trimming and converting to lowercase const normalizedText = text.trim().toLowerCase(); const normalizedWakeWord = config.wakeWord.trim().toLowerCase(); // Find the position of the wake word const wakeWordIndex = normalizedText.indexOf(normalizedWakeWord); log("debug", `Wake word index: ${wakeWordIndex}`); // If wake word found in current text, get everything after it if (wakeWordIndex !== -1) { const command = normalizedText .substring(wakeWordIndex + normalizedWakeWord.length) .trim(); log("debug", `Extracted command after wake word: "${command}"`); return command; } // If no wake word found but we're in a command, return the current command if (wakeWordDetected && currentCommand) { log("debug", `Using current command: "${currentCommand}"`); return currentCommand; } // Check if we have a buffered command that might be a quick command if (commandBuffer && !wakeWordDetected) { const now = Date.now(); if (now - lastWakeWordTime < QUICK_COMMAND_BUFFER_MS) { log("debug", `Using buffered command: "${commandBuffer}"`); return commandBuffer; } } // If we're in command mode but no wake word in this transcript, // assume the entire transcript is the command if (commandMode && !isCommandComplete) { log( "debug", `No wake word in transcript, assuming entire transcript is command: "${normalizedText}"` ); return normalizedText; } // Otherwise return empty string return ""; } /** * Start listening for the wake word */ function start() { try { // Don't start if we're in the process of stopping if (isStopping) { log("debug", "Cannot start while stopping, will retry"); setTimeout(() => start(), 50); return; } // Initialize recognition if not already initialized if (!recognition) { if (!initializeSpeechRecognition()) { return; } } // Clear previous state currentCommand = ""; isCommandComplete = false; isPaused = false; wakeWordDetected = false; log("info", `Starting with wake word: "${config.wakeWord}"`); // Start recognition recognition.start(); isListening = true; // Set inactivity timeout resetInactivityTimeout(); } catch (error) { log("error", "Error starting speech recognition:", error); config.onError(`Error starting speech recognition: ${error.message}`); } } /** * Stop listening for the wake word */ function stop() { if (recognition) { isStopping = true; recognition.stop(); isListening = false; isPaused = false; // Clear inactivity timeout if (inactivityTimeout) { clearTimeout(inactivityTimeout); inactivityTimeout = null; } // Set a flag to indicate we're no longer stopping after a short delay setTimeout(() => { isStopping = false; }, 50); } } /** * Pause listening for the wake word */ function pause() { if (recognition && isListening) { recognition.stop(); isPaused = true; } } /** * Resume listening for the wake word */ function resume() { if (recognition && isPaused) { recognition.start(); isPaused = false; } } /** * Set a new wake word * @param {string} wakeWord - The new wake word */ function setWakeWord(wakeWord) { // Clear all command-related state currentCommand = ""; isCommandComplete = false; wakeWordDetected = false; isProcessingCommand = false; fullTranscript = ""; interimTranscript = ""; commandBuffer = ""; // Clear command buffer // Clear any existing timeouts if (commandTimeout) { clearTimeout(commandTimeout); commandTimeout = null; } // Update the wake word config.wakeWord = wakeWord.toLowerCase().trim(); // Set pending restart flag and stop pendingRestart = true; stop(); log("info", `Wake word set to: "${config.wakeWord}"`); } /** * Set a new language * @param {string} language - The new language */ function setLanguage(language) { config.language = language; if (recognition) { recognition.lang = language; // Set pending restart flag and stop pendingRestart = true; stop(); } log("info", `Language set to: "${language}"`); } /** * Set the log level * @param {string} logLevel - The new log level (none, error, warn, info, debug, all) */ function setLogLevel(logLevel) { if (Object.values(LogLevel).includes(logLevel)) { config.logLevel = logLevel; log("info", `Log level set to: ${logLevel}`); } else { log( "warn", `Invalid log level: ${logLevel}. Using default: ${LogLevel.INFO}` ); } } /** * Check if the browser supports speech recognition * @returns {boolean} True if speech recognition is supported */ function isSupported() { return !!(window.SpeechRecognition || window.webkitSpeechRecognition); } // Return the public API return { start, stop, pause, resume, setWakeWord, setLanguage, setLogLevel, isSupported, }; }