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.
- buzzkit-0.1.4/.cargo/config.toml +12 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/.github/workflows/CI.yml +9 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/.github/workflows/release.yml +24 -2
- {buzzkit-0.1.2 → buzzkit-0.1.4}/.gitignore +1 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/Cargo.lock +37 -1
- {buzzkit-0.1.2 → buzzkit-0.1.4}/Cargo.toml +5 -2
- {buzzkit-0.1.2 → buzzkit-0.1.4}/PKG-INFO +30 -1
- {buzzkit-0.1.2 → buzzkit-0.1.4}/README.md +28 -0
- buzzkit-0.1.4/build-support/vendored-libopus.cmake +11 -0
- buzzkit-0.1.4/examples/analyze_audio_tap.py +218 -0
- buzzkit-0.1.4/examples/huddle_echo.py +58 -0
- buzzkit-0.1.4/examples/huddle_wire_recorder.py +123 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/pyproject.toml +2 -1
- {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/__init__.py +39 -1
- {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/_native.pyi +53 -1
- {buzzkit-0.1.2 → buzzkit-0.1.4}/python/buzzkit/client.py +43 -3
- buzzkit-0.1.4/python/buzzkit/huddle.py +604 -0
- buzzkit-0.1.4/src/huddle.rs +509 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/src/lib.rs +102 -2
- buzzkit-0.1.4/tests/test_huddle.py +298 -0
- buzzkit-0.1.4/tests/test_live_relay.py +106 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/tests/test_signing.py +29 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/LICENSE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/LICENSE-APACHE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/NOTICE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/_shared.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/claim_invite.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/list_channels.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/offline_sign.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/send_message.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/set_profile.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.4}/examples/subscribe.py +0 -0
- {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
|
-
|
|
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
|
-
|
|
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 }}
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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())
|