diff --git a/src/openjarvis/cli/chat_cmd.py b/src/openjarvis/cli/chat_cmd.py index 5610c5bdd..fa5387439 100644 --- a/src/openjarvis/cli/chat_cmd.py +++ b/src/openjarvis/cli/chat_cmd.py @@ -22,6 +22,75 @@ def _read_input(prompt: str = "You> ") -> Optional[str]: return None +_VOICE_EXIT = object() # sentinel: user wants to quit + + +def _record_voice(console: "Console") -> "Optional[str] | object": + """Record from mic, transcribe, and return text. + + Returns: + str — transcribed text to use as input + None — nothing heard / transcription empty, try again + _VOICE_EXIT — Ctrl-C / fatal error, caller should exit + """ + from openjarvis.core.config import load_config + from openjarvis.speech._discovery import get_speech_backend + from openjarvis.speech.voice_io import record_until_silence + + config = load_config() + backend = get_speech_backend(config) + if backend is None: + console.print( + "[red]No speech-to-text backend available. " + "Install faster-whisper: pip install faster-whisper[/red]" + ) + return _VOICE_EXIT + + console.print("[dim cyan]Listening… (speak now, stops on silence)[/dim cyan]") + try: + audio_bytes = record_until_silence() + except RuntimeError as exc: + console.print(f"[red]Mic error: {exc}[/red]") + return _VOICE_EXIT + except KeyboardInterrupt: + return _VOICE_EXIT + + console.print("[dim]Transcribing…[/dim]") + try: + result = backend.transcribe(audio_bytes, format="wav") + text = result.text.strip() + if text: + console.print(f"[bold]You (voice):[/bold] {text}") + return text + console.print("[dim]Nothing heard — try again.[/dim]") + return None + except Exception as exc: + console.print(f"[red]Transcription error: {exc}[/red]") + return None + + +def _speak(text: str, console: "Console") -> None: + """Synthesize text and play it back; silently skip on missing deps.""" + from openjarvis.core.registry import TTSRegistry + from openjarvis.speech.voice_io import play_wav + + # Try kokoro first (local), then any registered TTS backend + for key in ("kokoro", "openai_tts", "cartesia"): + if TTSRegistry.contains(key): + try: + backend = TTSRegistry.get(key)() + if not backend.health(): + continue + result = backend.synthesize(text, output_format="wav") + if result.audio: + play_wav(result.audio, sample_rate=result.sample_rate) + return + except Exception: + continue + + console.print("[dim yellow]No TTS backend available — install kokoro: pip install kokoro[/dim yellow]") + + @click.command() @click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.") @click.option("-m", "--model", "model_name", default=None, help="Model to use.") @@ -37,6 +106,13 @@ def _read_input(prompt: str = "You> ") -> Optional[str]: "(overrides config). Pass 'none' to disable all persona files." ), ) +@click.option( + "--voice", + "voice_mode", + is_flag=True, + default=False, + help="Enable voice I/O: mic input with silence detection + TTS response playback.", +) def chat( engine_key: str | None, model_name: str | None, @@ -44,6 +120,7 @@ def chat( tools: str | None, system_prompt: str | None, persona_name: str | None, + voice_mode: bool, ) -> None: """Start an interactive multi-turn chat session. @@ -53,6 +130,9 @@ def chat( /model — show current model /help — show available commands /history — show conversation history + + Pass --voice to use microphone input (silence-detection) and hear responses + read back via text-to-speech (kokoro local or OpenAI TTS). """ console = Console(stderr=True) @@ -158,11 +238,16 @@ def _confirm(prompt: str) -> bool: except Exception as exc: console.print(f"[yellow]Agent '{agent_key}' failed: {exc}[/yellow]") + # Trigger TTS backend registration so _speak can find backends + import openjarvis.speech # noqa: F401 + # Print banner + voice_hint = " [magenta]Voice mode ON[/magenta] — speak after the prompt; silence stops recording.\n" if voice_mode else "" console.print( f"[green bold]OpenJarvis Chat[/green bold]\n" f" Engine: [cyan]{engine_name}[/cyan] Model: [cyan]{model}[/cyan]" f" Agent: [cyan]{agent_key or 'direct'}[/cyan]\n" + f"{voice_hint}" f" Type /help for commands, /quit to exit.\n" ) @@ -199,14 +284,22 @@ def _confirm(prompt: str) -> bool: for note in _notifications.diff(get_status()): console.print(f"[dim cyan]{note}[/dim cyan]") - user_input = _read_input() - if user_input is None: - console.print("\n[dim]Goodbye![/dim]") - break - - user_input = user_input.strip() - if not user_input: - continue + if voice_mode: + result = _record_voice(console) + if result is _VOICE_EXIT: + console.print("\n[dim]Goodbye![/dim]") + break + if result is None: + continue # nothing heard, loop again + user_input = result + else: + user_input = _read_input() + if user_input is None: + console.print("\n[dim]Goodbye![/dim]") + break + user_input = user_input.strip() + if not user_input: + continue # Handle slash commands cmd = user_input.lower() @@ -266,6 +359,8 @@ def _confirm(prompt: str) -> bool: console.print() console.print(Markdown(content)) console.print() + if voice_mode: + _speak(content, console) except KeyboardInterrupt: console.print("\n[dim]Generation interrupted.[/dim]") except Exception as exc: diff --git a/src/openjarvis/speech/voice_io.py b/src/openjarvis/speech/voice_io.py new file mode 100644 index 000000000..d65348830 --- /dev/null +++ b/src/openjarvis/speech/voice_io.py @@ -0,0 +1,121 @@ +"""Microphone recording with silence detection and audio playback helpers.""" + +from __future__ import annotations + +import io +import wave +from typing import Optional + +_SAMPLE_RATE = 16000 +_CHANNELS = 1 +_CHUNK = 1024 +_SILENCE_THRESHOLD = 500 # RMS below this → silence +_SILENCE_SECONDS = 1.5 # seconds of silence before auto-stop +_MAX_RECORD_SECONDS = 30 # safety ceiling + + +def _rms(data: bytes) -> float: + """Compute RMS amplitude of 16-bit PCM bytes.""" + import struct + + n = len(data) // 2 + if n == 0: + return 0.0 + shorts = struct.unpack(f"{n}h", data[:n * 2]) + return (sum(s * s for s in shorts) / n) ** 0.5 + + +def record_until_silence( + *, + sample_rate: int = _SAMPLE_RATE, + silence_threshold: int = _SILENCE_THRESHOLD, + silence_seconds: float = _SILENCE_SECONDS, + max_seconds: float = _MAX_RECORD_SECONDS, +) -> bytes: + """Record from the default microphone until silence is detected. + + Returns raw WAV bytes (16-bit mono). + Raises RuntimeError if sounddevice/numpy are not installed. + """ + try: + import numpy as np + import sounddevice as sd + except ImportError: + raise RuntimeError( + "sounddevice and numpy are required for voice input. " + "Install with: pip install sounddevice numpy" + ) + + chunks_per_second = sample_rate / _CHUNK + silence_chunks = int(silence_seconds * chunks_per_second) + max_chunks = int(max_seconds * chunks_per_second) + + frames: list[bytes] = [] + silence_count = 0 + has_speech = False + + with sd.RawInputStream( + samplerate=sample_rate, + channels=_CHANNELS, + dtype="int16", + blocksize=_CHUNK, + ) as stream: + for _ in range(max_chunks): + raw, _ = stream.read(_CHUNK) + data = bytes(raw) + frames.append(data) + + amplitude = _rms(data) + if amplitude > silence_threshold: + has_speech = True + silence_count = 0 + elif has_speech: + silence_count += 1 + if silence_count >= silence_chunks: + break + + return _frames_to_wav(frames, sample_rate) + + +def _frames_to_wav(frames: list[bytes], sample_rate: int) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(_CHANNELS) + wf.setsampwidth(2) # 16-bit + wf.setframerate(sample_rate) + wf.writeframes(b"".join(frames)) + return buf.getvalue() + + +def play_wav(audio: bytes, sample_rate: int = 24000) -> None: + """Play raw WAV bytes through the default output device. + + If the bytes are a valid WAV file, sample rate is read from the header; + otherwise ``sample_rate`` is used as a fallback. + """ + try: + import numpy as np + import sounddevice as sd + import soundfile as sf + except ImportError: + raise RuntimeError( + "sounddevice, numpy, and soundfile are required for voice output. " + "Install with: pip install sounddevice numpy soundfile" + ) + + buf = io.BytesIO(audio) + try: + data, sr = sf.read(buf, dtype="float32") + except Exception: + # Fall back: treat as raw PCM + import struct + + n = len(audio) // 2 + data = np.array(struct.unpack(f"{n}h", audio[:n * 2]), dtype="float32") / 32768.0 + sr = sample_rate + + sd.play(data, sr) + sd.wait() + + +__all__ = ["play_wav", "record_until_silence"]