480 lines
17 KiB
Python
480 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
G1 Voice Deploy — Record on workstation, deploy & play on G1 robot.
|
|
Also sets up mic on robot and manages remote recordings.
|
|
|
|
Usage (run from workstation):
|
|
python3 g1_voice_deploy.py setup-mic # Unmute & configure wireless mic on robot
|
|
python3 g1_voice_deploy.py record --name hi # Record from workstation mic, save locally
|
|
python3 g1_voice_deploy.py record-robot --name arabic_greeting --seconds 5 # Record from robot mic
|
|
python3 g1_voice_deploy.py play --name hi # Deploy & play on G1 speaker
|
|
python3 g1_voice_deploy.py play-robot --path "/home/unitree/SanadVoice/recorded voices/welcome_single.wav"
|
|
python3 g1_voice_deploy.py tts --text "Hello" # TTS on G1 speaker
|
|
python3 g1_voice_deploy.py list # List local recordings
|
|
python3 g1_voice_deploy.py list-robot # List recordings on robot
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import wave
|
|
import numpy as np
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
DATA_DIR = SCRIPT_DIR / "DataG1"
|
|
|
|
ROBOT_IP = "192.168.123.164"
|
|
ROBOT_USER = "unitree"
|
|
ROBOT_PYTHON = "/home/unitree/miniconda3/envs/gemini/bin/python3"
|
|
ROBOT_VOICES_DIR = "/home/unitree/SanadVoice/recorded voices"
|
|
ROBOT_DDS_IFACE = "eth0"
|
|
|
|
SAMPLE_RATE = 16000
|
|
CHANNELS = 1
|
|
SAMPLE_WIDTH = 2
|
|
CHUNK_SIZE = 4096
|
|
|
|
SSH_OPTS = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5"]
|
|
|
|
|
|
def ssh_cmd(cmd: str, capture=True):
|
|
"""Run a command on the robot via SSH."""
|
|
target = f"{ROBOT_USER}@{ROBOT_IP}"
|
|
result = subprocess.run(
|
|
["ssh"] + SSH_OPTS + [target, cmd],
|
|
capture_output=capture, text=True,
|
|
)
|
|
if capture:
|
|
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
return result.returncode, "", ""
|
|
|
|
|
|
def scp_to_robot(local_path: str, remote_path: str):
|
|
"""Copy a file to the robot."""
|
|
target = f"{ROBOT_USER}@{ROBOT_IP}"
|
|
ret = subprocess.run(
|
|
["scp"] + SSH_OPTS + [local_path, f"{target}:{remote_path}"],
|
|
capture_output=True, text=True,
|
|
)
|
|
return ret.returncode == 0
|
|
|
|
|
|
def scp_from_robot(remote_path: str, local_path: str):
|
|
"""Copy a file from the robot."""
|
|
target = f"{ROBOT_USER}@{ROBOT_IP}"
|
|
ret = subprocess.run(
|
|
["scp"] + SSH_OPTS + [f"{target}:{remote_path}", local_path],
|
|
capture_output=True, text=True,
|
|
)
|
|
return ret.returncode == 0
|
|
|
|
|
|
# ─── SETUP MIC ──────────────────────────────────────────────
|
|
|
|
def cmd_setup_mic(args):
|
|
"""Unmute and configure the wireless mic on the robot."""
|
|
print("Setting up wireless mic on G1...\n")
|
|
|
|
# List sources
|
|
code, out, _ = ssh_cmd("pactl list sources short")
|
|
print("PulseAudio sources:")
|
|
print(out)
|
|
print()
|
|
|
|
# Find wireless mic source
|
|
mic_source = None
|
|
for line in out.split("\n"):
|
|
if "Wireless" in line or "Hollyland" in line:
|
|
mic_source = line.split("\t")[1] if "\t" in line else None
|
|
mic_index = line.split("\t")[0] if "\t" in line else None
|
|
break
|
|
|
|
if not mic_source:
|
|
print("Wireless mic not found. Is it plugged in?")
|
|
return
|
|
|
|
print(f"Found mic: {mic_source} (index {mic_index})")
|
|
|
|
# Set as default, unmute, set volume
|
|
ssh_cmd(f"pactl set-default-source {mic_source}")
|
|
print(" Set as default source.")
|
|
|
|
ssh_cmd(f"pactl set-source-mute {mic_index} 0")
|
|
print(" Unmuted.")
|
|
|
|
ssh_cmd(f"pactl set-source-volume {mic_index} 100%")
|
|
print(" Volume set to 100%.")
|
|
|
|
# Check mute status
|
|
code, out, _ = ssh_cmd(f'pactl list sources | grep -A 3 "Wireless" | grep Mute')
|
|
print(f" Status: {out.strip()}")
|
|
|
|
# Verify recording
|
|
print("\n Verifying mic (2 second test)...")
|
|
ssh_cmd("timeout 2 parec -d 3 --format=s16le --rate=16000 --channels=1 --raw > /tmp/_mic_verify.pcm")
|
|
code, out, _ = ssh_cmd(
|
|
f'{ROBOT_PYTHON} -c "'
|
|
'import numpy as np;'
|
|
'a = np.fromfile(\'/tmp/_mic_verify.pcm\', dtype=np.int16);'
|
|
'print(f\'samples={len(a)} std={a.std():.0f}\')'
|
|
'"'
|
|
)
|
|
print(f" Result: {out}")
|
|
if "std=0" in out:
|
|
print(" WARNING: Mic is silent! Check transmitter is powered on.")
|
|
else:
|
|
print(" Mic is working!")
|
|
|
|
|
|
# ─── RECORD ON WORKSTATION ───────────────────────────────────
|
|
|
|
def cmd_record(args):
|
|
"""Record from workstation microphone."""
|
|
import pyaudio
|
|
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
out_path = DATA_DIR / f"{args.name}.wav"
|
|
|
|
pa = pyaudio.PyAudio()
|
|
|
|
# Find best input device
|
|
device_index = args.device
|
|
if device_index is None:
|
|
for i in range(pa.get_device_count()):
|
|
info = pa.get_device_info_by_index(i)
|
|
if info["maxInputChannels"] > 0:
|
|
name = info["name"]
|
|
if name in ("default", "pipewire") or "pulse" in name.lower():
|
|
device_index = i
|
|
break
|
|
if device_index is None:
|
|
for i in range(pa.get_device_count()):
|
|
if pa.get_device_info_by_index(i)["maxInputChannels"] > 0:
|
|
device_index = i
|
|
break
|
|
|
|
# Find supported rate
|
|
info = pa.get_device_info_by_index(device_index)
|
|
rec_rate = int(info["defaultSampleRate"])
|
|
print(f"Using device [{device_index}] {info['name']} @ {rec_rate}Hz")
|
|
print(f"Recording {args.seconds}s... speak now!\n")
|
|
|
|
stream = pa.open(
|
|
format=pyaudio.paInt16, channels=1, rate=rec_rate,
|
|
input=True, input_device_index=device_index, frames_per_buffer=CHUNK_SIZE,
|
|
)
|
|
|
|
frames = []
|
|
total = int(rec_rate / CHUNK_SIZE * args.seconds)
|
|
start = time.time()
|
|
for i in range(total):
|
|
frames.append(stream.read(CHUNK_SIZE, exception_on_overflow=False))
|
|
elapsed = time.time() - start
|
|
pct = min(elapsed / args.seconds, 1.0)
|
|
bar = "█" * int(30 * pct) + "░" * (30 - int(30 * pct))
|
|
print(f"\r [{bar}] {elapsed:.1f}s / {args.seconds:.1f}s", end="", flush=True)
|
|
|
|
stream.stop_stream()
|
|
stream.close()
|
|
pa.terminate()
|
|
print()
|
|
|
|
# Resample to 16kHz
|
|
audio = np.frombuffer(b"".join(frames), dtype=np.int16)
|
|
if rec_rate != SAMPLE_RATE:
|
|
tl = int(len(audio) * SAMPLE_RATE / rec_rate)
|
|
audio = np.interp(
|
|
np.linspace(0, len(audio), tl, endpoint=False),
|
|
np.arange(len(audio)), audio.astype(np.float64),
|
|
).astype(np.int16)
|
|
|
|
wf = wave.open(str(out_path), "wb")
|
|
wf.setnchannels(1)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(SAMPLE_RATE)
|
|
wf.writeframes(audio.tobytes())
|
|
wf.close()
|
|
|
|
print(f"Saved: {out_path} ({len(audio)/SAMPLE_RATE:.1f}s, {out_path.stat().st_size/1024:.1f}KB)")
|
|
|
|
|
|
# ─── RECORD ON ROBOT ─────────────────────────────────────────
|
|
|
|
def cmd_record_robot(args):
|
|
"""Record from the wireless mic on the robot."""
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
seconds = args.seconds
|
|
remote_wav = f"/tmp/_rec_{args.name}.wav"
|
|
local_path = DATA_DIR / f"{args.name}.wav"
|
|
|
|
record_code = (
|
|
f"import time, subprocess, wave, numpy as np;"
|
|
f"print('Recording {seconds}s... speak now!');"
|
|
f"proc = subprocess.Popen(['parec','-d','3','--format=s16le','--rate=16000','--channels=1','--raw'], stdout=subprocess.PIPE);"
|
|
f"time.sleep({seconds});"
|
|
f"proc.terminate();"
|
|
f"raw = proc.stdout.read();"
|
|
f"audio = np.frombuffer(raw, dtype=np.int16);"
|
|
f"print(f'Recorded {{len(audio)}} samples, std={{audio.std():.0f}}');"
|
|
f"wf = wave.open('{remote_wav}', 'wb');"
|
|
f"wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000);"
|
|
f"wf.writeframes(audio.tobytes()); wf.close();"
|
|
f"print('Saved on robot')"
|
|
)
|
|
|
|
print(f"Recording {seconds}s from robot wireless mic...")
|
|
code, out, err = ssh_cmd(f'{ROBOT_PYTHON} -c "{record_code}"')
|
|
print(f" {out}")
|
|
if err:
|
|
print(f" stderr: {err}")
|
|
|
|
# Download to workstation
|
|
print(f"Downloading to {local_path}...")
|
|
if scp_from_robot(remote_wav, str(local_path)):
|
|
print(f" Saved: {local_path}")
|
|
ssh_cmd(f"rm -f {remote_wav}")
|
|
else:
|
|
print(" Download failed!")
|
|
|
|
# Also save on robot in recorded voices dir
|
|
remote_final = f"{ROBOT_VOICES_DIR}/{args.name}.wav"
|
|
ssh_cmd(f'cp "{remote_wav}" "{remote_final}" 2>/dev/null')
|
|
|
|
|
|
# ─── PLAY ON G1 ──────────────────────────────────────────────
|
|
|
|
def cmd_play(args):
|
|
"""Deploy a local WAV file to G1 and play on built-in speaker."""
|
|
wav_path = DATA_DIR / f"{args.name}.wav"
|
|
if not wav_path.exists():
|
|
print(f"Not found: {wav_path}")
|
|
cmd_list(args)
|
|
return
|
|
|
|
with wave.open(str(wav_path), "rb") as wf:
|
|
dur = wf.getnframes() / wf.getframerate()
|
|
print(f"Loaded: {wav_path.name} ({dur:.1f}s)")
|
|
|
|
# SCP to robot
|
|
remote_wav = f"/tmp/_play_{args.name}.wav"
|
|
print(f"Copying to robot...")
|
|
if not scp_to_robot(str(wav_path), remote_wav):
|
|
print(" SCP failed!")
|
|
return
|
|
print(" Copied.")
|
|
|
|
# Play on robot
|
|
_play_remote_wav(remote_wav, dur)
|
|
|
|
# Cleanup
|
|
ssh_cmd(f"rm -f {remote_wav}")
|
|
|
|
|
|
def cmd_play_robot(args):
|
|
"""Play a WAV file that already exists on the robot."""
|
|
# Get duration
|
|
code, out, _ = ssh_cmd(
|
|
f'{ROBOT_PYTHON} -c "import wave; '
|
|
f"wf = wave.open('{args.path}', 'rb'); "
|
|
f"print(wf.getnframes() / wf.getframerate()); wf.close()"
|
|
'"'
|
|
)
|
|
try:
|
|
dur = float(out)
|
|
except ValueError:
|
|
print(f"Cannot read: {args.path}")
|
|
print(f" {out}")
|
|
return
|
|
|
|
print(f"Playing: {args.path} ({dur:.1f}s)")
|
|
_play_remote_wav(args.path, dur)
|
|
|
|
|
|
def _play_remote_wav(remote_wav: str, duration: float):
|
|
"""Play a WAV file on the G1 speaker (file must already be on robot)."""
|
|
play_code = (
|
|
"import time, wave, json, numpy as np;"
|
|
"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 *;"
|
|
f"ChannelFactoryInitialize(0, '{ROBOT_DDS_IFACE}');"
|
|
"c = AudioClient(); c.SetTimeout(10.0); c.Init(); c.SetVolume(100);"
|
|
"c._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({'app_name':'p'}));"
|
|
"time.sleep(0.3);"
|
|
f"wf = wave.open('{remote_wav}', 'rb');"
|
|
"rate = wf.getframerate(); nch = wf.getnchannels();"
|
|
"audio = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16); wf.close();"
|
|
"audio = audio.reshape(-1, 2).mean(axis=1).astype(np.int16) if nch == 2 else audio;"
|
|
"tl = int(len(audio) * 16000 / rate) if rate != 16000 else len(audio);"
|
|
"audio = np.interp(np.linspace(0, len(audio), tl, endpoint=False), np.arange(len(audio)), audio.astype(np.float64)).astype(np.int16) if rate != 16000 else audio;"
|
|
"pcm = audio.tobytes();"
|
|
"sid = f's_{int(time.time()*1000)}';"
|
|
"param = json.dumps({'app_name':'p','stream_id':sid,'sample_rate':16000,'channels':1,'bits_per_sample':16});"
|
|
"c._CallRequestWithParamAndBin(ROBOT_API_ID_AUDIO_START_PLAY, param, list(pcm));"
|
|
f"time.sleep({duration + 1});"
|
|
"c._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({'app_name':'p'}));"
|
|
"print('played')"
|
|
)
|
|
|
|
print(f"Playing on G1... ({duration:.1f}s)")
|
|
proc = subprocess.Popen(
|
|
["ssh"] + SSH_OPTS + [f"{ROBOT_USER}@{ROBOT_IP}",
|
|
f'{ROBOT_PYTHON} -c "{play_code}"'],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
start = time.time()
|
|
while proc.poll() is None:
|
|
elapsed = time.time() - start
|
|
pct = min(elapsed / duration, 1.0)
|
|
bar = "█" * int(30 * pct) + "░" * (30 - int(30 * pct))
|
|
print(f"\r [{bar}] {min(elapsed, duration):.1f}s / {duration:.1f}s", end="", flush=True)
|
|
time.sleep(0.2)
|
|
|
|
out = proc.stdout.read().decode().strip()
|
|
if "played" in out:
|
|
print(f"\n Done.")
|
|
else:
|
|
print(f"\n Robot output: {out}")
|
|
|
|
|
|
# ─── TTS ──────────────────────────────────────────────────────
|
|
|
|
def cmd_tts(args):
|
|
"""Play text-to-speech on G1 speaker (English/Chinese only)."""
|
|
tts_code = (
|
|
"import json;"
|
|
"from unitree_sdk2py.core.channel import ChannelFactoryInitialize;"
|
|
"from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient;"
|
|
f"ChannelFactoryInitialize(0, '{ROBOT_DDS_IFACE}');"
|
|
"c = AudioClient(); c.SetTimeout(10.0); c.Init();"
|
|
f"c.SetVolume({args.volume});"
|
|
f"c.TtsMaker('{args.text}', {args.speaker_id});"
|
|
"print('ok')"
|
|
)
|
|
print(f"TTS: \"{args.text}\" (speaker_id={args.speaker_id}, vol={args.volume})")
|
|
code, out, _ = ssh_cmd(f'{ROBOT_PYTHON} -c "{tts_code}"')
|
|
if "ok" in out:
|
|
print(" Sent.")
|
|
else:
|
|
print(f" Result: {out}")
|
|
|
|
|
|
# ─── LIST ─────────────────────────────────────────────────────
|
|
|
|
def cmd_list(args):
|
|
"""List local recordings."""
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
wavs = sorted(DATA_DIR.glob("*.wav"))
|
|
if not wavs:
|
|
print(f"No recordings in {DATA_DIR}")
|
|
return
|
|
print(f"\n{'#':<4} {'Name':<35} {'Duration':>8} {'Size':>10}")
|
|
print("-" * 60)
|
|
for i, p in enumerate(wavs):
|
|
try:
|
|
with wave.open(str(p), "rb") as wf:
|
|
dur = wf.getnframes() / wf.getframerate()
|
|
size = p.stat().st_size / 1024
|
|
print(f"{i:<4} {p.stem:<35} {dur:>6.1f}s {size:>8.1f}KB")
|
|
except Exception:
|
|
print(f"{i:<4} {p.stem:<35} {'ERROR':>8}")
|
|
print()
|
|
|
|
|
|
def cmd_list_robot(args):
|
|
"""List recordings on the robot."""
|
|
code, out, _ = ssh_cmd(
|
|
f'{ROBOT_PYTHON} -c "'
|
|
"import wave, os; from pathlib import Path;"
|
|
f"d = Path('{ROBOT_VOICES_DIR}');"
|
|
"wavs = sorted(d.glob('*.wav'));"
|
|
"print(f'{{len(wavs)}} files in {d}');"
|
|
"for p in wavs:"
|
|
" try:"
|
|
" wf = wave.open(str(p),'rb'); dur = wf.getnframes()/wf.getframerate(); wf.close();"
|
|
" print(f'{p.stem:<45} {dur:>6.1f}s')"
|
|
" except: print(f'{p.stem:<45} ERROR')"
|
|
'"'
|
|
)
|
|
print(out)
|
|
|
|
|
|
# ─── MAIN ─────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="G1 Voice Deploy — Record, deploy & play audio on G1 robot",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s setup-mic
|
|
%(prog)s record --name greeting --seconds 5
|
|
%(prog)s record-robot --name arabic_hi --seconds 5
|
|
%(prog)s play --name greeting
|
|
%(prog)s play-robot --path "/home/unitree/SanadVoice/recorded voices/welcome_single.wav"
|
|
%(prog)s tts --text "Hello, welcome!"
|
|
%(prog)s list
|
|
%(prog)s list-robot
|
|
""",
|
|
)
|
|
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
# setup-mic
|
|
sub.add_parser("setup-mic", help="Unmute & configure wireless mic on robot")
|
|
|
|
# record
|
|
p = sub.add_parser("record", help="Record from workstation mic")
|
|
p.add_argument("--name", "-n", required=True, help="Recording name")
|
|
p.add_argument("--seconds", "-s", type=float, default=5.0)
|
|
p.add_argument("--device", "-d", type=int, default=None)
|
|
|
|
# record-robot
|
|
p = sub.add_parser("record-robot", help="Record from robot wireless mic")
|
|
p.add_argument("--name", "-n", required=True, help="Recording name")
|
|
p.add_argument("--seconds", "-s", type=float, default=5.0)
|
|
|
|
# play
|
|
p = sub.add_parser("play", help="Deploy & play local WAV on G1 speaker")
|
|
p.add_argument("--name", "-n", required=True, help="Recording name")
|
|
|
|
# play-robot
|
|
p = sub.add_parser("play-robot", help="Play a WAV file already on robot")
|
|
p.add_argument("--path", "-p", required=True, help="Full path on robot")
|
|
|
|
# tts
|
|
p = sub.add_parser("tts", help="Text-to-speech on G1 (English/Chinese)")
|
|
p.add_argument("--text", "-t", required=True, help="Text to speak")
|
|
p.add_argument("--speaker-id", type=int, default=0, help="Voice ID (0,1,2)")
|
|
p.add_argument("--volume", "-v", type=int, default=100)
|
|
|
|
# list
|
|
sub.add_parser("list", help="List local recordings")
|
|
sub.add_parser("list-robot", help="List recordings on robot")
|
|
|
|
args = parser.parse_args()
|
|
|
|
commands = {
|
|
"setup-mic": cmd_setup_mic,
|
|
"record": cmd_record,
|
|
"record-robot": cmd_record_robot,
|
|
"play": cmd_play,
|
|
"play-robot": cmd_play_robot,
|
|
"tts": cmd_tts,
|
|
"list": cmd_list,
|
|
"list-robot": cmd_list_robot,
|
|
}
|
|
|
|
if args.command in commands:
|
|
commands[args.command](args)
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|