Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 103 additions & 8 deletions src/openjarvis/cli/chat_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -37,13 +106,21 @@ 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,
agent_name: str | None,
tools: str | None,
system_prompt: str | None,
persona_name: str | None,
voice_mode: bool,
) -> None:
"""Start an interactive multi-turn chat session.

Expand All @@ -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)

Expand Down Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
121 changes: 121 additions & 0 deletions src/openjarvis/speech/voice_io.py
Original file line number Diff line number Diff line change
@@ -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"]