buzzkit 0.1.3__tar.gz → 0.1.4__tar.gz

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.
Files changed (34) hide show
  1. {buzzkit-0.1.3 → buzzkit-0.1.4}/.github/workflows/CI.yml +6 -0
  2. {buzzkit-0.1.3 → buzzkit-0.1.4}/.github/workflows/release.yml +4 -0
  3. {buzzkit-0.1.3 → buzzkit-0.1.4}/Cargo.lock +1 -1
  4. {buzzkit-0.1.3 → buzzkit-0.1.4}/Cargo.toml +1 -1
  5. {buzzkit-0.1.3 → buzzkit-0.1.4}/PKG-INFO +2 -1
  6. buzzkit-0.1.4/examples/analyze_audio_tap.py +218 -0
  7. buzzkit-0.1.4/examples/huddle_wire_recorder.py +123 -0
  8. {buzzkit-0.1.3 → buzzkit-0.1.4}/pyproject.toml +2 -1
  9. {buzzkit-0.1.3 → buzzkit-0.1.4}/python/buzzkit/client.py +1 -3
  10. buzzkit-0.1.4/python/buzzkit/huddle.py +604 -0
  11. {buzzkit-0.1.3 → buzzkit-0.1.4}/src/huddle.rs +20 -8
  12. {buzzkit-0.1.3 → buzzkit-0.1.4}/tests/test_huddle.py +76 -16
  13. {buzzkit-0.1.3 → buzzkit-0.1.4}/tests/test_live_relay.py +3 -9
  14. buzzkit-0.1.3/python/buzzkit/huddle.py +0 -368
  15. {buzzkit-0.1.3 → buzzkit-0.1.4}/.cargo/config.toml +0 -0
  16. {buzzkit-0.1.3 → buzzkit-0.1.4}/.gitignore +0 -0
  17. {buzzkit-0.1.3 → buzzkit-0.1.4}/LICENSE +0 -0
  18. {buzzkit-0.1.3 → buzzkit-0.1.4}/LICENSE-APACHE +0 -0
  19. {buzzkit-0.1.3 → buzzkit-0.1.4}/NOTICE +0 -0
  20. {buzzkit-0.1.3 → buzzkit-0.1.4}/README.md +0 -0
  21. {buzzkit-0.1.3 → buzzkit-0.1.4}/build-support/vendored-libopus.cmake +0 -0
  22. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/_shared.py +0 -0
  23. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/claim_invite.py +0 -0
  24. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/huddle_echo.py +0 -0
  25. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/list_channels.py +0 -0
  26. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/offline_sign.py +0 -0
  27. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/send_message.py +0 -0
  28. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/set_profile.py +0 -0
  29. {buzzkit-0.1.3 → buzzkit-0.1.4}/examples/subscribe.py +0 -0
  30. {buzzkit-0.1.3 → buzzkit-0.1.4}/python/buzzkit/__init__.py +0 -0
  31. {buzzkit-0.1.3 → buzzkit-0.1.4}/python/buzzkit/_native.pyi +0 -0
  32. {buzzkit-0.1.3 → buzzkit-0.1.4}/python/buzzkit/py.typed +0 -0
  33. {buzzkit-0.1.3 → buzzkit-0.1.4}/src/lib.rs +0 -0
  34. {buzzkit-0.1.3 → buzzkit-0.1.4}/tests/test_signing.py +0 -0
@@ -38,5 +38,11 @@ jobs:
38
38
  - name: Ruff
39
39
  run: ruff check python tests examples
40
40
 
41
+ - name: Ruff format
42
+ run: ruff format --check python tests examples
43
+
44
+ - name: Type check (ty)
45
+ run: ty check python
46
+
41
47
  - name: Pytest
42
48
  run: pytest
@@ -87,3 +87,7 @@ jobs:
87
87
  merge-multiple: true
88
88
  path: dist
89
89
  - uses: pypa/gh-action-pypi-publish@release/v1
90
+ with:
91
+ # API token (PYPI_API_TOKEN secret). Falls back to trusted
92
+ # publishing (OIDC) if the secret is ever removed.
93
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -179,7 +179,7 @@ dependencies = [
179
179
 
180
180
  [[package]]
181
181
  name = "buzzkit"
182
- version = "0.1.3"
182
+ version = "0.1.4"
183
183
  dependencies = [
184
184
  "base64",
185
185
  "buzz-core",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buzzkit"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  edition = "2021"
5
5
  publish = false
6
6
  description = "PyO3 bindings over Block's Buzz zero-I/O crates (buzz-core / buzz-sdk)"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buzzkit
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -16,6 +16,7 @@ Requires-Dist: websockets>=13
16
16
  Requires-Dist: pytest>=8 ; extra == 'dev'
17
17
  Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
18
18
  Requires-Dist: ruff>=0.6 ; extra == 'dev'
19
+ Requires-Dist: ty>=0.0.60 ; extra == 'dev'
19
20
  Requires-Dist: maturin>=1.7,<2 ; extra == 'dev'
20
21
  Provides-Extra: dev
21
22
  License-File: LICENSE
@@ -0,0 +1,218 @@
1
+ """Analyze audio tap captures and point at the failing link.
2
+
3
+ Reads a capture directory produced by either (or both):
4
+ * roomkit's ``examples/buzz_voice_agent.py`` run with ``BUZZ_TAP_DIR=...``
5
+ (sender-side taps: provider_out_24k.raw, pacer_in_48k.raw,
6
+ wire_out_48k.raw, wire_send.csv, events.csv), or
7
+ * ``examples/huddle_wire_recorder.py`` (receiver-side: recv_frames.csv,
8
+ peer<N>_48k.raw).
9
+
10
+ and reports, per stage: send/arrival timing (stalls, bursts), silence frames
11
+ spliced into speech, wire sequence gaps, and audible holes inside the PCM.
12
+
13
+ python examples/analyze_audio_tap.py <capture_dir>
14
+
15
+ Reading the verdict:
16
+ * holes in wire_out_48k.raw but not in pacer_in_48k.raw
17
+ -> the pacer inserted silence / dropped audio (sender side).
18
+ * clean wire_out but recv_frames.csv shows seq gaps or heavy arrival jitter
19
+ -> frames lost or bunched between agent and relay fan-out.
20
+ * clean + smooth arrivals at the recorder, but the app still sounds choppy
21
+ -> receiver side (NetEq/playout in the desktop app).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import pathlib
28
+ import sys
29
+
30
+ FRAME_MS = 20.0
31
+ NS = 1_000_000 # ns per ms
32
+
33
+
34
+ def fmt_ts(ms: float) -> str:
35
+ return f"{int(ms // 60000):02d}:{ms % 60000 / 1000:06.3f}"
36
+
37
+
38
+ # ---------------------------------------------------------------- timing CSVs
39
+
40
+
41
+ def analyze_send_timing(rows: list[dict], t_key: str, label: str) -> None:
42
+ """Inter-send/arrival timing: stalls (>2x frame) and bursts (<2 ms)."""
43
+ times = [int(r[t_key]) for r in rows]
44
+ if len(times) < 2:
45
+ print(f" {label}: not enough rows")
46
+ return
47
+ deltas = [(b - a) / NS for a, b in zip(times, times[1:])]
48
+ span_s = (times[-1] - times[0]) / NS / 1000
49
+ stalls = [(i, d) for i, d in enumerate(deltas) if d > 2 * FRAME_MS]
50
+ bursts = sum(1 for d in deltas if d < 2.0)
51
+ print(
52
+ f" {label}: {len(times)} frames over {span_s:.1f}s "
53
+ f"(mean {sum(deltas) / len(deltas):.1f} ms/frame, max gap {max(deltas):.0f} ms)"
54
+ )
55
+ print(f" gaps >{2 * FRAME_MS:.0f} ms: {len(stalls)} sends <2 ms apart (bursts): {bursts}")
56
+ t0 = times[0]
57
+ for i, d in stalls[:10]:
58
+ print(f" gap {d:6.0f} ms at t+{fmt_ts((times[i] - t0) / NS)}")
59
+ if len(stalls) > 10:
60
+ print(f" … and {len(stalls) - 10} more")
61
+
62
+
63
+ def analyze_wire_csv(path: pathlib.Path) -> None:
64
+ rows = list(csv.DictReader(path.open()))
65
+ if not rows:
66
+ print(" wire_send.csv: empty")
67
+ return
68
+ print("wire_send.csv — pacer output timing (what buzzkit is asked to send):")
69
+ analyze_send_timing(rows, "t_mono_ns", "pacer->send_pcm")
70
+
71
+ # Silence frames spliced into speech: an all-zero send whose neighbours
72
+ # within 300 ms on both sides contain non-zero audio.
73
+ times = [int(r["t_mono_ns"]) for r in rows]
74
+ zero = [r["all_zero"] == "1" for r in rows]
75
+ t0 = times[0]
76
+ nonzero_times = [t for t, z in zip(times, zero) if not z]
77
+ spliced = []
78
+ for t, z in zip(times, zero):
79
+ if not z or not nonzero_times:
80
+ continue
81
+ # nearest real-audio sends before/after this silence frame
82
+ before = any(0 < t - u <= 300 * NS for u in nonzero_times)
83
+ after = any(0 < u - t <= 300 * NS for u in nonzero_times)
84
+ if before and after:
85
+ spliced.append(t)
86
+ n_zero = sum(zero)
87
+ print(f" all-zero frames: {n_zero} total, {len(spliced)} spliced mid-speech")
88
+ for t in spliced[:10]:
89
+ print(f" silence splice at t+{fmt_ts((t - t0) / NS)}")
90
+ if len(spliced) > 10:
91
+ print(f" … and {len(spliced) - 10} more")
92
+
93
+
94
+ def analyze_recv_csv(path: pathlib.Path) -> None:
95
+ rows = list(csv.DictReader(path.open()))
96
+ if not rows:
97
+ print(" recv_frames.csv: empty")
98
+ return
99
+ print("recv_frames.csv — wire arrivals at the recorder (per peer):")
100
+ peers: dict[str, list[dict]] = {}
101
+ for r in rows:
102
+ peers.setdefault(r["peer_index"], []).append(r)
103
+ for peer, frames in sorted(peers.items()):
104
+ speech = [f for f in frames if f["is_dtx"] == "0"]
105
+ dtx = len(frames) - len(speech)
106
+ print(f" peer {peer}: {len(speech)} speech frames, {dtx} DTX")
107
+ if len(speech) < 2:
108
+ continue
109
+ analyze_send_timing(speech, "t_mono_ns", f"peer {peer} arrivals")
110
+ # Wire continuity over ALL frames (DTX included — every wire packet
111
+ # consumes one seq and 960 ts): dseq != 1 means frames were lost or
112
+ # reordered between the sender and us; dts != dseq*960 means the
113
+ # sender's own media timeline jumped (encoder restart / bug).
114
+ lost = jumps = 0
115
+ for a, b in zip(frames, frames[1:]):
116
+ dseq = (int(b["seq"]) - int(a["seq"])) % 65536
117
+ dts = (int(b["ts_48k"]) - int(a["ts_48k"])) % (1 << 32)
118
+ if dseq != 1:
119
+ lost += 1
120
+ if lost <= 10:
121
+ print(f" lost/reordered: seq {a['seq']}->{b['seq']} ({dseq - 1} missing)")
122
+ elif dts != 960:
123
+ jumps += 1
124
+ if jumps <= 10:
125
+ print(f" sender ts jump at seq {b['seq']}: +{dts} (expected +960)")
126
+ print(f" lost/reordered frames: {lost} sender ts jumps: {jumps}")
127
+
128
+
129
+ # ------------------------------------------------------------------ PCM scans
130
+
131
+
132
+ def analyze_raw(path: pathlib.Path, rate: int) -> None:
133
+ """Find near-silent runs >=15 ms that sit inside speech (audible holes)."""
134
+ data = path.read_bytes()
135
+ samples = memoryview(data).cast("h")
136
+ n = len(samples)
137
+ if n == 0:
138
+ print(f" {path.name}: empty")
139
+ return
140
+ ms = 1000.0 / rate
141
+ win = rate // 100 # 10 ms windows
142
+ # Classify 10 ms windows as silent/speech, then find silent runs
143
+ # bordered by speech within 200 ms on each side.
144
+ n_win = n // win
145
+ loud = []
146
+ for w in range(n_win):
147
+ seg = samples[w * win : (w + 1) * win]
148
+ peak = max(abs(min(seg)), abs(max(seg)))
149
+ loud.append(peak > 400)
150
+ holes: list[tuple[int, int]] = []
151
+ w = 0
152
+ while w < n_win:
153
+ if loud[w]:
154
+ w += 1
155
+ continue
156
+ start = w
157
+ while w < n_win and not loud[w]:
158
+ w += 1
159
+ run_ms = (w - start) * 10
160
+ ctx = 20 # 200 ms of context windows
161
+ speech_before = any(loud[max(0, start - ctx) : start])
162
+ speech_after = any(loud[w : w + ctx])
163
+ if run_ms >= 15 and run_ms <= 2000 and speech_before and speech_after:
164
+ holes.append((start, run_ms))
165
+ dur_s = n * ms / 1000
166
+ print(f" {path.name}: {dur_s:.1f}s of audio, {len(holes)} hole(s) inside speech")
167
+ for start, run_ms in holes[:15]:
168
+ print(f" {run_ms:4d} ms hole at {fmt_ts(start * 10.0)}")
169
+ if len(holes) > 15:
170
+ print(f" … and {len(holes) - 15} more")
171
+
172
+
173
+ RAW_RATES = {"24k": 24_000, "48k": 48_000, "16k": 16_000}
174
+
175
+
176
+ def main() -> None:
177
+ if len(sys.argv) != 2:
178
+ raise SystemExit("usage: analyze_audio_tap.py <capture_dir>")
179
+ cap = pathlib.Path(sys.argv[1])
180
+ if not cap.is_dir():
181
+ raise SystemExit(f"not a directory: {cap}")
182
+
183
+ found = False
184
+ wire_csv = cap / "wire_send.csv"
185
+ if wire_csv.exists():
186
+ found = True
187
+ analyze_wire_csv(wire_csv)
188
+ print()
189
+ recv_csv = cap / "recv_frames.csv"
190
+ if recv_csv.exists():
191
+ found = True
192
+ analyze_recv_csv(recv_csv)
193
+ print()
194
+ events_csv = cap / "events.csv"
195
+ if events_csv.exists():
196
+ rows = list(csv.DictReader(events_csv.open()))
197
+ interesting = [r for r in rows if r.get("event") not in (None, "")]
198
+ if interesting:
199
+ print(f"events.csv: {len(interesting)} event(s)")
200
+ for r in interesting[:20]:
201
+ print(f" {r}")
202
+ print()
203
+ raws = sorted(cap.glob("*.raw"))
204
+ if raws:
205
+ found = True
206
+ print("PCM hole scan (near-silence >=15 ms surrounded by speech):")
207
+ for raw in raws:
208
+ rate = next(
209
+ (hz for tag, hz in RAW_RATES.items() if tag in raw.name),
210
+ 48_000,
211
+ )
212
+ analyze_raw(raw, rate)
213
+ if not found:
214
+ raise SystemExit(f"no tap files found in {cap}")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,123 @@
1
+ """Wire-side recorder for Buzz huddles — capture exactly what receivers get.
2
+
3
+ Joins a huddle as a silent peer (sends nothing) and records every inbound
4
+ audio frame with its arrival time and wire header. This is the ground truth
5
+ for "what does the desktop app's jitter buffer actually see": if the audio
6
+ is already choppy here, the sender (e.g. the roomkit agent) is at fault; if
7
+ it is clean here but choppy in the app, the receiver side is at fault.
8
+
9
+ Watch mode (recommended): give it only the parent channel — the one in your
10
+ BUZZ_CHANNEL_ID — and it records every huddle announced there (kind 48100),
11
+ one output directory per huddle:
12
+
13
+ BUZZ_RELAY_URL=wss://... python examples/huddle_wire_recorder.py \
14
+ <parent_channel_id>
15
+
16
+ Direct mode, when you already know the ephemeral huddle id (the agent logs
17
+ it as "Joined huddle <uuid>"):
18
+
19
+ python examples/huddle_wire_recorder.py <parent_channel_id> \
20
+ <huddle_channel_id> [out_dir]
21
+
22
+ Output (out_dir, default ./wire-tap-<huddle>):
23
+ recv_frames.csv one row per wire frame:
24
+ t_mono_ns,peer_index,seq,ts_48k,level_dbov,is_dtx,pcm_bytes
25
+ events.csv roster events: t_mono_ns,event,pubkey
26
+ peer<N>_48k.raw decoded s16le mono 48 kHz PCM per peer (non-DTX frames,
27
+ concatenated in arrival order — listen with:
28
+ ffplay -f s16le -ar 48000 -ch_layout mono peer<N>_48k.raw)
29
+
30
+ Analyze the capture with examples/analyze_audio_tap.py.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import pathlib
38
+ import sys
39
+ import time
40
+
41
+ from _shared import agent_secret, relay_url
42
+ from buzzkit import KIND_HUDDLE_STARTED, BuzzClient, HuddleAudio, HuddleClient
43
+
44
+
45
+ async def record(
46
+ relay: str, secret: str, huddle_id: str, parent_id: str, out: pathlib.Path
47
+ ) -> None:
48
+ out.mkdir(parents=True, exist_ok=True)
49
+ frames_csv = (out / "recv_frames.csv").open("w")
50
+ frames_csv.write("t_mono_ns,peer_index,seq,ts_48k,level_dbov,is_dtx,pcm_bytes\n")
51
+ events_csv = (out / "events.csv").open("w")
52
+ events_csv.write("t_mono_ns,event,pubkey\n")
53
+ pcm_files: dict[int, object] = {}
54
+ n_frames = 0
55
+
56
+ # paced=False: the client never sends anything on its own (no silence
57
+ # stream), so this peer is acoustically invisible in the huddle.
58
+ client = HuddleClient(relay, secret, huddle_id, parent_channel_id=parent_id, paced=False)
59
+ async with client:
60
+ print(f"recording huddle {huddle_id} -> {out} (Ctrl+C to stop)")
61
+ try:
62
+ async for ev in client.events():
63
+ now = time.monotonic_ns()
64
+ if isinstance(ev, HuddleAudio):
65
+ frames_csv.write(
66
+ f"{now},{ev.peer_index},{ev.seq},{ev.ts_48k},"
67
+ f"{ev.level_dbov},{int(ev.is_dtx)},{len(ev.pcm)}\n"
68
+ )
69
+ if not ev.is_dtx:
70
+ f = pcm_files.get(ev.peer_index)
71
+ if f is None:
72
+ f = (out / f"peer{ev.peer_index}_48k.raw").open("wb")
73
+ pcm_files[ev.peer_index] = f
74
+ f.write(ev.pcm)
75
+ n_frames += 1
76
+ if n_frames % 500 == 0:
77
+ frames_csv.flush()
78
+ print(f" {n_frames} frames…")
79
+ else:
80
+ events_csv.write(f"{now},{type(ev).__name__},{ev.pubkey}\n")
81
+ events_csv.flush()
82
+ finally:
83
+ frames_csv.close()
84
+ events_csv.close()
85
+ for f in pcm_files.values():
86
+ f.close()
87
+ print(f"done: {n_frames} frames from {len(pcm_files)} speaking peer(s)")
88
+ print(f"analyze with: python examples/analyze_audio_tap.py {out}")
89
+
90
+
91
+ async def watch_and_record(relay: str, secret: str, parent_id: str) -> None:
92
+ """Record every huddle announced on the parent channel from now on."""
93
+ print(f"watching {parent_id} for huddles (kind {KIND_HUDDLE_STARTED})…")
94
+ async with BuzzClient(relay, secret) as bz:
95
+ live = {"kinds": [KIND_HUDDLE_STARTED], "#h": [parent_id], "since": int(time.time())}
96
+ async for event in bz.subscribe([live]):
97
+ huddle_id = json.loads(event["content"]).get("ephemeral_channel_id")
98
+ if not huddle_id:
99
+ continue
100
+ out = pathlib.Path(f"wire-tap-{huddle_id[:8]}")
101
+ await record(relay, secret, huddle_id, parent_id, out)
102
+ print(f"watching {parent_id} for the next huddle…")
103
+
104
+
105
+ async def main() -> None:
106
+ if len(sys.argv) < 2:
107
+ raise SystemExit(
108
+ "usage: huddle_wire_recorder.py <parent_channel_id> [huddle_channel_id] [out_dir]"
109
+ )
110
+ parent_id = sys.argv[1]
111
+ if len(sys.argv) < 3:
112
+ await watch_and_record(relay_url(), agent_secret(), parent_id)
113
+ return
114
+ huddle_id = sys.argv[2]
115
+ out = pathlib.Path(sys.argv[3] if len(sys.argv) > 3 else f"wire-tap-{huddle_id[:8]}")
116
+ await record(relay_url(), agent_secret(), huddle_id, parent_id, out)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ try:
121
+ asyncio.run(main())
122
+ except KeyboardInterrupt:
123
+ pass
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "buzzkit"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Python bindings + async client for Block's Buzz (Nostr) protocol, backed by Rust buzz-core/buzz-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -34,6 +34,7 @@ dev = [
34
34
  "pytest>=8",
35
35
  "pytest-asyncio>=0.24",
36
36
  "ruff>=0.6",
37
+ "ty>=0.0.60",
37
38
  "maturin>=1.7,<2",
38
39
  ]
39
40
 
@@ -153,9 +153,7 @@ class BuzzClient:
153
153
  result = await self.publish(create_ev)
154
154
  if not result["accepted"]:
155
155
  raise RuntimeError(f"huddle channel rejected: {result['message']}")
156
- started_ev = _native.build_huddle_started_event(
157
- self._secret, parent_channel_id, huddle_id
158
- )
156
+ started_ev = _native.build_huddle_started_event(self._secret, parent_channel_id, huddle_id)
159
157
  result = await self.publish(started_ev)
160
158
  if not result["accepted"]:
161
159
  raise RuntimeError(f"huddle announcement rejected: {result['message']}")