185 lines
5.4 KiB
Python
185 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
G1 Interactive Voice Player
|
|
----------------------------
|
|
Lists WAV files and lets you pick which ones to play on the G1 built-in speaker.
|
|
Uses _CallRequestWithParamAndBin with format params (the working method).
|
|
|
|
Usage (run ON the robot):
|
|
python3 g1_interactive_player.py "/home/unitree/SanadVoice/recorded voices"
|
|
python3 g1_interactive_player.py "/home/unitree/SanadVoice/recorded voices" --volume 80
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
import wave
|
|
import json
|
|
import argparse
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from unitree_sdk2py.core.channel import ChannelFactoryInitialize
|
|
from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient
|
|
from unitree_sdk2py.g1.audio.g1_audio_api import (
|
|
ROBOT_API_ID_AUDIO_START_PLAY,
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
)
|
|
|
|
TARGET_RATE = 16000
|
|
APP_NAME = "p"
|
|
|
|
|
|
def load_wav(path: str):
|
|
with wave.open(path, "rb") as wf:
|
|
rate = wf.getframerate()
|
|
nch = wf.getnchannels()
|
|
audio = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16)
|
|
|
|
if nch == 2:
|
|
audio = audio.reshape(-1, 2).mean(axis=1).astype(np.int16)
|
|
|
|
if rate != TARGET_RATE:
|
|
tl = int(len(audio) * TARGET_RATE / rate)
|
|
audio = np.interp(
|
|
np.linspace(0, len(audio), tl, endpoint=False),
|
|
np.arange(len(audio)),
|
|
audio.astype(np.float64),
|
|
).astype(np.int16)
|
|
|
|
return audio
|
|
|
|
|
|
def play_pcm(client, pcm: np.ndarray):
|
|
# Single call with a unique stream_id — chunked repeats with the same sid
|
|
# are dropped by the audio service, per voice_note.txt.
|
|
client._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({"app_name": APP_NAME}))
|
|
time.sleep(0.2)
|
|
|
|
pcm_bytes = pcm.tobytes()
|
|
duration = len(pcm) / TARGET_RATE
|
|
sid = f"s_{int(time.time() * 1000)}"
|
|
param = json.dumps({
|
|
"app_name": APP_NAME,
|
|
"stream_id": sid,
|
|
"sample_rate": TARGET_RATE,
|
|
"channels": 1,
|
|
"bits_per_sample": 16,
|
|
})
|
|
|
|
start = time.time()
|
|
client._CallRequestWithParamAndBin(
|
|
ROBOT_API_ID_AUDIO_START_PLAY, param, list(pcm_bytes)
|
|
)
|
|
time.sleep(duration + 0.3)
|
|
client._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({"app_name": APP_NAME}))
|
|
return time.time() - start
|
|
|
|
|
|
def show_menu(wavs):
|
|
print(f"\n{'#':<4} {'Name':<45} {'Duration':>8}")
|
|
print("-" * 59)
|
|
for i, (p, dur) in enumerate(wavs):
|
|
print(f"{i:<4} {p.stem:<45} {dur:>6.1f}s")
|
|
print("-" * 59)
|
|
print(f"{'a':<4} {'Play ALL':<45}")
|
|
print(f"{'m':<4} {'Show menu':<45}")
|
|
print(f"{'q':<4} {'Quit':<45}")
|
|
print()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="G1 Interactive Voice Player")
|
|
parser.add_argument("directory", help="Directory containing WAV files")
|
|
parser.add_argument("--volume", "-v", type=int, default=100, help="Volume 0-100")
|
|
parser.add_argument("--interface", "-i", default="eth0", help="Network interface")
|
|
|
|
args = parser.parse_args()
|
|
|
|
wav_dir = Path(args.directory)
|
|
if not wav_dir.is_dir():
|
|
print(f"Not a directory: {wav_dir}")
|
|
sys.exit(1)
|
|
|
|
wav_files = []
|
|
for p in sorted(wav_dir.glob("*.wav")):
|
|
try:
|
|
with wave.open(str(p), "rb") as wf:
|
|
dur = wf.getnframes() / wf.getframerate()
|
|
wav_files.append((p, dur))
|
|
except Exception:
|
|
pass
|
|
|
|
if not wav_files:
|
|
print(f"No WAV files found in {wav_dir}")
|
|
sys.exit(1)
|
|
|
|
print(f"Connecting to G1 speaker...")
|
|
ChannelFactoryInitialize(0, args.interface)
|
|
client = AudioClient()
|
|
client.SetTimeout(10.0)
|
|
client.Init()
|
|
client.SetVolume(args.volume)
|
|
print(f"Ready. Volume: {args.volume}%")
|
|
|
|
show_menu(wav_files)
|
|
|
|
while True:
|
|
try:
|
|
choice = input("Pick a number (or a/m/q): ").strip().lower()
|
|
except (KeyboardInterrupt, EOFError):
|
|
print("\nBye.")
|
|
break
|
|
|
|
if choice == "q":
|
|
print("Bye.")
|
|
break
|
|
|
|
if choice == "m":
|
|
show_menu(wav_files)
|
|
continue
|
|
|
|
if choice == "a":
|
|
print(f"\nPlaying all {len(wav_files)} files...\n")
|
|
for i, (p, dur) in enumerate(wav_files):
|
|
print(f" [{i+1}/{len(wav_files)}] {p.stem} ({dur:.1f}s)...", end=" ", flush=True)
|
|
pcm = load_wav(str(p))
|
|
elapsed = play_pcm(client, pcm)
|
|
print(f"done ({elapsed:.1f}s)")
|
|
time.sleep(0.5)
|
|
print("\nAll done.\n")
|
|
continue
|
|
|
|
indices = []
|
|
for part in choice.replace(" ", "").split(","):
|
|
if "-" in part:
|
|
try:
|
|
a, b = part.split("-", 1)
|
|
indices.extend(range(int(a), int(b) + 1))
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
try:
|
|
indices.append(int(part))
|
|
except ValueError:
|
|
pass
|
|
|
|
if not indices:
|
|
print("Invalid. Enter number, range (2-5), list (1,3,5), a, m, or q.")
|
|
continue
|
|
|
|
for idx in indices:
|
|
if idx < 0 or idx >= len(wav_files):
|
|
print(f" #{idx} out of range, skipping.")
|
|
continue
|
|
|
|
p, dur = wav_files[idx]
|
|
print(f" Playing: {p.stem} ({dur:.1f}s)...", end=" ", flush=True)
|
|
pcm = load_wav(str(p))
|
|
elapsed = play_pcm(client, pcm)
|
|
print(f"done ({elapsed:.1f}s)")
|
|
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|