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/architecture/PocketStation-v2.3.md b/docs/architecture/PocketStation-v2.3.md index 65aee29..11aa92c 100644 --- a/docs/architecture/PocketStation-v2.3.md +++ b/docs/architecture/PocketStation-v2.3.md @@ -313,7 +313,7 @@ impl AudioBufferPool { **Hot path rules — enforced, not aspirational:** ``` -No heap allocation (verified by DHAT profiler in CI) +No heap allocation (verified by counting allocator gate in CI; full DHAT integration deferred — see FAKE_SCAFFOLD_INVENTORY) No locks (SPSC ring buffer + atomic pool bitset) No blocking (callback returns immediately) No logging (metrics are atomic counters, not log calls) @@ -1194,7 +1194,7 @@ pocketstation-io/app-desktop Tauri (Rust + web frontend) #### Tier 7 — Developer Tools ``` -pocketstation-io/cli `ps` command — room create, source sine/file, listen, latency, relay status +pocketstation-io/cli `pks` command — room create, source sine/file, listen, latency, relay status pocketstation-io/docs docs.pocketstation.io ``` @@ -1274,6 +1274,8 @@ Phase 1: Create relay + api-server. Create app-web-receiver. Signaling types in First crates.io publish of audio-core happens here, after demo works. Phase 2: Create sdk-ios. First SPM publish. Extract protocol repo when sdk-js or sdk-android needs stable generated types. + Note (actual): protocol repo created early (pre-Phase 2) with provisional + proto definitions. Wire contract not yet stable; JSON is the live contract. Phase 3: Create sdk-android. First Maven Central publish. Create cli. Phase 4: Create app-creator. Phase 5: Create audio-ml. Create sdk-js, sdk-rust, sdk-python as demand warrants. @@ -1377,7 +1379,7 @@ Rust crate API finalized Python SDK (PyO3 bindings) Documentation: 3 quickstart guides, architecture explanation, API reference 3 demo apps -CLI (`ps` command) +CLI (`pks` command) ``` Exit: diff --git a/docs/standards/FAKE_SCAFFOLD_INVENTORY.md b/docs/standards/FAKE_SCAFFOLD_INVENTORY.md index bca9ef4..c41ebde 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 | --- 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/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()