diff --git a/.gitignore b/.gitignore index 5c085e9..0c698b1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,16 @@ coverage/ .cache/ .vscode/ .idea/ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +.eggs/ +.pytest_cache/ +.tox/ +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.claude/ diff --git a/docs/adr/ADR-004-backpressure-policy.md b/docs/adr/PY-004-backpressure-policy.md similarity index 91% rename from docs/adr/ADR-004-backpressure-policy.md rename to docs/adr/PY-004-backpressure-policy.md index 17baf19..e67b7dc 100644 --- a/docs/adr/ADR-004-backpressure-policy.md +++ b/docs/adr/PY-004-backpressure-policy.md @@ -1,4 +1,4 @@ -# ADR-004-backpressure-policy — Backpressure Policy on Pool / Ring Exhaustion +# PY-004-backpressure-policy — Backpressure Policy on Pool / Ring Exhaustion ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-005-relay-listener-slice.md b/docs/adr/PY-005-relay-listener-slice.md similarity index 93% rename from docs/adr/ADR-005-relay-listener-slice.md rename to docs/adr/PY-005-relay-listener-slice.md index f90974c..227155a 100644 --- a/docs/adr/ADR-005-relay-listener-slice.md +++ b/docs/adr/PY-005-relay-listener-slice.md @@ -1,4 +1,4 @@ -# ADR-005-relay-listener-slice — Relay Listener Slice Model +# PY-005-relay-listener-slice — Relay Listener Slice Model ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-006-clock-sync-src.md b/docs/adr/PY-006-clock-sync-src.md similarity index 92% rename from docs/adr/ADR-006-clock-sync-src.md rename to docs/adr/PY-006-clock-sync-src.md index c7cbd96..9d3fa71 100644 --- a/docs/adr/ADR-006-clock-sync-src.md +++ b/docs/adr/PY-006-clock-sync-src.md @@ -1,4 +1,4 @@ -# ADR-006-clock-sync-src — Clock Sync / Async Sample Rate Conversion +# PY-006-clock-sync-src — Clock Sync / Async Sample Rate Conversion ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-007-capability-negotiation.md b/docs/adr/PY-007-capability-negotiation.md similarity index 93% rename from docs/adr/ADR-007-capability-negotiation.md rename to docs/adr/PY-007-capability-negotiation.md index 22eb818..705092a 100644 --- a/docs/adr/ADR-007-capability-negotiation.md +++ b/docs/adr/PY-007-capability-negotiation.md @@ -1,4 +1,4 @@ -# ADR-007-capability-negotiation — Capability Negotiation +# PY-007-capability-negotiation — Capability Negotiation ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-008-workspace-release-sequencing.md b/docs/adr/PY-008-workspace-release-sequencing.md similarity index 91% rename from docs/adr/ADR-008-workspace-release-sequencing.md rename to docs/adr/PY-008-workspace-release-sequencing.md index 6d90f2e..3999e8e 100644 --- a/docs/adr/ADR-008-workspace-release-sequencing.md +++ b/docs/adr/PY-008-workspace-release-sequencing.md @@ -1,4 +1,4 @@ -# ADR-008-workspace-release-sequencing — Workspace Release Sequencing +# PY-008-workspace-release-sequencing — Workspace Release Sequencing ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-009-pion-writertp-profile.md b/docs/adr/PY-009-pion-writertp-profile.md similarity index 92% rename from docs/adr/ADR-009-pion-writertp-profile.md rename to docs/adr/PY-009-pion-writertp-profile.md index 05905ee..9cf235d 100644 --- a/docs/adr/ADR-009-pion-writertp-profile.md +++ b/docs/adr/PY-009-pion-writertp-profile.md @@ -1,4 +1,4 @@ -# ADR-009-pion-writertp-profile — Pion WriteRTP Allocation Profile +# PY-009-pion-writertp-profile — Pion WriteRTP Allocation Profile ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-010-jitter-buffer.md b/docs/adr/PY-010-jitter-buffer.md similarity index 94% rename from docs/adr/ADR-010-jitter-buffer.md rename to docs/adr/PY-010-jitter-buffer.md index 0528eb4..3c934ca 100644 --- a/docs/adr/ADR-010-jitter-buffer.md +++ b/docs/adr/PY-010-jitter-buffer.md @@ -1,4 +1,4 @@ -# ADR-010-jitter-buffer — Jitter Buffer Algorithm +# PY-010-jitter-buffer — Jitter Buffer Algorithm ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-011-spsc-ring.md b/docs/adr/PY-011-spsc-ring.md similarity index 94% rename from docs/adr/ADR-011-spsc-ring.md rename to docs/adr/PY-011-spsc-ring.md index 5a30803..fd6cf78 100644 --- a/docs/adr/ADR-011-spsc-ring.md +++ b/docs/adr/PY-011-spsc-ring.md @@ -1,4 +1,4 @@ -# ADR-011-spsc-ring — SPSC Ring Buffer Choice +# PY-011-spsc-ring — SPSC Ring Buffer Choice ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-012-opus-frame-duration.md b/docs/adr/PY-012-opus-frame-duration.md similarity index 93% rename from docs/adr/ADR-012-opus-frame-duration.md rename to docs/adr/PY-012-opus-frame-duration.md index 40fdbe0..eadd11e 100644 --- a/docs/adr/ADR-012-opus-frame-duration.md +++ b/docs/adr/PY-012-opus-frame-duration.md @@ -1,4 +1,4 @@ -# ADR-012-opus-frame-duration — Opus Frame Duration +# PY-012-opus-frame-duration — Opus Frame Duration ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/ADR-013-internal-format-channel-layout.md b/docs/adr/PY-013-internal-format-channel-layout.md similarity index 90% rename from docs/adr/ADR-013-internal-format-channel-layout.md rename to docs/adr/PY-013-internal-format-channel-layout.md index f4b3387..98d717c 100644 --- a/docs/adr/ADR-013-internal-format-channel-layout.md +++ b/docs/adr/PY-013-internal-format-channel-layout.md @@ -1,4 +1,4 @@ -# ADR-013-internal-format-channel-layout — Internal Sample Format and Channel Layout +# PY-013-internal-format-channel-layout — Internal Sample Format and Channel Layout ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/architecture/PocketStation-v2.3.md b/docs/architecture/PocketStation-v2.3.md index 65aee29..7a4ecd8 100644 --- a/docs/architecture/PocketStation-v2.3.md +++ b/docs/architecture/PocketStation-v2.3.md @@ -16,8 +16,8 @@ | `SourceCapability::AirPlayRouteInput` (iOS-only meaning, generic name) | Renamed to `ExternalRouteInput` | Name describes the capability shape, not a single Apple feature | | "Go + Pion v3" | Go + Pion v4 (current stable as of 2026-05-19); v5 in development | Pion v3 → v4 ship'd; using v3 on a new project is a deliberate downgrade | | Phase 0 exit: "Crate published on crates.io with docs.rs documentation" | "Crate is publish-ready: docs build cleanly, `cargo publish --dry-run` succeeds, public API reviewed by at least one external Rust reviewer. Actual publish happens after Phase 1 demo validates the surface." | Publishing locks names and creates SemVer pressure before the API has met one real route | -| Opus frame duration left implicit (`960 samples at 48kHz`) | ADR-012 added: 20ms default; 10ms option for voice-agent mode after benchmarks | Frame duration cascades into pool sizing, packet rate, jitter buffer, CPU, bitrate overhead — must be explicit | -| Internal sample format + channel layout policy unstated | ADR-013 added: interleaved f32 internal; mono 48kHz voice mode; stereo 48kHz music/broadcast; `AudioProcessorNode` declares accepted channel layout, graph auto-inserts `MonoMixNode` | Every encoder, VAD, STT, and ML node makes assumptions; lock the policy or pay for surprises | +| Opus frame duration left implicit (`960 samples at 48kHz`) | PY-012 added: 20ms default; 10ms option for voice-agent mode after benchmarks | Frame duration cascades into pool sizing, packet rate, jitter buffer, CPU, bitrate overhead — must be explicit | +| Internal sample format + channel layout policy unstated | PY-013 added: interleaved f32 internal; mono 48kHz voice mode; stereo 48kHz music/broadcast; `AudioProcessorNode` declares accepted channel layout, graph auto-inserts `MonoMixNode` | Every encoder, VAD, STT, and ML node makes assumptions; lock the policy or pay for surprises | This is the green-light version. No further structural rewrites planned. @@ -113,8 +113,8 @@ Social/consumer: Only after one user community proves repeat usage. | Generic `AppOutput`, `AirPlayRouteInput` names | Renamed to capability-shape names | | Pion v3 hardcoded | Pion v4 stable, current; v5 in flight | | "Published on crates.io" as Phase 0 exit | "Publish-ready"; first publish lands after Phase 1 demo | -| Opus frame duration implicit | ADR-012 makes it explicit | -| Channel layout / sample format policy implicit | ADR-013 makes it explicit | +| Opus frame duration implicit | PY-012 makes it explicit | +| Channel layout / sample format policy implicit | PY-013 makes it explicit | --- @@ -284,9 +284,9 @@ impl Drop for AudioBufferHandle { pub struct AudioFrame { pub stream_id: StreamId, pub source_id: SourceId, - pub sample_rate: u32, // 48_000 internally (ADR-013) - pub channels: u8, // 1 voice / 2 music (ADR-013) - pub format: SampleFormat, // F32LE interleaved (ADR-013) + pub sample_rate: u32, // 48_000 internally (PY-013) + pub channels: u8, // 1 voice / 2 music (PY-013) + pub format: SampleFormat, // F32LE interleaved (PY-013) pub timestamp_ns: u64, // monotonic, per-node, never wall clock pub sequence_number: u64, // monotonic per stream pub buffer: AudioBufferHandle, @@ -295,13 +295,13 @@ pub struct AudioFrame { ```rust /// Phase 0 pool — 64-slot ceiling imposed by AtomicU64 bitset. -/// At 20ms frame duration (ADR-012): 64 × 20ms = 1.28s of headroom. +/// At 20ms frame duration (PY-012): 64 × 20ms = 1.28s of headroom. /// Expansion path if needed: segmented bitset (Vec) or lock-free /// freelist (treiber stack of indices). Decision deferred until Phase 0 /// profiler data shows actual pool pressure. pub struct AudioBufferPool { slots: Box<[f32]>, // contiguous block, allocated once at session start - slot_size: usize, // samples per slot — 960 at 48kHz/20ms (ADR-012) + slot_size: usize, // samples per slot — 960 at 48kHz/20ms (PY-012) free_mask: AtomicU64, // bitset of free slots; one bit per slot, 64-slot cap } @@ -341,7 +341,7 @@ pub trait AudioProcessorNode: Send { fn process(&mut self, frame: AudioFrame) -> Option; /// Channel layout this node accepts. Graph auto-inserts MonoMixNode - /// or appropriate adapter when upstream layout differs. See ADR-013. + /// or appropriate adapter when upstream layout differs. See PY-013. fn accepted_channels(&self) -> ChannelLayout { ChannelLayout::Either } @@ -502,7 +502,7 @@ Capability negotiation on partial match: see §26.4. ## 6. Architecture Decision Records -### ADR-001: FFI/JNI Boundary Ownership +### PY-001: FFI/JNI Boundary Ownership **iOS: Platform owns the audio callback thread.** @@ -552,13 +552,13 @@ device.build_input_stream(&config, move |data: &[f32], info| { }, |err| tracing::error!("{err}"), None) ``` -### ADR-002: Star Topology — No Relay Chains +### PY-002: Star Topology — No Relay Chains All audio flows `source → cloud relay → listeners`. No device-to-device chains. Relay chains stack latency (each hop: 20-80ms minimum), require each intermediate node to encode/decode, and provide no capability that star topology does not. WebRTC ICE handles LAN-direct paths automatically. -### ADR-003: Custom Go Relay — No LiveKit +### PY-003: Custom Go Relay — No LiveKit Custom MVP relay using Pion v4. Production relay grows as a real subsystem. @@ -836,7 +836,7 @@ All ML runs as ProcessorGraph nodes. All models run on-device. Raw audio is neve **Threading rule (load-bearing):** ML nodes run only on the Rust processing thread, never inside platform audio callbacks. VAD inference is often quick enough that this distinction looks academic — until denoise or AEC pushes a model load or a 30ms inference into the callback path and the audio system glitches. The boundary is enforced architecturally: callbacks write to the SPSC ring and return; the processing thread drains the ring and runs all `AudioProcessorNode::process()` calls, including ML. -**Channel layout rule (ADR-013):** ML nodes declare `accepted_channels()`. Most voice ML expects mono. The graph inserts a `MonoMixNode` upstream of any mono-only node when fed stereo, transparently and at the cost of one extra allocation-free pass. +**Channel layout rule (PY-013):** ML nodes declare `accepted_channels()`. Most voice ML expects mono. The graph inserts a `MonoMixNode` upstream of any mono-only node when fed stereo, transparently and at the cost of one extra allocation-free pass. ### 10.1 VAD @@ -932,8 +932,8 @@ AudioWatermarkNode EU AI Act compliance let station = PocketStation::builder() .relay_url("wss://relay.pocketstation.io") .room_id("abc123") - .mode(AudioMode::Voice) // mono 48kHz internal (ADR-013) - .opus_frame_duration_ms(20) // default; 10 for voice-agent (ADR-012) + .mode(AudioMode::Voice) // mono 48kHz internal (PY-013) + .opus_frame_duration_ms(20) // default; 10 for voice-agent (PY-012) .add_processor(VadNode::default()) .add_processor(NoiseSuppressorNode::default()) .on_listener_count(|n| println!("{n} listening")) @@ -1293,16 +1293,16 @@ Prove every future audio source can enter one core. No OS audio APIs. No UI. Build: ``` AudioBufferPool (zero per-frame allocation, 64-slot cap documented) -AudioFrame with pool handles, interleaved f32 48kHz (ADR-013) +AudioFrame with pool handles, interleaved f32 48kHz (PY-013) SPSC ring buffer (proptest-verified invariants; crate choice per §26.8) FrameBus -ProcessorGraph (empty graph, PassthroughNode) with accepted_channels routing (ADR-013) +ProcessorGraph (empty graph, PassthroughNode) with accepted_channels routing (PY-013) ClockSync (algorithm decision per §26.3) -Opus encoder/decoder at 20ms default frame duration (ADR-012) +Opus encoder/decoder at 20ms default frame duration (PY-012) Fake sine-wave source File output sink Full metrics -Backpressure policy decided (ADR-004, §26.1) +Backpressure policy decided (PY-004, §26.1) ``` Exit criteria: @@ -1314,7 +1314,7 @@ BusMetrics P50/P95/P99 print correctly after 60-second run Crate is publish-ready: docs build cleanly, `cargo publish --dry-run` succeeds, public API reviewed by at least one external Rust reviewer. Actual crates.io publish happens at Phase 1 exit, not here. -ADR-004 through ADR-013 written and merged. +PY-004 through PY-013 written and merged. ``` ### Phase 1 — First Real Route @@ -1709,7 +1709,7 @@ First public artifacts: ### T5 — FFI Boundary Crashes in Production (Medium Probability if Not Designed Properly) Mitigation: -- ADR-001 defines the complete boundary contract before code +- PY-001 defines the complete boundary contract before code - `proptest` for ring buffer invariants - DHAT profiler in CI verifies zero allocation on hot path - Debug assertion: callback thread identity verified at session start @@ -1794,7 +1794,7 @@ B. Drop oldest — keeps fresh audio flowing, encoder glitch C. Block producer — violates no-blocking rule, non-starter ``` -**Recommended:** A. Documented as ADR-004. +**Recommended:** A. Documented as PY-004. ### 26.2 Relay Forward-Loop Locking — Blocks Phase 2 @@ -1806,7 +1806,7 @@ type Room struct { } ``` -Documented as ADR-005. +Documented as PY-005. ### 26.3 Clock Sync / Async Sample Rate Conversion — Blocks Phase 0 @@ -1816,7 +1816,7 @@ B. PI-controlled linear interpolation — ~100 lines, voice default C. Variable-rate SRC (libsoxr / rubato) — music quality, ~5x CPU ``` -**Recommended:** B for voice, hook for C in music-mode. Documented as ADR-006. +**Recommended:** B for voice, hook for C in music-mode. Documented as PY-006. ### 26.4 Capability Negotiation on Partial Match — Blocks Phase 1 @@ -1826,7 +1826,7 @@ B. Auto-insert ResampleNode + MonoMixNode C. Return descriptor delta, caller decides ``` -**Recommended:** B with explicit `negotiated: NegotiatedCapability` on the stream. Documented as ADR-007. +**Recommended:** B with explicit `negotiated: NegotiatedCapability` on the stream. Documented as PY-007. ### 26.5 Workspace Release Sequencing — Blocks Phase 1 First Publish @@ -1842,7 +1842,7 @@ pocketstation-metrics (deps: frame, bus) pocketstation-audio (re-export, deps: all above) ``` -Tooling: `cargo-release` with sequenced publish and per-crate retry. Git tag strategy: single `v0.X.Y` at workspace root, all crates published at the same version. Documented as ADR-008. +Tooling: `cargo-release` with sequenced publish and per-crate retry. Git tag strategy: single `v0.X.Y` at workspace root, all crates published at the same version. Documented as PY-008. ### 26.6 Pion `WriteRTP` Allocation Profile — Blocks Phase 1 @@ -1854,7 +1854,7 @@ Tooling: `cargo-release` with sequenced publish and per-crate retry. Git tag str 5. GC pressure at 50 pkt/sec × 200 listeners = 10,000 calls/sec/room? ``` -"No re-encode" does not mean "no allocation." Documented as ADR-009. +"No re-encode" does not mean "no allocation." Documented as PY-009. ### 26.7 JitterBuffer Algorithm — Blocks Phase 1 @@ -1866,13 +1866,13 @@ B. Adaptive (NetEQ-class) — WebRTC standard; ~500 lines C. RTT-variance-driven with PLC — wraps webrtc-audio-processing ``` -**Recommended:** B for Phase 1, optional swap to C in Phase 5. Documented as ADR-010. +**Recommended:** B for Phase 1, optional swap to C in Phase 5. Documented as PY-010. ### 26.8 SPSC Ring Buffer Crate Choice — Blocks Phase 0 Criteria: wait-free both ends, no alloc after construction, bounded capacity, cache-line padding, maintained, ideally no_std. -**Default candidate:** `rtrb` — fixed-capacity allocation at construction, no allocation afterward, lock-free wait-free reads/writes. Verified against criteria; confirm in Phase 0 prototype. Documented as ADR-011. +**Default candidate:** `rtrb` — fixed-capacity allocation at construction, no allocation afterward, lock-free wait-free reads/writes. Verified against criteria; confirm in Phase 0 prototype. Documented as PY-011. ### 26.9 Opus Frame Duration — Blocks Phase 0/1 @@ -1892,7 +1892,7 @@ C. 40 or 60ms frames Acceptable only for one-way music/broadcast with VAD off ``` -**Recommended:** 20ms default. Make it a runtime parameter on `PocketStation::builder()`. After Phase 1 latency measurements, enable 10ms for voice-agent mode if benchmarks justify the CPU/overhead tradeoff. Documented as ADR-012. +**Recommended:** 20ms default. Make it a runtime parameter on `PocketStation::builder()`. After Phase 1 latency measurements, enable 10ms for voice-agent mode if benchmarks justify the CPU/overhead tradeoff. Documented as PY-012. Knock-on effects to verify: - Pool slot size: 20ms × 48kHz = 960 samples; 10ms = 480 samples; 60ms = 2880 samples @@ -1925,10 +1925,10 @@ AudioMode::Broadcast (default) Channels: configurable per session; defaults to stereo ``` -**`AudioProcessorNode::accepted_channels()`** lets each node declare what it can ingest. Graph builder checks the chain and inserts adapter nodes (`MonoMixNode`, `StereoBroadcastNode`) at the right positions. Insertions are zero-allocation passes using the existing pool. Documented as ADR-013. +**`AudioProcessorNode::accepted_channels()`** lets each node declare what it can ingest. Graph builder checks the chain and inserts adapter nodes (`MonoMixNode`, `StereoBroadcastNode`) at the right positions. Insertions are zero-allocation passes using the existing pool. Documented as PY-013. --- -*Document version 2.3 — green-light version. No further structural rewrites planned. Phase 0 begins when ADR-004 through ADR-013 are written and merged.* +*Document version 2.3 — green-light version. No further structural rewrites planned. Phase 0 begins when PY-004 through PY-013 are written and merged.* *Kill criteria reviewed: 2026-05-19.* *Next revision trigger: Phase 0 exit criteria met and measured; first crates.io publish at Phase 1 exit.* diff --git a/docs/standards/FAKE_SCAFFOLD_INVENTORY.md b/docs/standards/FAKE_SCAFFOLD_INVENTORY.md index bca9ef4..c037401 100644 --- a/docs/standards/FAKE_SCAFFOLD_INVENTORY.md +++ b/docs/standards/FAKE_SCAFFOLD_INVENTORY.md @@ -22,9 +22,10 @@ DEFERRED Intentionally postponed; ADR or phase plan justifies it ## Active inventory -| Component | Status | Repo / File | What's missing | Replace by | Blocked on | -|---|---|---|---|---|---| -| _example row — delete when first real row lands_ | _SCAFFOLD_ | _audio-core/crates/pocketstation-codec/src/opus_mock.rs_ | _Real libopus binding_ | _Phase 0 task 7_ | _libopus-sys dependency approval_ | +| Component | Status | File | What's missing | Replace by | Blocked on | +|-----------|--------|------|----------------|------------|------------| +| `close()` LEAVE message | PARTIAL | `pocketstation/station.py` | Relay LEAVE message not sent on `close()`. WebSocket closes but relay does not receive LEAVE type. Room state may be stale until TTL. | Phase 5 | None — straightforward fix | +| WebRTC transport | DEFERRED | N/A | Python SDK is WebSocket-only. No WebRTC / audio capture planned for this binding. | Not planned | Architecture decision: Python is listener/voice-agent tier only | --- @@ -34,9 +35,9 @@ These are typical scaffolds expected at Phase 0 exit. Replace this section with | Component | Status | Repo / File | What's missing | Replace by | Blocked on | |---|---|---|---|---|---| -| Opus encoder/decoder | MOCK | audio-core / pocketstation-codec | Real libopus bindings; current mock copies bytes | Phase 1 | ADR-013 sample format finalized, libopus-sys dep approval | -| JitterBuffer | PARTIAL | audio-core / pocketstation-codec | NetEQ-class adaptive algorithm; current scaffold is fixed-delay | Phase 5 | ADR-010 algorithm choice | -| ClockSync | PARTIAL | audio-core / pocketstation-bus | PI controller per ADR-006; current scaffold is fixed-rate | Phase 1 | ADR-006 resolution | +| Opus encoder/decoder | MOCK | audio-core / pocketstation-codec | Real libopus bindings; current mock copies bytes | Phase 1 | PY-013 sample format finalized, libopus-sys dep approval | +| JitterBuffer | PARTIAL | audio-core / pocketstation-codec | NetEQ-class adaptive algorithm; current scaffold is fixed-delay | Phase 5 | PY-010 algorithm choice | +| ClockSync | PARTIAL | audio-core / pocketstation-bus | PI controller per PY-006; current scaffold is fixed-rate | Phase 1 | PY-006 resolution | | DHAT allocation check | DEFERRED | audio-core / tools/pocketstation-alloccheck | Real DHAT integration; current is cargo-bloat placeholder | Phase 1 | DHAT setup in CI | ## Phase 1 expected additions diff --git a/docs/standards/PRODUCTION_ENGINEERING_BAR.md b/docs/standards/PRODUCTION_ENGINEERING_BAR.md index 8cf26e2..8c50027 100644 --- a/docs/standards/PRODUCTION_ENGINEERING_BAR.md +++ b/docs/standards/PRODUCTION_ENGINEERING_BAR.md @@ -97,7 +97,7 @@ Phase 1 must measure three hot paths, no more, no less. The list is fixed so thi Phase 1 performance gate: 1. AudioBufferPool acquire / release / drop (Criterion bench in audio-core) 2. JWT verify rate (Go bench in relay/auth) - 3. Pion TrackLocalStaticRTP.WriteRTP allocation profile (per ADR-009) + 3. Pion TrackLocalStaticRTP.WriteRTP allocation profile (per PY-009) ``` Phase 2 hardening expands the gate to include: diff --git a/docs/standards/STAFF_ENGINEERING_BAR.md b/docs/standards/STAFF_ENGINEERING_BAR.md index 291543a..69f66ba 100644 --- a/docs/standards/STAFF_ENGINEERING_BAR.md +++ b/docs/standards/STAFF_ENGINEERING_BAR.md @@ -172,7 +172,7 @@ The architecture document at `docs/architecture/PocketStation-v2.3.md` (and any Agents must not edit the architecture document without an ADR documenting why the change is needed. -ADR-008 through ADR-013 in v2.3 are open questions; resolving them may legitimately require architecture-doc changes. When that happens: +PY-008 through PY-013 in v2.3 are open questions; resolving them may legitimately require architecture-doc changes. When that happens: 1. Write the ADR first. 2. Get human approval on the ADR. diff --git a/docs/standards/STRUCTURE_NAMING_STYLE_THINKING.md b/docs/standards/STRUCTURE_NAMING_STYLE_THINKING.md index 434c8b3..130df49 100644 --- a/docs/standards/STRUCTURE_NAMING_STYLE_THINKING.md +++ b/docs/standards/STRUCTURE_NAMING_STYLE_THINKING.md @@ -292,7 +292,7 @@ Good: ```rust // Drop newest instead of oldest because audio callback freshness -// matters more than completeness — see ADR-004. +// matters more than completeness — see PY-004. ``` Avoid: @@ -307,7 +307,7 @@ Every `unsafe` block has a `SAFETY:` comment (see `STAFF_ENGINEERING_BAR.md` §4 Every `TODO` references a phase and an ADR or issue: ```rust -// TODO(Phase 1, ADR-009): measure WriteRTP allocation behavior +// TODO(Phase 1, PY-009): measure WriteRTP allocation behavior // before production relay. ``` diff --git a/pocketstation/__init__.py b/pocketstation/__init__.py index fd50d86..3fa8738 100644 --- a/pocketstation/__init__.py +++ b/pocketstation/__init__.py @@ -1,6 +1,6 @@ """PocketStation Python SDK. Phase 5.""" -from .client import PocketStation as PocketStationClient, PocketStationSession from .station import PocketStation +from .client import PocketStation as PocketStationClient, PocketStationSession from .types import AudioFrame, AudioMode, IceServer, PocketStationError, RoomCredentials __version__ = "0.1.0" diff --git a/pocketstation/client.py b/pocketstation/client.py index 3fc3238..9600e95 100644 --- a/pocketstation/client.py +++ b/pocketstation/client.py @@ -48,7 +48,7 @@ def __init__(self, credentials: RoomCredentials) -> None: @property def credentials(self) -> RoomCredentials: - """Room credentials including TURN servers (ADR-023).""" + """Room credentials including TURN servers (PY-023).""" return self._credentials @property diff --git a/pocketstation/station.py b/pocketstation/station.py index 7b812f3..9a9a03b 100644 --- a/pocketstation/station.py +++ b/pocketstation/station.py @@ -8,21 +8,31 @@ import httpx import websockets -from .types import AudioFrame, AudioMode, IceServer, RoomCredentials +from .types import AudioFrame, AudioMode, IceServer, PocketStationError, RoomCredentials _AUDIO_FRAME_MSG_TYPE = "AUDIO_FRAME" _SUBSCRIBE_MSG_TYPE = "SUBSCRIBE" _ROOM_STATE_MSG_TYPE = "ROOM_STATE" +_DEFAULT_API_URL = "http://localhost:8090" +_DEFAULT_RELAY_URL = "ws://localhost:8080/v1/signal" +_DEFAULT_FRAME_DURATION_MS = 20 + class PocketStation: """Voice agent / broadcast session manager (spec §12.1). - Usage:: + Construct directly when you already have a room ID:: station = PocketStation(room_id="abc123", mode=AudioMode.VOICE_AGENT) async for frame in station.listen(): - ... + transcript = await stt.transcribe(frame.pcm) + await station.broadcast(tts_bytes) + await station.close() + + Or create a room and connect in one step:: + + station = await PocketStation.create(relay_url="wss://relay.pocketstation.io") """ def __init__( @@ -30,9 +40,9 @@ def __init__( *, room_id: Optional[str] = None, mode: AudioMode = AudioMode.VOICE_AGENT, - api_url: str = "http://localhost:8090", - relay_url: str = "ws://localhost:8080/v1/signal", - opus_frame_duration_ms: int = 20, + api_url: str = _DEFAULT_API_URL, + relay_url: str = _DEFAULT_RELAY_URL, + opus_frame_duration_ms: int = _DEFAULT_FRAME_DURATION_MS, on_listener_count: Optional[Callable[[int], None]] = None, on_packet_loss: Optional[Callable[[], None]] = None, ) -> None: @@ -46,14 +56,54 @@ def __init__( self._ws: Optional[websockets.WebSocketClientProtocol] = None self._credentials: Optional[RoomCredentials] = None + @classmethod + async def create( + cls, + *, + relay_url: str = _DEFAULT_RELAY_URL, + api_url: str = _DEFAULT_API_URL, + mode: AudioMode = AudioMode.VOICE_AGENT, + opus_frame_duration_ms: int = _DEFAULT_FRAME_DURATION_MS, + ) -> "PocketStation": + """Create a new room via the API server and return a ready PocketStation instance. + + :param relay_url: WebSocket URL of the relay. + :param api_url: Base URL of the api-server used to provision the room. + :param mode: Audio session mode. + :param opus_frame_duration_ms: Opus frame duration in milliseconds. + :raises PocketStationError: on network or HTTP failure. + """ + station = cls(api_url=api_url, relay_url=relay_url, mode=mode, + opus_frame_duration_ms=opus_frame_duration_ms) + await station._ensure_room() + return station + async def _ensure_room(self) -> RoomCredentials: """Create or reuse a room on the API server.""" if self._credentials is not None: return self._credentials - async with httpx.AsyncClient() as client: - r = await client.post(f"{self.api_url.rstrip('/')}/v1/rooms", json={}) - r.raise_for_status() + + url = self.api_url.rstrip("/") + "/v1/rooms" + try: + async with httpx.AsyncClient() as client: + r = await client.post(url, json={}) + except httpx.RequestError as exc: + raise PocketStationError( + f"network error creating room: {exc}", "network_error" + ) from exc + + if not r.is_success: + raise PocketStationError( + f"relay returned HTTP {r.status_code}: {r.text}", "http_error" + ) + + try: data = r.json() + except Exception as exc: + raise PocketStationError( + f"failed to parse room creation response: {exc}", "parse_error" + ) from exc + creds = RoomCredentials( room_id=data["room_id"], source_token=data.get("source_token", ""), @@ -76,8 +126,8 @@ async def _ensure_room(self) -> RoomCredentials: async def listen(self) -> AsyncIterator[AudioFrame]: """Subscribe to audio frames from the relay. - Connects via WebSocket SUBSCRIBE message and yields AudioFrame objects. - Falls back gracefully — yields nothing — if the relay is unreachable. + Connects via WebSocket, sends a SUBSCRIBE message, and yields AudioFrame objects. + Yields nothing if the relay is unreachable. """ creds = await self._ensure_room() subscribe_msg = json.dumps({ @@ -103,7 +153,6 @@ async def listen(self) -> AsyncIterator[AudioFrame]: if msg.get("type") == _ROOM_STATE_MSG_TYPE and self.on_listener_count: self.on_listener_count(msg.get("listener_count", 0)) except Exception: - # Relay unreachable or connection closed — yield nothing. return finally: self._ws = None @@ -111,8 +160,7 @@ async def listen(self) -> AsyncIterator[AudioFrame]: async def broadcast(self, audio: bytes) -> None: """Send raw PCM bytes (f32-LE 48 kHz mono) to the relay. - Used in voice agent response path: TTS output -> broadcast back. - Silently no-ops when not connected. + No-ops silently when not connected. """ if self._ws is None: return @@ -124,7 +172,7 @@ async def broadcast(self, audio: bytes) -> None: except Exception: pass - async def stop(self) -> None: + async def close(self) -> None: """Close the WebSocket connection.""" if self._ws is not None: await self._ws.close() diff --git a/pocketstation/types.py b/pocketstation/types.py index 34d7000..588c8a2 100644 --- a/pocketstation/types.py +++ b/pocketstation/types.py @@ -36,7 +36,7 @@ def samples(self) -> list[float]: @dataclass class IceServer: - """ICE server configuration (ADR-023 embedded TURN).""" + """ICE server configuration (PY-023 embedded TURN).""" urls: list[str] username: Optional[str] = None credential: Optional[str] = None