#!/usr/bin/env python3 """ G1 EDGE-TTS — Text to G1 speaker via Microsoft Edge TTS (no API key). Pipeline matches Marcus's working path documented in voice_note.txt: edge-tts → MP3 bytes → pydub 16 kHz mono WAV → _CallRequestWithParamAndBin on G1 AudioClient Voices (defaults): Arabic ar-AE-HamdanNeural (UAE male) English en-US-GuyNeural (US male) Runs on robot (has AudioClient) OR on workstation (save WAV only with --save). Requirements: pip install edge-tts pydub numpy (on robot use the gemini conda env which has the audio SDK methods) Usage: # Speak directly on G1 speaker: python3 g1_edge_tts.py "مرحبا بكم" --lang ar python3 g1_edge_tts.py "Hello welcome to Lootah" --lang en # Just save a WAV (no robot needed): python3 g1_edge_tts.py "Good morning" --save greeting.wav # Override voice: python3 g1_edge_tts.py "test" --lang ar --voice ar-SA-HamedNeural """ from __future__ import annotations import argparse import asyncio import io import json import sys import time import wave from pathlib import Path import numpy as np DEFAULT_VOICES = { "ar": "ar-AE-HamdanNeural", "en": "en-US-GuyNeural", } TARGET_RATE = 16000 APP_NAME = "edge_tts" async def _synthesize(text: str, voice: str) -> bytes: """Call edge-tts and return raw MP3 bytes.""" import edge_tts # lazy import — only needed when actually speaking communicate = edge_tts.Communicate(text=text, voice=voice) buf = io.BytesIO() async for chunk in communicate.stream(): if chunk["type"] == "audio": buf.write(chunk["data"]) return buf.getvalue() def _mp3_to_pcm16k(mp3: bytes) -> np.ndarray: """Decode MP3 → 16 kHz mono int16.""" from pydub import AudioSegment # lazy — pulls ffmpeg at import time seg = AudioSegment.from_file(io.BytesIO(mp3), format="mp3") seg = seg.set_channels(1).set_frame_rate(TARGET_RATE).set_sample_width(2) return np.frombuffer(seg.raw_data, dtype=np.int16) def save_wav(pcm: np.ndarray, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) with wave.open(str(path), "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(TARGET_RATE) wf.writeframes(pcm.tobytes()) def _play_on_g1(pcm: np.ndarray, interface: str = "eth0", volume: int = 100) -> float: """Single-call _CallRequestWithParamAndBin — the working method.""" 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, ) ChannelFactoryInitialize(0, interface) c = AudioClient() c.SetTimeout(10.0) c.Init() c.SetVolume(volume) c._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({"app_name": APP_NAME})) time.sleep(0.3) 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() c._CallRequestWithParamAndBin( ROBOT_API_ID_AUDIO_START_PLAY, param, list(pcm_bytes) ) time.sleep(duration + 0.5) c._Call(ROBOT_API_ID_AUDIO_STOP_PLAY, json.dumps({"app_name": APP_NAME})) return time.time() - start def main() -> int: p = argparse.ArgumentParser(description="Edge-TTS → G1 speaker (AR + EN)") p.add_argument("text", help="Text to speak (wrap in quotes)") p.add_argument("--lang", choices=["ar", "en"], default="en", help="Language (default: en)") p.add_argument("--voice", default=None, help="Override voice (e.g. ar-SA-HamedNeural)") p.add_argument("--save", metavar="PATH", help="Also save the synthesized 16 kHz WAV to PATH") p.add_argument("--no-play", action="store_true", help="Skip G1 playback; useful with --save from workstation") p.add_argument("--interface", default="eth0", help="DDS interface (default: eth0)") p.add_argument("--volume", type=int, default=100, help="Speaker volume 0-100") args = p.parse_args() voice = args.voice or DEFAULT_VOICES[args.lang] print(f"Synthesizing ({args.lang}, voice={voice}) ...") t0 = time.time() try: mp3 = asyncio.run(_synthesize(args.text, voice)) except Exception as exc: print(f"edge-tts failed: {exc}", file=sys.stderr) return 1 pcm = _mp3_to_pcm16k(mp3) dur = len(pcm) / TARGET_RATE print(f" {len(mp3):,} MP3 bytes -> {dur:.1f}s WAV ({time.time() - t0:.1f}s synth)") if args.save: save_path = Path(args.save) save_wav(pcm, save_path) print(f" saved {save_path} ({save_path.stat().st_size/1024:.1f} KB)") if args.no_play: return 0 try: elapsed = _play_on_g1(pcm, args.interface, args.volume) print(f"Played on G1 ({elapsed:.1f}s).") except Exception as exc: print(f"G1 playback failed: {exc}", file=sys.stderr) print("Hint: run this on the robot inside the gemini conda env.") return 2 return 0 if __name__ == "__main__": sys.exit(main())