Skip to content
Merged
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
175 changes: 114 additions & 61 deletions voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
import wave
import queue
import shutil
import threading
import traceback
import subprocess
from collections import deque

import numpy as np
from dotenv import load_dotenv
Expand All @@ -33,7 +36,8 @@

SAMPLE_RATE = 16000 # Whisper's native rate
BLOCK = 480 # 30 ms frames
SILENCE_HANG = 0.8 # seconds of trailing quiet that ends a turn
SILENCE_HANG = float(os.getenv("ARIA_VOICE_HANG", "1.2")) # trailing quiet that ends a turn (s)
MIN_SPEECH = float(os.getenv("ARIA_VOICE_MIN_SPEECH", "1.0")) # min voiced audio before a turn can end (s)
MAX_TURN = 30 # hard cap on one utterance (seconds)
EXIT_WORDS = {"exit", "quit", "goodbye", "bye", "stop"}

Expand Down Expand Up @@ -80,50 +84,71 @@ def speak(text: str):
# else: no TTS backend — the text is already printed, so we just stay silent.


def record_until_silence(sd) -> np.ndarray:
"""Wait for speech to start, then record until ~SILENCE_HANG of quiet."""
q: queue.Queue = queue.Queue()

def cb(indata, frames, t, status):
q.put(indata[:, 0].copy())

with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32",
blocksize=BLOCK, callback=cb):
ambient = [_rms(q.get()) for _ in range(int(0.4 * SAMPLE_RATE / BLOCK))]
floor = max(np.mean(ambient) * 3.0, 0.015) # adaptive threshold

frames, speaking, silent_for, elapsed = [], False, 0.0, 0.0
per_block = BLOCK / SAMPLE_RATE
def _drain(q: queue.Queue):
"""Discard everything captured so far — called before each turn so audio the mic
heard while Aria was speaking (her own voice) doesn't poison the next utterance."""
try:
while True:
block = q.get()
level = _rms(block)
if not speaking:
if level > floor:
speaking = True
frames.append(block)
continue
frames.append(block)
elapsed += per_block
silent_for = silent_for + per_block if level < floor else 0.0
if silent_for >= SILENCE_HANG or elapsed >= MAX_TURN:
break
q.get_nowait()
except queue.Empty:
pass


def listen_hands_free(q: queue.Queue) -> np.ndarray:
"""Read from the live mic queue: wait for speech, then record until a sustained pause.
A turn ends only after MIN_SPEECH of voiced audio AND SILENCE_HANG of trailing quiet,
so it won't cut off a couple words in. Returns empty if no speech for ~20 s."""
per_block = BLOCK / SAMPLE_RATE
ambient = [_rms(q.get()) for _ in range(int(0.4 * SAMPLE_RATE / BLOCK))]
# Sensitive enough not to clip soft speech, capped so a noisy calibration can't
# make us deaf and end turns early.
floor = min(max(np.mean(ambient) * 1.8, 0.01), 0.05)

preroll = deque(maxlen=6) # ~0.18 s kept before onset so word 1 isn't clipped
recent = deque(maxlen=3) # smooth ~90 ms so mid-word dips don't read as silence
frames, speaking = [], False
silent_for, voiced_for, elapsed, waited = 0.0, 0.0, 0.0, 0.0
while True:
try:
block = q.get(timeout=1.0)
except queue.Empty:
return np.zeros(0, dtype="float32") # stream stalled — let the loop re-prompt
recent.append(_rms(block))
level = max(recent)
if not speaking:
preroll.append(block)
waited += per_block
if level > floor:
speaking = True
frames.extend(preroll)
elif waited >= 20.0:
return np.zeros(0, dtype="float32")
continue
frames.append(block)
elapsed += per_block
if level >= floor:
voiced_for += per_block
silent_for = 0.0
else:
silent_for += per_block
if (silent_for >= SILENCE_HANG and voiced_for >= MIN_SPEECH) or elapsed >= MAX_TURN:
break
return np.concatenate(frames) if frames else np.zeros(0, dtype="float32")


def record_ptt(sd) -> np.ndarray:
"""Push-to-talk: record between two Enter presses."""
def listen_ptt(q: queue.Queue) -> np.ndarray:
"""Push-to-talk over the live mic queue: capture between two Enter presses."""
input(" [Enter] to start…")
q: queue.Queue = queue.Queue()

def cb(indata, frames, t, status):
q.put(indata[:, 0].copy())

with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32",
blocksize=BLOCK, callback=cb):
input(" 🔴 recording… [Enter] to stop")
_drain(q)
stop = threading.Event()
threading.Thread(target=lambda: (input(" 🔴 recording… [Enter] to stop"),
stop.set()), daemon=True).start()
frames = []
while not q.empty():
frames.append(q.get())
while not stop.is_set():
try:
frames.append(q.get(timeout=0.1))
except queue.Empty:
pass
return np.concatenate(frames) if frames else np.zeros(0, dtype="float32")


Expand All @@ -142,32 +167,60 @@ def main():
print("Initializing Aria…")
agent = build_agent(checkpointer=open_checkpointer())
thread_id = "local-voice"

# One persistent mic stream for the whole session — reopening per turn is flaky on
# macOS and drops audio. We flush it (_drain) before each turn instead.
q: queue.Queue = queue.Queue()

def cb(indata, frames, t, status):
q.put(indata[:, 0].copy())

stream = sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32",
blocksize=BLOCK, callback=cb)
stream.start()
print("🎙️ Voice mode. "
+ ("Press Enter to talk." if ptt else "Just start talking.")
+ " Say 'exit' or hit Ctrl-C to quit.")
+ " Say 'exit' or hit Ctrl-C to quit. (half-duplex: she can't hear you while she talks)")

while True:
try:
samples = record_ptt(sd) if ptt else record_until_silence(sd)
if samples.size < SAMPLE_RATE * 0.3: # <0.3 s — nothing useful
continue
text = transcribe_audio(_wav_bytes(samples), mime_type="audio/wav").strip()
if not text:
try:
while True:
# Per-turn isolation: a transcribe/agent error skips this turn instead of
# ending the whole session. Full traceback printed so failures are visible.
try:
_drain(q) # drop her own voice / stale audio
if not ptt:
print("🎧 listening…")
samples = listen_ptt(q) if ptt else listen_hands_free(q)
if samples.size < SAMPLE_RATE * 0.3: # <0.3 s — nothing useful
if not ptt:
print(" (didn't catch that)")
continue
print(" 📝 transcribing…")
text = transcribe_audio(_wav_bytes(samples), mime_type="audio/wav").strip()
if not text:
print(" (couldn't make that out)")
continue
print(f"You: {text}")
if re.sub(r"[^a-z]", "", text.lower()) in EXIT_WORDS:
speak("Goodbye.")
break
print(" 💭 thinking…")
result = agent.invoke({"messages": [HumanMessage(content=text)]},
config=thread_config(thread_id))
reply = extract_text(result["messages"][-1].content)
print(f"Aria: {reply}")
speak(reply)
except KeyboardInterrupt:
raise
except Exception:
print(" [turn error — skipping, full trace below]")
traceback.print_exc()
continue
print(f"\nYou: {text}")
if re.sub(r"[^a-z]", "", text.lower()) in EXIT_WORDS:
speak("Goodbye.")
break
result = agent.invoke({"messages": [HumanMessage(content=text)]},
config=thread_config(thread_id))
reply = extract_text(result["messages"][-1].content)
print(f"Aria: {reply}")
speak(reply)
except KeyboardInterrupt:
print("\nExiting voice mode.")
break
except Exception as e:
print(f"[voice error] {e}")
except KeyboardInterrupt:
print("\nExiting voice mode.")
finally:
stream.stop()
stream.close()


if __name__ == "__main__":
Expand Down
Loading
Loading