inferencekit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Client-timed PCM speech over HTTP NDJSON, using only the standard library.
|
|
2
|
+
|
|
3
|
+
POST /v1/audio/speech accepts AudioRequest.body(). The response is UTF-8 NDJSON:
|
|
4
|
+
metadata {type, sample_rate, channels, encoding: "pcm_s16le"}, then one or more
|
|
5
|
+
audio {type, pcm_b64}, then final {type, sample_count, effective}. sample_count
|
|
6
|
+
counts PCM frames PER CHANNEL, not interleaved int16 values. Metadata must match
|
|
7
|
+
the request. effective must report model, seed and every requested setting.
|
|
8
|
+
|
|
9
|
+
First audio means decoded PCM bytes, including silence; signal onset is reported
|
|
10
|
+
separately. Neither signal detection nor these structural checks proves speech
|
|
11
|
+
intelligibility. The complete timer ends only after consuming the entire HTTP
|
|
12
|
+
body, including final and EOF. Engine-provided durations are never trusted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import binascii
|
|
19
|
+
import http.client
|
|
20
|
+
import ipaddress
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import queue
|
|
24
|
+
import socket
|
|
25
|
+
import struct
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from inferencekit import evidence as ev
|
|
31
|
+
from inferencekit.canonical import canonical_json
|
|
32
|
+
from inferencekit.serve.client import Endpoint, scrub
|
|
33
|
+
|
|
34
|
+
AUDIO_PATH = "/v1/audio/speech"
|
|
35
|
+
MAX_LINE_BYTES = 8 * 1024 * 1024
|
|
36
|
+
MAX_PCM_BYTES = 256 * 1024 * 1024
|
|
37
|
+
ENCODING = "pcm_s16le"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _connect_bounded(address, timeout, source_address=None):
|
|
41
|
+
"""Bound DNS plus TCP connect; an abandoned resolver cannot open a socket."""
|
|
42
|
+
host, port = address
|
|
43
|
+
deadline = time.monotonic() + timeout
|
|
44
|
+
try:
|
|
45
|
+
literal = ipaddress.ip_address(host)
|
|
46
|
+
except ValueError:
|
|
47
|
+
literal = None
|
|
48
|
+
if literal is not None:
|
|
49
|
+
family = socket.AF_INET6 if literal.version == 6 else socket.AF_INET
|
|
50
|
+
addresses = [(family, socket.SOCK_STREAM, 0, "", (host, port))]
|
|
51
|
+
else:
|
|
52
|
+
resolved = queue.Queue(maxsize=1)
|
|
53
|
+
|
|
54
|
+
def resolve():
|
|
55
|
+
try:
|
|
56
|
+
resolved.put((socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM), None))
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
resolved.put((None, exc))
|
|
59
|
+
|
|
60
|
+
threading.Thread(target=resolve, daemon=True).start()
|
|
61
|
+
try:
|
|
62
|
+
addresses, error = resolved.get(timeout=max(0.001, deadline - time.monotonic()))
|
|
63
|
+
except queue.Empty:
|
|
64
|
+
raise TimeoutError("audio DNS deadline exceeded") from None
|
|
65
|
+
if error:
|
|
66
|
+
raise error
|
|
67
|
+
error = OSError("audio endpoint resolved no addresses")
|
|
68
|
+
for family, kind, protocol, _, destination in addresses:
|
|
69
|
+
remaining = deadline - time.monotonic()
|
|
70
|
+
if remaining <= 0:
|
|
71
|
+
raise TimeoutError("audio connection deadline exceeded")
|
|
72
|
+
sock = socket.socket(family, kind, protocol)
|
|
73
|
+
try:
|
|
74
|
+
sock.settimeout(remaining)
|
|
75
|
+
if source_address:
|
|
76
|
+
sock.bind(source_address)
|
|
77
|
+
sock.connect(destination)
|
|
78
|
+
return sock
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
sock.close()
|
|
81
|
+
error = exc
|
|
82
|
+
raise error
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class AudioRequest:
|
|
87
|
+
id: str
|
|
88
|
+
text: str
|
|
89
|
+
model: str
|
|
90
|
+
seed: int = 0
|
|
91
|
+
sample_rate: int = 44100
|
|
92
|
+
channels: int = 1
|
|
93
|
+
settings: dict = field(default_factory=dict)
|
|
94
|
+
reference_audio: bytes | None = field(default=None, repr=False)
|
|
95
|
+
reference_text: str | None = None
|
|
96
|
+
|
|
97
|
+
def body(self) -> dict:
|
|
98
|
+
body = {"input": self.text, "model": self.model, "seed": self.seed,
|
|
99
|
+
"sample_rate": self.sample_rate, "channels": self.channels,
|
|
100
|
+
"encoding": ENCODING, "stream": True, "settings": self.settings}
|
|
101
|
+
if self.reference_audio is not None:
|
|
102
|
+
body["reference_audio_b64"] = base64.b64encode(self.reference_audio).decode("ascii")
|
|
103
|
+
body["reference_text"] = self.reference_text
|
|
104
|
+
return body
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class AudioResult:
|
|
109
|
+
id: str
|
|
110
|
+
ok: bool = False
|
|
111
|
+
error: str | None = None
|
|
112
|
+
status: int | None = None
|
|
113
|
+
t_send_ns: int = 0
|
|
114
|
+
t_headers_ns: int | None = None
|
|
115
|
+
t_first_ns: int | None = None
|
|
116
|
+
t_signal_ns: int | None = None
|
|
117
|
+
t_audible_ns: int | None = None
|
|
118
|
+
t_done_ns: int | None = None
|
|
119
|
+
t_end_ns: int = 0
|
|
120
|
+
pcm: bytes = field(default=b"", repr=False)
|
|
121
|
+
sample_rate: int | None = None
|
|
122
|
+
channels: int | None = None
|
|
123
|
+
sample_count: int = 0
|
|
124
|
+
leading_silent_frames: int = 0
|
|
125
|
+
effective: dict | None = None
|
|
126
|
+
chunks: list[tuple[int, int]] = field(default_factory=list) # arrival ns, PCM frames
|
|
127
|
+
|
|
128
|
+
def milliseconds(self, timestamp: int | None) -> float | None:
|
|
129
|
+
return (timestamp - self.t_send_ns) / 1e6 if self.ok and timestamp is not None else None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def first_audio_ms(self):
|
|
133
|
+
return self.milliseconds(self.t_first_ns)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def first_signal_ms(self):
|
|
137
|
+
return self.milliseconds(self.t_signal_ns)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def first_audible_ms(self):
|
|
141
|
+
return self.milliseconds(self.t_audible_ns)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def complete_audio_ms(self):
|
|
145
|
+
return self.milliseconds(self.t_done_ns)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def audio_duration_s(self):
|
|
149
|
+
return self.sample_count / self.sample_rate if self.ok and self.sample_rate else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def playback_metrics(chunks: list[tuple[int, int]], sample_rate: int) -> dict:
|
|
153
|
+
"""Simulate playback starting immediately at the first chunk; no hidden prebuffer.
|
|
154
|
+
|
|
155
|
+
Stall intervals count once per late arrival. Stream closure after the last
|
|
156
|
+
sample cannot cause an underrun. This is a derived PCM buffer simulation,
|
|
157
|
+
not a claim about an operating system's real audio device.
|
|
158
|
+
"""
|
|
159
|
+
if not chunks:
|
|
160
|
+
return {"underruns": None, "underrun_ms": None, "max_stream_gap_ms": None}
|
|
161
|
+
play_end = chunks[0][0] / 1e9
|
|
162
|
+
count, stalled = 0, 0.0
|
|
163
|
+
for t_ns, frames in chunks:
|
|
164
|
+
arrival = t_ns / 1e9
|
|
165
|
+
gap = arrival - play_end
|
|
166
|
+
if gap > 1e-9:
|
|
167
|
+
count += 1
|
|
168
|
+
stalled += gap
|
|
169
|
+
play_end = max(play_end, arrival) + frames / sample_rate
|
|
170
|
+
gaps = [(b[0] - a[0]) / 1e6 for a, b in zip(chunks, chunks[1:])]
|
|
171
|
+
return {"underruns": count, "underrun_ms": stalled * 1000,
|
|
172
|
+
"max_stream_gap_ms": max(gaps, default=0.0)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AudioHTTP:
|
|
176
|
+
endpoint_kind = "audio_http"
|
|
177
|
+
|
|
178
|
+
def __init__(self, ep: Endpoint, timeout_s: float = 300.0):
|
|
179
|
+
if not math.isfinite(timeout_s) or timeout_s <= 0:
|
|
180
|
+
raise ValueError("timeout_s must be finite and positive")
|
|
181
|
+
self.ep, self.timeout_s = ep, timeout_s
|
|
182
|
+
|
|
183
|
+
def generate(self, request: AudioRequest) -> AudioResult:
|
|
184
|
+
res = AudioResult(request.id)
|
|
185
|
+
if (type(request.sample_rate) is not int or not 8000 <= request.sample_rate <= 192000
|
|
186
|
+
or type(request.channels) is not int or not 1 <= request.channels <= 8):
|
|
187
|
+
res.error = "request PCM rate must be 8000..192000 Hz and channels 1..8"
|
|
188
|
+
return res
|
|
189
|
+
cls = http.client.HTTPSConnection if self.ep.scheme == "https" else http.client.HTTPConnection
|
|
190
|
+
conn = cls(self.ep.host, self.ep.port, timeout=self.timeout_s)
|
|
191
|
+
headers = {"Content-Type": "application/json", "Accept": "application/x-ndjson", "Connection": "close"}
|
|
192
|
+
if self.ep.api_key:
|
|
193
|
+
headers["Authorization"] = "Bearer " + self.ep.api_key
|
|
194
|
+
payload = json.dumps(request.body(), allow_nan=False).encode()
|
|
195
|
+
pcm = bytearray()
|
|
196
|
+
metadata, ended = False, False
|
|
197
|
+
playback_end_ns = 0
|
|
198
|
+
res.t_send_ns = time.perf_counter_ns()
|
|
199
|
+
deadline = time.monotonic() + self.timeout_s
|
|
200
|
+
response = None
|
|
201
|
+
expired = threading.Event()
|
|
202
|
+
conn._create_connection = lambda address, timeout, source_address=None: _connect_bounded(
|
|
203
|
+
address, max(0.001, deadline - time.monotonic()), source_address)
|
|
204
|
+
|
|
205
|
+
def expire():
|
|
206
|
+
expired.set()
|
|
207
|
+
active = conn.sock or getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
|
|
208
|
+
if active is not None:
|
|
209
|
+
try:
|
|
210
|
+
active.shutdown(socket.SHUT_RDWR)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
active.close()
|
|
214
|
+
|
|
215
|
+
# A receive timeout resets on each packet. This watchdog also interrupts
|
|
216
|
+
# a peer dripping header bytes or one never-terminated NDJSON line.
|
|
217
|
+
watchdog = threading.Timer(self.timeout_s, expire)
|
|
218
|
+
watchdog.daemon = True
|
|
219
|
+
watchdog.start()
|
|
220
|
+
try:
|
|
221
|
+
conn.request("POST", self.ep.prefix + AUDIO_PATH, body=payload, headers=headers)
|
|
222
|
+
if conn.sock is not None:
|
|
223
|
+
conn.sock.settimeout(max(0.001, deadline - time.monotonic()))
|
|
224
|
+
response = conn.getresponse()
|
|
225
|
+
res.status = response.status
|
|
226
|
+
res.t_headers_ns = time.perf_counter_ns()
|
|
227
|
+
if response.status != 200:
|
|
228
|
+
# Do not echo bodies: proxies and engines can echo credentials or input.
|
|
229
|
+
raise ValueError(f"HTTP {response.status}")
|
|
230
|
+
ctype = (response.getheader("Content-Type") or "").split(";")[0].strip().lower()
|
|
231
|
+
if ctype not in ("application/x-ndjson", "application/ndjson"):
|
|
232
|
+
raise ValueError("response must be application/x-ndjson")
|
|
233
|
+
while True:
|
|
234
|
+
if expired.is_set() or time.monotonic() >= deadline:
|
|
235
|
+
raise ValueError("response exceeded request deadline")
|
|
236
|
+
active = conn.sock or getattr(getattr(response.fp, "raw", None), "_sock", None)
|
|
237
|
+
if active is not None:
|
|
238
|
+
active.settimeout(max(0.001, deadline - time.monotonic()))
|
|
239
|
+
line = response.readline(MAX_LINE_BYTES + 1)
|
|
240
|
+
if expired.is_set() or time.monotonic() >= deadline:
|
|
241
|
+
raise ValueError("response exceeded request deadline")
|
|
242
|
+
if not line:
|
|
243
|
+
break
|
|
244
|
+
if len(line) > MAX_LINE_BYTES:
|
|
245
|
+
raise ValueError("NDJSON event exceeds size limit")
|
|
246
|
+
if not line.endswith(b"\n"):
|
|
247
|
+
raise ValueError("truncated NDJSON event (missing newline)")
|
|
248
|
+
if not line.strip():
|
|
249
|
+
continue
|
|
250
|
+
if ended:
|
|
251
|
+
raise ValueError("event after final")
|
|
252
|
+
doc = json.loads(line)
|
|
253
|
+
if not isinstance(doc, dict):
|
|
254
|
+
raise ValueError("event must be a JSON object")
|
|
255
|
+
kind = doc.get("type")
|
|
256
|
+
if kind == "metadata":
|
|
257
|
+
if metadata:
|
|
258
|
+
raise ValueError("duplicate metadata")
|
|
259
|
+
for key, expected in (("sample_rate", request.sample_rate), ("channels", request.channels)):
|
|
260
|
+
if type(doc.get(key)) is not int or doc[key] != expected:
|
|
261
|
+
raise ValueError(f"metadata {key} mismatch")
|
|
262
|
+
if doc.get("encoding") != ENCODING:
|
|
263
|
+
raise ValueError("metadata encoding must be pcm_s16le")
|
|
264
|
+
res.sample_rate, res.channels, metadata = request.sample_rate, request.channels, True
|
|
265
|
+
elif kind == "audio":
|
|
266
|
+
if not metadata:
|
|
267
|
+
raise ValueError("audio before metadata")
|
|
268
|
+
value = doc.get("pcm_b64")
|
|
269
|
+
if not isinstance(value, str):
|
|
270
|
+
raise ValueError("audio pcm_b64 must be a string")
|
|
271
|
+
data = base64.b64decode(value, validate=True)
|
|
272
|
+
t_pcm = time.perf_counter_ns() # PCM is now playable; headers never count
|
|
273
|
+
if not data or len(data) % (2 * res.channels):
|
|
274
|
+
raise ValueError("empty or truncated PCM frame")
|
|
275
|
+
if len(pcm) + len(data) > MAX_PCM_BYTES:
|
|
276
|
+
raise ValueError("PCM response exceeds size limit")
|
|
277
|
+
frames = len(data) // (2 * res.channels)
|
|
278
|
+
res.t_first_ns = res.t_first_ns if res.t_first_ns is not None else t_pcm
|
|
279
|
+
if res.t_signal_ns is None:
|
|
280
|
+
first = next((i // res.channels for i, (v,) in enumerate(struct.iter_unpack("<h", data))
|
|
281
|
+
if v != 0), None)
|
|
282
|
+
res.leading_silent_frames += frames if first is None else first
|
|
283
|
+
if first is not None:
|
|
284
|
+
res.t_signal_ns = t_pcm
|
|
285
|
+
res.t_audible_ns = max(t_pcm, playback_end_ns) + round(first / res.sample_rate * 1e9)
|
|
286
|
+
playback_end_ns = max(t_pcm, playback_end_ns) + round(frames / res.sample_rate * 1e9)
|
|
287
|
+
res.chunks.append((t_pcm, frames))
|
|
288
|
+
pcm.extend(data)
|
|
289
|
+
elif kind == "final":
|
|
290
|
+
if not metadata:
|
|
291
|
+
raise ValueError("final before metadata")
|
|
292
|
+
if type(doc.get("sample_count")) is not int or doc["sample_count"] <= 0:
|
|
293
|
+
raise ValueError("final sample_count must be a positive integer")
|
|
294
|
+
res.sample_count = doc["sample_count"]
|
|
295
|
+
if res.sample_count * 2 * res.channels != len(pcm):
|
|
296
|
+
raise ValueError("final sample_count does not match PCM")
|
|
297
|
+
expected = {"model": request.model, "seed": request.seed, **request.settings}
|
|
298
|
+
reported = doc.get("effective")
|
|
299
|
+
# Retain only requested fields. Unsolicited server metadata may
|
|
300
|
+
# contain internal endpoints, credentials or launch commands.
|
|
301
|
+
res.effective = ev.redact({key: reported[key] for key in expected if key in reported}) \
|
|
302
|
+
if isinstance(reported, dict) else None
|
|
303
|
+
if not isinstance(res.effective, dict) or canonical_json(res.effective) != canonical_json(expected):
|
|
304
|
+
raise ValueError("final effective settings missing or mismatched")
|
|
305
|
+
ended = True
|
|
306
|
+
else:
|
|
307
|
+
raise ValueError("unknown NDJSON event type")
|
|
308
|
+
if response.length not in (None, 0):
|
|
309
|
+
raise ValueError("truncated HTTP body")
|
|
310
|
+
res.t_done_ns = time.perf_counter_ns() # complete HTTP body, not merely final announcement
|
|
311
|
+
if not ended:
|
|
312
|
+
raise ValueError("missing final event")
|
|
313
|
+
if res.t_signal_ns is None:
|
|
314
|
+
raise ValueError("silent PCM output (all samples zero)")
|
|
315
|
+
res.pcm, res.ok = bytes(pcm), True
|
|
316
|
+
except (OSError, http.client.HTTPException, ValueError, binascii.Error) as exc:
|
|
317
|
+
# Parser errors are deliberately static; exception text may contain wire data or keys.
|
|
318
|
+
safe = "response exceeded request deadline" if expired.is_set() else \
|
|
319
|
+
str(exc) if type(exc) is ValueError else type(exc).__name__
|
|
320
|
+
if self.ep.api_key:
|
|
321
|
+
safe = safe.replace(self.ep.api_key, "<redacted>")
|
|
322
|
+
res.error = scrub(safe, self.ep)
|
|
323
|
+
finally:
|
|
324
|
+
watchdog.cancel()
|
|
325
|
+
conn.close()
|
|
326
|
+
res.t_end_ns = time.perf_counter_ns()
|
|
327
|
+
return res
|
|
328
|
+
|
|
329
|
+
def close(self):
|
|
330
|
+
return None
|