Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Signed-off-by: Sy0307 <24841857+Sy0307@users.noreply.github.com> Co-authored-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Headless client for the PersonaPlex duplex server (official Moshi protocol).
|
|
|
|
Streams a 24 kHz mono WAV to ``/api/chat`` exactly as the browser client does
|
|
(Opus over WebSocket, tags ``\\x00`` ready / ``\\x01`` audio / ``\\x02`` text),
|
|
collects the agent's reply audio + inner-monologue text, and writes a WAV.
|
|
|
|
The browser UI at ``http://<host>:<port>/`` is the primary demo; this script is
|
|
for headless testing / scripting.
|
|
|
|
python duplex_client.py --url ws://localhost:8124/api/chat \
|
|
--input user.wav --out reply.wav
|
|
|
|
Deps: ``websockets``, ``sphn``, ``soundfile``, ``numpy``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
import sphn
|
|
|
|
SR = 24000 # PersonaPlex / Mimi codec rate
|
|
|
|
|
|
async def converse(url: str, pcm: np.ndarray) -> tuple[np.ndarray, str]:
|
|
import websockets
|
|
|
|
reader = sphn.OpusStreamReader(SR) # decode agent audio
|
|
writer = sphn.OpusStreamWriter(SR) # encode our mic audio
|
|
text: list[str] = []
|
|
ready = asyncio.Event()
|
|
last = {"t": 0.0}
|
|
|
|
async with websockets.connect(url, max_size=None) as ws:
|
|
|
|
async def receive() -> None:
|
|
loop = asyncio.get_event_loop()
|
|
while True:
|
|
try:
|
|
msg = await ws.recv()
|
|
except Exception:
|
|
return
|
|
if not isinstance(msg, (bytes, bytearray)) or not msg:
|
|
continue
|
|
tag, payload = msg[0], msg[1:]
|
|
if tag == 0:
|
|
ready.set()
|
|
elif tag == 1:
|
|
reader.append_bytes(bytes(payload))
|
|
last["t"] = loop.time()
|
|
elif tag == 2:
|
|
text.append(payload.decode("utf8", errors="replace"))
|
|
|
|
recv_task = asyncio.create_task(receive())
|
|
await asyncio.wait_for(ready.wait(), timeout=120) # waits for system-prompt prefill
|
|
|
|
frame = 1920 # 80 ms
|
|
for i in range(0, len(pcm), frame):
|
|
chunk = pcm[i : i + frame]
|
|
if len(chunk) < frame:
|
|
chunk = np.concatenate([chunk, np.zeros(frame - len(chunk), dtype=np.float32)])
|
|
writer.append_pcm(chunk)
|
|
data = writer.read_bytes()
|
|
if data:
|
|
await ws.send(b"\x01" + data)
|
|
await asyncio.sleep(frame / SR) # pace at real time
|
|
|
|
loop = asyncio.get_event_loop()
|
|
last["t"] = loop.time()
|
|
while loop.time() - last["t"] < 2.5: # drain until the agent stops talking
|
|
await asyncio.sleep(0.1)
|
|
recv_task.cancel()
|
|
|
|
chunks: list[np.ndarray] = []
|
|
while True:
|
|
out = reader.read_pcm()
|
|
if out is None or out.shape[-1] == 0:
|
|
break
|
|
chunks.append(np.asarray(out, dtype=np.float32))
|
|
audio = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.float32)
|
|
return audio, "".join(text)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--url", default="ws://localhost:8124/api/chat")
|
|
ap.add_argument("--input", required=True, help="24 kHz mono WAV of the user turn")
|
|
ap.add_argument("--out", default="personaplex_reply.wav")
|
|
ap.add_argument("--tail", type=float, default=10.0, help="trailing silence (s) for the agent reply")
|
|
ap.add_argument("--persona", default="")
|
|
ap.add_argument("--voice", default="NATF2.pt")
|
|
args = ap.parse_args()
|
|
|
|
wav, sr = sf.read(args.input, dtype="float32")
|
|
if wav.ndim > 1:
|
|
wav = wav.mean(axis=1)
|
|
if sr != SR:
|
|
raise SystemExit(f"input must be {SR} Hz mono; got {sr} Hz. Resample it first.")
|
|
wav = np.concatenate([wav, np.zeros(int(args.tail * SR), dtype=np.float32)])
|
|
|
|
url = f"{args.url}?text_prompt={args.persona}&voice_prompt={args.voice}"
|
|
audio, text = asyncio.run(converse(url, wav))
|
|
sf.write(args.out, audio if audio.size else np.zeros(SR, dtype=np.float32), SR)
|
|
print(f"agent reply: {audio.size / SR:.2f}s -> {args.out}")
|
|
print(f"inner-monologue: {text.strip()!r}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|