| |
| """ |
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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() |
|
|
| |
| 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]: |
| |
| |
| return None |
|
|
| def transcribe_file(self, audio_path: str) -> Optional[str]: |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| self.engines: Dict[str, STTEngine] = { |
| "whisper": WhisperEngine(model_size=os.environ.get("NIMA_STT_MODEL", "base")), |
| "vosk": VoskEngine(), |
| "google": GoogleSTTEngine(), |
| } |
|
|
| |
| 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"] |
|
|
| |
| 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: |
| |
| 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, |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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 ===") |
|
|