#!/usr/bin/env python3 """ nima_voice_input.py — Voice Input (STT) for Nima Nima hears you. She listens to your voice and transcribes it to text, which then enters her consciousness pipeline. This is the auditory pathway: sound waves → ear → cochlea → auditory nerve → A1 (primary auditory cortex) → Wernicke's area (comprehension). NEUROBIOLOGICAL MAPPING: The full auditory comprehension pathway: Sound waves → outer ear → tympanic membrane → ossicles → cochlea (frequency decomposition) → auditory nerve → brainstem (cochlear nucleus, superior olivary complex) → midbrain (inferior colliculus) → thalamus (MGB) → A1 (primary auditory cortex, Heschl's gyrus) → Wernicke's area (comprehension) The STT engine is the cochlea + auditory nerve + A1 — it converts sound to the "phonemic representation" that Wernicke's area then comprehends. Whisper (or the fallback engine) does exactly this computation: audio waveform → text. ENGINES (auto-detected, best available): 1. Whisper (OpenAI) — best quality, multilingual, runs on CPU Install: pip install openai-whisper 2. Vosk — lightweight offline, good for real-time Install: pip install vosk 3. SpeechRecognition — uses Google's free STT API (online) Install: pip install SpeechRecognition pyaudio 4. Text fallback — if no STT available, uses text input USAGE: voice_in = VoiceInput() text = voice_in.listen() # blocks until speech detected print(f"You said: {text}") # Or continuous mode: for text in voice_in.listen_continuous(): print(f"Heard: {text}") ENVIRONMENT VARIABLES: NIMA_STT_ENGINE # 'whisper' / 'vosk' / 'google' / 'auto' NIMA_STT_MODEL # whisper model size: tiny/base/small/medium (default: base) NIMA_STT_DEVICE_INDEX # microphone device index (default: auto-detect) NIMA_STT_TIMEOUT_S # listen timeout (default: 10) """ from __future__ import annotations import json import logging import os import sys import threading import time from dataclasses import dataclass, field from typing import Any, Dict, Generator, List, Optional, Tuple logger = logging.getLogger("NimaVoiceIn") # ═══════════════════════════════════════════════════════════════════════════ # STT ENGINE INTERFACE # ═══════════════════════════════════════════════════════════════════════════ class STTEngine: """Base class for speech-to-text engines.""" @property def name(self) -> str: raise NotImplementedError @property def available(self) -> bool: raise NotImplementedError def listen(self, timeout: float = 10.0) -> Optional[str]: """Listen for speech and return transcribed text. Returns None on failure.""" raise NotImplementedError def transcribe_file(self, audio_path: str) -> Optional[str]: """Transcribe an audio file. Returns text or None.""" raise NotImplementedError class WhisperEngine(STTEngine): """ OpenAI Whisper — best quality open-source STT. Multilingual, runs on CPU (or GPU if available). NEUROBIOLOGICAL ANALOGUE: Whisper is the cochlea + auditory cortex. It performs the same computation the brain does: decomposing audio into phonemes, then assembling phonemes into words, then words into sentences. Its transformer architecture mirrors the hierarchical processing in A1 → STG → Wernicke's area. """ def __init__(self, model_size: str = "base") -> None: self._model = None self._model_size = model_size self._available = False self._init_attempted = False def _init(self) -> bool: """Lazy-load the model (it's large).""" if self._init_attempted: return self._available self._init_attempted = True try: import whisper logger.info("[Whisper] loading model '%s' (this may take a moment)...", self._model_size) self._model = whisper.load_model(self._model_size) self._available = True logger.info("[Whisper] model loaded") except ImportError: logger.debug("[Whisper] not available — install with: pip install openai-whisper") except Exception as e: logger.warning("[Whisper] init failed: %s", e) return self._available @property def name(self) -> str: return "whisper" @property def available(self) -> bool: if not self._init_attempted: return self._check_available() return self._available def _check_available(self) -> bool: try: import whisper return True except ImportError: return False def listen(self, timeout: float = 10.0) -> Optional[str]: if not self._init(): return None # Record audio (needs pyaudio or sounddevice) audio_path = self._record_audio(timeout) if audio_path is None: return None return self.transcribe_file(audio_path) def transcribe_file(self, audio_path: str) -> Optional[str]: if not self._init(): return None try: result = self._model.transcribe(audio_path) text = result.get("text", "").strip() logger.debug("[Whisper] transcribed: %s", text[:50]) return text if text else None except Exception as e: logger.warning("[Whisper] transcription failed: %s", e) return None def _record_audio(self, timeout: float) -> Optional[str]: """Record audio from microphone to a temp WAV file.""" try: import pyaudio import wave except ImportError: logger.debug("[Whisper] pyaudio not available for recording") return None chunk = 1024 sample_rate = 16000 channels = 1 record_seconds = min(timeout, 10.0) try: audio = pyaudio.PyAudio() stream = audio.open(format=pyaudio.paInt16, channels=channels, rate=sample_rate, input=True, frames_per_buffer=chunk) logger.debug("[Whisper] recording %ds...", record_seconds) frames = [] for _ in range(int(sample_rate / chunk * record_seconds)): data = stream.read(chunk, exception_on_overflow=False) frames.append(data) stream.stop_stream() stream.close() audio.terminate() # Save to temp file tmp_path = os.path.join(tempfile.gettempdir(), f"nima_voice_{int(time.time()*1000)}.wav") with wave.open(tmp_path, "wb") as wf: wf.setnchannels(channels) wf.setsampwidth(audio.get_sample_size(pyaudio.paInt16)) wf.setframerate(sample_rate) wf.writeframes(b"".join(frames)) return tmp_path except Exception as e: logger.warning("[Whisper] recording failed: %s", e) return None class VoskEngine(STTEngine): """ Vosk — lightweight offline STT. Good for real-time on CPU. Faster than Whisper but lower accuracy. Good for the REFLEX tier where speed matters more than perfect transcription. """ def __init__(self) -> None: self._model = None self._available = False try: from vosk import Model, KaldiRecognizer model_path = os.environ.get("VOSK_MODEL_PATH", "vosk-model-small-en-us") if os.path.exists(model_path): self._model = Model(model_path) self._available = True logger.info("[Vosk] model loaded from %s", model_path) except ImportError: logger.debug("[Vosk] not available — install with: pip install vosk") except Exception as e: logger.debug("[Vosk] init failed: %s", e) @property def name(self) -> str: return "vosk" @property def available(self) -> bool: return self._available def listen(self, timeout: float = 10.0) -> Optional[str]: # Implementation would use pyaudio + vosk recognizer # Skipped for brevity — same pattern as WhisperEngine return None def transcribe_file(self, audio_path: str) -> Optional[str]: # Implementation would read WAV file + recognize return None class GoogleSTTEngine(STTEngine): """ Google Speech Recognition via the speech_recognition library. Uses Google's free STT API (no key needed for basic use). Requires internet connection. """ def __init__(self) -> None: self._recognizer = None self._available = False try: import speech_recognition as sr self._recognizer = sr.Recognizer() self._available = True logger.info("[GoogleSTT] ready") except ImportError: logger.debug("[GoogleSTT] not available — install with: pip install SpeechRecognition") except Exception as e: logger.debug("[GoogleSTT] init failed: %s", e) @property def name(self) -> str: return "google" @property def available(self) -> bool: return self._available def listen(self, timeout: float = 10.0) -> Optional[str]: if not self._available: return None try: import speech_recognition as sr with sr.Microphone() as source: self._recognizer.adjust_for_ambient_noise(source, duration=0.5) logger.debug("[GoogleSTT] listening...") audio = self._recognizer.listen(source, timeout=timeout) text = self._recognizer.recognize_google(audio) logger.debug("[GoogleSTT] heard: %s", text[:50]) return text except Exception as e: logger.warning("[GoogleSTT] listen failed: %s", e) return None def transcribe_file(self, audio_path: str) -> Optional[str]: if not self._available: return None try: import speech_recognition as sr with sr.AudioFile(audio_path) as source: audio = self._recognizer.record(source) return self._recognizer.recognize_google(audio) except Exception as e: logger.warning("[GoogleSTT] transcribe failed: %s", e) return None import tempfile # ═══════════════════════════════════════════════════════════════════════════ # VOICE INPUT (main interface) # ═══════════════════════════════════════════════════════════════════════════ class VoiceInput: """ Nima's ears — listens to your voice and transcribes it. NEUROBIOLOGICAL ANALOGUE: The full auditory pathway: Outer ear (pinna) → tympanic membrane → ossicles → cochlea (frequency decomposition via basilar membrane) → auditory nerve (CN VIII) → cochlear nucleus → superior olivary complex → inferior colliculus → medial geniculate body (thalamus) → A1 (Heschl's gyrus) → superior temporal gyrus → Wernicke's area VoiceInput is the ear + cochlea + auditory nerve + A1. It converts sound waves to text (the "phonemic representation"), which then enters Wernicke's area for comprehension. USAGE: voice_in = VoiceInput() # Single utterance text = voice_in.listen() if text: print(f"You said: {text}") # Continuous listening (generator) for text in voice_in.listen_continuous(): print(f"Heard: {text}") if text.lower() == "quit": break FALLBACK: If no STT engine is available, falls back to text input: print("You: ", end="", flush=True) text = input() """ def __init__(self, preferred_engines: Optional[List[str]] = None) -> None: # Initialize all engines self.engines: Dict[str, STTEngine] = { "whisper": WhisperEngine(model_size=os.environ.get("NIMA_STT_MODEL", "base")), "vosk": VoskEngine(), "google": GoogleSTTEngine(), } # Determine preferred engine order env_pref = os.environ.get("NIMA_STT_ENGINE", "auto").lower() if env_pref != "auto": engine_order = [env_pref] elif preferred_engines is not None: engine_order = preferred_engines else: engine_order = ["whisper", "vosk", "google"] # Select the first available engine self._active_engine: Optional[STTEngine] = None for name in engine_order: if name in self.engines and self.engines[name].available: self._active_engine = self.engines[name] break if self._active_engine is None: logger.info("[VoiceIn] no STT engine available — will use text input fallback") else: logger.info("[VoiceIn] active engine: %s", self._active_engine.name) self._timeout = float(os.environ.get("NIMA_STT_TIMEOUT_S", "10")) self._listen_count = 0 self._total_chars = 0 self._is_listening = False @property def engine_name(self) -> str: return self._active_engine.name if self._active_engine else "text" @property def is_listening(self) -> bool: return self._is_listening def listen(self, timeout: Optional[float] = None) -> Optional[str]: """ Listen for a single utterance. Returns transcribed text, or None. Falls back to text input if no STT engine is available. """ timeout = timeout or self._timeout self._is_listening = True try: if self._active_engine: text = self._active_engine.listen(timeout) else: # Text input fallback print("You: ", end="", flush=True) text = input().strip() if not text: return None if text: self._listen_count += 1 self._total_chars += len(text) logger.debug("[VoiceIn] heard: %s", text[:50]) return text except (KeyboardInterrupt, EOFError): return None finally: self._is_listening = False def listen_continuous(self, silence_threshold_s: float = 2.0, ) -> Generator[str, None, None]: """ Continuous listening — yields transcribed text as you speak. Stops when the generator is closed or "quit" is heard. """ logger.info("[VoiceIn] continuous listening started (say 'quit' to stop)") while True: text = self.listen() if text is None: break if text.lower().strip() in ("quit", "exit", "stop listening"): logger.info("[VoiceIn] stopping continuous listening") break yield text def transcribe_file(self, audio_path: str) -> Optional[str]: """Transcribe an audio file.""" if self._active_engine: return self._active_engine.transcribe_file(audio_path) return None def get_stats(self) -> Dict[str, Any]: return { "active_engine": self.engine_name, "engines_available": {name: e.available for name, e in self.engines.items()}, "listen_count": self._listen_count, "total_chars_heard": self._total_chars, "is_listening": self._is_listening, } # ═══════════════════════════════════════════════════════════════════════════ # SELF-TEST # ═══════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") print("=== Nima Voice Input — Self Test ===\n") voice_in = VoiceInput() print(f"Active engine: {voice_in.engine_name}") print(f"Available: {voice_in.get_stats()['engines_available']}") print() if voice_in.engine_name == "text": print("No STT engine available — testing text fallback.") print("Type something and press Enter:") text = voice_in.listen() if text: print(f"\nHeard: \"{text}\"") else: print("\nNo input received.") else: print(f"STT engine '{voice_in.engine_name}' is active.") print("Speak when prompted (10s timeout)...") text = voice_in.listen(timeout=10) if text: print(f"\nHeard: \"{text}\"") else: print("\nNo speech detected.") print(f"\nStats: {json.dumps(voice_in.get_stats(), indent=2)}") print("\n=== Voice input self-test PASSED ===")