diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e73c15..40178ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,8 @@ jobs: - name: Build wheel working-directory: sdk-python + env: + CARGO_TARGET_DIR: native/target-release run: maturin build --release --locked --out dist - name: Test isolated release wheel diff --git a/README.md b/README.md index 5970168..62e0bb5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,21 @@ Add `microphone=True` when you need the default microphone as a second independent stem. Add `record_to="recordings"` when you want each selected stem recorded. Both behaviors are off by default. +## Handle permissions and source changes + +PocketStation does not prompt during import or discovery. Check microphone +permission without prompting, then let Source opening report the authoritative +result. Application capture and microphone capture use separate permissions. + +When an application or device disappears, PocketStation reports the change and +does not switch to another Source. Stop the current Session, discover again, +confirm any changed selection, and create a new Session. Store a discovered +identity only for its reported persistence scope. Keep fallback and provider +retry policy explicit and finite. + +See [Prepare and qualify each Python platform](docs/operations/platform-support.md) +for the permission states, persistence scopes, and recovery sequence. + ## Debug a voice interruption [`examples/debug_voice_ai.py`](examples/debug_voice_ai.py) sends a physical @@ -135,7 +150,7 @@ with session.start(): The input uses finite preallocated Core buffers. Writes report full, closed, cancelled, and invalid-buffer outcomes explicitly. -## Build an integration +## Create an integration PocketStation uses four open boundaries: @@ -188,7 +203,7 @@ microphone.send_to(destination) PocketStation calls `start()` once, interleaves both source-aware stems through `send()`, and calls `stop()` once. A second Connector object creates a separate -destination. See [Build an integration](docs/guides/integrations.md) for +destination. See [Create an integration](docs/guides/integrations.md) for deadlines, failures, and the advanced SPI. Python provider callbacks execute on bounded off-realtime workers. They cannot @@ -253,6 +268,8 @@ uv run mypy python tests/qualification/typing_contract.py examples PCM input and selective output cancellation. - [Record and observe a Session](docs/guides/record-and-observe.md) — multistem outcomes, route metrics, and lifecycle events. +- [Prepare each platform](docs/operations/platform-support.md) — permissions, + source persistence, explicit rediscovery, and fallback policy. - [`examples/README.md`](examples/README.md) — runnable examples and prerequisites. - `pocketstation.capture` — concise application and microphone capture. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 66dba92..59af478 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,12 +1,15 @@ # PocketStation for Python release notes -## 0.1.1 — 2026-09-01 +## 0.1.2 — 2026-09-01 -Send audio to an external system with one function or one focused Python class. +Capture code can now send source-aware audio to an external system through one +function or one focused Python class. -### Added +This is the first PocketStation release published to PyPI. -Use `Connector(send=...)` when a destination is already open: +### Create a Connector + +Use `Connector(send=...)` when the destination is already open: ```python async def send_audio(frame): @@ -16,11 +19,10 @@ destination = pocketstation.aio.Connector(send=send_audio) application.send_to(destination) ``` -Subclass `pocketstation.Connector` for finite synchronous integrations or -`pocketstation.aio.Connector` for network providers. Implement `start()`, -`send(frame)`, and `stop()`; PocketStation supplies the bounded Core routes, -source and stem identity, delivery observations, drain, abort, and joined -shutdown. +Subclass `pocketstation.Connector` for a synchronous destination or +`pocketstation.aio.Connector` for an asynchronous provider. The class owns its +provider connection. PocketStation owns the bounded routes, source and stem +identity, delivery observations, drain, abort, and joined shutdown. ```python class WebSocketConnector(pocketstation.aio.Connector): @@ -40,42 +42,31 @@ class WebSocketConnector(pocketstation.aio.Connector): await self.socket.close() ``` -One configured object can receive several stems through one provider -lifecycle: - -```python -destination = WebSocketConnector(url, token) -application.send_to(destination) -microphone.send_to(destination) -``` - -Two Connector objects remain separate destinations. Async provider calls use -finite startup, delivery, and shutdown deadlines. Provider failures retain the -lifecycle stage, stable error code, and retryability when supplied. +One Connector object can receive several stems through one provider lifecycle. +Create another object when a backup or second destination needs its own queue, +failure, and shutdown outcome. -### Changed +### Operate capture safely -`Connector`, `AudioFrame`, and their asyncio Connector entry points are now -available from the concise package namespaces. The manifest, driver, worker, -configuration-schema, and explicit Endpoint contracts remain in -`pocketstation.connector` for integrations that need the advanced SPI. - -The callback form also accepts `start=` and `stop=` when a small integration -needs lifecycle operations without a class. The existing -`Connector.with_driver()`, `Connector.with_worker()`, -`Connector.from_handler()`, and `Connector.from_audio_handler()` APIs remain -available without migration. +The guides now show how to inspect microphone permission without prompting, +persist a discovered source only for its reported scope, recover after a +process or device disappears, reject ambiguous application matches, and make +fallback and provider retry policy explicit. ### Upgrade ```console -python -m pip install --upgrade pocketstation==0.1.1 +python -m pip install --upgrade pocketstation==0.1.2 ``` -Python Connectors run outside native realtime partitions, but each Python frame -delivery crosses the interpreter boundary. Use the shared native Relay -Connector or a native extension when provider-side Python execution is not -appropriate for the workload. +Python Connectors run outside native realtime partitions, but each frame still +crosses the Python interpreter boundary. Use a native Connector when that cost +is not appropriate for the destination. + +## 0.1.1 — 2026-09-01 + +This version was tagged on GitHub but was not published to PyPI. Its changes are +included in 0.1.2. ## 0.1.0 — 2026-08-31 @@ -129,10 +120,7 @@ provider-history truncation as unavailable instead of inferring them. startup outcome. This avoids an unsafe repeated WinRT query in Core 1.1.4. - WAN and TURN behavior are not yet qualified. -### Compatibility and upgrade +### Distribution -This is the first public Python release. There is no earlier package migration. - -```console -python -m pip install pocketstation==0.1.0 -``` +This version was tagged on GitHub but was not published to PyPI. Its package +features are included in 0.1.2. diff --git a/docs/README.md b/docs/README.md index 3142969..815f497 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,13 +10,13 @@ need the lower-level API. - [Stream any application audio to a browser](../README.md#stream-any-application-audio-to-a-browser) - [Browse the runnable examples](../examples/README.md) -## Build an integration +## Create an integration - [Write application-owned audio into a Session](guides/application-audio.md) - [Record stems and inspect Session delivery](guides/record-and-observe.md) - [Compose a bounded voice workflow](guides/voice.md) - [Publish a named AudioBus through Relay](guides/relay.md) -- [Build a Source, Operator, Connector, or Endpoint](guides/integrations.md) +- [Create a Source, Operator, Connector, or Endpoint](guides/integrations.md) ## Understand the system diff --git a/docs/getting-started/capture.md b/docs/getting-started/capture.md index d51486d..0e6d44c 100644 --- a/docs/getting-started/capture.md +++ b/docs/getting-started/capture.md @@ -30,6 +30,11 @@ Replace `Spotify` with a display name or application identifier. Pass a positive integer, such as `application=1234`, when you already have a process ID. Selection must resolve one running application before the Session starts. +A process ID lasts only for that process instance. If the application restarts, +discover it again. For a saved selection, use +`pocketstation.discover_sources()` and `Source.from_discovered()` as described +in [Persist a source at its supported scope](../operations/platform-support.md#persist-a-source-at-its-supported-scope). + The context manager starts one native Session and joins it when the block exits. The iterator reads a finite native Endpoint; it does not create an unbounded Python audio queue. diff --git a/docs/guides/integrations.md b/docs/guides/integrations.md index 3fa2483..57de06e 100644 --- a/docs/guides/integrations.md +++ b/docs/guides/integrations.md @@ -1,4 +1,4 @@ -# Build a Source, Operator, Connector, or Endpoint +# Create a Source, Operator, Connector, or Endpoint Choose the boundary by the direction and ownership of the work. All four use the same Session compiler, finite queues, lifecycle, observations, and joined @@ -20,7 +20,7 @@ owns Session lifecycle, route bounds, lineage, observations, and shutdown. Python integrations run off realtime. They must not capture audio again, create another Session, or hide an unbounded queue behind a provider callback. -## What a Connector solves +## Choose a Connector for outbound delivery A Connector is the outbound boundary between one PocketStation Session and an external system. Use it to publish source-aware audio to a WebSocket, call diff --git a/docs/operations/platform-support.md b/docs/operations/platform-support.md index f9d7a40..0d6722b 100644 --- a/docs/operations/platform-support.md +++ b/docs/operations/platform-support.md @@ -6,10 +6,92 @@ follow the host operating system. ## Supported Python -PocketStation 0.1.1 supports CPython 3.11 and newer through one ABI3 extension +PocketStation 0.1.2 supports CPython 3.11 and newer through one ABI3 extension per operating system and architecture. Install the wheel that matches the host; do not rely on a sibling Rust checkout. +## Check permission without prompting + +`pocketstation.microphone_permission_observation()` reads the current state +without showing consent UI: + +```python +import pocketstation as pks + +permission = pks.microphone_permission_observation() +print(permission.value) +``` + +`DENIED`, `RESTRICTED`, and `REVOKED` require a host or user action. +`NOT_DETERMINED` means the host has not decided. `NOT_OBSERVABLE` means the +platform cannot provide an authoritative preflight result; it must not be +treated as approval. + +PocketStation does not prompt on import or discovery. Start capture from a +clear user action so the operating system can present consent when supported. +Opening the Source is authoritative and returns a typed failure when capture +cannot start. Application capture and microphone capture use separate +permissions, so enable the microphone only when the workflow needs it. + +## Persist a source at its supported scope + +Use discovery when the application needs to remember a selection or reopen it +after a restart: + +```python +import pocketstation as pks + +matches = pks.discover_sources(pks.SourceQuery.application("Zoom")) +if len(matches) != 1: + raise RuntimeError("Select one running Zoom source") + +discovered = matches[0] +source = pks.Source.from_discovered(discovered) +print(discovered.selector_persistence_scope) +``` + +`Source.from_discovered()` chooses the strongest supported declaration: an +exact process instance plus stable application identity when both exist, a +stable application identity when no process is available, or a native +microphone device ID. + +Persist the platform, source kind, stable key, and persistence scope only when +the scope matches the workflow: + +| Persistence scope | Safe reuse | +|---|---| +| `APPLICATION_IDENTITY` | Reopen the application identity after a normal restart, then verify discovery still resolves it. | +| `DEVICE_IDENTITY` | Reopen the same installed and permitted device. | +| `PLATFORM_IDENTITY` | Reuse the platform-owned identity on that platform. | +| `PROCESS_LIFETIME` | Reuse only while the exact process is alive. Rediscover after exit. | +| `SESSION_DEFAULT_DEVICE` | Follow the host default on each new Session; do not treat it as a pinned device. | +| `None` | Do not persist the selector. Rediscover before the next Session. | + +`source_id` belongs to media lineage. It is not a portable account identifier +or a substitute for the discovered stable key. + +## Recover without selecting the wrong source + +PocketStation does not switch Sources silently. A source disappearance or +backend failure appears in the Session event stream. When +`recovery_requirement` is +`EXPLICIT_REDISCOVERY_AND_NEW_SESSION`, stop or cancel the current Session, +discover again, let the user confirm any changed selection, and create a new +Session. + +Choose fallback behavior explicitly: + +- prefer a stored application identity, then require user confirmation before + falling back to a unique display-name result; +- use `Source.microphone_id()` to pin one device or + `Source.microphone_default()` to follow the host default on each Session; +- reject zero or multiple discovery matches; +- use separate Connector objects for primary and backup destinations so their + queues, failures, and shutdown outcomes remain independent; and +- keep provider retries and reconnect attempts finite. The advanced Connector + API reports readiness, health, and recovery but does not invent a provider + retry policy. + ## macOS Application capture needs screen and system-audio recording permission. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 821dd36..ea0cc36 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,6 +16,24 @@ recording, and receiver playout as separate observations. Do not treat an empty iterator as proof of silence when source opening failed. +If the application restarted, do not reuse its old process ID. Stop the current +Session, discover the source again, and create a new Session with +`Source.from_discovered()`. PocketStation reports +`EXPLICIT_REDISCOVERY_AND_NEW_SESSION` instead of silently selecting another +process or device. + +## Permission changes are not reflected + +`microphone_permission_observation()` never prompts. Treat `NOT_OBSERVABLE` as +unknown and use the typed Source open result. On macOS, restart the Python host +after changing screen-recording or microphone consent. On Windows and Linux, +verify that the current desktop user or service can access the selected audio +session and device. + +Do not fall back from denied application capture to system mix or the default +microphone without a visible user choice. The fallback changes what audio the +application receives. + ## Python misses frames Inspect route capacity, queue depth, delivered frames, drops, and diff --git a/native/Cargo.lock b/native/Cargo.lock index 6919b3f..6cac869 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1437,7 +1437,7 @@ dependencies = [ [[package]] name = "pocketstation-python" -version = "0.1.1" +version = "0.1.2" dependencies = [ "pocketstation", "pocketstation-relay", diff --git a/native/Cargo.toml b/native/Cargo.toml index 1cba288..931ba9a 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pocketstation-python" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false description = "Native PocketStation runtime bindings for the Python SDK" diff --git a/pyproject.toml b/pyproject.toml index b432fce..886926d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "pocketstation" -version = "0.1.1" +version = "0.1.2" description = "Source-aware live audio capture, processing, and routing for Python" readme = "README.md" requires-python = ">=3.11" diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index dd88d48..d75b983 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -16,7 +16,7 @@ from .session import RecordingOutcome, RunningSession, Session, StopResult from .sources import Source, discover_sources -__version__ = "0.1.1" +__version__ = "0.1.2" __all__ = [ "RUNTIME_COMPATIBILITY", diff --git a/python/pocketstation/_api.py b/python/pocketstation/_api.py index 5fd80bc..753fd47 100644 --- a/python/pocketstation/_api.py +++ b/python/pocketstation/_api.py @@ -321,7 +321,7 @@ SignalStream, ) -__version__ = "0.1.1" +__version__ = "0.1.2" __all__ = [ "RUNTIME_COMPATIBILITY", "STREAM_EOF", diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py index 18cac6e..99a3c0b 100644 --- a/python/pocketstation/compatibility.py +++ b/python/pocketstation/compatibility.py @@ -18,7 +18,7 @@ class RuntimeCompatibility: RUNTIME_COMPATIBILITY = RuntimeCompatibility( - sdk_version="0.1.1", + sdk_version="0.1.2", core_version="1.1.4", relay_connector_version="0.1.2", python_requires=">=3.11", diff --git a/uv.lock b/uv.lock index ee35916..f183881 100644 --- a/uv.lock +++ b/uv.lock @@ -746,7 +746,7 @@ wheels = [ [[package]] name = "pocketstation" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "httpx" },