Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
8 changes: 5 additions & 3 deletions docs/architecture/PocketStation-v2.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions docs/standards/FAKE_SCAFFOLD_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion pocketstation/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
78 changes: 63 additions & 15 deletions pocketstation/station.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,41 @@
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__(
self,
*,
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:
Expand All @@ -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", ""),
Expand All @@ -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({
Expand All @@ -103,16 +153,14 @@ 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

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
Expand All @@ -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()
Expand Down
Loading