Skip to content
Closed
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
34 changes: 25 additions & 9 deletions tools/voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ def _audio_available() -> bool:
return False


def _default_input_samplerate(sd) -> int:
"""Return the preferred capture rate for the default input device.

Falls back to the Whisper-friendly 16 kHz constant when the backend does
not expose a numeric default rate.
"""
try:
info = sd.query_devices(None, "input")
rate = info.get("default_samplerate") if isinstance(info, dict) else getattr(info, "default_samplerate", None)
if isinstance(rate, (int, float)) and rate > 0:
return int(round(rate))
except Exception:
pass
return SAMPLE_RATE


from hermes_constants import is_termux as _is_termux_environment


Expand Down Expand Up @@ -100,7 +116,7 @@ def detect_audio_environment() -> dict:

# SSH detection
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
warnings.append("Running over SSH -- no audio devices available")
notices.append("Running over SSH — using local audio devices on the target machine")

# Docker/Podman container detection
from hermes_constants import is_container
Expand Down Expand Up @@ -394,6 +410,7 @@ def __init__(self) -> None:
self._frames: List[Any] = []
self._recording = False
self._start_time: float = 0.0
self._sample_rate: int = SAMPLE_RATE
# Silence detection state
self._has_spoken = False
self._speech_start: float = 0.0 # When speech attempt began
Expand Down Expand Up @@ -545,7 +562,7 @@ def _safe_cb():
stream = None
try:
stream = sd.InputStream(
samplerate=SAMPLE_RATE,
samplerate=self._sample_rate,
channels=CHANNELS,
dtype=DTYPE,
callback=_callback,
Expand Down Expand Up @@ -579,7 +596,7 @@ def start(self, on_silence_stop=None) -> None:
or if a recording is already in progress.
"""
try:
_import_audio()
sd, _ = _import_audio()
except (ImportError, OSError) as e:
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
Expand All @@ -601,13 +618,13 @@ def start(self, on_silence_stop=None) -> None:
self._peak_rms = 0
self._current_rms = 0
self._on_silence_stop = on_silence_stop

# Ensure the persistent stream is alive (no-op after first call).
self._sample_rate = _default_input_samplerate(sd)
self._ensure_stream()

with self._lock:
self._recording = True
logger.info("Voice recording started (rate=%d, channels=%d)", SAMPLE_RATE, CHANNELS)
logger.info("Voice recording started (rate=%d, channels=%d)", self._sample_rate, CHANNELS)

def _close_stream_with_timeout(self, timeout: float = 3.0) -> None:
"""Close the audio stream with a timeout to prevent CoreAudio hangs."""
Expand Down Expand Up @@ -662,7 +679,7 @@ def stop(self) -> Optional[str]:
logger.info("Voice recording stopped (%.1fs, %d samples)", elapsed, len(audio_data))

# Skip very short recordings (< 0.3s of audio)
min_samples = int(SAMPLE_RATE * 0.3)
min_samples = int(self._sample_rate * 0.3)
if len(audio_data) < min_samples:
logger.debug("Recording too short (%d samples), discarding", len(audio_data))
return None
Expand Down Expand Up @@ -700,8 +717,7 @@ def shutdown(self) -> None:

# -- private helpers -----------------------------------------------------

@staticmethod
def _write_wav(audio_data) -> str:
def _write_wav(self, audio_data) -> str:
"""Write numpy int16 audio data to a WAV file.

Returns the file path.
Expand All @@ -713,7 +729,7 @@ def _write_wav(audio_data) -> str:
with wave.open(wav_path, "wb") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.setframerate(self._sample_rate)
wf.writeframes(audio_data.tobytes())

file_size = os.path.getsize(wav_path)
Expand Down