166 lines
5.3 KiB
Python
166 lines
5.3 KiB
Python
#!/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())
|