voice_audio_multi/tools/streaming/measure_asr_streaming_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

595 lines
23 KiB
Python

#!/usr/bin/env python3
"""Measure ASR streaming with an auditable incremental-upload client."""
from __future__ import annotations
import argparse
import csv
import json
import socket
import subprocess
import sys
import threading
import time
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) -> dict[str, 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 audio_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 gpu_memory_mib() -> int:
output = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
text=True,
timeout=5,
)
values = [int(line.strip()) for line in output.splitlines() if line.strip()]
if not values:
raise RuntimeError("nvidia-smi returned no GPU memory values")
return max(values)
class VramSampler:
def __init__(self, path: Path, interval_s: float) -> None:
self.path = path
self.interval_s = interval_s
self.stop_event = threading.Event()
self.thread: threading.Thread | None = None
def __enter__(self) -> "VramSampler":
if self.interval_s <= 0.0:
raise RuntimeError("--vram-sample-ms must be positive")
self.path.parent.mkdir(parents=True, exist_ok=True)
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
return self
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
self.stop_event.set()
if self.thread is not None:
self.thread.join(timeout=5)
def _run(self) -> None:
start = time.perf_counter()
with self.path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["elapsed_ms", "vram_mib"])
while not self.stop_event.is_set():
writer.writerow([round((time.perf_counter() - start) * 1000.0, 3), gpu_memory_mib()])
handle.flush()
time.sleep(self.interval_s)
def read_vram_summary(path: Path) -> dict[str, Any]:
values: list[int] = []
with path.open("r", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
values.append(int(row["vram_mib"]))
if not values:
raise RuntimeError(f"VRAM sampler wrote no samples: {path}")
return {
"vram_start_mib": values[0],
"vram_peak_mib": max(values),
"vram_end_mib": values[-1],
"vram_samples": len(values),
}
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 multipart_preamble(boundary: str, model: str, language: str | None, filename: str) -> bytes:
fields = [
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n'
"\r\n"
f"{model}\r\n"
),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="stream"\r\n'
"\r\n"
"true\r\n"
),
]
if language:
fields.append(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="language"\r\n'
"\r\n"
f"{language}\r\n"
)
fields.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
"Content-Type: audio/wav\r\n"
"\r\n"
)
return "".join(fields).encode("utf-8")
def multipart_trailer(boundary: str) -> bytes:
return f"\r\n--{boundary}--\r\n".encode("utf-8")
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 stream_asr_request(
host: str,
port: int,
model: str,
audio_path: Path,
output_dir: Path,
request_label: str,
audio_chunk_ms: float,
pace_factor: float,
language: str | None,
timeout_s: float,
) -> dict[str, Any]:
info = audio_info(audio_path)
boundary = "audiocpp-stream-" + uuid.uuid4().hex
preamble = multipart_preamble(boundary, model, language, audio_path.name)
trailer = multipart_trailer(boundary)
file_bytes = audio_path.read_bytes()
content_length = len(preamble) + len(file_bytes) + len(trailer)
bytes_per_second = max(1, int(info["audio_bytes_per_second"]))
audio_chunk_bytes = max(1, int(bytes_per_second * audio_chunk_ms / 1000.0))
manifest = {
"label": request_label,
"model": model,
"endpoint": "/v1/audio/transcriptions",
"method": "POST",
"stream": True,
"language": language,
"audio": info,
"audio_chunk_ms": audio_chunk_ms,
"audio_chunk_bytes": audio_chunk_bytes,
"pace_factor": pace_factor,
"content_length": content_length,
"client_input_streaming": "multipart body sent as multiple socket writes",
"client_response_reading": "SSE response reader runs concurrently with upload",
"server_body_handling": "current server reads full HTTP body before invoking ASR streaming handler",
}
write_json(output_dir / f"{request_label}_request_manifest.json", manifest)
upload_log_path = output_dir / f"{request_label}_upload_chunks.csv"
events_path = output_dir / f"{request_label}_events.jsonl"
transcript_path = output_dir / f"{request_label}_transcript.txt"
raw_response_path = output_dir / f"{request_label}_response_headers.json"
request_headers = (
"POST /v1/audio/transcriptions HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
"Accept: text/event-stream\r\n"
f"Content-Length: {content_length}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("utf-8")
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
server_ttft_ms: float | None = None
delta_events = 0
final_text = ""
errors: list[str] = []
response_events: list[dict[str, Any]] = []
response_headers: dict[str, Any] = {}
reader_error: list[Exception] = []
def read_response(reader: Any) -> None:
nonlocal first_event_ms
nonlocal first_delta_ms
nonlocal done_ms
nonlocal server_ttft_ms
nonlocal delta_events
nonlocal final_text
try:
status, headers = read_http_headers(reader)
response_headers.update({"status": status, "headers": headers})
write_json(raw_response_path, response_headers)
if not status.startswith("HTTP/1.1 200"):
body = reader.read().decode("utf-8", "replace")
raise RuntimeError(f"{request_label}: {status}: {body}")
sse_buffer = ""
with events_path.open("w", encoding="utf-8") as events_handle:
transfer_encoding = headers.get("transfer-encoding", "").lower()
chunks = iter_chunked_response(reader) if "chunked" in transfer_encoding else [reader.read()]
for raw_chunk in 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:
event_ms = (time.perf_counter() - start) * 1000.0
if first_event_ms is None:
first_event_ms = event_ms
if data == "[DONE]":
done_ms = event_ms
events_handle.write(json.dumps({"elapsed_ms": event_ms, "data": data}, ensure_ascii=False) + "\n")
events_handle.flush()
continue
event = json.loads(data)
event_type = event.get("type")
if event_type == "transcript.text.delta":
delta_events += 1
if first_delta_ms is None:
first_delta_ms = event_ms
elif event_type == "transcript.text.done":
final_text = event.get("text", "")
server_ttft_ms = event.get("timing", {}).get("ttft_ms")
done_ms = event_ms
elif event_type == "error":
errors.append(event.get("error", {}).get("message", json.dumps(event)))
response_events.append({"elapsed_ms": event_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=timeout_s) as sock:
sock.settimeout(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", "kind", "elapsed_ms", "bytes", "file_offset", "audio_time_end_s"])
def send_part(index: int, kind: str, data: bytes, file_offset: int = 0) -> None:
sock.sendall(data)
elapsed_ms = (time.perf_counter() - start) * 1000.0
audio_time_end_s = min(file_offset, len(file_bytes)) / float(bytes_per_second)
writer.writerow([index, kind, round(elapsed_ms, 3), len(data), file_offset, round(audio_time_end_s, 6)])
upload_handle.flush()
send_part(0, "headers", request_headers)
send_part(1, "multipart_preamble", preamble)
index = 2
offset = 0
while offset < len(file_bytes):
end = min(len(file_bytes), offset + audio_chunk_bytes)
send_part(index, "audio", file_bytes[offset:end], end)
offset = end
index += 1
if pace_factor > 0.0 and offset < len(file_bytes):
time.sleep((audio_chunk_ms / 1000.0) * pace_factor)
send_part(index, "multipart_trailer", trailer, len(file_bytes))
upload_complete_ms = (time.perf_counter() - start) * 1000.0
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
reader_thread.join(timeout=timeout_s)
if reader_thread.is_alive():
raise RuntimeError(f"{request_label}: timed out waiting for streaming ASR response")
if reader_error:
raise reader_error[0]
if errors:
raise RuntimeError(f"{request_label}: " + "; ".join(errors))
if not final_text:
raise RuntimeError(f"{request_label}: streaming ASR response did not include final text")
if server_ttft_ms is None:
raise RuntimeError(f"{request_label}: streaming ASR response did not include server ttft_ms")
transcript_path.write_text(final_text, encoding="utf-8")
elapsed_ms = (time.perf_counter() - start) * 1000.0
audio_duration_ms = float(info["duration_s"]) * 1000.0
streaming_rtf = done_ms / audio_duration_ms if done_ms is not None and audio_duration_ms > 0.0 else None
return {
"label": request_label,
"audio": info,
"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,
"server_ttft_ms": server_ttft_ms,
"streaming_rtf": streaming_rtf,
"client_observed_ttft_ms": first_delta_ms if first_delta_ms is not None else done_ms,
"client_observed_ttft_after_upload_ms": (
(first_delta_ms if first_delta_ms is not None else done_ms) - upload_complete_ms
if upload_complete_ms is not None and (first_delta_ms is not None or done_ms is not None)
else None
),
"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
),
"delta_events": delta_events,
"text_chars": len(final_text),
"text_words": len(final_text.split()),
"artifacts": {
"request_manifest": str(output_dir / f"{request_label}_request_manifest.json"),
"upload_chunks": str(upload_log_path),
"events": str(events_path),
"response_headers": str(raw_response_path),
"transcript": str(transcript_path),
},
}
def run_measurement(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)
config = load_json(args.server_config)
host = args.host or config.get("host", "127.0.0.1")
port = int(args.port or config["port"])
models = config.get("models") or []
if not models and not args.model:
raise RuntimeError("server config contains no models and --model was not provided")
model = args.model or models[0]["id"]
write_json(output_dir / "server_config_snapshot.json", config)
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(host, port, args.health_timeout_s)
requests: list[dict[str, Any]] = []
if args.warmup_audio is not None:
warmup = stream_asr_request(
host,
port,
model,
args.warmup_audio,
output_dir,
"warmup_discard",
args.audio_chunk_ms,
args.pace_factor,
args.language,
args.request_timeout_s,
)
requests.append(warmup)
with VramSampler(output_dir / "measured_vram.csv", args.vram_sample_ms / 1000.0):
measured = stream_asr_request(
host,
port,
model,
args.audio,
output_dir,
"measured",
args.audio_chunk_ms,
args.pace_factor,
args.language,
args.request_timeout_s,
)
requests.append(measured)
summary = {
"measurement_kind": "client_incremental_upload_to_streaming_sse_asr",
"repo": str(args.repo_root),
"server_command": server_command,
"server_config": str(args.server_config),
"model": model,
"host": host,
"port": port,
"audio": str(args.audio),
"warmup_audio": str(args.warmup_audio) if args.warmup_audio is not None else None,
"audio_chunk_ms": args.audio_chunk_ms,
"pace_factor": args.pace_factor,
"client_input_streaming": "multipart body sent as multiple socket writes",
"server_body_handling": "current server reads full HTTP body before invoking ASR streaming handler",
"requests": requests,
"measured": measured,
"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,
"vram_csv": str(output_dir / "measured_vram.csv"),
},
}
summary.update(read_vram_summary(output_dir / "measured_vram.csv"))
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"asr_streaming_{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("--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", type=Path, required=True)
parser.add_argument("--warmup-audio", type=Path)
parser.add_argument("--language")
parser.add_argument("--output-dir", type=Path, default=default_output)
parser.add_argument("--audio-chunk-ms", type=float, default=200.0)
parser.add_argument(
"--pace-factor",
type=float,
default=0.0,
help="sleep audio_chunk_ms * pace_factor between audio chunk writes; 1.0 approximates real time",
)
parser.add_argument("--health-timeout-s", type=float, default=180.0)
parser.add_argument("--request-timeout-s", type=float, default=1800.0)
parser.add_argument("--vram-sample-ms", type=float, default=200.0)
return parser.parse_args()
def main() -> int:
try:
summary = run_measurement(parse_args())
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
measured = summary["measured"]
print(json.dumps(
{
"output_dir": summary["artifacts"]["summary"].rsplit("/", 1)[0],
"model": summary["model"],
"audio_duration_s": measured["audio"]["duration_s"],
"upload_complete_ms": measured["upload_complete_ms"],
"server_ttft_ms": measured["server_ttft_ms"],
"client_observed_ttft_ms": measured["client_observed_ttft_ms"],
"streaming_rtf": measured["streaming_rtf"],
"client_observed_ttft_after_upload_ms": measured["client_observed_ttft_after_upload_ms"],
"first_event_before_upload_complete": measured["first_event_before_upload_complete"],
"first_delta_before_upload_complete": measured["first_delta_before_upload_complete"],
"delta_events": measured["delta_events"],
"text_words": measured["text_words"],
"vram_peak_mib": summary["vram_peak_mib"],
},
indent=2,
))
return 0
if __name__ == "__main__":
raise SystemExit(main())