diff --git a/README.md b/README.md index fa7b216..a6384b4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ delivery, and one recording file per stem. ```text desktop application ─┐ -microphone ──────────┼─ native Session ─┬─ Python model code -generated PCM ───────┘ ├─ Relay and a browser - └─ separate recording stems +system audio ────────┼─ native Session ─┬─ Python model code +microphone ──────────┤ ├─ Relay and a browser +generated PCM ───────┘ └─ separate recording stems ``` PocketStation runs capture, frame timing, routing, recording, and Relay @@ -44,6 +44,26 @@ match. The context manager starts one native Session and joins it when the block exits. No microphone opens and no file is written unless you request them. +## Capture the complete output mix + +Use a Session directly when the workflow intentionally needs every sound +playing through the computer: + +```python +import pocketstation as pks + +session = pks.Session() +system_audio = session.capture(pks.Source.system_audio()) +system_audio.send(session.polled_audio()) + +with session.start() as running: + for frame in running.audio: + print(frame.source_id, frame.stem_id) +``` + +Use `Source.application(...)` instead when unrelated desktop audio must remain +outside the Session. + ## Add a microphone or recording ```python @@ -307,7 +327,7 @@ Rust code. | Windows x86-64 and ARM64 | published wheels; Core selection and 10 ms capture in a Windows 11 ARM64 VM; physical-device and latency qualification remain separate | | WAN and TURN | not yet qualified | -Version 0.1.3 uses PocketStation Core 1.1.7 and the shared Relay Connector +Version 0.1.4 uses PocketStation Core 1.1.9 and the shared Relay Connector 0.1.5. Reading native audio into Python copies samples into Python-owned bytes before diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1c0bb66..d619442 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,28 @@ ## Unreleased +## 0.1.4 — 2026-09-04 + +Capture the complete desktop output mix with the same Python Session used for +selected applications and microphones: + +```python +session = pocketstation.Session() +system_audio = session.capture(pocketstation.Source.system_audio()) +system_audio.send(session.polled_audio()) +``` + +Use `Source.application(...)` when the user selected one application. Use +`Source.system_audio()` when the workflow intentionally needs every sound +playing through the computer. Both preserve source and stem identity for +iteration, Connectors, Relay, and recording. + +This release uses PocketStation Core 1.1.9 and Relay Connector 0.1.5. + +```console +python -m pip install --upgrade pocketstation==0.1.4 +``` + ## 0.1.3 — 2026-09-03 Configure route delivery with one consistent set of names across Python and diff --git a/docs/getting-started/capture.md b/docs/getting-started/capture.md index 1b3f12c..2a2958e 100644 --- a/docs/getting-started/capture.md +++ b/docs/getting-started/capture.md @@ -40,6 +40,28 @@ exits. The iterator receives audio through a native queue that holds 32 frames by default. If Python stops reading and the queue fills, PocketStation drops new frames and reports the loss. +## Capture all desktop output + +Use `Source.system_audio()` when the workflow needs every sound playing +through the computer. This is an explicit choice because notifications, music, +and unrelated applications can be included. + +```python +import pocketstation as pks + +session = pks.Session() +desktop = session.capture(pks.Source.system_audio()) +desktop.send(session.polled_audio()) + +with session.start() as running: + for frame in running.audio: + print(frame.source_id, frame.stem_id) +``` + +Use `Source.application("Zoom")` when the user selected one application and +other desktop audio must remain private. Do not open both sources unless the +application needs both; their audio can overlap. + ## Add a microphone or recording ```python diff --git a/docs/operations/platform-support.md b/docs/operations/platform-support.md index efc155c..769219d 100644 --- a/docs/operations/platform-support.md +++ b/docs/operations/platform-support.md @@ -6,7 +6,7 @@ follow the host operating system. ## Supported Python -PocketStation 0.1.2 supports CPython 3.11 and newer through one ABI3 extension +PocketStation 0.1.4 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. @@ -96,6 +96,8 @@ Choose fallback behavior explicitly: ## macOS Application capture needs screen and system-audio recording permission. +System-audio capture uses the same operating-system recording permission and +includes the complete output mix. Microphone capture needs microphone permission. Restart the application after changing consent when macOS does not update the running process. @@ -105,15 +107,17 @@ microphone, the 10 ms voice profile, Relay, Chromium, and three recordings. ## Windows The release workflow builds Windows x64 and ARM64 wheels. Core selector and -10 ms correctness have been exercised in Windows 11 ARM64. VM scheduling is -not a physical-device latency result. +system-audio correctness at 10 ms and 20 ms have been exercised in an +interactive Windows 11 ARM64 VM. VM scheduling is not a physical-device +latency result. ## Linux The release workflow builds manylinux x86_64 and ARM64 wheels. Application -capture requires access to the logged-in PipeWire session. Microphone capture -uses ALSA. A service or container must receive those devices and session -permissions explicitly. +and system-audio capture require access to the logged-in PipeWire session. +System audio follows the default output monitor. Microphone capture uses ALSA. +A service or container must receive those devices and session permissions +explicitly. ## Separate correctness from performance diff --git a/docs/reference/api-map.md b/docs/reference/api-map.md index 16c0b9a..f0e663c 100644 --- a/docs/reference/api-map.md +++ b/docs/reference/api-map.md @@ -8,6 +8,7 @@ provider, Relay, and diagnostic APIs from the module that implements them. | Task | API | |---|---| | Capture one application | `pocketstation.capture` | +| Capture all desktop output | `Session.capture(Source.system_audio())` | | Declare a synchronous Session | `pocketstation.Session` | | Declare an asyncio Session | `pocketstation.aio.Session` | | Select a source | `pocketstation.Source` | diff --git a/native/Cargo.lock b/native/Cargo.lock index d875cb1..8b20408 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -240,9 +240,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -590,9 +590,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flagset" @@ -1413,9 +1413,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" -version = "1.1.7" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78d452027085b8556c77af89673e62815e81babe0b1f930bd8d26158427c5784" +checksum = "ba5b0d839d6ab60633c337dd58646eb09d8a4e215415471bb4283802a073d634" dependencies = [ "alsa", "cc", @@ -1437,7 +1437,7 @@ dependencies = [ [[package]] name = "pocketstation-python" -version = "0.1.3" +version = "0.1.4" dependencies = [ "pocketstation", "pocketstation-relay", diff --git a/native/Cargo.toml b/native/Cargo.toml index 3026496..e97f294 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pocketstation-python" -version = "0.1.3" +version = "0.1.4" edition = "2021" publish = false description = "Native PocketStation runtime bindings for the Python SDK" @@ -16,10 +16,10 @@ default = [] conformance-fixtures = ["pocketstation/conformance-fixtures"] [dependencies] -pocketstation = "=1.1.7" +pocketstation = "=1.1.9" pocketstation-relay = "=0.1.5" pyo3 = { version = "0.27", features = ["abi3-py311"] } [dev-dependencies] -pocketstation = { version = "=1.1.7", features = ["conformance-fixtures"] } +pocketstation = { version = "=1.1.9", features = ["conformance-fixtures"] } tempfile = "3" diff --git a/native/src/sources.rs b/native/src/sources.rs index fef7177..3f970a5 100644 --- a/native/src/sources.rs +++ b/native/src/sources.rs @@ -25,6 +25,7 @@ pub(crate) enum SourceDeclaration { platform: Platform, stable_key: String, }, + SystemAudio, MicrophoneDefault, MicrophoneId(String), } @@ -57,6 +58,7 @@ impl SourceDeclaration { ProcessId::new(*process_id), StableSourceId::new(*platform, SourceKind::Application, stable_key.clone()), )), + Self::SystemAudio => Source::system_audio(), Self::MicrophoneDefault => Source::microphone_default(), Self::MicrophoneId(device_id) => { Source::microphone(DeviceSelector::id(DeviceId::new(device_id.clone()))) @@ -290,6 +292,13 @@ impl PythonSource { }) } + #[staticmethod] + const fn system_audio() -> Self { + Self { + declaration: SourceDeclaration::SystemAudio, + } + } + #[staticmethod] const fn microphone_default() -> Self { Self { diff --git a/pyproject.toml b/pyproject.toml index bc2324a..355d327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "pocketstation" -version = "0.1.3" +version = "0.1.4" 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 21564d9..7475a8d 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.3" +__version__ = "0.1.4" __all__ = [ "RUNTIME_COMPATIBILITY", diff --git a/python/pocketstation/_api.py b/python/pocketstation/_api.py index fcd2d0f..5a0e715 100644 --- a/python/pocketstation/_api.py +++ b/python/pocketstation/_api.py @@ -322,7 +322,7 @@ SignalStream, ) -__version__ = "0.1.3" +__version__ = "0.1.4" __all__ = [ "RUNTIME_COMPATIBILITY", "STREAM_EOF", diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index dd6de57..18790f7 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -65,6 +65,8 @@ class Source: stable_key: str, ) -> Source: ... @staticmethod + def system_audio() -> Source: ... + @staticmethod def microphone_default() -> Source: ... @staticmethod def microphone_id(device_id: str) -> Source: ... diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py index 8cac81c..6ce371b 100644 --- a/python/pocketstation/compatibility.py +++ b/python/pocketstation/compatibility.py @@ -18,8 +18,8 @@ class RuntimeCompatibility: RUNTIME_COMPATIBILITY = RuntimeCompatibility( - sdk_version="0.1.3", - core_version="1.1.7", + sdk_version="0.1.4", + core_version="1.1.9", relay_connector_version="0.1.5", python_requires=">=3.11", python_abi="abi3-py311", diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index f06980a..d8b7471 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -453,6 +453,16 @@ def application_process_instance( ProcessInstanceSelector(process_id, stable_id), ) + @classmethod + def system_audio(cls) -> Source: + """Capture the audio playing through the host output devices.""" + native = _native_call(_NativeSource.system_audio) + return cls( + native, + SourceKind.SYSTEM_MIX, + SourceSelectorKind.SYSTEM_MIX, + ) + @classmethod def microphone_default(cls) -> Source: """Select the host default microphone for this Session open.""" @@ -478,8 +488,7 @@ def microphone_id(cls, device_id: str) -> Source: def from_discovered(cls, source: DiscoveredSource) -> Source: """Build the strongest supported Session declaration from discovery. - Output devices and system mix remain discovery-only in the stable 1.1 - Session declaration requirements. + Output devices remain discovery-only. """ stable_id = source.stable_id if stable_id.kind is SourceKind.APPLICATION: @@ -495,6 +504,8 @@ def from_discovered(cls, source: DiscoveredSource) -> Source: ) if stable_id.kind is SourceKind.INPUT_DEVICE: return cls.microphone_id(source.device_uid or stable_id.stable_key) + if stable_id.kind is SourceKind.SYSTEM_MIX: + return cls.system_audio() raise PocketStationError( "discovered " f"{stable_id.kind.value!r} is not a frozen built-in Session Source", @@ -586,9 +597,9 @@ def application_capture_available() -> bool: def microphone_permission_observation() -> PermissionObservation: """Read microphone authorization without prompting. - Linux, Python hosts on Windows with Core 1.1.4, and any backend without an - authoritative query return :attr:`PermissionObservation.NOT_OBSERVABLE`; - callers must not reinterpret it as allowed or denied. + Linux, Python hosts on Windows, and any backend without an authoritative + query return :attr:`PermissionObservation.NOT_OBSERVABLE`; callers must + not reinterpret it as allowed or denied. """ observation = _native_call(_native_microphone_permission_observation) return PermissionObservation(observation) diff --git a/tests/test_aio_streams.py b/tests/test_aio_streams.py index dd376a6..7af5cba 100644 --- a/tests/test_aio_streams.py +++ b/tests/test_aio_streams.py @@ -4,7 +4,6 @@ import asyncio import threading -from time import monotonic import pocketstation._native as _native import pytest @@ -162,14 +161,12 @@ def operation() -> str: task = asyncio.create_task(_native_async(operation)) assert await asyncio.to_thread(entered.wait, 1.0) - started = monotonic() task.cancel() asyncio.get_running_loop().call_later(0.02, release.set) with pytest.raises(asyncio.CancelledError): await task - assert monotonic() - started >= 0.015 assert finished.is_set() diff --git a/tests/test_session.py b/tests/test_session.py index 5a11e0f..5e16e21 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -57,6 +57,7 @@ def test_given_selector_family_when_declared_then_each_shape_is_available(): "macos", "bundle:com.spotify.client", ) + assert Source.system_audio() assert Source.microphone_id("device-42") diff --git a/tests/test_sources.py b/tests/test_sources.py index 3038de8..40add83 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -56,11 +56,15 @@ def _discovered(kind: SourceKind) -> DiscoveredSource: def test_source_declarations_are_immutable_and_descriptive() -> None: application = Source.application("PocketStation Fixture") + system_audio = Source.system_audio() microphone = Source.microphone_default() assert application.kind is SourceKind.APPLICATION assert application.selector_kind is SourceSelectorKind.APPLICATION_NAME assert application.selector_value == "PocketStation Fixture" + assert system_audio.kind is SourceKind.SYSTEM_MIX + assert system_audio.selector_kind is SourceSelectorKind.SYSTEM_MIX + assert system_audio.selector_value is None assert microphone.kind is SourceKind.INPUT_DEVICE assert microphone.selector_kind is SourceSelectorKind.MICROPHONE_DEFAULT with pytest.raises(FrozenInstanceError): @@ -81,6 +85,13 @@ def test_discovered_input_device_lowers_to_microphone_id() -> None: assert selected.selector_value == "device-42" +def test_discovered_system_mix_lowers_to_system_audio() -> None: + selected = Source.from_discovered(_discovered(SourceKind.SYSTEM_MIX)) + + assert selected.selector_kind is SourceSelectorKind.SYSTEM_MIX + assert selected.kind is SourceKind.SYSTEM_MIX + + def test_discovered_source_projects_typed_pre_open_authorization_evidence() -> None: native_snapshot = SimpleNamespace( capability="available", diff --git a/uv.lock b/uv.lock index efc7167..11dcab2 100644 --- a/uv.lock +++ b/uv.lock @@ -746,7 +746,7 @@ wheels = [ [[package]] name = "pocketstation" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "httpx" },