buzzkit 0.1.2__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 (33) hide show
  1. buzzkit-0.1.4/.cargo/config.toml +12 -0
  2. {buzzkit-0.1.2 → buzzkit-0.1.4}/.github/workflows/CI.yml +9 -0
  3. {buzzkit-0.1.2 → buzzkit-0.1.4}/.github/workflows/release.yml +24 -2
  4. {buzzkit-0.1.2 → buzzkit-0.1.4}/.gitignore +1 -0
  5. {buzzkit-0.1.2 → buzzkit-0.1.4}/Cargo.lock +37 -1
  6. {buzzkit-0.1.2 → buzzkit-0.1.4}/Cargo.toml +5 -2
  7. {buzzkit-0.1.2 → buzzkit-0.1.4}/PKG-INFO +30 -1
  8. {buzzkit-0.1.2 → buzzkit-0.1.4}/README.md +28 -0
  9. buzzkit-0.1.4/build-support/vendored-libopus.cmake +11 -0
  10. buzzkit-0.1.4/examples/analyze_audio_tap.py +218 -0
  11. buzzkit-0.1.4/examples/huddle_echo.py +58 -0
  12. buzzkit-0.1.4/examples/huddle_wire_recorder.py +123 -0
  13. {buzzkit-0.1.2 → buzzkit-0.1.4}/pyproject.toml +2 -1
  14. {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/__init__.py +39 -1
  15. {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/_native.pyi +53 -1
  16. {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/client.py +43 -3
  17. buzzkit-0.1.4/python/buzzkit/huddle.py +604 -0
  18. buzzkit-0.1.4/src/huddle.rs +509 -0
  19. {buzzkit-0.1.2 → buzzkit-0.1.4}/src/lib.rs +102 -2
  20. buzzkit-0.1.4/tests/test_huddle.py +298 -0
  21. buzzkit-0.1.4/tests/test_live_relay.py +106 -0
  22. {buzzkit-0.1.2 → buzzkit-0.1.4}/tests/test_signing.py +29 -0
  23. {buzzkit-0.1.2 → buzzkit-0.1.4}/LICENSE +0 -0
  24. {buzzkit-0.1.2 → buzzkit-0.1.4}/LICENSE-APACHE +0 -0
  25. {buzzkit-0.1.2 → buzzkit-0.1.4}/NOTICE +0 -0
  26. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/_shared.py +0 -0
  27. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/claim_invite.py +0 -0
  28. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/list_channels.py +0 -0
  29. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/offline_sign.py +0 -0
  30. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/send_message.py +0 -0
  31. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/set_profile.py +0 -0
  32. {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/subscribe.py +0 -0
  33. {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/py.typed +0 -0
@@ -0,0 +1,12 @@
1
+ # The `opus` crate (audiopus_sys) builds a vendored libopus whose CMakeLists
2
+ # declares a pre-3.5 minimum; CMake 4 removed compatibility with those and
3
+ # aborts configure. This env var is CMake's documented escape hatch and is a
4
+ # no-op on older CMake. Applies to local builds, CI, and source installs alike.
5
+ #
6
+ # Deliberately NOT set here: CMAKE_TOOLCHAIN_FILE. cmake-rs skips its own
7
+ # cross-compilation config whenever a toolchain file is present, which breaks
8
+ # cross builds (aarch64 wheels compiled host-arch objects). The lib64 pin in
9
+ # build-support/vendored-libopus.cmake is applied only where needed, by the
10
+ # release workflow (native RPM-family manylinux containers).
11
+ [env]
12
+ CMAKE_POLICY_VERSION_MINIMUM = "3.5"
@@ -29,11 +29,20 @@ jobs:
29
29
  - name: Clippy
30
30
  run: cargo clippy --all-targets -- -D warnings
31
31
 
32
+ - name: Rust tests
33
+ run: cargo test
34
+
32
35
  - name: Install (editable) with dev deps
33
36
  run: pip install -e '.[dev]'
34
37
 
35
38
  - name: Ruff
36
39
  run: ruff check python tests examples
37
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
+
38
47
  - name: Pytest
39
48
  run: pytest
@@ -21,7 +21,9 @@ jobs:
21
21
  platform:
22
22
  - { os: ubuntu-latest, target: x86_64 }
23
23
  - { os: ubuntu-latest, target: aarch64 }
24
- - { os: macos-13, target: x86_64 }
24
+ # Intel wheel is cross-compiled from the ARM runner: GitHub retired
25
+ # the macos-13 (Intel) runners — the label queues forever.
26
+ - { os: macos-14, target: x86_64 }
25
27
  - { os: macos-14, target: aarch64 }
26
28
  - { os: windows-latest, target: x64 }
27
29
  steps:
@@ -34,7 +36,23 @@ jobs:
34
36
  with:
35
37
  target: ${{ matrix.platform.target }}
36
38
  args: --release --out dist
37
- manylinux: auto
39
+ # 2_28 (not auto): its cross toolchains are recent enough to compile
40
+ # the vendored libopus NEON intrinsics (manylinux2014's aarch64 gcc
41
+ # chokes on them).
42
+ manylinux: 2_28
43
+ # Static libopus so the wheel is self-contained; cmake for the
44
+ # vendored build when the container doesn't ship it. On RPM-family
45
+ # containers (native x86_64 manylinux) GNUInstallDirs installs to
46
+ # lib64 where audiopus_sys never looks — pin libdir via a toolchain
47
+ # file there. Never set it for the cross containers (Ubuntu-family,
48
+ # libdir is already "lib"): cmake-rs skips its own cross config when
49
+ # a toolchain file is present, producing host-arch objects.
50
+ before-script-linux: |
51
+ export LIBOPUS_STATIC=1
52
+ command -v cmake || yum install -y cmake || dnf install -y cmake || (apt-get update && apt-get install -y cmake)
53
+ if [ -f /etc/redhat-release ] || [ -f /etc/almalinux-release ]; then
54
+ export CMAKE_TOOLCHAIN_FILE="$(pwd)/build-support/vendored-libopus.cmake"
55
+ fi
38
56
  - uses: actions/upload-artifact@v4
39
57
  with:
40
58
  name: wheels-${{ matrix.platform.os }}-${{ matrix.platform.target }}
@@ -69,3 +87,7 @@ jobs:
69
87
  merge-multiple: true
70
88
  path: dist
71
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 }}
@@ -8,3 +8,4 @@ __pycache__/
8
8
  agent.secret
9
9
  /dist
10
10
  owner.secret
11
+ /tasks
@@ -27,6 +27,17 @@ version = "0.7.8"
27
27
  source = "registry+https://github.com/rust-lang/crates.io-index"
28
28
  checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
29
29
 
30
+ [[package]]
31
+ name = "audiopus_sys"
32
+ version = "0.2.2"
33
+ source = "registry+https://github.com/rust-lang/crates.io-index"
34
+ checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
35
+ dependencies = [
36
+ "cmake",
37
+ "log",
38
+ "pkg-config",
39
+ ]
40
+
30
41
  [[package]]
31
42
  name = "autocfg"
32
43
  version = "1.5.1"
@@ -168,13 +179,14 @@ dependencies = [
168
179
 
169
180
  [[package]]
170
181
  name = "buzzkit"
171
- version = "0.1.2"
182
+ version = "0.1.4"
172
183
  dependencies = [
173
184
  "base64",
174
185
  "buzz-core",
175
186
  "buzz-sdk",
176
187
  "hex",
177
188
  "nostr",
189
+ "opus",
178
190
  "pyo3",
179
191
  "sha2 0.10.9",
180
192
  "uuid",
@@ -265,6 +277,15 @@ dependencies = [
265
277
  "zeroize",
266
278
  ]
267
279
 
280
+ [[package]]
281
+ name = "cmake"
282
+ version = "0.1.58"
283
+ source = "registry+https://github.com/rust-lang/crates.io-index"
284
+ checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
285
+ dependencies = [
286
+ "cc",
287
+ ]
288
+
268
289
  [[package]]
269
290
  name = "cmov"
270
291
  version = "0.5.4"
@@ -748,6 +769,15 @@ version = "0.3.1"
748
769
  source = "registry+https://github.com/rust-lang/crates.io-index"
749
770
  checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
750
771
 
772
+ [[package]]
773
+ name = "opus"
774
+ version = "0.3.1"
775
+ source = "registry+https://github.com/rust-lang/crates.io-index"
776
+ checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c"
777
+ dependencies = [
778
+ "audiopus_sys",
779
+ ]
780
+
751
781
  [[package]]
752
782
  name = "password-hash"
753
783
  version = "0.5.0"
@@ -781,6 +811,12 @@ version = "0.2.17"
781
811
  source = "registry+https://github.com/rust-lang/crates.io-index"
782
812
  checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
783
813
 
814
+ [[package]]
815
+ name = "pkg-config"
816
+ version = "0.3.33"
817
+ source = "registry+https://github.com/rust-lang/crates.io-index"
818
+ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
819
+
784
820
  [[package]]
785
821
  name = "poly1305"
786
822
  version = "0.8.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buzzkit"
3
- version = "0.1.2"
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)"
@@ -13,7 +13,9 @@ name = "_native"
13
13
  crate-type = ["cdylib"]
14
14
 
15
15
  [dependencies]
16
- pyo3 = { version = "0.24", features = ["extension-module", "abi3-py312"] }
16
+ # extension-module is enabled by maturin via pyproject.toml, not here, so
17
+ # plain `cargo build` / `cargo test` can link against libpython.
18
+ pyo3 = { version = "0.24", features = ["abi3-py312"] }
17
19
  # Must match the `nostr` version buzz-core/buzz-sdk resolve to so the
18
20
  # Event/Keys/EventBuilder types unify across crate boundaries.
19
21
  nostr = { version = "0.44", features = ["nip44", "nip98"] }
@@ -24,3 +26,4 @@ uuid = { version = "1", features = ["v4"] }
24
26
  sha2 = "0.10"
25
27
  hex = "0.4"
26
28
  base64 = "0.22"
29
+ opus = "0.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buzzkit
3
- Version: 0.1.2
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
@@ -86,6 +87,32 @@ async def main():
86
87
  asyncio.run(main())
87
88
  ```
88
89
 
90
+ ### Huddle audio (voice)
91
+
92
+ Buzz huddles are ephemeral voice channels; audio is Opus (48 kHz mono, 20 ms
93
+ frames) over a dedicated WebSocket. `HuddleClient` handles the handshake,
94
+ Opus encode/decode (in Rust), and real-time outbound pacing — you deal in raw
95
+ PCM (s16le mono 48 kHz):
96
+
97
+ ```python
98
+ from buzzkit import BuzzClient, HuddleAudio, HuddleClient
99
+
100
+ # Huddles announce themselves as kind 48100 on their parent channel:
101
+ async with BuzzClient(relay_url, nsec) as bz:
102
+ async for ev in bz.subscribe_channel(parent_id, kinds=[buzzkit.KIND_HUDDLE_STARTED]):
103
+ huddle_id = json.loads(ev["content"])["ephemeral_channel_id"]
104
+ break
105
+
106
+ async with HuddleClient(relay_url, nsec, huddle_id, parent_channel_id=parent_id) as h:
107
+ h.send_pcm(pcm_s16le_48k) # queued, paced at 50 frames/s
108
+ async for ev in h.events():
109
+ if isinstance(ev, HuddleAudio): # decoded remote audio
110
+ print(ev.pubkey, len(ev.pcm))
111
+ ```
112
+
113
+ Being a member of the parent channel is enough — the relay auto-adds you to
114
+ the ephemeral huddle when `parent_channel_id` is given.
115
+
89
116
  ## Joining a community (relay onboarding)
90
117
 
91
118
  Hosted Buzz communities are **closed relays**: an identity must be a relay member
@@ -114,6 +141,8 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
114
141
  | `verify_event(json)` | check id + Schnorr signature |
115
142
  | `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
116
143
  | `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
144
+ | `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
145
+ | `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |
117
146
 
118
147
  ## Build from source
119
148
 
@@ -53,6 +53,32 @@ async def main():
53
53
  asyncio.run(main())
54
54
  ```
55
55
 
56
+ ### Huddle audio (voice)
57
+
58
+ Buzz huddles are ephemeral voice channels; audio is Opus (48 kHz mono, 20 ms
59
+ frames) over a dedicated WebSocket. `HuddleClient` handles the handshake,
60
+ Opus encode/decode (in Rust), and real-time outbound pacing — you deal in raw
61
+ PCM (s16le mono 48 kHz):
62
+
63
+ ```python
64
+ from buzzkit import BuzzClient, HuddleAudio, HuddleClient
65
+
66
+ # Huddles announce themselves as kind 48100 on their parent channel:
67
+ async with BuzzClient(relay_url, nsec) as bz:
68
+ async for ev in bz.subscribe_channel(parent_id, kinds=[buzzkit.KIND_HUDDLE_STARTED]):
69
+ huddle_id = json.loads(ev["content"])["ephemeral_channel_id"]
70
+ break
71
+
72
+ async with HuddleClient(relay_url, nsec, huddle_id, parent_channel_id=parent_id) as h:
73
+ h.send_pcm(pcm_s16le_48k) # queued, paced at 50 frames/s
74
+ async for ev in h.events():
75
+ if isinstance(ev, HuddleAudio): # decoded remote audio
76
+ print(ev.pubkey, len(ev.pcm))
77
+ ```
78
+
79
+ Being a member of the parent channel is enough — the relay auto-adds you to
80
+ the ephemeral huddle when `parent_channel_id` is given.
81
+
56
82
  ## Joining a community (relay onboarding)
57
83
 
58
84
  Hosted Buzz communities are **closed relays**: an identity must be a relay member
@@ -81,6 +107,8 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
81
107
  | `verify_event(json)` | check id + Schnorr signature |
82
108
  | `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
83
109
  | `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
110
+ | `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
111
+ | `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |
84
112
 
85
113
  ## Build from source
86
114
 
@@ -0,0 +1,11 @@
1
+ # Toolchain file for building the vendored libopus (audiopus_sys) via cmake-rs.
2
+ #
3
+ # audiopus_sys's build script adds only `<out>/lib` to the linker search path,
4
+ # but GNUInstallDirs installs to `lib64` on RPM-family hosts (manylinux,
5
+ # Fedora, RHEL) — the link then fails with `unable to find library -lopus`.
6
+ # Pinning the classic layout keeps the library where the build script looks.
7
+ #
8
+ # Applied by the release workflow ONLY on native RPM-family containers.
9
+ # Do not export CMAKE_TOOLCHAIN_FILE globally: cmake-rs skips its own
10
+ # cross-compilation config when a toolchain file is present.
11
+ set(CMAKE_INSTALL_LIBDIR "lib" CACHE PATH "installation libdir pinned for audiopus_sys" FORCE)
@@ -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,58 @@
1
+ """Echo agent for Buzz huddles — repeats back everything it hears.
2
+
3
+ Joins a huddle and echoes remote audio with a short delay. Useful for
4
+ verifying the audio path end-to-end (desktop → relay → agent → relay →
5
+ desktop) without any AI provider.
6
+
7
+ BUZZ_RELAY_URL=wss://... python examples/huddle_echo.py <parent_channel_id>
8
+
9
+ Watches the parent channel for huddle announcements (kind 48100) and joins
10
+ each one; pass a second argument to join a known huddle directly:
11
+
12
+ python examples/huddle_echo.py <parent_channel_id> <huddle_channel_id>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import sys
20
+ import time
21
+
22
+ from _shared import agent_secret, relay_url
23
+ from buzzkit import KIND_HUDDLE_STARTED, BuzzClient, HuddleAudio, HuddleClient
24
+
25
+
26
+ async def echo_in_huddle(relay: str, secret: str, huddle_id: str, parent_id: str) -> None:
27
+ async with HuddleClient(relay, secret, huddle_id, parent_channel_id=parent_id) as huddle:
28
+ print(f"joined huddle {huddle_id} — echoing (peers: {len(huddle.peers) - 1})")
29
+ async for ev in huddle.events():
30
+ if isinstance(ev, HuddleAudio):
31
+ huddle.send_pcm(ev.pcm)
32
+ else:
33
+ print(f" {type(ev).__name__}: {ev.pubkey[:12]}…")
34
+ print(f"huddle {huddle_id} ended")
35
+
36
+
37
+ async def main() -> None:
38
+ if len(sys.argv) < 2:
39
+ raise SystemExit("usage: huddle_echo.py <parent_channel_id> [huddle_channel_id]")
40
+ parent_id = sys.argv[1]
41
+ relay, secret = relay_url(), agent_secret()
42
+
43
+ if len(sys.argv) > 2:
44
+ await echo_in_huddle(relay, secret, sys.argv[2], parent_id)
45
+ return
46
+
47
+ print(f"watching {parent_id} for huddles…")
48
+ async with BuzzClient(relay, secret) as bz:
49
+ live = {"kinds": [KIND_HUDDLE_STARTED], "#h": [parent_id], "since": int(time.time())}
50
+ async for event in bz.subscribe([live]):
51
+ huddle_id = json.loads(event["content"]).get("ephemeral_channel_id")
52
+ if huddle_id:
53
+ await echo_in_huddle(relay, secret, huddle_id, parent_id)
54
+ print(f"watching {parent_id} for the next huddle…")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ asyncio.run(main())