buzzkit 0.1.2__tar.gz → 0.1.3__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.3/.cargo/config.toml +12 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/.github/workflows/CI.yml +3 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/.github/workflows/release.yml +20 -2
- {buzzkit-0.1.2 → buzzkit-0.1.3}/.gitignore +1 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/Cargo.lock +37 -1
- {buzzkit-0.1.2 → buzzkit-0.1.3}/Cargo.toml +5 -2
- {buzzkit-0.1.2 → buzzkit-0.1.3}/PKG-INFO +29 -1
- {buzzkit-0.1.2 → buzzkit-0.1.3}/README.md +28 -0
- buzzkit-0.1.3/build-support/vendored-libopus.cmake +11 -0
- buzzkit-0.1.3/examples/huddle_echo.py +58 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/pyproject.toml +1 -1
- {buzzkit-0.1.2 → buzzkit-0.1.3}/python/buzzkit/__init__.py +39 -1
- {buzzkit-0.1.2 → buzzkit-0.1.3}/python/buzzkit/_native.pyi +53 -1
- {buzzkit-0.1.2 → buzzkit-0.1.3}/python/buzzkit/client.py +45 -3
- buzzkit-0.1.3/python/buzzkit/huddle.py +368 -0
- buzzkit-0.1.3/src/huddle.rs +497 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/src/lib.rs +102 -2
- buzzkit-0.1.3/tests/test_huddle.py +238 -0
- buzzkit-0.1.3/tests/test_live_relay.py +112 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/tests/test_signing.py +29 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/LICENSE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/LICENSE-APACHE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/NOTICE +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/_shared.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/claim_invite.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/list_channels.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/offline_sign.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/send_message.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/set_profile.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/examples/subscribe.py +0 -0
- {buzzkit-0.1.2 → buzzkit-0.1.3}/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"
|
|
@@ -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 }}
|
|
@@ -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.3"
|
|
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.3"
|
|
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.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -86,6 +86,32 @@ async def main():
|
|
|
86
86
|
asyncio.run(main())
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Huddle audio (voice)
|
|
90
|
+
|
|
91
|
+
Buzz huddles are ephemeral voice channels; audio is Opus (48 kHz mono, 20 ms
|
|
92
|
+
frames) over a dedicated WebSocket. `HuddleClient` handles the handshake,
|
|
93
|
+
Opus encode/decode (in Rust), and real-time outbound pacing — you deal in raw
|
|
94
|
+
PCM (s16le mono 48 kHz):
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from buzzkit import BuzzClient, HuddleAudio, HuddleClient
|
|
98
|
+
|
|
99
|
+
# Huddles announce themselves as kind 48100 on their parent channel:
|
|
100
|
+
async with BuzzClient(relay_url, nsec) as bz:
|
|
101
|
+
async for ev in bz.subscribe_channel(parent_id, kinds=[buzzkit.KIND_HUDDLE_STARTED]):
|
|
102
|
+
huddle_id = json.loads(ev["content"])["ephemeral_channel_id"]
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
async with HuddleClient(relay_url, nsec, huddle_id, parent_channel_id=parent_id) as h:
|
|
106
|
+
h.send_pcm(pcm_s16le_48k) # queued, paced at 50 frames/s
|
|
107
|
+
async for ev in h.events():
|
|
108
|
+
if isinstance(ev, HuddleAudio): # decoded remote audio
|
|
109
|
+
print(ev.pubkey, len(ev.pcm))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Being a member of the parent channel is enough — the relay auto-adds you to
|
|
113
|
+
the ephemeral huddle when `parent_channel_id` is given.
|
|
114
|
+
|
|
89
115
|
## Joining a community (relay onboarding)
|
|
90
116
|
|
|
91
117
|
Hosted Buzz communities are **closed relays**: an identity must be a relay member
|
|
@@ -114,6 +140,8 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
|
|
|
114
140
|
| `verify_event(json)` | check id + Schnorr signature |
|
|
115
141
|
| `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
|
|
116
142
|
| `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
|
|
143
|
+
| `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
|
|
144
|
+
| `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |
|
|
117
145
|
|
|
118
146
|
## Build from source
|
|
119
147
|
|
|
@@ -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,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())
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "buzzkit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
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"
|
|
@@ -10,14 +10,26 @@ This is an independent, unofficial project — not affiliated with Block, Inc.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
from ._native import (
|
|
13
|
+
HUDDLE_FRAME_SAMPLES,
|
|
14
|
+
HUDDLE_PROTOCOL_VERSION,
|
|
15
|
+
HUDDLE_SAMPLE_RATE,
|
|
13
16
|
KIND_ADD_MEMBER,
|
|
14
17
|
KIND_AUTH,
|
|
18
|
+
KIND_CREATE_CHANNEL,
|
|
15
19
|
KIND_HTTP_AUTH,
|
|
20
|
+
KIND_HUDDLE_ENDED,
|
|
21
|
+
KIND_HUDDLE_PARTICIPANT_JOINED,
|
|
22
|
+
KIND_HUDDLE_PARTICIPANT_LEFT,
|
|
23
|
+
KIND_HUDDLE_STARTED,
|
|
16
24
|
KIND_PRESENCE_UPDATE,
|
|
17
25
|
KIND_REACTION,
|
|
18
26
|
KIND_STREAM_MESSAGE,
|
|
19
27
|
KIND_STREAM_MESSAGE_V2,
|
|
28
|
+
HuddleDecoder,
|
|
29
|
+
HuddleEncoder,
|
|
20
30
|
build_auth_event,
|
|
31
|
+
build_create_channel_event,
|
|
32
|
+
build_huddle_started_event,
|
|
21
33
|
build_join_channel_event,
|
|
22
34
|
build_message_event,
|
|
23
35
|
build_presence_event,
|
|
@@ -29,19 +41,45 @@ from ._native import (
|
|
|
29
41
|
verify_event,
|
|
30
42
|
)
|
|
31
43
|
from .client import BuzzClient
|
|
44
|
+
from .huddle import (
|
|
45
|
+
HuddleAudio,
|
|
46
|
+
HuddleClient,
|
|
47
|
+
HuddleError,
|
|
48
|
+
HuddleEvent,
|
|
49
|
+
HuddlePeerJoined,
|
|
50
|
+
HuddlePeerLeft,
|
|
51
|
+
)
|
|
32
52
|
|
|
33
|
-
__version__ = "0.1.
|
|
53
|
+
__version__ = "0.1.3"
|
|
34
54
|
|
|
35
55
|
__all__ = [
|
|
56
|
+
"HUDDLE_FRAME_SAMPLES",
|
|
57
|
+
"HUDDLE_PROTOCOL_VERSION",
|
|
58
|
+
"HUDDLE_SAMPLE_RATE",
|
|
36
59
|
"KIND_ADD_MEMBER",
|
|
37
60
|
"KIND_AUTH",
|
|
61
|
+
"KIND_CREATE_CHANNEL",
|
|
38
62
|
"KIND_HTTP_AUTH",
|
|
63
|
+
"KIND_HUDDLE_ENDED",
|
|
64
|
+
"KIND_HUDDLE_PARTICIPANT_JOINED",
|
|
65
|
+
"KIND_HUDDLE_PARTICIPANT_LEFT",
|
|
66
|
+
"KIND_HUDDLE_STARTED",
|
|
39
67
|
"KIND_PRESENCE_UPDATE",
|
|
40
68
|
"KIND_REACTION",
|
|
41
69
|
"KIND_STREAM_MESSAGE",
|
|
42
70
|
"KIND_STREAM_MESSAGE_V2",
|
|
43
71
|
"BuzzClient",
|
|
72
|
+
"HuddleAudio",
|
|
73
|
+
"HuddleClient",
|
|
74
|
+
"HuddleDecoder",
|
|
75
|
+
"HuddleEncoder",
|
|
76
|
+
"HuddleError",
|
|
77
|
+
"HuddleEvent",
|
|
78
|
+
"HuddlePeerJoined",
|
|
79
|
+
"HuddlePeerLeft",
|
|
44
80
|
"build_auth_event",
|
|
81
|
+
"build_create_channel_event",
|
|
82
|
+
"build_huddle_started_event",
|
|
45
83
|
"build_join_channel_event",
|
|
46
84
|
"build_message_event",
|
|
47
85
|
"build_presence_event",
|
|
@@ -18,12 +18,33 @@ def build_profile_event(
|
|
|
18
18
|
about: str | None = ...,
|
|
19
19
|
picture: str | None = ...,
|
|
20
20
|
nip05: str | None = ...,
|
|
21
|
+
auth_tag: str | None = ...,
|
|
21
22
|
) -> str:
|
|
22
|
-
"""Build + sign a profile event (kind 0); returns NIP-01 event JSON.
|
|
23
|
+
"""Build + sign a profile event (kind 0); returns NIP-01 event JSON.
|
|
24
|
+
|
|
25
|
+
``auth_tag`` embeds a NIP-OA owner attestation so the Buzz desktop shows
|
|
26
|
+
the identity as "managed by <owner>".
|
|
27
|
+
"""
|
|
23
28
|
|
|
24
29
|
def build_join_channel_event(secret: str, channel_id: str) -> str:
|
|
25
30
|
"""Build + sign a NIP-29 channel self-join event (kind 9000, role=bot)."""
|
|
26
31
|
|
|
32
|
+
def build_create_channel_event(
|
|
33
|
+
secret: str,
|
|
34
|
+
channel_id: str,
|
|
35
|
+
name: str,
|
|
36
|
+
visibility: str | None = ...,
|
|
37
|
+
channel_type: str | None = ...,
|
|
38
|
+
about: str | None = ...,
|
|
39
|
+
ttl: int | None = ...,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Build + sign a NIP-29 create-channel event (kind 9007)."""
|
|
42
|
+
|
|
43
|
+
def build_huddle_started_event(
|
|
44
|
+
secret: str, parent_channel_id: str, ephemeral_channel_id: str
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Build + sign a huddle-started advisory (kind 48100) for the parent channel."""
|
|
47
|
+
|
|
27
48
|
def build_presence_event(secret: str, status: str = ...) -> str:
|
|
28
49
|
"""Build + sign a presence event (kind 20001); status online/away/offline."""
|
|
29
50
|
|
|
@@ -41,6 +62,29 @@ def sign_nip98(secret: str, method: str, url: str, body: bytes | None = ...) ->
|
|
|
41
62
|
def verify_event(event_json: str) -> bool:
|
|
42
63
|
"""Verify an event's id + Schnorr signature."""
|
|
43
64
|
|
|
65
|
+
class HuddleEncoder:
|
|
66
|
+
"""Stateful huddle audio encoder: s16le mono 48 kHz PCM in, v2 wire frames out."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, bitrate: int = 32000, dtx: bool = True) -> None: ...
|
|
69
|
+
def encode(self, pcm: bytes) -> list[bytes]:
|
|
70
|
+
"""Feed PCM; returns complete 20 ms wire frames (partials are buffered)."""
|
|
71
|
+
|
|
72
|
+
def flush(self) -> bytes | None:
|
|
73
|
+
"""Zero-pad and emit the buffered partial frame, if any."""
|
|
74
|
+
|
|
75
|
+
def discard(self) -> None:
|
|
76
|
+
"""Drop buffered PCM without emitting it (barge-in)."""
|
|
77
|
+
|
|
78
|
+
class HuddleDecoder:
|
|
79
|
+
"""Stateful huddle audio decoder for relay frames ([peer_index][header][opus])."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None: ...
|
|
82
|
+
def decode(self, frame: bytes) -> tuple[int, int, int, int, bool, bytes]:
|
|
83
|
+
"""Return ``(peer_index, seq, ts_48k, level_dbov, is_dtx, pcm_s16le_48k)``."""
|
|
84
|
+
|
|
85
|
+
def remove_peer(self, peer_index: int) -> None:
|
|
86
|
+
"""Forget a peer's decoder state (indexes are recycled by the relay)."""
|
|
87
|
+
|
|
44
88
|
KIND_REACTION: int
|
|
45
89
|
KIND_STREAM_MESSAGE: int
|
|
46
90
|
KIND_PRESENCE_UPDATE: int
|
|
@@ -48,3 +92,11 @@ KIND_AUTH: int
|
|
|
48
92
|
KIND_HTTP_AUTH: int
|
|
49
93
|
KIND_STREAM_MESSAGE_V2: int
|
|
50
94
|
KIND_ADD_MEMBER: int
|
|
95
|
+
KIND_CREATE_CHANNEL: int
|
|
96
|
+
KIND_HUDDLE_STARTED: int
|
|
97
|
+
KIND_HUDDLE_PARTICIPANT_JOINED: int
|
|
98
|
+
KIND_HUDDLE_PARTICIPANT_LEFT: int
|
|
99
|
+
KIND_HUDDLE_ENDED: int
|
|
100
|
+
HUDDLE_PROTOCOL_VERSION: int
|
|
101
|
+
HUDDLE_SAMPLE_RATE: int
|
|
102
|
+
HUDDLE_FRAME_SAMPLES: int
|
|
@@ -20,6 +20,7 @@ import asyncio
|
|
|
20
20
|
import contextlib
|
|
21
21
|
import json
|
|
22
22
|
import logging
|
|
23
|
+
import uuid
|
|
23
24
|
from collections.abc import AsyncIterator
|
|
24
25
|
from typing import Any
|
|
25
26
|
|
|
@@ -61,7 +62,7 @@ class BuzzClient:
|
|
|
61
62
|
self._http = _to_http(relay_url)
|
|
62
63
|
self._ws_url = _to_ws(relay_url)
|
|
63
64
|
self._secret = secret
|
|
64
|
-
self._auth_tag = auth_tag #
|
|
65
|
+
self._auth_tag = auth_tag # NIP-OA owner attestation (AUTH + profile)
|
|
65
66
|
self.npub, self.pubkey_hex = _native.pubkey_from_secret(secret)
|
|
66
67
|
self._ws: Any = None
|
|
67
68
|
self._reader: asyncio.Task | None = None
|
|
@@ -96,15 +97,26 @@ class BuzzClient:
|
|
|
96
97
|
return await self.post_event(ev)
|
|
97
98
|
|
|
98
99
|
async def set_profile(
|
|
99
|
-
self,
|
|
100
|
+
self,
|
|
101
|
+
display_name: str,
|
|
102
|
+
*,
|
|
103
|
+
about: str | None = None,
|
|
104
|
+
picture: str | None = None,
|
|
105
|
+
auth_tag: str | None = None,
|
|
100
106
|
) -> dict:
|
|
101
|
-
"""Publish this identity's profile (kind 0) so it shows a name in Buzz.
|
|
107
|
+
"""Publish this identity's profile (kind 0) so it shows a name in Buzz.
|
|
108
|
+
|
|
109
|
+
``auth_tag`` defaults to the client's NIP-OA owner attestation, so a
|
|
110
|
+
client constructed with one automatically shows as "managed by
|
|
111
|
+
<owner>" in the Buzz desktop (which reads the tag from kind 0).
|
|
112
|
+
"""
|
|
102
113
|
ev = _native.build_profile_event(
|
|
103
114
|
self._secret,
|
|
104
115
|
display_name=display_name,
|
|
105
116
|
name=display_name,
|
|
106
117
|
about=about,
|
|
107
118
|
picture=picture,
|
|
119
|
+
auth_tag=auth_tag if auth_tag is not None else self._auth_tag,
|
|
108
120
|
)
|
|
109
121
|
return await self.post_event(ev)
|
|
110
122
|
|
|
@@ -119,6 +131,36 @@ class BuzzClient:
|
|
|
119
131
|
ev = _native.build_join_channel_event(self._secret, channel_id)
|
|
120
132
|
return await self.publish(ev)
|
|
121
133
|
|
|
134
|
+
async def start_huddle(
|
|
135
|
+
self, parent_channel_id: str, *, name: str | None = None, ttl: int = 3600
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Start a huddle in a channel; returns the ephemeral huddle channel id.
|
|
138
|
+
|
|
139
|
+
Creates a private ephemeral channel (kind 9007, over the WebSocket —
|
|
140
|
+
:meth:`connect` first) and posts the kind-48100 announcement to the
|
|
141
|
+
parent channel. Join the audio with
|
|
142
|
+
``HuddleClient(..., huddle_id, parent_channel_id=parent_channel_id)``.
|
|
143
|
+
"""
|
|
144
|
+
huddle_id = str(uuid.uuid4())
|
|
145
|
+
create_ev = _native.build_create_channel_event(
|
|
146
|
+
self._secret,
|
|
147
|
+
huddle_id,
|
|
148
|
+
name or f"huddle-{huddle_id[:8]}",
|
|
149
|
+
visibility="private",
|
|
150
|
+
channel_type="stream",
|
|
151
|
+
ttl=ttl,
|
|
152
|
+
)
|
|
153
|
+
result = await self.publish(create_ev)
|
|
154
|
+
if not result["accepted"]:
|
|
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
|
+
)
|
|
159
|
+
result = await self.publish(started_ev)
|
|
160
|
+
if not result["accepted"]:
|
|
161
|
+
raise RuntimeError(f"huddle announcement rejected: {result['message']}")
|
|
162
|
+
return huddle_id
|
|
163
|
+
|
|
122
164
|
async def publish_presence(self, status: str = "online") -> dict:
|
|
123
165
|
"""Announce presence (kind 20001, ephemeral) over the WebSocket."""
|
|
124
166
|
ev = _native.build_presence_event(self._secret, status)
|