voice_audio_multi/tests/personaplex/personaplex_live_multiturn_client.py
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

988 lines
40 KiB
Python

#!/usr/bin/env python3
"""Run a true PersonaPlex speech/live multi-turn conversation.
Example turns JSON:
[
{
"label": "turn_01_part_availability",
"audio": "questions/turn_01.wav",
"user_question": "My dishwasher stopped draining. Can you tell me if the replacement pump is in stock?",
"assistant_instruction": "Answer as a helpful appliance repair dispatcher. Be concise and specific."
},
{
"label": "turn_02_delay",
"audio": "questions/turn_02.wav",
"user_question": "If the part is not in stock, how long would the alternative take?",
"assistant_instruction": "Continue the same support call and explain the delay clearly."
},
{
"label": "turn_03_cost",
"audio": "questions/turn_03.wav",
"user_question": "What should I expect for labor cost, and can I still book a morning appointment?",
"assistant_instruction": "Continue the same support call and answer both cost and scheduling."
}
]
The request body is raw PCM over HTTP/1.1 chunked transfer encoding, and the
SSE response reader runs concurrently with upload. This validates the live path;
it is not equivalent to uploading a complete WAV first.
"""
from __future__ import annotations
import argparse
import base64
import csv
import json
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.parse
import uuid
import wave
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator
REPO_ROOT = Path(__file__).resolve().parents[2]
LOG_ROOT = REPO_ROOT / "logs" / "streaming_test"
DEFAULT_SERVER_BIN = REPO_ROOT / "build" / "debug" / "bin" / "audiocpp_server"
def timestamp_slug() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def path_is_under(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def wav_info(path: Path) -> dict[str, Any]:
with wave.open(str(path), "rb") as wav:
channels = wav.getnchannels()
sample_width = wav.getsampwidth()
sample_rate = wav.getframerate()
frames = wav.getnframes()
return {
"path": str(path),
"bytes": path.stat().st_size,
"channels": channels,
"sample_width_bytes": sample_width,
"sample_rate": sample_rate,
"frames": frames,
"duration_s": frames / float(sample_rate),
"audio_bytes_per_second": channels * sample_width * sample_rate,
}
def load_pcm16_wav(path: Path, max_seconds: float | None) -> tuple[bytes, dict[str, Any]]:
info = wav_info(path)
if info["sample_width_bytes"] != 2:
raise RuntimeError(f"{path}: expected 16-bit PCM WAV, got sample width {info['sample_width_bytes']}")
if info["channels"] <= 0 or info["sample_rate"] <= 0:
raise RuntimeError(f"{path}: invalid WAV format")
with wave.open(str(path), "rb") as wav:
frames = wav.getnframes()
if max_seconds is not None:
frames = min(frames, int(max_seconds * wav.getframerate()))
pcm = wav.readframes(frames)
info["streamed_frames"] = frames
info["streamed_duration_s"] = frames / float(info["sample_rate"])
info["streamed_pcm_bytes"] = len(pcm)
return pcm, info
def write_pcm16_wav(path: Path, pcm: bytes, sample_rate: int, channels: int = 1) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm)
def analyze_pcm16(pcm: bytes, sample_rate: int) -> dict[str, Any]:
if not pcm:
return {
"duration_s": 0.0,
"max_peak": 0.0,
"first_peak_gt_0_01_s": None,
"last_peak_gt_0_01_s": None,
"silent_spans_peak_lt_0_01": [],
}
sample_count = len(pcm) // 2
samples = struct.unpack("<" + "h" * sample_count, pcm[:sample_count * 2])
win = max(1, sample_rate // 10)
first: float | None = None
last: float | None = None
silent_start: float | None = None
silent_spans: list[tuple[float, float]] = []
max_peak = 0.0
for offset in range(0, sample_count, win):
chunk = samples[offset:offset + win]
peak = max(abs(value) for value in chunk) / 32768.0 if chunk else 0.0
max_peak = max(max_peak, peak)
t = offset / float(sample_rate)
if peak > 0.01:
if first is None:
first = t
last = t
if peak < 0.01:
if silent_start is None:
silent_start = t
elif silent_start is not None:
if t - silent_start >= 0.5:
silent_spans.append((round(silent_start, 2), round(t, 2)))
silent_start = None
duration_s = sample_count / float(sample_rate)
if silent_start is not None and duration_s - silent_start >= 0.5:
silent_spans.append((round(silent_start, 2), round(duration_s, 2)))
return {
"duration_s": duration_s,
"max_peak": max_peak,
"first_peak_gt_0_01_s": first,
"last_peak_gt_0_01_s": last,
"silent_spans_peak_lt_0_01": silent_spans,
}
def read_http_headers(reader: Any) -> tuple[str, dict[str, str]]:
status = reader.readline().decode("iso-8859-1", "replace").strip()
headers: dict[str, str] = {}
while True:
line = reader.readline().decode("iso-8859-1", "replace")
if line in ("\r\n", "\n", ""):
break
key, _, value = line.partition(":")
headers[key.strip().lower()] = value.strip()
return status, headers
def parse_sse_data(buffer: str) -> tuple[list[str], str]:
events: list[str] = []
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
data_lines: list[str] = []
for line in block.splitlines():
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if data_lines:
events.append("\n".join(data_lines))
return events, buffer
def iter_chunked_response(reader: Any) -> Iterator[bytes]:
while True:
size_line = reader.readline().decode("ascii", "replace").strip()
if not size_line:
continue
size = int(size_line.split(";", 1)[0], 16)
if size == 0:
reader.readline()
return
chunk = reader.read(size)
reader.read(2)
yield chunk
def wait_for_health(host: str, port: int, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
last_error: Exception | None = None
request = (
f"GET /health HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("utf-8")
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=1.0) as sock:
sock.sendall(request)
response = sock.recv(256)
if b"200 OK" in response:
return
except Exception as exc:
last_error = exc
time.sleep(0.5)
raise RuntimeError(f"server health timeout for {host}:{port}: {last_error}")
def normalize_turns(turns_path: Path, repo_root: Path) -> list[dict[str, Any]]:
raw = load_json(turns_path)
if not isinstance(raw, list):
raise RuntimeError("--turns-json must contain a JSON array")
if not raw:
raise RuntimeError("--turns-json must contain at least one turn")
turns: list[dict[str, Any]] = []
for index, item in enumerate(raw, 1):
if not isinstance(item, dict):
raise RuntimeError(f"turn {index}: expected object")
label = str(item.get("label") or f"turn_{index:02d}")
audio_raw = item.get("audio")
question = str(item.get("user_question") or "").strip()
instruction = str(item.get("assistant_instruction") or "").strip()
if not audio_raw:
raise RuntimeError(f"{label}: missing audio")
if not question:
raise RuntimeError(f"{label}: missing user_question")
if not instruction:
raise RuntimeError(f"{label}: missing assistant_instruction")
audio_path = Path(str(audio_raw))
if not audio_path.is_absolute():
audio_path = (turns_path.parent / audio_path).resolve()
if not audio_path.exists():
audio_path = (repo_root / str(audio_raw)).resolve()
if not audio_path.exists():
raise RuntimeError(f"{label}: audio path does not exist: {audio_path}")
turns.append({
"label": label,
"audio": audio_path,
"user_question": question,
"assistant_instruction": instruction,
"expected_answer_points": item.get("expected_answer_points", []),
})
return turns
def stream_turn(
host: str,
port: int,
model: str,
turn: dict[str, Any],
output_dir: Path,
audio_chunk_ms: float,
pace_factor: float,
max_input_seconds: float | None,
request_timeout_s: float,
extra_options: dict[str, str],
) -> dict[str, Any]:
pcm_input, input_info = load_pcm16_wav(turn["audio"], max_input_seconds)
sample_rate = int(input_info["sample_rate"])
channels = int(input_info["channels"])
bytes_per_second = max(1, int(input_info["audio_bytes_per_second"]))
chunk_bytes = max(channels * 2, int(bytes_per_second * audio_chunk_ms / 1000.0))
frame_bytes = channels * 2
chunk_bytes = max(frame_bytes, (chunk_bytes // frame_bytes) * frame_bytes)
query = {
"model": model,
"input": turn["assistant_instruction"],
"sample_rate": str(sample_rate),
"channels": str(channels),
"sample_format": "s16le",
"stream_format": "sse",
"response_format": "pcm",
}
query.update(extra_options)
target = "/v1/audio/speech/live?" + urllib.parse.urlencode(query)
turn_dir = output_dir / turn["label"]
turn_dir.mkdir(parents=True, exist_ok=True)
upload_log_path = turn_dir / "upload_chunks.csv"
events_path = turn_dir / "events.jsonl"
response_headers_path = turn_dir / "response_headers.json"
output_wav = turn_dir / "assistant_response.wav"
request_manifest_path = turn_dir / "request_manifest.json"
request_headers = (
f"POST {target} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Content-Type: application/octet-stream\r\n"
"Accept: text/event-stream\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"Expect:\r\n"
"\r\n"
).encode("utf-8")
manifest = {
"label": turn["label"],
"endpoint": "/v1/audio/speech/live",
"model": model,
"user_question": turn["user_question"],
"assistant_instruction": turn["assistant_instruction"],
"expected_answer_points": turn["expected_answer_points"],
"input_audio": input_info,
"audio_chunk_ms": audio_chunk_ms,
"audio_chunk_bytes": chunk_bytes,
"pace_factor": pace_factor,
"client_input_streaming": "raw PCM sent with HTTP/1.1 Transfer-Encoding: chunked",
"client_response_reading": "SSE response reader runs concurrently with upload",
}
write_json(request_manifest_path, manifest)
start = time.perf_counter()
upload_complete_ms: float | None = None
first_event_ms: float | None = None
first_delta_ms: float | None = None
done_ms: float | None = None
ttft_ms: float | None = None
first_audio_before_input_end: bool | None = None
request_start_to_first_audio_ms: float | None = None
input_end_ms: float | None = None
overlap_ms: float | None = None
delta_events = 0
output_pcm = bytearray()
response_headers: dict[str, Any] = {}
response_events: list[dict[str, Any]] = []
errors: list[str] = []
reader_error: list[Exception] = []
def read_response(reader: Any) -> None:
nonlocal first_event_ms, first_delta_ms, done_ms, ttft_ms, first_audio_before_input_end
nonlocal request_start_to_first_audio_ms, input_end_ms, overlap_ms, delta_events
try:
status, headers = read_http_headers(reader)
response_headers.update({"status": status, "headers": headers})
write_json(response_headers_path, response_headers)
if not status.startswith("HTTP/1.1 200"):
body = reader.read().decode("utf-8", "replace")
raise RuntimeError(f"{turn['label']}: {status}: {body}")
sse_buffer = ""
with events_path.open("w", encoding="utf-8") as events_handle:
response_chunks = (
iter_chunked_response(reader)
if "chunked" in headers.get("transfer-encoding", "").lower()
else [reader.read()]
)
for raw_chunk in response_chunks:
if not raw_chunk:
continue
sse_buffer += raw_chunk.decode("utf-8", "replace").replace("\r\n", "\n")
events, sse_buffer = parse_sse_data(sse_buffer)
for data in events:
elapsed_ms = (time.perf_counter() - start) * 1000.0
if first_event_ms is None:
first_event_ms = elapsed_ms
if data == "[DONE]":
done_ms = elapsed_ms
events_handle.write(json.dumps({"elapsed_ms": elapsed_ms, "data": data}) + "\n")
events_handle.flush()
continue
event = json.loads(data)
event_type = event.get("type")
if event_type == "speech.audio.delta":
delta_events += 1
if first_delta_ms is None:
first_delta_ms = elapsed_ms
output_pcm.extend(base64.b64decode(event["audio"]))
elif event_type == "speech.audio.done":
done_ms = elapsed_ms
timing = event.get("timing", {})
ttft_ms = timing.get("ttft_ms")
first_audio_before_input_end = timing.get("first_audio_before_input_end")
request_start_to_first_audio_ms = timing.get("request_start_to_first_audio_ms")
input_end_ms = timing.get("input_end_ms")
overlap_ms = timing.get("overlap_ms")
if request_start_to_first_audio_ms is None:
request_start_to_first_audio_ms = ttft_ms
elif event_type == "error":
errors.append(event.get("error", {}).get("message", json.dumps(event)))
response_events.append({"elapsed_ms": elapsed_ms, "event": event})
events_handle.write(json.dumps(response_events[-1], ensure_ascii=False) + "\n")
events_handle.flush()
except Exception as exc:
reader_error.append(exc)
with socket.create_connection((host, port), timeout=request_timeout_s) as sock:
sock.settimeout(request_timeout_s)
reader = sock.makefile("rb")
reader_thread = threading.Thread(target=read_response, args=(reader,), daemon=True)
reader_thread.start()
with upload_log_path.open("w", newline="", encoding="utf-8") as upload_handle:
writer = csv.writer(upload_handle)
writer.writerow(["index", "elapsed_ms", "bytes", "audio_offset_bytes", "audio_time_end_s"])
sock.sendall(request_headers)
offset = 0
index = 0
while offset < len(pcm_input):
end = min(len(pcm_input), offset + chunk_bytes)
chunk = pcm_input[offset:end]
sock.sendall(f"{len(chunk):X}\r\n".encode("ascii"))
sock.sendall(chunk)
sock.sendall(b"\r\n")
elapsed_ms = (time.perf_counter() - start) * 1000.0
writer.writerow([
index,
round(elapsed_ms, 3),
len(chunk),
end,
round(end / float(bytes_per_second), 6),
])
upload_handle.flush()
offset = end
index += 1
if pace_factor > 0.0 and offset < len(pcm_input):
time.sleep((audio_chunk_ms / 1000.0) * pace_factor)
sock.sendall(b"0\r\n\r\n")
upload_complete_ms = (time.perf_counter() - start) * 1000.0
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
reader_thread.join(timeout=request_timeout_s)
if reader_thread.is_alive():
raise RuntimeError(f"{turn['label']}: timed out waiting for live speech response")
if reader_error:
raise reader_error[0]
if errors:
raise RuntimeError(f"{turn['label']}: " + "; ".join(errors))
if request_start_to_first_audio_ms is None:
raise RuntimeError(f"{turn['label']}: response did not include first-audio timing")
if ttft_ms is None and first_audio_before_input_end is not True:
raise RuntimeError(f"{turn['label']}: response did not include live TTFT timing")
if not output_pcm:
raise RuntimeError(f"{turn['label']}: response produced no audio")
write_pcm16_wav(output_wav, bytes(output_pcm), sample_rate=sample_rate, channels=1)
audio_stats = analyze_pcm16(bytes(output_pcm), sample_rate)
elapsed_ms = (time.perf_counter() - start) * 1000.0
result = {
"label": turn["label"],
"user_question": turn["user_question"],
"assistant_instruction": turn["assistant_instruction"],
"expected_answer_points": turn["expected_answer_points"],
"elapsed_ms": elapsed_ms,
"upload_complete_ms": upload_complete_ms,
"first_event_ms": first_event_ms,
"first_delta_ms": first_delta_ms,
"done_ms": done_ms,
"ttft_ms": ttft_ms,
"first_audio_before_input_end": first_audio_before_input_end,
"request_start_to_first_audio_ms": request_start_to_first_audio_ms,
"input_end_ms": input_end_ms,
"overlap_ms": overlap_ms,
"first_event_before_upload_complete": (
first_event_ms < upload_complete_ms
if first_event_ms is not None and upload_complete_ms is not None
else None
),
"first_delta_before_upload_complete": (
first_delta_ms < upload_complete_ms
if first_delta_ms is not None and upload_complete_ms is not None
else None
),
"client_observed_ttft_after_upload_ms": (
first_delta_ms - upload_complete_ms
if first_delta_ms is not None and upload_complete_ms is not None
else None
),
"delta_events": delta_events,
"input_audio": input_info,
"output_audio": audio_stats,
"artifacts": {
"request_manifest": str(request_manifest_path),
"upload_chunks": str(upload_log_path),
"events": str(events_path),
"response_headers": str(response_headers_path),
"assistant_response_wav": str(output_wav),
},
}
write_json(turn_dir / "summary.json", result)
return result
def stream_continuous_conversation(
host: str,
port: int,
model: str,
turns: list[dict[str, Any]],
output_dir: Path,
system_prompt: str,
audio_chunk_ms: float,
pace_factor: float,
turn_gap_seconds: float,
final_gap_seconds: float,
max_input_seconds: float | None,
request_timeout_s: float,
extra_options: dict[str, str],
) -> dict[str, Any]:
if turn_gap_seconds < 0.0:
raise RuntimeError("--turn-gap-seconds must be non-negative")
if final_gap_seconds < 0.0:
raise RuntimeError("--final-gap-seconds must be non-negative")
input_parts: list[bytes] = []
input_infos: list[dict[str, Any]] = []
sample_rate: int | None = None
channels: int | None = None
sample_width = 2
timeline: list[dict[str, Any]] = []
cursor_s = 0.0
for index, turn in enumerate(turns):
pcm, info = load_pcm16_wav(turn["audio"], max_input_seconds)
current_sample_rate = int(info["sample_rate"])
current_channels = int(info["channels"])
if sample_rate is None:
sample_rate = current_sample_rate
channels = current_channels
if current_sample_rate != sample_rate or current_channels != channels:
raise RuntimeError("continuous PersonaPlex test requires all turn WAVs to share sample rate and channels")
input_parts.append(pcm)
input_infos.append(info)
duration_s = len(pcm) / float(max(1, current_sample_rate * current_channels * sample_width))
timeline.append({
"label": turn["label"],
"user_question": turn["user_question"],
"start_s": round(cursor_s, 6),
"end_s": round(cursor_s + duration_s, 6),
"audio": info,
"expected_answer_points": turn["expected_answer_points"],
})
cursor_s += duration_s
if index + 1 < len(turns) and turn_gap_seconds > 0.0:
silence_frames = int(round(turn_gap_seconds * current_sample_rate))
silence = b"\x00" * silence_frames * current_channels * sample_width
input_parts.append(silence)
timeline.append({
"label": f"gap_after_{turn['label']}",
"start_s": round(cursor_s, 6),
"end_s": round(cursor_s + silence_frames / float(current_sample_rate), 6),
"silence_s": silence_frames / float(current_sample_rate),
})
cursor_s += silence_frames / float(current_sample_rate)
if final_gap_seconds > 0.0:
silence_frames = int(round(final_gap_seconds * sample_rate))
silence = b"\x00" * silence_frames * channels * sample_width
input_parts.append(silence)
timeline.append({
"label": "final_response_gap",
"start_s": round(cursor_s, 6),
"end_s": round(cursor_s + silence_frames / float(sample_rate), 6),
"silence_s": silence_frames / float(sample_rate),
})
cursor_s += silence_frames / float(sample_rate)
if sample_rate is None or channels is None:
raise RuntimeError("continuous PersonaPlex test requires at least one turn")
pcm_input = b"".join(input_parts)
bytes_per_second = max(1, sample_rate * channels * sample_width)
chunk_bytes = max(channels * sample_width, int(bytes_per_second * audio_chunk_ms / 1000.0))
frame_bytes = channels * sample_width
chunk_bytes = max(frame_bytes, (chunk_bytes // frame_bytes) * frame_bytes)
query = {
"model": model,
"input": system_prompt,
"sample_rate": str(sample_rate),
"channels": str(channels),
"sample_format": "s16le",
"stream_format": "sse",
"response_format": "pcm",
}
query.update(extra_options)
target = "/v1/audio/speech/live?" + urllib.parse.urlencode(query)
conversation_dir = output_dir / "continuous"
conversation_dir.mkdir(parents=True, exist_ok=True)
upload_log_path = conversation_dir / "upload_chunks.csv"
events_path = conversation_dir / "events.jsonl"
response_headers_path = conversation_dir / "response_headers.json"
output_wav = conversation_dir / "assistant_response.wav"
request_manifest_path = conversation_dir / "request_manifest.json"
input_wav = conversation_dir / "user_input_stream.wav"
write_pcm16_wav(input_wav, pcm_input, sample_rate=sample_rate, channels=channels)
request_headers = (
f"POST {target} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Content-Type: application/octet-stream\r\n"
"Accept: text/event-stream\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"Expect:\r\n"
"\r\n"
).encode("utf-8")
manifest = {
"endpoint": "/v1/audio/speech/live",
"model": model,
"system_prompt": system_prompt,
"input_wav": str(input_wav),
"input_duration_s": len(pcm_input) / float(bytes_per_second),
"timeline": timeline,
"audio_chunk_ms": audio_chunk_ms,
"audio_chunk_bytes": chunk_bytes,
"pace_factor": pace_factor,
"turn_gap_seconds": turn_gap_seconds,
"final_gap_seconds": final_gap_seconds,
"client_input_streaming": "all user turns sent over one HTTP/1.1 chunked live request",
"client_response_reading": "one SSE response reader runs concurrently with upload",
}
write_json(request_manifest_path, manifest)
start = time.perf_counter()
upload_complete_ms: float | None = None
first_event_ms: float | None = None
first_delta_ms: float | None = None
done_ms: float | None = None
ttft_ms: float | None = None
first_audio_before_input_end: bool | None = None
request_start_to_first_audio_ms: float | None = None
input_end_ms: float | None = None
overlap_ms: float | None = None
delta_events = 0
output_pcm = bytearray()
response_headers: dict[str, Any] = {}
response_events: list[dict[str, Any]] = []
errors: list[str] = []
reader_error: list[Exception] = []
def read_response(reader: Any) -> None:
nonlocal first_event_ms, first_delta_ms, done_ms, ttft_ms, first_audio_before_input_end
nonlocal request_start_to_first_audio_ms, input_end_ms, overlap_ms, delta_events
try:
status, headers = read_http_headers(reader)
response_headers.update({"status": status, "headers": headers})
write_json(response_headers_path, response_headers)
if not status.startswith("HTTP/1.1 200"):
body = reader.read().decode("utf-8", "replace")
raise RuntimeError(f"continuous: {status}: {body}")
sse_buffer = ""
with events_path.open("w", encoding="utf-8") as events_handle:
response_chunks = (
iter_chunked_response(reader)
if "chunked" in headers.get("transfer-encoding", "").lower()
else [reader.read()]
)
for raw_chunk in response_chunks:
if not raw_chunk:
continue
sse_buffer += raw_chunk.decode("utf-8", "replace").replace("\r\n", "\n")
events, sse_buffer = parse_sse_data(sse_buffer)
for data in events:
elapsed_ms = (time.perf_counter() - start) * 1000.0
if first_event_ms is None:
first_event_ms = elapsed_ms
if data == "[DONE]":
done_ms = elapsed_ms
events_handle.write(json.dumps({"elapsed_ms": elapsed_ms, "data": data}) + "\n")
events_handle.flush()
continue
event = json.loads(data)
event_type = event.get("type")
if event_type == "speech.audio.delta":
delta_events += 1
if first_delta_ms is None:
first_delta_ms = elapsed_ms
output_pcm.extend(base64.b64decode(event["audio"]))
elif event_type == "speech.audio.done":
done_ms = elapsed_ms
timing = event.get("timing", {})
ttft_ms = timing.get("ttft_ms")
first_audio_before_input_end = timing.get("first_audio_before_input_end")
request_start_to_first_audio_ms = timing.get("request_start_to_first_audio_ms")
input_end_ms = timing.get("input_end_ms")
overlap_ms = timing.get("overlap_ms")
if request_start_to_first_audio_ms is None:
request_start_to_first_audio_ms = ttft_ms
elif event_type == "error":
errors.append(event.get("error", {}).get("message", json.dumps(event)))
response_events.append({"elapsed_ms": elapsed_ms, "event": event})
events_handle.write(json.dumps(response_events[-1], ensure_ascii=False) + "\n")
events_handle.flush()
except Exception as exc:
reader_error.append(exc)
with socket.create_connection((host, port), timeout=request_timeout_s) as sock:
sock.settimeout(request_timeout_s)
reader = sock.makefile("rb")
reader_thread = threading.Thread(target=read_response, args=(reader,), daemon=True)
reader_thread.start()
with upload_log_path.open("w", newline="", encoding="utf-8") as upload_handle:
writer = csv.writer(upload_handle)
writer.writerow(["index", "elapsed_ms", "bytes", "audio_offset_bytes", "audio_time_end_s"])
sock.sendall(request_headers)
offset = 0
index = 0
while offset < len(pcm_input):
end = min(len(pcm_input), offset + chunk_bytes)
chunk = pcm_input[offset:end]
sock.sendall(f"{len(chunk):X}\r\n".encode("ascii"))
sock.sendall(chunk)
sock.sendall(b"\r\n")
elapsed_ms = (time.perf_counter() - start) * 1000.0
writer.writerow([
index,
round(elapsed_ms, 3),
len(chunk),
end,
round(end / float(bytes_per_second), 6),
])
upload_handle.flush()
offset = end
index += 1
if pace_factor > 0.0 and offset < len(pcm_input):
time.sleep((audio_chunk_ms / 1000.0) * pace_factor)
sock.sendall(b"0\r\n\r\n")
upload_complete_ms = (time.perf_counter() - start) * 1000.0
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
reader_thread.join(timeout=request_timeout_s)
if reader_thread.is_alive():
raise RuntimeError("continuous: timed out waiting for live speech response")
if reader_error:
raise reader_error[0]
if errors:
raise RuntimeError("continuous: " + "; ".join(errors))
if request_start_to_first_audio_ms is None:
raise RuntimeError("continuous: response did not include first-audio timing")
if ttft_ms is None and first_audio_before_input_end is not True:
raise RuntimeError("continuous: response did not include live TTFT timing")
if not output_pcm:
raise RuntimeError("continuous: response produced no audio")
write_pcm16_wav(output_wav, bytes(output_pcm), sample_rate=sample_rate, channels=1)
audio_stats = analyze_pcm16(bytes(output_pcm), sample_rate)
elapsed_ms = (time.perf_counter() - start) * 1000.0
result = {
"label": "continuous",
"elapsed_ms": elapsed_ms,
"upload_complete_ms": upload_complete_ms,
"first_event_ms": first_event_ms,
"first_delta_ms": first_delta_ms,
"done_ms": done_ms,
"ttft_ms": ttft_ms,
"first_audio_before_input_end": first_audio_before_input_end,
"request_start_to_first_audio_ms": request_start_to_first_audio_ms,
"input_end_ms": input_end_ms,
"overlap_ms": overlap_ms,
"first_event_before_upload_complete": (
first_event_ms < upload_complete_ms
if first_event_ms is not None and upload_complete_ms is not None
else None
),
"first_delta_before_upload_complete": (
first_delta_ms < upload_complete_ms
if first_delta_ms is not None and upload_complete_ms is not None
else None
),
"client_observed_ttft_after_upload_ms": (
first_delta_ms - upload_complete_ms
if first_delta_ms is not None and upload_complete_ms is not None
else None
),
"delta_events": delta_events,
"input_audio": {
"sample_rate": sample_rate,
"channels": channels,
"streamed_duration_s": len(pcm_input) / float(bytes_per_second),
"streamed_pcm_bytes": len(pcm_input),
"turns": input_infos,
},
"output_audio": audio_stats,
"timeline": timeline,
"artifacts": {
"request_manifest": str(request_manifest_path),
"input_wav": str(input_wav),
"upload_chunks": str(upload_log_path),
"events": str(events_path),
"response_headers": str(response_headers_path),
"assistant_response_wav": str(output_wav),
},
}
write_json(conversation_dir / "summary.json", result)
return result
def run_client(args: argparse.Namespace) -> dict[str, Any]:
output_dir = args.output_dir
if not path_is_under(output_dir, LOG_ROOT):
raise RuntimeError(f"--output-dir must be under {LOG_ROOT}")
if args.audio_chunk_ms <= 0.0:
raise RuntimeError("--audio-chunk-ms must be positive")
if args.pace_factor < 0.0:
raise RuntimeError("--pace-factor must be non-negative")
output_dir.mkdir(parents=True, exist_ok=True)
server_config = load_json(args.server_config)
host = args.host or server_config.get("host", "127.0.0.1")
port = int(args.port or server_config["port"])
models = server_config.get("models") or []
model = args.model or (models[0]["id"] if models else "")
if not model:
raise RuntimeError("--model is required when server config has no models")
write_json(output_dir / "server_config_snapshot.json", server_config)
option_pairs: dict[str, str] = {}
for raw in args.request_option:
key, sep, value = raw.partition("=")
if not sep or not key:
raise RuntimeError(f"--request-option must be key=value, got: {raw}")
option_pairs[key] = value
turns = normalize_turns(args.turns_json, args.repo_root)
server: subprocess.Popen[str] | None = None
server_log_handle = None
server_command: list[str] | None = None
try:
if args.start_server:
server_command = [
str(args.server_bin),
"--config",
str(args.server_config),
"--log-file",
str(output_dir / "framework.log"),
]
server_log_handle = (output_dir / "server_stdout.log").open("w", encoding="utf-8")
server = subprocess.Popen(
server_command,
cwd=args.repo_root,
stdout=server_log_handle,
stderr=subprocess.STDOUT,
text=True,
)
wait_for_health(str(host), port, args.health_timeout_s)
effective_system_prompt = args.system_prompt
if not effective_system_prompt:
effective_system_prompt = turns[0]["assistant_instruction"]
results = [stream_continuous_conversation(
str(host),
port,
model,
turns,
output_dir,
effective_system_prompt,
args.audio_chunk_ms,
args.pace_factor,
args.turn_gap_seconds,
args.final_gap_seconds,
args.max_input_seconds,
args.request_timeout_s,
option_pairs,
)]
summary = {
"measurement_kind": "personaplex_true_speech_live_continuous_multiturn",
"repo": str(args.repo_root),
"server_command": server_command,
"server_config": str(args.server_config),
"turns_json": str(args.turns_json),
"model": model,
"host": host,
"port": port,
"system_prompt": effective_system_prompt,
"audio_chunk_ms": args.audio_chunk_ms,
"pace_factor": args.pace_factor,
"turn_gap_seconds": args.turn_gap_seconds,
"final_gap_seconds": args.final_gap_seconds,
"max_input_seconds": args.max_input_seconds,
"request_options": option_pairs,
"turns": results,
"artifacts": {
"summary": str(output_dir / "summary.json"),
"server_config_snapshot": str(output_dir / "server_config_snapshot.json"),
"framework_log": str(output_dir / "framework.log") if args.start_server else None,
"server_stdout": str(output_dir / "server_stdout.log") if args.start_server else None,
},
}
write_json(output_dir / "summary.json", summary)
return summary
finally:
if server is not None:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=10)
if server_log_handle is not None:
server_log_handle.close()
def parse_args() -> argparse.Namespace:
default_output = LOG_ROOT / f"personaplex_live_multiturn_{timestamp_slug()}"
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-root", type=Path, default=REPO_ROOT)
parser.add_argument("--server-bin", type=Path, default=DEFAULT_SERVER_BIN)
parser.add_argument("--server-config", type=Path, required=True)
parser.add_argument("--turns-json", type=Path, required=True)
parser.add_argument("--start-server", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--host", default="")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--model", default="")
parser.add_argument("--audio-chunk-ms", type=float, default=200.0)
parser.add_argument("--turn-gap-seconds", type=float, default=1.2)
parser.add_argument("--final-gap-seconds", type=float, default=6.0)
parser.add_argument(
"--system-prompt",
default="",
)
parser.add_argument(
"--pace-factor",
type=float,
default=1.0,
help="sleep audio_chunk_ms * pace_factor between chunks; 1.0 approximates real time",
)
parser.add_argument("--max-input-seconds", type=float)
parser.add_argument("--request-option", action="append", default=[])
parser.add_argument("--output-dir", type=Path, default=default_output)
parser.add_argument("--health-timeout-s", type=float, default=180.0)
parser.add_argument("--request-timeout-s", type=float, default=1800.0)
return parser.parse_args()
def main() -> int:
try:
summary = run_client(parse_args())
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({
"output_dir": summary["artifacts"]["summary"].rsplit("/", 1)[0],
"model": summary["model"],
"turns": [
{
"label": turn["label"],
"input_duration_s": turn["input_audio"]["streamed_duration_s"],
"output_duration_s": turn["output_audio"]["duration_s"],
"ttft_ms": turn["ttft_ms"],
"first_audio_before_input_end": turn["first_audio_before_input_end"],
"request_start_to_first_audio_ms": turn["request_start_to_first_audio_ms"],
"overlap_ms": turn["overlap_ms"],
"first_delta_ms": turn["first_delta_ms"],
"upload_complete_ms": turn["upload_complete_ms"],
"first_delta_before_upload_complete": turn["first_delta_before_upload_complete"],
"delta_events": turn["delta_events"],
"silent_spans": turn["output_audio"]["silent_spans_peak_lt_0_01"],
"wav": turn["artifacts"]["assistant_response_wav"],
"input_wav": turn["artifacts"].get("input_wav"),
}
for turn in summary["turns"]
],
}, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())