From d5bf8a77d86df469c87d4efc5630a952e829608a Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 15:37:19 -0400 Subject: [PATCH 01/49] feat: establish complete native Python SDK foundation --- .github/workflows/ci.yml | 105 +- .github/workflows/publish.yml | 34 - .gitignore | 9 + README.md | 409 ++- docs/standards/FAKE_SCAFFOLD_INVENTORY.md | 78 - native/Cargo.lock | 2816 +++++++++++++++++++++ native/Cargo.toml | 25 + native/build.rs | 73 + native/src/audio_input.rs | 213 ++ native/src/errors.rs | 102 + native/src/extensions.rs | 416 +++ native/src/graph.rs | 1357 ++++++++++ native/src/lib.rs | 32 + native/src/observations.rs | 2170 ++++++++++++++++ native/src/relay.rs | 127 + native/src/session.rs | 1271 ++++++++++ native/src/sidecar.rs | 485 ++++ native/src/signals.rs | 993 ++++++++ native/src/sources.rs | 444 ++++ native/src/streams.rs | 273 ++ pocketstation/__init__.py | 13 - pocketstation/station.py | 200 -- pocketstation/types.py | 80 - pyproject.toml | 28 +- python/pocketstation/__init__.py | 355 +++ python/pocketstation/_native.pyi | 916 +++++++ python/pocketstation/aio/__init__.py | 50 + python/pocketstation/aio/audio_input.py | 70 + python/pocketstation/aio/capture.py | 177 ++ python/pocketstation/aio/control.py | 223 ++ python/pocketstation/aio/extensions.py | 21 + python/pocketstation/aio/observations.py | 77 + python/pocketstation/aio/relay.py | 353 +++ python/pocketstation/aio/session.py | 389 +++ python/pocketstation/aio/sidecar.py | 146 ++ python/pocketstation/aio/sources.py | 42 + python/pocketstation/aio/streams.py | 238 ++ python/pocketstation/audio_input.py | 111 + python/pocketstation/capture.py | 173 ++ python/pocketstation/control.py | 381 +++ python/pocketstation/errors.py | 150 ++ python/pocketstation/extensions.py | 167 ++ python/pocketstation/graph.py | 923 +++++++ python/pocketstation/observations.py | 1095 ++++++++ python/pocketstation/py.typed | 0 python/pocketstation/relay.py | 498 ++++ python/pocketstation/session.py | 361 +++ python/pocketstation/sidecar.py | 398 +++ python/pocketstation/signal.py | 256 ++ python/pocketstation/sources.py | 452 ++++ python/pocketstation/streams.py | 308 +++ tests/_pkss_child.py | 101 + tests/run_installed_stream_conformance.py | 109 + tests/run_relay_e2e_publisher.py | 157 ++ tests/test_aio_capture.py | 95 + tests/test_aio_observations.py | 139 + tests/test_aio_relay.py | 103 + tests/test_aio_session.py | 57 + tests/test_aio_streams.py | 241 ++ tests/test_capture.py | 126 + tests/test_control.py | 170 ++ tests/test_discovery.py | 64 + tests/test_extensions.py | 247 ++ tests/test_generated_audio.py | 98 + tests/test_graph.py | 273 ++ tests/test_lifecycle.py | 145 ++ tests/test_metrics.py | 97 + tests/test_native_module_structure.py | 71 + tests/test_observations.py | 140 + tests/test_package_structure.py | 75 + tests/test_permissions.py | 46 + tests/test_public_api.py | 217 ++ tests/test_realtime_boundary.py | 74 + tests/test_recording.py | 94 + tests/test_relay.py | 249 ++ tests/test_session.py | 59 + tests/test_sidecar.py | 185 ++ tests/test_signal_streams.py | 209 ++ tests/test_source_lifecycle.py | 119 + tests/test_sources.py | 152 ++ tests/test_station.py | 345 +-- tests/test_stream_state_machine.py | 140 + tests/test_streams.py | 207 ++ tests/test_types.py | 48 +- uv.lock | 467 ++++ 85 files changed, 24392 insertions(+), 810 deletions(-) delete mode 100644 .github/workflows/publish.yml delete mode 100644 docs/standards/FAKE_SCAFFOLD_INVENTORY.md create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/build.rs create mode 100644 native/src/audio_input.rs create mode 100644 native/src/errors.rs create mode 100644 native/src/extensions.rs create mode 100644 native/src/graph.rs create mode 100644 native/src/lib.rs create mode 100644 native/src/observations.rs create mode 100644 native/src/relay.rs create mode 100644 native/src/session.rs create mode 100644 native/src/sidecar.rs create mode 100644 native/src/signals.rs create mode 100644 native/src/sources.rs create mode 100644 native/src/streams.rs delete mode 100644 pocketstation/__init__.py delete mode 100644 pocketstation/station.py delete mode 100644 pocketstation/types.py create mode 100644 python/pocketstation/__init__.py create mode 100644 python/pocketstation/_native.pyi create mode 100644 python/pocketstation/aio/__init__.py create mode 100644 python/pocketstation/aio/audio_input.py create mode 100644 python/pocketstation/aio/capture.py create mode 100644 python/pocketstation/aio/control.py create mode 100644 python/pocketstation/aio/extensions.py create mode 100644 python/pocketstation/aio/observations.py create mode 100644 python/pocketstation/aio/relay.py create mode 100644 python/pocketstation/aio/session.py create mode 100644 python/pocketstation/aio/sidecar.py create mode 100644 python/pocketstation/aio/sources.py create mode 100644 python/pocketstation/aio/streams.py create mode 100644 python/pocketstation/audio_input.py create mode 100644 python/pocketstation/capture.py create mode 100644 python/pocketstation/control.py create mode 100644 python/pocketstation/errors.py create mode 100644 python/pocketstation/extensions.py create mode 100644 python/pocketstation/graph.py create mode 100644 python/pocketstation/observations.py create mode 100644 python/pocketstation/py.typed create mode 100644 python/pocketstation/relay.py create mode 100644 python/pocketstation/session.py create mode 100644 python/pocketstation/sidecar.py create mode 100644 python/pocketstation/signal.py create mode 100644 python/pocketstation/sources.py create mode 100644 python/pocketstation/streams.py create mode 100644 tests/_pkss_child.py create mode 100644 tests/run_installed_stream_conformance.py create mode 100644 tests/run_relay_e2e_publisher.py create mode 100644 tests/test_aio_capture.py create mode 100644 tests/test_aio_observations.py create mode 100644 tests/test_aio_relay.py create mode 100644 tests/test_aio_session.py create mode 100644 tests/test_aio_streams.py create mode 100644 tests/test_capture.py create mode 100644 tests/test_control.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_extensions.py create mode 100644 tests/test_generated_audio.py create mode 100644 tests/test_graph.py create mode 100644 tests/test_lifecycle.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_native_module_structure.py create mode 100644 tests/test_observations.py create mode 100644 tests/test_package_structure.py create mode 100644 tests/test_permissions.py create mode 100644 tests/test_public_api.py create mode 100644 tests/test_realtime_boundary.py create mode 100644 tests/test_recording.py create mode 100644 tests/test_relay.py create mode 100644 tests/test_session.py create mode 100644 tests/test_sidecar.py create mode 100644 tests/test_signal_streams.py create mode 100644 tests/test_source_lifecycle.py create mode 100644 tests/test_sources.py create mode 100644 tests/test_stream_state_machine.py create mode 100644 tests/test_streams.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e11319..b205509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,97 @@ -name: ci-python -on: [pull_request, push] +name: python-sdk-ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + jobs: - py: + test: + name: ${{ matrix.os }} / Python ${{ matrix.python }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.11", "3.13"] + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Check out Python SDK + uses: actions/checkout@v4 with: - python-version: '3.12' - - name: Upgrade pip - run: python -m pip install --upgrade pip - - name: Install package (non-Windows) - if: runner.os != 'Windows' - run: python -m pip install -e ".[dev]" - - name: Install package (Windows) - if: runner.os == 'Windows' - run: python -m pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org -e ".[dev]" - - run: python -m pytest + path: sdk-python + + - name: Check out frozen Rust core + uses: actions/checkout@v4 + with: + repository: pocketstation-io/pocketstation + ref: pocketstation-v1.1.1 + path: pocketstation + + - name: Install Rust 1.95 + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + components: clippy, rustfmt + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install Linux native dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes libasound2-dev libpipewire-0.3-dev + + - name: Install SDK and development gates + working-directory: sdk-python + run: | + python -m pip install --upgrade pip + python -m pip install "maturin>=1.9.4,<2.0" "pytest>=8.0" "pytest-asyncio>=0.23" "mypy>=1.15" "ruff>=0.11" + maturin develop --release --locked --features conformance-fixtures + + - name: Rust formatting + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' + working-directory: sdk-python + run: cargo fmt --manifest-path native/Cargo.toml -- --check + + - name: Native conformance + working-directory: sdk-python + run: cargo test --manifest-path native/Cargo.toml --all-features --locked + + - name: Native lint + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' + working-directory: sdk-python + run: cargo clippy --manifest-path native/Cargo.toml --all-targets --all-features --locked -- -D warnings + + - name: Python lint and formatting + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' + working-directory: sdk-python + run: | + ruff check python tests + ruff format --check python tests + + - name: Python type contract + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' + working-directory: sdk-python + run: mypy python + + - name: Python tests + working-directory: sdk-python + run: python -m pytest + + - name: Build wheel + working-directory: sdk-python + run: maturin build --release --locked --out dist + + - name: Upload wheel for inspection + uses: actions/upload-artifact@v4 + with: + name: pocketstation-${{ matrix.os }}-py${{ matrix.python }} + path: sdk-python/dist/*.whl + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index ecf55bd..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: publish-pypi - -on: - push: - tags: - - 'v*' - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install build tools - run: pip install build twine - - - name: Build distribution - run: python -m build - - - name: Check distribution - run: python -m twine check dist/* - - - name: Upload to PyPI - run: python -m twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 0c698b1..46239e4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,18 @@ __pycache__/ *.egg-info/ *.egg .eggs/ +*.so +*.pyd .pytest_cache/ .tox/ .venv/ + +# Private execution and development records +/PHASE*_PROGRESS.md +/docs/PYTHON_CAPABILITY_MATRIX.md +/docs/PYTHON_SDK_DESIGN.md +/docs/standards/FAKE_SCAFFOLD_INVENTORY.md +/tests/test_capability_contract.py venv/ .mypy_cache/ .ruff_cache/ diff --git a/README.md b/README.md index 217e1a4..6edae50 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,403 @@ -# sdk-python +# PocketStation for Python -**Organization:** `pocketstation-io` -**Repository:** `pocketstation-io/sdk-python` -**v2.3 tier:** Tier 2 — Client SDKs -**Activation phase:** Phase 5 -**Language/package:** Python/PyPI -**Release strategy:** PyPI pocketstation +> **Development status: PARTIAL.** This is not yet the complete Python SDK and +> does not have full Rust capability parity. The binding program has accepted +> the exhaustive capability matrix, Pythonic stream slice, package ownership, +> typed source lifecycle, Rust-backed graph declarations, bounded typed signal +> streams, process sidecars, compiled native extensions, complete observations, +> application-owned PCM ingress, and independently installable wheel/sdist +> artifacts. Real relay/browser composition, notebook proof, platform +> qualification, and OSS readiness remain gated. -This is an independently releasable PocketStation v2.3 repository folder. It is not meant to be merged permanently into a monorepo. +Capture one application and one microphone as independent, source-aware live +audio stems. Consume both from a bounded Python endpoint while the native Rust +runtime records each stem separately and preserves lineage, timing, drops, and +discontinuities. The same Session model extends to devices and explicit network +endpoints without changing the identity contract. -Agents must respect the phase gate in `docs/REPO_CONTRACT.md`. +```python +import pocketstation + +with pocketstation.capture( + application="PocketStation Demo", + microphone=True, + record_to="recordings", +) as live: + for frame in live.audio: + print(frame) +``` + +The concise recipe and the explicit API use the same native `Session`. Python +does not reimplement capture, routing, timing, backpressure, or recording. + +## Current implementation truth + +The accepted stream, structure, source-lifecycle, graph, typed-signal, +extension, and sidecar slices are `SAFE-TO-MERGE`; the SDK as a whole remains +`PARTIAL`. Their component and +canonical-Session evidence is not release evidence: + +- native application and microphone declarations; +- independent stem and source identity on every delivered frame; +- one bounded managed-language polling boundary; +- frame-first sync and async streams that lazily flatten one native batch, + reject mixed reader modes, and fail immediately on concurrent readers; +- typed sync and async lifecycle/failure event streams backed by bounded native + waits rather than user-written polling loops; +- immutable sync and async source discovery over the canonical Rust provider, + including stable source identity, selector persistence, process-tree scope, + and exact native state; +- seven-state microphone permission observation with no implicit prompt and no + boolean collapse of `NotObservable`; +- typed source-unavailable/backend-failure events carrying source identity, + generation, failure detail, and the explicit recovery requirement; +- canonical Rust-backed `SignalSpec`, media, port, edge, operator, source, and + endpoint declarations with open stable identifiers and named ports; +- bounded application-owned float32 PCM input with preallocated Core buffers, + explicit full/closed/cancelled/invalid outcomes, source/stream identity, + discontinuity propagation, sync/async writers, and normal Session fan-out; +- operator chaining and concrete generated-audio reentry through the Rust + compiler/runtime, preserving lineage and recording without a Python hot-path + callback; +- real bounded `BusSubscription` endpoints for PCM, text, and bytes with + Pythonic sync/async read and iteration, immutable payloads, complete generic + timing/lineage/derivation, and distinct timeout, EOF, fault, and close states; +- immutable native extension descriptors and trusted absolute-path compiled + libraries validated by the linked ABI 1.2 authority, transactionally imported + into the canonical native `Session`, and retained for its full lifetime; +- typed process-sidecar specs, messages, sync/async streams, bounded queue + saturation, deadlines, cancellation, graceful close, forced kill, wait, reap, + and live/final observations, all owned by the same native `Session`; +- native blocking waits release the interpreter, and executable tests prove + Python remains responsive while a hung child is terminated and reaped; +- immutable Session snapshots covering event and audio queues, source ingress, + routes, operator inputs/workers, external sources, derived routes, and + generated-audio reentry with capacities, bytes, depths, peaks, loss causes, + discontinuities, and named nanosecond latency boundaries; +- bounded native trace configuration, final recorder accounting, offline read, + rolling hash, and deterministic lifecycle/terminal validation; +- deterministic, idempotent Python shutdown; +- distinct stop/cancel dispositions with the native terminal event retained so + source, endpoint, rollback, and finalization fault categories are not lost; +- complete/incomplete per-stem recording outcomes with stable error codes, + queue/write/drop counters, and typed gap detail; +- synchronous and `asyncio` ownership models; +- typed Session lifecycle client for the current control-plane HTTP API. + +The SDK is not complete: + +- Core's normal public API does not name raw trace-record values or the typed + component/stage enums behind rollback and finalization failures. Python + therefore exposes validated trace summary/terminal truth and stable failure + stages, but does not claim a stable raw-record or fully typed control-failure + owner projection; +- capture authorization snapshots and permission-transition ownership are not + attached to the canonical running Session; the SDK preserves discovery and + the authoritative seven-state platform observation without inventing either; +- real relay/browser composition and notebook proof remain later gates; +- isolated macOS wheel and independently rebuilt sdist consumers exist; Linux, + Windows, and real-device matrices remain release gates; +- the control client creates remote Session credentials but does not publish + media or invent a browser join URL. + +The ordinary API is frame iteration over the native bounded endpoint; explicit +reads and native batch iteration remain advanced modes. The accepted stream +gate proves that no second Python queue exists and reader modes cannot be mixed +or consumed concurrently. Python is never invoked from an audio callback or +realtime partition. + +## Compiled native extensions + +Load a trusted C or Rust dynamic library before starting the Session. This is a +raw native-code trust boundary, not package authentication: PocketStation does +not verify its publisher, signature, or checksum and does not sandbox it. The +path must be absolute; Core canonicalizes it, validates the ABI and complete +descriptor set, imports every registration transactionally, and retains the +library until the Session is destroyed. + +```python +from pathlib import Path + +import pocketstation + +session = pocketstation.Session() +library = session.load_native_extension_library( + Path("extensions/libacme_processor.dylib").resolve() +) + +source = session.source("acme.source") +operator = session.operator(pocketstation.Operator("acme.operator")) +source.output("out").connect(operator.input("in")) +``` + +The receipt exposes the canonical path and immutable source, operator, and +endpoint registrations. Loading is a synchronous pre-start declaration in +both `pocketstation.Session` and `pocketstation.aio.Session`; it does not run +Python in foreign callbacks or admit PCM callbacks onto realtime partitions. +Python-authored extensions use the Session-owned process-sidecar contract. + +The capability matrix distinguishes declaration-level `REAL` rows from +component-only `PARTIAL` rows and completely `ABSENT` projections. A row marked +`REAL` is evidence-scoped; it does not upgrade the SDK, a platform, or a +deployment to production readiness. + +## Application-owned audio + +When an application already owns PCM, feed it directly into the Session instead +of recapturing the application through the operating system: + +```python +from array import array + +from pocketstation import Session + +session = Session() +playback = session.audio_input("playback", frame_samples_per_channel=480) +playback.output.send(session.polled_audio()) +playback.output.record("playback") + +with session.start() as running: + playback.write(array("f", [0.0] * 480)) + frame = running.audio.read(timeout_s=1.0) +``` + +`write()` accepts one C-contiguous float32 frame and never grows the native +queue. Advanced integrations can use `Session.pcm_source(AudioInputConfig(...))` +to retain explicit source-output and writer ownership. The asyncio API exposes +the same contract without executing Python on realtime partitions. + +## Explicit Session API + +Use the explicit surface when route identifiers and lifecycle control matter: + +```python +from itertools import islice + +from pocketstation import Session, Source + +session = Session(recording_root="recordings") +application = session.capture(Source.application("PocketStation Demo")) +microphone = session.capture(Source.microphone_default()) +audio = session.polled_audio() + +application_route = application.send(audio) +microphone_route = microphone.send(audio) +application.record("application") +microphone.record("microphone") + +with session.start() as running: + for frame in islice(running.audio, 500): + print(frame) + + metrics = running.metrics() + print(metrics.polled_audio.queue_capacity_frames) + print(metrics.polled_audio.queue_full_drops_total) + for route in metrics.routes: + print(route.route_id, route.edge.frames_dropped_total) + +stop = running.stop_result +assert stop is not None +recording = stop.recording +terminal = stop.terminal_event +``` + +Enable a finite diagnostic trace at Session declaration time and validate it +offline after shutdown: + +```python +from pocketstation import Session, SessionTrace, SessionTraceConfiguration + +session = Session(trace=SessionTraceConfiguration("session.trace", 256)) +# declare sources and routes, start, then stop the Session +trace = SessionTrace.read("session.trace") +validation = trace.validate() +print(validation.terminal_state, trace.outcome.rolling_hash) +``` + +Snapshots and outcomes are frozen, slotted Python values copied from the +canonical native owner. PocketStation does not start an exporter or a Python +telemetry thread; a future OpenTelemetry adapter must remain optional and +outside the realtime runtime. + +Application selectors also support bundle ID, process ID, stable source ID, and +an exact process instance. Microphones support the default device or a stable +device ID. + +## Typed graph declarations + +The expert surface remains Pythonic without creating a Python graph engine. +Each declaration immediately becomes an opaque handle in the same Rust +`Session`; the Rust compiler remains authoritative for registration, ports, +media, exclusivity, and route errors. + +```python +from pocketstation import Operator, Session, Source + +session = Session(recording_root="recordings") +microphone = session.capture(Source.microphone_default()) +transcribed = microphone.through( + Operator("org.example.transcriber.v1"), + input_port="audio-in", + output_port="transcript", +) +transcribed.send( + session.connector("org.example.transcript-sink.v1"), + input_port="events", +) +``` + +`SignalSpec`, `MediaCaps`, `PortSpec`, and `EdgeContract` project the canonical +Rust value contracts. Operator and connector IDs remain open strings—there is +no closed model/provider enum. Generated PCM uses +`derived.reenter_audio()` and returns a normal source-aware `Stem`; concrete +media and exclusive consumption are checked before runtime start. + +## Typed signal subscriptions + +Operators and external sources can expose non-audio signals without creating a +second Python graph or queue. A subscription is a real bounded endpoint in the +same Rust `Session`: + +```python +from pocketstation import Operator, Session, SignalSpec, Source + +session = Session() +microphone = session.capture(Source.microphone_default()) +transcript = microphone.through( + Operator("org.example.transcriber.v1"), + input_port="audio-in", + output_port="transcript", +) +subscription = session.subscribe(transcript, signal=SignalSpec.text()) + +with session.start() as running: + for envelope in running.signals(subscription): + print(envelope.payload, envelope.lineage, envelope.derivation) +``` + +`read()` returns `None` only when its bounded wait expires and `STREAM_EOF` +after permanent endpoint closure. Faults raise `StreamError`. A stream fixes its +reader mode on first use and rejects concurrent readers. The default edge is a +finite bounded-async contract with media inferred from the exact `SignalSpec`. + +## Discovery and permission truth + +Discovery is a point-in-time immutable snapshot from the Rust source provider; +Python does not maintain a second registry or reinterpret platform state: + +```python +import pocketstation + +for source in pocketstation.discover_sources( + pocketstation.SourceQuery.kind(pocketstation.SourceKind.APPLICATION) +): + print(source.stable_id, source.state, source.identity_strength) + +permission = pocketstation.microphone_permission_observation() +if permission is pocketstation.PermissionObservation.NOT_DETERMINED: + print("The host application must request permission explicitly.") +``` + +Observation never prompts. macOS and eligible Windows application contexts can +provide authoritative states; Linux and other backends return +`NOT_OBSERVABLE` unless the native backend can establish authority. That value +does not mean allowed or denied. Source disappearance is delivered through +`running.events` with the stable identity, source generation, failure detail, +and an explicit `EXPLICIT_REDISCOVERY_AND_NEW_SESSION` recovery requirement. + +Each `AudioFrame.samples` is a read-only `memoryview` over owned little-endian +`f32` PCM bytes. The view adds no further copy, but transferring a realtime +frame into Python ownership does copy it out of the native bounded batch. Use +`numpy.frombuffer(frame.samples, dtype=" Option { + if let Some(python) = env::var_os("PYO3_PYTHON") { + return Some(python); + } + for environment in ["VIRTUAL_ENV", "CONDA_PREFIX"] { + if let Some(prefix) = env::var_os(environment) { + let python = PathBuf::from(prefix).join("bin/python"); + if python.is_file() { + return Some(python.into_os_string()); + } + } + } + let workspace_python = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent()? + .join(".venv/bin/python"); + if workspace_python.is_file() { + return Some(workspace_python.into_os_string()); + } + Some(OsString::from("python3")) +} + +fn contains_python_dylib(directory: &Path) -> bool { + let Ok(entries) = fs::read_dir(directory) else { + return false; + }; + entries.filter_map(Result::ok).any(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with("libpython") && name.ends_with(".dylib") + }) +} diff --git a/native/src/audio_input.rs b/native/src/audio_input.rs new file mode 100644 index 0000000..9cc917b --- /dev/null +++ b/native/src/audio_input.rs @@ -0,0 +1,213 @@ +use std::sync::Mutex; + +use pocketstation::{ + AudioInput, AudioInputConfig, AudioInputObservations, AudioInputWriteError, + AudioInputWriteErrorKind, +}; +use pyo3::buffer::PyBuffer; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use crate::errors::coded_reason; +use crate::graph::PythonSourceOutput; + +#[pyclass(name = "_AudioInputObservations", frozen)] +pub(crate) struct PythonAudioInputObservations { + observations: AudioInputObservations, +} + +#[pymethods] +impl PythonAudioInputObservations { + #[getter] + fn capacity_frames(&self) -> u64 { + self.observations.capacity_frames + } + + #[getter] + fn buffer_slots(&self) -> u64 { + self.observations.buffer_slots + } + + #[getter] + fn available_buffers(&self) -> u64 { + self.observations.available_buffers + } + + #[getter] + fn accepted_total(&self) -> u64 { + self.observations.accepted_total + } + + #[getter] + fn full_total(&self) -> u64 { + self.observations.full_total + } + + #[getter] + fn invalid_total(&self) -> u64 { + self.observations.invalid_total + } + + #[getter] + fn cancelled(&self) -> bool { + self.observations.cancelled + } + + #[getter] + fn closed(&self) -> bool { + self.observations.closed + } +} + +#[pyclass(name = "_AudioInput")] +pub(crate) struct PythonAudioInput { + input: Mutex, +} + +impl PythonAudioInput { + pub(crate) const fn new(input: AudioInput) -> Self { + Self { + input: Mutex::new(input), + } + } + + fn with_input( + &self, + operation: impl FnOnce(&mut AudioInput) -> PyResult, + ) -> PyResult { + let mut input = self.input.lock().map_err(|_| { + PyRuntimeError::new_err(coded_reason( + "audio_input.state_unavailable", + "audio input state is unavailable", + )) + })?; + operation(&mut input) + } +} + +#[pymethods] +impl PythonAudioInput { + #[getter] + fn source_id(&self) -> PyResult { + self.with_input(|input| Ok(input.source().source_id().get())) + } + + #[getter] + fn stream_id(&self) -> PyResult { + self.with_input(|input| Ok(input.output().stream_id().get())) + } + + #[getter] + fn output(&self) -> PyResult { + self.with_input(|input| { + Ok(PythonSourceOutput { + handle: input.output().clone(), + }) + }) + } + + #[pyo3(signature = (samples, *, discontinuity=false))] + fn try_write( + &self, + py: Python<'_>, + samples: PyBuffer, + discontinuity: bool, + ) -> PyResult<()> { + let source = samples.as_slice(py).ok_or_else(|| { + PyValueError::new_err(coded_reason( + "audio_input.invalid_buffer", + "samples must be a C-contiguous float32 buffer", + )) + })?; + self.with_input(|input| { + let mut buffer = input.try_acquire().map_err(audio_input_acquire_error)?; + buffer + .try_set_sample_count(source.len()) + .map_err(|error| invalid_buffer(error.to_string()))?; + for (destination, value) in buffer.samples_mut().iter_mut().zip(source) { + *destination = value.get(); + } + if discontinuity { + buffer.mark_discontinuity(); + } + input.try_send(buffer).map_err(audio_input_write_error) + }) + } + + fn close(&self) -> PyResult<()> { + self.with_input(|input| { + input.close(); + Ok(()) + }) + } + + fn observations(&self) -> PyResult { + self.with_input(|input| { + Ok(PythonAudioInputObservations { + observations: input.observations(), + }) + }) + } +} + +fn audio_input_acquire_error(error: pocketstation::AudioInputBufferAcquireError) -> PyErr { + let (code, message) = match error { + pocketstation::AudioInputBufferAcquireError::Full => { + ("audio_input.full", "audio input is full") + } + pocketstation::AudioInputBufferAcquireError::Closed => { + ("audio_input.closed", "audio input is closed") + } + pocketstation::AudioInputBufferAcquireError::Cancelled => { + ("audio_input.cancelled", "audio input Session was cancelled") + } + }; + PyRuntimeError::new_err(coded_reason(code, message)) +} + +fn audio_input_write_error(error: AudioInputWriteError) -> PyErr { + let code = match error.kind() { + AudioInputWriteErrorKind::Full => "audio_input.full", + AudioInputWriteErrorKind::Closed => "audio_input.closed", + AudioInputWriteErrorKind::Cancelled => "audio_input.cancelled", + AudioInputWriteErrorKind::InvalidBuffer(_) => "audio_input.invalid_buffer", + }; + let message = error.to_string(); + match error.kind() { + AudioInputWriteErrorKind::InvalidBuffer(_) => invalid_buffer(message), + _ => PyRuntimeError::new_err(coded_reason(code, message)), + } +} + +fn invalid_buffer(message: String) -> PyErr { + PyValueError::new_err(coded_reason("audio_input.invalid_buffer", message)) +} + +pub(crate) fn configuration( + sample_rate_hz: u32, + channels: u8, + capacity_frames: usize, + frame_samples_per_channel: usize, +) -> PyResult { + AudioInputConfig::new( + pocketstation::SampleSpec::new( + sample_rate_hz, + channels, + pocketstation::SampleFormat::F32Interleaved, + ), + capacity_frames, + frame_samples_per_channel, + ) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "audio_input.invalid_configuration", + error.to_string(), + )) + }) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/errors.rs b/native/src/errors.rs new file mode 100644 index 0000000..8431755 --- /dev/null +++ b/native/src/errors.rs @@ -0,0 +1,102 @@ +use pocketstation::{Platform, SessionStartError}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +#[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. +pub(crate) fn session_error(error: pocketstation::SessionError) -> PyErr { + PyValueError::new_err(coded_reason( + pocketstation::session_declaration_error_code(&error).as_str(), + error.to_string(), + )) +} + +#[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. +pub(crate) fn session_endpoint_error(error: pocketstation::SessionEndpointError) -> PyErr { + PyRuntimeError::new_err(coded_reason( + "session.endpoint_registration_unavailable", + error.to_string(), + )) +} + +#[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. +pub(crate) fn session_start_error(error: SessionStartError) -> PyErr { + PyRuntimeError::new_err(coded_reason(error.code().as_str(), error.to_string())) +} + +#[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. +pub(crate) fn native_extension_error( + error: pocketstation::native_extension::NativeExtensionLibraryError, +) -> PyErr { + use pocketstation::native_extension::NativeExtensionLibraryErrorCode; + + let code = match error.code() { + NativeExtensionLibraryErrorCode::PathNotAbsolute => "extension.path_not_absolute", + NativeExtensionLibraryErrorCode::PathCanonicalizationFailed => { + "extension.path_canonicalization_failed" + } + NativeExtensionLibraryErrorCode::PathNotFile => "extension.path_not_file", + NativeExtensionLibraryErrorCode::LibraryLoadFailed => "extension.library_load_failed", + NativeExtensionLibraryErrorCode::EntrypointMissing => "extension.entrypoint_missing", + NativeExtensionLibraryErrorCode::EntrypointPanicked => "extension.entrypoint_panicked", + NativeExtensionLibraryErrorCode::EntrypointFailed => "extension.entrypoint_failed", + NativeExtensionLibraryErrorCode::UnsupportedAbiMajor => "extension.unsupported_abi_major", + NativeExtensionLibraryErrorCode::UnsupportedAbiMinor => "extension.unsupported_abi_minor", + NativeExtensionLibraryErrorCode::InvalidLibraryDescriptor => { + "extension.invalid_library_descriptor" + } + NativeExtensionLibraryErrorCode::RegistrationAcquisitionPanicked => { + "extension.registration_acquisition_panicked" + } + NativeExtensionLibraryErrorCode::RegistrationAcquisitionFailed => { + "extension.registration_acquisition_failed" + } + NativeExtensionLibraryErrorCode::InvalidRegistration => "extension.invalid_registration", + NativeExtensionLibraryErrorCode::DuplicateRegistration => { + "extension.duplicate_registration" + } + NativeExtensionLibraryErrorCode::RegistrationStateUnavailable => { + "extension.registration_state_unavailable" + } + }; + PyRuntimeError::new_err(coded_reason(code, error.to_string())) +} + +pub(crate) fn coded_reason(code: &str, reason: impl AsRef) -> String { + format!("[{code}] {}", reason.as_ref()) +} + +pub(crate) fn validate_nonempty(label: &str, value: &str) -> PyResult<()> { + if value.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_selector", + format!("{label} must not be empty"), + ))); + } + Ok(()) +} + +pub(crate) fn validate_process_id(process_id: u32) -> PyResult<()> { + if process_id == 0 { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_selector", + "application process ID must be non-zero", + ))); + } + Ok(()) +} + +pub(crate) fn parse_platform(value: &str) -> PyResult { + match value.trim().to_ascii_lowercase().as_str() { + "macos" => Ok(Platform::Macos), + "windows" => Ok(Platform::Windows), + "linux" => Ok(Platform::Linux), + "ios" => Ok(Platform::Ios), + "android" => Ok(Platform::Android), + "web" => Ok(Platform::Web), + "unknown" => Ok(Platform::Unknown), + _ => Err(PyValueError::new_err(coded_reason( + "session.invalid_selector", + "platform must be macos, windows, linux, ios, android, web, or unknown", + ))), + } +} diff --git a/native/src/extensions.rs b/native/src/extensions.rs new file mode 100644 index 0000000..9aab334 --- /dev/null +++ b/native/src/extensions.rs @@ -0,0 +1,416 @@ +use std::mem::size_of; +use std::path::PathBuf; + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use crate::errors::coded_reason; + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawStatus { + code: u32, + detail: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawUtf8 { + data: *const u8, + len_bytes: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawAbiVersion { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawDescriptor { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + kind: u32, + revision: u32, + generation: u32, + port_count: u32, + extension_id: RawUtf8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawPort { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + direction: u32, + required: u32, + name: RawUtf8, + signal_id: RawUtf8, + semantic_role: RawUtf8, + schema: RawUtf8, +} + +unsafe extern "C" { + fn pks_extension_abi_get_version(output_version: *mut RawAbiVersion) -> RawStatus; + fn pks_extension_abi_is_compatible( + requested_abi_major: u16, + requested_abi_minor: u16, + requested_struct_size_bytes: u32, + ) -> RawStatus; + fn pks_extension_descriptor_validate( + descriptor: *const RawDescriptor, + ports: *const RawPort, + port_count: u32, + ) -> RawStatus; +} + +#[pyclass(name = "_ExtensionAbiVersion", frozen)] +struct PythonExtensionAbiVersion { + #[pyo3(get)] + struct_size_bytes: u32, + #[pyo3(get)] + abi_major: u16, + #[pyo3(get)] + abi_minor: u16, +} + +#[pyclass(name = "_NativeExtensionRegistration", frozen)] +#[derive(Clone)] +pub(crate) struct PythonNativeExtensionRegistration { + #[pyo3(get)] + id: String, + #[pyo3(get)] + kind: &'static str, + #[pyo3(get)] + revision: u32, + #[pyo3(get)] + generation: u32, +} + +#[pyclass(name = "_NativeExtensionLibrary", frozen)] +pub(crate) struct PythonNativeExtensionLibrary { + #[pyo3(get)] + canonical_path: PathBuf, + registrations: Vec, +} + +#[pymethods] +impl PythonNativeExtensionLibrary { + #[getter] + fn registrations(&self) -> Vec { + self.registrations.clone() + } +} + +impl From + for PythonNativeExtensionLibrary +{ + fn from(value: pocketstation::native_extension::NativeExtensionLibrary) -> Self { + let registrations = value + .registrations() + .iter() + .map(|registration| PythonNativeExtensionRegistration { + id: registration.id().to_owned(), + kind: match registration.kind() { + pocketstation::native_extension::NativeExtensionKind::Source => "source", + pocketstation::native_extension::NativeExtensionKind::Operator => "operator", + pocketstation::native_extension::NativeExtensionKind::Endpoint => "endpoint", + }, + revision: registration.revision(), + generation: registration.generation(), + }) + .collect(); + Self { + canonical_path: value.canonical_path().to_owned(), + registrations, + } + } +} + +#[pyfunction] +fn extension_abi_version() -> PyResult { + let mut version = RawAbiVersion { + struct_size_bytes: 0, + abi_major: 0, + abi_minor: 0, + }; + // SAFETY: version is one writable, aligned current-process record. + let status = unsafe { pks_extension_abi_get_version(&raw mut version) }; + check_status(status)?; + Ok(PythonExtensionAbiVersion { + struct_size_bytes: version.struct_size_bytes, + abi_major: version.abi_major, + abi_minor: version.abi_minor, + }) +} + +#[pyfunction] +fn extension_abi_is_compatible( + abi_major: u16, + abi_minor: u16, + struct_size_bytes: u32, +) -> PyResult<()> { + // SAFETY: this ABI function accepts values only and retains no memory. + let status = + unsafe { pks_extension_abi_is_compatible(abi_major, abi_minor, struct_size_bytes) }; + check_status(status) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn validate_extension_descriptor( + extension_id: String, + kind: String, + revision: u32, + generation: u32, + abi_major: u16, + abi_minor: u16, + ports: Vec<(String, String, bool, String, String, String)>, +) -> PyResult<()> { + let port_count = u32::try_from(ports.len()).map_err(|_| { + PyValueError::new_err(coded_reason( + "extension.invalid_descriptor", + "port count exceeds the extension ABI range", + )) + })?; + let raw_ports = ports + .iter() + .map( + |(name, direction, required, signal_id, semantic_role, schema)| { + Ok(RawPort { + struct_size_bytes: size_of::() as u32, + abi_major, + abi_minor, + direction: parse_direction(direction)?, + required: u32::from(*required), + name: raw_utf8(name)?, + signal_id: raw_utf8(signal_id)?, + semantic_role: raw_utf8(semantic_role)?, + schema: raw_utf8(schema)?, + }) + }, + ) + .collect::>>()?; + let descriptor = RawDescriptor { + struct_size_bytes: size_of::() as u32, + abi_major, + abi_minor, + kind: parse_kind(&kind)?, + revision, + generation, + port_count, + extension_id: raw_utf8(&extension_id)?, + }; + // SAFETY: all records and UTF-8 byte strings remain alive and readable for + // this synchronous validation call, which copies and retains no memory. + let status = unsafe { + pks_extension_descriptor_validate(&raw const descriptor, raw_ports.as_ptr(), port_count) + }; + check_status(status) +} + +fn raw_utf8(value: &str) -> PyResult { + let len_bytes = u32::try_from(value.len()).map_err(|_| { + PyValueError::new_err(coded_reason( + "extension.invalid_descriptor", + "extension text exceeds the ABI range", + )) + })?; + Ok(RawUtf8 { + data: value.as_ptr(), + len_bytes, + }) +} + +fn parse_kind(value: &str) -> PyResult { + match value { + "source" => Ok(1), + "operator" => Ok(2), + "endpoint" => Ok(3), + _ => Err(PyValueError::new_err(coded_reason( + "extension.invalid_descriptor", + "kind must be source, operator, or endpoint", + ))), + } +} + +fn parse_direction(value: &str) -> PyResult { + match value { + "input" => Ok(1), + "output" => Ok(2), + _ => Err(PyValueError::new_err(coded_reason( + "extension.invalid_descriptor", + "port direction must be input or output", + ))), + } +} + +fn check_status(status: RawStatus) -> PyResult<()> { + if status.code == 0 { + return Ok(()); + } + let (code, reason) = match status.code { + 1 => ( + "extension.null_argument", + "the native ABI received a null pointer", + ), + 3 => ( + "extension.unsupported_abi_major", + "unsupported extension ABI major", + ), + 4 => ( + "extension.invalid_struct_size", + "invalid extension ABI struct size", + ), + 8 => ( + "extension.internal_panic", + "native extension ABI trapped a panic", + ), + 9 => ( + "extension.misaligned_pointer", + "misaligned extension ABI pointer", + ), + 10 => ( + "extension.invalid_descriptor", + "invalid extension descriptor or ports", + ), + 17 => ( + "extension.unsupported_abi_minor", + "unsupported extension ABI minor", + ), + _ => ( + "extension.abi_error", + "native extension ABI rejected the request", + ), + }; + Err(PyRuntimeError::new_err(coded_reason( + code, + format!("{reason} (detail={})", status.detail), + ))) +} + +#[cfg(feature = "conformance-fixtures")] +#[pyclass(name = "ExtensionConformanceReport", frozen)] +struct PythonExtensionConformanceReport { + #[pyo3(get)] + signal_id: String, + #[pyo3(get)] + schema_id: String, + #[pyo3(get)] + role_id: String, + #[pyo3(get)] + source_type_id: String, + #[pyo3(get)] + operator_id: String, + #[pyo3(get)] + endpoint_id: String, + #[pyo3(get)] + input_payload: String, + #[pyo3(get)] + output_payload: String, + #[pyo3(get)] + failure_requested: bool, + #[pyo3(get)] + source_prepared_total: u64, + #[pyo3(get)] + source_emitted_total: u64, + #[pyo3(get)] + source_closed_total: u64, + #[pyo3(get)] + operator_prepared_total: u64, + #[pyo3(get)] + operator_processed_total: u64, + #[pyo3(get)] + operator_output_total: u64, + #[pyo3(get)] + operator_failure_total: u64, + #[pyo3(get)] + operator_closed_total: u64, + #[pyo3(get)] + endpoint_prepared_total: u64, + #[pyo3(get)] + endpoint_received_total: u64, + #[pyo3(get)] + endpoint_stopped_total: u64, + #[pyo3(get)] + endpoint_finalized_total: u64, + #[pyo3(get)] + lifecycle_event_total: u64, + #[pyo3(get)] + terminal_event_total: u64, + #[pyo3(get)] + queue_capacity_signals: u64, + #[pyo3(get)] + queue_peak_signals: u64, + #[pyo3(get)] + route_capacity_signals: u64, + #[pyo3(get)] + route_peak_signals: u64, + #[pyo3(get)] + route_delivered_total: u64, + #[pyo3(get)] + maximum_buffered_payload_bytes: u64, + #[pyo3(get)] + stop_success: bool, +} + +#[cfg(feature = "conformance-fixtures")] +#[pyfunction] +fn run_extension_conformance( + failure_requested: bool, +) -> PyResult { + let report = pocketstation::conformance::run_extension_vector(failure_requested) + .map_err(PyRuntimeError::new_err)?; + Ok(PythonExtensionConformanceReport { + signal_id: report.signal_id.to_owned(), + schema_id: report.schema_id.to_owned(), + role_id: report.role_id.to_owned(), + source_type_id: report.source_type_id.to_owned(), + operator_id: report.operator_id.to_owned(), + endpoint_id: report.endpoint_id.to_owned(), + input_payload: report.input_payload.to_owned(), + output_payload: report.output_payload.to_owned(), + failure_requested: report.failure_requested, + source_prepared_total: report.source_prepared_total, + source_emitted_total: report.source_emitted_total, + source_closed_total: report.source_closed_total, + operator_prepared_total: report.operator_prepared_total, + operator_processed_total: report.operator_processed_total, + operator_output_total: report.operator_output_total, + operator_failure_total: report.operator_failure_total, + operator_closed_total: report.operator_closed_total, + endpoint_prepared_total: report.endpoint_prepared_total, + endpoint_received_total: report.endpoint_received_total, + endpoint_stopped_total: report.endpoint_stopped_total, + endpoint_finalized_total: report.endpoint_finalized_total, + lifecycle_event_total: report.lifecycle_event_total, + terminal_event_total: report.terminal_event_total, + queue_capacity_signals: report.queue_capacity_signals, + queue_peak_signals: report.queue_peak_signals, + route_capacity_signals: report.route_capacity_signals, + route_peak_signals: report.route_peak_signals, + route_delivered_total: report.route_delivered_total, + maximum_buffered_payload_bytes: report.maximum_buffered_payload_bytes, + stop_success: report.stop_success, + }) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(extension_abi_version, module)?)?; + module.add_function(wrap_pyfunction!(extension_abi_is_compatible, module)?)?; + module.add_function(wrap_pyfunction!(validate_extension_descriptor, module)?)?; + #[cfg(feature = "conformance-fixtures")] + { + module.add_class::()?; + module.add_function(wrap_pyfunction!(run_extension_conformance, module)?)?; + } + Ok(()) +} diff --git a/native/src/graph.rs b/native/src/graph.rs new file mode 100644 index 0000000..b1076e1 --- /dev/null +++ b/native/src/graph.rs @@ -0,0 +1,1357 @@ +use std::collections::HashMap; + +use pocketstation::connector::ConnectorSecret; +use pocketstation::{ + AudioCaps, BackpressurePolicy, BinaryFormat, ChannelLayout, Codec, CopyPolicy, + DerivedStreamHandle, EdgeContract, EndpointConfiguration, EndpointDescriptor, EndpointHandle, + EventFormat, MediaCaps, Multiplicity, Operator, OperatorConfiguration, OperatorId, + OperatorInputHandle, OperatorInstanceHandle, PortDirection, PortSpec, SampleFormat, + SignalClass, SignalSpec, SourceConfiguration, SourceInstanceHandle, SourceOutputHandle, + SourceTypeId, StemHandle, TextFormat, +}; +use pocketstation_relay::{RelayPublishReceiptKey, RelayRouteConfiguration}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use crate::errors::{coded_reason, session_error, validate_nonempty}; +use crate::relay::{PythonRelayPublisher, RelayRouteRegistration}; + +fn invalid_contract(reason: impl Into) -> PyErr { + PyValueError::new_err(coded_reason("graph.invalid_contract", reason.into())) +} + +fn parse_codec(value: &str) -> PyResult { + match value { + "opus" => Ok(Codec::Opus), + "aac" => Ok(Codec::Aac), + "mp3" => Ok(Codec::Mp3), + "g711-ulaw" => Ok(Codec::G711Ulaw), + "g711-alaw" => Ok(Codec::G711Alaw), + "webm-opus" => Ok(Codec::WebmOpus), + _ => Err(invalid_contract(format!("unsupported codec {value:?}"))), + } +} + +const fn codec_name(value: Codec) -> &'static str { + match value { + Codec::Opus => "opus", + Codec::Aac => "aac", + Codec::Mp3 => "mp3", + Codec::G711Ulaw => "g711-ulaw", + Codec::G711Alaw => "g711-alaw", + Codec::WebmOpus => "webm-opus", + } +} + +fn parse_text_format(value: &str) -> PyResult { + match value { + "utf8" => Ok(TextFormat::Utf8), + "json" => Ok(TextFormat::Json), + "markdown" => Ok(TextFormat::Markdown), + _ => Err(invalid_contract(format!( + "unsupported text format {value:?}" + ))), + } +} + +const fn text_format_name(value: TextFormat) -> &'static str { + match value { + TextFormat::Utf8 => "utf8", + TextFormat::Json => "json", + TextFormat::Markdown => "markdown", + } +} + +fn parse_event_format(value: &str) -> PyResult { + match value { + "json" => Ok(EventFormat::Json), + "protobuf" => Ok(EventFormat::Protobuf), + "flatbuffers" => Ok(EventFormat::Flatbuffers), + "cbor" => Ok(EventFormat::Cbor), + _ => Err(invalid_contract(format!( + "unsupported event format {value:?}" + ))), + } +} + +const fn event_format_name(value: EventFormat) -> &'static str { + match value { + EventFormat::Json => "json", + EventFormat::Protobuf => "protobuf", + EventFormat::Flatbuffers => "flatbuffers", + EventFormat::Cbor => "cbor", + } +} + +fn parse_binary_format(value: &str) -> PyResult { + match value { + "raw" => Ok(BinaryFormat::Raw), + "protobuf" => Ok(BinaryFormat::Protobuf), + "flatbuffers" => Ok(BinaryFormat::Flatbuffers), + "cbor" => Ok(BinaryFormat::Cbor), + _ => Err(invalid_contract(format!( + "unsupported binary format {value:?}" + ))), + } +} + +const fn binary_format_name(value: BinaryFormat) -> &'static str { + match value { + BinaryFormat::Raw => "raw", + BinaryFormat::Protobuf => "protobuf", + BinaryFormat::Flatbuffers => "flatbuffers", + BinaryFormat::Cbor => "cbor", + } +} + +fn parse_channel_layout(value: &str) -> PyResult { + match value { + "mono" => Ok(ChannelLayout::Mono), + "stereo" => Ok(ChannelLayout::Stereo), + "any" => Ok(ChannelLayout::Any), + _ => Err(invalid_contract(format!( + "unsupported channel layout {value:?}" + ))), + } +} + +const fn channel_layout_name(value: ChannelLayout) -> &'static str { + match value { + ChannelLayout::Mono => "mono", + ChannelLayout::Stereo => "stereo", + ChannelLayout::Any => "any", + } +} + +fn parse_backpressure(value: &str) -> PyResult { + match value { + "drop-newest" => Ok(BackpressurePolicy::DropNewest), + "drop-oldest" => Ok(BackpressurePolicy::DropOldest), + "bounded-queue" => Ok(BackpressurePolicy::BoundedQueue), + "block-forbidden" => Ok(BackpressurePolicy::BlockForbidden), + _ => Err(invalid_contract(format!( + "unsupported backpressure policy {value:?}" + ))), + } +} + +const fn backpressure_name(value: BackpressurePolicy) -> &'static str { + match value { + BackpressurePolicy::DropNewest => "drop-newest", + BackpressurePolicy::DropOldest => "drop-oldest", + BackpressurePolicy::BoundedQueue => "bounded-queue", + BackpressurePolicy::BlockForbidden => "block-forbidden", + } +} + +fn parse_copy_policy(value: &str) -> PyResult { + match value { + "move-exclusive" => Ok(CopyPolicy::MoveExclusive), + "share-read-only" => Ok(CopyPolicy::ShareReadOnly), + "copy-to-branch-pool" => Ok(CopyPolicy::CopyToBranchPool), + _ => Err(invalid_contract(format!( + "unsupported copy policy {value:?}" + ))), + } +} + +const fn copy_policy_name(value: CopyPolicy) -> &'static str { + match value { + CopyPolicy::MoveExclusive => "move-exclusive", + CopyPolicy::ShareReadOnly => "share-read-only", + CopyPolicy::CopyToBranchPool => "copy-to-branch-pool", + } +} + +fn make_configuration(values: HashMap) -> OperatorConfiguration { + values.into_iter().fold( + OperatorConfiguration::new(), + |configuration, (key, value)| configuration.with(&key, &value), + ) +} + +pub(crate) fn make_operator( + operator_id: String, + configuration: HashMap, +) -> Operator { + Operator::new( + OperatorId::new(operator_id), + make_configuration(configuration), + ) +} + +pub(crate) fn make_source_configuration(values: HashMap) -> SourceConfiguration { + let mut configuration = SourceConfiguration::default(); + for (key, value) in values { + configuration.insert(key, value); + } + configuration +} + +#[pyclass(name = "_SignalSpec", frozen)] +#[derive(Clone)] +pub(crate) struct PythonSignalSpec { + pub(crate) value: SignalSpec, +} + +#[pymethods] +impl PythonSignalSpec { + #[new] + #[pyo3(signature = (kind, format=None, custom_id=None, role=None, schema=None))] + fn new( + kind: String, + format: Option, + custom_id: Option, + role: Option, + schema: Option, + ) -> PyResult { + let mut value = match kind.as_str() { + "any" => SignalSpec::any(), + "pcm-audio" => SignalSpec::audio(), + "encoded-audio" => SignalSpec::encoded_audio(parse_codec( + format + .as_deref() + .ok_or_else(|| invalid_contract("encoded audio requires a codec"))?, + )?), + "text" => SignalSpec::text(parse_text_format( + format + .as_deref() + .ok_or_else(|| invalid_contract("text requires a format"))?, + )?), + "event" => SignalSpec::event(parse_event_format( + format + .as_deref() + .ok_or_else(|| invalid_contract("event requires a format"))?, + )?), + "metrics" => SignalSpec::metrics(), + "control" => SignalSpec::control(), + "binary" => SignalSpec::binary(parse_binary_format( + format + .as_deref() + .ok_or_else(|| invalid_contract("binary requires a format"))?, + )?), + "custom" => SignalSpec::custom( + custom_id.ok_or_else(|| invalid_contract("custom signal requires a stable ID"))?, + ), + _ => { + return Err(invalid_contract(format!( + "unsupported signal kind {kind:?}" + ))) + } + }; + if let Some(role) = role { + value = value.with_role(role); + } + if let Some(schema) = schema { + value = value.with_schema(schema); + } + value + .validate() + .map_err(|error| invalid_contract(error.to_string()))?; + Ok(Self { value }) + } + + #[getter] + fn kind(&self) -> &'static str { + match self.value.class() { + SignalClass::Any => "any", + SignalClass::PcmAudio => "pcm-audio", + SignalClass::EncodedAudio(_) => "encoded-audio", + SignalClass::Text(_) => "text", + SignalClass::Event(_) => "event", + SignalClass::Metrics => "metrics", + SignalClass::Control => "control", + SignalClass::Binary(_) => "binary", + SignalClass::Custom(_) => "custom", + } + } + + #[getter] + fn format(&self) -> Option<&'static str> { + match self.value.class() { + SignalClass::EncodedAudio(value) => Some(codec_name(*value)), + SignalClass::Text(value) => Some(text_format_name(*value)), + SignalClass::Event(value) => Some(event_format_name(*value)), + SignalClass::Binary(value) => Some(binary_format_name(*value)), + _ => None, + } + } + + #[getter] + fn custom_id(&self) -> Option { + match self.value.class() { + SignalClass::Custom(value) => Some(value.as_str().to_owned()), + _ => None, + } + } + + #[getter] + fn role(&self) -> Option { + self.value.role().map(|value| value.as_str().to_owned()) + } + + #[getter] + fn schema(&self) -> Option { + self.value.schema().map(|value| value.as_str().to_owned()) + } + + #[getter] + fn wire_id(&self) -> String { + self.value.wire_id().to_owned() + } + + #[getter] + fn is_audio(&self) -> bool { + self.value.class().is_audio() + } + + fn is_compatible_with(&self, other: &Self) -> bool { + self.value.is_compatible_with(&other.value) + } +} + +#[pyclass(name = "_MediaCaps", frozen)] +#[derive(Clone, Copy)] +pub(crate) struct PythonMediaCaps { + pub(crate) value: MediaCaps, +} + +#[pymethods] +impl PythonMediaCaps { + #[new] + #[pyo3(signature = (kind, format=None, sample_rate_hz=None, frame_samples=None, channel_layout=None))] + fn new( + kind: String, + format: Option, + sample_rate_hz: Option, + frame_samples: Option, + channel_layout: Option, + ) -> PyResult { + let value = match kind.as_str() { + "audio-pcm" => MediaCaps::Audio(AudioCaps { + sample_rate_hz, + frame_samples, + channel_layout: parse_channel_layout(channel_layout.as_deref().unwrap_or("any"))?, + format: SampleFormat::F32Interleaved, + }), + "audio-encoded" => MediaCaps::EncodedAudio(parse_codec( + format + .as_deref() + .ok_or_else(|| invalid_contract("encoded audio requires a codec"))?, + )?), + "text" => MediaCaps::Text, + "event" => MediaCaps::Event, + "metrics" => MediaCaps::Metrics, + "control" => MediaCaps::Control, + "binary" => MediaCaps::Binary(parse_binary_format( + format + .as_deref() + .ok_or_else(|| invalid_contract("binary media requires a format"))?, + )?), + "any" => MediaCaps::Any, + _ => return Err(invalid_contract(format!("unsupported media kind {kind:?}"))), + }; + Ok(Self { value }) + } + + #[getter] + fn kind(&self) -> &'static str { + match self.value { + MediaCaps::Audio(_) => "audio-pcm", + MediaCaps::EncodedAudio(_) => "audio-encoded", + MediaCaps::Text => "text", + MediaCaps::Event => "event", + MediaCaps::Metrics => "metrics", + MediaCaps::Control => "control", + MediaCaps::Binary(_) => "binary", + MediaCaps::Any => "any", + } + } + + #[getter] + fn format(&self) -> Option<&'static str> { + match self.value { + MediaCaps::Audio(_) => Some("f32-interleaved"), + MediaCaps::EncodedAudio(value) => Some(codec_name(value)), + MediaCaps::Binary(value) => Some(binary_format_name(value)), + _ => None, + } + } + + #[getter] + fn sample_rate_hz(&self) -> Option { + match self.value { + MediaCaps::Audio(value) => value.sample_rate_hz, + _ => None, + } + } + + #[getter] + fn frame_samples(&self) -> Option { + match self.value { + MediaCaps::Audio(value) => value.frame_samples, + _ => None, + } + } + + #[getter] + fn channel_layout(&self) -> Option<&'static str> { + match self.value { + MediaCaps::Audio(value) => Some(channel_layout_name(value.channel_layout)), + _ => None, + } + } + + fn is_compatible_with(&self, other: &Self) -> bool { + self.value.is_compatible_with(&other.value) + } + + fn supports_signal(&self, signal: &PythonSignalSpec) -> bool { + self.value.supports_signal(&signal.value) + } +} + +#[pyclass(name = "_PortSpec", frozen)] +#[derive(Clone)] +pub(crate) struct PythonPortSpec { + pub(crate) value: PortSpec, +} + +#[pymethods] +impl PythonPortSpec { + #[new] + fn new( + name: String, + direction: String, + signal: &PythonSignalSpec, + media: &PythonMediaCaps, + multiplicity: String, + required: bool, + ) -> PyResult { + let direction = match direction.as_str() { + "input" => PortDirection::Input, + "output" => PortDirection::Output, + _ => return Err(invalid_contract("port direction must be input or output")), + }; + let multiplicity = match multiplicity.as_str() { + "one" => Multiplicity::One, + "many" => Multiplicity::Many, + _ => return Err(invalid_contract("port multiplicity must be one or many")), + }; + PortSpec::new( + name, + direction, + signal.value.clone(), + media.value, + multiplicity, + required, + ) + .map(|value| Self { value }) + .map_err(|error| invalid_contract(error.to_string())) + } + + #[getter] + fn name(&self) -> String { + self.value.name().to_owned() + } + + #[getter] + fn direction(&self) -> &'static str { + match self.value.direction() { + PortDirection::Input => "input", + PortDirection::Output => "output", + } + } + + #[getter] + fn signal(&self) -> PythonSignalSpec { + PythonSignalSpec { + value: self.value.signal().clone(), + } + } + + #[getter] + fn media(&self) -> PythonMediaCaps { + PythonMediaCaps { + value: self.value.media(), + } + } + + #[getter] + fn multiplicity(&self) -> &'static str { + match self.value.multiplicity() { + Multiplicity::One => "one", + Multiplicity::Many => "many", + } + } + + #[getter] + fn required(&self) -> bool { + self.value.required() + } +} + +#[pyclass(name = "_EdgeContract", frozen)] +#[derive(Clone, Copy)] +pub(crate) struct PythonEdgeContract { + pub(crate) value: EdgeContract, +} + +#[pymethods] +impl PythonEdgeContract { + #[staticmethod] + fn realtime_audio() -> Self { + Self { + value: EdgeContract::realtime_audio(), + } + } + + #[staticmethod] + fn bounded_async() -> Self { + Self { + value: EdgeContract::bounded_async(), + } + } + + fn with_media(&self, media: &PythonMediaCaps) -> Self { + Self { + value: self.value.with_media(media.value), + } + } + + fn with_backpressure(&self, value: String) -> PyResult { + Ok(Self { + value: self.value.with_backpressure(parse_backpressure(&value)?), + }) + } + + fn with_copy_policy(&self, value: String) -> PyResult { + Ok(Self { + value: self.value.with_copy_policy(parse_copy_policy(&value)?), + }) + } + + fn with_jitter_budget_ms(&self, value: Option) -> Self { + Self { + value: self.value.with_jitter_budget_ms(value), + } + } + + fn with_max_payload_bytes(&self, value: usize) -> PyResult { + Ok(Self { + value: self.value.with_max_payload_bytes(value), + }) + } + + #[getter] + fn media(&self) -> PythonMediaCaps { + PythonMediaCaps { + value: self.value.media(), + } + } + + #[getter] + fn clock(&self) -> &'static str { + match self.value.clock() { + pocketstation::ClockDomain::Capture => "capture", + pocketstation::ClockDomain::Playback => "playback", + pocketstation::ClockDomain::Network => "network", + pocketstation::ClockDomain::Inherited => "inherited", + pocketstation::ClockDomain::Wallclock => "wallclock", + } + } + + #[getter] + fn latency_budget_ms(&self) -> Option { + self.value.latency_budget_ms() + } + + #[getter] + fn jitter_budget_ms(&self) -> Option { + self.value.jitter_budget_ms() + } + + #[getter] + fn backpressure(&self) -> &'static str { + backpressure_name(self.value.backpressure()) + } + + #[getter] + fn delivery(&self) -> &'static str { + match self.value.delivery() { + pocketstation::DeliverySemantics::BestEffortRealtime => "best-effort-realtime", + pocketstation::DeliverySemantics::Ordered => "ordered", + pocketstation::DeliverySemantics::ExactlyOnceNotRealtime => "exactly-once-not-realtime", + } + } + + #[getter] + fn loss(&self) -> &'static str { + match self.value.loss() { + pocketstation::LossPolicy::ConcealForAudio => "conceal-for-audio", + pocketstation::LossPolicy::MustDeliverOrFail => "must-deliver-or-fail", + pocketstation::LossPolicy::DropAllowed => "drop-allowed", + } + } + + #[getter] + fn copy_policy(&self) -> &'static str { + copy_policy_name(self.value.copy_policy()) + } + + #[getter] + fn observability(&self) -> &'static str { + match self.value.observability() { + pocketstation::EdgeObservabilityLevel::Off => "off", + pocketstation::EdgeObservabilityLevel::Counters => "counters", + pocketstation::EdgeObservabilityLevel::Full => "full", + } + } + + #[getter] + fn max_payload_bytes(&self) -> Option { + self.value.max_payload_bytes() + } +} + +#[pyclass(name = "_EndpointDescriptor", frozen)] +#[derive(Clone)] +pub(crate) struct PythonEndpointDescriptor { + pub(crate) value: EndpointDescriptor, +} + +#[pymethods] +impl PythonEndpointDescriptor { + #[new] + #[pyo3(signature = (node_type_id, operator_id, configuration, input_edge=None))] + fn new( + node_type_id: String, + operator_id: String, + configuration: HashMap, + input_edge: Option<&PythonEdgeContract>, + ) -> PyResult { + let configuration = configuration.into_iter().fold( + EndpointConfiguration::new(), + |configuration, (key, value)| configuration.with(key, value), + ); + let mut value = EndpointDescriptor::new( + pocketstation::NodeTypeId::from(node_type_id.as_str()), + OperatorId::new(operator_id), + ) + .with_configuration(configuration); + if let Some(input_edge) = input_edge { + value = value.with_input_edge(input_edge.value); + } + Ok(Self { value }) + } +} + +#[pyclass(name = "Endpoint", frozen)] +pub(crate) struct PythonEndpoint { + pub(crate) handle: EndpointHandle, +} + +#[pymethods] +impl PythonEndpoint { + #[getter] + fn id(&self) -> u64 { + self.handle.id().get() + } + + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } + + #[getter] + fn connector_id(&self) -> Option { + self.handle + .connector_id() + .map(pocketstation::ConnectorId::get) + } +} + +#[pyclass(name = "OperatorInput", frozen)] +pub(crate) struct PythonOperatorInput { + pub(crate) handle: OperatorInputHandle, + port_name: String, +} + +#[pymethods] +impl PythonOperatorInput { + #[getter] + fn port_name(&self) -> String { + self.port_name.clone() + } +} + +#[pyclass(name = "OperatorInstance", frozen)] +pub(crate) struct PythonOperatorInstance { + pub(crate) handle: OperatorInstanceHandle, +} + +#[pymethods] +impl PythonOperatorInstance { + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } + + #[getter] + fn instance_id(&self) -> u64 { + self.handle.instance_id().value() + } + + fn input(&self, port_name: String) -> PyResult { + self.handle + .input(port_name.clone()) + .map(|handle| PythonOperatorInput { handle, port_name }) + .map_err(session_error) + } + + fn output(&self, port_name: String) -> PyResult { + self.handle + .output(port_name) + .map(|handle| PythonDerivedStream { handle }) + .map_err(session_error) + } +} + +fn publish_stem( + handle: &StemHandle, + publisher: &PythonRelayPublisher, + bus_id: String, +) -> PyResult { + publish_route(publisher, bus_id, |endpoint| { + handle.send(endpoint).map_err(session_error) + }) +} + +fn publish_source_output( + handle: &SourceOutputHandle, + publisher: &PythonRelayPublisher, + bus_id: String, +) -> PyResult { + publish_route(publisher, bus_id, |endpoint| { + handle.send(endpoint).map_err(session_error) + }) +} + +fn publish_route( + publisher: &PythonRelayPublisher, + bus_id: String, + send: impl FnOnce(EndpointHandle) -> PyResult, +) -> PyResult { + validate_nonempty("AudioBus ID", &bus_id)?; + let configuration = RelayRouteConfiguration::new( + &publisher.relay_url, + &publisher.relay_session_id, + ConnectorSecret::new(&publisher.source_token) + .map_err(|error| PyValueError::new_err(error.to_string()))?, + &bus_id, + ) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let mut routes = publisher + .routes + .lock() + .map_err(|_| PyRuntimeError::new_err("relay route state is unavailable"))?; + if routes.iter().any(|route| route.bus_id == bus_id) { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_endpoint", + "AudioBus IDs must be unique within one relay publisher", + ))); + } + let session = publisher + .session + .lock() + .map_err(|_| PyRuntimeError::new_err("Session state is unavailable"))?; + let session = session.as_ref().ok_or_else(|| { + PyRuntimeError::new_err(coded_reason( + pocketstation::SessionDeclarationErrorCode::DraftFrozen.as_str(), + "Session has already started", + )) + })?; + let endpoint = publisher + .registered + .declare( + session, + configuration + .connector_configuration() + .map_err(|error| PyValueError::new_err(error.to_string()))?, + EdgeContract::realtime_audio(), + ) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let route_id = send(endpoint)?; + let key = RelayPublishReceiptKey { + endpoint_id: endpoint.id(), + route_id, + }; + routes.push(RelayRouteRegistration { bus_id, key }); + Ok(route_id.get()) +} + +#[pyclass(name = "Stem", frozen)] +pub(crate) struct PythonStem { + pub(crate) handle: StemHandle, +} + +#[pymethods] +impl PythonStem { + fn send(&self, endpoint: &PythonEndpoint) -> PyResult { + self.handle + .send(endpoint.handle) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn send_to(&self, endpoint: &PythonEndpoint, input_port: Option) -> PyResult { + self.handle + .send_to(endpoint.handle, input_port) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn connect(&self, input: &PythonOperatorInput) -> PyResult { + self.handle + .connect(input.handle.clone()) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + #[pyo3(signature = (operator_id, configuration, input_port=None, output_port=None))] + fn through( + &self, + operator_id: String, + configuration: HashMap, + input_port: Option, + output_port: Option, + ) -> PyResult { + self.handle + .through_ports( + make_operator(operator_id, configuration), + input_port, + output_port, + ) + .map(|handle| PythonDerivedStream { handle }) + .map_err(session_error) + } + + fn record(&self, stem_name: String) -> PyResult { + if stem_name.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_endpoint", + "recording stem name must not be empty", + ))); + } + self.handle + .record(stem_name) + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + } + + fn publish(&self, publisher: &PythonRelayPublisher, bus_id: String) -> PyResult { + publish_stem(&self.handle, publisher, bus_id) + } + + #[getter] + fn id(&self) -> u64 { + self.handle.id().get() + } + + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } +} + +#[pyclass(name = "DerivedStream", frozen)] +pub(crate) struct PythonDerivedStream { + pub(crate) handle: DerivedStreamHandle, +} + +#[pymethods] +impl PythonDerivedStream { + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } + + #[getter] + fn operator_instance_id(&self) -> u64 { + self.handle.operator_instance_id().value() + } + + #[getter] + fn output_port(&self) -> Option { + self.handle.output_port().map(str::to_owned) + } + + fn output(&self, port_name: String) -> PyResult { + self.handle + .output(port_name) + .map(|handle| Self { handle }) + .map_err(session_error) + } + + fn connect(&self, input: &PythonOperatorInput) -> PyResult { + self.handle + .connect(input.handle.clone()) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + #[pyo3(signature = (operator_id, configuration, input_port=None, output_port=None))] + fn through( + &self, + operator_id: String, + configuration: HashMap, + input_port: Option, + output_port: Option, + ) -> PyResult { + self.handle + .through_ports( + make_operator(operator_id, configuration), + input_port, + output_port, + ) + .map(|handle| Self { handle }) + .map_err(session_error) + } + + fn send(&self, endpoint: &PythonEndpoint) -> PyResult { + self.handle + .send(endpoint.handle) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn send_to(&self, endpoint: &PythonEndpoint, input_port: Option) -> PyResult { + self.handle + .send_to(endpoint.handle, input_port) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn reenter_audio(&self) -> PyResult { + self.handle + .reenter_audio() + .map(|handle| PythonStem { handle }) + .map_err(session_error) + } +} + +#[pyclass(name = "SourceInstance", frozen)] +pub(crate) struct PythonSourceInstance { + pub(crate) handle: SourceInstanceHandle, +} + +#[pymethods] +impl PythonSourceInstance { + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } + + #[getter] + fn instance_id(&self) -> u64 { + self.handle.instance_id().value() + } + + #[getter] + fn source_id(&self) -> u64 { + self.handle.source_id().get() + } + + fn output(&self, port_name: String) -> PyResult { + self.handle + .output(port_name) + .map(|handle| PythonSourceOutput { handle }) + .map_err(session_error) + } +} + +#[pyclass(name = "SourceOutput", frozen)] +pub(crate) struct PythonSourceOutput { + pub(crate) handle: SourceOutputHandle, +} + +#[pymethods] +impl PythonSourceOutput { + #[getter] + fn session_id(&self) -> u64 { + self.handle.session_id().get() + } + + #[getter] + fn source_instance_id(&self) -> u64 { + self.handle.source_instance_id().value() + } + + #[getter] + fn source_id(&self) -> u64 { + self.handle.source_id().get() + } + + #[getter] + fn stream_id(&self) -> u64 { + self.handle.stream_id().get() + } + + #[getter] + fn output_port(&self) -> String { + self.handle.output_port().to_owned() + } + + fn connect(&self, input: &PythonOperatorInput) -> PyResult { + self.handle + .connect(input.handle.clone()) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + #[pyo3(signature = (operator_id, configuration, input_port=None, output_port=None))] + fn through( + &self, + operator_id: String, + configuration: HashMap, + input_port: Option, + output_port: Option, + ) -> PyResult { + self.handle + .through_ports( + make_operator(operator_id, configuration), + input_port, + output_port, + ) + .map(|handle| PythonDerivedStream { handle }) + .map_err(session_error) + } + + fn send(&self, endpoint: &PythonEndpoint) -> PyResult { + self.handle + .send(endpoint.handle) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn send_to(&self, endpoint: &PythonEndpoint, input_port: Option) -> PyResult { + self.handle + .send_to(endpoint.handle, input_port) + .map(pocketstation::RouteId::get) + .map_err(session_error) + } + + fn record(&self, stem_name: String) -> PyResult { + if stem_name.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_endpoint", + "recording stem name must not be empty", + ))); + } + self.handle + .record(stem_name) + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + } + + fn publish(&self, publisher: &PythonRelayPublisher, bus_id: String) -> PyResult { + publish_source_output(&self.handle, publisher, bus_id) + } +} + +pub(crate) fn make_source_type_id(value: String) -> PyResult { + SourceTypeId::new(value).map_err(|error| invalid_contract(error.to_string())) +} + +#[cfg(feature = "conformance-fixtures")] +pub(crate) const GRAPH_CONFORMANCE_OPERATOR_ID: &str = + "org.pocketstation.python.conformance.audio-pass-through.v1"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_CONFORMANCE_NODE_ID: &str = + "org.pocketstation.python.conformance.audio-pass-through-node.v1"; +#[cfg(feature = "conformance-fixtures")] +pub(crate) const GRAPH_NONCONCRETE_OPERATOR_ID: &str = + "org.pocketstation.python.conformance.nonconcrete-audio.v1"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_NONCONCRETE_NODE_ID: &str = + "org.pocketstation.python.conformance.nonconcrete-audio-node.v1"; +#[cfg(feature = "conformance-fixtures")] +pub(crate) const GRAPH_TEXT_OPERATOR_ID: &str = + "org.pocketstation.python.conformance.audio-to-text.v1"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_TEXT_NODE_ID: &str = "org.pocketstation.python.conformance.audio-to-text-node.v1"; +#[cfg(feature = "conformance-fixtures")] +pub(crate) const GRAPH_BYTES_OPERATOR_ID: &str = + "org.pocketstation.python.conformance.audio-to-bytes.v1"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_BYTES_NODE_ID: &str = "org.pocketstation.python.conformance.audio-to-bytes-node.v1"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_CONFORMANCE_INPUT_PORT: &str = "audio-in"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_CONFORMANCE_OUTPUT_PORT: &str = "audio-out"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_TEXT_OUTPUT_PORT: &str = "text-out"; +#[cfg(feature = "conformance-fixtures")] +const GRAPH_BYTES_OUTPUT_PORT: &str = "bytes-out"; + +#[cfg(feature = "conformance-fixtures")] +#[derive(Clone, Copy)] +enum GraphConformanceProjection { + Audio, + Text, + Bytes, +} + +#[cfg(feature = "conformance-fixtures")] +struct GraphConformanceOperatorFactory { + manifest: pocketstation::AsyncOperatorManifest, + projection: GraphConformanceProjection, +} + +#[cfg(feature = "conformance-fixtures")] +struct GraphConformanceOperator { + operator_id: OperatorId, + projection: GraphConformanceProjection, +} + +#[cfg(feature = "conformance-fixtures")] +impl pocketstation::AsyncNode for GraphConformanceOperator { + fn prepare<'a>( + &'a mut self, + _context: &'a pocketstation::AsyncOperatorPrepareContext, + ) -> pocketstation::AsyncNodeFuture<'a, Result<(), pocketstation::NodeError>> { + Box::pin(async { Ok(()) }) + } + + fn process<'a>( + &'a mut self, + input: pocketstation::SignalEnvelope, + ) -> pocketstation::AsyncNodeFuture< + 'a, + Result, pocketstation::NodeError>, + > { + Box::pin(async move { + let lineage = input.lineage().ok_or_else(|| { + pocketstation::NodeError::Process( + "graph conformance audio input omitted Session lineage".to_owned(), + ) + })?; + let derivation = pocketstation::SignalDerivation::new( + lineage, + input.timing(), + self.operator_id.clone(), + 1, + 1, + None, + ) + .map_err(|error| pocketstation::NodeError::Process(error.to_string()))?; + let output = match self.projection { + GraphConformanceProjection::Audio => input, + GraphConformanceProjection::Text => { + let text = format!( + "source={} sequence={}", + lineage.source_id().get(), + lineage.sequence_number() + ); + input.map_payload( + pocketstation::SignalPayload::Text(text), + SignalSpec::text(TextFormat::Utf8), + ) + } + GraphConformanceProjection::Bytes => input.map_payload( + pocketstation::SignalPayload::Bytes( + lineage.sequence_number().to_le_bytes().to_vec(), + ), + SignalSpec::binary(BinaryFormat::Raw), + ), + }; + Ok(vec![output.with_derivation(derivation)]) + }) + } +} + +#[cfg(feature = "conformance-fixtures")] +impl pocketstation::AsyncOperatorFactory for GraphConformanceOperatorFactory { + fn manifest(&self) -> &pocketstation::AsyncOperatorManifest { + &self.manifest + } + + fn validate_config( + &self, + _configuration: &pocketstation::OperatorConfiguration, + ) -> Result<(), pocketstation::ConfigError> { + Ok(()) + } + + fn create( + &self, + _configuration: &pocketstation::OperatorConfiguration, + ) -> Result, pocketstation::NodeError> { + Ok(Box::new(GraphConformanceOperator { + operator_id: self.manifest.operator_id().clone(), + projection: self.projection, + })) + } +} + +#[cfg(feature = "conformance-fixtures")] +pub(crate) fn register_graph_conformance_operator( + session: &pocketstation::Session, +) -> Result<(), String> { + use std::sync::Arc; + + let concrete_media = MediaCaps::Audio(AudioCaps { + sample_rate_hz: Some(48_000), + frame_samples: Some(960), + channel_layout: ChannelLayout::Mono, + format: SampleFormat::F32Interleaved, + }); + let wildcard_media = MediaCaps::Audio(AudioCaps { + sample_rate_hz: None, + frame_samples: None, + channel_layout: ChannelLayout::Any, + format: SampleFormat::F32Interleaved, + }); + for (manifest, projection) in [ + ( + graph_conformance_manifest( + GRAPH_CONFORMANCE_OPERATOR_ID, + GRAPH_CONFORMANCE_NODE_ID, + "Python graph conformance audio pass-through", + GRAPH_CONFORMANCE_OUTPUT_PORT, + SignalSpec::audio(), + concrete_media, + )?, + GraphConformanceProjection::Audio, + ), + ( + graph_conformance_manifest( + GRAPH_NONCONCRETE_OPERATOR_ID, + GRAPH_NONCONCRETE_NODE_ID, + "Python graph conformance nonconcrete audio", + GRAPH_CONFORMANCE_OUTPUT_PORT, + SignalSpec::audio(), + wildcard_media, + )?, + GraphConformanceProjection::Audio, + ), + ( + graph_conformance_manifest( + GRAPH_TEXT_OPERATOR_ID, + GRAPH_TEXT_NODE_ID, + "Python graph conformance audio-to-text", + GRAPH_TEXT_OUTPUT_PORT, + SignalSpec::text(TextFormat::Utf8), + MediaCaps::Text, + )?, + GraphConformanceProjection::Text, + ), + ( + graph_conformance_manifest( + GRAPH_BYTES_OPERATOR_ID, + GRAPH_BYTES_NODE_ID, + "Python graph conformance audio-to-bytes", + GRAPH_BYTES_OUTPUT_PORT, + SignalSpec::binary(BinaryFormat::Raw), + MediaCaps::Binary(BinaryFormat::Raw), + )?, + GraphConformanceProjection::Bytes, + ), + ] { + session + .register_operator(Arc::new(GraphConformanceOperatorFactory { + manifest, + projection, + })) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +#[cfg(feature = "conformance-fixtures")] +fn graph_conformance_manifest( + operator_id: &'static str, + node_id: &'static str, + display_name: &'static str, + output_port: &'static str, + output_signal: SignalSpec, + output_media: MediaCaps, +) -> Result { + let input_signal = SignalSpec::audio(); + let input_media = MediaCaps::Audio(AudioCaps { + sample_rate_hz: Some(48_000), + frame_samples: Some(960), + channel_layout: ChannelLayout::Mono, + format: SampleFormat::F32Interleaved, + }); + let input = PortSpec::new( + GRAPH_CONFORMANCE_INPUT_PORT, + PortDirection::Input, + input_signal, + input_media, + Multiplicity::Many, + true, + ) + .map_err(|error| error.to_string())?; + let output = PortSpec::new( + output_port, + PortDirection::Output, + output_signal, + output_media, + Multiplicity::Many, + true, + ) + .map_err(|error| error.to_string())?; + let input_edge = EdgeContract::bounded_async() + .with_media(input_media) + .with_backpressure(BackpressurePolicy::DropNewest) + .with_copy_policy(CopyPolicy::CopyToBranchPool); + let output_edge = EdgeContract::bounded_async() + .with_media(output_media) + .with_copy_policy(CopyPolicy::CopyToBranchPool); + pocketstation::AsyncOperatorManifest::new( + OperatorId::new(operator_id), + 1, + 1, + pocketstation::NodeDescriptor::new( + pocketstation::NodeTypeId::from(node_id), + display_name, + vec![input], + vec![output], + pocketstation::ExecutionPartition::AsyncWorker, + pocketstation::SafetyContract::AllocationAllowed, + false, + ) + .map_err(|error| error.to_string())?, + input_edge, + output_edge, + 16, + pocketstation::OperatorPermissionPolicy { + network_allowed: false, + filesystem_allowed: false, + }, + pocketstation::OperatorDeadlinePolicy { + process_timeout_ms: 500, + }, + pocketstation::OperatorCancellationPolicy::DiscardQueued, + pocketstation::OperatorFailurePolicy::StopWorker, + pocketstation::OperatorOutputRolePolicy::default(), + ) + .map_err(|error| error.to_string()) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/lib.rs b/native/src/lib.rs new file mode 100644 index 0000000..67696e7 --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,32 @@ +// The extension is a private cdylib: cross-owner items are crate-visible but +// intentionally not an external Rust API. +#![allow(clippy::redundant_pub_crate)] + +pub(crate) mod audio_input; +pub(crate) mod errors; +pub(crate) mod extensions; +pub(crate) mod graph; +pub(crate) mod observations; +pub(crate) mod relay; +pub(crate) mod session; +pub(crate) mod sidecar; +pub(crate) mod signals; +pub(crate) mod sources; +pub(crate) mod streams; + +use pyo3::prelude::*; + +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + audio_input::register(module)?; + extensions::register(module)?; + sources::register(module)?; + graph::register(module)?; + signals::register(module)?; + sidecar::register(module)?; + relay::register(module)?; + streams::register(module)?; + observations::register(module)?; + session::register(module)?; + Ok(()) +} diff --git a/native/src/observations.rs b/native/src/observations.rs new file mode 100644 index 0000000..7461010 --- /dev/null +++ b/native/src/observations.rs @@ -0,0 +1,2170 @@ +use std::path::PathBuf; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::thread; +use std::time::{Duration, Instant}; + +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; + +use crate::errors::coded_reason; +use crate::relay::{OwnedRelayPublishOutcome, PythonRelayPublishOutcome}; +use crate::session::SessionCommand; +use crate::sources::stable_source_parts; + +#[pyclass(name = "RecordingDiscontinuity", frozen)] +pub(crate) struct PythonRecordingDiscontinuity { + #[pyo3(get)] + stem_id: u64, + #[pyo3(get)] + label: String, + #[pyo3(get)] + kind: String, + #[pyo3(get)] + timestamp_start_ns: u64, + #[pyo3(get)] + timestamp_end_ns: u64, + #[pyo3(get)] + sequence_start: Option, + #[pyo3(get)] + sequence_end: Option, +} + +#[pyclass(name = "RecordingStemOutcome", frozen)] +pub(crate) struct PythonRecordingStemOutcome { + #[pyo3(get)] + stem_name: String, + #[pyo3(get)] + frames_written_total: u64, + #[pyo3(get)] + stale_frames_total: u64, + #[pyo3(get)] + error: Option, + #[pyo3(get)] + queue_capacity_frames: u64, + #[pyo3(get)] + queue_peak_frames: u64, + #[pyo3(get)] + frames_delivered_total: u64, + #[pyo3(get)] + frames_dropped_total: u64, + #[pyo3(get)] + queue_full_drops_total: u64, + #[pyo3(get)] + discontinuities_total: u64, + discontinuities: Vec>, +} + +#[pymethods] +impl PythonRecordingStemOutcome { + fn discontinuities(&self, py: Python<'_>) -> Vec> { + self.discontinuities + .iter() + .map(|value| value.clone_ref(py)) + .collect() + } +} + +#[pyclass(name = "RecordingOutcome", frozen)] +pub(crate) struct PythonRecordingOutcome { + #[pyo3(get)] + complete: bool, + #[pyo3(get)] + state: String, + #[pyo3(get)] + completed_stems: usize, + #[pyo3(get)] + failed_stems: usize, + #[pyo3(get)] + session_directory: String, + #[pyo3(get)] + error_code: Option, + stems: Vec>, +} + +#[pymethods] +impl PythonRecordingOutcome { + fn stems(&self, py: Python<'_>) -> Vec> { + self.stems.iter().map(|stem| stem.clone_ref(py)).collect() + } +} + +#[pyclass(name = "StopResult", frozen)] +pub(crate) struct PythonStopResult { + #[pyo3(get)] + pub(crate) success: bool, + #[pyo3(get)] + pub(crate) already_stopped: bool, + #[pyo3(get)] + pub(crate) disposition: String, + #[pyo3(get)] + pub(crate) runtime_worker_panicked: bool, + #[pyo3(get)] + pub(crate) capture_finalization_failures_total: u64, + #[pyo3(get)] + pub(crate) operator_finalization_failures_total: u64, + #[pyo3(get)] + pub(crate) endpoint_finalization_failures_total: u64, + #[pyo3(get)] + pub(crate) runtime_failures_total: u64, + #[pyo3(get)] + pub(crate) lineage_failures_total: u64, + #[pyo3(get)] + pub(crate) source_send_rejections_total: u64, + #[pyo3(get)] + pub(crate) runtime_events_total: u64, + #[pyo3(get)] + pub(crate) recording: Option>, + #[pyo3(get)] + pub(crate) trace: Option>, + #[pyo3(get)] + pub(crate) trace_error: Option, + #[pyo3(get)] + pub(crate) terminal_event: Option>, + pub(crate) relay: Vec>, + pub(crate) sidecars: Vec>, +} + +#[pyclass(name = "_SessionFailure", frozen)] +pub(crate) struct PythonSessionFailure { + #[pyo3(get)] + kind: String, + #[pyo3(get)] + stage: Option, + #[pyo3(get)] + operation: Option, + #[pyo3(get)] + error_class: Option, + #[pyo3(get)] + component: Option, + #[pyo3(get)] + message: Option, + #[pyo3(get)] + stem_id: Option, + #[pyo3(get)] + route_id: Option, + #[pyo3(get)] + endpoint_id: Option, + #[pyo3(get)] + operator_instance_id: Option, + #[pyo3(get)] + sidecar_id: Option, + #[pyo3(get)] + source_event_kind: Option, + #[pyo3(get)] + source_platform: Option, + #[pyo3(get)] + source_kind: Option, + #[pyo3(get)] + source_stable_key: Option, + #[pyo3(get)] + source_source_id: Option, + #[pyo3(get)] + source_generation: Option, + #[pyo3(get)] + source_recovery_requirement: Option, + #[pyo3(get)] + source_failure_operation: Option, + #[pyo3(get)] + source_failure_class: Option, + #[pyo3(get)] + source_platform_status_code: Option, + #[pyo3(get)] + source_backend_class: Option, +} + +#[pymethods] +impl PythonStopResult { + fn relay_outcomes(&self, py: Python<'_>) -> Vec> { + self.relay + .iter() + .map(|outcome| outcome.clone_ref(py)) + .collect() + } + + fn sidecar_outcomes(&self, py: Python<'_>) -> Vec> { + self.sidecars + .iter() + .map(|outcome| outcome.clone_ref(py)) + .collect() + } +} + +#[pyclass(name = "SessionEvent", frozen)] +pub(crate) struct PythonSessionEvent { + #[pyo3(get)] + kind: String, + #[pyo3(get)] + lifecycle_state: Option, + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + stem_id: Option, + #[pyo3(get)] + endpoint_id: Option, + #[pyo3(get)] + route_id: Option, + #[pyo3(get)] + failures_total: u64, + #[pyo3(get)] + terminal_state: Option, + #[pyo3(get)] + source_event_kind: Option, + #[pyo3(get)] + source_platform: Option, + #[pyo3(get)] + source_kind: Option, + #[pyo3(get)] + source_stable_key: Option, + #[pyo3(get)] + source_source_id: Option, + #[pyo3(get)] + source_generation: Option, + #[pyo3(get)] + source_recovery_requirement: Option, + #[pyo3(get)] + source_failure_operation: Option, + #[pyo3(get)] + source_failure_class: Option, + #[pyo3(get)] + source_platform_status_code: Option, + #[pyo3(get)] + source_backend_class: Option, + failures: Vec>, +} + +#[pymethods] +impl PythonSessionEvent { + fn failures(&self, py: Python<'_>) -> Vec> { + self.failures + .iter() + .map(|failure| failure.clone_ref(py)) + .collect() + } +} + +#[pyclass(name = "_SessionSourceMetrics", frozen)] +pub(crate) struct PythonSessionSourceMetrics { + #[pyo3(get)] + stem_id: u64, + #[pyo3(get)] + callback_buffers_total: u64, + #[pyo3(get)] + capture_frames_enqueued_total: u64, + #[pyo3(get)] + capture_pool_exhausted_total: u64, + #[pyo3(get)] + capture_dispatch_queue_full_total: u64, + #[pyo3(get)] + capture_invalid_buffer_total: u64, + #[pyo3(get)] + capture_oversized_buffer_total: u64, + #[pyo3(get)] + capture_stream_errors_total: u64, + #[pyo3(get)] + capture_timestamp_epoch_clamps_total: u64, + #[pyo3(get)] + frame_stream_delivered_frames_total: u64, + #[pyo3(get)] + frame_stream_dropped_newest_frames_total: u64, + #[pyo3(get)] + frames_discarded_before_start_total: u64, + #[pyo3(get)] + runtime_event_capacity_count: u64, + #[pyo3(get)] + runtime_event_maximum_event_owned_bytes: u64, + #[pyo3(get)] + runtime_event_maximum_buffered_owned_bytes: u64, + #[pyo3(get)] + runtime_event_depth_count: u64, + #[pyo3(get)] + runtime_event_depth_owned_bytes: u64, + #[pyo3(get)] + runtime_event_peak_depth_owned_bytes: u64, + #[pyo3(get)] + runtime_events_enqueued_total: u64, + #[pyo3(get)] + runtime_events_dropped_total: u64, + #[pyo3(get)] + runtime_events_dropped_oversized_total: u64, + #[pyo3(get)] + ingress_queue_capacity_frames: u64, + #[pyo3(get)] + ingress_queue_depth_frames: u64, + #[pyo3(get)] + ingress_queue_peak_frames: u64, + #[pyo3(get)] + ingress_frames_enqueued_total: u64, + #[pyo3(get)] + ingress_frames_delivered_total: u64, + #[pyo3(get)] + ingress_frames_rejected_full_total: u64, + #[pyo3(get)] + ingress_frames_rejected_cancelled_total: u64, + #[pyo3(get)] + ingress_frames_discarded_total: u64, +} + +#[pyclass(name = "_ExternalSourceMetrics", frozen)] +pub(crate) struct PythonExternalSourceMetrics { + #[pyo3(get)] + source_instance_id: u64, + #[pyo3(get)] + source_id: u64, + #[pyo3(get)] + emitted_total: u64, + #[pyo3(get)] + dropped_total: u64, + #[pyo3(get)] + failure_total: u64, + #[pyo3(get)] + cancellation_total: u64, + #[pyo3(get)] + discontinuity_total: u64, + #[pyo3(get)] + recovery_total: u64, + #[pyo3(get)] + policy_change_total: u64, + #[pyo3(get)] + ready: bool, + #[pyo3(get)] + joined: bool, +} + +#[pyclass(name = "_OperatorInputMetrics", frozen)] +pub(crate) struct PythonOperatorInputMetrics { + #[pyo3(get)] + port_name: String, + #[pyo3(get)] + edge: Py, +} + +#[pyclass(name = "_OperatorWorkerMetrics", frozen)] +pub(crate) struct PythonOperatorWorkerMetrics { + #[pyo3(get)] + input_attempted_total: u64, + #[pyo3(get)] + input_dropped_total: u64, + #[pyo3(get)] + processed_total: u64, + #[pyo3(get)] + output_emitted_total: u64, + #[pyo3(get)] + output_dropped_total: u64, + #[pyo3(get)] + output_nonterminal_total: u64, + #[pyo3(get)] + output_terminal_total: u64, + #[pyo3(get)] + process_failure_total: u64, + #[pyo3(get)] + timeout_total: u64, + #[pyo3(get)] + cancellation_total: u64, + #[pyo3(get)] + graceful_finish_total: u64, + #[pyo3(get)] + idle_poll_total: u64, + #[pyo3(get)] + ready: bool, + #[pyo3(get)] + joined: bool, +} + +#[pyclass(name = "_OperatorMetrics", frozen)] +pub(crate) struct PythonOperatorMetrics { + #[pyo3(get)] + operator_instance_id: u64, + #[pyo3(get)] + input_edge: Py, + #[pyo3(get)] + worker: Py, + #[pyo3(get)] + finalization_failures_total: u64, + input_ports: Vec>, +} + +#[pymethods] +impl PythonOperatorMetrics { + fn input_ports(&self, py: Python<'_>) -> Vec> { + self.input_ports + .iter() + .map(|input| input.clone_ref(py)) + .collect() + } +} + +#[pyclass(name = "_TypedEdgeMetrics", frozen)] +pub(crate) struct PythonTypedEdgeMetrics { + #[pyo3(get)] + capacity_signals: u64, + #[pyo3(get)] + max_payload_bytes: u64, + #[pyo3(get)] + maximum_buffered_payload_bytes: u64, + #[pyo3(get)] + depth_signals: u64, + #[pyo3(get)] + peak_depth_signals: u64, + #[pyo3(get)] + enqueued_total: u64, + #[pyo3(get)] + received_total: u64, + #[pyo3(get)] + dropped_total: u64, +} + +#[pyclass(name = "_DerivedRouteMetrics", frozen)] +pub(crate) struct PythonDerivedRouteMetrics { + #[pyo3(get)] + route_id: u64, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + output: Py, + #[pyo3(get)] + endpoint_observation_stage: String, + #[pyo3(get)] + endpoint_frames_received_total: u64, + #[pyo3(get)] + endpoint_frames_delivered_total: u64, + #[pyo3(get)] + endpoint_frames_dropped_total: u64, + #[pyo3(get)] + endpoint_discontinuities_total: u64, + #[pyo3(get)] + endpoint_failures_total: u64, + #[pyo3(get)] + endpoint_finalization_failures_total: u64, +} + +#[pyclass(name = "_AudioReentryMetrics", frozen)] +pub(crate) struct PythonAudioReentryMetrics { + #[pyo3(get)] + operator_instance_id: u64, + #[pyo3(get)] + stem_id: u64, + #[pyo3(get)] + queue_capacity_signals: u64, + #[pyo3(get)] + queue_depth_signals: u64, + #[pyo3(get)] + queue_peak_signals: u64, + #[pyo3(get)] + signals_enqueued_total: u64, + #[pyo3(get)] + signals_received_total: u64, + #[pyo3(get)] + signals_dropped_total: u64, + #[pyo3(get)] + pool_slots: u64, + #[pyo3(get)] + frame_capacity_samples: u64, + #[pyo3(get)] + maximum_buffered_audio_bytes: u64, + #[pyo3(get)] + normalized_total: u64, + #[pyo3(get)] + invalid_total: u64, + #[pyo3(get)] + shared_audio_rejected_total: u64, + #[pyo3(get)] + pool_exhausted_total: u64, + #[pyo3(get)] + ingress_rejected_total: u64, + #[pyo3(get)] + audio_frames_enqueued_total: u64, + #[pyo3(get)] + cancellation_total: u64, + #[pyo3(get)] + joined: bool, +} + +#[pyclass(name = "_EdgeMetrics", frozen)] +pub(crate) struct PythonEdgeMetrics { + #[pyo3(get)] + queue_capacity_frames: u64, + #[pyo3(get)] + queue_depth_frames: u64, + #[pyo3(get)] + queue_peak_frames: u64, + #[pyo3(get)] + frames_enqueued_total: u64, + #[pyo3(get)] + frames_delivered_total: u64, + #[pyo3(get)] + frames_dropped_total: u64, + #[pyo3(get)] + overruns_total: u64, + #[pyo3(get)] + receiver_unavailable_drops_total: u64, + #[pyo3(get)] + queue_full_drops_total: u64, + #[pyo3(get)] + shared_reference_exhausted_drops_total: u64, + #[pyo3(get)] + branch_pool_exhausted_drops_total: u64, + #[pyo3(get)] + invalid_copy_policy_drops_total: u64, + #[pyo3(get)] + freeze_failed_drops_total: u64, + #[pyo3(get)] + discontinuities_total: u64, + #[pyo3(get)] + source_identity_discontinuities_total: u64, + #[pyo3(get)] + sequence_discontinuities_total: u64, + #[pyo3(get)] + timestamp_discontinuities_total: u64, + #[pyo3(get)] + lineage_epoch_discontinuities_total: u64, + #[pyo3(get)] + manually_reported_discontinuities_total: u64, + #[pyo3(get)] + enqueue_to_receive_samples_total: u64, + #[pyo3(get)] + enqueue_to_receive_invalid_order_total: u64, + #[pyo3(get)] + enqueue_to_receive_p50_ns: u64, + #[pyo3(get)] + enqueue_to_receive_p95_ns: u64, + #[pyo3(get)] + enqueue_to_receive_p99_ns: u64, + #[pyo3(get)] + enqueue_to_receive_max_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_samples_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_missing_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_future_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_p50_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_p95_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_p99_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_max_ns: u64, + #[pyo3(get)] + worker_failures_total: u64, + #[pyo3(get)] + shutdown_discarded_total: u64, +} + +#[pyclass(name = "SessionMetrics", frozen)] +pub(crate) struct PythonSessionMetrics { + #[pyo3(get)] + event_capacity_count: u64, + #[pyo3(get)] + event_maximum_event_owned_bytes: u64, + #[pyo3(get)] + event_maximum_buffered_owned_bytes: u64, + #[pyo3(get)] + event_depth_count: u64, + #[pyo3(get)] + event_depth_owned_bytes: u64, + #[pyo3(get)] + event_peak_depth_count: u64, + #[pyo3(get)] + event_peak_depth_owned_bytes: u64, + #[pyo3(get)] + events_enqueued_total: u64, + #[pyo3(get)] + events_dropped_total: u64, + #[pyo3(get)] + events_dropped_oversized_total: u64, + #[pyo3(get)] + event_receiver_closed_total: u64, + #[pyo3(get)] + audio_registered_endpoints: u64, + #[pyo3(get)] + audio_queue_capacity_frames: u64, + #[pyo3(get)] + audio_queue_depth_frames: u64, + #[pyo3(get)] + audio_queue_peak_frames: u64, + #[pyo3(get)] + audio_queue_depth_invariant_failures_total: u64, + #[pyo3(get)] + audio_frames_received_total: u64, + #[pyo3(get)] + audio_frames_delivered_total: u64, + #[pyo3(get)] + audio_queue_full_drops_total: u64, + #[pyo3(get)] + audio_invalid_ownership_drops_total: u64, + #[pyo3(get)] + audio_lease_capacity_count: u64, + #[pyo3(get)] + audio_outstanding_leases: u64, + #[pyo3(get)] + audio_lease_exhausted_total: u64, + #[pyo3(get)] + audio_batches_polled_total: u64, + #[pyo3(get)] + audio_frames_polled_total: u64, + #[pyo3(get)] + source_count: usize, + #[pyo3(get)] + external_source_count: usize, + #[pyo3(get)] + route_count: usize, + #[pyo3(get)] + operator_count: usize, + #[pyo3(get)] + derived_route_count: usize, + #[pyo3(get)] + audio_reentry_count: usize, + #[pyo3(get)] + routes: Vec>, + #[pyo3(get)] + sources: Vec>, + #[pyo3(get)] + external_sources: Vec>, + #[pyo3(get)] + operators: Vec>, + #[pyo3(get)] + derived_routes: Vec>, + #[pyo3(get)] + audio_reentries: Vec>, +} + +#[pyclass(name = "RouteMetrics", frozen)] +pub(crate) struct PythonRouteMetrics { + #[pyo3(get)] + route_id: u64, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + endpoint_observation_stage: String, + #[pyo3(get)] + queue_capacity_frames: u64, + #[pyo3(get)] + queue_depth_frames: u64, + #[pyo3(get)] + queue_peak_frames: u64, + #[pyo3(get)] + frames_enqueued_total: u64, + #[pyo3(get)] + frames_attempted_total: u64, + #[pyo3(get)] + frames_delivered_total: u64, + #[pyo3(get)] + frames_dropped_total: u64, + #[pyo3(get)] + queue_full_drops_total: u64, + #[pyo3(get)] + overruns_total: u64, + #[pyo3(get)] + receiver_unavailable_drops_total: u64, + #[pyo3(get)] + shared_reference_exhausted_drops_total: u64, + #[pyo3(get)] + branch_pool_exhausted_drops_total: u64, + #[pyo3(get)] + invalid_copy_policy_drops_total: u64, + #[pyo3(get)] + freeze_failed_drops_total: u64, + #[pyo3(get)] + discontinuities_total: u64, + #[pyo3(get)] + source_identity_discontinuities_total: u64, + #[pyo3(get)] + sequence_discontinuities_total: u64, + #[pyo3(get)] + timestamp_discontinuities_total: u64, + #[pyo3(get)] + lineage_epoch_discontinuities_total: u64, + #[pyo3(get)] + manually_reported_discontinuities_total: u64, + #[pyo3(get)] + enqueue_to_receive_samples_total: u64, + #[pyo3(get)] + enqueue_to_receive_invalid_order_total: u64, + #[pyo3(get)] + enqueue_to_receive_p50_ns: u64, + #[pyo3(get)] + enqueue_to_receive_p95_ns: u64, + #[pyo3(get)] + enqueue_to_receive_p99_ns: u64, + #[pyo3(get)] + enqueue_to_receive_max_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_samples_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_missing_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_future_total: u64, + #[pyo3(get)] + source_timestamp_to_receive_p50_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_p95_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_p99_ns: u64, + #[pyo3(get)] + source_timestamp_to_receive_max_ns: u64, + #[pyo3(get)] + worker_failures_total: u64, + #[pyo3(get)] + shutdown_discarded_total: u64, + #[pyo3(get)] + endpoint_frames_received_total: u64, + #[pyo3(get)] + endpoint_frames_delivered_total: u64, + #[pyo3(get)] + endpoint_frames_dropped_total: u64, + #[pyo3(get)] + endpoint_discontinuities_total: u64, + #[pyo3(get)] + endpoint_failures_total: u64, + #[pyo3(get)] + endpoint_finalization_failures_total: u64, + #[pyo3(get)] + drop_observation_interval: String, + #[pyo3(get)] + drop_rate_pct: f64, + #[pyo3(get)] + source_latency_boundary: String, + #[pyo3(get)] + source_latency_unit: String, +} + +#[pyclass(name = "SessionTraceRecorderOutcome", frozen)] +pub(crate) struct PythonSessionTraceRecorderOutcome { + #[pyo3(get)] + path: String, + #[pyo3(get)] + records_attempted_total: u64, + #[pyo3(get)] + records_enqueued_total: u64, + #[pyo3(get)] + records_dropped_total: u64, + #[pyo3(get)] + records_written_total: u64, + #[pyo3(get)] + rolling_hash: u64, + #[pyo3(get)] + complete: bool, +} + +#[pyclass(name = "_SessionTraceValidation", frozen)] +pub(crate) struct PythonSessionTraceValidation { + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + lifecycle: Vec, + #[pyo3(get)] + terminal_state: String, + #[pyo3(get)] + source_failures_total: u64, + #[pyo3(get)] + endpoint_failures_total: u64, + #[pyo3(get)] + rollback_failures_total: u64, + #[pyo3(get)] + finalization_failures_total: u64, + #[pyo3(get)] + records_validated_total: u64, +} + +#[pyclass(name = "SessionTrace", frozen)] +pub(crate) struct PythonSessionTrace { + trace: pocketstation::SessionTrace, +} + +#[pymethods] +impl PythonSessionTrace { + #[staticmethod] + fn read(path: PathBuf) -> PyResult { + pocketstation::SessionTrace::read(path) + .map(|trace| Self { trace }) + .map_err(session_trace_validation_error) + } + + #[getter] + fn session_id(&self) -> u64 { + self.trace.session_id().get() + } + + #[getter] + fn outcome(&self) -> PythonSessionTraceRecorderOutcome { + PythonSessionTraceRecorderOutcome::from(self.trace.outcome().clone()) + } + + #[getter] + fn records_total(&self) -> usize { + self.trace.records().len() + } + + fn validate(&self) -> PyResult { + self.trace + .validate() + .map(PythonSessionTraceValidation::from) + .map_err(session_trace_validation_error) + } +} + +pub(crate) struct OwnedRecordingStemOutcome { + stem_name: String, + frames_written_total: u64, + stale_frames_total: u64, + error: Option, + queue_capacity_frames: u64, + queue_peak_frames: u64, + frames_delivered_total: u64, + frames_dropped_total: u64, + queue_full_drops_total: u64, + discontinuities_total: u64, + discontinuities: Vec, +} + +struct OwnedRecordingDiscontinuity { + stem_id: u64, + label: String, + kind: String, + timestamp_start_ns: u64, + timestamp_end_ns: u64, + sequence_start: Option, + sequence_end: Option, +} + +pub(crate) struct OwnedRecordingOutcome { + pub(crate) complete: bool, + state: String, + completed_stems: usize, + failed_stems: usize, + session_directory: String, + error_code: Option, + pub(crate) stems: Vec, +} + +pub(crate) struct OwnedStopResult { + pub(crate) success: bool, + pub(crate) already_stopped: bool, + pub(crate) disposition: String, + pub(crate) runtime_worker_panicked: bool, + pub(crate) capture_finalization_failures_total: u64, + pub(crate) operator_finalization_failures_total: u64, + pub(crate) endpoint_finalization_failures_total: u64, + pub(crate) runtime_failures_total: u64, + pub(crate) lineage_failures_total: u64, + pub(crate) source_send_rejections_total: u64, + pub(crate) runtime_events_total: u64, + pub(crate) recording: Option, + pub(crate) trace: Option, + pub(crate) trace_error: Option, + pub(crate) terminal_event: Option, + pub(crate) relay: Vec, + pub(crate) sidecars: Vec, +} + +pub(crate) struct OwnedSessionEvent { + pub(crate) kind: String, + lifecycle_state: Option, + session_id: u64, + stem_id: Option, + endpoint_id: Option, + route_id: Option, + failures_total: u64, + terminal_state: Option, + source_event_kind: Option, + source_platform: Option, + source_kind: Option, + source_stable_key: Option, + source_source_id: Option, + source_generation: Option, + source_recovery_requirement: Option, + source_failure_operation: Option, + source_failure_class: Option, + source_platform_status_code: Option, + source_backend_class: Option, + failures: Vec, +} + +#[derive(Default)] +struct OwnedSessionFailure { + kind: String, + stage: Option, + operation: Option, + error_class: Option, + component: Option, + message: Option, + stem_id: Option, + route_id: Option, + endpoint_id: Option, + operator_instance_id: Option, + sidecar_id: Option, + source_event_kind: Option, + source_platform: Option, + source_kind: Option, + source_stable_key: Option, + source_source_id: Option, + source_generation: Option, + source_recovery_requirement: Option, + source_failure_operation: Option, + source_failure_class: Option, + source_platform_status_code: Option, + source_backend_class: Option, +} + +pub(crate) struct OwnedSessionMetrics { + event_capacity_count: u64, + event_maximum_event_owned_bytes: u64, + event_maximum_buffered_owned_bytes: u64, + event_depth_count: u64, + event_depth_owned_bytes: u64, + event_peak_depth_count: u64, + event_peak_depth_owned_bytes: u64, + events_enqueued_total: u64, + events_dropped_total: u64, + events_dropped_oversized_total: u64, + event_receiver_closed_total: u64, + audio_registered_endpoints: u64, + audio_queue_capacity_frames: u64, + audio_queue_depth_frames: u64, + audio_queue_peak_frames: u64, + audio_queue_depth_invariant_failures_total: u64, + audio_frames_received_total: u64, + audio_frames_delivered_total: u64, + audio_queue_full_drops_total: u64, + audio_invalid_ownership_drops_total: u64, + audio_lease_capacity_count: u64, + audio_outstanding_leases: u64, + audio_lease_exhausted_total: u64, + audio_batches_polled_total: u64, + audio_frames_polled_total: u64, + pub(crate) source_count: usize, + external_source_count: usize, + pub(crate) route_count: usize, + operator_count: usize, + derived_route_count: usize, + audio_reentry_count: usize, + pub(crate) routes: Vec, + sources: Vec, + external_sources: Vec, + operators: Vec, + derived_routes: Vec, + audio_reentries: Vec, +} + +pub(crate) struct OwnedRouteMetrics { + pub(crate) route_id: u64, + endpoint_id: u64, + endpoint_observation_stage: String, + queue_capacity_frames: u64, + queue_depth_frames: u64, + queue_peak_frames: u64, + frames_enqueued_total: u64, + frames_attempted_total: u64, + pub(crate) frames_delivered_total: u64, + frames_dropped_total: u64, + queue_full_drops_total: u64, + overruns_total: u64, + receiver_unavailable_drops_total: u64, + shared_reference_exhausted_drops_total: u64, + branch_pool_exhausted_drops_total: u64, + invalid_copy_policy_drops_total: u64, + freeze_failed_drops_total: u64, + discontinuities_total: u64, + source_identity_discontinuities_total: u64, + sequence_discontinuities_total: u64, + timestamp_discontinuities_total: u64, + lineage_epoch_discontinuities_total: u64, + manually_reported_discontinuities_total: u64, + enqueue_to_receive_samples_total: u64, + enqueue_to_receive_invalid_order_total: u64, + enqueue_to_receive_p50_ns: u64, + enqueue_to_receive_p95_ns: u64, + enqueue_to_receive_p99_ns: u64, + enqueue_to_receive_max_ns: u64, + source_timestamp_to_receive_samples_total: u64, + source_timestamp_to_receive_missing_total: u64, + source_timestamp_to_receive_future_total: u64, + source_timestamp_to_receive_p50_ns: u64, + source_timestamp_to_receive_p95_ns: u64, + source_timestamp_to_receive_p99_ns: u64, + source_timestamp_to_receive_max_ns: u64, + worker_failures_total: u64, + shutdown_discarded_total: u64, + pub(crate) endpoint_frames_received_total: u64, + endpoint_frames_delivered_total: u64, + endpoint_frames_dropped_total: u64, + endpoint_discontinuities_total: u64, + endpoint_failures_total: u64, + endpoint_finalization_failures_total: u64, + drop_rate_pct: f64, +} + +pub(crate) fn request_event( + commands: &SyncSender, +) -> PyResult> { + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::PollEvent { response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not return an event"))? + .map_err(PyRuntimeError::new_err) +} + +pub(crate) fn request_event_wait( + commands: &SyncSender, + timeout: Duration, +) -> PyResult> { + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::WaitEvent { timeout, response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not return an event"))? + .map_err(PyRuntimeError::new_err) +} + +pub(crate) fn request_metrics( + commands: &SyncSender, +) -> PyResult { + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::Metrics { response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not return metrics"))? + .map_err(PyRuntimeError::new_err) +} + +pub(crate) fn drain_terminal_event( + running: &pocketstation::RunningSession, +) -> Option { + let mut terminal = None; + while let pocketstation::SessionEventReceive::Event(event) = running.try_recv_event() { + let projected = owned_session_event(&event); + if projected.kind == "terminal" { + terminal = Some(projected); + } + } + terminal +} + +pub(crate) fn copy_event( + running: &pocketstation::RunningSession, +) -> Result, String> { + match running.try_recv_event() { + pocketstation::SessionEventReceive::Event(event) => Ok(Some(owned_session_event(&event))), + pocketstation::SessionEventReceive::Empty => Ok(None), + pocketstation::SessionEventReceive::Closed => { + Err("native Session event queue is closed".to_owned()) + } + } +} + +pub(crate) fn copy_event_until( + running: &pocketstation::RunningSession, + timeout: Duration, +) -> Result, String> { + let deadline = Instant::now() + timeout; + loop { + match copy_event(running)? { + Some(event) => return Ok(Some(event)), + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(1)), + None => return Ok(None), + } + } +} + +pub(crate) fn copy_metrics( + running: &pocketstation::RunningSession, +) -> Result { + let snapshot = running + .metrics_snapshot() + .map_err(|error| error.to_string())?; + let events = snapshot.event_queue(); + let audio = snapshot.polled_audio(); + let routes = (0..snapshot.route_count()) + .filter_map(|index| snapshot.route(index)) + .map(|route| { + let endpoint = route.endpoint.unwrap_or_default(); + OwnedRouteMetrics { + route_id: route.route_id.get(), + endpoint_id: route.endpoint_id.get(), + endpoint_observation_stage: endpoint_observation_stage_name( + route.endpoint_observation_stage, + ), + queue_capacity_frames: route.edge.queue_capacity_frames, + queue_depth_frames: route.edge.queue_depth_frames, + queue_peak_frames: route.edge.queue_peak_frames, + frames_enqueued_total: route.edge.frames_enqueued_total, + frames_attempted_total: route.edge.frames_attempted_total(), + frames_delivered_total: route.edge.frames_delivered_total, + frames_dropped_total: route.edge.frames_dropped_total, + queue_full_drops_total: route.edge.queue_full_drops_total, + overruns_total: route.edge.overruns_total, + receiver_unavailable_drops_total: route.edge.receiver_unavailable_drops_total, + shared_reference_exhausted_drops_total: route + .edge + .shared_reference_exhausted_drops_total, + branch_pool_exhausted_drops_total: route.edge.branch_pool_exhausted_drops_total, + invalid_copy_policy_drops_total: route.edge.invalid_copy_policy_drops_total, + freeze_failed_drops_total: route.edge.freeze_failed_drops_total, + discontinuities_total: route.edge.discontinuities_total, + source_identity_discontinuities_total: route + .edge + .source_identity_discontinuities_total, + sequence_discontinuities_total: route.edge.sequence_discontinuities_total, + timestamp_discontinuities_total: route.edge.timestamp_discontinuities_total, + lineage_epoch_discontinuities_total: route.edge.lineage_epoch_discontinuities_total, + manually_reported_discontinuities_total: route + .edge + .manually_reported_discontinuities_total, + enqueue_to_receive_samples_total: route.edge.enqueue_to_receive_samples_total, + enqueue_to_receive_invalid_order_total: route + .edge + .enqueue_to_receive_invalid_order_total, + enqueue_to_receive_p50_ns: route.edge.enqueue_to_receive_p50_ns, + enqueue_to_receive_p95_ns: route.edge.enqueue_to_receive_p95_ns, + enqueue_to_receive_p99_ns: route.edge.enqueue_to_receive_p99_ns, + enqueue_to_receive_max_ns: route.edge.enqueue_to_receive_max_ns, + source_timestamp_to_receive_samples_total: route + .edge + .source_timestamp_to_receive_samples_total, + source_timestamp_to_receive_missing_total: route + .edge + .source_timestamp_to_receive_missing_total, + source_timestamp_to_receive_future_total: route + .edge + .source_timestamp_to_receive_future_total, + source_timestamp_to_receive_p50_ns: route.edge.source_timestamp_to_receive_p50_ns, + source_timestamp_to_receive_p95_ns: route.edge.source_timestamp_to_receive_p95_ns, + source_timestamp_to_receive_p99_ns: route.edge.source_timestamp_to_receive_p99_ns, + source_timestamp_to_receive_max_ns: route.edge.source_timestamp_to_receive_max_ns, + worker_failures_total: route.edge.worker_failures_total, + shutdown_discarded_total: route.edge.shutdown_discarded_total, + endpoint_frames_received_total: endpoint.frames_received_total, + endpoint_frames_delivered_total: endpoint.frames_delivered_total, + endpoint_frames_dropped_total: endpoint.frames_dropped_total, + endpoint_discontinuities_total: endpoint.discontinuities_total, + endpoint_failures_total: endpoint.failures_total, + endpoint_finalization_failures_total: route.endpoint_finalization_failures_total, + drop_rate_pct: route.drop_observations().drop_rate_pct(), + } + }) + .collect(); + let sources = (0..snapshot.source_count()) + .filter_map(|index| snapshot.source(index).copied()) + .collect(); + let external_sources = running.external_source_metrics().into_vec(); + let operators = running.operator_metrics().into_vec(); + let derived_routes = running.derived_route_metrics().into_vec(); + let audio_reentries = running.audio_reentry_metrics().into_vec(); + Ok(OwnedSessionMetrics { + event_capacity_count: events.capacity_event_count, + event_maximum_event_owned_bytes: events.maximum_event_owned_bytes, + event_maximum_buffered_owned_bytes: events.maximum_buffered_owned_bytes, + event_depth_count: events.depth_events, + event_depth_owned_bytes: events.depth_owned_bytes, + event_peak_depth_count: events.peak_depth_event_count, + event_peak_depth_owned_bytes: events.peak_depth_owned_bytes, + events_enqueued_total: events.events_enqueued_total, + events_dropped_total: events.events_dropped_total, + events_dropped_oversized_total: events.events_dropped_oversized_total, + event_receiver_closed_total: events.receiver_closed_total, + audio_registered_endpoints: audio.registered_endpoints, + audio_queue_capacity_frames: audio.queue_capacity_frames, + audio_queue_depth_frames: audio.queue_depth_frames, + audio_queue_peak_frames: audio.queue_peak_frames, + audio_queue_depth_invariant_failures_total: audio.queue_depth_invariant_failures_total, + audio_frames_received_total: audio.frames_received_total, + audio_frames_delivered_total: audio.frames_delivered_total, + audio_queue_full_drops_total: audio.queue_full_drops_total, + audio_invalid_ownership_drops_total: audio.invalid_ownership_drops_total, + audio_lease_capacity_count: audio.lease_capacity_count, + audio_outstanding_leases: audio.outstanding_leases, + audio_lease_exhausted_total: audio.lease_exhausted_total, + audio_batches_polled_total: audio.batches_polled_total, + audio_frames_polled_total: audio.frames_polled_total, + source_count: snapshot.source_count(), + external_source_count: external_sources.len(), + route_count: snapshot.route_count(), + operator_count: operators.len(), + derived_route_count: derived_routes.len(), + audio_reentry_count: audio_reentries.len(), + routes, + sources, + external_sources, + operators, + derived_routes, + audio_reentries, + }) +} + +const fn lifecycle_state_name(state: pocketstation::SessionLifecycleState) -> &'static str { + match state { + pocketstation::SessionLifecycleState::Starting => "starting", + pocketstation::SessionLifecycleState::Running => "running", + pocketstation::SessionLifecycleState::Stopping => "stopping", + pocketstation::SessionLifecycleState::Stopped => "stopped", + pocketstation::SessionLifecycleState::Failed => "failed", + } +} + +const fn terminal_state_name(state: pocketstation::SessionTerminalState) -> &'static str { + match state { + pocketstation::SessionTerminalState::Stopped => "stopped", + pocketstation::SessionTerminalState::Failed => "failed", + } +} + +const fn endpoint_failure_stage_name(stage: pocketstation::EndpointFailureStage) -> &'static str { + match stage { + pocketstation::EndpointFailureStage::Prepare => "prepare", + pocketstation::EndpointFailureStage::CancelPreparation => "cancel-preparation", + pocketstation::EndpointFailureStage::Start => "start", + pocketstation::EndpointFailureStage::RequestStop => "request-stop", + pocketstation::EndpointFailureStage::JoinFinalize => "join-finalize", + } +} + +fn rollback_stage_name(stage: impl std::fmt::Debug) -> String { + match format!("{stage:?}").as_str() { + "CancelOperator" => "cancel-operator", + "CancelEndpointPreparation" => "cancel-endpoint-preparation", + "FinalizeStartedEndpoint" => "finalize-started-endpoint", + "StopOpenedCapture" => "stop-opened-capture", + "DiscardRuntimeQueues" => "discard-runtime-queues", + _ => "unrecognized-rollback-stage", + } + .to_owned() +} + +fn finalization_stage_name(stage: impl std::fmt::Debug) -> String { + match format!("{stage:?}").as_str() { + "StopCapture" => "stop-capture", + "DrainRuntime" => "drain-runtime", + "DrainOperator" => "drain-operator", + "RequestEndpointStop" => "request-endpoint-stop", + "JoinEndpoint" => "join-endpoint", + "FinalizeEndpoint" => "finalize-endpoint", + "DrainSidecar" => "drain-sidecar", + _ => "unrecognized-finalization-stage", + } + .to_owned() +} + +fn endpoint_observation_stage_name(stage: impl std::fmt::Debug) -> String { + match format!("{stage:?}").as_str() { + "Unavailable" => "unavailable", + "Live" => "live", + "Finalized" => "finalized", + _ => "unrecognized-endpoint-observation-stage", + } + .to_owned() +} + +fn owned_session_event(event: &pocketstation::SessionEvent) -> OwnedSessionEvent { + let mut output = empty_owned_session_event(event.session_id().get()); + match event.kind() { + pocketstation::SessionEventKind::Lifecycle(state) => { + output.lifecycle_state = Some(lifecycle_state_name(*state).to_owned()); + } + pocketstation::SessionEventKind::Source(failure) => { + "source_failure".clone_into(&mut output.kind); + output.stem_id = Some(failure.stem_id().get()); + output.failures_total = 1; + populate_source_runtime_event(&mut output, failure.event()); + output.failures.push(owned_source_failure( + failure.stem_id().get(), + failure.event(), + )); + } + pocketstation::SessionEventKind::Endpoint(failure) => { + "endpoint_failure".clone_into(&mut output.kind); + output.endpoint_id = Some(failure.endpoint_id().get()); + output.route_id = Some(failure.route_id().get()); + output.failures_total = 1; + output.failures.push(owned_endpoint_failure( + failure.route_id().get(), + failure.endpoint_id().get(), + endpoint_failure_stage_name(failure.stage()).to_owned(), + failure.failure().message(), + )); + } + pocketstation::SessionEventKind::Rollback(failure) => { + "rollback_failure".clone_into(&mut output.kind); + output.failures_total = 1; + output.failures.push(owned_control_failure( + "rollback", + Some(rollback_stage_name(failure.stage())), + failure.failure(), + )); + } + pocketstation::SessionEventKind::Finalization(failure) => { + "finalization_failure".clone_into(&mut output.kind); + output.failures_total = 1; + output.failures.push(owned_control_failure( + "finalization", + Some(finalization_stage_name(failure.stage())), + failure.failure(), + )); + } + pocketstation::SessionEventKind::Terminal(outcome) => { + "terminal".clone_into(&mut output.kind); + let terminal_state = terminal_state_name(outcome.state()).to_owned(); + output.lifecycle_state = Some(terminal_state.clone()); + output.terminal_state = Some(terminal_state); + output.failures_total = (outcome.source_failures().len() + + outcome.endpoint_failures().len() + + outcome.rollback_failures().len() + + outcome.finalization_failures().len()) as u64; + output.failures.extend( + outcome + .source_failures() + .iter() + .map(|failure| owned_source_failure(failure.stem_id().get(), failure.event())), + ); + output + .failures + .extend(outcome.endpoint_failures().iter().map(|failure| { + owned_endpoint_failure( + failure.route_id().get(), + failure.endpoint_id().get(), + endpoint_failure_stage_name(failure.stage()).to_owned(), + failure.failure().message(), + ) + })); + output + .failures + .extend(outcome.rollback_failures().iter().map(|failure| { + owned_control_failure( + "rollback", + Some(rollback_stage_name(failure.stage())), + failure.failure(), + ) + })); + output + .failures + .extend(outcome.finalization_failures().iter().map(|failure| { + owned_control_failure( + "finalization", + Some(finalization_stage_name(failure.stage())), + failure.failure(), + ) + })); + } + } + output +} + +fn empty_owned_session_event(session_id: u64) -> OwnedSessionEvent { + OwnedSessionEvent { + kind: "lifecycle".to_owned(), + lifecycle_state: None, + session_id, + stem_id: None, + endpoint_id: None, + route_id: None, + failures_total: 0, + terminal_state: None, + source_event_kind: None, + source_platform: None, + source_kind: None, + source_stable_key: None, + source_source_id: None, + source_generation: None, + source_recovery_requirement: None, + source_failure_operation: None, + source_failure_class: None, + source_platform_status_code: None, + source_backend_class: None, + failures: Vec::new(), + } +} + +fn owned_source_failure( + stem_id: u64, + event: &pocketstation::SourceRuntimeEvent, +) -> OwnedSessionFailure { + let mut projected = empty_owned_session_event(0); + populate_source_runtime_event(&mut projected, event); + OwnedSessionFailure { + kind: "source".to_owned(), + stem_id: Some(stem_id), + source_event_kind: projected.source_event_kind, + source_platform: projected.source_platform, + source_kind: projected.source_kind, + source_stable_key: projected.source_stable_key, + source_source_id: projected.source_source_id, + source_generation: projected.source_generation, + source_recovery_requirement: projected.source_recovery_requirement, + source_failure_operation: projected.source_failure_operation, + source_failure_class: projected.source_failure_class, + source_platform_status_code: projected.source_platform_status_code, + source_backend_class: projected.source_backend_class, + ..OwnedSessionFailure::default() + } +} + +fn owned_endpoint_failure( + route_id: u64, + endpoint_id: u64, + stage: String, + message: &str, +) -> OwnedSessionFailure { + OwnedSessionFailure { + kind: "endpoint".to_owned(), + stage: Some(stage), + error_class: Some("endpoint-failure".to_owned()), + message: Some(message.to_owned()), + route_id: Some(route_id), + endpoint_id: Some(endpoint_id), + ..OwnedSessionFailure::default() + } +} + +fn owned_control_failure( + kind: &str, + stage: Option, + failure: &pocketstation::SessionControlFailure, +) -> OwnedSessionFailure { + OwnedSessionFailure { + kind: kind.to_owned(), + stage, + operation: Some(failure.operation().to_owned()), + error_class: Some(failure.error_class().to_owned()), + component: Some(format!("{:?}", failure.component())), + ..OwnedSessionFailure::default() + } +} + +fn populate_source_runtime_event( + output: &mut OwnedSessionEvent, + event: &pocketstation::SourceRuntimeEvent, +) { + let (event_kind, stable_id, generation, recovery_requirement, failure) = match event { + pocketstation::SourceRuntimeEvent::SourceUnavailable { + stable_id, + generation, + recovery_requirement, + failure, + } => ( + "source-unavailable", + stable_id, + generation, + Some(match recovery_requirement { + pocketstation::SourceRecoveryRequirement::ExplicitRediscoveryAndNewSession => { + "explicit-rediscovery-and-new-session" + } + }), + failure, + ), + pocketstation::SourceRuntimeEvent::BackendFailure { + stable_id, + generation, + failure, + } => ("backend-failure", stable_id, generation, None, failure), + }; + let (platform, kind, stable_key) = stable_source_parts(stable_id); + output.source_event_kind = Some(event_kind.to_owned()); + output.source_platform = Some(platform.to_owned()); + output.source_kind = Some(kind.to_owned()); + output.source_stable_key = Some(stable_key.to_owned()); + output.source_source_id = Some(stable_id.source_id().get()); + output.source_generation = Some(generation.0); + output.source_recovery_requirement = recovery_requirement.map(str::to_owned); + output.source_failure_operation = Some(failure.operation.to_owned()); + match &failure.error_class { + pocketstation::CaptureRuntimeFailureClass::SourceInstanceExited => { + output.source_failure_class = Some("source-instance-exited".to_owned()); + } + pocketstation::CaptureRuntimeFailureClass::PlatformStatus { status_code } => { + output.source_failure_class = Some("platform-status".to_owned()); + output.source_platform_status_code = Some(*status_code); + } + pocketstation::CaptureRuntimeFailureClass::BackendClass { class } => { + output.source_failure_class = Some("backend-class".to_owned()); + output.source_backend_class = Some(class.clone()); + } + } +} + +pub(crate) fn python_session_event( + py: Python<'_>, + event: OwnedSessionEvent, +) -> PyResult { + let failures = event + .failures + .into_iter() + .map(|failure| { + Py::new( + py, + PythonSessionFailure { + kind: failure.kind, + stage: failure.stage, + operation: failure.operation, + error_class: failure.error_class, + component: failure.component, + message: failure.message, + stem_id: failure.stem_id, + route_id: failure.route_id, + endpoint_id: failure.endpoint_id, + operator_instance_id: failure.operator_instance_id, + sidecar_id: failure.sidecar_id, + source_event_kind: failure.source_event_kind, + source_platform: failure.source_platform, + source_kind: failure.source_kind, + source_stable_key: failure.source_stable_key, + source_source_id: failure.source_source_id, + source_generation: failure.source_generation, + source_recovery_requirement: failure.source_recovery_requirement, + source_failure_operation: failure.source_failure_operation, + source_failure_class: failure.source_failure_class, + source_platform_status_code: failure.source_platform_status_code, + source_backend_class: failure.source_backend_class, + }, + ) + }) + .collect::>>()?; + Ok(PythonSessionEvent { + kind: event.kind, + lifecycle_state: event.lifecycle_state, + session_id: event.session_id, + stem_id: event.stem_id, + endpoint_id: event.endpoint_id, + route_id: event.route_id, + failures_total: event.failures_total, + terminal_state: event.terminal_state, + source_event_kind: event.source_event_kind, + source_platform: event.source_platform, + source_kind: event.source_kind, + source_stable_key: event.source_stable_key, + source_source_id: event.source_source_id, + source_generation: event.source_generation, + source_recovery_requirement: event.source_recovery_requirement, + source_failure_operation: event.source_failure_operation, + source_failure_class: event.source_failure_class, + source_platform_status_code: event.source_platform_status_code, + source_backend_class: event.source_backend_class, + failures, + }) +} + +impl From for PythonEdgeMetrics { + fn from(edge: pocketstation::EdgeObservations) -> Self { + Self { + queue_capacity_frames: edge.queue_capacity_frames, + queue_depth_frames: edge.queue_depth_frames, + queue_peak_frames: edge.queue_peak_frames, + frames_enqueued_total: edge.frames_enqueued_total, + frames_delivered_total: edge.frames_delivered_total, + frames_dropped_total: edge.frames_dropped_total, + overruns_total: edge.overruns_total, + receiver_unavailable_drops_total: edge.receiver_unavailable_drops_total, + queue_full_drops_total: edge.queue_full_drops_total, + shared_reference_exhausted_drops_total: edge.shared_reference_exhausted_drops_total, + branch_pool_exhausted_drops_total: edge.branch_pool_exhausted_drops_total, + invalid_copy_policy_drops_total: edge.invalid_copy_policy_drops_total, + freeze_failed_drops_total: edge.freeze_failed_drops_total, + discontinuities_total: edge.discontinuities_total, + source_identity_discontinuities_total: edge.source_identity_discontinuities_total, + sequence_discontinuities_total: edge.sequence_discontinuities_total, + timestamp_discontinuities_total: edge.timestamp_discontinuities_total, + lineage_epoch_discontinuities_total: edge.lineage_epoch_discontinuities_total, + manually_reported_discontinuities_total: edge.manually_reported_discontinuities_total, + enqueue_to_receive_samples_total: edge.enqueue_to_receive_samples_total, + enqueue_to_receive_invalid_order_total: edge.enqueue_to_receive_invalid_order_total, + enqueue_to_receive_p50_ns: edge.enqueue_to_receive_p50_ns, + enqueue_to_receive_p95_ns: edge.enqueue_to_receive_p95_ns, + enqueue_to_receive_p99_ns: edge.enqueue_to_receive_p99_ns, + enqueue_to_receive_max_ns: edge.enqueue_to_receive_max_ns, + source_timestamp_to_receive_samples_total: edge + .source_timestamp_to_receive_samples_total, + source_timestamp_to_receive_missing_total: edge + .source_timestamp_to_receive_missing_total, + source_timestamp_to_receive_future_total: edge.source_timestamp_to_receive_future_total, + source_timestamp_to_receive_p50_ns: edge.source_timestamp_to_receive_p50_ns, + source_timestamp_to_receive_p95_ns: edge.source_timestamp_to_receive_p95_ns, + source_timestamp_to_receive_p99_ns: edge.source_timestamp_to_receive_p99_ns, + source_timestamp_to_receive_max_ns: edge.source_timestamp_to_receive_max_ns, + worker_failures_total: edge.worker_failures_total, + shutdown_discarded_total: edge.shutdown_discarded_total, + } + } +} + +impl From for PythonSessionSourceMetrics { + fn from(source: pocketstation::SessionSourceMetrics) -> Self { + Self { + stem_id: source.stem_id.get(), + callback_buffers_total: source.capture.backend.callback_buffers_total, + capture_frames_enqueued_total: source.capture.backend.frames_enqueued_total, + capture_pool_exhausted_total: source.capture.backend.pool_exhausted_total, + capture_dispatch_queue_full_total: source.capture.backend.dispatch_queue_full_total, + capture_invalid_buffer_total: source.capture.backend.invalid_buffer_total, + capture_oversized_buffer_total: source.capture.backend.oversized_buffer_total, + capture_stream_errors_total: source.capture.backend.stream_errors_total, + capture_timestamp_epoch_clamps_total: source + .capture + .backend + .timestamp_epoch_clamps_total, + frame_stream_delivered_frames_total: source.capture.frame_stream.delivered_frames, + frame_stream_dropped_newest_frames_total: source + .capture + .frame_stream + .dropped_newest_frames, + frames_discarded_before_start_total: source + .capture + .frame_stream + .frames_discarded_before_start_total, + runtime_event_capacity_count: source.capture.runtime_events.capacity_event_count, + runtime_event_maximum_event_owned_bytes: source + .capture + .runtime_events + .maximum_event_owned_bytes, + runtime_event_maximum_buffered_owned_bytes: source + .capture + .runtime_events + .maximum_buffered_owned_bytes, + runtime_event_depth_count: source.capture.runtime_events.depth_events, + runtime_event_depth_owned_bytes: source.capture.runtime_events.depth_owned_bytes, + runtime_event_peak_depth_owned_bytes: source + .capture + .runtime_events + .peak_depth_owned_bytes, + runtime_events_enqueued_total: source.capture.runtime_events.events_enqueued_total, + runtime_events_dropped_total: source.capture.runtime_events.events_dropped_total, + runtime_events_dropped_oversized_total: source + .capture + .runtime_events + .events_dropped_oversized_total, + ingress_queue_capacity_frames: source.ingress.queue_capacity_frames, + ingress_queue_depth_frames: source.ingress.queue_depth_frames, + ingress_queue_peak_frames: source.ingress.queue_peak_frames, + ingress_frames_enqueued_total: source.ingress.frames_enqueued_total, + ingress_frames_delivered_total: source.ingress.frames_delivered_total, + ingress_frames_rejected_full_total: source.ingress.frames_rejected_full_total, + ingress_frames_rejected_cancelled_total: source.ingress.frames_rejected_cancelled_total, + ingress_frames_discarded_total: source.ingress.frames_discarded_total, + } + } +} + +impl From for PythonExternalSourceMetrics { + fn from(source: pocketstation::SessionExternalSourceMetrics) -> Self { + Self { + source_instance_id: source.source_instance_id.value(), + source_id: source.source_id.get(), + emitted_total: source.runtime.emitted_total, + dropped_total: source.runtime.dropped_total, + failure_total: source.runtime.failure_total, + cancellation_total: source.runtime.cancellation_total, + discontinuity_total: source.runtime.discontinuity_total, + recovery_total: source.runtime.recovery_total, + policy_change_total: source.runtime.policy_change_total, + ready: source.runtime.ready, + joined: source.runtime.joined, + } + } +} + +fn python_operator_metrics( + py: Python<'_>, + operator: pocketstation::SessionOperatorMetrics, +) -> PyResult> { + let input_ports = operator + .input_ports + .iter() + .map(|input| { + Py::new( + py, + PythonOperatorInputMetrics { + port_name: input.port_name.clone(), + edge: Py::new(py, PythonEdgeMetrics::from(input.edge))?, + }, + ) + }) + .collect::>>()?; + let worker = operator.worker; + Py::new( + py, + PythonOperatorMetrics { + operator_instance_id: operator.operator_instance_id.value(), + input_edge: Py::new(py, PythonEdgeMetrics::from(operator.input_edge))?, + worker: Py::new( + py, + PythonOperatorWorkerMetrics { + input_attempted_total: worker.input_attempted_total, + input_dropped_total: worker.input_dropped_total, + processed_total: worker.processed_total, + output_emitted_total: worker.output_emitted_total, + output_dropped_total: worker.output_dropped_total, + output_nonterminal_total: worker.output_nonterminal_total, + output_terminal_total: worker.output_terminal_total, + process_failure_total: worker.process_failure_total, + timeout_total: worker.timeout_total, + cancellation_total: worker.cancellation_total, + graceful_finish_total: worker.graceful_finish_total, + idle_poll_total: worker.idle_poll_total, + ready: worker.ready, + joined: worker.joined, + }, + )?, + finalization_failures_total: operator.finalization_failures_total, + input_ports, + }, + ) +} + +fn python_derived_route_metrics( + py: Python<'_>, + route: pocketstation::SessionDerivedRouteMetrics, +) -> PyResult> { + let endpoint = route.endpoint.unwrap_or_default(); + Py::new( + py, + PythonDerivedRouteMetrics { + route_id: route.route_id.get(), + endpoint_id: route.endpoint_id.get(), + output: Py::new( + py, + PythonTypedEdgeMetrics { + capacity_signals: route.output.capacity_signals, + max_payload_bytes: route.output.max_payload_bytes, + maximum_buffered_payload_bytes: route.output.maximum_buffered_payload_bytes, + depth_signals: route.output.depth_signals, + peak_depth_signals: route.output.peak_depth_signals, + enqueued_total: route.output.enqueued_total, + received_total: route.output.received_total, + dropped_total: route.output.dropped_total, + }, + )?, + endpoint_observation_stage: endpoint_observation_stage_name( + route.endpoint_observation_stage, + ), + endpoint_frames_received_total: endpoint.frames_received_total, + endpoint_frames_delivered_total: endpoint.frames_delivered_total, + endpoint_frames_dropped_total: endpoint.frames_dropped_total, + endpoint_discontinuities_total: endpoint.discontinuities_total, + endpoint_failures_total: endpoint.failures_total, + endpoint_finalization_failures_total: route.endpoint_finalization_failures_total, + }, + ) +} + +impl From for PythonAudioReentryMetrics { + fn from(reentry: pocketstation::SessionAudioReentryMetrics) -> Self { + Self { + operator_instance_id: reentry.operator_instance_id().value(), + stem_id: reentry.stem_id().get(), + queue_capacity_signals: reentry.queue_capacity_signals(), + queue_depth_signals: reentry.queue_depth_signals(), + queue_peak_signals: reentry.queue_peak_signals(), + signals_enqueued_total: reentry.signals_enqueued_total(), + signals_received_total: reentry.signals_received_total(), + signals_dropped_total: reentry.signals_dropped_total(), + pool_slots: reentry.pool_slots(), + frame_capacity_samples: reentry.frame_capacity_samples(), + maximum_buffered_audio_bytes: reentry.maximum_buffered_audio_bytes(), + normalized_total: reentry.normalized_total(), + invalid_total: reentry.invalid_total(), + shared_audio_rejected_total: reentry.shared_audio_rejected_total(), + pool_exhausted_total: reentry.pool_exhausted_total(), + ingress_rejected_total: reentry.ingress_rejected_total(), + audio_frames_enqueued_total: reentry.audio_frames_enqueued_total(), + cancellation_total: reentry.cancellation_total(), + joined: reentry.joined(), + } + } +} + +impl From for PythonSessionTraceRecorderOutcome { + fn from(outcome: pocketstation::SessionTraceRecorderOutcome) -> Self { + Self { + path: outcome.path.display().to_string(), + records_attempted_total: outcome.records_attempted_total, + records_enqueued_total: outcome.records_enqueued_total, + records_dropped_total: outcome.records_dropped_total, + records_written_total: outcome.records_written_total, + rolling_hash: outcome.rolling_hash, + complete: outcome.is_complete(), + } + } +} + +impl From for PythonSessionTraceValidation { + fn from(validation: pocketstation::SessionTraceValidation) -> Self { + Self { + session_id: validation.session_id.get(), + lifecycle: validation + .lifecycle + .iter() + .map(|state| lifecycle_state_name(*state).to_owned()) + .collect(), + terminal_state: terminal_state_name(validation.terminal.state).to_owned(), + source_failures_total: validation.terminal.source_failures_total, + endpoint_failures_total: validation.terminal.endpoint_failures_total, + rollback_failures_total: validation.terminal.rollback_failures_total, + finalization_failures_total: validation.terminal.finalization_failures_total, + records_validated_total: validation.records_validated_total, + } + } +} + +fn session_trace_validation_error(error: pocketstation::SessionTraceValidationError) -> PyErr { + let code = match &error { + pocketstation::SessionTraceValidationError::Io(_) => "trace.io", + pocketstation::SessionTraceValidationError::InvalidMagic => "trace.invalid_magic", + pocketstation::SessionTraceValidationError::UnsupportedVersion => { + "trace.unsupported_version" + } + pocketstation::SessionTraceValidationError::InvalidLayout => "trace.invalid_layout", + pocketstation::SessionTraceValidationError::Truncated => "trace.truncated", + pocketstation::SessionTraceValidationError::InvalidChecksum => "trace.invalid_checksum", + pocketstation::SessionTraceValidationError::IncompleteTrace => "trace.incomplete", + pocketstation::SessionTraceValidationError::SequenceGap => "trace.sequence_gap", + pocketstation::SessionTraceValidationError::SessionMismatch => "trace.session_mismatch", + pocketstation::SessionTraceValidationError::TimestampRegression => { + "trace.timestamp_regression" + } + pocketstation::SessionTraceValidationError::InvalidLifecycleTransition => { + "trace.invalid_lifecycle_transition" + } + pocketstation::SessionTraceValidationError::MissingTerminal => "trace.missing_terminal", + pocketstation::SessionTraceValidationError::TerminalMismatch => "trace.terminal_mismatch", + pocketstation::SessionTraceValidationError::RecordAfterTerminal => { + "trace.record_after_terminal" + } + pocketstation::SessionTraceValidationError::UnknownRecordType => { + "trace.unknown_record_type" + } + }; + PyRuntimeError::new_err(coded_reason(code, error.to_string())) +} + +pub(crate) fn python_session_metrics( + py: Python<'_>, + metrics: OwnedSessionMetrics, +) -> PyResult { + let sources = metrics + .sources + .into_iter() + .map(|source| Py::new(py, PythonSessionSourceMetrics::from(source))) + .collect::>>()?; + let external_sources = metrics + .external_sources + .into_iter() + .map(|source| Py::new(py, PythonExternalSourceMetrics::from(source))) + .collect::>>()?; + let operators = metrics + .operators + .into_iter() + .map(|operator| python_operator_metrics(py, operator)) + .collect::>>()?; + let derived_routes = metrics + .derived_routes + .into_iter() + .map(|route| python_derived_route_metrics(py, route)) + .collect::>>()?; + let audio_reentries = metrics + .audio_reentries + .into_iter() + .map(|reentry| Py::new(py, PythonAudioReentryMetrics::from(reentry))) + .collect::>>()?; + let routes = metrics + .routes + .into_iter() + .map(|route| { + Py::new( + py, + PythonRouteMetrics { + route_id: route.route_id, + endpoint_id: route.endpoint_id, + endpoint_observation_stage: route.endpoint_observation_stage, + queue_capacity_frames: route.queue_capacity_frames, + queue_depth_frames: route.queue_depth_frames, + queue_peak_frames: route.queue_peak_frames, + frames_enqueued_total: route.frames_enqueued_total, + frames_attempted_total: route.frames_attempted_total, + frames_delivered_total: route.frames_delivered_total, + frames_dropped_total: route.frames_dropped_total, + queue_full_drops_total: route.queue_full_drops_total, + overruns_total: route.overruns_total, + receiver_unavailable_drops_total: route.receiver_unavailable_drops_total, + shared_reference_exhausted_drops_total: route + .shared_reference_exhausted_drops_total, + branch_pool_exhausted_drops_total: route.branch_pool_exhausted_drops_total, + invalid_copy_policy_drops_total: route.invalid_copy_policy_drops_total, + freeze_failed_drops_total: route.freeze_failed_drops_total, + discontinuities_total: route.discontinuities_total, + source_identity_discontinuities_total: route + .source_identity_discontinuities_total, + sequence_discontinuities_total: route.sequence_discontinuities_total, + timestamp_discontinuities_total: route.timestamp_discontinuities_total, + lineage_epoch_discontinuities_total: route.lineage_epoch_discontinuities_total, + manually_reported_discontinuities_total: route + .manually_reported_discontinuities_total, + enqueue_to_receive_samples_total: route.enqueue_to_receive_samples_total, + enqueue_to_receive_invalid_order_total: route + .enqueue_to_receive_invalid_order_total, + enqueue_to_receive_p50_ns: route.enqueue_to_receive_p50_ns, + enqueue_to_receive_p95_ns: route.enqueue_to_receive_p95_ns, + enqueue_to_receive_p99_ns: route.enqueue_to_receive_p99_ns, + enqueue_to_receive_max_ns: route.enqueue_to_receive_max_ns, + source_timestamp_to_receive_samples_total: route + .source_timestamp_to_receive_samples_total, + source_timestamp_to_receive_missing_total: route + .source_timestamp_to_receive_missing_total, + source_timestamp_to_receive_future_total: route + .source_timestamp_to_receive_future_total, + source_timestamp_to_receive_p50_ns: route.source_timestamp_to_receive_p50_ns, + source_timestamp_to_receive_p95_ns: route.source_timestamp_to_receive_p95_ns, + source_timestamp_to_receive_p99_ns: route.source_timestamp_to_receive_p99_ns, + source_timestamp_to_receive_max_ns: route.source_timestamp_to_receive_max_ns, + worker_failures_total: route.worker_failures_total, + shutdown_discarded_total: route.shutdown_discarded_total, + endpoint_frames_received_total: route.endpoint_frames_received_total, + endpoint_frames_delivered_total: route.endpoint_frames_delivered_total, + endpoint_frames_dropped_total: route.endpoint_frames_dropped_total, + endpoint_discontinuities_total: route.endpoint_discontinuities_total, + endpoint_failures_total: route.endpoint_failures_total, + endpoint_finalization_failures_total: route + .endpoint_finalization_failures_total, + drop_observation_interval: "route-lifetime-to-snapshot".to_owned(), + drop_rate_pct: route.drop_rate_pct, + source_latency_boundary: "source-monotonic-timestamp-to-route-receive" + .to_owned(), + source_latency_unit: "nanoseconds".to_owned(), + }, + ) + }) + .collect::>>()?; + Ok(PythonSessionMetrics { + event_capacity_count: metrics.event_capacity_count, + event_maximum_event_owned_bytes: metrics.event_maximum_event_owned_bytes, + event_maximum_buffered_owned_bytes: metrics.event_maximum_buffered_owned_bytes, + event_depth_count: metrics.event_depth_count, + event_depth_owned_bytes: metrics.event_depth_owned_bytes, + event_peak_depth_count: metrics.event_peak_depth_count, + event_peak_depth_owned_bytes: metrics.event_peak_depth_owned_bytes, + events_enqueued_total: metrics.events_enqueued_total, + events_dropped_total: metrics.events_dropped_total, + events_dropped_oversized_total: metrics.events_dropped_oversized_total, + event_receiver_closed_total: metrics.event_receiver_closed_total, + audio_registered_endpoints: metrics.audio_registered_endpoints, + audio_queue_capacity_frames: metrics.audio_queue_capacity_frames, + audio_queue_depth_frames: metrics.audio_queue_depth_frames, + audio_queue_peak_frames: metrics.audio_queue_peak_frames, + audio_queue_depth_invariant_failures_total: metrics + .audio_queue_depth_invariant_failures_total, + audio_frames_received_total: metrics.audio_frames_received_total, + audio_frames_delivered_total: metrics.audio_frames_delivered_total, + audio_queue_full_drops_total: metrics.audio_queue_full_drops_total, + audio_invalid_ownership_drops_total: metrics.audio_invalid_ownership_drops_total, + audio_lease_capacity_count: metrics.audio_lease_capacity_count, + audio_outstanding_leases: metrics.audio_outstanding_leases, + audio_lease_exhausted_total: metrics.audio_lease_exhausted_total, + audio_batches_polled_total: metrics.audio_batches_polled_total, + audio_frames_polled_total: metrics.audio_frames_polled_total, + source_count: metrics.source_count, + external_source_count: metrics.external_source_count, + route_count: metrics.route_count, + operator_count: metrics.operator_count, + derived_route_count: metrics.derived_route_count, + audio_reentry_count: metrics.audio_reentry_count, + routes, + sources, + external_sources, + operators, + derived_routes, + audio_reentries, + }) +} + +pub(crate) fn owned_recording_outcome( + running: &pocketstation::RunningSession, +) -> Option { + let outcome = running.recording_outcome()?; + let stems = outcome + .stems + .iter() + .map(|stem| { + let discontinuities = stem + .gap_ranges + .iter() + .map(|record| OwnedRecordingDiscontinuity { + stem_id: record.stem_id, + label: record.label.clone(), + kind: match format!("{:?}", record.kind).as_str() { + "TimestampGap" => "timestamp-gap", + "SequenceGap" => "sequence-gap", + "OverlapRejected" => "overlap-rejected", + _ => "unrecognized-discontinuity", + } + .to_owned(), + timestamp_start_ns: record.timestamp_start_ns, + timestamp_end_ns: record.timestamp_end_ns, + sequence_start: record.sequence_start, + sequence_end: record.sequence_end, + }) + .collect(); + OwnedRecordingStemOutcome { + stem_name: stem.label.clone(), + frames_written_total: stem.written_frames, + stale_frames_total: stem.stale_frames, + error: stem.error.clone(), + queue_capacity_frames: stem.edge_observations.queue_capacity_frames, + queue_peak_frames: stem.edge_observations.queue_peak_frames, + frames_delivered_total: stem.edge_observations.frames_delivered_total, + frames_dropped_total: stem.edge_observations.frames_dropped_total, + queue_full_drops_total: stem.edge_observations.queue_full_drops_total, + discontinuities_total: stem.edge_observations.discontinuities_total, + discontinuities, + } + }) + .collect(); + Some(OwnedRecordingOutcome { + complete: outcome.state == pocketstation::SessionRecordingState::Complete, + state: format!("{:?}", outcome.state).to_lowercase(), + completed_stems: outcome.completed_stems, + failed_stems: outcome.failed_stems, + session_directory: outcome.session_dir.display().to_string(), + error_code: pocketstation::session_recording_outcome_error_code(outcome) + .map(|code| code.as_str().to_owned()), + stems, + }) +} + +pub(crate) fn python_recording_outcome( + py: Python<'_>, + outcome: OwnedRecordingOutcome, +) -> PyResult> { + let stems = outcome + .stems + .into_iter() + .map(|stem| { + let discontinuities = stem + .discontinuities + .into_iter() + .map(|value| { + Py::new( + py, + PythonRecordingDiscontinuity { + stem_id: value.stem_id, + label: value.label, + kind: value.kind, + timestamp_start_ns: value.timestamp_start_ns, + timestamp_end_ns: value.timestamp_end_ns, + sequence_start: value.sequence_start, + sequence_end: value.sequence_end, + }, + ) + }) + .collect::>>()?; + Py::new( + py, + PythonRecordingStemOutcome { + stem_name: stem.stem_name, + frames_written_total: stem.frames_written_total, + stale_frames_total: stem.stale_frames_total, + error: stem.error, + queue_capacity_frames: stem.queue_capacity_frames, + queue_peak_frames: stem.queue_peak_frames, + frames_delivered_total: stem.frames_delivered_total, + frames_dropped_total: stem.frames_dropped_total, + queue_full_drops_total: stem.queue_full_drops_total, + discontinuities_total: stem.discontinuities_total, + discontinuities, + }, + ) + }) + .collect::>>()?; + Py::new( + py, + PythonRecordingOutcome { + complete: outcome.complete, + state: outcome.state, + completed_stems: outcome.completed_stems, + failed_stems: outcome.failed_stems, + session_directory: outcome.session_directory, + error_code: outcome.error_code, + stems, + }, + ) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pocketstation::{ + CaptureRuntimeFailure, CaptureRuntimeFailureClass, Platform, SourceGeneration, SourceKind, + SourceRecoveryRequirement, SourceRuntimeEvent, StableSourceId, + }; + + #[test] + fn source_disappearance_survives_the_native_projection() { + let stable_id = + StableSourceId::new(Platform::Windows, SourceKind::Application, "aumid:fixture"); + let expected_source_id = stable_id.source_id().get(); + let event = SourceRuntimeEvent::SourceUnavailable { + stable_id, + generation: SourceGeneration(7), + recovery_requirement: SourceRecoveryRequirement::ExplicitRediscoveryAndNewSession, + failure: CaptureRuntimeFailure { + operation: "capture", + error_class: CaptureRuntimeFailureClass::PlatformStatus { status_code: -42 }, + }, + }; + let mut projected = empty_owned_session_event(1); + populate_source_runtime_event(&mut projected, &event); + + assert_eq!( + projected.source_event_kind.as_deref(), + Some("source-unavailable") + ); + assert_eq!(projected.source_platform.as_deref(), Some("windows")); + assert_eq!(projected.source_kind.as_deref(), Some("application")); + assert_eq!( + projected.source_stable_key.as_deref(), + Some("aumid:fixture") + ); + assert_eq!(projected.source_source_id, Some(expected_source_id)); + assert_eq!(projected.source_generation, Some(7)); + assert_eq!( + projected.source_recovery_requirement.as_deref(), + Some("explicit-rediscovery-and-new-session") + ); + assert_eq!( + projected.source_failure_operation.as_deref(), + Some("capture") + ); + assert_eq!( + projected.source_failure_class.as_deref(), + Some("platform-status") + ); + assert_eq!(projected.source_platform_status_code, Some(-42)); + } +} diff --git a/native/src/relay.rs b/native/src/relay.rs new file mode 100644 index 0000000..302263f --- /dev/null +++ b/native/src/relay.rs @@ -0,0 +1,127 @@ +use std::sync::{Arc, Mutex}; + +use pocketstation::connector::RegisteredConnector; +use pocketstation_relay::{RelayConnector, RelayPublishReceiptKey}; +use pyo3::prelude::*; + +#[pyclass(name = "RelayPublisher", frozen)] +pub(crate) struct PythonRelayPublisher { + pub(crate) session: Arc>>, + pub(crate) registered: RegisteredConnector, + pub(crate) relay_url: String, + pub(crate) relay_session_id: String, + pub(crate) source_token: String, + pub(crate) routes: Arc>>, +} + +pub(crate) struct RelayRouteRegistration { + pub(crate) bus_id: String, + pub(crate) key: RelayPublishReceiptKey, +} + +pub(crate) struct RelayRuntime { + pub(crate) connector: Arc, + pub(crate) routes: Vec<(String, RelayPublishReceiptKey)>, +} + +#[pyclass(name = "RelayPublishOutcome", frozen)] +pub(crate) struct PythonRelayPublishOutcome { + #[pyo3(get)] + bus_id: String, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + route_id: u64, + #[pyo3(get)] + frames_received_total: u64, + #[pyo3(get)] + rtp_packets_sent_total: u64, + #[pyo3(get)] + rtp_payload_bytes_sent_total: u64, + #[pyo3(get)] + ingress_queue_drops_total: u64, + #[pyo3(get)] + publisher_stale_drops_total: u64, + #[pyo3(get)] + failures_total: u64, + #[pyo3(get)] + error: Option, +} + +pub(crate) struct OwnedRelayPublishOutcome { + pub(crate) bus_id: String, + pub(crate) endpoint_id: u64, + pub(crate) route_id: u64, + pub(crate) frames_received_total: u64, + pub(crate) rtp_packets_sent_total: u64, + pub(crate) rtp_payload_bytes_sent_total: u64, + pub(crate) ingress_queue_drops_total: u64, + pub(crate) publisher_stale_drops_total: u64, + pub(crate) failures_total: u64, + pub(crate) error: Option, +} + +pub(crate) fn owned_relay_outcomes(relay: Option<&RelayRuntime>) -> Vec { + let Some(relay) = relay else { + return Vec::new(); + }; + relay + .routes + .iter() + .map(|(bus_id, key)| { + relay.connector.take_result(*key).map_or_else( + || OwnedRelayPublishOutcome { + bus_id: bus_id.clone(), + endpoint_id: key.endpoint_id.get(), + route_id: key.route_id.get(), + frames_received_total: 0, + rtp_packets_sent_total: 0, + rtp_payload_bytes_sent_total: 0, + ingress_queue_drops_total: 0, + publisher_stale_drops_total: 0, + failures_total: 1, + error: Some("relay publication result is unavailable".to_owned()), + }, + |result| OwnedRelayPublishOutcome { + bus_id: bus_id.clone(), + endpoint_id: key.endpoint_id.get(), + route_id: key.route_id.get(), + frames_received_total: result.edge_observations.frames_delivered_total, + rtp_packets_sent_total: result.statistics.rtp_packets_sent_total, + rtp_payload_bytes_sent_total: result.statistics.rtp_payload_bytes_sent_total, + ingress_queue_drops_total: result.statistics.ingress_queue_drops_total, + publisher_stale_drops_total: result.statistics.publisher_stale_drops_total, + failures_total: u64::from(result.error.is_some()), + error: result.error.map(|error| error.to_string()), + }, + ) + }) + .collect() +} + +pub(crate) fn python_relay_outcome( + py: Python<'_>, + outcome: OwnedRelayPublishOutcome, +) -> PyResult> { + Py::new( + py, + PythonRelayPublishOutcome { + bus_id: outcome.bus_id, + endpoint_id: outcome.endpoint_id, + route_id: outcome.route_id, + frames_received_total: outcome.frames_received_total, + rtp_packets_sent_total: outcome.rtp_packets_sent_total, + rtp_payload_bytes_sent_total: outcome.rtp_payload_bytes_sent_total, + ingress_queue_drops_total: outcome.ingress_queue_drops_total, + publisher_stale_drops_total: outcome.publisher_stale_drops_total, + failures_total: outcome.failures_total, + error: outcome.error, + }, + ) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/session.rs b/native/src/session.rs new file mode 100644 index 0000000..b26d619 --- /dev/null +++ b/native/src/session.rs @@ -0,0 +1,1271 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use pocketstation_relay::RelayConnector; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use crate::audio_input::{configuration as audio_input_configuration, PythonAudioInput}; +use crate::errors::{ + coded_reason, native_extension_error, session_error, session_start_error, validate_nonempty, +}; +use crate::extensions::PythonNativeExtensionLibrary; +use crate::graph::{ + make_operator, make_source_configuration, make_source_type_id, PythonDerivedStream, + PythonEdgeContract, PythonEndpoint, PythonEndpointDescriptor, PythonOperatorInstance, + PythonSignalSpec, PythonSourceInstance, PythonSourceOutput, PythonStem, +}; +use crate::observations::{ + copy_event, copy_event_until, copy_metrics, drain_terminal_event, owned_recording_outcome, + python_recording_outcome, python_session_event, python_session_metrics, OwnedSessionEvent, + OwnedSessionMetrics, OwnedStopResult, PythonSessionEvent, PythonSessionMetrics, + PythonStopResult, +}; +use crate::relay::{ + owned_relay_outcomes, python_relay_outcome, PythonRelayPublisher, RelayRouteRegistration, + RelayRuntime, +}; +use crate::sidecar::{ + poll_sidecar, runtime_error as sidecar_runtime_error, sidecar_error_message, sidecar_snapshot, + wait_sidecar, OwnedSidecarRead, PythonSidecarMessage, PythonSidecarProcessSpec, + PythonSidecarRead, PythonSidecarSnapshot, MAXIMUM_WAIT_MS as MAXIMUM_SIDECAR_WAIT_MS, +}; +use crate::signals::{ + close_signal, copy_signal_metrics, new_signal_receipts, poll_signal, subscribe_derived, + subscribe_source_output, validate_signal_subscription, wait_signal, + OwnedSignalSubscriptionMetrics, PythonBusSubscription, PythonSignalRead, + PythonSignalSubscriptionMetrics, SignalReceipts, +}; +use crate::sources::PythonSource; +use crate::streams::{ + copy_audio_batch, copy_audio_batch_until, python_audio_batch, request_audio_batch, + request_audio_batch_wait, OwnedAudioFrame, PythonAudioBatch, +}; + +pub(crate) enum SessionCommand { + PollAudio { + response: SyncSender>, String>>, + }, + WaitAudio { + timeout: Duration, + response: SyncSender>, String>>, + }, + PollEvent { + response: SyncSender, String>>, + }, + WaitEvent { + timeout: Duration, + response: SyncSender, String>>, + }, + Metrics { + response: SyncSender>, + }, + SignalMetrics { + route_id: u64, + response: SyncSender>, + }, + SendSidecar { + sidecar_id: u64, + message: pocketstation::SidecarMessage, + response: SyncSender>, + }, + PollSidecar { + sidecar_id: u64, + response: SyncSender>, + }, + WaitSidecar { + sidecar_id: u64, + timeout: Duration, + response: SyncSender>, + }, + SidecarSnapshot { + sidecar_id: u64, + response: SyncSender>, + }, + Stop { + response: SyncSender, + }, + Cancel { + response: SyncSender, + }, + Shutdown, +} + +pub(crate) struct SessionWorker { + pub(crate) commands: SyncSender, + join: Option>, +} + +#[pyclass(name = "Session")] +pub(crate) struct PythonSession { + session: Arc>>, + relay_declared: Mutex, + relay_connector: Mutex>>, + relay_routes: Arc>>, + signal_receipts: SignalReceipts, + next_signal_subscription_id: Mutex, +} + +#[pyclass(name = "_SessionStartCancellation", frozen)] +pub(crate) struct PythonSessionStartCancellation { + cancellation: pocketstation::SessionStartCancellation, +} + +#[pymethods] +impl PythonSessionStartCancellation { + #[new] + fn new() -> Self { + Self { + cancellation: pocketstation::SessionStartCancellation::default(), + } + } + + fn request(&self) { + self.cancellation.request(); + } + + fn is_requested(&self) -> bool { + self.cancellation.is_requested() + } +} + +#[pymethods] +impl PythonSession { + #[new] + #[pyo3(signature = (*, recording_root=None, trace_path=None, trace_capacity_records=256, sample_rate_hz=48_000, channels=1))] + fn new( + recording_root: Option, + trace_path: Option, + trace_capacity_records: usize, + sample_rate_hz: u32, + channels: u8, + ) -> PyResult { + if trace_path.is_some() && trace_capacity_records == 0 { + return Err(PyValueError::new_err(coded_reason( + "trace.invalid_capacity", + "trace_capacity_records must be greater than zero", + ))); + } + if sample_rate_hz == 0 || !matches!(channels, 1 | 2) { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_sample_spec", + "sample_rate_hz must be non-zero and channels must be 1 or 2", + ))); + } + let mut builder = + pocketstation::Session::builder().sample_spec(pocketstation::SampleSpec::new( + sample_rate_hz, + channels, + pocketstation::SampleFormat::F32Interleaved, + )); + if let Some(root) = recording_root { + builder = builder.recording_root(root); + } + if let Some(path) = trace_path { + builder = builder.session_trace(path, trace_capacity_records); + } + let session = builder.build(); + Ok(Self { + session: Arc::new(Mutex::new(Some(session))), + relay_declared: Mutex::new(false), + relay_connector: Mutex::new(None), + relay_routes: Arc::new(Mutex::new(Vec::new())), + signal_receipts: new_signal_receipts(), + next_signal_subscription_id: Mutex::new(0), + }) + } + + #[cfg(feature = "conformance-fixtures")] + #[staticmethod] + #[pyo3(signature = (recording_root, trace_path=None, trace_capacity_records=256))] + fn conformance( + recording_root: PathBuf, + trace_path: Option, + trace_capacity_records: usize, + ) -> PyResult { + if trace_path.is_some() && trace_capacity_records == 0 { + return Err(PyValueError::new_err(coded_reason( + "trace.invalid_capacity", + "trace_capacity_records must be greater than zero", + ))); + } + let session = match trace_path { + Some(trace_path) => pocketstation::conformance::session_with_recording_and_trace( + recording_root, + trace_path, + trace_capacity_records, + ), + None => pocketstation::conformance::session_with_recording(recording_root), + } + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + crate::graph::register_graph_conformance_operator(&session) + .map_err(PyRuntimeError::new_err)?; + Ok(Self { + session: Arc::new(Mutex::new(Some(session))), + relay_declared: Mutex::new(false), + relay_connector: Mutex::new(None), + relay_routes: Arc::new(Mutex::new(Vec::new())), + signal_receipts: new_signal_receipts(), + next_signal_subscription_id: Mutex::new(0), + }) + } + + fn capture(&self, source: &PythonSource) -> PyResult { + self.with_session(|session| { + session + .capture(source.declaration.to_source()) + .map(|handle| PythonStem { handle }) + .map_err(session_error) + }) + } + + #[pyo3(signature = (sample_rate_hz, channels, capacity_frames=8, frame_samples_per_channel=480))] + fn audio_input( + &self, + sample_rate_hz: u32, + channels: u8, + capacity_frames: usize, + frame_samples_per_channel: usize, + ) -> PyResult { + let configuration = audio_input_configuration( + sample_rate_hz, + channels, + capacity_frames, + frame_samples_per_channel, + )?; + self.with_session(|session| { + session + .audio_input(configuration) + .map(PythonAudioInput::new) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "audio_input.declaration_failed", + error.to_string(), + )) + }) + }) + } + + #[pyo3(signature = (sample_rate_hz, channels, capacity_frames=8, frame_samples_per_channel=480))] + fn pcm_source( + &self, + sample_rate_hz: u32, + channels: u8, + capacity_frames: usize, + frame_samples_per_channel: usize, + ) -> PyResult { + self.audio_input( + sample_rate_hz, + channels, + capacity_frames, + frame_samples_per_channel, + ) + } + + #[getter] + fn id(&self) -> PyResult { + self.with_session(|session| Ok(session.id().get())) + } + + fn source( + &self, + source_type_id: String, + configuration: HashMap, + ) -> PyResult { + self.with_session(|session| { + session + .source( + make_source_type_id(source_type_id)?, + make_source_configuration(configuration), + ) + .map(|handle| PythonSourceInstance { handle }) + .map_err(session_error) + }) + } + + fn operator( + &self, + operator_id: String, + configuration: HashMap, + ) -> PyResult { + self.with_session(|session| { + session + .operator(make_operator(operator_id, configuration)) + .map(|handle| PythonOperatorInstance { handle }) + .map_err(session_error) + }) + } + + fn endpoint(&self, descriptor: &PythonEndpointDescriptor) -> PyResult { + self.with_session(|session| { + session + .endpoint(descriptor.value.clone()) + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + }) + } + + #[allow(deprecated)] + fn connector( + &self, + operator_id: String, + configuration: HashMap, + ) -> PyResult { + validate_nonempty("operator ID", &operator_id)?; + if configuration.keys().any(|key| key.trim().is_empty()) { + return Err(PyValueError::new_err(coded_reason( + "graph.invalid_contract", + "endpoint configuration keys cannot be empty", + ))); + } + let configuration = configuration.into_iter().fold( + pocketstation::EndpointConfiguration::new(), + |configuration, (key, value)| configuration.with(key, value), + ); + self.with_session(|session| { + session + .connector(pocketstation::OperatorId::new(operator_id), configuration) + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + }) + } + + fn browser(&self, receiver_uri: String) -> PyResult { + validate_nonempty("receiver URI", &receiver_uri)?; + self.with_session(|session| { + session + .browser(receiver_uri) + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + }) + } + + fn polled_audio(&self) -> PyResult { + self.with_session(|session| { + session + .polled_audio() + .map(|handle| PythonEndpoint { handle }) + .map_err(session_error) + }) + } + + fn register_sidecar(&self, spec: &PythonSidecarProcessSpec) -> PyResult { + self.with_session(|session| { + session.register_sidecar(spec.to_core()).map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "sidecar.registration_unavailable", + error.to_string(), + )) + })?; + Ok(spec.id) + }) + } + + fn load_native_extension_library( + &self, + py: Python<'_>, + path: PathBuf, + ) -> PyResult { + py.detach(|| { + self.with_session(|session| { + // SAFETY: the Python API names and documents this as a trusted + // native-code boundary. Core still validates every mechanical + // path, descriptor, and capacity invariant before registration. + unsafe { session.load_native_extension_library(path) } + .map(Into::into) + .map_err(native_extension_error) + }) + }) + } + + fn subscribe_derived( + &self, + stream: &PythonDerivedStream, + signal: &PythonSignalSpec, + edge: &PythonEdgeContract, + ) -> PyResult { + let subscription_id = self.allocate_signal_subscription_id()?; + self.with_session(|session| { + subscribe_derived( + session, + &stream.handle, + signal, + edge, + subscription_id, + &self.signal_receipts, + ) + }) + } + + fn subscribe_source_output( + &self, + stream: &PythonSourceOutput, + signal: &PythonSignalSpec, + edge: &PythonEdgeContract, + ) -> PyResult { + let subscription_id = self.allocate_signal_subscription_id()?; + self.with_session(|session| { + subscribe_source_output( + session, + &stream.handle, + signal, + edge, + subscription_id, + &self.signal_receipts, + ) + }) + } + + fn relay( + &self, + relay_url: String, + relay_session_id: String, + source_token: String, + ) -> PyResult { + validate_nonempty("relay URL", &relay_url)?; + validate_nonempty("relay Session ID", &relay_session_id)?; + validate_nonempty("source token", &source_token)?; + let mut declared = self + .relay_declared + .lock() + .map_err(|_| PyRuntimeError::new_err("relay declaration state is unavailable"))?; + if *declared { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_endpoint", + "a Session supports one relay publisher with multiple named AudioBuses", + ))); + } + let connector = Arc::new( + RelayConnector::new().map_err(|error| PyValueError::new_err(error.to_string()))?, + ); + let registered = self.with_session(|session| { + connector + .register(session) + .map_err(|error| PyValueError::new_err(error.to_string())) + })?; + *self + .relay_connector + .lock() + .map_err(|_| PyRuntimeError::new_err("relay connector state is unavailable"))? = + Some(Arc::clone(&connector)); + *declared = true; + drop(declared); + Ok(PythonRelayPublisher { + session: Arc::clone(&self.session), + registered, + relay_url, + relay_session_id, + source_token, + routes: Arc::clone(&self.relay_routes), + }) + } + + #[cfg(feature = "conformance-fixtures")] + fn observed_connector(&self, per_frame_delay_ms: u64) -> PyResult { + self.with_session(|session| { + pocketstation::conformance::observed_connector( + session, + Duration::from_millis(per_frame_delay_ms), + ) + .map(|handle| PythonEndpoint { handle }) + .map_err(|error| PyRuntimeError::new_err(error.to_string())) + }) + } + + #[cfg(feature = "conformance-fixtures")] + fn observed_browser(&self, per_frame_delay_ms: u64) -> PyResult { + self.with_session(|session| { + pocketstation::conformance::observed_browser( + session, + Duration::from_millis(per_frame_delay_ms), + ) + .map(|handle| PythonEndpoint { handle }) + .map_err(|error| PyRuntimeError::new_err(error.to_string())) + }) + } + + #[pyo3(signature = (cancellation=None))] + fn start( + &self, + py: Python<'_>, + cancellation: Option<&PythonSessionStartCancellation>, + ) -> PyResult { + let session = self + .session + .lock() + .map_err(|_| PyRuntimeError::new_err("Session state is unavailable"))? + .take() + .ok_or_else(|| { + PyRuntimeError::new_err(coded_reason( + pocketstation::SessionDeclarationErrorCode::DraftFrozen.as_str(), + "Session has already started", + )) + })?; + let relay = self.prepare_relay(&session)?; + let session_id = session.id().get(); + let cancellation = cancellation + .map(|value| value.cancellation.clone()) + .unwrap_or_default(); + let running = py + .detach(|| session.start_cancellable(cancellation)) + .map_err(session_start_error)?; + PythonRunningSession::spawn( + running, + relay, + Arc::clone(&self.signal_receipts), + session_id, + ) + } +} + +impl PythonSession { + fn allocate_signal_subscription_id(&self) -> PyResult { + let mut next = self + .next_signal_subscription_id + .lock() + .map_err(|_| PyRuntimeError::new_err("BusSubscription ID state is unavailable"))?; + *next = next.checked_add(1).ok_or_else(|| { + PyRuntimeError::new_err(coded_reason( + "session.capacity_exhausted", + "BusSubscription ID space is exhausted", + )) + })?; + Ok(*next) + } + + fn prepare_relay(&self, _session: &pocketstation::Session) -> PyResult> { + let declared = *self + .relay_declared + .lock() + .map_err(|_| PyRuntimeError::new_err("relay declaration state is unavailable"))?; + let routes = std::mem::take( + &mut *self + .relay_routes + .lock() + .map_err(|_| PyRuntimeError::new_err("relay route state is unavailable"))?, + ); + if !declared { + return Ok(None); + } + if routes.is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_endpoint", + "relay publisher requires at least one published AudioBus", + ))); + } + let route_keys = routes + .iter() + .map(|route| (route.bus_id.clone(), route.key)) + .collect(); + drop(routes); + let connector = self + .relay_connector + .lock() + .map_err(|_| PyRuntimeError::new_err("relay connector state is unavailable"))? + .clone() + .ok_or_else(|| PyRuntimeError::new_err("relay connector is not registered"))?; + Ok(Some(RelayRuntime { + connector, + routes: route_keys, + })) + } + + #[allow(clippy::significant_drop_tightening)] // The lock owns the borrowed draft Session. + fn with_session( + &self, + operation: impl FnOnce(&pocketstation::Session) -> PyResult, + ) -> PyResult { + let guard = self + .session + .lock() + .map_err(|_| PyRuntimeError::new_err("Session state is unavailable"))?; + let session = guard.as_ref().ok_or_else(|| { + PyRuntimeError::new_err(coded_reason( + pocketstation::SessionDeclarationErrorCode::DraftFrozen.as_str(), + "Session has already started", + )) + })?; + operation(session) + } +} + +#[pyclass(name = "RunningSession")] +pub(crate) struct PythonRunningSession { + worker: Mutex>, + signal_receipts: SignalReceipts, + session_id: u64, +} + +#[pymethods] +impl PythonRunningSession { + #[getter] + const fn session_id(&self) -> u64 { + self.session_id + } + + fn poll_audio(&self, py: Python<'_>) -> PyResult> { + let commands = self.commands()?; + let owned = py.detach(|| request_audio_batch(&commands))?; + python_audio_batch(py, owned) + } + + #[pyo3(signature = (timeout_ms=100))] + fn wait_audio(&self, py: Python<'_>, timeout_ms: u64) -> PyResult> { + const MAXIMUM_TIMEOUT_MS: u64 = 1_000; + if timeout_ms > MAXIMUM_TIMEOUT_MS { + return Err(PyValueError::new_err(format!( + "timeout_ms must be at most {MAXIMUM_TIMEOUT_MS}" + ))); + } + let commands = self.commands()?; + let owned = + py.detach(|| request_audio_batch_wait(&commands, Duration::from_millis(timeout_ms)))?; + python_audio_batch(py, owned) + } + + fn poll_event(&self, py: Python<'_>) -> PyResult> { + let commands = self.commands()?; + let event = py.detach(|| crate::observations::request_event(&commands))?; + event + .map(|event| python_session_event(py, event)) + .transpose() + } + + #[pyo3(signature = (timeout_ms=100))] + fn wait_event(&self, py: Python<'_>, timeout_ms: u64) -> PyResult> { + const MAXIMUM_TIMEOUT_MS: u64 = 1_000; + if timeout_ms > MAXIMUM_TIMEOUT_MS { + return Err(PyValueError::new_err(format!( + "timeout_ms must be at most {MAXIMUM_TIMEOUT_MS}" + ))); + } + let commands = self.commands()?; + let event = py.detach(|| { + crate::observations::request_event_wait(&commands, Duration::from_millis(timeout_ms)) + })?; + event + .map(|event| python_session_event(py, event)) + .transpose() + } + + fn poll_signal( + &self, + py: Python<'_>, + subscription: &PythonBusSubscription, + ) -> PyResult { + poll_signal(py, &self.signal_receipts, self.session_id, subscription) + } + + #[pyo3(signature = (subscription, timeout_ms=100))] + fn wait_signal( + &self, + py: Python<'_>, + subscription: &PythonBusSubscription, + timeout_ms: u64, + ) -> PyResult { + wait_signal( + py, + &self.signal_receipts, + self.session_id, + subscription, + timeout_ms, + ) + } + + fn close_signal(&self, subscription: &PythonBusSubscription) -> PyResult<()> { + close_signal(&self.signal_receipts, self.session_id, subscription) + } + + fn signal_metrics( + &self, + py: Python<'_>, + subscription: &PythonBusSubscription, + ) -> PyResult { + validate_signal_subscription(&self.signal_receipts, self.session_id, subscription)?; + let commands = self.commands()?; + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::SignalMetrics { + route_id: subscription.route_id, + response, + }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + let metrics = py.detach(move || { + receiver + .recv() + .map_err(|_| "native Session worker did not return signal metrics".to_owned())? + }); + metrics + .map(PythonSignalSubscriptionMetrics::from) + .map_err(PyRuntimeError::new_err) + } + + fn send_sidecar( + &self, + py: Python<'_>, + sidecar_id: u64, + message: &PythonSidecarMessage, + ) -> PyResult<()> { + let commands = self.commands()?; + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::SendSidecar { + sidecar_id, + message: message.value.clone(), + response, + }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + py.detach(move || { + receiver + .recv() + .map_err(|_| "native Session worker did not send sidecar signal".to_owned())? + }) + .map_err(sidecar_runtime_error) + } + + fn poll_sidecar(&self, py: Python<'_>, sidecar_id: u64) -> PyResult { + self.request_sidecar_read(py, sidecar_id, None) + } + + #[pyo3(signature = (sidecar_id, timeout_ms=100))] + fn wait_sidecar( + &self, + py: Python<'_>, + sidecar_id: u64, + timeout_ms: u64, + ) -> PyResult { + if timeout_ms > MAXIMUM_SIDECAR_WAIT_MS { + return Err(PyValueError::new_err(coded_reason( + "sidecar.invalid_timeout", + "timeout_ms must be between 0 and 1000", + ))); + } + self.request_sidecar_read(py, sidecar_id, Some(Duration::from_millis(timeout_ms))) + } + + fn sidecar_snapshot(&self, py: Python<'_>, sidecar_id: u64) -> PyResult { + let commands = self.commands()?; + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::SidecarSnapshot { + sidecar_id, + response, + }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + py.detach(move || { + receiver + .recv() + .map_err(|_| "native Session worker did not return sidecar metrics".to_owned())? + }) + .map(PythonSidecarSnapshot::from) + .map_err(sidecar_runtime_error) + } + + fn metrics(&self, py: Python<'_>) -> PyResult { + let commands = self.commands()?; + let metrics = py.detach(|| crate::observations::request_metrics(&commands))?; + python_session_metrics(py, metrics) + } + + fn stop(&self, py: Python<'_>) -> PyResult { + let worker = self + .worker + .lock() + .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))? + .take() + .ok_or_else(|| PyRuntimeError::new_err("Session has stopped"))?; + let owned = py.detach(|| stop_worker(worker))?; + let recording = owned + .recording + .map(|recording| python_recording_outcome(py, recording)) + .transpose()?; + let relay = owned + .relay + .into_iter() + .map(|outcome| python_relay_outcome(py, outcome)) + .collect::>>()?; + let sidecars = owned + .sidecars + .into_iter() + .map(|outcome| Py::new(py, PythonSidecarSnapshot::from(outcome))) + .collect::>>()?; + let trace = owned + .trace + .map(|outcome| { + Py::new( + py, + crate::observations::PythonSessionTraceRecorderOutcome::from(outcome), + ) + }) + .transpose()?; + let terminal_event = owned + .terminal_event + .map(|event| python_session_event(py, event)) + .transpose()? + .map(|event| Py::new(py, event)) + .transpose()?; + Ok(PythonStopResult { + success: owned.success, + already_stopped: owned.already_stopped, + disposition: owned.disposition, + runtime_worker_panicked: owned.runtime_worker_panicked, + capture_finalization_failures_total: owned.capture_finalization_failures_total, + operator_finalization_failures_total: owned.operator_finalization_failures_total, + endpoint_finalization_failures_total: owned.endpoint_finalization_failures_total, + runtime_failures_total: owned.runtime_failures_total, + lineage_failures_total: owned.lineage_failures_total, + source_send_rejections_total: owned.source_send_rejections_total, + runtime_events_total: owned.runtime_events_total, + recording, + trace, + trace_error: owned.trace_error, + terminal_event, + relay, + sidecars, + }) + } + + fn cancel(&self, py: Python<'_>) -> PyResult { + let worker = self + .worker + .lock() + .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))? + .take() + .ok_or_else(|| PyRuntimeError::new_err("Session has stopped"))?; + let owned = py.detach(|| cancel_worker(worker))?; + let recording = owned + .recording + .map(|recording| python_recording_outcome(py, recording)) + .transpose()?; + let relay = owned + .relay + .into_iter() + .map(|outcome| python_relay_outcome(py, outcome)) + .collect::>>()?; + let sidecars = owned + .sidecars + .into_iter() + .map(|outcome| Py::new(py, PythonSidecarSnapshot::from(outcome))) + .collect::>>()?; + let trace = owned + .trace + .map(|outcome| { + Py::new( + py, + crate::observations::PythonSessionTraceRecorderOutcome::from(outcome), + ) + }) + .transpose()?; + let terminal_event = owned + .terminal_event + .map(|event| python_session_event(py, event)) + .transpose()? + .map(|event| Py::new(py, event)) + .transpose()?; + Ok(PythonStopResult { + success: owned.success, + already_stopped: owned.already_stopped, + disposition: owned.disposition, + runtime_worker_panicked: owned.runtime_worker_panicked, + capture_finalization_failures_total: owned.capture_finalization_failures_total, + operator_finalization_failures_total: owned.operator_finalization_failures_total, + endpoint_finalization_failures_total: owned.endpoint_finalization_failures_total, + runtime_failures_total: owned.runtime_failures_total, + lineage_failures_total: owned.lineage_failures_total, + source_send_rejections_total: owned.source_send_rejections_total, + runtime_events_total: owned.runtime_events_total, + recording, + trace, + trace_error: owned.trace_error, + terminal_event, + relay, + sidecars, + }) + } +} + +impl PythonRunningSession { + fn request_sidecar_read( + &self, + py: Python<'_>, + sidecar_id: u64, + timeout: Option, + ) -> PyResult { + let commands = self.commands()?; + let (response, receiver) = sync_channel(1); + let command = match timeout { + Some(timeout) => SessionCommand::WaitSidecar { + sidecar_id, + timeout, + response, + }, + None => SessionCommand::PollSidecar { + sidecar_id, + response, + }, + }; + commands + .send(command) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + py.detach(move || { + receiver + .recv() + .map_err(|_| "native Session worker did not return sidecar signal".to_owned())? + }) + .map(PythonSidecarRead::from) + .map_err(sidecar_runtime_error) + } + + fn commands(&self) -> PyResult> { + self.worker + .lock() + .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))? + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Session has stopped")) + .map(|worker| worker.commands.clone()) + } + + fn spawn( + running: pocketstation::RunningSession, + relay: Option, + signal_receipts: SignalReceipts, + session_id: u64, + ) -> PyResult { + const COMMAND_CAPACITY_COUNT: usize = 8; + let (commands, receiver) = sync_channel(COMMAND_CAPACITY_COUNT); + let join = thread::Builder::new() + .name("pocketstation-python-session".to_owned()) + .spawn(move || session_worker(running, receiver, relay)) + .map_err(|error| { + PyRuntimeError::new_err(format!("failed to start Session worker: {error}")) + })?; + Ok(Self { + worker: Mutex::new(Some(SessionWorker { + commands, + join: Some(join), + })), + signal_receipts, + session_id, + }) + } +} + +impl Drop for PythonRunningSession { + fn drop(&mut self) { + let Ok(worker) = self.worker.get_mut() else { + return; + }; + let Some(worker) = worker.take() else { + return; + }; + let _ = worker.commands.try_send(SessionCommand::Shutdown); + // Destruction must not wait on capture or finalization. Dropping the + // JoinHandle detaches the worker, which still owns and stops Session. + drop(worker); + } +} + +#[allow(clippy::needless_pass_by_value)] // Thread entry owns receiver and relay lifetime. +fn session_worker( + mut running: pocketstation::RunningSession, + receiver: Receiver, + relay: Option, +) { + while let Ok(command) = receiver.recv() { + match command { + SessionCommand::PollAudio { response } => { + let _ = response.send(copy_audio_batch(&running)); + } + SessionCommand::WaitAudio { timeout, response } => { + let _ = response.send(copy_audio_batch_until(&running, timeout)); + } + SessionCommand::PollEvent { response } => { + let _ = response.send(copy_event(&running)); + } + SessionCommand::WaitEvent { timeout, response } => { + let _ = response.send(copy_event_until(&running, timeout)); + } + SessionCommand::Metrics { response } => { + let _ = response.send(copy_metrics(&running)); + } + SessionCommand::SignalMetrics { route_id, response } => { + let _ = response.send(copy_signal_metrics(&running, route_id)); + } + SessionCommand::SendSidecar { + sidecar_id, + message, + response, + } => { + let _ = response.send( + running + .try_send_sidecar_signal(sidecar_id, message) + .map_err(sidecar_error_message), + ); + } + SessionCommand::PollSidecar { + sidecar_id, + response, + } => { + let _ = response.send(poll_sidecar(&running, sidecar_id)); + } + SessionCommand::WaitSidecar { + sidecar_id, + timeout, + response, + } => { + let _ = response.send(wait_sidecar(&running, sidecar_id, timeout)); + } + SessionCommand::SidecarSnapshot { + sidecar_id, + response, + } => { + let _ = response.send(sidecar_snapshot(&running, sidecar_id)); + } + SessionCommand::Stop { response } => { + let stop = running.stop(); + let outcome = stop.outcome(); + let already_stopped = matches!( + stop.disposition(), + pocketstation::SessionStopDisposition::AlreadyStopped + ); + let _ = response.send(OwnedStopResult { + success: stop.is_success(), + already_stopped, + disposition: if already_stopped { + "already-stopped".to_owned() + } else { + "stopped".to_owned() + }, + runtime_worker_panicked: outcome.runtime_worker_panicked(), + capture_finalization_failures_total: outcome + .capture_finalization_failures_total(), + operator_finalization_failures_total: outcome + .operator_finalization_failures_total(), + endpoint_finalization_failures_total: outcome + .endpoint_finalization_failures_total(), + runtime_failures_total: outcome.runtime_failures_total(), + lineage_failures_total: outcome.lineage_failures_total(), + source_send_rejections_total: outcome.source_send_rejections_total(), + runtime_events_total: outcome.runtime_events_total(), + recording: owned_recording_outcome(&running), + trace: running + .session_trace_outcome() + .and_then(Result::ok) + .cloned(), + trace_error: running + .session_trace_outcome() + .and_then(Result::err) + .map(ToString::to_string), + terminal_event: drain_terminal_event(&running), + relay: owned_relay_outcomes(relay.as_ref()), + sidecars: running.sidecar_metrics().into_vec(), + }); + return; + } + SessionCommand::Cancel { response } => { + let cancel = running.cancel(); + let outcome = cancel.outcome(); + let already_stopped = matches!( + cancel.disposition(), + pocketstation::SessionCancelDisposition::AlreadyStopped + ); + let _ = response.send(OwnedStopResult { + success: cancel.is_success(), + already_stopped, + disposition: if already_stopped { + "already-stopped".to_owned() + } else { + "cancelled".to_owned() + }, + runtime_worker_panicked: outcome.runtime_worker_panicked(), + capture_finalization_failures_total: outcome + .capture_finalization_failures_total(), + operator_finalization_failures_total: outcome + .operator_finalization_failures_total(), + endpoint_finalization_failures_total: outcome + .endpoint_finalization_failures_total(), + runtime_failures_total: outcome.runtime_failures_total(), + lineage_failures_total: outcome.lineage_failures_total(), + source_send_rejections_total: outcome.source_send_rejections_total(), + runtime_events_total: outcome.runtime_events_total(), + recording: owned_recording_outcome(&running), + trace: running + .session_trace_outcome() + .and_then(Result::ok) + .cloned(), + trace_error: running + .session_trace_outcome() + .and_then(Result::err) + .map(ToString::to_string), + terminal_event: drain_terminal_event(&running), + relay: owned_relay_outcomes(relay.as_ref()), + sidecars: running.sidecar_metrics().into_vec(), + }); + return; + } + SessionCommand::Shutdown => { + let _ = running.stop(); + return; + } + } + } + let _ = running.stop(); +} + +pub(crate) fn stop_worker(mut worker: SessionWorker) -> PyResult { + let (response, receiver) = sync_channel(1); + worker + .commands + .send(SessionCommand::Stop { response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + let result = receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not finalize"))?; + if let Some(join) = worker.join.take() { + join.join() + .map_err(|_| PyRuntimeError::new_err("native Session worker panicked"))?; + } + Ok(result) +} + +pub(crate) fn cancel_worker(mut worker: SessionWorker) -> PyResult { + let (response, receiver) = sync_channel(1); + worker + .commands + .send(SessionCommand::Cancel { response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + let result = receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not cancel"))?; + if let Some(join) = worker.join.take() { + join.join() + .map_err(|_| PyRuntimeError::new_err("native Session worker panicked"))?; + } + Ok(result) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::time::{Duration, Instant}; + + use pocketstation::{ApplicationSelector, Source}; + + use super::{stop_worker, PythonRunningSession}; + use crate::observations::{request_event, request_metrics}; + use crate::signals::new_signal_receipts; + use crate::streams::request_audio_batch_wait; + + #[test] + fn given_native_python_worker_when_polled_then_batches_preserve_both_stems() { + let recording_root = tempfile::tempdir().expect("temporary recording root"); + let session = pocketstation::conformance::session_with_recording(recording_root.path()) + .expect("conformance Session"); + let application = session + .capture(Source::application(ApplicationSelector::name( + "PocketStation Python Fixture", + ))) + .expect("application stem"); + let microphone = session + .capture(Source::microphone_default()) + .expect("microphone stem"); + let audio = session.polled_audio().expect("polled audio endpoint"); + let connector = pocketstation::conformance::observed_connector(&session, Duration::ZERO) + .expect("observed connector"); + let browser = + pocketstation::conformance::observed_browser(&session, Duration::from_millis(25)) + .expect("observed browser"); + application.send(audio).expect("application route"); + microphone.send(audio).expect("microphone route"); + let application_connector_route = application + .send(connector) + .expect("application connector route"); + let microphone_connector_route = microphone + .send(connector) + .expect("microphone connector route"); + let application_browser_route = application + .send(browser) + .expect("application browser route"); + let microphone_browser_route = microphone.send(browser).expect("microphone browser route"); + application + .record("application") + .expect("application recording"); + microphone + .record("microphone") + .expect("microphone recording"); + + let session_id = session.id().get(); + let running = session.start().expect("running conformance Session"); + let python_running = + PythonRunningSession::spawn(running, None, new_signal_receipts(), session_id) + .expect("Python Session worker"); + let worker = python_running + .worker + .lock() + .expect("worker state") + .take() + .expect("live worker"); + let first_event = request_event(&worker.commands) + .expect("event poll") + .expect("starting or running event"); + assert_eq!(first_event.kind, "lifecycle"); + let external_route_ids = [ + application_connector_route.get(), + microphone_connector_route.get(), + application_browser_route.get(), + microphone_browser_route.get(), + ]; + let deadline = Instant::now() + Duration::from_secs(5); + let mut stems = BTreeSet::new(); + let metrics = loop { + if let Some(batch) = + request_audio_batch_wait(&worker.commands, Duration::from_millis(100)) + .expect("bounded batch wait") + { + stems.extend(batch.into_iter().map(|frame| frame.stem_id)); + } + let metrics = request_metrics(&worker.commands).expect("metrics snapshot"); + let external_routes: Vec<_> = metrics + .routes + .iter() + .filter(|route| external_route_ids.contains(&route.route_id)) + .collect(); + let external_routes_ready = external_routes.len() == external_route_ids.len() + && external_routes.iter().all(|route| { + route.frames_delivered_total > 0 && route.endpoint_frames_received_total > 0 + }); + if stems.len() == 2 && external_routes_ready { + break metrics; + } + assert!( + Instant::now() < deadline, + "both stems and all external routes must deliver before deadline" + ); + std::thread::sleep(Duration::from_millis(1)); + }; + assert_eq!(metrics.source_count, 2); + assert_eq!(metrics.route_count, 8); + assert_eq!( + stems.len(), + 2, + "both source-aware stems must cross the batch" + ); + + let stop = stop_worker(worker).expect("worker stop"); + assert!(stop.success, "native Session must finalize successfully"); + let recording = stop.recording.expect("recording outcome"); + assert!(recording.complete); + assert_eq!(recording.stems.len(), 2); + } +} diff --git a/native/src/sidecar.rs b/native/src/sidecar.rs new file mode 100644 index 0000000..134fd38 --- /dev/null +++ b/native/src/sidecar.rs @@ -0,0 +1,485 @@ +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +use crate::errors::coded_reason; + +pub(crate) const MAXIMUM_WAIT_MS: u64 = 1_000; + +#[pyclass(name = "_SidecarProcessSpec", frozen)] +pub(crate) struct PythonSidecarProcessSpec { + #[pyo3(get)] + pub(crate) id: u64, + #[pyo3(get)] + program: PathBuf, + #[pyo3(get)] + arguments: Vec, + configuration: Vec, + #[pyo3(get)] + data_capacity_messages: usize, + #[pyo3(get)] + max_signal_id_bytes: usize, + #[pyo3(get)] + max_role_bytes: usize, + #[pyo3(get)] + max_schema_bytes: usize, + #[pyo3(get)] + max_payload_bytes: usize, + #[pyo3(get)] + ready_timeout_ms: u64, + #[pyo3(get)] + processing_timeout_ms: u64, + #[pyo3(get)] + shutdown_timeout_ms: u64, +} + +#[pymethods] +impl PythonSidecarProcessSpec { + #[new] + #[pyo3(signature = ( + id, + program, + arguments=Vec::new(), + configuration=Vec::new(), + data_capacity_messages=64, + max_signal_id_bytes=256, + max_role_bytes=256, + max_schema_bytes=1024, + max_payload_bytes=1048576, + ready_timeout_ms=5000, + processing_timeout_ms=5000, + shutdown_timeout_ms=2000, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + id: u64, + program: PathBuf, + arguments: Vec, + configuration: Vec, + data_capacity_messages: usize, + max_signal_id_bytes: usize, + max_role_bytes: usize, + max_schema_bytes: usize, + max_payload_bytes: usize, + ready_timeout_ms: u64, + processing_timeout_ms: u64, + shutdown_timeout_ms: u64, + ) -> PyResult { + if id == 0 { + return Err(invalid_spec("sidecar ID must be non-zero")); + } + if program.as_os_str().is_empty() { + return Err(invalid_spec("program must not be empty")); + } + if data_capacity_messages == 0 { + return Err(invalid_spec( + "data_capacity_messages must be greater than zero", + )); + } + if max_signal_id_bytes == 0 + || max_role_bytes == 0 + || max_schema_bytes == 0 + || max_payload_bytes == 0 + { + return Err(invalid_spec("protocol limits must be greater than zero")); + } + if ready_timeout_ms == 0 || processing_timeout_ms == 0 || shutdown_timeout_ms == 0 { + return Err(invalid_spec("sidecar deadlines must be greater than zero")); + } + Ok(Self { + id, + program, + arguments, + configuration, + data_capacity_messages, + max_signal_id_bytes, + max_role_bytes, + max_schema_bytes, + max_payload_bytes, + ready_timeout_ms, + processing_timeout_ms, + shutdown_timeout_ms, + }) + } + + #[getter] + fn configuration(&self, py: Python<'_>) -> Py { + PyBytes::new(py, &self.configuration).unbind() + } +} + +impl PythonSidecarProcessSpec { + pub(crate) fn to_core(&self) -> pocketstation::SidecarProcessSpec { + let mut spec = pocketstation::SidecarProcessSpec::new(self.id, self.program.clone()); + spec.arguments = self.arguments.iter().map(Into::into).collect(); + spec.configuration.clone_from(&self.configuration); + spec.data_capacity_messages = self.data_capacity_messages; + spec.protocol_limits = pocketstation::SidecarProtocolLimits { + max_signal_id_bytes: self.max_signal_id_bytes, + max_role_bytes: self.max_role_bytes, + max_schema_bytes: self.max_schema_bytes, + max_payload_bytes: self.max_payload_bytes, + }; + spec.deadlines = pocketstation::SidecarDeadlines { + ready: Duration::from_millis(self.ready_timeout_ms), + processing: Duration::from_millis(self.processing_timeout_ms), + shutdown: Duration::from_millis(self.shutdown_timeout_ms), + }; + spec + } +} + +#[pyclass(name = "_SidecarMessage", frozen)] +#[derive(Clone)] +pub(crate) struct PythonSidecarMessage { + pub(crate) value: pocketstation::SidecarMessage, +} + +#[pymethods] +impl PythonSidecarMessage { + #[new] + #[pyo3(signature = ( + *, + kind, + stream_id, + sequence_number, + timestamp_ns, + signal_id, + payload, + terminal=false, + role=None, + schema=None, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + kind: &str, + stream_id: u64, + sequence_number: u64, + timestamp_ns: u64, + signal_id: String, + payload: Vec, + terminal: bool, + role: Option, + schema: Option, + ) -> PyResult { + if signal_id.is_empty() { + return Err(PyValueError::new_err(coded_reason( + "sidecar.invalid_message", + "signal_id must not be empty", + ))); + } + Ok(Self { + value: pocketstation::SidecarMessage { + kind: parse_kind(kind)?, + terminal, + stream_id, + sequence_number, + timestamp_ns, + signal_id, + role, + schema, + payload, + }, + }) + } + + #[getter] + fn kind(&self) -> &'static str { + kind_name(self.value.kind) + } + + #[getter] + const fn terminal(&self) -> bool { + self.value.terminal + } + + #[getter] + const fn stream_id(&self) -> u64 { + self.value.stream_id + } + + #[getter] + const fn sequence_number(&self) -> u64 { + self.value.sequence_number + } + + #[getter] + const fn timestamp_ns(&self) -> u64 { + self.value.timestamp_ns + } + + #[getter] + fn signal_id(&self) -> &str { + &self.value.signal_id + } + + #[getter] + fn role(&self) -> Option<&str> { + self.value.role.as_deref() + } + + #[getter] + fn schema(&self) -> Option<&str> { + self.value.schema.as_deref() + } + + #[getter] + fn payload(&self, py: Python<'_>) -> Py { + PyBytes::new(py, &self.value.payload).unbind() + } +} + +impl From for PythonSidecarMessage { + fn from(value: pocketstation::SidecarMessage) -> Self { + Self { value } + } +} + +#[pyclass(name = "_SidecarSnapshot", frozen)] +pub(crate) struct PythonSidecarSnapshot { + #[pyo3(get)] + pub(crate) sidecar_id: u64, + #[pyo3(get)] + state: &'static str, + #[pyo3(get)] + state_transitions: u64, + #[pyo3(get)] + data_enqueued_total: u64, + #[pyo3(get)] + data_received_total: u64, + #[pyo3(get)] + data_dropped_total: u64, + #[pyo3(get)] + protocol_failures_total: u64, + #[pyo3(get)] + timeouts_total: u64, + #[pyo3(get)] + forced_kills_total: u64, + #[pyo3(get)] + reaps_total: u64, +} + +#[pymethods] +impl PythonSidecarSnapshot { + fn visited(&self, state: &str) -> PyResult { + let state = parse_state(state)?; + Ok(self.state_transitions & (1u64 << state as u8) != 0) + } +} + +impl From for PythonSidecarSnapshot { + fn from(value: pocketstation::SessionSidecarMetrics) -> Self { + Self { + sidecar_id: value.sidecar_id, + state: state_name(value.host.state), + state_transitions: value.host.state_transitions, + data_enqueued_total: value.host.data_enqueued_total, + data_received_total: value.host.data_received_total, + data_dropped_total: value.host.data_dropped_total, + protocol_failures_total: value.host.protocol_failures_total, + timeouts_total: value.host.timeouts_total, + forced_kills_total: value.host.forced_kills_total, + reaps_total: value.host.reaps_total, + } + } +} + +pub(crate) enum OwnedSidecarRead { + Item(pocketstation::SidecarMessage), + Empty, + Closed, +} + +#[pyclass(name = "_SidecarRead", frozen)] +pub(crate) struct PythonSidecarRead { + #[pyo3(get)] + status: &'static str, + message: Option, +} + +#[pymethods] +impl PythonSidecarRead { + #[getter] + fn message(&self) -> Option { + self.message.clone() + } +} + +impl From for PythonSidecarRead { + fn from(value: OwnedSidecarRead) -> Self { + match value { + OwnedSidecarRead::Item(message) => Self { + status: "item", + message: Some(message.into()), + }, + OwnedSidecarRead::Empty => Self { + status: "empty", + message: None, + }, + OwnedSidecarRead::Closed => Self { + status: "closed", + message: None, + }, + } + } +} + +pub(crate) fn poll_sidecar( + running: &pocketstation::RunningSession, + sidecar_id: u64, +) -> Result { + match running.try_receive_sidecar_signal(sidecar_id) { + Ok(Some(message)) => Ok(OwnedSidecarRead::Item(message)), + Ok(None) => Ok(OwnedSidecarRead::Empty), + Err(pocketstation::SidecarHostError::Closed) => Ok(OwnedSidecarRead::Closed), + Err(error) => Err(sidecar_error_message(error)), + } +} + +pub(crate) fn wait_sidecar( + running: &pocketstation::RunningSession, + sidecar_id: u64, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match poll_sidecar(running, sidecar_id)? { + OwnedSidecarRead::Empty if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(1)); + } + value => return Ok(value), + } + } +} + +pub(crate) fn sidecar_snapshot( + running: &pocketstation::RunningSession, + sidecar_id: u64, +) -> Result { + running + .sidecar_metrics() + .into_vec() + .into_iter() + .find(|metrics| metrics.sidecar_id == sidecar_id) + .ok_or_else(|| { + coded_reason( + "sidecar.unknown", + format!("sidecar process ID {sidecar_id} is not owned by this Session"), + ) + }) +} + +pub(crate) fn sidecar_error_message(error: pocketstation::SidecarHostError) -> String { + let code = match error { + pocketstation::SidecarHostError::InvalidConfiguration(_) => "sidecar.invalid_configuration", + pocketstation::SidecarHostError::Spawn(_) => "sidecar.spawn_failed", + pocketstation::SidecarHostError::ThreadSpawn(_) => "sidecar.thread_spawn_failed", + pocketstation::SidecarHostError::MissingPipe(_) => "sidecar.missing_pipe", + pocketstation::SidecarHostError::Io(_) => "sidecar.io", + pocketstation::SidecarHostError::Protocol(_) => "sidecar.protocol", + pocketstation::SidecarHostError::FrameTooLarge => "sidecar.frame_too_large", + pocketstation::SidecarHostError::DataQueueFull => "sidecar.queue_full", + pocketstation::SidecarHostError::ControlQueueFull => "sidecar.control_queue_full", + pocketstation::SidecarHostError::Closed => "sidecar.closed", + pocketstation::SidecarHostError::UnexpectedEof => "sidecar.unexpected_eof", + pocketstation::SidecarHostError::UnexpectedMessage { .. } => "sidecar.unexpected_message", + pocketstation::SidecarHostError::Timeout(_) => "sidecar.timeout", + pocketstation::SidecarHostError::ProcessingTimeout => "sidecar.processing_timeout", + pocketstation::SidecarHostError::InvalidState { .. } => "sidecar.invalid_state", + pocketstation::SidecarHostError::InvalidDataKind(_) => "sidecar.invalid_message_kind", + pocketstation::SidecarHostError::Wait(_) => "sidecar.wait_failed", + pocketstation::SidecarHostError::Kill(_) => "sidecar.kill_failed", + pocketstation::SidecarHostError::AlreadyReaped => "sidecar.already_reaped", + pocketstation::SidecarHostError::UnknownSidecar(_) => "sidecar.unknown", + }; + coded_reason(code, error.to_string()) +} + +fn invalid_spec(reason: &str) -> PyErr { + PyValueError::new_err(coded_reason("sidecar.invalid_configuration", reason)) +} + +fn parse_kind(value: &str) -> PyResult { + match value { + "signal" => Ok(pocketstation::SidecarMessageKind::Signal), + "ready" => Ok(pocketstation::SidecarMessageKind::Ready), + "error" => Ok(pocketstation::SidecarMessageKind::Error), + "cancel" => Ok(pocketstation::SidecarMessageKind::Cancel), + "close" => Ok(pocketstation::SidecarMessageKind::Close), + "hello" => Ok(pocketstation::SidecarMessageKind::Hello), + "manifest" => Ok(pocketstation::SidecarMessageKind::Manifest), + "configure" => Ok(pocketstation::SidecarMessageKind::Configure), + "observation" => Ok(pocketstation::SidecarMessageKind::Observation), + "closed" => Ok(pocketstation::SidecarMessageKind::Closed), + _ => Err(PyValueError::new_err(coded_reason( + "sidecar.invalid_message_kind", + "kind must be signal, ready, error, cancel, close, hello, manifest, configure, observation, or closed", + ))), + } +} + +const fn kind_name(value: pocketstation::SidecarMessageKind) -> &'static str { + match value { + pocketstation::SidecarMessageKind::Signal => "signal", + pocketstation::SidecarMessageKind::Ready => "ready", + pocketstation::SidecarMessageKind::Error => "error", + pocketstation::SidecarMessageKind::Cancel => "cancel", + pocketstation::SidecarMessageKind::Close => "close", + pocketstation::SidecarMessageKind::Hello => "hello", + pocketstation::SidecarMessageKind::Manifest => "manifest", + pocketstation::SidecarMessageKind::Configure => "configure", + pocketstation::SidecarMessageKind::Observation => "observation", + pocketstation::SidecarMessageKind::Closed => "closed", + } +} + +fn parse_state(value: &str) -> PyResult { + match value { + "spawned" => Ok(pocketstation::SidecarState::Spawned), + "hello" => Ok(pocketstation::SidecarState::Hello), + "manifest" => Ok(pocketstation::SidecarState::Manifest), + "configure" => Ok(pocketstation::SidecarState::Configure), + "ready" => Ok(pocketstation::SidecarState::Ready), + "running" => Ok(pocketstation::SidecarState::Running), + "cancelling" => Ok(pocketstation::SidecarState::Cancelling), + "closing" => Ok(pocketstation::SidecarState::Closing), + "closed" => Ok(pocketstation::SidecarState::Closed), + "reaped" => Ok(pocketstation::SidecarState::Reaped), + "failed" => Ok(pocketstation::SidecarState::Failed), + _ => Err(PyValueError::new_err(coded_reason( + "sidecar.invalid_state", + "unknown sidecar state", + ))), + } +} + +const fn state_name(value: pocketstation::SidecarState) -> &'static str { + match value { + pocketstation::SidecarState::Spawned => "spawned", + pocketstation::SidecarState::Hello => "hello", + pocketstation::SidecarState::Manifest => "manifest", + pocketstation::SidecarState::Configure => "configure", + pocketstation::SidecarState::Ready => "ready", + pocketstation::SidecarState::Running => "running", + pocketstation::SidecarState::Cancelling => "cancelling", + pocketstation::SidecarState::Closing => "closing", + pocketstation::SidecarState::Closed => "closed", + pocketstation::SidecarState::Reaped => "reaped", + pocketstation::SidecarState::Failed => "failed", + } +} + +pub(crate) fn runtime_error(error: String) -> PyErr { + PyRuntimeError::new_err(error) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/signals.rs b/native/src/signals.rs new file mode 100644 index 0000000..9131731 --- /dev/null +++ b/native/src/signals.rs @@ -0,0 +1,993 @@ +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use pocketstation::graph::NodeConfig; +use pocketstation::{ + ConfigError, DerivedStreamHandle, EndpointCancellationOutcome, EndpointConfiguration, + EndpointDriverFactory, EndpointDriverFinalization, EndpointDriverObservations, EndpointFailure, + EndpointFailureStage, EndpointPortInput, EndpointReceiver, EndpointStartGate, + ExecutionPartition, Multiplicity, NodeDefinition, NodeDescriptor, NodeTypeId, OperatorId, + PortDirection, PortSpec, PreparedEndpointDriver, RouteId, RunningEndpointDriver, + SafetyContract, Session, SignalEnvelope, SignalPayload, SignalSpec, SourceOutputHandle, +}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyMemoryView}; + +use crate::errors::{coded_reason, session_endpoint_error, session_error}; +use crate::graph::{PythonEdgeContract, PythonSignalSpec}; + +const SUBSCRIPTION_INPUT_PORT: &str = "signal"; +const SUBSCRIPTION_CONFIG_KEY: &str = "subscription_id"; +const MAXIMUM_WAIT_MS: u64 = 1_000; + +enum ReceiptState { + Declared, + Active(pocketstation::EndpointSignalReceiver), + Closed, + Fault(String), +} + +pub(crate) struct SignalReceipt { + state: Mutex, + received_total: AtomicU64, + closed: AtomicBool, +} + +impl SignalReceipt { + fn new() -> Self { + Self { + state: Mutex::new(ReceiptState::Declared), + received_total: AtomicU64::new(0), + closed: AtomicBool::new(false), + } + } + + fn activate( + &self, + receiver: pocketstation::EndpointSignalReceiver, + ) -> Result<(), EndpointFailure> { + let mut state = self.state.lock().map_err(|_| { + EndpointFailure::new( + EndpointFailureStage::Start, + "Python BusSubscription receipt state is unavailable", + ) + })?; + if self.closed.load(Ordering::Acquire) { + *state = ReceiptState::Closed; + return Ok(()); + } + if !matches!(*state, ReceiptState::Declared) { + return Err(EndpointFailure::new( + EndpointFailureStage::Start, + "Python BusSubscription receipt was activated more than once", + )); + } + *state = ReceiptState::Active(receiver); + Ok(()) + } + + fn poll(&self) -> SignalRead { + let Ok(mut state) = self.state.lock() else { + return SignalRead::Fault( + "Python BusSubscription receipt state is unavailable".to_owned(), + ); + }; + match &mut *state { + ReceiptState::Declared => SignalRead::Empty, + ReceiptState::Active(receiver) => { + if let Some(envelope) = receiver.try_recv() { + if let Err(error) = envelope.validate() { + let message = format!( + "Python BusSubscription received an invalid signal envelope: {error}" + ); + self.closed.store(true, Ordering::Release); + *state = ReceiptState::Fault(message.clone()); + return SignalRead::Fault(message); + } + self.received_total.fetch_add(1, Ordering::Relaxed); + return SignalRead::Item(Box::new(copy_envelope(&envelope))); + } + if receiver.is_abandoned() { + self.closed.store(true, Ordering::Release); + *state = ReceiptState::Closed; + SignalRead::Closed + } else { + SignalRead::Empty + } + } + ReceiptState::Closed => SignalRead::Closed, + ReceiptState::Fault(message) => SignalRead::Fault(message.clone()), + } + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + if let Ok(mut state) = self.state.lock() { + *state = ReceiptState::Closed; + } + } + + #[cfg(test)] + fn fail(&self, message: impl Into) { + let message = message.into(); + self.closed.store(true, Ordering::Release); + if let Ok(mut state) = self.state.lock() { + *state = ReceiptState::Fault(message); + } + } + + fn observations(&self) -> EndpointDriverObservations { + let received = self.received_total.load(Ordering::Relaxed); + EndpointDriverObservations { + frames_received_total: received, + frames_delivered_total: received, + ..EndpointDriverObservations::default() + } + } +} + +pub(crate) type SignalReceipts = Arc>>>; + +pub(crate) fn new_signal_receipts() -> SignalReceipts { + Arc::new(Mutex::new(std::collections::HashMap::new())) +} + +struct SubscriptionDefinition { + descriptor: NodeDescriptor, + subscription_id: String, +} + +impl NodeDefinition for SubscriptionDefinition { + fn descriptor(&self) -> NodeDescriptor { + self.descriptor.clone() + } + + fn validate_config(&self, config: &NodeConfig) -> Result<(), ConfigError> { + match config.get(SUBSCRIPTION_CONFIG_KEY) { + Some(value) if value == self.subscription_id => Ok(()), + Some(_) => Err(ConfigError::Invalid { + key: SUBSCRIPTION_CONFIG_KEY.to_owned(), + reason: "does not match the registered BusSubscription".to_owned(), + }), + None => Err(ConfigError::Missing(SUBSCRIPTION_CONFIG_KEY.to_owned())), + } + } +} + +struct SubscriptionFactory { + subscription_id: String, + receipt: Arc, +} + +impl EndpointDriverFactory for SubscriptionFactory { + fn prepare( + &self, + mut inputs: Vec, + ) -> Result, EndpointFailure> { + if inputs.len() != 1 { + return Err(EndpointFailure::new( + EndpointFailureStage::Prepare, + "one Python BusSubscription requires exactly one signal input", + )); + } + let input = inputs.pop().expect("length checked"); + if input + .context() + .node_configuration() + .get(SUBSCRIPTION_CONFIG_KEY) + != Some(self.subscription_id.as_str()) + { + return Err(EndpointFailure::new( + EndpointFailureStage::Prepare, + "Python BusSubscription configuration does not match its receipt", + )); + } + if !matches!(input.receiver(), EndpointReceiver::Signal(_)) { + return Err(EndpointFailure::new( + EndpointFailureStage::Prepare, + "Python BusSubscription accepts typed signal inputs only", + )); + } + Ok(Box::new(PreparedSubscription { + input, + receipt: Arc::clone(&self.receipt), + })) + } +} + +struct PreparedSubscription { + input: EndpointPortInput, + receipt: Arc, +} + +impl PreparedEndpointDriver for PreparedSubscription { + fn start( + self: Box, + _start_gate: Arc, + ) -> Result, EndpointFailure> { + let (receiver, _) = self.input.into_parts(); + let EndpointReceiver::Signal(receiver) = receiver else { + self.receipt.close(); + return Err(EndpointFailure::new( + EndpointFailureStage::Start, + "Python BusSubscription received a realtime audio edge", + )); + }; + self.receipt.activate(receiver)?; + Ok(Box::new(RunningSubscription { + receipt: Arc::clone(&self.receipt), + })) + } + + fn cancel_preparation(self: Box) -> EndpointCancellationOutcome { + self.receipt.close(); + EndpointCancellationOutcome { + observations: self.receipt.observations(), + result: Ok(()), + } + } +} + +struct RunningSubscription { + receipt: Arc, +} + +impl RunningEndpointDriver for RunningSubscription { + fn observations(&self) -> EndpointDriverObservations { + self.receipt.observations() + } + + fn request_stop(&mut self) -> Result<(), EndpointFailure> { + self.receipt.close(); + Ok(()) + } + + fn join_and_finalize(self: Box) -> EndpointDriverFinalization { + self.receipt.close(); + EndpointDriverFinalization { + observations: self.receipt.observations(), + result: Ok(()), + } + } +} + +#[pyclass(name = "BusSubscription", frozen)] +pub(crate) struct PythonBusSubscription { + #[pyo3(get)] + pub(crate) id: u64, + #[pyo3(get)] + pub(crate) session_id: u64, + #[pyo3(get)] + pub(crate) route_id: u64, + signal: PythonSignalSpec, + edge: PythonEdgeContract, +} + +#[pymethods] +impl PythonBusSubscription { + #[getter] + fn signal(&self) -> PythonSignalSpec { + self.signal.clone() + } + + #[getter] + fn edge(&self) -> PythonEdgeContract { + self.edge + } +} + +pub(crate) fn subscribe_derived( + session: &Session, + stream: &DerivedStreamHandle, + signal: &PythonSignalSpec, + edge: &PythonEdgeContract, + subscription_id: u64, + receipts: &SignalReceipts, +) -> PyResult { + declare_subscription( + session, + stream.session_id().get(), + signal, + edge, + subscription_id, + receipts, + |endpoint| stream.send(endpoint), + ) +} + +pub(crate) fn subscribe_source_output( + session: &Session, + stream: &SourceOutputHandle, + signal: &PythonSignalSpec, + edge: &PythonEdgeContract, + subscription_id: u64, + receipts: &SignalReceipts, +) -> PyResult { + declare_subscription( + session, + stream.session_id().get(), + signal, + edge, + subscription_id, + receipts, + |endpoint| stream.send(endpoint), + ) +} + +fn declare_subscription( + session: &Session, + stream_session_id: u64, + signal: &PythonSignalSpec, + edge: &PythonEdgeContract, + subscription_id: u64, + receipts: &SignalReceipts, + send: impl FnOnce(pocketstation::EndpointHandle) -> Result, +) -> PyResult { + if stream_session_id != session.id().get() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_route", + "BusSubscription stream belongs to a different Session", + ))); + } + signal.value.validate().map_err(|error| { + PyValueError::new_err(coded_reason("graph.invalid_contract", error.to_string())) + })?; + if !edge.value.media().supports_signal(&signal.value) { + return Err(PyValueError::new_err(coded_reason( + "graph.invalid_contract", + "BusSubscription edge media does not support its SignalSpec", + ))); + } + + let subscription_key = subscription_id.to_string(); + let node_type_id = + format!("io.pocketstation.python.bus-subscription.node.v1.{subscription_id}"); + let operator_id = format!("io.pocketstation.python.bus-subscription.v1.{subscription_id}"); + let input = PortSpec::new( + SUBSCRIPTION_INPUT_PORT, + PortDirection::Input, + signal.value.clone(), + edge.value.media(), + Multiplicity::Many, + true, + ) + .map_err(|error| { + PyValueError::new_err(coded_reason("graph.invalid_contract", error.to_string())) + })?; + let descriptor = NodeDescriptor::new( + NodeTypeId::from(node_type_id.as_str()), + "Python BusSubscription", + vec![input], + Vec::new(), + ExecutionPartition::External, + SafetyContract::ExternalService, + true, + ) + .map_err(|error| { + PyValueError::new_err(coded_reason("graph.invalid_contract", error.to_string())) + })?; + let receipt = Arc::new(SignalReceipt::new()); + session + .register_endpoint( + OperatorId::new(operator_id.clone()), + Arc::new(SubscriptionDefinition { + descriptor, + subscription_id: subscription_key.clone(), + }), + Arc::new(SubscriptionFactory { + subscription_id: subscription_key.clone(), + receipt: Arc::clone(&receipt), + }), + ) + .map_err(session_endpoint_error)?; + let endpoint = session + .endpoint( + pocketstation::EndpointDescriptor::new( + NodeTypeId::from(node_type_id.as_str()), + OperatorId::new(operator_id), + ) + .with_configuration( + EndpointConfiguration::new().with(SUBSCRIPTION_CONFIG_KEY, subscription_key), + ) + .with_input_edge(edge.value), + ) + .map_err(session_error)?; + let route_id = send(endpoint).map_err(session_error)?; + receipts + .lock() + .map_err(|_| PyRuntimeError::new_err("BusSubscription registry is unavailable"))? + .insert(subscription_id, receipt); + Ok(PythonBusSubscription { + id: subscription_id, + session_id: session.id().get(), + route_id: route_id.get(), + signal: signal.clone(), + edge: *edge, + }) +} + +pub(crate) enum SignalRead { + Item(Box), + Empty, + Closed, + Fault(String), +} + +#[derive(Clone, Copy)] +pub(crate) struct OwnedSignalSubscriptionMetrics { + capacity_signals: u64, + max_payload_bytes: u64, + maximum_buffered_payload_bytes: u64, + depth_signals: u64, + peak_depth_signals: u64, + enqueued_total: u64, + received_total: u64, + dropped_total: u64, +} + +#[pyclass(name = "_SignalSubscriptionMetrics", frozen)] +pub(crate) struct PythonSignalSubscriptionMetrics { + #[pyo3(get)] + capacity_signals: u64, + #[pyo3(get)] + max_payload_bytes: u64, + #[pyo3(get)] + maximum_buffered_payload_bytes: u64, + #[pyo3(get)] + depth_signals: u64, + #[pyo3(get)] + peak_depth_signals: u64, + #[pyo3(get)] + enqueued_total: u64, + #[pyo3(get)] + received_total: u64, + #[pyo3(get)] + dropped_total: u64, +} + +impl From for PythonSignalSubscriptionMetrics { + fn from(value: OwnedSignalSubscriptionMetrics) -> Self { + Self { + capacity_signals: value.capacity_signals, + max_payload_bytes: value.max_payload_bytes, + maximum_buffered_payload_bytes: value.maximum_buffered_payload_bytes, + depth_signals: value.depth_signals, + peak_depth_signals: value.peak_depth_signals, + enqueued_total: value.enqueued_total, + received_total: value.received_total, + dropped_total: value.dropped_total, + } + } +} + +#[pyclass(name = "_SignalRead", frozen)] +pub(crate) struct PythonSignalRead { + #[pyo3(get)] + status: &'static str, + envelope: Option>, + #[pyo3(get)] + error: Option, +} + +#[pymethods] +impl PythonSignalRead { + #[getter] + fn envelope(&self, py: Python<'_>) -> Option> { + self.envelope.as_ref().map(|value| value.clone_ref(py)) + } +} + +#[derive(Clone, Copy)] +struct OwnedSignalTiming { + source_timestamp_ns: Option, + observed_timestamp_ns: u64, + session_timestamp_ns: Option, + duration_ns: Option, +} + +#[derive(Clone, Copy)] +struct OwnedSignalLineage { + session_id: u64, + stream_id: u64, + source_id: u64, + clock_id: u32, + sequence_number: u64, + source_generation: u32, + discontinuity_epoch: u64, + policy_epoch: u64, +} + +struct OwnedSignalDerivation { + upstream_lineage: OwnedSignalLineage, + upstream_timing: OwnedSignalTiming, + operator_id: String, + operator_revision: u32, + operator_generation: u32, + connector_id: Option, +} + +struct OwnedSignalAudio { + samples_f32le: Vec, + sample_count: usize, + sample_rate_hz: u32, + channel_count: u8, + stream_id: u64, + source_id: u64, + sequence_number: u64, + timestamp_ns: u64, +} + +enum OwnedSignalPayload { + Audio(OwnedSignalAudio), + Text(String), + Bytes(Vec), +} + +pub(crate) struct OwnedSignalEnvelope { + signal: SignalSpec, + timing: OwnedSignalTiming, + lineage: Option, + derivation: Option, + payload: OwnedSignalPayload, +} + +#[pyclass(name = "_SignalTiming", frozen)] +pub(crate) struct PythonSignalTiming { + #[pyo3(get)] + source_timestamp_ns: Option, + #[pyo3(get)] + observed_timestamp_ns: u64, + #[pyo3(get)] + session_timestamp_ns: Option, + #[pyo3(get)] + duration_ns: Option, +} + +#[pyclass(name = "_SignalLineage", frozen)] +pub(crate) struct PythonSignalLineage { + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + stream_id: u64, + #[pyo3(get)] + source_id: u64, + #[pyo3(get)] + clock_id: u32, + #[pyo3(get)] + sequence_number: u64, + #[pyo3(get)] + source_generation: u32, + #[pyo3(get)] + discontinuity_epoch: u64, + #[pyo3(get)] + policy_epoch: u64, +} + +#[pyclass(name = "_SignalDerivation", frozen)] +pub(crate) struct PythonSignalDerivation { + upstream_lineage: Py, + upstream_timing: Py, + #[pyo3(get)] + operator_id: String, + #[pyo3(get)] + operator_revision: u32, + #[pyo3(get)] + operator_generation: u32, + #[pyo3(get)] + connector_id: Option, +} + +#[pymethods] +impl PythonSignalDerivation { + #[getter] + fn upstream_lineage(&self, py: Python<'_>) -> Py { + self.upstream_lineage.clone_ref(py) + } + + #[getter] + fn upstream_timing(&self, py: Python<'_>) -> Py { + self.upstream_timing.clone_ref(py) + } +} + +#[pyclass(name = "_SignalAudioPayload", frozen)] +pub(crate) struct PythonSignalAudioPayload { + samples_f32le: Py, + #[pyo3(get)] + sample_count: usize, + #[pyo3(get)] + sample_rate_hz: u32, + #[pyo3(get)] + channel_count: u8, + #[pyo3(get)] + stream_id: u64, + #[pyo3(get)] + source_id: u64, + #[pyo3(get)] + sequence_number: u64, + #[pyo3(get)] + timestamp_ns: u64, +} + +#[pymethods] +impl PythonSignalAudioPayload { + #[getter] + fn samples<'py>(&self, py: Python<'py>) -> PyResult> { + PyMemoryView::from(self.samples_f32le.bind(py).as_any()) + } + + #[getter] + fn samples_f32le(&self, py: Python<'_>) -> Py { + self.samples_f32le.clone_ref(py) + } + + #[getter] + #[allow(clippy::unused_self)] + const fn sample_format(&self) -> &'static str { + "f32le" + } +} + +#[pyclass(name = "_SignalEnvelope", frozen)] +pub(crate) struct PythonSignalEnvelope { + signal: PythonSignalSpec, + timing: Py, + lineage: Option>, + derivation: Option>, + #[pyo3(get)] + payload_kind: &'static str, + #[pyo3(get)] + text: Option, + bytes: Option>, + audio: Option>, +} + +#[pymethods] +impl PythonSignalEnvelope { + #[getter] + fn signal(&self) -> PythonSignalSpec { + self.signal.clone() + } + + #[getter] + fn timing(&self, py: Python<'_>) -> Py { + self.timing.clone_ref(py) + } + + #[getter] + fn lineage(&self, py: Python<'_>) -> Option> { + self.lineage.as_ref().map(|value| value.clone_ref(py)) + } + + #[getter] + fn derivation(&self, py: Python<'_>) -> Option> { + self.derivation.as_ref().map(|value| value.clone_ref(py)) + } + + #[getter] + fn bytes(&self, py: Python<'_>) -> Option> { + self.bytes.as_ref().map(|value| value.clone_ref(py)) + } + + #[getter] + fn audio(&self, py: Python<'_>) -> Option> { + self.audio.as_ref().map(|value| value.clone_ref(py)) + } +} + +fn copy_timing(value: pocketstation::SignalTiming) -> OwnedSignalTiming { + OwnedSignalTiming { + source_timestamp_ns: value.source_timestamp_ns(), + observed_timestamp_ns: value.observed_timestamp_ns(), + session_timestamp_ns: value.session_timestamp_ns(), + duration_ns: value.duration_ns(), + } +} + +fn copy_lineage(value: pocketstation::SignalLineage) -> OwnedSignalLineage { + OwnedSignalLineage { + session_id: value.session_id().get(), + stream_id: value.stream_id().get(), + source_id: value.source_id().get(), + clock_id: value.clock_id().get(), + sequence_number: value.sequence_number(), + source_generation: value.source_generation(), + discontinuity_epoch: value.discontinuity_epoch(), + policy_epoch: value.policy_epoch(), + } +} + +fn copy_envelope(value: &SignalEnvelope) -> OwnedSignalEnvelope { + let payload = match value.payload() { + SignalPayload::Audio(frame) => OwnedSignalPayload::Audio(OwnedSignalAudio { + samples_f32le: f32_samples_to_le_bytes(frame.samples()), + sample_count: frame.samples().len(), + sample_rate_hz: frame.sample_rate_hz(), + channel_count: frame.channels(), + stream_id: frame.stream_id().get(), + source_id: frame.source_id().get(), + sequence_number: frame.sequence_number(), + timestamp_ns: frame.timestamp_ns(), + }), + SignalPayload::Text(text) => OwnedSignalPayload::Text(text.clone()), + SignalPayload::Bytes(bytes) => OwnedSignalPayload::Bytes(bytes.clone()), + }; + let derivation = value.derivation().map(|derivation| OwnedSignalDerivation { + upstream_lineage: copy_lineage(derivation.upstream_lineage()), + upstream_timing: copy_timing(derivation.upstream_timing()), + operator_id: derivation.operator_id().as_str().to_owned(), + operator_revision: derivation.operator_revision(), + operator_generation: derivation.operator_generation(), + connector_id: derivation + .connector_id() + .map(pocketstation::ConnectorId::get), + }); + OwnedSignalEnvelope { + signal: value.signal_spec().clone(), + timing: copy_timing(value.timing()), + lineage: value.lineage().map(copy_lineage), + derivation, + payload, + } +} + +fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(samples)); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes +} + +fn python_timing(py: Python<'_>, value: OwnedSignalTiming) -> PyResult> { + Py::new( + py, + PythonSignalTiming { + source_timestamp_ns: value.source_timestamp_ns, + observed_timestamp_ns: value.observed_timestamp_ns, + session_timestamp_ns: value.session_timestamp_ns, + duration_ns: value.duration_ns, + }, + ) +} + +fn python_lineage(py: Python<'_>, value: OwnedSignalLineage) -> PyResult> { + Py::new( + py, + PythonSignalLineage { + session_id: value.session_id, + stream_id: value.stream_id, + source_id: value.source_id, + clock_id: value.clock_id, + sequence_number: value.sequence_number, + source_generation: value.source_generation, + discontinuity_epoch: value.discontinuity_epoch, + policy_epoch: value.policy_epoch, + }, + ) +} + +fn python_envelope(py: Python<'_>, value: OwnedSignalEnvelope) -> PyResult { + let timing = python_timing(py, value.timing)?; + let lineage = value + .lineage + .map(|value| python_lineage(py, value)) + .transpose()?; + let derivation = value + .derivation + .map(|value| { + let upstream_lineage = python_lineage(py, value.upstream_lineage)?; + let upstream_timing = python_timing(py, value.upstream_timing)?; + Py::new( + py, + PythonSignalDerivation { + upstream_lineage, + upstream_timing, + operator_id: value.operator_id, + operator_revision: value.operator_revision, + operator_generation: value.operator_generation, + connector_id: value.connector_id, + }, + ) + }) + .transpose()?; + let (payload_kind, text, bytes, audio) = match value.payload { + OwnedSignalPayload::Text(text) => ("text", Some(text), None, None), + OwnedSignalPayload::Bytes(bytes) => { + ("bytes", None, Some(PyBytes::new(py, &bytes).unbind()), None) + } + OwnedSignalPayload::Audio(audio) => { + let samples_f32le = PyBytes::new(py, &audio.samples_f32le).unbind(); + let audio = Py::new( + py, + PythonSignalAudioPayload { + samples_f32le, + sample_count: audio.sample_count, + sample_rate_hz: audio.sample_rate_hz, + channel_count: audio.channel_count, + stream_id: audio.stream_id, + source_id: audio.source_id, + sequence_number: audio.sequence_number, + timestamp_ns: audio.timestamp_ns, + }, + )?; + ("audio", None, None, Some(audio)) + } + }; + Ok(PythonSignalEnvelope { + signal: PythonSignalSpec { + value: value.signal, + }, + timing, + lineage, + derivation, + payload_kind, + text, + bytes, + audio, + }) +} + +fn receipt( + receipts: &SignalReceipts, + session_id: u64, + subscription: &PythonBusSubscription, +) -> PyResult> { + if subscription.session_id != session_id { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_route", + "BusSubscription belongs to a different running Session", + ))); + } + receipts + .lock() + .map_err(|_| PyRuntimeError::new_err("BusSubscription registry is unavailable"))? + .get(&subscription.id) + .cloned() + .ok_or_else(|| { + PyValueError::new_err(coded_reason( + "session.invalid_route", + "BusSubscription is not registered on this Session", + )) + }) +} + +pub(crate) fn poll_signal( + py: Python<'_>, + receipts: &SignalReceipts, + session_id: u64, + subscription: &PythonBusSubscription, +) -> PyResult { + let receipt = receipt(receipts, session_id, subscription)?; + let read = py.detach(|| receipt.poll()); + python_read(py, read) +} + +pub(crate) fn wait_signal( + py: Python<'_>, + receipts: &SignalReceipts, + session_id: u64, + subscription: &PythonBusSubscription, + timeout_ms: u64, +) -> PyResult { + if timeout_ms > MAXIMUM_WAIT_MS { + return Err(PyValueError::new_err(format!( + "timeout_ms must be at most {MAXIMUM_WAIT_MS}" + ))); + } + let receipt = receipt(receipts, session_id, subscription)?; + let read = py.detach(|| { + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let read = receipt.poll(); + if !matches!(read, SignalRead::Empty) || Instant::now() >= deadline { + return read; + } + thread::sleep(Duration::from_millis(1)); + } + }); + python_read(py, read) +} + +pub(crate) fn close_signal( + receipts: &SignalReceipts, + session_id: u64, + subscription: &PythonBusSubscription, +) -> PyResult<()> { + receipt(receipts, session_id, subscription)?.close(); + Ok(()) +} + +pub(crate) fn validate_signal_subscription( + receipts: &SignalReceipts, + session_id: u64, + subscription: &PythonBusSubscription, +) -> PyResult<()> { + receipt(receipts, session_id, subscription).map(|_| ()) +} + +pub(crate) fn copy_signal_metrics( + running: &pocketstation::RunningSession, + route_id: u64, +) -> Result { + let derived_routes = running.derived_route_metrics(); + let metrics = derived_routes + .iter() + .find(|metrics| metrics.route_id.get() == route_id) + .ok_or_else(|| format!("typed signal metrics are unavailable for route {route_id}"))?; + let value = metrics.output; + Ok(OwnedSignalSubscriptionMetrics { + capacity_signals: value.capacity_signals, + max_payload_bytes: value.max_payload_bytes, + maximum_buffered_payload_bytes: value.maximum_buffered_payload_bytes, + depth_signals: value.depth_signals, + peak_depth_signals: value.peak_depth_signals, + enqueued_total: value.enqueued_total, + received_total: value.received_total, + dropped_total: value.dropped_total, + }) +} + +fn python_read(py: Python<'_>, read: SignalRead) -> PyResult { + match read { + SignalRead::Item(envelope) => Ok(PythonSignalRead { + status: "item", + envelope: Some(Py::new(py, python_envelope(py, *envelope)?)?), + error: None, + }), + SignalRead::Empty => Ok(PythonSignalRead { + status: "empty", + envelope: None, + error: None, + }), + SignalRead::Closed => Ok(PythonSignalRead { + status: "closed", + envelope: None, + error: None, + }), + SignalRead::Fault(error) => Ok(PythonSignalRead { + status: "fault", + envelope: None, + error: Some(error), + }), + } +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ReceiptState, SignalRead, SignalReceipt}; + + #[test] + fn declared_receipt_is_empty_and_close_is_sticky() { + let receipt = SignalReceipt::new(); + assert!(matches!(receipt.poll(), SignalRead::Empty)); + receipt.close(); + receipt.close(); + assert!(matches!(receipt.poll(), SignalRead::Closed)); + let state = receipt.state.lock().expect("receipt state"); + assert!(matches!(&*state, ReceiptState::Closed)); + } + + #[test] + fn receipt_fault_is_sticky() { + let receipt = SignalReceipt::new(); + receipt.fail("fixture fault"); + assert!(matches!(receipt.poll(), SignalRead::Fault(message) if message == "fixture fault")); + assert!(matches!(receipt.poll(), SignalRead::Fault(message) if message == "fixture fault")); + } +} diff --git a/native/src/sources.rs b/native/src/sources.rs new file mode 100644 index 0000000..1c339a4 --- /dev/null +++ b/native/src/sources.rs @@ -0,0 +1,444 @@ +use pocketstation::{ + ApplicationSelector, CaptureSource, DeviceId, DeviceSelector, PermissionObservation, Platform, + ProcessId, ProcessTreeScope, SelectorPersistenceScope, Source, SourceIdentityStrength, + SourceKind, SourceQuery, SourceState, StableSourceId, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::errors::{coded_reason, parse_platform, validate_nonempty, validate_process_id}; + +#[derive(Clone)] +pub(crate) enum SourceDeclaration { + ApplicationName(String), + ApplicationBundleId(String), + ApplicationProcessId(u32), + ApplicationStableId { + platform: Platform, + stable_key: String, + }, + ApplicationProcessInstance { + process_id: u32, + platform: Platform, + stable_key: String, + }, + MicrophoneDefault, + MicrophoneId(String), +} + +impl SourceDeclaration { + pub(crate) fn to_source(&self) -> Source { + match self { + Self::ApplicationName(name) => { + Source::application(ApplicationSelector::name(name.clone())) + } + Self::ApplicationBundleId(bundle_id) => { + Source::application(ApplicationSelector::bundle_id(bundle_id.clone())) + } + Self::ApplicationProcessId(process_id) => { + Source::application(ApplicationSelector::process_id(ProcessId::new(*process_id))) + } + Self::ApplicationStableId { + platform, + stable_key, + } => Source::application(ApplicationSelector::stable_id(StableSourceId::new( + *platform, + SourceKind::Application, + stable_key.clone(), + ))), + Self::ApplicationProcessInstance { + process_id, + platform, + stable_key, + } => Source::application(ApplicationSelector::process_instance( + ProcessId::new(*process_id), + StableSourceId::new(*platform, SourceKind::Application, stable_key.clone()), + )), + Self::MicrophoneDefault => Source::microphone_default(), + Self::MicrophoneId(device_id) => { + Source::microphone(DeviceSelector::id(DeviceId::new(device_id.clone()))) + } + } + } +} + +#[pyclass(name = "Source", frozen)] +pub(crate) struct PythonSource { + pub(crate) declaration: SourceDeclaration, +} + +#[pyclass(name = "DiscoveredSource", frozen)] +pub(crate) struct PythonDiscoveredSource { + #[pyo3(get)] + platform: String, + #[pyo3(get)] + kind: String, + #[pyo3(get)] + stable_key: String, + #[pyo3(get)] + source_id: u64, + #[pyo3(get)] + name: String, + #[pyo3(get)] + process_id: Option, + #[pyo3(get)] + application_id: Option, + #[pyo3(get)] + device_uid: Option, + #[pyo3(get)] + state: String, + #[pyo3(get)] + sample_rate_hz: u32, + #[pyo3(get)] + channel_count: u16, + #[pyo3(get)] + identity_strength: String, + #[pyo3(get)] + selector_persistence_scope: Option, + #[pyo3(get)] + process_tree_scope: Option, +} + +#[pymethods] +impl PythonSource { + #[staticmethod] + fn application(name: String) -> PyResult { + if name.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_selector", + "application name must not be empty", + ))); + } + Ok(Self { + declaration: SourceDeclaration::ApplicationName(name), + }) + } + + #[staticmethod] + fn application_bundle_id(bundle_id: String) -> PyResult { + validate_nonempty("application bundle ID", &bundle_id)?; + Ok(Self { + declaration: SourceDeclaration::ApplicationBundleId(bundle_id), + }) + } + + #[staticmethod] + fn application_process_id(process_id: u32) -> PyResult { + validate_process_id(process_id)?; + Ok(Self { + declaration: SourceDeclaration::ApplicationProcessId(process_id), + }) + } + + #[staticmethod] + fn application_stable_id(platform: &str, stable_key: String) -> PyResult { + validate_nonempty("application stable ID", &stable_key)?; + Ok(Self { + declaration: SourceDeclaration::ApplicationStableId { + platform: parse_platform(platform)?, + stable_key, + }, + }) + } + + #[staticmethod] + fn application_process_instance( + process_id: u32, + platform: &str, + stable_key: String, + ) -> PyResult { + validate_process_id(process_id)?; + validate_nonempty("application stable ID", &stable_key)?; + Ok(Self { + declaration: SourceDeclaration::ApplicationProcessInstance { + process_id, + platform: parse_platform(platform)?, + stable_key, + }, + }) + } + + #[staticmethod] + const fn microphone_default() -> Self { + Self { + declaration: SourceDeclaration::MicrophoneDefault, + } + } + + #[staticmethod] + fn microphone_id(device_id: String) -> PyResult { + if device_id.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_selector", + "microphone device ID must not be empty", + ))); + } + Ok(Self { + declaration: SourceDeclaration::MicrophoneId(device_id), + }) + } +} + +fn platform_name(platform: Platform) -> &'static str { + match platform { + Platform::Macos => "macos", + Platform::Windows => "windows", + Platform::Linux => "linux", + Platform::Ios => "ios", + Platform::Android => "android", + Platform::Web => "web", + Platform::Unknown => "unknown", + } +} + +fn source_kind_name(kind: SourceKind) -> &'static str { + match kind { + SourceKind::Application => "application", + SourceKind::OutputDevice => "output-device", + SourceKind::InputDevice => "input-device", + SourceKind::SystemMix => "system-mix", + } +} + +fn source_state_name(state: SourceState) -> &'static str { + match state { + SourceState::Available => "available", + SourceState::Playing => "playing", + SourceState::Silent => "silent", + SourceState::Unavailable => "unavailable", + SourceState::PermissionBlocked => "permission-blocked", + } +} + +fn identity_strength_name(strength: SourceIdentityStrength) -> &'static str { + match strength { + SourceIdentityStrength::ApplicationIdAndProcessId => "application-id-and-process-id", + SourceIdentityStrength::StableApplicationId => "stable-application-id", + SourceIdentityStrength::ProcessId => "process-id", + SourceIdentityStrength::StableDeviceUid => "stable-device-uid", + SourceIdentityStrength::PlatformStableId => "platform-stable-id", + } +} + +fn selector_persistence_scope_name(scope: SelectorPersistenceScope) -> &'static str { + match scope { + SelectorPersistenceScope::ProcessLifetime => "process-lifetime", + SelectorPersistenceScope::ApplicationIdentity => "application-identity", + SelectorPersistenceScope::DeviceIdentity => "device-identity", + SelectorPersistenceScope::SessionDefaultDevice => "session-default-device", + SelectorPersistenceScope::PlatformIdentity => "platform-identity", + } +} + +fn process_tree_scope_name(scope: ProcessTreeScope) -> &'static str { + match scope { + ProcessTreeScope::SelectedProcessOnly => "selected-process-only", + ProcessTreeScope::SelectedProcessAndDescendants => "selected-process-and-descendants", + ProcessTreeScope::ApplicationIdentity => "application-identity", + ProcessTreeScope::NotApplicable => "not-applicable", + } +} + +pub(crate) fn permission_observation_name(observation: PermissionObservation) -> &'static str { + match observation { + PermissionObservation::Allowed => "allowed", + PermissionObservation::Denied => "denied", + PermissionObservation::Restricted => "restricted", + PermissionObservation::NotDetermined => "not-determined", + PermissionObservation::Revoked => "revoked", + PermissionObservation::NotObservable => "not-observable", + PermissionObservation::NotApplicable => "not-applicable", + } +} + +pub(crate) fn stable_source_parts( + stable_id: &StableSourceId, +) -> (&'static str, &'static str, &str) { + ( + platform_name(stable_id.platform), + source_kind_name(stable_id.kind), + &stable_id.stable_key, + ) +} + +fn discovered_source(source: CaptureSource) -> PythonDiscoveredSource { + let platform = platform_name(source.stable_id.platform).to_owned(); + let kind = source_kind_name(source.stable_id.kind).to_owned(); + let source_id = source.stable_id.source_id().get(); + let identity_strength = identity_strength_name(source.identity_strength()).to_owned(); + let selector_persistence_scope = source + .selector_persistence_scope() + .map(selector_persistence_scope_name) + .map(str::to_owned); + let process_tree_scope = source + .process_tree_scope() + .map(process_tree_scope_name) + .map(str::to_owned); + PythonDiscoveredSource { + platform, + kind, + stable_key: source.stable_id.stable_key, + source_id, + name: source.name, + process_id: source.process_id, + application_id: source.app_id, + device_uid: source.device_uid, + state: source_state_name(source.state).to_owned(), + sample_rate_hz: source.sample_rate_hz, + channel_count: source.channels, + identity_strength, + selector_persistence_scope, + process_tree_scope, + } +} + +fn parse_source_kind(value: &str) -> PyResult { + match value { + "application" => Ok(SourceKind::Application), + "output-device" => Ok(SourceKind::OutputDevice), + "input-device" => Ok(SourceKind::InputDevice), + "system-mix" => Ok(SourceKind::SystemMix), + _ => Err(PyValueError::new_err(coded_reason( + "source.invalid_query", + "source kind must be application, output-device, input-device, or system-mix", + ))), + } +} + +fn parse_source_query(query_kind: &str, value: Option) -> PyResult { + match (query_kind, value) { + ("any", None) => Ok(SourceQuery::Any), + ("application", Some(value)) => { + validate_nonempty("application query", &value)?; + Ok(SourceQuery::App(value)) + } + ("kind", Some(value)) => Ok(SourceQuery::ByKind(parse_source_kind(&value)?)), + ("stable-key", Some(value)) => { + validate_nonempty("stable source key", &value)?; + Ok(SourceQuery::ByStableKey(value)) + } + ("playing", None) => Ok(SourceQuery::Playing), + ("any" | "playing", Some(_)) => Err(PyValueError::new_err(coded_reason( + "source.invalid_query", + "this source query does not accept a value", + ))), + ("application" | "kind" | "stable-key", None) => Err(PyValueError::new_err(coded_reason( + "source.invalid_query", + "this source query requires a value", + ))), + _ => Err(PyValueError::new_err(coded_reason( + "source.invalid_query", + "query kind must be any, application, kind, stable-key, or playing", + ))), + } +} + +#[pyfunction(name = "discover_sources")] +#[pyo3(signature = (query_kind="any", value=None))] +fn python_discover_sources( + query_kind: &str, + value: Option, +) -> PyResult> { + let query = parse_source_query(query_kind, value)?; + Ok( + pocketstation::resolve_query(&query, &pocketstation::discover_sources()) + .into_iter() + .map(discovered_source) + .collect(), + ) +} + +#[pyfunction(name = "application_capture_available")] +fn python_application_capture_available() -> bool { + pocketstation::application_capture_available() +} + +#[pyfunction(name = "microphone_permission_observation")] +fn python_microphone_permission_observation() -> &'static str { + permission_observation_name(pocketstation::microphone_permission_observation()) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(python_discover_sources, module)?)?; + module.add_function(wrap_pyfunction!( + python_application_capture_available, + module + )?)?; + module.add_function(wrap_pyfunction!( + python_microphone_permission_observation, + module + )?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_source() -> CaptureSource { + CaptureSource { + stable_id: StableSourceId::new(Platform::Linux, SourceKind::Application, "pw-app:42"), + name: "Fixture".to_owned(), + process_id: Some(42), + app_id: Some("io.pocketstation.fixture".to_owned()), + device_uid: None, + state: SourceState::Playing, + sample_rate_hz: 48_000, + channels: 2, + } + } + + #[test] + fn discovered_projection_preserves_identity_and_selector_truth() { + let source = fixture_source(); + let expected_source_id = source.stable_id.source_id().get(); + let projected = discovered_source(source); + assert_eq!(projected.platform, "linux"); + assert_eq!(projected.kind, "application"); + assert_eq!(projected.stable_key, "pw-app:42"); + assert_eq!(projected.source_id, expected_source_id); + assert_eq!(projected.process_id, Some(42)); + assert_eq!( + projected.application_id.as_deref(), + Some("io.pocketstation.fixture") + ); + assert_eq!(projected.state, "playing"); + assert_eq!(projected.identity_strength, "application-id-and-process-id"); + assert_eq!( + projected.selector_persistence_scope.as_deref(), + Some("application-identity") + ); + assert_eq!( + projected.process_tree_scope.as_deref(), + Some("application-identity") + ); + } + + #[test] + fn permission_projection_keeps_every_core_state_distinct() { + let values = [ + (PermissionObservation::Allowed, "allowed"), + (PermissionObservation::Denied, "denied"), + (PermissionObservation::Restricted, "restricted"), + (PermissionObservation::NotDetermined, "not-determined"), + (PermissionObservation::Revoked, "revoked"), + (PermissionObservation::NotObservable, "not-observable"), + (PermissionObservation::NotApplicable, "not-applicable"), + ]; + for (value, expected) in values { + assert_eq!(permission_observation_name(value), expected); + } + } + + #[test] + fn query_parser_rejects_missing_or_surplus_values() { + assert!(parse_source_query("application", None).is_err()); + assert!(parse_source_query("playing", Some("unexpected".to_owned())).is_err()); + assert_eq!( + parse_source_query("kind", Some("input-device".to_owned())).expect("typed query"), + SourceQuery::ByKind(SourceKind::InputDevice) + ); + } +} diff --git a/native/src/streams.rs b/native/src/streams.rs new file mode 100644 index 0000000..4a00ba1 --- /dev/null +++ b/native/src/streams.rs @@ -0,0 +1,273 @@ +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::thread; +use std::time::{Duration, Instant}; + +use pocketstation::PolledAudioPollError; +use pyo3::exceptions::{PyIndexError, PyRuntimeError}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyMemoryView}; + +use crate::session::SessionCommand; + +#[pyclass(name = "AudioFrame", frozen)] +pub(crate) struct PythonAudioFrame { + samples_f32le: Py, + sample_count: usize, + #[pyo3(get)] + sample_rate_hz: u32, + #[pyo3(get)] + channel_count: u8, + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + stream_id: u64, + #[pyo3(get)] + source_id: u64, + #[pyo3(get)] + stem_id: u64, + #[pyo3(get)] + clock_id: u32, + #[pyo3(get)] + sequence_num: u64, + #[pyo3(get)] + timestamp_start_ns: u64, + #[pyo3(get)] + duration_ns: u64, + #[pyo3(get)] + source_generation: u32, + #[pyo3(get)] + discontinuity_epoch: u64, + #[pyo3(get)] + permission_epoch: u64, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + connector_id: u64, + #[pyo3(get)] + route_id: u64, +} + +#[pymethods] +impl PythonAudioFrame { + fn __repr__(&self) -> String { + format!( + "AudioFrame(stem_id={}, source_id={}, sequence_number={}, timestamp_start_ns={}, sample_count={}, sample_rate_hz={}, channel_count={}, discontinuity_epoch={})", + self.stem_id, + self.source_id, + self.sequence_num, + self.timestamp_start_ns, + self.sample_count, + self.sample_rate_hz, + self.channel_count, + self.discontinuity_epoch, + ) + } + + /// Read-only zero-copy Python view over owned little-endian f32 PCM bytes. + #[getter] + fn samples<'py>(&self, py: Python<'py>) -> PyResult> { + PyMemoryView::from(self.samples_f32le.bind(py).as_any()) + } + + /// Owned bytes suitable for numpy.frombuffer(..., dtype=") -> Py { + self.samples_f32le.clone_ref(py) + } + + #[getter] + const fn sample_count(&self) -> usize { + self.sample_count + } + + #[getter] + #[allow(clippy::unused_self)] // PyO3 property getter is instance-shaped. + const fn sample_format(&self) -> &'static str { + "f32le" + } + + #[getter] + const fn sequence_number(&self) -> u64 { + self.sequence_num + } +} + +#[pyclass(name = "AudioBatch", frozen)] +pub(crate) struct PythonAudioBatch { + frames: Vec>, +} + +#[pymethods] +impl PythonAudioBatch { + const fn __len__(&self) -> usize { + self.frames.len() + } + + fn frames(&self, py: Python<'_>) -> Vec> { + self.frames + .iter() + .map(|frame| frame.clone_ref(py)) + .collect() + } + + fn __getitem__(&self, index: isize, py: Python<'_>) -> PyResult> { + let length = self.frames.len().cast_signed(); + let normalized = if index < 0 { length + index } else { index }; + if normalized < 0 || normalized >= length { + return Err(PyIndexError::new_err("audio batch index out of range")); + } + Ok(self.frames[normalized.cast_unsigned()].clone_ref(py)) + } +} + +pub(crate) struct OwnedAudioFrame { + pub(crate) samples_f32le: Vec, + pub(crate) sample_count: usize, + pub(crate) sample_rate_hz: u32, + pub(crate) channel_count: u8, + pub(crate) session_id: u64, + pub(crate) stream_id: u64, + pub(crate) source_id: u64, + pub(crate) stem_id: u64, + pub(crate) clock_id: u32, + pub(crate) sequence_num: u64, + pub(crate) timestamp_start_ns: u64, + pub(crate) duration_ns: u64, + pub(crate) source_generation: u32, + pub(crate) discontinuity_epoch: u64, + pub(crate) permission_epoch: u64, + pub(crate) endpoint_id: u64, + pub(crate) connector_id: u64, + pub(crate) route_id: u64, +} + +pub(crate) fn request_audio_batch( + commands: &SyncSender, +) -> PyResult>> { + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::PollAudio { response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not return audio"))? + .map_err(PyRuntimeError::new_err) +} + +pub(crate) fn request_audio_batch_wait( + commands: &SyncSender, + timeout: Duration, +) -> PyResult>> { + let (response, receiver) = sync_channel(1); + commands + .send(SessionCommand::WaitAudio { timeout, response }) + .map_err(|_| PyRuntimeError::new_err("native Session worker has stopped"))?; + receiver + .recv() + .map_err(|_| PyRuntimeError::new_err("native Session worker did not return audio"))? + .map_err(PyRuntimeError::new_err) +} + +pub(crate) fn copy_audio_batch( + running: &pocketstation::RunningSession, +) -> Result>, String> { + let batch = match running.try_poll_audio() { + Ok(batch) => batch, + Err(PolledAudioPollError::Empty) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let mut frames = Vec::with_capacity(batch.len()); + for index in 0..batch.len() { + let frame = batch + .frame(index) + .ok_or_else(|| "native audio batch changed during copy".to_owned())?; + let lineage = frame.lineage(); + frames.push(OwnedAudioFrame { + samples_f32le: f32_samples_to_le_bytes(frame.samples()), + sample_count: frame.samples().len(), + sample_rate_hz: frame.sample_rate_hz(), + channel_count: frame.channels(), + session_id: lineage.session_id().get(), + stream_id: frame.stream_id().get(), + source_id: lineage.source_id().get(), + stem_id: lineage.stem_id().get(), + clock_id: lineage.clock_id().get(), + sequence_num: lineage.sequence_number(), + timestamp_start_ns: lineage.timestamp_start_ns(), + duration_ns: lineage.duration_ns(), + source_generation: lineage.source_generation(), + discontinuity_epoch: lineage.discontinuity_epoch(), + permission_epoch: lineage.permission_epoch(), + endpoint_id: frame.endpoint_id().get(), + connector_id: frame.connector_id().get(), + route_id: frame.route_id().get(), + }); + } + Ok(Some(frames)) +} + +pub(crate) fn copy_audio_batch_until( + running: &pocketstation::RunningSession, + timeout: Duration, +) -> Result>, String> { + let deadline = Instant::now() + timeout; + loop { + match copy_audio_batch(running)? { + Some(batch) => return Ok(Some(batch)), + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(1)), + None => return Ok(None), + } + } +} + +pub(crate) fn python_audio_batch( + py: Python<'_>, + owned: Option>, +) -> PyResult> { + let Some(owned) = owned else { + return Ok(None); + }; + let frames = owned + .into_iter() + .map(|frame| { + Py::new( + py, + PythonAudioFrame { + sample_count: frame.sample_count, + samples_f32le: PyBytes::new(py, &frame.samples_f32le).unbind(), + sample_rate_hz: frame.sample_rate_hz, + channel_count: frame.channel_count, + session_id: frame.session_id, + stream_id: frame.stream_id, + source_id: frame.source_id, + stem_id: frame.stem_id, + clock_id: frame.clock_id, + sequence_num: frame.sequence_num, + timestamp_start_ns: frame.timestamp_start_ns, + duration_ns: frame.duration_ns, + source_generation: frame.source_generation, + discontinuity_epoch: frame.discontinuity_epoch, + permission_epoch: frame.permission_epoch, + endpoint_id: frame.endpoint_id, + connector_id: frame.connector_id, + route_id: frame.route_id, + }, + ) + }) + .collect::>>()?; + Ok(Some(PythonAudioBatch { frames })) +} + +fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(samples)); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/pocketstation/__init__.py b/pocketstation/__init__.py deleted file mode 100644 index 362c253..0000000 --- a/pocketstation/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""PocketStation Python SDK — spec §12.1.""" -from .station import PocketStation -from .types import AudioFrame, AudioMode, IceServer, PocketStationError, RoomCredentials - -__version__ = "0.1.0" -__all__ = [ - "PocketStation", - "AudioFrame", - "AudioMode", - "RoomCredentials", - "IceServer", - "PocketStationError", -] diff --git a/pocketstation/station.py b/pocketstation/station.py deleted file mode 100644 index da4f7ca..0000000 --- a/pocketstation/station.py +++ /dev/null @@ -1,200 +0,0 @@ -"""PocketStation session API — spec §12.1. - -Wire format contract: - - broadcast() sends raw binary PCM bytes over the WebSocket, never base64 JSON. - - listen() yields binary WebSocket frames as AudioFrame objects. - - Errors from the WebSocket propagate to the caller; they are never swallowed. - - disconnect() sends a LEAVE message before closing the WebSocket. - -Phase scope: Phase 5 — WebSocket listener / voice-agent mode. -WebRTC transport is intentionally out of scope for this binding (see FAKE_SCAFFOLD_INVENTORY). -""" -from __future__ import annotations - -import json -import time -from typing import AsyncIterator, Optional - -import httpx -import websockets - -from .types import AudioFrame, AudioMode, IceServer, PocketStationError, RoomCredentials - -_MSG_TYPE_SUBSCRIBE = "SUBSCRIBE" -_MSG_TYPE_LEAVE = "LEAVE" -_MSG_TYPE_ROOM_STATE = "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 (spec §12.1). - - Lifecycle:: - - station = PocketStation(room_id="abc123", relay_url="wss://...", mode=AudioMode.VOICE_AGENT) - await station.connect() - async for frame in station.listen(): - await station.broadcast(tts_bytes) - await station.disconnect() - - Or as a context manager:: - - async with PocketStation(room_id="abc123", relay_url="wss://...") as station: - async for frame in station.listen(): - await station.broadcast(tts_bytes) - """ - - def __init__( - self, - *, - room_id: Optional[str] = None, - relay_url: str = _DEFAULT_RELAY_URL, - api_url: str = _DEFAULT_API_URL, - mode: AudioMode = AudioMode.VOICE_AGENT, - ) -> None: - self.room_id = room_id - self.relay_url = relay_url - self.api_url = api_url - self.mode = mode - self._ws: Optional[websockets.WebSocketClientProtocol] = None - self._credentials: Optional[RoomCredentials] = None - - # ------------------------------------------------------------------ - # Context manager - # ------------------------------------------------------------------ - - async def __aenter__(self) -> "PocketStation": - await self.connect() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - await self.disconnect() - return None - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - async def connect(self) -> None: - """POST /v1/rooms, open the WebSocket, send SUBSCRIBE. - - :raises PocketStationError: on HTTP failure. - :raises OSError: if the WebSocket cannot be opened. - """ - credentials = await self._ensure_room() - ws = await websockets.connect(self.relay_url) - self._ws = ws - subscribe = json.dumps({ - "type": _MSG_TYPE_SUBSCRIBE, - "room_id": self.room_id, - "token": credentials.listener_token, - }) - await ws.send(subscribe) - - async def listen(self) -> AsyncIterator[AudioFrame]: - """Yield AudioFrame objects for each binary PCM frame received. - - Errors from the WebSocket propagate to the caller; they are not caught here. - Text (JSON) control frames are consumed silently. - - :raises ConnectionError: (or subclasses) when the WebSocket drops. - :raises websockets.exceptions.WebSocketException: on protocol errors. - """ - if self._ws is None: - raise RuntimeError("call connect() before listen()") - - sequence = 0 - async for message in self._ws: - if isinstance(message, bytes): - yield AudioFrame( - pcm=message, - sequence=sequence, - timestamp_ns=time.monotonic_ns(), - ) - sequence += 1 - # Text frames (e.g. ROOM_STATE JSON) are intentionally skipped here; - # they carry relay control metadata, not audio payload. - - async def broadcast(self, audio: bytes) -> None: - """Send raw PCM bytes to the relay. - - The payload is transmitted as binary WebSocket data — not base64 JSON. - - :param audio: raw PCM bytes (f32-LE 48 kHz mono per PY-013). - :raises RuntimeError: if not connected. - :raises websockets.exceptions.WebSocketException: on send failure. - """ - if self._ws is None: - raise RuntimeError("call connect() before broadcast()") - await self._ws.send(audio) - - async def disconnect(self) -> None: - """Send LEAVE, then close the WebSocket. - - Safe to call even if already disconnected. - """ - if self._ws is None: - return - ws = self._ws - self._ws = None - try: - leave = json.dumps({ - "type": _MSG_TYPE_LEAVE, - "room_id": self.room_id, - }) - await ws.send(leave) - finally: - await ws.close() - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - async def _ensure_room(self) -> RoomCredentials: - """Create or reuse a room via POST /v1/rooms.""" - if self._credentials is not None: - return self._credentials - - url = self.api_url.rstrip("/") + "/v1/rooms" - try: - async with httpx.AsyncClient() as client: - response = await client.post(url, json={}) - except httpx.RequestError as exc: - raise PocketStationError( - f"network error creating room: {exc}", "network_error" - ) from exc - - if not response.is_success: - raise PocketStationError( - f"relay returned HTTP {response.status_code}: {response.text}", - "http_error", - ) - - try: - data = response.json() - except Exception as exc: - raise PocketStationError( - f"failed to parse room creation response: {exc}", "parse_error" - ) from exc - - credentials = RoomCredentials( - room_id=data["room_id"], - source_token=data.get("source_token", ""), - listener_token=data.get("listener_token", ""), - qr_url=data.get("qr_url", ""), - ice_servers=[ - IceServer( - urls=s.get("urls", []), - username=s.get("username"), - credential=s.get("credential"), - ) - for s in data.get("ice_servers", []) - ], - ) - if not self.room_id: - self.room_id = credentials.room_id - self._credentials = credentials - return credentials diff --git a/pocketstation/types.py b/pocketstation/types.py deleted file mode 100644 index 7ff53d3..0000000 --- a/pocketstation/types.py +++ /dev/null @@ -1,80 +0,0 @@ -"""PocketStation SDK type definitions. Phase 5.""" -from __future__ import annotations -import dataclasses -import enum -import struct -from dataclasses import dataclass, field -from typing import Optional - - -class AudioMode(enum.Enum): - """Audio session mode (spec §12.1).""" - VOICE = "voice" - VOICE_AGENT = "voice_agent" - MUSIC = "music" - BROADCAST = "broadcast" - - -@dataclasses.dataclass -class AudioFrame: - """A single audio frame received from the relay. - - pcm: raw PCM bytes, 48 kHz mono f32-LE (PY-013). - sequence: monotonically increasing frame counter per stream. - timestamp_ns: monotonic nanosecond timestamp at frame receipt. - """ - pcm: bytes - sequence: int = 0 - timestamp_ns: int = 0 - sample_rate: int = 48000 - channels: int = 1 - duration_ms: int = 20 - - @property - def samples(self) -> list[float]: - """Decode f32-LE PCM bytes to float samples.""" - n = len(self.pcm) // 4 - return list(struct.unpack(f"<{n}f", self.pcm[:n * 4])) - - -@dataclass -class IceServer: - """ICE server configuration (PY-023 embedded TURN).""" - urls: list[str] - username: Optional[str] = None - credential: Optional[str] = None - - -@dataclass -class RoomCredentials: - """Credentials returned by POST /v1/rooms.""" - room_id: str - source_token: str - listener_token: str - ice_servers: list[IceServer] = field(default_factory=list) - qr_url: str = "" - - @classmethod - def from_dict(cls, data: dict) -> "RoomCredentials": - ice_servers = [ - IceServer( - urls=srv.get("urls", []), - username=srv.get("username"), - credential=srv.get("credential"), - ) - for srv in data.get("ice_servers", []) - ] - return cls( - room_id=data["room_id"], - source_token=data["source_token"], - listener_token=data["listener_token"], - ice_servers=ice_servers, - qr_url=data.get("qr_url", ""), - ) - - -class PocketStationError(Exception): - """Base error for all PocketStation SDK failures.""" - def __init__(self, message: str, code: str = "error") -> None: - super().__init__(message) - self.code = code diff --git a/pyproject.toml b/pyproject.toml index 70500f9..c714f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,42 @@ +[build-system] +requires = ["maturin>=1.9.4,<2.0"] +build-backend = "maturin" + [project] name = "pocketstation" version = "0.1.0" -description = "PocketStation Python client SDK" +description = "Source-aware live audio capture, processing, and routing for Python" +readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [ "httpx>=0.27", - "websockets>=12.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", - "respx>=0.21", + "mypy>=1.15", + "ruff>=0.11", ] [tool.pytest.ini_options] asyncio_mode = "auto" + +[tool.mypy] +packages = ["pocketstation"] +mypy_path = "python" +python_version = "3.11" +strict = true + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "UP"] + +[tool.maturin] +manifest-path = "native/Cargo.toml" +python-source = "python" +module-name = "pocketstation._native" diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py new file mode 100644 index 0000000..e0cc7d5 --- /dev/null +++ b/python/pocketstation/__init__.py @@ -0,0 +1,355 @@ +"""PocketStation: source-aware live audio Sessions for Python.""" + +from __future__ import annotations + +from . import aio as aio +from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource +from .capture import Capture, capture +from .control import ( + ControlClient, + ControlPlaneError, + IceServer, + SecretToken, + SessionCredentials, + SessionId, + SessionSnapshot, + SubscriberCredentials, +) +from .errors import ( + AudioInputBufferError, + AudioInputCancelledError, + AudioInputClosedError, + AudioInputError, + AudioInputFullError, + ExtensionError, + PocketStationError, + SidecarBackpressureError, + SidecarError, + SidecarProtocolError, + SidecarTimeoutError, + StreamError, + StreamInUseError, + StreamModeError, +) +from .extensions import ( + ExtensionAbiVersion, + ExtensionDescriptor, + ExtensionKind, + ExtensionPort, + ExtensionPortDirection, + NativeExtensionLibrary, + NativeExtensionRegistration, +) +from .graph import ( + AudioCaps, + BackpressurePolicy, + BinaryFormat, + ChannelLayout, + ClockDomain, + Codec, + CopyPolicy, + DeliverySemantics, + DerivedStream, + EdgeContract, + EdgeObservabilityLevel, + Endpoint, + EndpointConfiguration, + EndpointDescriptor, + EventFormat, + LossPolicy, + MediaCaps, + MediaKind, + Multiplicity, + Operator, + OperatorConfiguration, + OperatorInput, + OperatorInstance, + PortDirection, + PortSpec, + SampleFormat, + SignalKind, + SignalSpec, + SourceConfiguration, + SourceInstance, + SourceOutput, + Stem, + TextFormat, +) +from .observations import ( + AudioReentryMetrics, + DerivedRouteMetrics, + EdgeMetrics, + EndpointFailureStage, + EndpointMetrics, + EndpointObservationStage, + EventQueueMetrics, + EventStream, + ExternalSourceMetrics, + LatencyHistogram, + OperatorInputMetrics, + OperatorMetrics, + OperatorWorkerMetrics, + PolledAudioMetrics, + RecordingDiscontinuity, + RecordingDiscontinuityKind, + RecordingState, + RelayPublishOutcome, + RouteLatencyBoundary, + RouteLatencyUnit, + RouteObservationInterval, + SessionEventType, + SessionFailure, + SessionFailureKind, + SessionFinalizationStage, + SessionLifecycleState, + SessionRollbackStage, + SessionTerminalState, + SessionTrace, + SessionTraceConfiguration, + SessionTraceRecorderOutcome, + SessionTraceValidation, + SourceMetrics, + TerminationDisposition, + TypedEdgeMetrics, +) +from .relay import ( + PublisherActivation, + ReceiverActivation, + ReceiverInvitation, + RelayError, + RelayPublisher, + RelayRoute, + RelaySession, + RelayTimeoutError, +) +from .session import ( + AudioBatch, + AudioFrame, + RecordingOutcome, + RecordingStemOutcome, + RouteMetrics, + RunningSession, + Session, + SessionEvent, + SessionMetrics, + StopResult, +) +from .sidecar import ( + SidecarConnection, + SidecarDeadlines, + SidecarHandle, + SidecarMessage, + SidecarMessageKind, + SidecarProcessSpec, + SidecarProtocolLimits, + SidecarReadResult, + SidecarSnapshot, + SidecarState, + SidecarStream, +) +from .signal import ( + STREAM_EOF, + BusSubscription, + EndOfStream, + SignalAudioPayload, + SignalDerivation, + SignalEnvelope, + SignalLineage, + SignalPayload, + SignalReadResult, + SignalSubscriptionMetrics, + SignalTiming, +) +from .sources import ( + DiscoveredSource, + PermissionObservation, + Platform, + ProcessInstanceSelector, + ProcessTreeScope, + SelectorPersistenceScope, + Source, + SourceFailureClass, + SourceIdentityStrength, + SourceKind, + SourceQuery, + SourceRecoveryRequirement, + SourceRuntimeEvent, + SourceRuntimeEventKind, + SourceSelectorKind, + SourceState, + StableSourceId, + application_capture_available, + discover_sources, + microphone_permission_observation, +) +from .streams import AudioStream, SignalStream + +__version__ = "0.1.0" +__all__ = [ + "STREAM_EOF", + "AudioBatch", + "AudioCaps", + "AudioFrame", + "AudioInput", + "AudioInputBufferError", + "AudioInputCancelledError", + "AudioInputClosedError", + "AudioInputConfig", + "AudioInputError", + "AudioInputFullError", + "AudioInputObservations", + "AudioReentryMetrics", + "AudioStream", + "BackpressurePolicy", + "BinaryFormat", + "BusSubscription", + "Capture", + "ChannelLayout", + "ClockDomain", + "Codec", + "ControlClient", + "ControlPlaneError", + "CopyPolicy", + "DeliverySemantics", + "DerivedRouteMetrics", + "DerivedStream", + "DiscoveredSource", + "EdgeContract", + "EdgeMetrics", + "EdgeObservabilityLevel", + "EndOfStream", + "Endpoint", + "EndpointConfiguration", + "EndpointDescriptor", + "EndpointFailureStage", + "EndpointMetrics", + "EndpointObservationStage", + "EventFormat", + "EventQueueMetrics", + "EventStream", + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionError", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "ExternalSourceMetrics", + "IceServer", + "LatencyHistogram", + "LossPolicy", + "MediaCaps", + "MediaKind", + "Multiplicity", + "NativeExtensionLibrary", + "NativeExtensionRegistration", + "Operator", + "OperatorConfiguration", + "OperatorInput", + "OperatorInputMetrics", + "OperatorInstance", + "OperatorMetrics", + "OperatorWorkerMetrics", + "PcmSource", + "PermissionObservation", + "Platform", + "PocketStationError", + "PolledAudioMetrics", + "PortDirection", + "PortSpec", + "ProcessInstanceSelector", + "ProcessTreeScope", + "PublisherActivation", + "ReceiverActivation", + "ReceiverInvitation", + "RecordingDiscontinuity", + "RecordingDiscontinuityKind", + "RecordingOutcome", + "RecordingState", + "RecordingStemOutcome", + "RelayError", + "RelayPublishOutcome", + "RelayPublisher", + "RelayRoute", + "RelaySession", + "RelayTimeoutError", + "RouteLatencyBoundary", + "RouteLatencyUnit", + "RouteMetrics", + "RouteObservationInterval", + "RunningSession", + "SampleFormat", + "SecretToken", + "SelectorPersistenceScope", + "Session", + "SessionCredentials", + "SessionEvent", + "SessionEventType", + "SessionFailure", + "SessionFailureKind", + "SessionFinalizationStage", + "SessionId", + "SessionLifecycleState", + "SessionMetrics", + "SessionRollbackStage", + "SessionSnapshot", + "SessionTerminalState", + "SessionTrace", + "SessionTraceConfiguration", + "SessionTraceRecorderOutcome", + "SessionTraceValidation", + "SidecarBackpressureError", + "SidecarConnection", + "SidecarDeadlines", + "SidecarError", + "SidecarHandle", + "SidecarMessage", + "SidecarMessageKind", + "SidecarProcessSpec", + "SidecarProtocolError", + "SidecarProtocolLimits", + "SidecarReadResult", + "SidecarSnapshot", + "SidecarState", + "SidecarStream", + "SidecarTimeoutError", + "SignalAudioPayload", + "SignalDerivation", + "SignalEnvelope", + "SignalKind", + "SignalLineage", + "SignalPayload", + "SignalReadResult", + "SignalSpec", + "SignalStream", + "SignalSubscriptionMetrics", + "SignalTiming", + "Source", + "SourceConfiguration", + "SourceFailureClass", + "SourceIdentityStrength", + "SourceInstance", + "SourceKind", + "SourceMetrics", + "SourceOutput", + "SourceQuery", + "SourceRecoveryRequirement", + "SourceRuntimeEvent", + "SourceRuntimeEventKind", + "SourceSelectorKind", + "SourceState", + "StableSourceId", + "Stem", + "StopResult", + "StreamError", + "StreamInUseError", + "StreamModeError", + "SubscriberCredentials", + "TerminationDisposition", + "TextFormat", + "TypedEdgeMetrics", + "aio", + "application_capture_available", + "capture", + "discover_sources", + "microphone_permission_observation", +] diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi new file mode 100644 index 0000000..f0a373c --- /dev/null +++ b/python/pocketstation/_native.pyi @@ -0,0 +1,916 @@ +"""Static interface for the PyO3 extension.""" + +from collections.abc import Iterator +from pathlib import Path + +class _ExtensionAbiVersion: + struct_size_bytes: int + abi_major: int + abi_minor: int + +class _NativeExtensionRegistration: + id: str + kind: str + revision: int + generation: int + +class _NativeExtensionLibrary: + canonical_path: Path + registrations: list[_NativeExtensionRegistration] + +def extension_abi_version() -> _ExtensionAbiVersion: ... +def extension_abi_is_compatible( + abi_major: int, + abi_minor: int, + struct_size_bytes: int, +) -> None: ... +def validate_extension_descriptor( + extension_id: str, + kind: str, + revision: int, + generation: int, + abi_major: int, + abi_minor: int, + ports: list[tuple[str, str, bool, str, str, str]], +) -> None: ... + +class Source: + @staticmethod + def application(name: str) -> Source: ... + @staticmethod + def application_bundle_id(bundle_id: str) -> Source: ... + @staticmethod + def application_process_id(process_id: int) -> Source: ... + @staticmethod + def application_stable_id(platform: str, stable_key: str) -> Source: ... + @staticmethod + def application_process_instance( + process_id: int, + platform: str, + stable_key: str, + ) -> Source: ... + @staticmethod + def microphone_default() -> Source: ... + @staticmethod + def microphone_id(device_id: str) -> Source: ... + +class DiscoveredSource: + platform: str + kind: str + stable_key: str + source_id: int + name: str + process_id: int | None + application_id: str | None + device_uid: str | None + state: str + sample_rate_hz: int + channel_count: int + identity_strength: str + selector_persistence_scope: str | None + process_tree_scope: str | None + +def discover_sources( + query_kind: str = "any", + value: str | None = None, +) -> list[DiscoveredSource]: ... +def application_capture_available() -> bool: ... +def microphone_permission_observation() -> str: ... + +class _SignalSpec: + def __init__( + self, + kind: str, + format: str | None = None, + custom_id: str | None = None, + role: str | None = None, + schema: str | None = None, + ) -> None: ... + kind: str + format: str | None + custom_id: str | None + role: str | None + schema: str | None + wire_id: str + is_audio: bool + def is_compatible_with(self, other: _SignalSpec) -> bool: ... + +class _MediaCaps: + def __init__( + self, + kind: str, + format: str | None = None, + sample_rate_hz: int | None = None, + frame_samples: int | None = None, + channel_layout: str | None = None, + ) -> None: ... + kind: str + format: str | None + sample_rate_hz: int | None + frame_samples: int | None + channel_layout: str | None + def is_compatible_with(self, other: _MediaCaps) -> bool: ... + def supports_signal(self, signal: _SignalSpec) -> bool: ... + +class _PortSpec: + def __init__( + self, + name: str, + direction: str, + signal: _SignalSpec, + media: _MediaCaps, + multiplicity: str, + required: bool, + ) -> None: ... + name: str + direction: str + signal: _SignalSpec + media: _MediaCaps + multiplicity: str + required: bool + +class _EdgeContract: + @staticmethod + def realtime_audio() -> _EdgeContract: ... + @staticmethod + def bounded_async() -> _EdgeContract: ... + media: _MediaCaps + clock: str + latency_budget_ms: int | None + jitter_budget_ms: int | None + backpressure: str + delivery: str + loss: str + copy_policy: str + observability: str + max_payload_bytes: int | None + def with_media(self, media: _MediaCaps) -> _EdgeContract: ... + def with_backpressure(self, value: str) -> _EdgeContract: ... + def with_copy_policy(self, value: str) -> _EdgeContract: ... + def with_jitter_budget_ms(self, value: int | None) -> _EdgeContract: ... + def with_max_payload_bytes(self, value: int) -> _EdgeContract: ... + +class BusSubscription: + id: int + session_id: int + route_id: int + signal: _SignalSpec + edge: _EdgeContract + +class _SignalTiming: + source_timestamp_ns: int | None + observed_timestamp_ns: int + session_timestamp_ns: int | None + duration_ns: int | None + +class _SignalLineage: + session_id: int + stream_id: int + source_id: int + clock_id: int + sequence_number: int + source_generation: int + discontinuity_epoch: int + policy_epoch: int + +class _SignalDerivation: + upstream_lineage: _SignalLineage + upstream_timing: _SignalTiming + operator_id: str + operator_revision: int + operator_generation: int + connector_id: int | None + +class _SignalAudioPayload: + samples: memoryview + samples_f32le: bytes + sample_count: int + sample_rate_hz: int + channel_count: int + stream_id: int + source_id: int + sequence_number: int + timestamp_ns: int + sample_format: str + +class _SignalEnvelope: + signal: _SignalSpec + timing: _SignalTiming + lineage: _SignalLineage | None + derivation: _SignalDerivation | None + payload_kind: str + text: str | None + bytes: bytes | None + audio: _SignalAudioPayload | None + +class _SignalRead: + status: str + envelope: _SignalEnvelope | None + error: str | None + +class _SignalSubscriptionMetrics: + capacity_signals: int + max_payload_bytes: int + maximum_buffered_payload_bytes: int + depth_signals: int + peak_depth_signals: int + enqueued_total: int + received_total: int + dropped_total: int + +class _EndpointDescriptor: + def __init__( + self, + node_type_id: str, + operator_id: str, + configuration: dict[str, str], + input_edge: _EdgeContract | None = None, + ) -> None: ... + +class Endpoint: + id: int + session_id: int + connector_id: int | None + +class RelayPublisher: ... + +class OperatorInput: + port_name: str + +class OperatorInstance: + session_id: int + instance_id: int + def input(self, port_name: str) -> OperatorInput: ... + def output(self, port_name: str) -> DerivedStream: ... + +class Stem: + @property + def id(self) -> int: ... + def send(self, endpoint: Endpoint) -> int: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... + def connect(self, input: OperatorInput) -> int: ... + def through( + self, + operator_id: str, + configuration: dict[str, str], + input_port: str | None = None, + output_port: str | None = None, + ) -> DerivedStream: ... + def record(self, stem_name: str) -> Endpoint: ... + def publish(self, publisher: RelayPublisher, bus_id: str) -> int: ... + session_id: int + +class DerivedStream: + session_id: int + operator_instance_id: int + output_port: str | None + def output(self, port_name: str) -> DerivedStream: ... + def connect(self, input: OperatorInput) -> int: ... + def through( + self, + operator_id: str, + configuration: dict[str, str], + input_port: str | None = None, + output_port: str | None = None, + ) -> DerivedStream: ... + def send(self, endpoint: Endpoint) -> int: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... + def reenter_audio(self) -> Stem: ... + +class SourceInstance: + session_id: int + instance_id: int + source_id: int + def output(self, port_name: str) -> SourceOutput: ... + +class SourceOutput: + session_id: int + source_instance_id: int + source_id: int + stream_id: int + output_port: str + def connect(self, input: OperatorInput) -> int: ... + def through( + self, + operator_id: str, + configuration: dict[str, str], + input_port: str | None = None, + output_port: str | None = None, + ) -> DerivedStream: ... + def send(self, endpoint: Endpoint) -> int: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... + def record(self, stem_name: str) -> Endpoint: ... + def publish(self, publisher: RelayPublisher, bus_id: str) -> int: ... + +class AudioFrame: + sample_rate_hz: int + channel_count: int + session_id: int + stream_id: int + source_id: int + stem_id: int + clock_id: int + sequence_num: int + sequence_number: int + timestamp_start_ns: int + duration_ns: int + source_generation: int + discontinuity_epoch: int + permission_epoch: int + endpoint_id: int + connector_id: int + route_id: int + @property + def samples(self) -> memoryview: ... + @property + def samples_f32le(self) -> bytes: ... + @property + def sample_count(self) -> int: ... + @property + def sample_format(self) -> str: ... + def __repr__(self) -> str: ... + +class AudioBatch: + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[AudioFrame]: ... + def __getitem__(self, index: int) -> AudioFrame: ... + def frames(self) -> list[AudioFrame]: ... + +class RecordingDiscontinuity: + stem_id: int + label: str + kind: str + timestamp_start_ns: int + timestamp_end_ns: int + sequence_start: int | None + sequence_end: int | None + +class RecordingStemOutcome: + stem_name: str + frames_written_total: int + stale_frames_total: int + error: str | None + queue_capacity_frames: int + queue_peak_frames: int + frames_delivered_total: int + frames_dropped_total: int + queue_full_drops_total: int + discontinuities_total: int + def discontinuities(self) -> list[RecordingDiscontinuity]: ... + +class RecordingOutcome: + complete: bool + state: str + completed_stems: int + failed_stems: int + session_directory: str + error_code: str | None + def stems(self) -> list[RecordingStemOutcome]: ... + +class StopResult: + success: bool + already_stopped: bool + disposition: str + runtime_worker_panicked: bool + capture_finalization_failures_total: int + operator_finalization_failures_total: int + endpoint_finalization_failures_total: int + runtime_failures_total: int + lineage_failures_total: int + source_send_rejections_total: int + runtime_events_total: int + recording: RecordingOutcome | None + trace: SessionTraceRecorderOutcome | None + trace_error: str | None + terminal_event: SessionEvent | None + def relay_outcomes(self) -> list[RelayPublishOutcome]: ... + def sidecar_outcomes(self) -> list[_SidecarSnapshot]: ... + +class RelayPublishOutcome: + bus_id: str + endpoint_id: int + route_id: int + frames_received_total: int + rtp_packets_sent_total: int + rtp_payload_bytes_sent_total: int + ingress_queue_drops_total: int + publisher_stale_drops_total: int + failures_total: int + error: str | None + +class SessionEvent: + kind: str + lifecycle_state: str | None + session_id: int + stem_id: int | None + endpoint_id: int | None + route_id: int | None + failures_total: int + terminal_state: str | None + source_event_kind: str | None + source_platform: str | None + source_kind: str | None + source_stable_key: str | None + source_source_id: int | None + source_generation: int | None + source_recovery_requirement: str | None + source_failure_operation: str | None + source_failure_class: str | None + source_platform_status_code: int | None + source_backend_class: str | None + def failures(self) -> list[_SessionFailure]: ... + +class _SessionFailure: + kind: str + stage: str | None + operation: str | None + error_class: str | None + component: str | None + message: str | None + stem_id: int | None + route_id: int | None + endpoint_id: int | None + operator_instance_id: int | None + sidecar_id: int | None + source_event_kind: str | None + source_platform: str | None + source_kind: str | None + source_stable_key: str | None + source_source_id: int | None + source_generation: int | None + source_recovery_requirement: str | None + source_failure_operation: str | None + source_failure_class: str | None + source_platform_status_code: int | None + source_backend_class: str | None + +class _EdgeMetrics: + queue_capacity_frames: int + queue_depth_frames: int + queue_peak_frames: int + frames_enqueued_total: int + frames_delivered_total: int + frames_dropped_total: int + overruns_total: int + receiver_unavailable_drops_total: int + queue_full_drops_total: int + shared_reference_exhausted_drops_total: int + branch_pool_exhausted_drops_total: int + invalid_copy_policy_drops_total: int + freeze_failed_drops_total: int + discontinuities_total: int + source_identity_discontinuities_total: int + sequence_discontinuities_total: int + timestamp_discontinuities_total: int + lineage_epoch_discontinuities_total: int + manually_reported_discontinuities_total: int + enqueue_to_receive_samples_total: int + enqueue_to_receive_invalid_order_total: int + enqueue_to_receive_p50_ns: int + enqueue_to_receive_p95_ns: int + enqueue_to_receive_p99_ns: int + enqueue_to_receive_max_ns: int + source_timestamp_to_receive_samples_total: int + source_timestamp_to_receive_missing_total: int + source_timestamp_to_receive_future_total: int + source_timestamp_to_receive_p50_ns: int + source_timestamp_to_receive_p95_ns: int + source_timestamp_to_receive_p99_ns: int + source_timestamp_to_receive_max_ns: int + worker_failures_total: int + shutdown_discarded_total: int + +class RouteMetrics: + route_id: int + endpoint_id: int + endpoint_observation_stage: str + queue_capacity_frames: int + queue_depth_frames: int + queue_peak_frames: int + frames_enqueued_total: int + frames_attempted_total: int + frames_delivered_total: int + frames_dropped_total: int + queue_full_drops_total: int + overruns_total: int + receiver_unavailable_drops_total: int + shared_reference_exhausted_drops_total: int + branch_pool_exhausted_drops_total: int + invalid_copy_policy_drops_total: int + freeze_failed_drops_total: int + discontinuities_total: int + source_identity_discontinuities_total: int + sequence_discontinuities_total: int + timestamp_discontinuities_total: int + lineage_epoch_discontinuities_total: int + manually_reported_discontinuities_total: int + enqueue_to_receive_samples_total: int + enqueue_to_receive_invalid_order_total: int + enqueue_to_receive_p50_ns: int + enqueue_to_receive_p95_ns: int + enqueue_to_receive_p99_ns: int + enqueue_to_receive_max_ns: int + source_timestamp_to_receive_samples_total: int + source_timestamp_to_receive_missing_total: int + source_timestamp_to_receive_future_total: int + source_timestamp_to_receive_p50_ns: int + source_timestamp_to_receive_p95_ns: int + source_timestamp_to_receive_p99_ns: int + source_timestamp_to_receive_max_ns: int + worker_failures_total: int + shutdown_discarded_total: int + endpoint_frames_received_total: int + endpoint_frames_delivered_total: int + endpoint_frames_dropped_total: int + endpoint_discontinuities_total: int + endpoint_failures_total: int + endpoint_finalization_failures_total: int + drop_observation_interval: str + drop_rate_pct: float + source_latency_boundary: str + source_latency_unit: str + +class _SessionSourceMetrics: + stem_id: int + callback_buffers_total: int + capture_frames_enqueued_total: int + capture_pool_exhausted_total: int + capture_dispatch_queue_full_total: int + capture_invalid_buffer_total: int + capture_oversized_buffer_total: int + capture_stream_errors_total: int + capture_timestamp_epoch_clamps_total: int + frame_stream_delivered_frames_total: int + frame_stream_dropped_newest_frames_total: int + frames_discarded_before_start_total: int + runtime_event_capacity_count: int + runtime_event_maximum_event_owned_bytes: int + runtime_event_maximum_buffered_owned_bytes: int + runtime_event_depth_count: int + runtime_event_depth_owned_bytes: int + runtime_event_peak_depth_owned_bytes: int + runtime_events_enqueued_total: int + runtime_events_dropped_total: int + runtime_events_dropped_oversized_total: int + ingress_queue_capacity_frames: int + ingress_queue_depth_frames: int + ingress_queue_peak_frames: int + ingress_frames_enqueued_total: int + ingress_frames_delivered_total: int + ingress_frames_rejected_full_total: int + ingress_frames_rejected_cancelled_total: int + ingress_frames_discarded_total: int + +class _ExternalSourceMetrics: + source_instance_id: int + source_id: int + emitted_total: int + dropped_total: int + failure_total: int + cancellation_total: int + discontinuity_total: int + recovery_total: int + policy_change_total: int + ready: bool + joined: bool + +class _OperatorInputMetrics: + port_name: str + edge: _EdgeMetrics + +class _OperatorWorkerMetrics: + input_attempted_total: int + input_dropped_total: int + processed_total: int + output_emitted_total: int + output_dropped_total: int + output_nonterminal_total: int + output_terminal_total: int + process_failure_total: int + timeout_total: int + cancellation_total: int + graceful_finish_total: int + idle_poll_total: int + ready: bool + joined: bool + +class _OperatorMetrics: + operator_instance_id: int + input_edge: _EdgeMetrics + worker: _OperatorWorkerMetrics + finalization_failures_total: int + def input_ports(self) -> list[_OperatorInputMetrics]: ... + +class _TypedEdgeMetrics: + capacity_signals: int + max_payload_bytes: int + maximum_buffered_payload_bytes: int + depth_signals: int + peak_depth_signals: int + enqueued_total: int + received_total: int + dropped_total: int + +class _DerivedRouteMetrics: + route_id: int + endpoint_id: int + output: _TypedEdgeMetrics + endpoint_observation_stage: str + endpoint_frames_received_total: int + endpoint_frames_delivered_total: int + endpoint_frames_dropped_total: int + endpoint_discontinuities_total: int + endpoint_failures_total: int + endpoint_finalization_failures_total: int + +class _AudioReentryMetrics: + operator_instance_id: int + stem_id: int + queue_capacity_signals: int + queue_depth_signals: int + queue_peak_signals: int + signals_enqueued_total: int + signals_received_total: int + signals_dropped_total: int + pool_slots: int + frame_capacity_samples: int + maximum_buffered_audio_bytes: int + normalized_total: int + invalid_total: int + shared_audio_rejected_total: int + pool_exhausted_total: int + ingress_rejected_total: int + audio_frames_enqueued_total: int + cancellation_total: int + joined: bool + +class SessionMetrics: + event_capacity_count: int + event_maximum_event_owned_bytes: int + event_maximum_buffered_owned_bytes: int + event_depth_count: int + event_depth_owned_bytes: int + event_peak_depth_count: int + event_peak_depth_owned_bytes: int + events_enqueued_total: int + events_dropped_total: int + events_dropped_oversized_total: int + event_receiver_closed_total: int + audio_registered_endpoints: int + audio_queue_capacity_frames: int + audio_queue_depth_frames: int + audio_queue_peak_frames: int + audio_queue_depth_invariant_failures_total: int + audio_frames_received_total: int + audio_frames_delivered_total: int + audio_queue_full_drops_total: int + audio_invalid_ownership_drops_total: int + audio_lease_capacity_count: int + audio_outstanding_leases: int + audio_lease_exhausted_total: int + audio_batches_polled_total: int + audio_frames_polled_total: int + source_count: int + external_source_count: int + route_count: int + operator_count: int + derived_route_count: int + audio_reentry_count: int + routes: list[RouteMetrics] + sources: list[_SessionSourceMetrics] + external_sources: list[_ExternalSourceMetrics] + operators: list[_OperatorMetrics] + derived_routes: list[_DerivedRouteMetrics] + audio_reentries: list[_AudioReentryMetrics] + +class SessionTraceRecorderOutcome: + path: str + records_attempted_total: int + records_enqueued_total: int + records_dropped_total: int + records_written_total: int + rolling_hash: int + complete: bool + +class _SessionTraceValidation: + session_id: int + lifecycle: list[str] + terminal_state: str + source_failures_total: int + endpoint_failures_total: int + rollback_failures_total: int + finalization_failures_total: int + records_validated_total: int + +class SessionTrace: + @staticmethod + def read(path: Path) -> SessionTrace: ... + session_id: int + outcome: SessionTraceRecorderOutcome + records_total: int + def validate(self) -> _SessionTraceValidation: ... + +class _SidecarProcessSpec: + def __init__( + self, + id: int, + program: Path, + arguments: list[str] = [], + configuration: bytes = b"", + data_capacity_messages: int = 64, + max_signal_id_bytes: int = 256, + max_role_bytes: int = 256, + max_schema_bytes: int = 1024, + max_payload_bytes: int = 1048576, + ready_timeout_ms: int = 5000, + processing_timeout_ms: int = 5000, + shutdown_timeout_ms: int = 2000, + ) -> None: ... + id: int + program: Path + arguments: list[str] + configuration: bytes + data_capacity_messages: int + max_signal_id_bytes: int + max_role_bytes: int + max_schema_bytes: int + max_payload_bytes: int + ready_timeout_ms: int + processing_timeout_ms: int + shutdown_timeout_ms: int + +class _SidecarMessage: + def __init__( + self, + *, + kind: str, + stream_id: int, + sequence_number: int, + timestamp_ns: int, + signal_id: str, + payload: bytes, + terminal: bool = False, + role: str | None = None, + schema: str | None = None, + ) -> None: ... + kind: str + terminal: bool + stream_id: int + sequence_number: int + timestamp_ns: int + signal_id: str + role: str | None + schema: str | None + payload: bytes + +class _SidecarRead: + status: str + message: _SidecarMessage | None + +class _SidecarSnapshot: + sidecar_id: int + state: str + state_transitions: int + data_enqueued_total: int + data_received_total: int + data_dropped_total: int + protocol_failures_total: int + timeouts_total: int + forced_kills_total: int + reaps_total: int + def visited(self, state: str) -> bool: ... + +class _SessionStartCancellation: + def __init__(self) -> None: ... + def request(self) -> None: ... + def is_requested(self) -> bool: ... + +class _AudioInputObservations: + capacity_frames: int + buffer_slots: int + available_buffers: int + accepted_total: int + full_total: int + invalid_total: int + cancelled: bool + closed: bool + +class _AudioInput: + source_id: int + stream_id: int + output: SourceOutput + def try_write( + self, + samples: object, + *, + discontinuity: bool = False, + ) -> None: ... + def close(self) -> None: ... + def observations(self) -> _AudioInputObservations: ... + +class Session: + def __init__( + self, + *, + recording_root: Path | None = None, + trace_path: Path | None = None, + trace_capacity_records: int = 256, + sample_rate_hz: int = 48_000, + channels: int = 1, + ) -> None: ... + @staticmethod + def conformance( + recording_root: Path, + trace_path: Path | None = None, + trace_capacity_records: int = 256, + ) -> Session: ... + id: int + def capture(self, source: Source) -> Stem: ... + def audio_input( + self, + sample_rate_hz: int, + channels: int, + capacity_frames: int = 8, + frame_samples_per_channel: int = 480, + ) -> _AudioInput: ... + def pcm_source( + self, + sample_rate_hz: int, + channels: int, + capacity_frames: int = 8, + frame_samples_per_channel: int = 480, + ) -> _AudioInput: ... + def source( + self, + source_type_id: str, + configuration: dict[str, str], + ) -> SourceInstance: ... + def operator( + self, + operator_id: str, + configuration: dict[str, str], + ) -> OperatorInstance: ... + def endpoint(self, descriptor: _EndpointDescriptor) -> Endpoint: ... + def connector( + self, + operator_id: str, + configuration: dict[str, str], + ) -> Endpoint: ... + def browser(self, receiver_uri: str) -> Endpoint: ... + def polled_audio(self) -> Endpoint: ... + def load_native_extension_library( + self, + path: Path, + ) -> _NativeExtensionLibrary: ... + def register_sidecar(self, spec: _SidecarProcessSpec) -> int: ... + def relay( + self, + relay_url: str, + relay_session_id: str, + source_token: str, + ) -> RelayPublisher: ... + def subscribe_derived( + self, + stream: DerivedStream, + signal: _SignalSpec, + edge: _EdgeContract, + ) -> BusSubscription: ... + def subscribe_source_output( + self, + stream: SourceOutput, + signal: _SignalSpec, + edge: _EdgeContract, + ) -> BusSubscription: ... + def start( + self, + cancellation: _SessionStartCancellation | None = None, + ) -> RunningSession: ... + +class RunningSession: + session_id: int + def poll_audio(self) -> AudioBatch | None: ... + def wait_audio(self, timeout_ms: int = 100) -> AudioBatch | None: ... + def poll_event(self) -> SessionEvent | None: ... + def wait_event(self, timeout_ms: int = 100) -> SessionEvent | None: ... + def poll_signal(self, subscription: BusSubscription) -> _SignalRead: ... + def wait_signal( + self, + subscription: BusSubscription, + timeout_ms: int = 100, + ) -> _SignalRead: ... + def close_signal(self, subscription: BusSubscription) -> None: ... + def signal_metrics( + self, + subscription: BusSubscription, + ) -> _SignalSubscriptionMetrics: ... + def send_sidecar(self, sidecar_id: int, message: _SidecarMessage) -> None: ... + def poll_sidecar(self, sidecar_id: int) -> _SidecarRead: ... + def wait_sidecar( + self, + sidecar_id: int, + timeout_ms: int = 100, + ) -> _SidecarRead: ... + def sidecar_snapshot(self, sidecar_id: int) -> _SidecarSnapshot: ... + def metrics(self) -> SessionMetrics: ... + def stop(self) -> StopResult: ... + def cancel(self) -> StopResult: ... diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py new file mode 100644 index 0000000..fa36a41 --- /dev/null +++ b/python/pocketstation/aio/__init__.py @@ -0,0 +1,50 @@ +"""Asyncio PocketStation SDK surface.""" + +from .audio_input import AudioInput, PcmSource +from .capture import Capture, capture +from .control import ControlClient +from .extensions import ( + ExtensionAbiVersion, + ExtensionDescriptor, + ExtensionKind, + ExtensionPort, + ExtensionPortDirection, + NativeExtensionLibrary, + NativeExtensionRegistration, +) +from .observations import EventStream +from .relay import RelaySession +from .session import RunningSession, Session +from .sidecar import SidecarConnection, SidecarStream +from .sources import ( + application_capture_available, + discover_sources, + microphone_permission_observation, +) +from .streams import AudioStream, SignalStream + +__all__ = [ + "AudioInput", + "AudioStream", + "Capture", + "ControlClient", + "EventStream", + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "NativeExtensionLibrary", + "NativeExtensionRegistration", + "PcmSource", + "RelaySession", + "RunningSession", + "Session", + "SidecarConnection", + "SidecarStream", + "SignalStream", + "application_capture_available", + "capture", + "discover_sources", + "microphone_permission_observation", +] diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py new file mode 100644 index 0000000..e715589 --- /dev/null +++ b/python/pocketstation/aio/audio_input.py @@ -0,0 +1,70 @@ +"""Asyncio projection of bounded application-owned PCM input.""" + +from __future__ import annotations + +import asyncio + +from ..audio_input import ( + AudioInputConfig, + AudioInputObservations, +) +from ..audio_input import ( + PcmSource as SyncPcmSource, +) +from ..graph import SourceOutput + + +class PcmSource: + """Async writer over the same native Session-owned PCM source.""" + + def __init__(self, source: SyncPcmSource) -> None: + self._source = source + + @property + def config(self) -> AudioInputConfig: + return self._source.config + + @property + def source_id(self) -> int: + return self._source.source_id + + @property + def stream_id(self) -> int: + return self._source.stream_id + + @property + def output(self) -> SourceOutput: + return self._source.output + + async def try_write( + self, + samples: object, + *, + discontinuity: bool = False, + ) -> None: + await asyncio.to_thread( + self._source.try_write, + samples, + discontinuity=discontinuity, + ) + + async def close(self) -> None: + await asyncio.to_thread(self._source.close) + + async def observations(self) -> AudioInputObservations: + return await asyncio.to_thread(self._source.observations) + + +class AudioInput(PcmSource): + """Intent-first asyncio input over the canonical bounded native source.""" + + async def write( + self, + samples: object, + *, + discontinuity: bool = False, + ) -> None: + await self.try_write(samples, discontinuity=discontinuity) + + +__all__ = ["AudioInput", "PcmSource"] diff --git a/python/pocketstation/aio/capture.py b/python/pocketstation/aio/capture.py new file mode 100644 index 0000000..91b2019 --- /dev/null +++ b/python/pocketstation/aio/capture.py @@ -0,0 +1,177 @@ +"""High-signal asyncio recipe over the explicit Session API.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path +from types import TracebackType + +from .._native import AudioBatch +from ..graph import Stem +from ..observations import ( + RecordingOutcome, + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from ..sources import Source +from .observations import EventStream +from .session import RunningSession, Session +from .streams import AudioStream + + +class Capture: + """One application and optional microphone captured as independent stems.""" + + def __init__( + self, + *, + application: str, + microphone: bool | str = True, + record_to: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, + ) -> None: + if not application.strip(): + raise ValueError("application must not be empty") + if not isinstance(microphone, (bool, str)): + raise TypeError("microphone must be True, False, or a device ID") + if isinstance(microphone, str) and not microphone.strip(): + raise ValueError("microphone device ID must not be empty") + + self._application_name = application + self._microphone = microphone + self._record_to = None if record_to is None else Path(record_to) + self._trace = trace + self._running: RunningSession | None = None + self._entered = False + self._declare() + + def _declare(self) -> None: + session = ( + Session(recording_root=self._record_to) + if self._trace is None + else Session(recording_root=self._record_to, trace=self._trace) + ) + application = session.capture(Source.application(self._application_name)) + microphone: Stem | None = None + if self._microphone is True: + microphone = session.capture(Source.microphone_default()) + elif isinstance(self._microphone, str): + microphone = session.capture(Source.microphone_id(self._microphone)) + + audio = session.polled_audio() + self.application_route_id = application.send(audio) + self.microphone_route_id = ( + None if microphone is None else microphone.send(audio) + ) + if self._record_to is not None: + application.record("application") + if microphone is not None: + microphone.record("microphone") + + self.session = session + self.application_stem = application + self.microphone_stem = microphone + + @property + def is_running(self) -> bool: + return self._running is not None and not self._running.is_stopped + + @property + def stop_result(self) -> StopResult | None: + return None if self._running is None else self._running.stop_result + + @property + def recording_outcome(self) -> RecordingOutcome | None: + result = self.stop_result + return None if result is None else result.recording + + @property + def audio(self) -> AudioStream: + """Frame-first bounded audio from the running native Session.""" + return self._require_running().audio + + @property + def events(self) -> EventStream: + """Async lifecycle and failure events from the native Session.""" + return self._require_running().events + + async def start(self) -> Capture: + if self._running is not None: + raise RuntimeError("Capture has already started") + self._running = await self.session.start() + return self + + async def poll_audio(self) -> AudioBatch | None: + return await self._require_running().poll_audio() + + async def wait_audio(self, *, timeout_ms: int = 100) -> AudioBatch | None: + return await self._require_running().wait_audio(timeout_ms=timeout_ms) + + def audio_batches( + self, + *, + wait_timeout_ms: int = 100, + ) -> AsyncIterator[AudioBatch]: + return self._require_running().audio_batches(wait_timeout_ms=wait_timeout_ms) + + async def poll_event(self) -> SessionEvent | None: + return await self._require_running().poll_event() + + async def wait_event(self, *, timeout_ms: int = 100) -> SessionEvent | None: + return await self._require_running().wait_event(timeout_ms=timeout_ms) + + async def metrics(self) -> SessionMetrics: + return await self._require_running().metrics() + + async def stop(self) -> StopResult: + return await self._require_started().stop() + + async def aclose(self) -> None: + if self._running is not None: + await self._running.aclose() + + async def __aenter__(self) -> Capture: + if self._entered: + raise RuntimeError("Capture context cannot be entered twice") + self._entered = True + return await self.start() + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + def _require_started(self) -> RunningSession: + if self._running is None: + raise RuntimeError("Capture has not started") + return self._running + + def _require_running(self) -> RunningSession: + running = self._require_started() + if running.is_stopped: + raise RuntimeError("Capture has stopped") + return running + + +def capture( + *, + application: str, + microphone: bool | str = True, + record_to: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, +) -> Capture: + """Declare a concise app+mic recipe backed by one native Rust Session.""" + return Capture( + application=application, + microphone=microphone, + record_to=record_to, + trace=trace, + ) + + +__all__ = ["Capture", "capture"] diff --git a/python/pocketstation/aio/control.py b/python/pocketstation/aio/control.py new file mode 100644 index 0000000..2a7abd7 --- /dev/null +++ b/python/pocketstation/aio/control.py @@ -0,0 +1,223 @@ +"""Typed asyncio client for the PocketStation control-plane Session API.""" + +from __future__ import annotations + +import json +from types import TracebackType +from typing import Any +from urllib.parse import quote, urljoin + +import httpx + +from ..control import ( + _MAX_ERROR_BODY_BYTES, + _MAX_JSON_BODY_BYTES, + ControlPlaneError, + SecretToken, + SessionCredentials, + SessionId, + SessionSnapshot, + SubscriberCredentials, + _normalize_base_url, + _session_credentials, + _session_snapshot, + _subscriber_credentials, +) + + +class ControlClient: + """Reusable, bounded asyncio HTTP client for Session lifecycle operations.""" + + def __init__( + self, + control_plane_url: str, + *, + timeout_seconds: float | None = 10.0, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self.control_plane_url = _normalize_base_url(control_plane_url) + self._timeout_seconds = timeout_seconds + self._owns_http_client = http_client is None + self._http_client = http_client or httpx.AsyncClient(timeout=timeout_seconds) + self._closed = False + + async def create_session( + self, + *, + timeout_seconds: float | None = None, + ) -> SessionCredentials: + payload = await self._json_request( + "POST", + "v1/sessions", + expected_status=201, + timeout_seconds=timeout_seconds, + ) + return _session_credentials(payload) + + async def session( + self, + session_id: str | SessionId, + *, + timeout_seconds: float | None = None, + ) -> SessionSnapshot: + identifier = SessionId(str(session_id)) + payload = await self._json_request( + "GET", + f"v1/sessions/{quote(identifier, safe='')}", + expected_status=200, + timeout_seconds=timeout_seconds, + ) + return _session_snapshot(payload) + + async def issue_subscriber_credentials( + self, + session_id: str | SessionId, + *, + timeout_seconds: float | None = None, + ) -> SubscriberCredentials: + identifier = SessionId(str(session_id)) + payload = await self._json_request( + "POST", + f"v1/sessions/{quote(identifier, safe='')}/subscribe", + expected_status=200, + timeout_seconds=timeout_seconds, + ) + return _subscriber_credentials(payload) + + async def delete_session( + self, + session_id: str | SessionId, + source_token: SecretToken, + *, + timeout_seconds: float | None = None, + ) -> None: + identifier = SessionId(str(session_id)) + await self._request( + "DELETE", + f"v1/sessions/{quote(identifier, safe='')}", + expected_status=204, + timeout_seconds=timeout_seconds, + authorization=source_token, + expect_json=False, + ) + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + if self._owns_http_client: + await self._http_client.aclose() + + async def __aenter__(self) -> ControlClient: + if self._closed: + raise RuntimeError("ControlClient has closed") + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + async def _json_request( + self, + method: str, + path: str, + *, + expected_status: int, + timeout_seconds: float | None, + ) -> dict[str, Any]: + return await self._request( + method, + path, + expected_status=expected_status, + timeout_seconds=timeout_seconds, + authorization=None, + expect_json=True, + ) + + async def _request( + self, + method: str, + path: str, + *, + expected_status: int, + timeout_seconds: float | None, + authorization: SecretToken | None, + expect_json: bool, + ) -> dict[str, Any]: + if self._closed: + raise RuntimeError("ControlClient has closed") + headers = {} + redacted_values: tuple[str, ...] = () + if authorization is not None: + exposed = authorization.expose_secret() + headers["Authorization"] = f"Bearer {exposed}" + redacted_values = (exposed,) + timeout = self._timeout_seconds if timeout_seconds is None else timeout_seconds + try: + async with self._http_client.stream( + method, + urljoin(self.control_plane_url, path), + headers=headers, + timeout=timeout, + ) as response: + if response.status_code != expected_status: + body = await _read_bounded( + response.aiter_bytes(), + _MAX_ERROR_BODY_BYTES, + ) + detail = body.decode("utf-8", errors="replace") + for value in redacted_values: + detail = detail.replace(value, "[redacted]") + raise ControlPlaneError( + f"control-plane returned HTTP {response.status_code}: {detail}", + "control.http_status", + status_code=response.status_code, + ) + if not expect_json: + return {} + body = await _read_bounded( + response.aiter_bytes(), + _MAX_JSON_BODY_BYTES, + ) + except ControlPlaneError: + raise + except httpx.HTTPError as error: + raise ControlPlaneError( + f"control-plane request failed: {error}", + "control.request", + ) from error + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ControlPlaneError( + f"control-plane response could not be decoded: {error}", + "control.response_decode", + ) from error + if not isinstance(payload, dict): + raise ControlPlaneError( + "control-plane response must be a JSON object", + "control.response_decode", + ) + return payload + + +async def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: + body = bytearray() + async for chunk in chunks: + remaining = limit_bytes + 1 - len(body) + if remaining <= 0: + break + body.extend(chunk[:remaining]) + if len(body) > limit_bytes: + raise ControlPlaneError( + f"control-plane response exceeds {limit_bytes} bytes", + "control.response_too_large", + ) + return bytes(body) + + +__all__ = ["ControlClient"] diff --git a/python/pocketstation/aio/extensions.py b/python/pocketstation/aio/extensions.py new file mode 100644 index 0000000..a28ea74 --- /dev/null +++ b/python/pocketstation/aio/extensions.py @@ -0,0 +1,21 @@ +"""Async namespace parity for immutable compiled-extension declarations.""" + +from ..extensions import ( + ExtensionAbiVersion, + ExtensionDescriptor, + ExtensionKind, + ExtensionPort, + ExtensionPortDirection, + NativeExtensionLibrary, + NativeExtensionRegistration, +) + +__all__ = [ + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "NativeExtensionLibrary", + "NativeExtensionRegistration", +] diff --git a/python/pocketstation/aio/observations.py b/python/pocketstation/aio/observations.py new file mode 100644 index 0000000..dbeb33a --- /dev/null +++ b/python/pocketstation/aio/observations.py @@ -0,0 +1,77 @@ +"""Asyncio lifecycle and failure observations from a running Session.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable + +from ..observations import SessionEvent +from ..streams import ( + _DEFAULT_ITERATION_TIMEOUT_SECONDS, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + + +class EventStream: + """Exclusive asyncio view over the native bounded Session event queue.""" + + def __init__( + self, + *, + poll_event: Callable[[], Awaitable[SessionEvent | None]], + wait_event: Callable[[int], Awaitable[SessionEvent | None]], + is_closed: Callable[[], bool], + ) -> None: + self._poll_event = poll_event + self._wait_event = wait_event + self._is_closed = is_closed + self._state = _ReaderState() + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._is_closed() + + async def poll(self) -> SessionEvent | None: + token = self._state.claim("event_read") + try: + return None if self.is_closed else await self._poll_event() + finally: + self._state.release(token) + + async def read(self, *, timeout_s: float = 1.0) -> SessionEvent | None: + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("event_read") + try: + return None if self.is_closed else await self._wait_event(timeout_ms) + finally: + self._state.release(token) + + def __aiter__(self) -> AsyncIterator[SessionEvent]: + return self.iter_events() + + def iter_events( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[SessionEvent]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[SessionEvent]: + token = self._state.claim("events") + try: + while not self.is_closed: + event = await self._wait_event(timeout_ms) + if event is not None: + yield event + finally: + self._state.release(token) + + return iterate() + + +__all__ = ["EventStream"] diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py new file mode 100644 index 0000000..71aaf0a --- /dev/null +++ b/python/pocketstation/aio/relay.py @@ -0,0 +1,353 @@ +"""Asyncio control composition for the real PocketStation relay services.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Callable +from time import monotonic +from types import TracebackType +from typing import TYPE_CHECKING, Any +from urllib.parse import quote, urljoin + +import httpx + +from ..control import SecretToken, SessionCredentials, SessionId, SessionSnapshot +from ..errors import _native_call +from ..relay import ( + _MAX_RELAY_RESPONSE_BYTES, + PublisherActivation, + ReceiverActivation, + ReceiverInvitation, + RelayError, + RelayPublisher, + RelayTimeoutError, + _bounded_request_timeout, + _normalize_relay_url, + _receiver_invitation, + _validate_optional_timeout, + _validate_wait, +) +from .control import ControlClient + +if TYPE_CHECKING: + from .session import Session + + +class RelaySession: + """Async owner of one remote Session and bounded control clients.""" + + def __init__( + self, + *, + relay_url: str, + credentials: SessionCredentials, + control: ControlClient, + relay_http: httpx.AsyncClient, + owns_control: bool, + owns_relay_http: bool, + request_timeout_seconds: float | None, + ) -> None: + self.relay_url = _normalize_relay_url(relay_url) + self.credentials = credentials + self._control = control + self._relay_http = relay_http + self._owns_control = owns_control + self._owns_relay_http = owns_relay_http + self._request_timeout_seconds = request_timeout_seconds + self._publisher_activation: PublisherActivation | None = None + self._invitation: ReceiverInvitation | None = None + self._receiver_activation: ReceiverActivation | None = None + self._closed = False + + @classmethod + async def create( + cls, + *, + control_plane_url: str, + relay_url: str, + request_timeout_seconds: float | None = 10.0, + control_client: ControlClient | None = None, + relay_http_client: httpx.AsyncClient | None = None, + ) -> RelaySession: + _validate_optional_timeout(request_timeout_seconds, "request_timeout_seconds") + normalized_relay_url = _normalize_relay_url(relay_url) + owns_control = control_client is None + owns_relay_http = relay_http_client is None + control = control_client or ControlClient( + control_plane_url, + timeout_seconds=request_timeout_seconds, + ) + relay_http = relay_http_client or httpx.AsyncClient( + timeout=request_timeout_seconds, + ) + try: + credentials = await control.create_session( + timeout_seconds=request_timeout_seconds, + ) + except BaseException: + if owns_relay_http: + await relay_http.aclose() + if owns_control: + await control.aclose() + raise + return cls( + relay_url=normalized_relay_url, + credentials=credentials, + control=control, + relay_http=relay_http, + owns_control=owns_control, + owns_relay_http=owns_relay_http, + request_timeout_seconds=request_timeout_seconds, + ) + + @property + def session_id(self) -> SessionId: + return self.credentials.session_id + + @property + def publisher_activation(self) -> PublisherActivation | None: + return self._publisher_activation + + @property + def invitation(self) -> ReceiverInvitation | None: + return self._invitation + + @property + def receiver_activation(self) -> ReceiverActivation | None: + return self._receiver_activation + + def publisher(self, session: Session) -> RelayPublisher: + """Declare the same native relay endpoint used by the sync namespace.""" + self._require_open() + native = _native_call( + lambda: session._native.relay( + self.relay_url, + str(self.session_id), + self.credentials.source_token.expose_secret(), + ) + ) + return RelayPublisher( + native, + relay_url=self.relay_url, + session_id=self.session_id, + ) + + async def wait_for_publisher( + self, + *, + timeout_seconds: float = 10.0, + poll_interval_seconds: float = 0.1, + ) -> PublisherActivation: + self._require_open() + snapshot = await self._wait_for_snapshot( + lambda value: value.source_active, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + timeout_code="relay.publisher_timeout", + timeout_message="relay publisher did not become active before the deadline", + ) + activation = PublisherActivation(snapshot) + self._publisher_activation = activation + return activation + + async def create_receiver_invitation(self) -> ReceiverInvitation: + self._require_open() + if self._publisher_activation is None: + raise RelayError( + "wait_for_publisher() must succeed before creating an invitation", + "relay.publisher_not_active", + ) + payload = await _relay_json_request( + self._relay_http, + relay_url=self.relay_url, + method="POST", + path=(f"v1/sessions/{quote(str(self.session_id), safe='')}/invitations"), + expected_status=201, + authorization=self.credentials.source_token, + timeout_seconds=self._request_timeout_seconds, + ) + invitation = _receiver_invitation(payload, self.session_id) + self._invitation = invitation + return invitation + + async def wait_for_publisher_and_invitation( + self, + *, + timeout_seconds: float = 10.0, + poll_interval_seconds: float = 0.1, + ) -> ReceiverInvitation: + await self.wait_for_publisher( + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + return await self.create_receiver_invitation() + + async def wait_for_receiver( + self, + *, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 0.1, + ) -> ReceiverActivation: + self._require_open() + if self._invitation is None: + raise RelayError( + "create_receiver_invitation() must succeed before waiting " + "for a receiver", + "relay.invitation_missing", + ) + snapshot = await self._wait_for_snapshot( + lambda value: value.source_active and value.subscription_count > 0, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + timeout_code="relay.receiver_timeout", + timeout_message="relay receiver did not become active before the deadline", + ) + activation = ReceiverActivation(snapshot) + self._receiver_activation = activation + return activation + + async def aclose(self, *, delete_remote_session: bool = True) -> None: + if self._closed: + return + self._closed = True + try: + if delete_remote_session: + await self._control.delete_session( + self.session_id, + self.credentials.source_token, + timeout_seconds=self._request_timeout_seconds, + ) + finally: + if self._owns_relay_http: + await self._relay_http.aclose() + if self._owns_control: + await self._control.aclose() + + async def __aenter__(self) -> RelaySession: + self._require_open() + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + def __repr__(self) -> str: + return ( + "aio.RelaySession(" + f"session_id={self.session_id!r}, relay_url={self.relay_url!r}, " + "credentials=[redacted])" + ) + + async def _wait_for_snapshot( + self, + predicate: Callable[[SessionSnapshot], bool], + *, + timeout_seconds: float, + poll_interval_seconds: float, + timeout_code: str, + timeout_message: str, + ) -> SessionSnapshot: + _validate_wait(timeout_seconds, poll_interval_seconds) + deadline = monotonic() + timeout_seconds + while True: + remaining = deadline - monotonic() + if remaining <= 0: + raise RelayTimeoutError(timeout_message, timeout_code) + snapshot = await self._control.session( + self.session_id, + timeout_seconds=_bounded_request_timeout( + remaining, + self._request_timeout_seconds, + ), + ) + if predicate(snapshot): + return snapshot + await asyncio.sleep( + min(poll_interval_seconds, max(0.0, deadline - monotonic())) + ) + + def _require_open(self) -> None: + if self._closed: + raise RelayError("RelaySession has closed", "relay.closed") + + +async def _relay_json_request( + client: httpx.AsyncClient, + *, + relay_url: str, + method: str, + path: str, + expected_status: int, + authorization: SecretToken, + timeout_seconds: float | None, +) -> dict[str, Any]: + exposed = authorization.expose_secret() + try: + async with client.stream( + method, + urljoin(relay_url + "/", path), + headers={"Authorization": f"Bearer {exposed}"}, + timeout=timeout_seconds, + ) as response: + body = await _read_bounded( + response.aiter_bytes(), + _MAX_RELAY_RESPONSE_BYTES, + ) + if response.status_code != expected_status: + detail = body.decode("utf-8", errors="replace").replace( + exposed, + "[redacted]", + ) + raise RelayError( + f"relay returned HTTP {response.status_code}: {detail}", + "relay.http_status", + ) + except RelayError: + raise + except httpx.HTTPError as error: + message = str(error).replace(exposed, "[redacted]") + raise RelayError(f"relay request failed: {message}", "relay.request") from error + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RelayError( + f"relay response could not be decoded: {error}", + "relay.response_decode", + ) from error + if not isinstance(payload, dict): + raise RelayError( + "relay response must be a JSON object", + "relay.response_decode", + ) + return payload + + +async def _read_bounded(chunks: AsyncIterator[bytes], limit_bytes: int) -> bytes: + body = bytearray() + async for chunk in chunks: + remaining = limit_bytes + 1 - len(body) + if remaining <= 0: + break + body.extend(chunk[:remaining]) + if len(body) > limit_bytes: + raise RelayError( + f"relay response exceeds {limit_bytes} bytes", + "relay.response_too_large", + ) + return bytes(body) + + +__all__ = [ + "PublisherActivation", + "ReceiverActivation", + "ReceiverInvitation", + "RelayError", + "RelayPublisher", + "RelaySession", + "RelayTimeoutError", +] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py new file mode 100644 index 0000000..a00792f --- /dev/null +++ b/python/pocketstation/aio/session.py @@ -0,0 +1,389 @@ +"""Asyncio ownership of the canonical native PocketStation Session.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +from pathlib import Path +from types import TracebackType +from typing import TYPE_CHECKING, TypeVar + +from .._native import ( + AudioBatch, + _SessionStartCancellation, +) +from .._native import ( + RunningSession as _NativeRunningSession, +) +from .._native import ( + Session as _NativeSession, +) +from ..audio_input import AudioInputConfig +from ..audio_input import PcmSource as SyncPcmSource +from ..errors import PocketStationError, _native_call, _normalize_native_error +from ..extensions import NativeExtensionLibrary +from ..graph import ( + Endpoint, + Stem, + _GraphSessionDeclarations, +) +from ..observations import ( + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from ..sidecar import SidecarHandle, SidecarProcessSpec +from ..signal import BusSubscription +from ..sources import Source +from .audio_input import AudioInput, PcmSource +from .observations import EventStream +from .sidecar import SidecarConnection +from .streams import AudioStream, SignalStream + +if TYPE_CHECKING: + from ..relay import RelayPublisher + from .relay import RelaySession + +_Result = TypeVar("_Result") +_BACKGROUND_TASKS: set[asyncio.Task[None]] = set() + + +class RunningSession: + """Running native Session with bounded asyncio batch delivery.""" + + def __init__(self, native: _NativeRunningSession) -> None: + self._native = native + self._stop_result: StopResult | None = None + self._stop_lock = asyncio.Lock() + self._audio = AudioStream( + poll_batch=self._poll_audio_native, + wait_batch=self._wait_audio_native, + is_closed=lambda: self.is_stopped, + ) + self._events = EventStream( + poll_event=self._poll_event_native, + wait_event=self._wait_event_native, + is_closed=lambda: self.is_stopped, + ) + self._signals: dict[int, SignalStream] = {} + self._sidecars: dict[int, SidecarConnection] = {} + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def is_stopped(self) -> bool: + return self._stop_result is not None + + @property + def stop_result(self) -> StopResult | None: + return self._stop_result + + @property + def audio(self) -> AudioStream: + """The exclusive async frame-first native endpoint view.""" + return self._audio + + @property + def events(self) -> EventStream: + """The exclusive async lifecycle and failure event stream.""" + return self._events + + def signals(self, subscription: BusSubscription) -> SignalStream: + """Return the one exclusive asyncio stream for a subscription.""" + stream = self._signals.get(subscription.id) + if stream is None: + native = subscription._native + stream = SignalStream( + poll_signal=lambda: _native_async( + lambda: self._native.poll_signal(native) + ), + wait_signal=lambda timeout_ms: _native_async( + lambda: self._native.wait_signal(native, timeout_ms) + ), + close_signal=lambda: _native_async( + lambda: self._native.close_signal(native) + ), + signal_metrics=lambda: _native_async( + lambda: self._native.signal_metrics(native) + ), + ) + self._signals[subscription.id] = stream + return stream + + def sidecar(self, handle: SidecarHandle) -> SidecarConnection: + """Return the Session-owned asyncio connection for one child.""" + self._require_running() + if handle.session_id != self._native.session_id: + raise ValueError("SidecarHandle belongs to a different Session") + connection = self._sidecars.get(handle.id) + if connection is None: + connection = SidecarConnection( + handle=handle, + send_message=lambda message: _native_async( + lambda: self._native.send_sidecar(handle.id, message) + ), + poll_message=lambda: _native_async( + lambda: self._native.poll_sidecar(handle.id) + ), + wait_message=lambda timeout_ms: _native_async( + lambda: self._native.wait_sidecar(handle.id, timeout_ms) + ), + snapshot=lambda: _native_async( + lambda: self._native.sidecar_snapshot(handle.id) + ), + is_session_stopped=lambda: self.is_stopped, + ) + self._sidecars[handle.id] = connection + return connection + + async def poll_audio(self) -> AudioBatch | None: + """Compatibility alias for the advanced non-blocking batch mode.""" + self._require_running() + return await self.audio.poll_batch() + + async def wait_audio(self, *, timeout_ms: int = 100) -> AudioBatch | None: + """Compatibility alias for the advanced bounded batch mode.""" + self._require_running() + if not 0 <= timeout_ms <= 1_000: + raise ValueError("timeout_ms must be between 0 and 1000") + return await self.audio.read_batch(timeout_s=timeout_ms / 1_000) + + def audio_batches( + self, + *, + wait_timeout_ms: int = 100, + ) -> AsyncIterator[AudioBatch]: + """Compatibility alias for ``audio.batches()``.""" + self._require_running() + if not 0 <= wait_timeout_ms <= 1_000: + raise ValueError("wait_timeout_ms must be between 0 and 1000") + return self.audio.batches(wait_timeout_s=wait_timeout_ms / 1_000) + + async def poll_event(self) -> SessionEvent | None: + """Compatibility alias for ``events.poll()``.""" + self._require_running() + return await self.events.poll() + + async def wait_event(self, *, timeout_ms: int = 100) -> SessionEvent | None: + """Compatibility alias for the bounded ``events.read()`` mode.""" + self._require_running() + if not 0 <= timeout_ms <= 1_000: + raise ValueError("timeout_ms must be between 0 and 1000") + return await self.events.read(timeout_s=timeout_ms / 1_000) + + async def metrics(self) -> SessionMetrics: + """Return a complete immutable point-in-time metrics snapshot.""" + self._require_running() + native = await _native_async(self._native.metrics) + return SessionMetrics._from_native(native) + + async def stop(self) -> StopResult: + """Stop once, finalize endpoints/recording, and cache the outcome.""" + async with self._stop_lock: + if self._stop_result is None: + native = await _native_async(self._native.stop) + self._stop_result = StopResult._from_native(native) + return self._stop_result + + async def cancel(self) -> StopResult: + """Cancel asynchronous work and sidecars, then join and reap once.""" + async with self._stop_lock: + if self._stop_result is None: + native = await _native_async(self._native.cancel) + self._stop_result = StopResult._from_native(native) + return self._stop_result + + async def aclose(self) -> None: + await self.stop() + + async def __aenter__(self) -> RunningSession: + self._require_running() + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.stop() + + def _require_running(self) -> None: + if self.is_stopped: + raise PocketStationError("Session has stopped", "session.stopped") + + async def _poll_audio_native(self) -> AudioBatch | None: + self._require_running() + return await _native_async(self._native.poll_audio) + + async def _wait_audio_native(self, timeout_ms: int) -> AudioBatch | None: + self._require_running() + return await _native_async(lambda: self._native.wait_audio(timeout_ms)) + + async def _poll_event_native(self) -> SessionEvent | None: + self._require_running() + event = await _native_async(self._native.poll_event) + return None if event is None else SessionEvent._from_native(event) + + async def _wait_event_native(self, timeout_ms: int) -> SessionEvent | None: + self._require_running() + event = await _native_async(lambda: self._native.wait_event(timeout_ms)) + return None if event is None else SessionEvent._from_native(event) + + +class Session(_GraphSessionDeclarations): + """Explicit asyncio façade over the canonical Rust Session.""" + + def __init__( + self, + *, + recording_root: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, + sample_rate_hz: int = 48_000, + channels: int = 1, + ) -> None: + root = None if recording_root is None else Path(recording_root) + self._native = _NativeSession( + recording_root=root, + trace_path=None if trace is None else trace.path, + trace_capacity_records=256 if trace is None else trace.capacity_records, + sample_rate_hz=sample_rate_hz, + channels=channels, + ) + self._sample_rate_hz = sample_rate_hz + self._channels = channels + + @classmethod + def _from_native(cls, native: _NativeSession) -> Session: + """Construct an internal façade around a canonical conformance Session.""" + session = cls.__new__(cls) + session._native = native + session._sample_rate_hz = 48_000 + session._channels = 1 + return session + + @property + def id(self) -> int: + return self._native.id + + def capture(self, source: Source) -> Stem: + """Declare one independent source-aware stem.""" + return _native_call(lambda: Stem(self._native.capture(source._native))) + + def audio_input( + self, + name: str, + *, + sample_rate_hz: int | None = None, + channels: int | None = None, + capacity_frames: int = 8, + frame_samples_per_channel: int = 480, + ) -> AudioInput: + config = AudioInputConfig( + name=name, + sample_rate_hz=( + self._sample_rate_hz if sample_rate_hz is None else sample_rate_hz + ), + channels=self._channels if channels is None else channels, + capacity_frames=capacity_frames, + frame_samples_per_channel=frame_samples_per_channel, + ) + native = _native_call( + lambda: self._native.audio_input( + config.sample_rate_hz, + config.channels, + config.capacity_frames, + config.frame_samples_per_channel, + ) + ) + return AudioInput(SyncPcmSource(native, config)) + + def pcm_source(self, config: AudioInputConfig) -> PcmSource: + native = _native_call( + lambda: self._native.pcm_source( + config.sample_rate_hz, + config.channels, + config.capacity_frames, + config.frame_samples_per_channel, + ) + ) + return PcmSource(SyncPcmSource(native, config)) + + def polled_audio(self) -> Endpoint: + """Declare the bounded managed-language polling endpoint.""" + return _native_call(lambda: Endpoint(self._native.polled_audio())) + + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: + """Register a bounded PKSS child to spawn during transactional start.""" + sidecar_id = _native_call( + lambda: self._native.register_sidecar(spec._to_native()) + ) + return SidecarHandle(id=sidecar_id, session_id=self._native.id) + + def load_native_extension_library( + self, + path: str | Path, + ) -> NativeExtensionLibrary: + """Load trusted native code into this Session draft. + + This accepts a raw dynamic library. PocketStation validates its ABI + records and imports registrations transactionally, but does not verify + a publisher, signature, checksum, or sandbox the loaded code. Callers + must establish trust in the exact library and its ABI implementation. + """ + native = _native_call( + lambda: self._native.load_native_extension_library(Path(path)) + ) + return NativeExtensionLibrary._from_native(native) + + def relay(self, remote: RelaySession) -> RelayPublisher: + """Declare the existing bounded Rust relay connector.""" + return remote.publisher(self) + + async def start(self) -> RunningSession: + """Start transactionally and propagate asyncio cancellation to Rust.""" + cancellation = _SessionStartCancellation() + start_task = asyncio.create_task( + asyncio.to_thread(self._native.start, cancellation) + ) + try: + native = await asyncio.shield(start_task) + except asyncio.CancelledError: + cancellation.request() + cleanup = asyncio.create_task(_settle_cancelled_start(start_task)) + _BACKGROUND_TASKS.add(cleanup) + cleanup.add_done_callback(_BACKGROUND_TASKS.discard) + raise + except (RuntimeError, ValueError) as error: + raise _normalize_native_error(error) from error + return RunningSession(native) + + +async def _settle_cancelled_start( + start_task: asyncio.Task[_NativeRunningSession], +) -> None: + try: + native = await start_task + except Exception: + return + await asyncio.to_thread(native.stop) + + +async def _native_async(operation: Callable[[], _Result]) -> _Result: + native_task = asyncio.create_task(asyncio.to_thread(operation)) + try: + return await asyncio.shield(native_task) + except asyncio.CancelledError: + try: + await native_task + except Exception: + pass + raise + except (RuntimeError, ValueError) as error: + raise _normalize_native_error(error) from error + + +__all__ = ["RunningSession", "Session"] diff --git a/python/pocketstation/aio/sidecar.py b/python/pocketstation/aio/sidecar.py new file mode 100644 index 0000000..d1db3f0 --- /dev/null +++ b/python/pocketstation/aio/sidecar.py @@ -0,0 +1,146 @@ +"""Asyncio views of Session-owned native PKSS sidecars.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable + +from .._native import _SidecarMessage as _NativeSidecarMessage +from .._native import _SidecarRead as _NativeSidecarRead +from .._native import _SidecarSnapshot as _NativeSidecarSnapshot +from ..errors import SidecarProtocolError +from ..sidecar import ( + SidecarHandle, + SidecarMessage, + SidecarReadResult, + SidecarSnapshot, +) +from ..signal import STREAM_EOF, EndOfStream +from ..streams import ( + _DEFAULT_ITERATION_TIMEOUT_SECONDS, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + + +class SidecarStream: + """Cancellation-safe one-reader view of the native incoming PKSS queue.""" + + def __init__( + self, + *, + poll_message: Callable[[], Awaitable[_NativeSidecarRead]], + wait_message: Callable[[int], Awaitable[_NativeSidecarRead]], + is_session_stopped: Callable[[], bool], + ) -> None: + self._poll_message = poll_message + self._wait_message = wait_message + self._is_session_stopped = is_session_stopped + self._state = _ReaderState() + self._closed = False + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._closed or self._is_session_stopped() + + async def poll(self) -> SidecarReadResult: + token = self._state.claim("sidecar_read") + try: + return ( + STREAM_EOF + if self.is_closed + else self._decode(await self._poll_message()) + ) + finally: + self._state.release(token) + + async def read(self, *, timeout_s: float = 1.0) -> SidecarReadResult: + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("sidecar_read") + try: + return ( + STREAM_EOF + if self.is_closed + else self._decode(await self._wait_message(timeout_ms)) + ) + finally: + self._state.release(token) + + def __aiter__(self) -> AsyncIterator[SidecarMessage]: + return self.messages() + + def messages( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[SidecarMessage]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[SidecarMessage]: + token = self._state.claim("sidecar") + try: + while not self.is_closed: + result = self._decode(await self._wait_message(timeout_ms)) + if isinstance(result, EndOfStream): + break + if result is not None: + yield result + finally: + self._state.release(token) + + return iterate() + + def _decode(self, value: _NativeSidecarRead) -> SidecarReadResult: + if value.status == "item": + if value.message is None: + raise SidecarProtocolError( + "native sidecar read omitted its message", + "sidecar.invalid_read", + ) + return SidecarMessage._from_native(value.message) + if value.status == "empty": + return None + if value.status == "closed": + self._closed = True + return STREAM_EOF + raise SidecarProtocolError( + f"native sidecar read has unknown state {value.status!r}", + "sidecar.invalid_read", + ) + + +class SidecarConnection: + """Asyncio RunningSession view of one Session-owned child.""" + + def __init__( + self, + *, + handle: SidecarHandle, + send_message: Callable[[_NativeSidecarMessage], Awaitable[None]], + poll_message: Callable[[], Awaitable[_NativeSidecarRead]], + wait_message: Callable[[int], Awaitable[_NativeSidecarRead]], + snapshot: Callable[[], Awaitable[_NativeSidecarSnapshot]], + is_session_stopped: Callable[[], bool], + ) -> None: + self.handle = handle + self._send_message = send_message + self._snapshot = snapshot + self.messages = SidecarStream( + poll_message=poll_message, + wait_message=wait_message, + is_session_stopped=is_session_stopped, + ) + + async def send(self, message: SidecarMessage) -> None: + """Try one immediate native bounded enqueue without event-loop blocking.""" + await self._send_message(message._to_native()) + + async def snapshot(self) -> SidecarSnapshot: + return SidecarSnapshot._from_native(await self._snapshot()) + + +__all__ = ["SidecarConnection", "SidecarStream"] diff --git a/python/pocketstation/aio/sources.py b/python/pocketstation/aio/sources.py new file mode 100644 index 0000000..d9a02cb --- /dev/null +++ b/python/pocketstation/aio/sources.py @@ -0,0 +1,42 @@ +"""Asyncio access to shared native source discovery and permission policy.""" + +from __future__ import annotations + +import asyncio + +from ..sources import ( + DiscoveredSource, + PermissionObservation, + SourceQuery, +) +from ..sources import ( + application_capture_available as _application_capture_available, +) +from ..sources import discover_sources as _discover_sources +from ..sources import ( + microphone_permission_observation as _microphone_permission_observation, +) + + +async def discover_sources( + query: SourceQuery | None = None, +) -> tuple[DiscoveredSource, ...]: + """Run canonical native source discovery off the asyncio event loop.""" + return await asyncio.to_thread(_discover_sources, query) + + +async def application_capture_available() -> bool: + """Read the native application-capture capability off the event loop.""" + return await asyncio.to_thread(_application_capture_available) + + +async def microphone_permission_observation() -> PermissionObservation: + """Read non-prompting native microphone authorization off the event loop.""" + return await asyncio.to_thread(_microphone_permission_observation) + + +__all__ = [ + "application_capture_available", + "discover_sources", + "microphone_permission_observation", +] diff --git a/python/pocketstation/aio/streams.py b/python/pocketstation/aio/streams.py new file mode 100644 index 0000000..f0c9843 --- /dev/null +++ b/python/pocketstation/aio/streams.py @@ -0,0 +1,238 @@ +"""Bounded asyncio streams over one native polled-audio endpoint.""" + +from __future__ import annotations + +from collections import deque +from collections.abc import AsyncIterator, Awaitable, Callable + +from .._native import AudioBatch, AudioFrame, _SignalRead, _SignalSubscriptionMetrics +from ..errors import StreamError +from ..signal import ( + STREAM_EOF, + EndOfStream, + SignalEnvelope, + SignalReadResult, + SignalSubscriptionMetrics, +) +from ..streams import ( + _DEFAULT_ITERATION_TIMEOUT_SECONDS, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + + +class AudioStream: + """Frame-first asyncio view with one explicit reader and no Python queue.""" + + def __init__( + self, + *, + poll_batch: Callable[[], Awaitable[AudioBatch | None]], + wait_batch: Callable[[int], Awaitable[AudioBatch | None]], + is_closed: Callable[[], bool], + ) -> None: + self._poll_batch = poll_batch + self._wait_batch = wait_batch + self._is_closed = is_closed + self._state = _ReaderState() + self._pending_frames: deque[AudioFrame] = deque() + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._is_closed() + + async def read(self, *, timeout_s: float = 1.0) -> AudioFrame | None: + """Read one frame without blocking the event-loop thread.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("read") + try: + return await self._read_frame(timeout_ms) + finally: + self._state.release(token) + + async def poll_batch(self) -> AudioBatch | None: + """Advanced non-blocking batch read using the exclusive batch mode.""" + token = self._state.claim("batches") + try: + return None if self.is_closed else await self._poll_batch() + finally: + self._state.release(token) + + async def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: + """Advanced bounded batch read using the exclusive batch mode.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("batches") + try: + return None if self.is_closed else await self._wait_batch(timeout_ms) + finally: + self._state.release(token) + + def __aiter__(self) -> AsyncIterator[AudioFrame]: + return self.frames() + + def frames( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[AudioFrame]: + """Yield frames lazily until the owning Session closes.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[AudioFrame]: + token = self._state.claim("frames") + try: + while not self.is_closed: + frame = await self._read_frame(timeout_ms) + if frame is not None: + yield frame + finally: + self._state.release(token) + + return iterate() + + def batches( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[AudioBatch]: + """Yield native-owned batches without an event-loop polling loop.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[AudioBatch]: + token = self._state.claim("batches") + try: + while not self.is_closed: + batch = await self._wait_batch(timeout_ms) + if batch is not None: + yield batch + finally: + self._state.release(token) + + return iterate() + + async def _read_frame(self, timeout_ms: int) -> AudioFrame | None: + if self._pending_frames: + return self._pending_frames.popleft() + if self.is_closed: + return None + batch = await self._wait_batch(timeout_ms) + if batch is None: + return None + self._pending_frames.extend(batch) + if not self._pending_frames: + return None + return self._pending_frames.popleft() + + +class SignalStream: + """Cancellation-safe asyncio view of one native ``BusSubscription``.""" + + def __init__( + self, + *, + poll_signal: Callable[[], Awaitable[_SignalRead]], + wait_signal: Callable[[int], Awaitable[_SignalRead]], + close_signal: Callable[[], Awaitable[None]], + signal_metrics: Callable[[], Awaitable[_SignalSubscriptionMetrics]], + ) -> None: + self._poll_signal = poll_signal + self._wait_signal = wait_signal + self._close_signal = close_signal + self._signal_metrics = signal_metrics + self._state = _ReaderState() + self._closed = False + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._closed + + async def poll(self) -> SignalReadResult: + token = self._state.claim("signal_read") + try: + return ( + STREAM_EOF if self._closed else self._decode(await self._poll_signal()) + ) + finally: + self._state.release(token) + + async def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("signal_read") + try: + return ( + STREAM_EOF + if self._closed + else self._decode(await self._wait_signal(timeout_ms)) + ) + finally: + self._state.release(token) + + def __aiter__(self) -> AsyncIterator[SignalEnvelope]: + return self.iter_signals() + + def iter_signals( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[SignalEnvelope]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[SignalEnvelope]: + token = self._state.claim("signals") + try: + while not self._closed: + result = self._decode(await self._wait_signal(timeout_ms)) + if isinstance(result, EndOfStream): + break + if result is not None: + yield result + finally: + self._state.release(token) + + return iterate() + + async def aclose(self) -> None: + if self._closed: + return + await self._close_signal() + self._closed = True + + async def metrics(self) -> SignalSubscriptionMetrics: + """Snapshot capacity, payload-byte bounds, depth, delivery, and drops.""" + return SignalSubscriptionMetrics._from_native(await self._signal_metrics()) + + def _decode(self, result: _SignalRead) -> SignalReadResult: + if result.status == "item": + if result.envelope is None: + raise StreamError( + "native signal read omitted its envelope", + "stream.invalid_read", + ) + return SignalEnvelope._from_native(result.envelope) + if result.status == "empty": + return None + if result.status == "closed": + self._closed = True + return STREAM_EOF + if result.status == "fault": + self._closed = True + raise StreamError( + result.error or "native signal endpoint failed", + "stream.fault", + ) + raise StreamError( + f"native signal read has unknown state {result.status!r}", + "stream.invalid_read", + ) + + +__all__ = ["AudioStream", "SignalStream"] diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py new file mode 100644 index 0000000..5f40854 --- /dev/null +++ b/python/pocketstation/audio_input.py @@ -0,0 +1,111 @@ +"""Bounded application-owned PCM input for a PocketStation Session.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ._native import _AudioInput as _NativeAudioInput +from ._native import _AudioInputObservations as _NativeAudioInputObservations +from .errors import _native_call +from .graph import SourceOutput + + +@dataclass(frozen=True, slots=True) +class AudioInputConfig: + """Finite PCM contract shared by the convenient and advanced APIs.""" + + name: str + sample_rate_hz: int = 48_000 + channels: int = 1 + capacity_frames: int = 8 + frame_samples_per_channel: int = 480 + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("name must not be empty") + + +@dataclass(frozen=True, slots=True) +class AudioInputObservations: + """Point-in-time bounded-buffer and terminal-state observations.""" + + capacity_frames: int + buffer_slots: int + available_buffers: int + accepted_total: int + full_total: int + invalid_total: int + cancelled: bool + closed: bool + + @classmethod + def _from_native( + cls, + native: _NativeAudioInputObservations, + ) -> AudioInputObservations: + return cls( + capacity_frames=native.capacity_frames, + buffer_slots=native.buffer_slots, + available_buffers=native.available_buffers, + accepted_total=native.accepted_total, + full_total=native.full_total, + invalid_total=native.invalid_total, + cancelled=native.cancelled, + closed=native.closed, + ) + + +class PcmSource: + """Advanced explicit ownership of one Session source output and PCM writer.""" + + def __init__(self, native: _NativeAudioInput, config: AudioInputConfig) -> None: + self._native = native + self._config = config + self._output = SourceOutput(native.output) + + @property + def config(self) -> AudioInputConfig: + return self._config + + @property + def source_id(self) -> int: + return self._native.source_id + + @property + def stream_id(self) -> int: + return self._native.stream_id + + @property + def output(self) -> SourceOutput: + return self._output + + def try_write(self, samples: object, *, discontinuity: bool = False) -> None: + """Copy one C-contiguous float32 frame into a preallocated Core buffer.""" + _native_call( + lambda: self._native.try_write(samples, discontinuity=discontinuity) + ) + + def close(self) -> None: + """Close after accepted frames drain; subsequent writes fail explicitly.""" + _native_call(self._native.close) + + def observations(self) -> AudioInputObservations: + return AudioInputObservations._from_native( + _native_call(self._native.observations) + ) + + +class AudioInput(PcmSource): + """Intent-first input for audio already owned by the embedding application.""" + + def write(self, samples: object, *, discontinuity: bool = False) -> None: + """Submit one complete frame without blocking or growing the queue.""" + self.try_write(samples, discontinuity=discontinuity) + + +__all__ = [ + "AudioInput", + "AudioInputConfig", + "AudioInputObservations", + "PcmSource", +] diff --git a/python/pocketstation/capture.py b/python/pocketstation/capture.py new file mode 100644 index 0000000..9bc38c9 --- /dev/null +++ b/python/pocketstation/capture.py @@ -0,0 +1,173 @@ +"""High-signal synchronous recipe over the explicit Session API.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path +from types import TracebackType + +from ._native import AudioBatch +from .graph import Stem +from .observations import ( + EventStream, + RecordingOutcome, + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from .session import RunningSession, Session +from .sources import Source +from .streams import AudioStream + + +class Capture: + """One application and optional microphone captured as independent stems.""" + + def __init__( + self, + *, + application: str, + microphone: bool | str = True, + record_to: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, + ) -> None: + if not application.strip(): + raise ValueError("application must not be empty") + if not isinstance(microphone, (bool, str)): + raise TypeError("microphone must be True, False, or a device ID") + if isinstance(microphone, str) and not microphone.strip(): + raise ValueError("microphone device ID must not be empty") + + self._application_name = application + self._microphone = microphone + self._record_to = None if record_to is None else Path(record_to) + self._trace = trace + self._running: RunningSession | None = None + self._entered = False + self._declare() + + def _declare(self) -> None: + session = ( + Session(recording_root=self._record_to) + if self._trace is None + else Session(recording_root=self._record_to, trace=self._trace) + ) + application = session.capture(Source.application(self._application_name)) + microphone: Stem | None = None + if self._microphone is True: + microphone = session.capture(Source.microphone_default()) + elif isinstance(self._microphone, str): + microphone = session.capture(Source.microphone_id(self._microphone)) + + audio = session.polled_audio() + self.application_route_id = application.send(audio) + self.microphone_route_id = ( + None if microphone is None else microphone.send(audio) + ) + if self._record_to is not None: + application.record("application") + if microphone is not None: + microphone.record("microphone") + + self.session = session + self.application_stem = application + self.microphone_stem = microphone + + @property + def is_running(self) -> bool: + return self._running is not None and not self._running.is_stopped + + @property + def stop_result(self) -> StopResult | None: + return None if self._running is None else self._running.stop_result + + @property + def recording_outcome(self) -> RecordingOutcome | None: + result = self.stop_result + return None if result is None else result.recording + + @property + def audio(self) -> AudioStream: + """Frame-first bounded audio from the running native Session.""" + return self._require_running().audio + + @property + def events(self) -> EventStream: + """Lifecycle and failure events from the running native Session.""" + return self._require_running().events + + def start(self) -> Capture: + if self._running is not None: + raise RuntimeError("Capture has already started") + self._running = self.session.start() + return self + + def poll_audio(self) -> AudioBatch | None: + return self._require_running().poll_audio() + + def wait_audio(self, *, timeout_ms: int = 100) -> AudioBatch | None: + return self._require_running().wait_audio(timeout_ms=timeout_ms) + + def audio_batches(self, *, wait_timeout_ms: int = 100) -> Iterator[AudioBatch]: + return self._require_running().audio_batches(wait_timeout_ms=wait_timeout_ms) + + def poll_event(self) -> SessionEvent | None: + return self._require_running().poll_event() + + def wait_event(self, *, timeout_ms: int = 100) -> SessionEvent | None: + return self._require_running().wait_event(timeout_ms=timeout_ms) + + def metrics(self) -> SessionMetrics: + return self._require_running().metrics() + + def stop(self) -> StopResult: + return self._require_started().stop() + + def close(self) -> None: + if self._running is not None: + self._running.close() + + def __enter__(self) -> Capture: + if self._entered: + raise RuntimeError("Capture context cannot be entered twice") + self._entered = True + return self.start() + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def _require_started(self) -> RunningSession: + if self._running is None: + raise RuntimeError("Capture has not started") + return self._running + + def _require_running(self) -> RunningSession: + running = self._require_started() + if running.is_stopped: + raise RuntimeError("Capture has stopped") + return running + + +def capture( + *, + application: str, + microphone: bool | str = True, + record_to: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, +) -> Capture: + """Declare a concise app+mic recipe backed by one native Rust Session.""" + return Capture( + application=application, + microphone=microphone, + record_to=record_to, + trace=trace, + ) + + +__all__ = ["Capture", "capture"] diff --git a/python/pocketstation/control.py b/python/pocketstation/control.py new file mode 100644 index 0000000..646d10f --- /dev/null +++ b/python/pocketstation/control.py @@ -0,0 +1,381 @@ +"""Typed synchronous client for the PocketStation control-plane Session API.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from types import TracebackType +from typing import Any +from urllib.parse import quote, urljoin, urlparse + +import httpx + +from .errors import PocketStationError + +_MAX_ERROR_BODY_BYTES = 4_096 +_MAX_JSON_BODY_BYTES = 65_536 + + +class ControlPlaneError(PocketStationError): + """A configuration, transport, HTTP, or decoding control-plane failure.""" + + def __init__( + self, + message: str, + code: str, + *, + status_code: int | None = None, + ) -> None: + super().__init__(message, code) + self.status_code = status_code + + +class SessionId(str): + """Validated Session identifier safe for one URL path segment.""" + + def __new__(cls, value: str) -> SessionId: + if not value or not all( + character.isascii() and (character.isalnum() or character in "-_") + for character in value + ): + raise ValueError( + "Session ID must contain only ASCII letters, digits, '-' or '_'" + ) + return str.__new__(cls, value) + + +class SecretToken: + """Credential that redacts itself unless exposure is explicitly requested.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + if not value: + raise ValueError("credential token must not be empty") + self._value = value + + def expose_secret(self) -> str: + return self._value + + def __repr__(self) -> str: + return "SecretToken('[redacted]')" + + +@dataclass(frozen=True, slots=True) +class IceServer: + urls: tuple[str, ...] + username: str | None = None + credential: str | None = None + + +@dataclass(frozen=True, slots=True) +class SessionCredentials: + session_id: SessionId + source_token: SecretToken + subscriber_token: SecretToken + whip_url: str | None = None + whep_url: str | None = None + ice_servers: tuple[IceServer, ...] = () + + +@dataclass(frozen=True, slots=True) +class SessionSnapshot: + session_id: SessionId + source_active: bool + subscription_count: int + codec: str + + +@dataclass(frozen=True, slots=True) +class SubscriberCredentials: + session_id: SessionId + subscriber_token: SecretToken + + +class ControlClient: + """Reusable, bounded HTTP client for Session lifecycle operations.""" + + def __init__( + self, + control_plane_url: str, + *, + timeout_seconds: float | None = 10.0, + http_client: httpx.Client | None = None, + ) -> None: + self.control_plane_url = _normalize_base_url(control_plane_url) + self._timeout_seconds = timeout_seconds + self._owns_http_client = http_client is None + self._http_client = http_client or httpx.Client(timeout=timeout_seconds) + self._closed = False + + def create_session( + self, + *, + timeout_seconds: float | None = None, + ) -> SessionCredentials: + payload = self._json_request( + "POST", + "v1/sessions", + expected_status=201, + timeout_seconds=timeout_seconds, + ) + return _session_credentials(payload) + + def session( + self, + session_id: str | SessionId, + *, + timeout_seconds: float | None = None, + ) -> SessionSnapshot: + identifier = SessionId(str(session_id)) + payload = self._json_request( + "GET", + f"v1/sessions/{quote(identifier, safe='')}", + expected_status=200, + timeout_seconds=timeout_seconds, + ) + return _session_snapshot(payload) + + def issue_subscriber_credentials( + self, + session_id: str | SessionId, + *, + timeout_seconds: float | None = None, + ) -> SubscriberCredentials: + identifier = SessionId(str(session_id)) + payload = self._json_request( + "POST", + f"v1/sessions/{quote(identifier, safe='')}/subscribe", + expected_status=200, + timeout_seconds=timeout_seconds, + ) + return _subscriber_credentials(payload) + + def delete_session( + self, + session_id: str | SessionId, + source_token: SecretToken, + *, + timeout_seconds: float | None = None, + ) -> None: + identifier = SessionId(str(session_id)) + self._request( + "DELETE", + f"v1/sessions/{quote(identifier, safe='')}", + expected_status=204, + timeout_seconds=timeout_seconds, + authorization=source_token, + expect_json=False, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._owns_http_client: + self._http_client.close() + + def __enter__(self) -> ControlClient: + if self._closed: + raise RuntimeError("ControlClient has closed") + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def _json_request( + self, + method: str, + path: str, + *, + expected_status: int, + timeout_seconds: float | None, + ) -> dict[str, Any]: + return self._request( + method, + path, + expected_status=expected_status, + timeout_seconds=timeout_seconds, + authorization=None, + expect_json=True, + ) + + def _request( + self, + method: str, + path: str, + *, + expected_status: int, + timeout_seconds: float | None, + authorization: SecretToken | None, + expect_json: bool, + ) -> dict[str, Any]: + if self._closed: + raise RuntimeError("ControlClient has closed") + headers = {} + redacted_values: tuple[str, ...] = () + if authorization is not None: + exposed = authorization.expose_secret() + headers["Authorization"] = f"Bearer {exposed}" + redacted_values = (exposed,) + timeout = self._timeout_seconds if timeout_seconds is None else timeout_seconds + try: + with self._http_client.stream( + method, + urljoin(self.control_plane_url, path), + headers=headers, + timeout=timeout, + ) as response: + if response.status_code != expected_status: + body = _read_bounded(response.iter_bytes(), _MAX_ERROR_BODY_BYTES) + detail = body.decode("utf-8", errors="replace") + for value in redacted_values: + detail = detail.replace(value, "[redacted]") + raise ControlPlaneError( + f"control-plane returned HTTP {response.status_code}: {detail}", + "control.http_status", + status_code=response.status_code, + ) + if not expect_json: + return {} + body = _read_bounded(response.iter_bytes(), _MAX_JSON_BODY_BYTES) + except ControlPlaneError: + raise + except httpx.HTTPError as error: + raise ControlPlaneError( + f"control-plane request failed: {error}", + "control.request", + ) from error + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ControlPlaneError( + f"control-plane response could not be decoded: {error}", + "control.response_decode", + ) from error + if not isinstance(payload, dict): + raise ControlPlaneError( + "control-plane response must be a JSON object", + "control.response_decode", + ) + return payload + + +def _normalize_base_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("control_plane_url must be an absolute http or https URL") + return value.split("?", 1)[0].split("#", 1)[0].rstrip("/") + "/" + + +def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: + body = bytearray() + for chunk in chunks: + remaining = limit_bytes + 1 - len(body) + if remaining <= 0: + break + body.extend(chunk[:remaining]) + if len(body) > limit_bytes: + raise ControlPlaneError( + f"control-plane response exceeds {limit_bytes} bytes", + "control.response_too_large", + ) + return bytes(body) + + +def _required(payload: dict[str, Any], key: str, expected_type: type[Any]) -> Any: + value = payload.get(key) + if not isinstance(value, expected_type): + raise ControlPlaneError( + f"control-plane response field {key!r} has the wrong type", + "control.response_decode", + ) + return value + + +def _ice_servers(payload: dict[str, Any]) -> tuple[IceServer, ...]: + raw_servers = payload.get("ice_servers", []) + if not isinstance(raw_servers, list): + raise ControlPlaneError( + "control-plane response field 'ice_servers' has the wrong type", + "control.response_decode", + ) + servers: list[IceServer] = [] + for raw_server in raw_servers: + if not isinstance(raw_server, dict): + raise ControlPlaneError( + "control-plane ICE server must be a JSON object", + "control.response_decode", + ) + urls = _required(raw_server, "urls", list) + if not all(isinstance(url, str) for url in urls): + raise ControlPlaneError( + "control-plane ICE server URLs must be strings", + "control.response_decode", + ) + username = raw_server.get("username") + credential = raw_server.get("credential") + if username is not None and not isinstance(username, str): + raise ControlPlaneError( + "ICE username must be a string", "control.response_decode" + ) + if credential is not None and not isinstance(credential, str): + raise ControlPlaneError( + "ICE credential must be a string", "control.response_decode" + ) + servers.append(IceServer(tuple(urls), username, credential)) + return tuple(servers) + + +def _session_credentials(payload: dict[str, Any]) -> SessionCredentials: + return SessionCredentials( + session_id=SessionId(_required(payload, "session_id", str)), + source_token=SecretToken(_required(payload, "source_token", str)), + subscriber_token=SecretToken(_required(payload, "subscriber_token", str)), + whip_url=_optional_string(payload, "whip_url"), + whep_url=_optional_string(payload, "whep_url"), + ice_servers=_ice_servers(payload), + ) + + +def _session_snapshot(payload: dict[str, Any]) -> SessionSnapshot: + return SessionSnapshot( + session_id=SessionId(_required(payload, "session_id", str)), + source_active=_required(payload, "source_active", bool), + subscription_count=_required(payload, "subscription_count", int), + codec=_required(payload, "codec", str), + ) + + +def _subscriber_credentials(payload: dict[str, Any]) -> SubscriberCredentials: + return SubscriberCredentials( + session_id=SessionId(_required(payload, "session_id", str)), + subscriber_token=SecretToken(_required(payload, "subscriber_token", str)), + ) + + +def _optional_string(payload: dict[str, Any], key: str) -> str | None: + value = payload.get(key) + if value is not None and not isinstance(value, str): + raise ControlPlaneError( + f"control-plane response field {key!r} has the wrong type", + "control.response_decode", + ) + return value + + +__all__ = [ + "ControlClient", + "ControlPlaneError", + "IceServer", + "SecretToken", + "SessionCredentials", + "SessionId", + "SessionSnapshot", + "SubscriberCredentials", +] diff --git a/python/pocketstation/errors.py b/python/pocketstation/errors.py new file mode 100644 index 0000000..a27ff46 --- /dev/null +++ b/python/pocketstation/errors.py @@ -0,0 +1,150 @@ +"""Stable exception hierarchy for the PocketStation Python SDK.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import TypeVar + +_Result = TypeVar("_Result") +_CODED_ERROR = re.compile(r"\[([a-z0-9_.-]+)\]\s*(.*)", re.DOTALL) + + +class PocketStationError(Exception): + """Base SDK failure with a stable machine-readable error code.""" + + def __init__(self, message: str, code: str = "error") -> None: + super().__init__(message) + self.code = code + + +class StreamError(PocketStationError): + """Base failure for managed consumption of one native endpoint.""" + + +class StreamModeError(StreamError): + """Raised when one stream is consumed through incompatible reader modes.""" + + def __init__(self, active_mode: str, requested_mode: str) -> None: + super().__init__( + f"stream already uses {active_mode!r}; cannot switch to {requested_mode!r}", + "stream.mode_conflict", + ) + self.active_mode = active_mode + self.requested_mode = requested_mode + + +class StreamInUseError(StreamError): + """Raised instead of waiting when another reader owns the stream.""" + + def __init__(self, mode: str) -> None: + super().__init__( + f"stream already has an active {mode!r} reader", + "stream.in_use", + ) + self.mode = mode + + +class SidecarError(PocketStationError): + """Base failure from one Session-owned process sidecar.""" + + +class SidecarBackpressureError(SidecarError): + """The configured finite sidecar data or control queue is full.""" + + +class SidecarProtocolError(SidecarError): + """The child violated the frozen PKSS wire or handshake contract.""" + + +class SidecarTimeoutError(SidecarError): + """A ready, processing, close, cancel, or reap deadline expired.""" + + +class ExtensionError(PocketStationError): + """A compiled extension descriptor or ABI contract was rejected.""" + + +class AudioInputError(PocketStationError): + """Base failure from one bounded application-owned PCM input.""" + + +class AudioInputFullError(AudioInputError): + """No preallocated frame or queue slot is currently available.""" + + +class AudioInputClosedError(AudioInputError): + """The input was closed or its Session has stopped.""" + + +class AudioInputCancelledError(AudioInputError): + """The owning Session cancelled this input.""" + + +class AudioInputBufferError(AudioInputError, ValueError): + """The supplied object is not one exact contiguous float32 frame.""" + + +def _native_call(operation: Callable[[], _Result]) -> _Result: + """Execute one synchronous native call through the shared error policy.""" + try: + return operation() + except (RuntimeError, ValueError) as error: + raise _normalize_native_error(error) from error + + +def _normalize_native_error(error: Exception) -> PocketStationError: + message = str(error) + match = _CODED_ERROR.search(message) + if match is None: + return PocketStationError(message, "session.internal") + code = match.group(1) + detail = match.group(2) or code + if code in {"sidecar.queue_full", "sidecar.control_queue_full"}: + return SidecarBackpressureError(detail, code) + if code in { + "sidecar.protocol", + "sidecar.unexpected_eof", + "sidecar.unexpected_message", + "sidecar.invalid_message_kind", + }: + return SidecarProtocolError(detail, code) + if code in {"sidecar.timeout", "sidecar.processing_timeout"}: + return SidecarTimeoutError(detail, code) + if code.startswith("sidecar."): + return SidecarError(detail, code) + if code.startswith("extension."): + return ExtensionError(detail, code) + if code == "audio_input.full": + return AudioInputFullError(detail, code) + if code == "audio_input.closed": + return AudioInputClosedError(detail, code) + if code == "audio_input.cancelled": + return AudioInputCancelledError(detail, code) + if code in { + "audio_input.invalid_buffer", + "audio_input.invalid_configuration", + "audio_input.declaration_failed", + }: + return AudioInputBufferError(detail, code) + if code.startswith("audio_input."): + return AudioInputError(detail, code) + return PocketStationError(detail, code) + + +__all__ = [ + "AudioInputBufferError", + "AudioInputCancelledError", + "AudioInputClosedError", + "AudioInputError", + "AudioInputFullError", + "ExtensionError", + "PocketStationError", + "SidecarBackpressureError", + "SidecarError", + "SidecarProtocolError", + "SidecarTimeoutError", + "StreamError", + "StreamInUseError", + "StreamModeError", +] diff --git a/python/pocketstation/extensions.py b/python/pocketstation/extensions.py new file mode 100644 index 0000000..fd3dea6 --- /dev/null +++ b/python/pocketstation/extensions.py @@ -0,0 +1,167 @@ +"""Versioned compiled-extension descriptors backed by PocketStation's C ABI.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from ._native import _NativeExtensionLibrary, _NativeExtensionRegistration +from ._native import extension_abi_is_compatible as _abi_is_compatible +from ._native import extension_abi_version as _abi_version +from ._native import validate_extension_descriptor as _validate_descriptor +from .errors import _native_call + + +class ExtensionKind(StrEnum): + """Open compiled extension roles admitted by ABI 1.x.""" + + SOURCE = "source" + OPERATOR = "operator" + ENDPOINT = "endpoint" + + +class ExtensionPortDirection(StrEnum): + """Direction of one named typed-signal port.""" + + INPUT = "input" + OUTPUT = "output" + + +@dataclass(frozen=True, slots=True) +class ExtensionAbiVersion: + """Native authority for the linked PocketStation extension ABI.""" + + struct_size_bytes: int + abi_major: int + abi_minor: int + + @classmethod + def current(cls) -> ExtensionAbiVersion: + value = _native_call(_abi_version) + return cls( + struct_size_bytes=value.struct_size_bytes, + abi_major=value.abi_major, + abi_minor=value.abi_minor, + ) + + def require_compatible(self) -> None: + _native_call( + lambda: _abi_is_compatible( + self.abi_major, + self.abi_minor, + self.struct_size_bytes, + ) + ) + + +@dataclass(frozen=True, slots=True) +class ExtensionPort: + """One versioned named port preserving SignalSpec wire identity.""" + + name: str + direction: ExtensionPortDirection + signal_id: str + required: bool = True + semantic_role: str = "" + schema: str = "" + + def _native_tuple(self) -> tuple[str, str, bool, str, str, str]: + return ( + self.name, + self.direction.value, + self.required, + self.signal_id, + self.semantic_role, + self.schema, + ) + + +@dataclass(frozen=True, slots=True) +class ExtensionDescriptor: + """Copied source, operator, or endpoint ABI descriptor. + + Construction validates the complete record using the linked frozen native + ABI. This object is a descriptor, not an executable Python callback. + """ + + extension_id: str + kind: ExtensionKind + ports: tuple[ExtensionPort, ...] + revision: int = 1 + generation: int = 1 + abi_major: int | None = None + abi_minor: int | None = None + + def __post_init__(self) -> None: + ports = tuple(self.ports) + if any(not isinstance(port, ExtensionPort) for port in ports): + raise TypeError("ports must contain only ExtensionPort values") + object.__setattr__(self, "ports", ports) + current = ExtensionAbiVersion.current() + major = current.abi_major if self.abi_major is None else self.abi_major + minor = current.abi_minor if self.abi_minor is None else self.abi_minor + _native_call( + lambda: _validate_descriptor( + self.extension_id, + self.kind.value, + self.revision, + self.generation, + major, + minor, + [port._native_tuple() for port in ports], + ) + ) + object.__setattr__(self, "abi_major", major) + object.__setattr__(self, "abi_minor", minor) + + +@dataclass(frozen=True, slots=True) +class NativeExtensionRegistration: + """One source, operator, or endpoint imported into a Session.""" + + id: str + kind: ExtensionKind + revision: int + generation: int + + @classmethod + def _from_native( + cls, + native: _NativeExtensionRegistration, + ) -> NativeExtensionRegistration: + return cls( + id=native.id, + kind=ExtensionKind(native.kind), + revision=native.revision, + generation=native.generation, + ) + + +@dataclass(frozen=True, slots=True) +class NativeExtensionLibrary: + """Immutable receipt for one library imported into a native Session.""" + + canonical_path: Path + registrations: tuple[NativeExtensionRegistration, ...] + + @classmethod + def _from_native(cls, native: _NativeExtensionLibrary) -> NativeExtensionLibrary: + return cls( + canonical_path=Path(native.canonical_path), + registrations=tuple( + NativeExtensionRegistration._from_native(registration) + for registration in native.registrations + ), + ) + + +__all__ = [ + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "NativeExtensionLibrary", + "NativeExtensionRegistration", +] diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py new file mode 100644 index 0000000..a147745 --- /dev/null +++ b/python/pocketstation/graph.py @@ -0,0 +1,923 @@ +"""Pythonic graph declarations lowered by the canonical Rust ``Session``.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, TypeAlias + +from ._native import DerivedStream as _NativeDerivedStream +from ._native import Endpoint as _NativeEndpoint +from ._native import OperatorInput as _NativeOperatorInput +from ._native import OperatorInstance as _NativeOperatorInstance +from ._native import Session as _NativeSession +from ._native import SourceInstance as _NativeSourceInstance +from ._native import SourceOutput as _NativeSourceOutput +from ._native import Stem as _NativeStem +from ._native import _EdgeContract as _NativeEdgeContract +from ._native import _EndpointDescriptor as _NativeEndpointDescriptor +from ._native import _MediaCaps as _NativeMediaCaps +from ._native import _PortSpec as _NativePortSpec +from ._native import _SignalSpec as _NativeSignalSpec +from .errors import _native_call + +if TYPE_CHECKING: + from .relay import RelayPublisher, RelayRoute + from .signal import BusSubscription + + +class SignalKind(StrEnum): + ANY = "any" + PCM_AUDIO = "pcm-audio" + ENCODED_AUDIO = "encoded-audio" + TEXT = "text" + EVENT = "event" + METRICS = "metrics" + CONTROL = "control" + BINARY = "binary" + CUSTOM = "custom" + + +class Codec(StrEnum): + OPUS = "opus" + AAC = "aac" + MP3 = "mp3" + G711_ULAW = "g711-ulaw" + G711_ALAW = "g711-alaw" + WEBM_OPUS = "webm-opus" + + +class TextFormat(StrEnum): + UTF8 = "utf8" + JSON = "json" + MARKDOWN = "markdown" + + +class EventFormat(StrEnum): + JSON = "json" + PROTOBUF = "protobuf" + FLATBUFFERS = "flatbuffers" + CBOR = "cbor" + + +class BinaryFormat(StrEnum): + RAW = "raw" + PROTOBUF = "protobuf" + FLATBUFFERS = "flatbuffers" + CBOR = "cbor" + + +SignalFormat: TypeAlias = Codec | TextFormat | EventFormat | BinaryFormat + + +@dataclass(frozen=True, slots=True) +class SignalSpec: + """Stable language-neutral signal identity, role, and schema contract.""" + + kind: SignalKind + format: SignalFormat | None = None + custom_id: str | None = None + role: str | None = None + schema: str | None = None + _native: _NativeSignalSpec = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeSignalSpec( + self.kind.value, + None if self.format is None else self.format.value, + self.custom_id, + self.role, + self.schema, + ) + ) + object.__setattr__(self, "_native", native) + + @classmethod + def _from_native(cls, native: _NativeSignalSpec) -> SignalSpec: + kind = SignalKind(native.kind) + format_value: SignalFormat | None = None + if native.format is not None: + if kind is SignalKind.ENCODED_AUDIO: + format_value = Codec(native.format) + elif kind is SignalKind.TEXT: + format_value = TextFormat(native.format) + elif kind is SignalKind.EVENT: + format_value = EventFormat(native.format) + elif kind is SignalKind.BINARY: + format_value = BinaryFormat(native.format) + else: + raise AssertionError( + f"Rust signal {kind.value!r} exposed an unexpected format" + ) + return cls( + kind, + format_value, + native.custom_id, + native.role, + native.schema, + ) + + @classmethod + def any(cls, *, role: str | None = None, schema: str | None = None) -> SignalSpec: + return cls(SignalKind.ANY, role=role, schema=schema) + + @classmethod + def audio(cls, *, role: str | None = None, schema: str | None = None) -> SignalSpec: + return cls(SignalKind.PCM_AUDIO, role=role, schema=schema) + + @classmethod + def encoded_audio( + cls, + codec: Codec, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec: + return cls(SignalKind.ENCODED_AUDIO, codec, role=role, schema=schema) + + @classmethod + def text( + cls, + format: TextFormat = TextFormat.UTF8, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec: + return cls(SignalKind.TEXT, format, role=role, schema=schema) + + @classmethod + def event( + cls, + format: EventFormat = EventFormat.JSON, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec: + return cls(SignalKind.EVENT, format, role=role, schema=schema) + + @classmethod + def metrics( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec: + return cls(SignalKind.METRICS, role=role, schema=schema) + + @classmethod + def control( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec: + return cls(SignalKind.CONTROL, role=role, schema=schema) + + @classmethod + def binary( + cls, + format: BinaryFormat = BinaryFormat.RAW, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec: + return cls(SignalKind.BINARY, format, role=role, schema=schema) + + @classmethod + def custom( + cls, + signal_id: str, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec: + return cls( + SignalKind.CUSTOM, + custom_id=signal_id, + role=role, + schema=schema, + ) + + @property + def wire_id(self) -> str: + return self._native.wire_id + + @property + def is_audio(self) -> bool: + return self._native.is_audio + + def is_compatible_with(self, other: SignalSpec) -> bool: + return self._native.is_compatible_with(other._native) + + +class MediaKind(StrEnum): + AUDIO_PCM = "audio-pcm" + AUDIO_ENCODED = "audio-encoded" + TEXT = "text" + EVENT = "event" + METRICS = "metrics" + CONTROL = "control" + BINARY = "binary" + ANY = "any" + + +class ChannelLayout(StrEnum): + MONO = "mono" + STEREO = "stereo" + ANY = "any" + + +class SampleFormat(StrEnum): + F32_INTERLEAVED = "f32-interleaved" + + +@dataclass(frozen=True, slots=True) +class AudioCaps: + """Physical PCM constraints; ``None`` means the Rust wildcard.""" + + sample_rate_hz: int | None = None + frame_samples: int | None = None + channel_layout: ChannelLayout = ChannelLayout.ANY + format: SampleFormat = SampleFormat.F32_INTERLEAVED + + +MediaFormat: TypeAlias = Codec | BinaryFormat + + +@dataclass(frozen=True, slots=True) +class MediaCaps: + """Exact Rust media representation projected as an immutable value.""" + + kind: MediaKind + audio_caps: AudioCaps | None = None + format: MediaFormat | None = None + _native: _NativeMediaCaps = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + audio = self.audio_caps + native = _native_call( + lambda: _NativeMediaCaps( + self.kind.value, + None if self.format is None else self.format.value, + None if audio is None else audio.sample_rate_hz, + None if audio is None else audio.frame_samples, + None if audio is None else audio.channel_layout.value, + ) + ) + object.__setattr__(self, "_native", native) + + @classmethod + def audio(cls, caps: AudioCaps | None = None) -> MediaCaps: + return cls(MediaKind.AUDIO_PCM, audio_caps=caps or AudioCaps()) + + @classmethod + def encoded_audio(cls, codec: Codec) -> MediaCaps: + return cls(MediaKind.AUDIO_ENCODED, format=codec) + + @classmethod + def text(cls) -> MediaCaps: + return cls(MediaKind.TEXT) + + @classmethod + def event(cls) -> MediaCaps: + return cls(MediaKind.EVENT) + + @classmethod + def metrics(cls) -> MediaCaps: + return cls(MediaKind.METRICS) + + @classmethod + def control(cls) -> MediaCaps: + return cls(MediaKind.CONTROL) + + @classmethod + def binary(cls, format: BinaryFormat = BinaryFormat.RAW) -> MediaCaps: + return cls(MediaKind.BINARY, format=format) + + @classmethod + def any(cls) -> MediaCaps: + return cls(MediaKind.ANY) + + def is_compatible_with(self, other: MediaCaps) -> bool: + return self._native.is_compatible_with(other._native) + + def supports_signal(self, signal: SignalSpec) -> bool: + return self._native.supports_signal(signal._native) + + +class PortDirection(StrEnum): + INPUT = "input" + OUTPUT = "output" + + +class Multiplicity(StrEnum): + ONE = "one" + MANY = "many" + + +@dataclass(frozen=True, slots=True) +class PortSpec: + """Named typed graph port validated by the Rust ``PortSpec`` owner.""" + + name: str + direction: PortDirection + signal: SignalSpec + media: MediaCaps + multiplicity: Multiplicity = Multiplicity.ONE + required: bool = True + _native: _NativePortSpec = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativePortSpec( + self.name, + self.direction.value, + self.signal._native, + self.media._native, + self.multiplicity.value, + self.required, + ) + ) + object.__setattr__(self, "_native", native) + + +class ClockDomain(StrEnum): + CAPTURE = "capture" + PLAYBACK = "playback" + NETWORK = "network" + INHERITED = "inherited" + WALLCLOCK = "wallclock" + + +class BackpressurePolicy(StrEnum): + DROP_NEWEST = "drop-newest" + DROP_OLDEST = "drop-oldest" + BOUNDED_QUEUE = "bounded-queue" + BLOCK_FORBIDDEN = "block-forbidden" + + +class DeliverySemantics(StrEnum): + BEST_EFFORT_REALTIME = "best-effort-realtime" + ORDERED = "ordered" + EXACTLY_ONCE_NOT_REALTIME = "exactly-once-not-realtime" + + +class LossPolicy(StrEnum): + CONCEAL_FOR_AUDIO = "conceal-for-audio" + MUST_DELIVER_OR_FAIL = "must-deliver-or-fail" + DROP_ALLOWED = "drop-allowed" + + +class CopyPolicy(StrEnum): + MOVE_EXCLUSIVE = "move-exclusive" + SHARE_READ_ONLY = "share-read-only" + COPY_TO_BRANCH_POOL = "copy-to-branch-pool" + + +class EdgeObservabilityLevel(StrEnum): + OFF = "off" + COUNTERS = "counters" + FULL = "full" + + +@dataclass(frozen=True, slots=True) +class EdgeContract: + """Canonical bounded edge preset plus the exact public Rust modifiers.""" + + _native: _NativeEdgeContract = field(repr=False, compare=False) + + @classmethod + def realtime_audio(cls) -> EdgeContract: + return cls(_NativeEdgeContract.realtime_audio()) + + @classmethod + def bounded_async(cls) -> EdgeContract: + return cls(_NativeEdgeContract.bounded_async()) + + @property + def media(self) -> MediaCaps: + return _media_from_native(self._native.media) + + @property + def clock(self) -> ClockDomain: + return ClockDomain(self._native.clock) + + @property + def latency_budget_ms(self) -> int | None: + return self._native.latency_budget_ms + + @property + def jitter_budget_ms(self) -> int | None: + return self._native.jitter_budget_ms + + @property + def backpressure(self) -> BackpressurePolicy: + return BackpressurePolicy(self._native.backpressure) + + @property + def delivery(self) -> DeliverySemantics: + return DeliverySemantics(self._native.delivery) + + @property + def loss(self) -> LossPolicy: + return LossPolicy(self._native.loss) + + @property + def copy_policy(self) -> CopyPolicy: + return CopyPolicy(self._native.copy_policy) + + @property + def observability(self) -> EdgeObservabilityLevel: + return EdgeObservabilityLevel(self._native.observability) + + @property + def max_payload_bytes(self) -> int | None: + return self._native.max_payload_bytes + + def with_media(self, media: MediaCaps) -> EdgeContract: + return type(self)(self._native.with_media(media._native)) + + def with_backpressure(self, policy: BackpressurePolicy) -> EdgeContract: + return type(self)( + _native_call(lambda: self._native.with_backpressure(policy.value)) + ) + + def with_copy_policy(self, policy: CopyPolicy) -> EdgeContract: + return type(self)( + _native_call(lambda: self._native.with_copy_policy(policy.value)) + ) + + def with_jitter_budget_ms(self, budget_ms: int | None) -> EdgeContract: + return type(self)(self._native.with_jitter_budget_ms(budget_ms)) + + def with_max_payload_bytes(self, maximum_bytes: int) -> EdgeContract: + return type(self)( + _native_call(lambda: self._native.with_max_payload_bytes(maximum_bytes)) + ) + + +ConfigurationInput: TypeAlias = Mapping[str, str] | Iterable[tuple[str, str]] + + +def _configuration_items(values: ConfigurationInput) -> tuple[tuple[str, str], ...]: + entries = values.items() if isinstance(values, Mapping) else values + return tuple(sorted(entries)) + + +@dataclass(frozen=True, slots=True, init=False) +class OperatorConfiguration: + values: tuple[tuple[str, str], ...] + + def __init__(self, values: ConfigurationInput = ()) -> None: + object.__setattr__(self, "values", _configuration_items(values)) + + def with_value(self, key: str, value: str) -> OperatorConfiguration: + return type(self)(dict((*self.values, (key, value)))) + + def _as_dict(self) -> dict[str, str]: + return dict(self.values) + + +@dataclass(frozen=True, slots=True, init=False) +class SourceConfiguration: + values: tuple[tuple[str, str], ...] + + def __init__(self, values: ConfigurationInput = ()) -> None: + object.__setattr__(self, "values", _configuration_items(values)) + + def with_value(self, key: str, value: str) -> SourceConfiguration: + return type(self)(dict((*self.values, (key, value)))) + + def _as_dict(self) -> dict[str, str]: + return dict(self.values) + + +@dataclass(frozen=True, slots=True, init=False) +class EndpointConfiguration: + values: tuple[tuple[str, str], ...] + + def __init__(self, values: ConfigurationInput = ()) -> None: + object.__setattr__(self, "values", _configuration_items(values)) + + def with_value(self, key: str, value: str) -> EndpointConfiguration: + return type(self)(dict((*self.values, (key, value)))) + + def _as_dict(self) -> dict[str, str]: + return dict(self.values) + + +@dataclass(frozen=True, slots=True) +class Operator: + """Open operator declaration; implementation registration is a later gate.""" + + operator_id: str + configuration: OperatorConfiguration = field(default_factory=OperatorConfiguration) + + +@dataclass(frozen=True, slots=True) +class EndpointDescriptor: + """Open endpoint declaration lowered and validated by the Rust Session.""" + + node_type_id: str + operator_id: str + configuration: EndpointConfiguration = field(default_factory=EndpointConfiguration) + input_edge: EdgeContract | None = None + _native: _NativeEndpointDescriptor = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeEndpointDescriptor( + self.node_type_id, + self.operator_id, + self.configuration._as_dict(), + None if self.input_edge is None else self.input_edge._native, + ) + ) + object.__setattr__(self, "_native", native) + + +class Endpoint: + """Opaque Session-scoped destination handle.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpoint) -> None: + self._native = native + + @property + def id(self) -> int: + return self._native.id + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def connector_id(self) -> int | None: + return self._native.connector_id + + +class OperatorInput: + """One explicit named input on a Session-owned operator instance.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeOperatorInput) -> None: + self._native = native + + @property + def port_name(self) -> str: + return self._native.port_name + + +class OperatorInstance: + """Session-scoped operator instance with explicit named ports.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeOperatorInstance) -> None: + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def instance_id(self) -> int: + return self._native.instance_id + + def input(self, port_name: str) -> OperatorInput: + return _native_call(lambda: OperatorInput(self._native.input(port_name))) + + def output(self, port_name: str) -> DerivedStream: + return _native_call(lambda: DerivedStream(self._native.output(port_name))) + + +class _RoutableStream: + __slots__ = () + + _native: _NativeStem | _NativeDerivedStream | _NativeSourceOutput + + def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> int: + if input_port is None: + return _native_call(lambda: self._native.send(endpoint._native)) + return _native_call(lambda: self._native.send_to(endpoint._native, input_port)) + + def connect(self, input: OperatorInput) -> int: + return _native_call(lambda: self._native.connect(input._native)) + + def through( + self, + operator: Operator, + *, + input_port: str | None = None, + output_port: str | None = None, + ) -> DerivedStream: + return _native_call( + lambda: DerivedStream( + self._native.through( + operator.operator_id, + operator.configuration._as_dict(), + input_port, + output_port, + ) + ) + ) + + +class Stem(_RoutableStream): + """Independent source-aware PCM path declared on a Session.""" + + __slots__ = ("_native",) + _native: _NativeStem + + def __init__(self, native: _NativeStem) -> None: + self._native = native + + @property + def id(self) -> int: + return self._native.id + + @property + def session_id(self) -> int: + return self._native.session_id + + def record(self, stem_name: str) -> Endpoint: + return _native_call(lambda: Endpoint(self._native.record(stem_name))) + + def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: + """Publish this stem as one named bus through the Rust connector.""" + from .relay import RelayPublisher, RelayRoute + + if not isinstance(publisher, RelayPublisher): + raise TypeError("publisher must be a RelayPublisher") + route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) + return RelayRoute(bus_id=bus_id, route_id=route_id) + + +class DerivedStream(_RoutableStream): + """Named operator output that remains owned by the Rust Session draft.""" + + __slots__ = ("_native",) + _native: _NativeDerivedStream + + def __init__(self, native: _NativeDerivedStream) -> None: + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def operator_instance_id(self) -> int: + return self._native.operator_instance_id + + @property + def output_port(self) -> str | None: + return self._native.output_port + + def output(self, port_name: str) -> DerivedStream: + return _native_call(lambda: type(self)(self._native.output(port_name))) + + def reenter_audio(self) -> Stem: + """Declare canonical generated-PCM reentry; no Python callback runs.""" + return _native_call(lambda: Stem(self._native.reenter_audio())) + + +class SourceInstance: + """Open registered source declaration scoped to one Session.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeSourceInstance) -> None: + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def instance_id(self) -> int: + return self._native.instance_id + + @property + def source_id(self) -> int: + return self._native.source_id + + def output(self, port_name: str) -> SourceOutput: + return _native_call(lambda: SourceOutput(self._native.output(port_name))) + + +class SourceOutput(_RoutableStream): + """One named output from an externally registered source instance.""" + + __slots__ = ("_native",) + _native: _NativeSourceOutput + + def __init__(self, native: _NativeSourceOutput) -> None: + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def source_instance_id(self) -> int: + return self._native.source_instance_id + + @property + def source_id(self) -> int: + return self._native.source_id + + @property + def stream_id(self) -> int: + return self._native.stream_id + + @property + def output_port(self) -> str: + return self._native.output_port + + def record(self, stem_name: str) -> Endpoint: + return _native_call(lambda: Endpoint(self._native.record(stem_name))) + + def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: + """Publish this source output as one named Relay AudioBus.""" + from .relay import RelayPublisher, RelayRoute + + if not isinstance(publisher, RelayPublisher): + raise TypeError("publisher must be a RelayPublisher") + route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) + return RelayRoute(bus_id=bus_id, route_id=route_id) + + +class _GraphSessionDeclarations: + """One shared sync/async policy for immediate Rust draft declarations.""" + + _native: _NativeSession + + @property + def id(self) -> int: + """Stable identity allocated by the canonical Rust Session.""" + return self._native.id + + def source( + self, + source_type_id: str, + configuration: SourceConfiguration | None = None, + ) -> SourceInstance: + """Declare one instance of an open, externally registered source.""" + values = SourceConfiguration() if configuration is None else configuration + return _native_call( + lambda: SourceInstance( + self._native.source(source_type_id, values._as_dict()) + ) + ) + + def operator(self, operator: Operator) -> OperatorInstance: + """Declare one open operator instance with named ports.""" + return _native_call( + lambda: OperatorInstance( + self._native.operator( + operator.operator_id, + operator.configuration._as_dict(), + ) + ) + ) + + def endpoint(self, descriptor: EndpointDescriptor) -> Endpoint: + """Declare one open endpoint descriptor on the canonical draft.""" + return _native_call(lambda: Endpoint(self._native.endpoint(descriptor._native))) + + def connector( + self, + operator_id: str, + configuration: EndpointConfiguration | None = None, + ) -> Endpoint: + """Declare an external connector endpoint without provider taxonomy.""" + values = EndpointConfiguration() if configuration is None else configuration + return _native_call( + lambda: Endpoint(self._native.connector(operator_id, values._as_dict())) + ) + + def browser(self, receiver_uri: str) -> Endpoint: + """Declare the frozen browser/remote receiver endpoint contract.""" + return _native_call(lambda: Endpoint(self._native.browser(receiver_uri))) + + def subscribe( + self, + stream: DerivedStream | SourceOutput, + *, + signal: SignalSpec, + edge: EdgeContract | None = None, + ) -> BusSubscription: + """Declare one bounded, exclusive typed-signal subscription. + + The subscription is a real endpoint in the canonical Rust Session. + Python owns no additional queue, router, or background pump. + """ + from .signal import BusSubscription + + contract = ( + EdgeContract.bounded_async().with_media(_media_for_signal(signal)) + if edge is None + else edge + ) + if isinstance(stream, DerivedStream): + native = _native_call( + lambda: self._native.subscribe_derived( + stream._native, + signal._native, + contract._native, + ) + ) + elif isinstance(stream, SourceOutput): + native = _native_call( + lambda: self._native.subscribe_source_output( + stream._native, + signal._native, + contract._native, + ) + ) + else: + raise TypeError("stream must be a DerivedStream or SourceOutput") + return BusSubscription(native) + + +def _media_from_native(native: _NativeMediaCaps) -> MediaCaps: + kind = MediaKind(native.kind) + if kind is MediaKind.AUDIO_PCM: + caps = AudioCaps( + sample_rate_hz=native.sample_rate_hz, + frame_samples=native.frame_samples, + channel_layout=ChannelLayout(native.channel_layout or "any"), + ) + return MediaCaps(kind, audio_caps=caps) + if kind is MediaKind.AUDIO_ENCODED: + format = native.format + if format is None: + raise AssertionError("Rust encoded-audio media omitted its codec") + return MediaCaps(kind, format=Codec(format)) + if kind is MediaKind.BINARY: + format = native.format + if format is None: + raise AssertionError("Rust binary media omitted its format") + return MediaCaps(kind, format=BinaryFormat(format)) + return MediaCaps(kind) + + +def _media_for_signal(signal: SignalSpec) -> MediaCaps: + if signal.kind is SignalKind.PCM_AUDIO: + return MediaCaps.audio() + if signal.kind is SignalKind.ENCODED_AUDIO: + if not isinstance(signal.format, Codec): + raise AssertionError("encoded-audio SignalSpec omitted its codec") + return MediaCaps.encoded_audio(signal.format) + if signal.kind is SignalKind.TEXT: + return MediaCaps.text() + if signal.kind is SignalKind.EVENT: + return MediaCaps.event() + if signal.kind is SignalKind.METRICS: + return MediaCaps.metrics() + if signal.kind is SignalKind.CONTROL: + return MediaCaps.control() + if signal.kind is SignalKind.BINARY: + format = ( + signal.format + if isinstance(signal.format, BinaryFormat) + else BinaryFormat.RAW + ) + return MediaCaps.binary(format) + return MediaCaps.any() + + +__all__ = [ + "AudioCaps", + "BackpressurePolicy", + "BinaryFormat", + "ChannelLayout", + "ClockDomain", + "Codec", + "CopyPolicy", + "DeliverySemantics", + "DerivedStream", + "EdgeContract", + "EdgeObservabilityLevel", + "Endpoint", + "EndpointConfiguration", + "EndpointDescriptor", + "EventFormat", + "LossPolicy", + "MediaCaps", + "MediaKind", + "Multiplicity", + "Operator", + "OperatorConfiguration", + "OperatorInput", + "OperatorInstance", + "PortDirection", + "PortSpec", + "SampleFormat", + "SignalKind", + "SignalSpec", + "SourceConfiguration", + "SourceInstance", + "SourceOutput", + "Stem", + "TextFormat", +] diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py new file mode 100644 index 0000000..25019ba --- /dev/null +++ b/python/pocketstation/observations.py @@ -0,0 +1,1095 @@ +"""Typed, immutable observations from the canonical native Session.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import cast + +from ._native import RecordingDiscontinuity as _NativeRecordingDiscontinuity +from ._native import RecordingOutcome as _NativeRecordingOutcome +from ._native import RecordingStemOutcome as _NativeRecordingStemOutcome +from ._native import RelayPublishOutcome as _NativeRelayPublishOutcome +from ._native import RouteMetrics as _NativeRouteMetrics +from ._native import SessionEvent as _NativeSessionEvent +from ._native import SessionMetrics as _NativeSessionMetrics +from ._native import SessionTrace as _NativeSessionTrace +from ._native import SessionTraceRecorderOutcome as _NativeTraceRecorderOutcome +from ._native import StopResult as _NativeStopResult +from ._native import _AudioReentryMetrics as _NativeAudioReentryMetrics +from ._native import _DerivedRouteMetrics as _NativeDerivedRouteMetrics +from ._native import _EdgeMetrics as _NativeEdgeMetrics +from ._native import _ExternalSourceMetrics as _NativeExternalSourceMetrics +from ._native import _OperatorInputMetrics as _NativeOperatorInputMetrics +from ._native import _OperatorMetrics as _NativeOperatorMetrics +from ._native import _OperatorWorkerMetrics as _NativeOperatorWorkerMetrics +from ._native import _SessionFailure as _NativeSessionFailure +from ._native import _SessionSourceMetrics as _NativeSessionSourceMetrics +from ._native import _SessionTraceValidation as _NativeTraceValidation +from ._native import _TypedEdgeMetrics as _NativeTypedEdgeMetrics +from .errors import PocketStationError, _native_call +from .sidecar import SidecarSnapshot +from .sources import SourceRuntimeEvent +from .streams import ( + _DEFAULT_ITERATION_TIMEOUT_SECONDS, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + + +class SessionEventType(StrEnum): + """Stable variants of the authoritative Session event stream.""" + + LIFECYCLE = "lifecycle" + SOURCE_FAILURE = "source_failure" + ENDPOINT_FAILURE = "endpoint_failure" + ROLLBACK_FAILURE = "rollback_failure" + FINALIZATION_FAILURE = "finalization_failure" + TERMINAL = "terminal" + + +class SessionLifecycleState(StrEnum): + STARTING = "starting" + RUNNING = "running" + STOPPING = "stopping" + STOPPED = "stopped" + FAILED = "failed" + + +class SessionTerminalState(StrEnum): + STOPPED = "stopped" + FAILED = "failed" + + +class SessionFailureKind(StrEnum): + SOURCE = "source" + ENDPOINT = "endpoint" + ROLLBACK = "rollback" + FINALIZATION = "finalization" + + +class EndpointFailureStage(StrEnum): + PREPARE = "prepare" + CANCEL_PREPARATION = "cancel-preparation" + START = "start" + REQUEST_STOP = "request-stop" + JOIN_FINALIZE = "join-finalize" + + +class SessionRollbackStage(StrEnum): + CANCEL_OPERATOR = "cancel-operator" + CANCEL_ENDPOINT_PREPARATION = "cancel-endpoint-preparation" + FINALIZE_STARTED_ENDPOINT = "finalize-started-endpoint" + STOP_OPENED_CAPTURE = "stop-opened-capture" + DISCARD_RUNTIME_QUEUES = "discard-runtime-queues" + + +class SessionFinalizationStage(StrEnum): + STOP_CAPTURE = "stop-capture" + DRAIN_RUNTIME = "drain-runtime" + DRAIN_OPERATOR = "drain-operator" + REQUEST_ENDPOINT_STOP = "request-endpoint-stop" + JOIN_ENDPOINT = "join-endpoint" + FINALIZE_ENDPOINT = "finalize-endpoint" + DRAIN_SIDECAR = "drain-sidecar" + + +class EndpointObservationStage(StrEnum): + UNAVAILABLE = "unavailable" + LIVE = "live" + FINALIZED = "finalized" + + +class TerminationDisposition(StrEnum): + STOPPED = "stopped" + CANCELLED = "cancelled" + ALREADY_STOPPED = "already-stopped" + + +class RecordingState(StrEnum): + RECORDING = "recording" + COMPLETE = "complete" + INCOMPLETE = "incomplete" + + +class RecordingDiscontinuityKind(StrEnum): + TIMESTAMP_GAP = "timestamp-gap" + SEQUENCE_GAP = "sequence-gap" + OVERLAP_REJECTED = "overlap-rejected" + + +class RouteObservationInterval(StrEnum): + ROUTE_LIFETIME_TO_SNAPSHOT = "route-lifetime-to-snapshot" + + +class RouteLatencyBoundary(StrEnum): + SOURCE_TIMESTAMP_TO_ROUTE_RECEIVE = "source-monotonic-timestamp-to-route-receive" + + +class RouteLatencyUnit(StrEnum): + NANOSECONDS = "nanoseconds" + + +FailureStage = EndpointFailureStage | SessionRollbackStage | SessionFinalizationStage + + +def _failure_stage(kind: SessionFailureKind, value: str | None) -> FailureStage | None: + if value is None: + return None + if kind is SessionFailureKind.ENDPOINT: + return EndpointFailureStage(value) + if kind is SessionFailureKind.ROLLBACK: + return SessionRollbackStage(value) + if kind is SessionFailureKind.FINALIZATION: + return SessionFinalizationStage(value) + return None + + +@dataclass(frozen=True, slots=True) +class SessionFailure: + """One typed source, endpoint, rollback, or finalization failure.""" + + kind: SessionFailureKind + stage: FailureStage | None + operation: str | None + error_class: str | None + component: str | None + message: str | None + stem_id: int | None + route_id: int | None + endpoint_id: int | None + operator_instance_id: int | None + sidecar_id: int | None + source: SourceRuntimeEvent | None + + @classmethod + def _from_native(cls, failure: _NativeSessionFailure) -> SessionFailure: + kind = SessionFailureKind(failure.kind) + return cls( + kind=kind, + stage=_failure_stage(kind, failure.stage), + operation=failure.operation, + error_class=failure.error_class, + component=failure.component, + message=failure.message, + stem_id=failure.stem_id, + route_id=failure.route_id, + endpoint_id=failure.endpoint_id, + operator_instance_id=failure.operator_instance_id, + sidecar_id=failure.sidecar_id, + source=SourceRuntimeEvent._from_native(cast(_NativeSessionEvent, failure)), + ) + + +@dataclass(frozen=True, slots=True) +class SessionEvent: + """One immutable projection of an authoritative native Session event.""" + + kind: SessionEventType + lifecycle_state: SessionLifecycleState | None + session_id: int + stem_id: int | None + endpoint_id: int | None + route_id: int | None + failures: tuple[SessionFailure, ...] + terminal_state: SessionTerminalState | None + source: SourceRuntimeEvent | None + + @property + def failures_total(self) -> int: + return len(self.failures) + + @classmethod + def _from_native(cls, event: _NativeSessionEvent) -> SessionEvent: + lifecycle_state = ( + None + if event.lifecycle_state is None + else SessionLifecycleState(event.lifecycle_state) + ) + terminal_state = ( + None + if event.terminal_state is None + else SessionTerminalState(event.terminal_state) + ) + failures = tuple( + SessionFailure._from_native(failure) for failure in event.failures() + ) + if event.failures_total != len(failures): + raise PocketStationError( + "native Session event failure count is inconsistent", + "session.invalid_event", + ) + return cls( + kind=SessionEventType(event.kind), + lifecycle_state=lifecycle_state, + session_id=event.session_id, + stem_id=event.stem_id, + endpoint_id=event.endpoint_id, + route_id=event.route_id, + failures=failures, + terminal_state=terminal_state, + source=SourceRuntimeEvent._from_native(event), + ) + + +@dataclass(frozen=True, slots=True) +class EventQueueMetrics: + capacity_count: int + maximum_event_owned_bytes: int + maximum_buffered_owned_bytes: int + depth_count: int + depth_owned_bytes: int + peak_depth_count: int + peak_depth_owned_bytes: int + enqueued_total: int + dropped_total: int + dropped_oversized_total: int + receiver_closed_total: int + + +@dataclass(frozen=True, slots=True) +class PolledAudioMetrics: + registered_endpoints: int + queue_capacity_frames: int + queue_depth_frames: int + queue_peak_frames: int + queue_depth_invariant_failures_total: int + frames_received_total: int + frames_delivered_total: int + queue_full_drops_total: int + invalid_ownership_drops_total: int + lease_capacity_count: int + outstanding_leases: int + lease_exhausted_total: int + batches_polled_total: int + frames_polled_total: int + + +@dataclass(frozen=True, slots=True) +class LatencyHistogram: + samples_total: int + invalid_order_total: int + missing_total: int + future_total: int + p50_ns: int + p95_ns: int + p99_ns: int + max_ns: int + + +@dataclass(frozen=True, slots=True) +class EdgeMetrics: + queue_capacity_frames: int + queue_depth_frames: int + queue_peak_frames: int + frames_enqueued_total: int + frames_delivered_total: int + frames_dropped_total: int + overruns_total: int + receiver_unavailable_drops_total: int + queue_full_drops_total: int + shared_reference_exhausted_drops_total: int + branch_pool_exhausted_drops_total: int + invalid_copy_policy_drops_total: int + freeze_failed_drops_total: int + discontinuities_total: int + source_identity_discontinuities_total: int + sequence_discontinuities_total: int + timestamp_discontinuities_total: int + lineage_epoch_discontinuities_total: int + manually_reported_discontinuities_total: int + enqueue_to_receive: LatencyHistogram + source_timestamp_to_receive: LatencyHistogram + worker_failures_total: int + shutdown_discarded_total: int + + @classmethod + def _from_native(cls, value: _NativeEdgeMetrics) -> EdgeMetrics: + return cls( + queue_capacity_frames=value.queue_capacity_frames, + queue_depth_frames=value.queue_depth_frames, + queue_peak_frames=value.queue_peak_frames, + frames_enqueued_total=value.frames_enqueued_total, + frames_delivered_total=value.frames_delivered_total, + frames_dropped_total=value.frames_dropped_total, + overruns_total=value.overruns_total, + receiver_unavailable_drops_total=value.receiver_unavailable_drops_total, + queue_full_drops_total=value.queue_full_drops_total, + shared_reference_exhausted_drops_total=value.shared_reference_exhausted_drops_total, + branch_pool_exhausted_drops_total=value.branch_pool_exhausted_drops_total, + invalid_copy_policy_drops_total=value.invalid_copy_policy_drops_total, + freeze_failed_drops_total=value.freeze_failed_drops_total, + discontinuities_total=value.discontinuities_total, + source_identity_discontinuities_total=value.source_identity_discontinuities_total, + sequence_discontinuities_total=value.sequence_discontinuities_total, + timestamp_discontinuities_total=value.timestamp_discontinuities_total, + lineage_epoch_discontinuities_total=value.lineage_epoch_discontinuities_total, + manually_reported_discontinuities_total=value.manually_reported_discontinuities_total, + enqueue_to_receive=LatencyHistogram( + samples_total=value.enqueue_to_receive_samples_total, + invalid_order_total=value.enqueue_to_receive_invalid_order_total, + missing_total=0, + future_total=0, + p50_ns=value.enqueue_to_receive_p50_ns, + p95_ns=value.enqueue_to_receive_p95_ns, + p99_ns=value.enqueue_to_receive_p99_ns, + max_ns=value.enqueue_to_receive_max_ns, + ), + source_timestamp_to_receive=LatencyHistogram( + samples_total=value.source_timestamp_to_receive_samples_total, + invalid_order_total=0, + missing_total=value.source_timestamp_to_receive_missing_total, + future_total=value.source_timestamp_to_receive_future_total, + p50_ns=value.source_timestamp_to_receive_p50_ns, + p95_ns=value.source_timestamp_to_receive_p95_ns, + p99_ns=value.source_timestamp_to_receive_p99_ns, + max_ns=value.source_timestamp_to_receive_max_ns, + ), + worker_failures_total=value.worker_failures_total, + shutdown_discarded_total=value.shutdown_discarded_total, + ) + + +@dataclass(frozen=True, slots=True) +class EndpointMetrics: + observation_stage: EndpointObservationStage + frames_received_total: int + frames_delivered_total: int + frames_dropped_total: int + discontinuities_total: int + failures_total: int + finalization_failures_total: int + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + route_id: int + endpoint_id: int + edge: EdgeMetrics + endpoint: EndpointMetrics + frames_attempted_total: int + observation_interval: RouteObservationInterval + drop_rate_pct: float + source_latency_boundary: RouteLatencyBoundary + source_latency_unit: RouteLatencyUnit + + @property + def queue_capacity_frames(self) -> int: + return self.edge.queue_capacity_frames + + @property + def frames_delivered_total(self) -> int: + return self.edge.frames_delivered_total + + @property + def frames_dropped_total(self) -> int: + return self.edge.frames_dropped_total + + @classmethod + def _from_native(cls, value: _NativeRouteMetrics) -> RouteMetrics: + edge = EdgeMetrics._from_native(cast(_NativeEdgeMetrics, value)) + return cls( + route_id=value.route_id, + endpoint_id=value.endpoint_id, + edge=edge, + endpoint=EndpointMetrics( + observation_stage=EndpointObservationStage( + value.endpoint_observation_stage + ), + frames_received_total=value.endpoint_frames_received_total, + frames_delivered_total=value.endpoint_frames_delivered_total, + frames_dropped_total=value.endpoint_frames_dropped_total, + discontinuities_total=value.endpoint_discontinuities_total, + failures_total=value.endpoint_failures_total, + finalization_failures_total=value.endpoint_finalization_failures_total, + ), + frames_attempted_total=value.frames_attempted_total, + observation_interval=RouteObservationInterval( + value.drop_observation_interval + ), + drop_rate_pct=value.drop_rate_pct, + source_latency_boundary=RouteLatencyBoundary(value.source_latency_boundary), + source_latency_unit=RouteLatencyUnit(value.source_latency_unit), + ) + + +@dataclass(frozen=True, slots=True) +class SourceMetrics: + stem_id: int + callback_buffers_total: int + capture_frames_enqueued_total: int + capture_pool_exhausted_total: int + capture_dispatch_queue_full_total: int + capture_invalid_buffer_total: int + capture_oversized_buffer_total: int + capture_stream_errors_total: int + capture_timestamp_epoch_clamps_total: int + frame_stream_delivered_frames_total: int + frame_stream_dropped_newest_frames_total: int + frames_discarded_before_start_total: int + runtime_event_queue: EventQueueMetrics + ingress_queue_capacity_frames: int + ingress_queue_depth_frames: int + ingress_queue_peak_frames: int + ingress_frames_enqueued_total: int + ingress_frames_delivered_total: int + ingress_frames_rejected_full_total: int + ingress_frames_rejected_cancelled_total: int + ingress_frames_discarded_total: int + + @classmethod + def _from_native(cls, value: _NativeSessionSourceMetrics) -> SourceMetrics: + return cls( + stem_id=value.stem_id, + callback_buffers_total=value.callback_buffers_total, + capture_frames_enqueued_total=value.capture_frames_enqueued_total, + capture_pool_exhausted_total=value.capture_pool_exhausted_total, + capture_dispatch_queue_full_total=value.capture_dispatch_queue_full_total, + capture_invalid_buffer_total=value.capture_invalid_buffer_total, + capture_oversized_buffer_total=value.capture_oversized_buffer_total, + capture_stream_errors_total=value.capture_stream_errors_total, + capture_timestamp_epoch_clamps_total=value.capture_timestamp_epoch_clamps_total, + frame_stream_delivered_frames_total=value.frame_stream_delivered_frames_total, + frame_stream_dropped_newest_frames_total=value.frame_stream_dropped_newest_frames_total, + frames_discarded_before_start_total=value.frames_discarded_before_start_total, + runtime_event_queue=EventQueueMetrics( + capacity_count=value.runtime_event_capacity_count, + maximum_event_owned_bytes=value.runtime_event_maximum_event_owned_bytes, + maximum_buffered_owned_bytes=value.runtime_event_maximum_buffered_owned_bytes, + depth_count=value.runtime_event_depth_count, + depth_owned_bytes=value.runtime_event_depth_owned_bytes, + peak_depth_count=0, + peak_depth_owned_bytes=value.runtime_event_peak_depth_owned_bytes, + enqueued_total=value.runtime_events_enqueued_total, + dropped_total=value.runtime_events_dropped_total, + dropped_oversized_total=value.runtime_events_dropped_oversized_total, + receiver_closed_total=0, + ), + ingress_queue_capacity_frames=value.ingress_queue_capacity_frames, + ingress_queue_depth_frames=value.ingress_queue_depth_frames, + ingress_queue_peak_frames=value.ingress_queue_peak_frames, + ingress_frames_enqueued_total=value.ingress_frames_enqueued_total, + ingress_frames_delivered_total=value.ingress_frames_delivered_total, + ingress_frames_rejected_full_total=value.ingress_frames_rejected_full_total, + ingress_frames_rejected_cancelled_total=value.ingress_frames_rejected_cancelled_total, + ingress_frames_discarded_total=value.ingress_frames_discarded_total, + ) + + +@dataclass(frozen=True, slots=True) +class ExternalSourceMetrics: + source_instance_id: int + source_id: int + emitted_total: int + dropped_total: int + failure_total: int + cancellation_total: int + discontinuity_total: int + recovery_total: int + policy_change_total: int + ready: bool + joined: bool + + @classmethod + def _from_native(cls, value: _NativeExternalSourceMetrics) -> ExternalSourceMetrics: + return cls(**{name: getattr(value, name) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True, slots=True) +class TypedEdgeMetrics: + capacity_signals: int + max_payload_bytes: int + maximum_buffered_payload_bytes: int + depth_signals: int + peak_depth_signals: int + enqueued_total: int + received_total: int + dropped_total: int + + @classmethod + def _from_native(cls, value: _NativeTypedEdgeMetrics) -> TypedEdgeMetrics: + return cls(**{name: getattr(value, name) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True, slots=True) +class OperatorWorkerMetrics: + input_attempted_total: int + input_dropped_total: int + processed_total: int + output_emitted_total: int + output_dropped_total: int + output_nonterminal_total: int + output_terminal_total: int + process_failure_total: int + timeout_total: int + cancellation_total: int + graceful_finish_total: int + idle_poll_total: int + ready: bool + joined: bool + + @classmethod + def _from_native(cls, value: _NativeOperatorWorkerMetrics) -> OperatorWorkerMetrics: + return cls(**{name: getattr(value, name) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True, slots=True) +class OperatorInputMetrics: + port_name: str + edge: EdgeMetrics + + @classmethod + def _from_native(cls, value: _NativeOperatorInputMetrics) -> OperatorInputMetrics: + return cls(port_name=value.port_name, edge=EdgeMetrics._from_native(value.edge)) + + +@dataclass(frozen=True, slots=True) +class OperatorMetrics: + operator_instance_id: int + input_edge: EdgeMetrics + worker: OperatorWorkerMetrics + finalization_failures_total: int + input_ports: tuple[OperatorInputMetrics, ...] + + @classmethod + def _from_native(cls, value: _NativeOperatorMetrics) -> OperatorMetrics: + return cls( + operator_instance_id=value.operator_instance_id, + input_edge=EdgeMetrics._from_native(value.input_edge), + worker=OperatorWorkerMetrics._from_native(value.worker), + finalization_failures_total=value.finalization_failures_total, + input_ports=tuple( + OperatorInputMetrics._from_native(port) for port in value.input_ports() + ), + ) + + +@dataclass(frozen=True, slots=True) +class DerivedRouteMetrics: + route_id: int + endpoint_id: int + output: TypedEdgeMetrics + endpoint: EndpointMetrics + + @classmethod + def _from_native(cls, value: _NativeDerivedRouteMetrics) -> DerivedRouteMetrics: + return cls( + route_id=value.route_id, + endpoint_id=value.endpoint_id, + output=TypedEdgeMetrics._from_native(value.output), + endpoint=EndpointMetrics( + observation_stage=EndpointObservationStage( + value.endpoint_observation_stage + ), + frames_received_total=value.endpoint_frames_received_total, + frames_delivered_total=value.endpoint_frames_delivered_total, + frames_dropped_total=value.endpoint_frames_dropped_total, + discontinuities_total=value.endpoint_discontinuities_total, + failures_total=value.endpoint_failures_total, + finalization_failures_total=value.endpoint_finalization_failures_total, + ), + ) + + +@dataclass(frozen=True, slots=True) +class AudioReentryMetrics: + operator_instance_id: int + stem_id: int + queue_capacity_signals: int + queue_depth_signals: int + queue_peak_signals: int + signals_enqueued_total: int + signals_received_total: int + signals_dropped_total: int + pool_slots: int + frame_capacity_samples: int + maximum_buffered_audio_bytes: int + normalized_total: int + invalid_total: int + shared_audio_rejected_total: int + pool_exhausted_total: int + ingress_rejected_total: int + audio_frames_enqueued_total: int + cancellation_total: int + joined: bool + + @classmethod + def _from_native(cls, value: _NativeAudioReentryMetrics) -> AudioReentryMetrics: + return cls(**{name: getattr(value, name) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True, slots=True) +class SessionMetrics: + event_queue: EventQueueMetrics + polled_audio: PolledAudioMetrics + sources: tuple[SourceMetrics, ...] + external_sources: tuple[ExternalSourceMetrics, ...] + routes: tuple[RouteMetrics, ...] + operators: tuple[OperatorMetrics, ...] + derived_routes: tuple[DerivedRouteMetrics, ...] + audio_reentries: tuple[AudioReentryMetrics, ...] + + @property + def source_count(self) -> int: + return len(self.sources) + + @property + def route_count(self) -> int: + return len(self.routes) + + @property + def audio_queue_capacity_frames(self) -> int: + return self.polled_audio.queue_capacity_frames + + @property + def audio_queue_full_drops_total(self) -> int: + return self.polled_audio.queue_full_drops_total + + @classmethod + def _from_native(cls, value: _NativeSessionMetrics) -> SessionMetrics: + result = cls( + event_queue=EventQueueMetrics( + capacity_count=value.event_capacity_count, + maximum_event_owned_bytes=value.event_maximum_event_owned_bytes, + maximum_buffered_owned_bytes=value.event_maximum_buffered_owned_bytes, + depth_count=value.event_depth_count, + depth_owned_bytes=value.event_depth_owned_bytes, + peak_depth_count=value.event_peak_depth_count, + peak_depth_owned_bytes=value.event_peak_depth_owned_bytes, + enqueued_total=value.events_enqueued_total, + dropped_total=value.events_dropped_total, + dropped_oversized_total=value.events_dropped_oversized_total, + receiver_closed_total=value.event_receiver_closed_total, + ), + polled_audio=PolledAudioMetrics( + registered_endpoints=value.audio_registered_endpoints, + queue_capacity_frames=value.audio_queue_capacity_frames, + queue_depth_frames=value.audio_queue_depth_frames, + queue_peak_frames=value.audio_queue_peak_frames, + queue_depth_invariant_failures_total=value.audio_queue_depth_invariant_failures_total, + frames_received_total=value.audio_frames_received_total, + frames_delivered_total=value.audio_frames_delivered_total, + queue_full_drops_total=value.audio_queue_full_drops_total, + invalid_ownership_drops_total=value.audio_invalid_ownership_drops_total, + lease_capacity_count=value.audio_lease_capacity_count, + outstanding_leases=value.audio_outstanding_leases, + lease_exhausted_total=value.audio_lease_exhausted_total, + batches_polled_total=value.audio_batches_polled_total, + frames_polled_total=value.audio_frames_polled_total, + ), + sources=tuple(SourceMetrics._from_native(item) for item in value.sources), + external_sources=tuple( + ExternalSourceMetrics._from_native(item) + for item in value.external_sources + ), + routes=tuple(RouteMetrics._from_native(item) for item in value.routes), + operators=tuple( + OperatorMetrics._from_native(item) for item in value.operators + ), + derived_routes=tuple( + DerivedRouteMetrics._from_native(item) for item in value.derived_routes + ), + audio_reentries=tuple( + AudioReentryMetrics._from_native(item) for item in value.audio_reentries + ), + ) + expected = ( + value.source_count, + value.external_source_count, + value.route_count, + value.operator_count, + value.derived_route_count, + value.audio_reentry_count, + ) + actual = ( + len(result.sources), + len(result.external_sources), + len(result.routes), + len(result.operators), + len(result.derived_routes), + len(result.audio_reentries), + ) + if actual != expected: + raise PocketStationError( + "native Session metrics counts are inconsistent", + "session.invalid_metrics_snapshot", + ) + return result + + +@dataclass(frozen=True, slots=True) +class RecordingDiscontinuity: + stem_id: int + label: str + kind: RecordingDiscontinuityKind + timestamp_start_ns: int + timestamp_end_ns: int + sequence_start: int | None + sequence_end: int | None + + @classmethod + def _from_native( + cls, value: _NativeRecordingDiscontinuity + ) -> RecordingDiscontinuity: + return cls( + stem_id=value.stem_id, + label=value.label, + kind=RecordingDiscontinuityKind(value.kind), + timestamp_start_ns=value.timestamp_start_ns, + timestamp_end_ns=value.timestamp_end_ns, + sequence_start=value.sequence_start, + sequence_end=value.sequence_end, + ) + + +@dataclass(frozen=True, slots=True) +class RecordingStemOutcome: + stem_name: str + frames_written_total: int + stale_frames_total: int + error: str | None + queue_capacity_frames: int + queue_peak_frames: int + frames_delivered_total: int + frames_dropped_total: int + queue_full_drops_total: int + discontinuities_total: int + discontinuities: tuple[RecordingDiscontinuity, ...] + + @classmethod + def _from_native(cls, value: _NativeRecordingStemOutcome) -> RecordingStemOutcome: + return cls( + stem_name=value.stem_name, + frames_written_total=value.frames_written_total, + stale_frames_total=value.stale_frames_total, + error=value.error, + queue_capacity_frames=value.queue_capacity_frames, + queue_peak_frames=value.queue_peak_frames, + frames_delivered_total=value.frames_delivered_total, + frames_dropped_total=value.frames_dropped_total, + queue_full_drops_total=value.queue_full_drops_total, + discontinuities_total=value.discontinuities_total, + discontinuities=tuple( + RecordingDiscontinuity._from_native(record) + for record in value.discontinuities() + ), + ) + + +@dataclass(frozen=True, slots=True) +class RecordingOutcome: + state: RecordingState + completed_stems: int + failed_stems: int + session_directory: Path + error_code: str | None + stems: tuple[RecordingStemOutcome, ...] + + @property + def complete(self) -> bool: + return self.state is RecordingState.COMPLETE + + @classmethod + def _from_native(cls, value: _NativeRecordingOutcome) -> RecordingOutcome: + result = cls( + state=RecordingState(value.state), + completed_stems=value.completed_stems, + failed_stems=value.failed_stems, + session_directory=Path(value.session_directory), + error_code=value.error_code, + stems=tuple( + RecordingStemOutcome._from_native(stem) for stem in value.stems() + ), + ) + if result.complete != value.complete: + raise PocketStationError( + "native recording terminal state is inconsistent", + "recording.invalid_outcome", + ) + return result + + +@dataclass(frozen=True, slots=True) +class RelayPublishOutcome: + bus_id: str + endpoint_id: int + route_id: int + frames_received_total: int + rtp_packets_sent_total: int + rtp_payload_bytes_sent_total: int + ingress_queue_drops_total: int + publisher_stale_drops_total: int + failures_total: int + error: str | None + + @classmethod + def _from_native(cls, value: _NativeRelayPublishOutcome) -> RelayPublishOutcome: + return cls(**{name: getattr(value, name) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True, slots=True) +class SessionTraceConfiguration: + """Finite native trace configuration attached when the Session starts.""" + + path: Path + capacity_records: int = 256 + + def __init__(self, path: str | Path, capacity_records: int = 256) -> None: + if ( + isinstance(capacity_records, bool) + or not isinstance(capacity_records, int) + or capacity_records <= 0 + ): + raise ValueError("capacity_records must be a positive integer") + object.__setattr__(self, "path", Path(path)) + object.__setattr__(self, "capacity_records", capacity_records) + + +@dataclass(frozen=True, slots=True) +class SessionTraceRecorderOutcome: + path: Path + records_attempted_total: int + records_enqueued_total: int + records_dropped_total: int + records_written_total: int + rolling_hash: int + complete: bool + + @classmethod + def _from_native( + cls, value: _NativeTraceRecorderOutcome + ) -> SessionTraceRecorderOutcome: + return cls( + path=Path(value.path), + records_attempted_total=value.records_attempted_total, + records_enqueued_total=value.records_enqueued_total, + records_dropped_total=value.records_dropped_total, + records_written_total=value.records_written_total, + rolling_hash=value.rolling_hash, + complete=value.complete, + ) + + +@dataclass(frozen=True, slots=True) +class SessionTraceValidation: + session_id: int + lifecycle: tuple[SessionLifecycleState, ...] + terminal_state: SessionTerminalState + source_failures_total: int + endpoint_failures_total: int + rollback_failures_total: int + finalization_failures_total: int + records_validated_total: int + + @classmethod + def _from_native(cls, value: _NativeTraceValidation) -> SessionTraceValidation: + return cls( + session_id=value.session_id, + lifecycle=tuple(SessionLifecycleState(state) for state in value.lifecycle), + terminal_state=SessionTerminalState(value.terminal_state), + source_failures_total=value.source_failures_total, + endpoint_failures_total=value.endpoint_failures_total, + rollback_failures_total=value.rollback_failures_total, + finalization_failures_total=value.finalization_failures_total, + records_validated_total=value.records_validated_total, + ) + + +class SessionTrace: + """Validated reader for one finite native Session trace artifact.""" + + def __init__(self, native: _NativeSessionTrace) -> None: + self._native = native + + @classmethod + def read(cls, path: str | Path) -> SessionTrace: + return cls(_native_call(lambda: _NativeSessionTrace.read(Path(path)))) + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def records_total(self) -> int: + return self._native.records_total + + @property + def outcome(self) -> SessionTraceRecorderOutcome: + return SessionTraceRecorderOutcome._from_native(self._native.outcome) + + def validate(self) -> SessionTraceValidation: + return SessionTraceValidation._from_native(_native_call(self._native.validate)) + + +@dataclass(frozen=True, slots=True) +class StopResult: + success: bool + already_stopped: bool + disposition: TerminationDisposition + runtime_worker_panicked: bool + capture_finalization_failures_total: int + operator_finalization_failures_total: int + endpoint_finalization_failures_total: int + runtime_failures_total: int + lineage_failures_total: int + source_send_rejections_total: int + runtime_events_total: int + recording: RecordingOutcome | None + trace: SessionTraceRecorderOutcome | None + trace_error: str | None + terminal_event: SessionEvent | None + relay_outcomes: tuple[RelayPublishOutcome, ...] + sidecar_outcomes: tuple[SidecarSnapshot, ...] + + @classmethod + def _from_native(cls, value: _NativeStopResult) -> StopResult: + return cls( + success=value.success, + already_stopped=value.already_stopped, + disposition=TerminationDisposition(value.disposition), + runtime_worker_panicked=value.runtime_worker_panicked, + capture_finalization_failures_total=value.capture_finalization_failures_total, + operator_finalization_failures_total=value.operator_finalization_failures_total, + endpoint_finalization_failures_total=value.endpoint_finalization_failures_total, + runtime_failures_total=value.runtime_failures_total, + lineage_failures_total=value.lineage_failures_total, + source_send_rejections_total=value.source_send_rejections_total, + runtime_events_total=value.runtime_events_total, + recording=( + None + if value.recording is None + else RecordingOutcome._from_native(value.recording) + ), + trace=( + None + if value.trace is None + else SessionTraceRecorderOutcome._from_native(value.trace) + ), + trace_error=value.trace_error, + terminal_event=( + None + if value.terminal_event is None + else SessionEvent._from_native(value.terminal_event) + ), + relay_outcomes=tuple( + RelayPublishOutcome._from_native(outcome) + for outcome in value.relay_outcomes() + ), + sidecar_outcomes=tuple( + SidecarSnapshot._from_native(outcome) + for outcome in value.sidecar_outcomes() + ), + ) + + +class EventStream: + """Exclusive bounded view of authoritative native Session events. + + Iteration waits in the native worker while the GIL is released. It creates + no Python queue, polling thread, or unbounded event buffer. + """ + + def __init__( + self, + *, + poll_event: Callable[[], SessionEvent | None], + wait_event: Callable[[int], SessionEvent | None], + is_closed: Callable[[], bool], + ) -> None: + self._poll_event = poll_event + self._wait_event = wait_event + self._is_closed = is_closed + self._state = _ReaderState() + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._is_closed() + + def poll(self) -> SessionEvent | None: + token = self._state.claim("event_read") + try: + return None if self.is_closed else self._poll_event() + finally: + self._state.release(token) + + def read(self, *, timeout_s: float = 1.0) -> SessionEvent | None: + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("event_read") + try: + return None if self.is_closed else self._wait_event(timeout_ms) + finally: + self._state.release(token) + + def __iter__(self) -> Iterator[SessionEvent]: + return self.iter_events() + + def iter_events( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[SessionEvent]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[SessionEvent]: + token = self._state.claim("events") + try: + while not self.is_closed: + event = self._wait_event(timeout_ms) + if event is not None: + yield event + finally: + self._state.release(token) + + return iterate() + + +__all__ = [ + "AudioReentryMetrics", + "DerivedRouteMetrics", + "EdgeMetrics", + "EndpointFailureStage", + "EndpointMetrics", + "EndpointObservationStage", + "EventQueueMetrics", + "EventStream", + "ExternalSourceMetrics", + "LatencyHistogram", + "OperatorInputMetrics", + "OperatorMetrics", + "OperatorWorkerMetrics", + "PolledAudioMetrics", + "RecordingDiscontinuity", + "RecordingDiscontinuityKind", + "RecordingOutcome", + "RecordingState", + "RecordingStemOutcome", + "RelayPublishOutcome", + "RouteLatencyBoundary", + "RouteLatencyUnit", + "RouteMetrics", + "RouteObservationInterval", + "SessionEvent", + "SessionEventType", + "SessionFailure", + "SessionFailureKind", + "SessionFinalizationStage", + "SessionLifecycleState", + "SessionMetrics", + "SessionRollbackStage", + "SessionTerminalState", + "SessionTrace", + "SessionTraceConfiguration", + "SessionTraceRecorderOutcome", + "SessionTraceValidation", + "SourceMetrics", + "StopResult", + "TerminationDisposition", + "TypedEdgeMetrics", +] diff --git a/python/pocketstation/py.typed b/python/pocketstation/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/pocketstation/relay.py b/python/pocketstation/relay.py new file mode 100644 index 0000000..9e3c1ce --- /dev/null +++ b/python/pocketstation/relay.py @@ -0,0 +1,498 @@ +"""Explicit control and declaration composition for the real relay services.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from time import monotonic, sleep +from types import TracebackType +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs, quote, urljoin, urlparse + +import httpx + +from ._native import RelayPublisher as _NativeRelayPublisher +from .control import ( + ControlClient, + SecretToken, + SessionCredentials, + SessionId, + SessionSnapshot, +) +from .errors import PocketStationError, _native_call + +if TYPE_CHECKING: + from .session import Session + +_MAX_RELAY_RESPONSE_BYTES = 16_384 + + +class RelayError(PocketStationError): + """A relay declaration, HTTP, activation, or lifecycle failure.""" + + +class RelayTimeoutError(RelayError): + """An authoritative publisher or receiver activation deadline expired.""" + + +@dataclass(frozen=True, slots=True) +class RelayRoute: + """One native Session route publishing a named AudioBus.""" + + bus_id: str + route_id: int + + +@dataclass(frozen=True, slots=True) +class PublisherActivation: + """Control-plane snapshot after the relay confirmed a live source.""" + + snapshot: SessionSnapshot + + +@dataclass(frozen=True, slots=True) +class ReceiverActivation: + """Control-plane snapshot after relay WebRTC/downlink activation.""" + + snapshot: SessionSnapshot + + +@dataclass(frozen=True, slots=True) +class ReceiverInvitation: + """Opaque relay-issued browser invitation containing no subscriber token.""" + + session_id: SessionId + join_code: str + url: str + + @property + def join_url(self) -> str: + return self.url + + +class RelayPublisher: + """Session-scoped handle for the existing bounded Rust relay connector.""" + + __slots__ = ("_native", "relay_url", "session_id") + + def __init__( + self, + native: _NativeRelayPublisher, + *, + relay_url: str, + session_id: SessionId, + ) -> None: + self._native = native + self.relay_url = relay_url + self.session_id = session_id + + +class RelaySession: + """Explicit owner of one remote Session and its bounded control clients. + + The object creates no relay, control-plane, browser, signaling, or media + process. Callers provide already-running service origins. Audio remains in + the canonical Rust Session and shared ``pocketstation-relay`` crate. + """ + + def __init__( + self, + *, + relay_url: str, + credentials: SessionCredentials, + control: ControlClient, + relay_http: httpx.Client, + owns_control: bool, + owns_relay_http: bool, + request_timeout_seconds: float | None, + ) -> None: + self.relay_url = _normalize_relay_url(relay_url) + self.credentials = credentials + self._control = control + self._relay_http = relay_http + self._owns_control = owns_control + self._owns_relay_http = owns_relay_http + self._request_timeout_seconds = request_timeout_seconds + self._publisher_activation: PublisherActivation | None = None + self._invitation: ReceiverInvitation | None = None + self._receiver_activation: ReceiverActivation | None = None + self._closed = False + + @classmethod + def create( + cls, + *, + control_plane_url: str, + relay_url: str, + request_timeout_seconds: float | None = 10.0, + control_client: ControlClient | None = None, + relay_http_client: httpx.Client | None = None, + ) -> RelaySession: + _validate_optional_timeout(request_timeout_seconds, "request_timeout_seconds") + normalized_relay_url = _normalize_relay_url(relay_url) + owns_control = control_client is None + owns_relay_http = relay_http_client is None + control = control_client or ControlClient( + control_plane_url, + timeout_seconds=request_timeout_seconds, + ) + relay_http = relay_http_client or httpx.Client( + timeout=request_timeout_seconds, + ) + try: + credentials = control.create_session( + timeout_seconds=request_timeout_seconds, + ) + except Exception: + if owns_relay_http: + relay_http.close() + if owns_control: + control.close() + raise + return cls( + relay_url=normalized_relay_url, + credentials=credentials, + control=control, + relay_http=relay_http, + owns_control=owns_control, + owns_relay_http=owns_relay_http, + request_timeout_seconds=request_timeout_seconds, + ) + + @property + def session_id(self) -> SessionId: + return self.credentials.session_id + + @property + def publisher_activation(self) -> PublisherActivation | None: + return self._publisher_activation + + @property + def invitation(self) -> ReceiverInvitation | None: + return self._invitation + + @property + def receiver_activation(self) -> ReceiverActivation | None: + return self._receiver_activation + + def publisher(self, session: Session) -> RelayPublisher: + """Declare the native relay endpoint on an unstarted Session.""" + self._require_open() + native = _native_call( + lambda: session._native.relay( + self.relay_url, + str(self.session_id), + self.credentials.source_token.expose_secret(), + ) + ) + return RelayPublisher( + native, + relay_url=self.relay_url, + session_id=self.session_id, + ) + + def wait_for_publisher( + self, + *, + timeout_seconds: float = 10.0, + poll_interval_seconds: float = 0.1, + ) -> PublisherActivation: + """Wait for the relay's source-active callback, within one deadline.""" + self._require_open() + snapshot = self._wait_for_snapshot( + lambda value: value.source_active, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + timeout_code="relay.publisher_timeout", + timeout_message="relay publisher did not become active before the deadline", + ) + activation = PublisherActivation(snapshot) + self._publisher_activation = activation + return activation + + def create_receiver_invitation(self) -> ReceiverInvitation: + """Ask the relay for an opaque invitation after publisher activation.""" + self._require_open() + if self._publisher_activation is None: + raise RelayError( + "wait_for_publisher() must succeed before creating an invitation", + "relay.publisher_not_active", + ) + payload = _relay_json_request( + self._relay_http, + relay_url=self.relay_url, + method="POST", + path=(f"v1/sessions/{quote(str(self.session_id), safe='')}/invitations"), + expected_status=201, + authorization=self.credentials.source_token, + timeout_seconds=self._request_timeout_seconds, + ) + invitation = _receiver_invitation(payload, self.session_id) + self._invitation = invitation + return invitation + + def wait_for_publisher_and_invitation( + self, + *, + timeout_seconds: float = 10.0, + poll_interval_seconds: float = 0.1, + ) -> ReceiverInvitation: + self.wait_for_publisher( + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + return self.create_receiver_invitation() + + def wait_for_receiver( + self, + *, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 0.1, + ) -> ReceiverActivation: + """Wait for relay-confirmed WebRTC connection and downlink install.""" + self._require_open() + if self._invitation is None: + raise RelayError( + "create_receiver_invitation() must succeed before waiting " + "for a receiver", + "relay.invitation_missing", + ) + snapshot = self._wait_for_snapshot( + lambda value: value.source_active and value.subscription_count > 0, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + timeout_code="relay.receiver_timeout", + timeout_message="relay receiver did not become active before the deadline", + ) + activation = ReceiverActivation(snapshot) + self._receiver_activation = activation + return activation + + def close(self, *, delete_remote_session: bool = True) -> None: + if self._closed: + return + self._closed = True + try: + if delete_remote_session: + self._control.delete_session( + self.session_id, + self.credentials.source_token, + timeout_seconds=self._request_timeout_seconds, + ) + finally: + if self._owns_relay_http: + self._relay_http.close() + if self._owns_control: + self._control.close() + + def __enter__(self) -> RelaySession: + self._require_open() + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def __repr__(self) -> str: + return ( + "RelaySession(" + f"session_id={self.session_id!r}, relay_url={self.relay_url!r}, " + "credentials=[redacted])" + ) + + def _wait_for_snapshot( + self, + predicate: Callable[[SessionSnapshot], bool], + *, + timeout_seconds: float, + poll_interval_seconds: float, + timeout_code: str, + timeout_message: str, + ) -> SessionSnapshot: + _validate_wait(timeout_seconds, poll_interval_seconds) + deadline = monotonic() + timeout_seconds + while True: + remaining = deadline - monotonic() + if remaining <= 0: + raise RelayTimeoutError(timeout_message, timeout_code) + request_timeout = _bounded_request_timeout( + remaining, + self._request_timeout_seconds, + ) + snapshot = self._control.session( + self.session_id, + timeout_seconds=request_timeout, + ) + if predicate(snapshot): + return snapshot + sleep(min(poll_interval_seconds, max(0.0, deadline - monotonic()))) + + def _require_open(self) -> None: + if self._closed: + raise RelayError("RelaySession has closed", "relay.closed") + + +def _normalize_relay_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("relay_url must be an absolute http or https origin") + if parsed.path not in {"", "/"}: + raise ValueError("relay_url must not include a path") + return value.split("?", 1)[0].split("#", 1)[0].rstrip("/") + + +def _validate_optional_timeout(value: float | None, name: str) -> None: + if value is not None and (isinstance(value, bool) or value <= 0): + raise ValueError(f"{name} must be positive or None") + + +def _validate_wait(timeout_seconds: float, poll_interval_seconds: float) -> None: + for name, value in ( + ("timeout_seconds", timeout_seconds), + ("poll_interval_seconds", poll_interval_seconds), + ): + if isinstance(value, bool) or value <= 0: + raise ValueError(f"{name} must be positive") + + +def _bounded_request_timeout( + remaining_seconds: float, + configured_seconds: float | None, +) -> float: + if configured_seconds is None: + return remaining_seconds + return min(remaining_seconds, configured_seconds) + + +def _relay_json_request( + client: httpx.Client, + *, + relay_url: str, + method: str, + path: str, + expected_status: int, + authorization: SecretToken, + timeout_seconds: float | None, +) -> dict[str, Any]: + exposed = authorization.expose_secret() + try: + with client.stream( + method, + urljoin(relay_url + "/", path), + headers={"Authorization": f"Bearer {exposed}"}, + timeout=timeout_seconds, + ) as response: + body = _read_bounded(response.iter_bytes(), _MAX_RELAY_RESPONSE_BYTES) + if response.status_code != expected_status: + detail = body.decode("utf-8", errors="replace").replace( + exposed, + "[redacted]", + ) + raise RelayError( + f"relay returned HTTP {response.status_code}: {detail}", + "relay.http_status", + ) + except RelayError: + raise + except httpx.HTTPError as error: + message = str(error).replace(exposed, "[redacted]") + raise RelayError(f"relay request failed: {message}", "relay.request") from error + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RelayError( + f"relay response could not be decoded: {error}", + "relay.response_decode", + ) from error + if not isinstance(payload, dict): + raise RelayError( + "relay response must be a JSON object", + "relay.response_decode", + ) + return payload + + +def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: + body = bytearray() + for chunk in chunks: + remaining = limit_bytes + 1 - len(body) + if remaining <= 0: + break + body.extend(chunk[:remaining]) + if len(body) > limit_bytes: + raise RelayError( + f"relay response exceeds {limit_bytes} bytes", + "relay.response_too_large", + ) + return bytes(body) + + +def _receiver_invitation( + payload: dict[str, Any], + expected_session_id: SessionId, +) -> ReceiverInvitation: + session_id = SessionId(_required_string(payload, "session_id")) + if session_id != expected_session_id: + raise RelayError( + "relay invitation belongs to a different Session", + "relay.response_identity", + ) + join_code = _required_string(payload, "join_code") + invitation_url = _required_string(payload, "join_url") + parsed = urlparse(invitation_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise RelayError( + "relay invitation URL must be absolute HTTP or HTTPS", + "relay.response_decode", + ) + query = parse_qs(parsed.query, keep_blank_values=True) + unsafe_keys = { + "session", + "session_id", + "source_token", + "subscriber_token", + "token", + } + if unsafe_keys.intersection(query): + raise RelayError( + "relay invitation URL exposes a credential or Session identifier", + "relay.unsafe_invitation", + ) + if query.get("join") != [join_code]: + raise RelayError( + "relay invitation URL does not contain its opaque join code", + "relay.response_identity", + ) + if parsed.fragment or expected_session_id in invitation_url: + raise RelayError( + "relay invitation URL exposes the Session identifier", + "relay.unsafe_invitation", + ) + return ReceiverInvitation(session_id, join_code, invitation_url) + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise RelayError( + f"relay response field {key!r} must be a non-empty string", + "relay.response_decode", + ) + return value + + +__all__ = [ + "PublisherActivation", + "ReceiverActivation", + "ReceiverInvitation", + "RelayError", + "RelayPublisher", + "RelayRoute", + "RelaySession", + "RelayTimeoutError", +] diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py new file mode 100644 index 0000000..d12e378 --- /dev/null +++ b/python/pocketstation/session.py @@ -0,0 +1,361 @@ +"""Synchronous Python ownership of the canonical native PocketStation Session.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path +from types import TracebackType +from typing import TYPE_CHECKING + +from ._native import ( + AudioBatch, + AudioFrame, +) +from ._native import ( + RunningSession as _NativeRunningSession, +) +from ._native import ( + Session as _NativeSession, +) +from .audio_input import AudioInput, AudioInputConfig, PcmSource +from .errors import PocketStationError, _native_call +from .extensions import NativeExtensionLibrary +from .graph import ( + Endpoint, + Stem, + _GraphSessionDeclarations, +) +from .observations import ( + EventStream, + RecordingOutcome, + RecordingStemOutcome, + RouteMetrics, + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from .sidecar import SidecarConnection, SidecarHandle, SidecarProcessSpec +from .signal import BusSubscription +from .sources import Source +from .streams import AudioStream, SignalStream + +if TYPE_CHECKING: + from .relay import RelayPublisher, RelaySession + + +class RunningSession: + """Running native Session with bounded synchronous batch delivery.""" + + def __init__(self, native: _NativeRunningSession) -> None: + self._native = native + self._stop_result: StopResult | None = None + self._audio = AudioStream( + poll_batch=self._poll_audio_native, + wait_batch=self._wait_audio_native, + is_closed=lambda: self.is_stopped, + ) + self._events = EventStream( + poll_event=self._poll_event_native, + wait_event=self._wait_event_native, + is_closed=lambda: self.is_stopped, + ) + self._signals: dict[int, SignalStream] = {} + self._sidecars: dict[int, SidecarConnection] = {} + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def is_stopped(self) -> bool: + return self._stop_result is not None + + @property + def stop_result(self) -> StopResult | None: + return self._stop_result + + @property + def audio(self) -> AudioStream: + """The exclusive frame-first view of the native bounded endpoint.""" + return self._audio + + @property + def events(self) -> EventStream: + """The exclusive typed lifecycle and failure event stream.""" + return self._events + + def signals(self, subscription: BusSubscription) -> SignalStream: + """Return the one exclusive stream for a declared subscription.""" + stream = self._signals.get(subscription.id) + if stream is None: + native = subscription._native + stream = SignalStream( + poll_signal=lambda: _native_call( + lambda: self._native.poll_signal(native) + ), + wait_signal=lambda timeout_ms: _native_call( + lambda: self._native.wait_signal(native, timeout_ms) + ), + close_signal=lambda: _native_call( + lambda: self._native.close_signal(native) + ), + signal_metrics=lambda: _native_call( + lambda: self._native.signal_metrics(native) + ), + ) + self._signals[subscription.id] = stream + return stream + + def sidecar(self, handle: SidecarHandle) -> SidecarConnection: + """Return the Session-owned bounded connection for one child.""" + self._require_running() + if handle.session_id != self._native.session_id: + raise ValueError("SidecarHandle belongs to a different Session") + connection = self._sidecars.get(handle.id) + if connection is None: + connection = SidecarConnection( + handle=handle, + send_message=lambda message: _native_call( + lambda: self._native.send_sidecar(handle.id, message) + ), + poll_message=lambda: _native_call( + lambda: self._native.poll_sidecar(handle.id) + ), + wait_message=lambda timeout_ms: _native_call( + lambda: self._native.wait_sidecar(handle.id, timeout_ms) + ), + snapshot=lambda: _native_call( + lambda: self._native.sidecar_snapshot(handle.id) + ), + is_session_stopped=lambda: self.is_stopped, + ) + self._sidecars[handle.id] = connection + return connection + + def poll_audio(self) -> AudioBatch | None: + """Compatibility alias for the advanced non-blocking batch mode.""" + self._require_running() + return self.audio.poll_batch() + + def wait_audio(self, *, timeout_ms: int = 100) -> AudioBatch | None: + """Compatibility alias for the advanced bounded batch mode.""" + self._require_running() + if not 0 <= timeout_ms <= 1_000: + raise ValueError("timeout_ms must be between 0 and 1000") + return self.audio.read_batch(timeout_s=timeout_ms / 1_000) + + def audio_batches(self, *, wait_timeout_ms: int = 100) -> Iterator[AudioBatch]: + """Compatibility alias for ``audio.batches()``.""" + self._require_running() + if not 0 <= wait_timeout_ms <= 1_000: + raise ValueError("wait_timeout_ms must be between 0 and 1000") + return self.audio.batches(wait_timeout_s=wait_timeout_ms / 1_000) + + def poll_event(self) -> SessionEvent | None: + """Compatibility alias for ``events.poll()``.""" + self._require_running() + return self.events.poll() + + def wait_event(self, *, timeout_ms: int = 100) -> SessionEvent | None: + """Compatibility alias for the bounded ``events.read()`` mode.""" + self._require_running() + if not 0 <= timeout_ms <= 1_000: + raise ValueError("timeout_ms must be between 0 and 1000") + return self.events.read(timeout_s=timeout_ms / 1_000) + + def metrics(self) -> SessionMetrics: + """Return a complete immutable point-in-time metrics snapshot.""" + self._require_running() + return SessionMetrics._from_native(_native_call(self._native.metrics)) + + def stop(self) -> StopResult: + """Stop once, finalize endpoints/recording, and cache the outcome.""" + if self._stop_result is None: + self._stop_result = StopResult._from_native(_native_call(self._native.stop)) + return self._stop_result + + def cancel(self) -> StopResult: + """Cancel asynchronous work and sidecars, then join and reap once.""" + if self._stop_result is None: + self._stop_result = StopResult._from_native( + _native_call(self._native.cancel) + ) + return self._stop_result + + def close(self) -> None: + """Context-manager compatible alias that deterministically stops.""" + self.stop() + + def __enter__(self) -> RunningSession: + self._require_running() + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.stop() + + def _require_running(self) -> None: + if self.is_stopped: + raise PocketStationError("Session has stopped", "session.stopped") + + def _poll_audio_native(self) -> AudioBatch | None: + self._require_running() + return _native_call(self._native.poll_audio) + + def _wait_audio_native(self, timeout_ms: int) -> AudioBatch | None: + self._require_running() + return _native_call(lambda: self._native.wait_audio(timeout_ms)) + + def _poll_event_native(self) -> SessionEvent | None: + self._require_running() + event = _native_call(self._native.poll_event) + return None if event is None else SessionEvent._from_native(event) + + def _wait_event_native(self, timeout_ms: int) -> SessionEvent | None: + self._require_running() + event = _native_call(lambda: self._native.wait_event(timeout_ms)) + return None if event is None else SessionEvent._from_native(event) + + +class Session(_GraphSessionDeclarations): + """Explicit synchronous façade over the canonical Rust Session.""" + + def __init__( + self, + *, + recording_root: str | Path | None = None, + trace: SessionTraceConfiguration | None = None, + sample_rate_hz: int = 48_000, + channels: int = 1, + ) -> None: + root = None if recording_root is None else Path(recording_root) + self._native = _NativeSession( + recording_root=root, + trace_path=None if trace is None else trace.path, + trace_capacity_records=256 if trace is None else trace.capacity_records, + sample_rate_hz=sample_rate_hz, + channels=channels, + ) + self._sample_rate_hz = sample_rate_hz + self._channels = channels + + @classmethod + def _from_native(cls, native: _NativeSession) -> Session: + """Construct an internal façade around a canonical conformance Session.""" + session = cls.__new__(cls) + session._native = native + session._sample_rate_hz = 48_000 + session._channels = 1 + return session + + @property + def id(self) -> int: + return self._native.id + + def capture(self, source: Source) -> Stem: + """Declare one independent source-aware stem.""" + return _native_call(lambda: Stem(self._native.capture(source._native))) + + def audio_input( + self, + name: str, + *, + sample_rate_hz: int | None = None, + channels: int | None = None, + capacity_frames: int = 8, + frame_samples_per_channel: int = 480, + ) -> AudioInput: + """Open bounded input for PCM already owned by this application.""" + config = AudioInputConfig( + name=name, + sample_rate_hz=( + self._sample_rate_hz if sample_rate_hz is None else sample_rate_hz + ), + channels=self._channels if channels is None else channels, + capacity_frames=capacity_frames, + frame_samples_per_channel=frame_samples_per_channel, + ) + native = _native_call( + lambda: self._native.audio_input( + config.sample_rate_hz, + config.channels, + config.capacity_frames, + config.frame_samples_per_channel, + ) + ) + return AudioInput(native, config) + + def pcm_source(self, config: AudioInputConfig) -> PcmSource: + """Open the advanced explicit source-output and writer ownership API.""" + native = _native_call( + lambda: self._native.pcm_source( + config.sample_rate_hz, + config.channels, + config.capacity_frames, + config.frame_samples_per_channel, + ) + ) + return PcmSource(native, config) + + def polled_audio(self) -> Endpoint: + """Declare the bounded managed-language polling endpoint.""" + return _native_call(lambda: Endpoint(self._native.polled_audio())) + + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: + """Register a bounded PKSS child to spawn during transactional start.""" + sidecar_id = _native_call( + lambda: self._native.register_sidecar(spec._to_native()) + ) + return SidecarHandle(id=sidecar_id, session_id=self._native.id) + + def load_native_extension_library( + self, + path: str | Path, + ) -> NativeExtensionLibrary: + """Load trusted native code into this Session draft. + + This accepts a raw dynamic library. PocketStation validates its ABI + records and imports registrations transactionally, but does not verify + a publisher, signature, checksum, or sandbox the loaded code. Callers + must establish trust in the exact library and its ABI implementation. + """ + native = _native_call( + lambda: self._native.load_native_extension_library(Path(path)) + ) + return NativeExtensionLibrary._from_native(native) + + def relay(self, remote: RelaySession) -> RelayPublisher: + """Declare the existing bounded Rust relay connector.""" + return remote.publisher(self) + + def start(self) -> RunningSession: + """Transactionally start the frozen native Session declaration.""" + return _native_call(lambda: RunningSession(self._native.start())) + + +__all__ = [ + "AudioBatch", + "AudioFrame", + "AudioInput", + "AudioInputConfig", + "Endpoint", + "RecordingOutcome", + "RecordingStemOutcome", + "RouteMetrics", + "RunningSession", + "Session", + "SessionEvent", + "SessionMetrics", + "SidecarConnection", + "SidecarHandle", + "SidecarProcessSpec", + "SignalStream", + "Source", + "Stem", + "StopResult", +] diff --git a/python/pocketstation/sidecar.py b/python/pocketstation/sidecar.py new file mode 100644 index 0000000..9525376 --- /dev/null +++ b/python/pocketstation/sidecar.py @@ -0,0 +1,398 @@ +"""Typed Session-owned process sidecars using PocketStation's PKSS protocol.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import TypeAlias + +from ._native import _SidecarMessage as _NativeSidecarMessage +from ._native import _SidecarProcessSpec as _NativeSidecarProcessSpec +from ._native import _SidecarRead as _NativeSidecarRead +from ._native import _SidecarSnapshot as _NativeSidecarSnapshot +from .errors import SidecarProtocolError +from .signal import STREAM_EOF, EndOfStream +from .streams import ( + _DEFAULT_ITERATION_TIMEOUT_SECONDS, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + + +class SidecarMessageKind(StrEnum): + """Frozen PKSS 1.0 message kinds.""" + + SIGNAL = "signal" + READY = "ready" + ERROR = "error" + CANCEL = "cancel" + CLOSE = "close" + HELLO = "hello" + MANIFEST = "manifest" + CONFIGURE = "configure" + OBSERVATION = "observation" + CLOSED = "closed" + + +class SidecarState(StrEnum): + """Session-owned native child lifecycle states.""" + + SPAWNED = "spawned" + HELLO = "hello" + MANIFEST = "manifest" + CONFIGURE = "configure" + READY = "ready" + RUNNING = "running" + CANCELLING = "cancelling" + CLOSING = "closing" + CLOSED = "closed" + REAPED = "reaped" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class SidecarProtocolLimits: + """Finite PKSS field and payload bounds measured in bytes.""" + + max_signal_id_bytes: int = 256 + max_role_bytes: int = 256 + max_schema_bytes: int = 1_024 + max_payload_bytes: int = 1_048_576 + + def __post_init__(self) -> None: + for name, value in ( + ("max_signal_id_bytes", self.max_signal_id_bytes), + ("max_role_bytes", self.max_role_bytes), + ("max_schema_bytes", self.max_schema_bytes), + ("max_payload_bytes", self.max_payload_bytes), + ): + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{name} must be a positive integer") + + +@dataclass(frozen=True, slots=True) +class SidecarDeadlines: + """Native lifecycle deadlines measured in seconds.""" + + ready_s: float = 5.0 + processing_s: float = 5.0 + shutdown_s: float = 2.0 + + def __post_init__(self) -> None: + for name, value in ( + ("ready_s", self.ready_s), + ("processing_s", self.processing_s), + ("shutdown_s", self.shutdown_s), + ): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a number") + if float(value) <= 0.0: + raise ValueError(f"{name} must be greater than zero") + + def _milliseconds(self) -> tuple[int, int, int]: + return ( + max(1, round(float(self.ready_s) * 1_000)), + max(1, round(float(self.processing_s) * 1_000)), + max(1, round(float(self.shutdown_s) * 1_000)), + ) + + +@dataclass(frozen=True, slots=True) +class SidecarProcessSpec: + """One bounded child declaration spawned transactionally by ``Session``. + + ``stdout`` is reserved for PKSS. The native host owns pipes, handshake, + deadlines, close/cancel, kill, wait, and reap. No shell is involved. + """ + + id: int + program: str | Path + arguments: tuple[str, ...] = () + configuration: bytes = b"" + data_capacity_messages: int = 64 + protocol_limits: SidecarProtocolLimits = field( + default_factory=SidecarProtocolLimits + ) + deadlines: SidecarDeadlines = field(default_factory=SidecarDeadlines) + + def __post_init__(self) -> None: + if isinstance(self.id, bool) or not isinstance(self.id, int) or self.id <= 0: + raise ValueError("id must be a positive integer") + if not str(self.program): + raise ValueError("program must not be empty") + if any(not isinstance(argument, str) for argument in self.arguments): + raise TypeError("arguments must contain only strings") + if not isinstance(self.configuration, bytes): + raise TypeError("configuration must be bytes") + if ( + isinstance(self.data_capacity_messages, bool) + or not isinstance(self.data_capacity_messages, int) + or self.data_capacity_messages <= 0 + ): + raise ValueError("data_capacity_messages must be a positive integer") + + def _to_native(self) -> _NativeSidecarProcessSpec: + ready_ms, processing_ms, shutdown_ms = self.deadlines._milliseconds() + limits = self.protocol_limits + return _NativeSidecarProcessSpec( + self.id, + Path(self.program), + list(self.arguments), + self.configuration, + self.data_capacity_messages, + limits.max_signal_id_bytes, + limits.max_role_bytes, + limits.max_schema_bytes, + limits.max_payload_bytes, + ready_ms, + processing_ms, + shutdown_ms, + ) + + +@dataclass(frozen=True, slots=True) +class SidecarHandle: + """A Session-scoped reference to one registered sidecar.""" + + id: int + session_id: int + + +@dataclass(frozen=True, slots=True) +class SidecarMessage: + """One owned PKSS message with finite payload bytes.""" + + kind: SidecarMessageKind + stream_id: int + sequence_number: int + timestamp_ns: int + signal_id: str + payload: bytes + terminal: bool = False + role: str | None = None + schema: str | None = None + + @classmethod + def signal( + cls, + payload: bytes, + *, + signal_id: str, + stream_id: int, + sequence_number: int, + timestamp_ns: int, + role: str | None = None, + schema: str | None = None, + terminal: bool = False, + ) -> SidecarMessage: + """Build the only message kind accepted by the bounded data queue.""" + return cls( + kind=SidecarMessageKind.SIGNAL, + stream_id=stream_id, + sequence_number=sequence_number, + timestamp_ns=timestamp_ns, + signal_id=signal_id, + payload=payload, + terminal=terminal, + role=role, + schema=schema, + ) + + def _to_native(self) -> _NativeSidecarMessage: + return _NativeSidecarMessage( + kind=self.kind.value, + stream_id=self.stream_id, + sequence_number=self.sequence_number, + timestamp_ns=self.timestamp_ns, + signal_id=self.signal_id, + payload=self.payload, + terminal=self.terminal, + role=self.role, + schema=self.schema, + ) + + @classmethod + def _from_native(cls, value: _NativeSidecarMessage) -> SidecarMessage: + return cls( + kind=SidecarMessageKind(value.kind), + stream_id=value.stream_id, + sequence_number=value.sequence_number, + timestamp_ns=value.timestamp_ns, + signal_id=value.signal_id, + payload=value.payload, + terminal=value.terminal, + role=value.role, + schema=value.schema, + ) + + +@dataclass(frozen=True, slots=True) +class SidecarSnapshot: + """Native process lifecycle and finite queue counters for one child.""" + + sidecar_id: int + state: SidecarState + state_transitions: int + data_enqueued_total: int + data_received_total: int + data_dropped_total: int + protocol_failures_total: int + timeouts_total: int + forced_kills_total: int + reaps_total: int + + @classmethod + def _from_native(cls, value: _NativeSidecarSnapshot) -> SidecarSnapshot: + return cls( + sidecar_id=value.sidecar_id, + state=SidecarState(value.state), + state_transitions=value.state_transitions, + data_enqueued_total=value.data_enqueued_total, + data_received_total=value.data_received_total, + data_dropped_total=value.data_dropped_total, + protocol_failures_total=value.protocol_failures_total, + timeouts_total=value.timeouts_total, + forced_kills_total=value.forced_kills_total, + reaps_total=value.reaps_total, + ) + + def visited(self, state: SidecarState) -> bool: + position = list(SidecarState).index(state) + return self.state_transitions & (1 << position) != 0 + + +SidecarReadResult: TypeAlias = SidecarMessage | EndOfStream | None + + +class SidecarStream: + """One-reader stream over the native bounded incoming PKSS queue.""" + + def __init__( + self, + *, + poll_message: Callable[[], _NativeSidecarRead], + wait_message: Callable[[int], _NativeSidecarRead], + is_session_stopped: Callable[[], bool], + ) -> None: + self._poll_message = poll_message + self._wait_message = wait_message + self._is_session_stopped = is_session_stopped + self._state = _ReaderState() + self._closed = False + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._closed or self._is_session_stopped() + + def poll(self) -> SidecarReadResult: + token = self._state.claim("sidecar_read") + try: + return STREAM_EOF if self.is_closed else self._decode(self._poll_message()) + finally: + self._state.release(token) + + def read(self, *, timeout_s: float = 1.0) -> SidecarReadResult: + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("sidecar_read") + try: + return ( + STREAM_EOF + if self.is_closed + else self._decode(self._wait_message(timeout_ms)) + ) + finally: + self._state.release(token) + + def __iter__(self) -> Iterator[SidecarMessage]: + return self.messages() + + def messages( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[SidecarMessage]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[SidecarMessage]: + token = self._state.claim("sidecar") + try: + while not self.is_closed: + result = self._decode(self._wait_message(timeout_ms)) + if isinstance(result, EndOfStream): + break + if result is not None: + yield result + finally: + self._state.release(token) + + return iterate() + + def _decode(self, value: _NativeSidecarRead) -> SidecarReadResult: + if value.status == "item": + if value.message is None: + raise SidecarProtocolError( + "native sidecar read omitted its message", + "sidecar.invalid_read", + ) + return SidecarMessage._from_native(value.message) + if value.status == "empty": + return None + if value.status == "closed": + self._closed = True + return STREAM_EOF + raise SidecarProtocolError( + f"native sidecar read has unknown state {value.status!r}", + "sidecar.invalid_read", + ) + + +class SidecarConnection: + """Running Session view of one registered sidecar.""" + + def __init__( + self, + *, + handle: SidecarHandle, + send_message: Callable[[_NativeSidecarMessage], None], + poll_message: Callable[[], _NativeSidecarRead], + wait_message: Callable[[int], _NativeSidecarRead], + snapshot: Callable[[], _NativeSidecarSnapshot], + is_session_stopped: Callable[[], bool], + ) -> None: + self.handle = handle + self._send_message = send_message + self._snapshot = snapshot + self.messages = SidecarStream( + poll_message=poll_message, + wait_message=wait_message, + is_session_stopped=is_session_stopped, + ) + + def send(self, message: SidecarMessage) -> None: + """Try one immediate native bounded enqueue; never waits for capacity.""" + self._send_message(message._to_native()) + + def snapshot(self) -> SidecarSnapshot: + return SidecarSnapshot._from_native(self._snapshot()) + + +__all__ = [ + "SidecarConnection", + "SidecarDeadlines", + "SidecarHandle", + "SidecarMessage", + "SidecarMessageKind", + "SidecarProcessSpec", + "SidecarProtocolLimits", + "SidecarReadResult", + "SidecarSnapshot", + "SidecarState", + "SidecarStream", +] diff --git a/python/pocketstation/signal.py b/python/pocketstation/signal.py new file mode 100644 index 0000000..2989c9e --- /dev/null +++ b/python/pocketstation/signal.py @@ -0,0 +1,256 @@ +"""Immutable typed signals delivered by canonical Rust Session endpoints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from ._native import BusSubscription as _NativeBusSubscription +from ._native import _SignalAudioPayload as _NativeSignalAudioPayload +from ._native import _SignalDerivation as _NativeSignalDerivation +from ._native import _SignalEnvelope as _NativeSignalEnvelope +from ._native import _SignalLineage as _NativeSignalLineage +from ._native import _SignalSubscriptionMetrics as _NativeSignalSubscriptionMetrics +from ._native import _SignalTiming as _NativeSignalTiming +from .graph import EdgeContract, SignalSpec + + +@dataclass(frozen=True, slots=True) +class SignalTiming: + """Clock observations preserved on one routed signal.""" + + source_timestamp_ns: int | None + observed_timestamp_ns: int + session_timestamp_ns: int | None + duration_ns: int | None + + @classmethod + def _from_native(cls, value: _NativeSignalTiming) -> SignalTiming: + return cls( + source_timestamp_ns=value.source_timestamp_ns, + observed_timestamp_ns=value.observed_timestamp_ns, + session_timestamp_ns=value.session_timestamp_ns, + duration_ns=value.duration_ns, + ) + + +@dataclass(frozen=True, slots=True) +class SignalLineage: + """Source and stream identity that survives graph and language boundaries.""" + + session_id: int + stream_id: int + source_id: int + clock_id: int + sequence_number: int + source_generation: int + discontinuity_epoch: int + policy_epoch: int + + @classmethod + def _from_native(cls, value: _NativeSignalLineage) -> SignalLineage: + return cls( + session_id=value.session_id, + stream_id=value.stream_id, + source_id=value.source_id, + clock_id=value.clock_id, + sequence_number=value.sequence_number, + source_generation=value.source_generation, + discontinuity_epoch=value.discontinuity_epoch, + policy_epoch=value.policy_epoch, + ) + + +@dataclass(frozen=True, slots=True) +class SignalDerivation: + """Operator provenance attached to a derived signal.""" + + upstream_lineage: SignalLineage + upstream_timing: SignalTiming + operator_id: str + operator_revision: int + operator_generation: int + connector_id: int | None + + @classmethod + def _from_native(cls, value: _NativeSignalDerivation) -> SignalDerivation: + return cls( + upstream_lineage=SignalLineage._from_native(value.upstream_lineage), + upstream_timing=SignalTiming._from_native(value.upstream_timing), + operator_id=value.operator_id, + operator_revision=value.operator_revision, + operator_generation=value.operator_generation, + connector_id=value.connector_id, + ) + + +@dataclass(frozen=True, slots=True) +class SignalAudioPayload: + """Owned immutable interleaved f32 PCM payload.""" + + samples_f32le: bytes + sample_count: int + sample_rate_hz: int + channel_count: int + stream_id: int + source_id: int + sequence_number: int + timestamp_ns: int + + @classmethod + def _from_native(cls, value: _NativeSignalAudioPayload) -> SignalAudioPayload: + return cls( + samples_f32le=value.samples_f32le, + sample_count=value.sample_count, + sample_rate_hz=value.sample_rate_hz, + channel_count=value.channel_count, + stream_id=value.stream_id, + source_id=value.source_id, + sequence_number=value.sequence_number, + timestamp_ns=value.timestamp_ns, + ) + + @property + def samples(self) -> memoryview: + """Read-only bytes view; conversion to floats stays caller-controlled.""" + return memoryview(self.samples_f32le) + + @property + def sample_format(self) -> str: + return "f32le" + + +SignalPayload: TypeAlias = SignalAudioPayload | str | bytes + + +@dataclass(frozen=True, slots=True) +class SignalSubscriptionMetrics: + """Unit-bearing finite edge and saturation observations.""" + + capacity_signals: int + max_payload_bytes: int + maximum_buffered_payload_bytes: int + depth_signals: int + peak_depth_signals: int + enqueued_total: int + received_total: int + dropped_total: int + + @classmethod + def _from_native( + cls, + value: _NativeSignalSubscriptionMetrics, + ) -> SignalSubscriptionMetrics: + return cls( + capacity_signals=value.capacity_signals, + max_payload_bytes=value.max_payload_bytes, + maximum_buffered_payload_bytes=value.maximum_buffered_payload_bytes, + depth_signals=value.depth_signals, + peak_depth_signals=value.peak_depth_signals, + enqueued_total=value.enqueued_total, + received_total=value.received_total, + dropped_total=value.dropped_total, + ) + + +@dataclass(frozen=True, slots=True) +class SignalEnvelope: + """One owned payload with its exact signal, timing, lineage, and derivation.""" + + signal: SignalSpec + timing: SignalTiming + lineage: SignalLineage | None + derivation: SignalDerivation | None + payload: SignalPayload + + @classmethod + def _from_native(cls, value: _NativeSignalEnvelope) -> SignalEnvelope: + if value.payload_kind == "audio": + if value.audio is None: + raise RuntimeError("native audio signal omitted its payload") + payload: SignalPayload = SignalAudioPayload._from_native(value.audio) + elif value.payload_kind == "text": + if value.text is None: + raise RuntimeError("native text signal omitted its payload") + payload = value.text + elif value.payload_kind == "bytes": + if value.bytes is None: + raise RuntimeError("native bytes signal omitted its payload") + payload = value.bytes + else: + raise RuntimeError( + f"native signal has unknown payload kind {value.payload_kind!r}" + ) + return cls( + signal=SignalSpec._from_native(value.signal), + timing=SignalTiming._from_native(value.timing), + lineage=( + None + if value.lineage is None + else SignalLineage._from_native(value.lineage) + ), + derivation=( + None + if value.derivation is None + else SignalDerivation._from_native(value.derivation) + ), + payload=payload, + ) + + +class BusSubscription: + """Session-scoped receipt for one bounded typed ``AudioBus`` route.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeBusSubscription) -> None: + self._native = native + + @property + def id(self) -> int: + return self._native.id + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def route_id(self) -> int: + return self._native.route_id + + @property + def signal(self) -> SignalSpec: + return SignalSpec._from_native(self._native.signal) + + @property + def edge(self) -> EdgeContract: + return EdgeContract(self._native.edge) + + +class EndOfStream: + """Stable singleton returned by explicit reads after native endpoint EOF.""" + + __slots__ = () + + def __repr__(self) -> str: + return "STREAM_EOF" + + +STREAM_EOF = EndOfStream() +SignalReadResult: TypeAlias = SignalEnvelope | EndOfStream | None + + +__all__ = [ + "STREAM_EOF", + "BusSubscription", + "EndOfStream", + "SignalAudioPayload", + "SignalDerivation", + "SignalEnvelope", + "SignalLineage", + "SignalPayload", + "SignalReadResult", + "SignalSpec", + "SignalSubscriptionMetrics", + "SignalTiming", +] diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py new file mode 100644 index 0000000..5945697 --- /dev/null +++ b/python/pocketstation/sources.py @@ -0,0 +1,452 @@ +"""Typed source declarations, discovery, permissions, and runtime identity.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum + +from ._native import DiscoveredSource as _NativeDiscoveredSource +from ._native import SessionEvent as _NativeSessionEvent +from ._native import Source as _NativeSource +from ._native import ( + application_capture_available as _native_application_capture_available, +) +from ._native import discover_sources as _native_discover_sources +from ._native import ( + microphone_permission_observation as _native_microphone_permission_observation, +) +from .errors import PocketStationError, _native_call + + +class Platform(StrEnum): + MACOS = "macos" + WINDOWS = "windows" + LINUX = "linux" + IOS = "ios" + ANDROID = "android" + WEB = "web" + UNKNOWN = "unknown" + + +class SourceKind(StrEnum): + APPLICATION = "application" + OUTPUT_DEVICE = "output-device" + INPUT_DEVICE = "input-device" + SYSTEM_MIX = "system-mix" + + +class SourceState(StrEnum): + AVAILABLE = "available" + PLAYING = "playing" + SILENT = "silent" + UNAVAILABLE = "unavailable" + PERMISSION_BLOCKED = "permission-blocked" + + +class SourceIdentityStrength(StrEnum): + APPLICATION_ID_AND_PROCESS_ID = "application-id-and-process-id" + STABLE_APPLICATION_ID = "stable-application-id" + PROCESS_ID = "process-id" + STABLE_DEVICE_UID = "stable-device-uid" + PLATFORM_STABLE_ID = "platform-stable-id" + + +class SelectorPersistenceScope(StrEnum): + PROCESS_LIFETIME = "process-lifetime" + APPLICATION_IDENTITY = "application-identity" + DEVICE_IDENTITY = "device-identity" + SESSION_DEFAULT_DEVICE = "session-default-device" + PLATFORM_IDENTITY = "platform-identity" + + +class ProcessTreeScope(StrEnum): + SELECTED_PROCESS_ONLY = "selected-process-only" + SELECTED_PROCESS_AND_DESCENDANTS = "selected-process-and-descendants" + APPLICATION_IDENTITY = "application-identity" + NOT_APPLICABLE = "not-applicable" + + +class PermissionObservation(StrEnum): + """Authoritative permission observation, never a prompt result guess.""" + + ALLOWED = "allowed" + DENIED = "denied" + RESTRICTED = "restricted" + NOT_DETERMINED = "not-determined" + REVOKED = "revoked" + NOT_OBSERVABLE = "not-observable" + NOT_APPLICABLE = "not-applicable" + + +class SourceSelectorKind(StrEnum): + APPLICATION_NAME = "application-name" + APPLICATION_BUNDLE_ID = "application-bundle-id" + APPLICATION_PROCESS_ID = "application-process-id" + APPLICATION_STABLE_ID = "application-stable-id" + APPLICATION_PROCESS_INSTANCE = "application-process-instance" + MICROPHONE_DEFAULT = "microphone-default" + MICROPHONE_ID = "microphone-id" + + +class SourceRuntimeEventKind(StrEnum): + SOURCE_UNAVAILABLE = "source-unavailable" + BACKEND_FAILURE = "backend-failure" + + +class SourceRecoveryRequirement(StrEnum): + EXPLICIT_REDISCOVERY_AND_NEW_SESSION = "explicit-rediscovery-and-new-session" + + +class SourceFailureClass(StrEnum): + SOURCE_INSTANCE_EXITED = "source-instance-exited" + PLATFORM_STATUS = "platform-status" + BACKEND_CLASS = "backend-class" + + +@dataclass(frozen=True, slots=True) +class StableSourceId: + """Version-stable native source identity projected without re-hashing.""" + + platform: Platform + kind: SourceKind + stable_key: str + source_id: int | None + + +@dataclass(frozen=True, slots=True) +class DiscoveredSource: + """Immutable point-in-time result from the canonical Rust discovery query.""" + + stable_id: StableSourceId + name: str + process_id: int | None + application_id: str | None + device_uid: str | None + state: SourceState + sample_rate_hz: int + channel_count: int + identity_strength: SourceIdentityStrength + selector_persistence_scope: SelectorPersistenceScope | None + process_tree_scope: ProcessTreeScope | None + + @classmethod + def _from_native(cls, source: _NativeDiscoveredSource) -> DiscoveredSource: + return cls( + stable_id=StableSourceId( + platform=Platform(source.platform), + kind=SourceKind(source.kind), + stable_key=source.stable_key, + source_id=source.source_id, + ), + name=source.name, + process_id=source.process_id, + application_id=source.application_id, + device_uid=source.device_uid, + state=SourceState(source.state), + sample_rate_hz=source.sample_rate_hz, + channel_count=source.channel_count, + identity_strength=SourceIdentityStrength(source.identity_strength), + selector_persistence_scope=( + None + if source.selector_persistence_scope is None + else SelectorPersistenceScope(source.selector_persistence_scope) + ), + process_tree_scope=( + None + if source.process_tree_scope is None + else ProcessTreeScope(source.process_tree_scope) + ), + ) + + +@dataclass(frozen=True, slots=True) +class SourceQuery: + """Typed query executed by the canonical Rust source provider.""" + + _query_kind: str = "any" + _value: str | None = None + + @classmethod + def any(cls) -> SourceQuery: + return cls() + + @classmethod + def application(cls, name: str) -> SourceQuery: + return cls("application", _require_nonempty("application query", name)) + + @classmethod + def kind(cls, kind: SourceKind) -> SourceQuery: + return cls("kind", kind.value) + + @classmethod + def stable_key(cls, stable_key: str) -> SourceQuery: + return cls("stable-key", _require_nonempty("stable source key", stable_key)) + + @classmethod + def playing(cls) -> SourceQuery: + return cls("playing") + + +@dataclass(frozen=True, slots=True) +class ProcessInstanceSelector: + process_id: int + stable_id: StableSourceId + + +SourceSelectorValue = str | int | StableSourceId | ProcessInstanceSelector | None + + +@dataclass(frozen=True, slots=True) +class Source: + """Immutable declaration lowered by the canonical Rust ``Session``.""" + + _native: _NativeSource = field(repr=False) + kind: SourceKind + selector_kind: SourceSelectorKind + selector_value: SourceSelectorValue = None + + @classmethod + def application(cls, name: str) -> Source: + """Select one application by its display name.""" + native = _native_call(lambda: _NativeSource.application(name)) + return cls( + native, + SourceKind.APPLICATION, + SourceSelectorKind.APPLICATION_NAME, + name, + ) + + @classmethod + def application_bundle_id(cls, bundle_id: str) -> Source: + """Select one application by an OS bundle/application identifier.""" + native = _native_call(lambda: _NativeSource.application_bundle_id(bundle_id)) + return cls( + native, + SourceKind.APPLICATION, + SourceSelectorKind.APPLICATION_BUNDLE_ID, + bundle_id, + ) + + @classmethod + def application_process_id(cls, process_id: int) -> Source: + """Select the current process instance with the given process ID.""" + native = _native_call(lambda: _NativeSource.application_process_id(process_id)) + return cls( + native, + SourceKind.APPLICATION, + SourceSelectorKind.APPLICATION_PROCESS_ID, + process_id, + ) + + @classmethod + def application_stable_id( + cls, + platform: Platform | str, + stable_key: str, + ) -> Source: + """Select one application by its PocketStation stable source key.""" + platform_value = _platform_value(platform) + native = _native_call( + lambda: _NativeSource.application_stable_id(platform_value, stable_key) + ) + stable_id = StableSourceId( + platform=Platform(platform_value), + kind=SourceKind.APPLICATION, + stable_key=stable_key, + source_id=None, + ) + return cls( + native, + SourceKind.APPLICATION, + SourceSelectorKind.APPLICATION_STABLE_ID, + stable_id, + ) + + @classmethod + def application_process_instance( + cls, + process_id: int, + platform: Platform | str, + stable_key: str, + ) -> Source: + """Select an exact process instance and stable application identity.""" + platform_value = _platform_value(platform) + native = _native_call( + lambda: _NativeSource.application_process_instance( + process_id, + platform_value, + stable_key, + ) + ) + stable_id = StableSourceId( + platform=Platform(platform_value), + kind=SourceKind.APPLICATION, + stable_key=stable_key, + source_id=None, + ) + return cls( + native, + SourceKind.APPLICATION, + SourceSelectorKind.APPLICATION_PROCESS_INSTANCE, + ProcessInstanceSelector(process_id, stable_id), + ) + + @classmethod + def microphone_default(cls) -> Source: + """Select the host default microphone for this Session open.""" + native = _native_call(_NativeSource.microphone_default) + return cls( + native, + SourceKind.INPUT_DEVICE, + SourceSelectorKind.MICROPHONE_DEFAULT, + ) + + @classmethod + def microphone_id(cls, device_id: str) -> Source: + """Select a microphone by its native stable device identifier.""" + native = _native_call(lambda: _NativeSource.microphone_id(device_id)) + return cls( + native, + SourceKind.INPUT_DEVICE, + SourceSelectorKind.MICROPHONE_ID, + device_id, + ) + + @classmethod + def from_discovered(cls, source: DiscoveredSource) -> Source: + """Build the strongest supported Session declaration from discovery. + + System mix and output devices can be discovered as host capabilities, + but the frozen public ``Session`` does not expose them as built-in + ``Source`` variants. This method rejects them instead of fabricating a + lowering path. + """ + stable_id = source.stable_id + if stable_id.kind is SourceKind.APPLICATION: + if source.process_id is not None: + return cls.application_process_instance( + source.process_id, + stable_id.platform, + stable_id.stable_key, + ) + return cls.application_stable_id( + stable_id.platform, + stable_id.stable_key, + ) + if stable_id.kind is SourceKind.INPUT_DEVICE: + return cls.microphone_id(source.device_uid or stable_id.stable_key) + raise PocketStationError( + "discovered " + f"{stable_id.kind.value!r} is not a frozen built-in Session Source", + "source.unsupported_session_kind", + ) + + +@dataclass(frozen=True, slots=True) +class SourceRuntimeEvent: + """Typed native source disappearance or backend-failure observation.""" + + kind: SourceRuntimeEventKind + stable_id: StableSourceId + generation: int + recovery_requirement: SourceRecoveryRequirement | None + operation: str + failure_class: SourceFailureClass + platform_status_code: int | None + backend_class: str | None + + @classmethod + def _from_native(cls, event: _NativeSessionEvent) -> SourceRuntimeEvent | None: + if event.source_event_kind is None: + return None + if ( + event.source_platform is None + or event.source_kind is None + or event.source_stable_key is None + or event.source_source_id is None + or event.source_generation is None + or event.source_failure_operation is None + or event.source_failure_class is None + ): + raise PocketStationError( + "native source event is incomplete", + "source.invalid_runtime_event", + ) + return cls( + kind=SourceRuntimeEventKind(event.source_event_kind), + stable_id=StableSourceId( + platform=Platform(event.source_platform), + kind=SourceKind(event.source_kind), + stable_key=event.source_stable_key, + source_id=event.source_source_id, + ), + generation=event.source_generation, + recovery_requirement=( + None + if event.source_recovery_requirement is None + else SourceRecoveryRequirement(event.source_recovery_requirement) + ), + operation=event.source_failure_operation, + failure_class=SourceFailureClass(event.source_failure_class), + platform_status_code=event.source_platform_status_code, + backend_class=event.source_backend_class, + ) + + +def discover_sources(query: SourceQuery | None = None) -> tuple[DiscoveredSource, ...]: + """Return one immutable native discovery snapshot for ``query``.""" + selected = SourceQuery.any() if query is None else query + native_sources = _native_call( + lambda: _native_discover_sources(selected._query_kind, selected._value) + ) + return tuple(DiscoveredSource._from_native(source) for source in native_sources) + + +def application_capture_available() -> bool: + """Read native application-capture capability without opening a source.""" + return _native_call(_native_application_capture_available) + + +def microphone_permission_observation() -> PermissionObservation: + """Read microphone authorization without prompting. + + Linux 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) + + +def _require_nonempty(label: str, value: str) -> str: + if not value.strip(): + raise ValueError(f"{label} must not be empty") + return value + + +def _platform_value(platform: Platform | str) -> str: + return platform.value if isinstance(platform, Platform) else platform + + +__all__ = [ + "DiscoveredSource", + "PermissionObservation", + "Platform", + "ProcessInstanceSelector", + "ProcessTreeScope", + "SelectorPersistenceScope", + "Source", + "SourceFailureClass", + "SourceIdentityStrength", + "SourceKind", + "SourceQuery", + "SourceRecoveryRequirement", + "SourceRuntimeEvent", + "SourceRuntimeEventKind", + "SourceSelectorKind", + "SourceState", + "StableSourceId", + "application_capture_available", + "discover_sources", + "microphone_permission_observation", +] diff --git a/python/pocketstation/streams.py b/python/pocketstation/streams.py new file mode 100644 index 0000000..68e8ae9 --- /dev/null +++ b/python/pocketstation/streams.py @@ -0,0 +1,308 @@ +"""Bounded synchronous streams over one native polled-audio endpoint.""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Callable, Iterator +from threading import Lock +from typing import Literal + +from ._native import AudioBatch, AudioFrame, _SignalRead, _SignalSubscriptionMetrics +from .errors import StreamError, StreamInUseError, StreamModeError +from .signal import ( + STREAM_EOF, + EndOfStream, + SignalEnvelope, + SignalReadResult, + SignalSubscriptionMetrics, +) + +_ReaderMode = Literal[ + "frames", + "batches", + "read", + "events", + "event_read", + "signals", + "signal_read", + "sidecar", + "sidecar_read", +] +_MAXIMUM_TIMEOUT_SECONDS = 1.0 +_DEFAULT_ITERATION_TIMEOUT_SECONDS = 0.1 + + +class _ReaderState: + """Fail-fast one-mode/one-reader ownership shared by sync and asyncio.""" + + def __init__(self) -> None: + self._lock = Lock() + self._mode: _ReaderMode | None = None + self._active: object | None = None + + @property + def mode(self) -> str | None: + with self._lock: + return self._mode + + def claim(self, mode: _ReaderMode) -> object: + token = object() + with self._lock: + if self._mode is not None and self._mode != mode: + raise StreamModeError(self._mode, mode) + if self._active is not None: + raise StreamInUseError(mode) + self._mode = mode + self._active = token + return token + + def release(self, token: object) -> None: + with self._lock: + if self._active is token: + self._active = None + + +def _timeout_milliseconds(timeout_s: float, *, label: str = "timeout_s") -> int: + if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)): + raise TypeError(f"{label} must be a number") + value = float(timeout_s) + if not 0.0 <= value <= _MAXIMUM_TIMEOUT_SECONDS: + raise ValueError(f"{label} must be between 0.0 and 1.0") + return round(value * 1_000) + + +def _iteration_timeout_milliseconds(timeout_s: float) -> int: + timeout_ms = _timeout_milliseconds(timeout_s, label="wait_timeout_s") + if timeout_ms == 0: + raise ValueError("wait_timeout_s must be at least 0.001") + return timeout_ms + + +class AudioStream: + """Frame-first view of one native bounded audio endpoint. + + Native batches are flattened lazily. At most one batch is retained while + its frames are consumed, so this object never creates a second audio queue. + The first reader mode is permanent for the stream lifetime and concurrent + readers fail immediately. + """ + + def __init__( + self, + *, + poll_batch: Callable[[], AudioBatch | None], + wait_batch: Callable[[int], AudioBatch | None], + is_closed: Callable[[], bool], + ) -> None: + self._poll_batch = poll_batch + self._wait_batch = wait_batch + self._is_closed = is_closed + self._state = _ReaderState() + self._pending_frames: deque[AudioFrame] = deque() + + @property + def reader_mode(self) -> str | None: + """Return the permanently selected reader mode, if consumption began.""" + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._is_closed() + + def read(self, *, timeout_s: float = 1.0) -> AudioFrame | None: + """Read one frame, or return ``None`` when the bounded wait expires.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("read") + try: + return self._read_frame(timeout_ms) + finally: + self._state.release(token) + + def poll_batch(self) -> AudioBatch | None: + """Advanced non-blocking batch read using the exclusive batch mode.""" + token = self._state.claim("batches") + try: + return None if self.is_closed else self._poll_batch() + finally: + self._state.release(token) + + def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: + """Advanced bounded batch read using the exclusive batch mode.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("batches") + try: + return None if self.is_closed else self._wait_batch(timeout_ms) + finally: + self._state.release(token) + + def __iter__(self) -> Iterator[AudioFrame]: + return self.frames() + + def frames( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[AudioFrame]: + """Yield frames lazily until the owning Session closes.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[AudioFrame]: + token = self._state.claim("frames") + try: + while not self.is_closed: + frame = self._read_frame(timeout_ms) + if frame is not None: + yield frame + finally: + self._state.release(token) + + return iterate() + + def batches( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[AudioBatch]: + """Yield native-owned batches without adding a managed queue.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[AudioBatch]: + token = self._state.claim("batches") + try: + while not self.is_closed: + batch = self._wait_batch(timeout_ms) + if batch is not None: + yield batch + finally: + self._state.release(token) + + return iterate() + + def _read_frame(self, timeout_ms: int) -> AudioFrame | None: + if self._pending_frames: + return self._pending_frames.popleft() + if self.is_closed: + return None + batch = self._wait_batch(timeout_ms) + if batch is None: + return None + self._pending_frames.extend(batch) + if not self._pending_frames: + return None + return self._pending_frames.popleft() + + +class SignalStream: + """Exclusive Pythonic view of one native bounded ``BusSubscription``. + + ``None`` means a bounded read timed out, while ``STREAM_EOF`` means the + endpoint is permanently closed. Iteration handles both states naturally + and owns no queue or worker beyond the canonical Rust edge. + """ + + def __init__( + self, + *, + poll_signal: Callable[[], _SignalRead], + wait_signal: Callable[[int], _SignalRead], + close_signal: Callable[[], None], + signal_metrics: Callable[[], _SignalSubscriptionMetrics], + ) -> None: + self._poll_signal = poll_signal + self._wait_signal = wait_signal + self._close_signal = close_signal + self._signal_metrics = signal_metrics + self._state = _ReaderState() + self._closed = False + + @property + def reader_mode(self) -> str | None: + return self._state.mode + + @property + def is_closed(self) -> bool: + return self._closed + + def poll(self) -> SignalReadResult: + """Read immediately: envelope, ``None`` for empty, or ``STREAM_EOF``.""" + token = self._state.claim("signal_read") + try: + return STREAM_EOF if self._closed else self._decode(self._poll_signal()) + finally: + self._state.release(token) + + def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: + """Perform one native bounded wait with explicit timeout and EOF states.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("signal_read") + try: + return ( + STREAM_EOF + if self._closed + else self._decode(self._wait_signal(timeout_ms)) + ) + finally: + self._state.release(token) + + def __iter__(self) -> Iterator[SignalEnvelope]: + return self.iter_signals() + + def iter_signals( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[SignalEnvelope]: + """Yield immutable envelopes until native EOF or explicit close.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[SignalEnvelope]: + token = self._state.claim("signals") + try: + while not self._closed: + result = self._decode(self._wait_signal(timeout_ms)) + if isinstance(result, EndOfStream): + break + if result is not None: + yield result + finally: + self._state.release(token) + + return iterate() + + def close(self) -> None: + """Idempotently close this receipt without stopping its Session.""" + if self._closed: + return + self._close_signal() + self._closed = True + + def metrics(self) -> SignalSubscriptionMetrics: + """Snapshot capacity, payload-byte bounds, depth, delivery, and drops.""" + return SignalSubscriptionMetrics._from_native(self._signal_metrics()) + + def _decode(self, result: _SignalRead) -> SignalReadResult: + if result.status == "item": + if result.envelope is None: + raise StreamError( + "native signal read omitted its envelope", + "stream.invalid_read", + ) + return SignalEnvelope._from_native(result.envelope) + if result.status == "empty": + return None + if result.status == "closed": + self._closed = True + return STREAM_EOF + if result.status == "fault": + self._closed = True + raise StreamError( + result.error or "native signal endpoint failed", + "stream.fault", + ) + raise StreamError( + f"native signal read has unknown state {result.status!r}", + "stream.invalid_read", + ) + + +__all__ = ["AudioStream", "SignalStream"] diff --git a/tests/_pkss_child.py b/tests/_pkss_child.py new file mode 100644 index 0000000..8511b8f --- /dev/null +++ b/tests/_pkss_child.py @@ -0,0 +1,101 @@ +"""Independent test peer for the frozen PKSS 1.0 process contract.""" + +from __future__ import annotations + +import struct +import sys +import time +from typing import BinaryIO + +MAGIC = b"PKSS" +HEADER_BYTES = 52 +KINDS = { + "signal": 1, + "ready": 2, + "cancel": 4, + "close": 5, + "hello": 6, + "manifest": 7, + "configure": 8, + "closed": 10, +} + + +def read_exact(stream: BinaryIO, size: int) -> bytes: + data = bytearray() + while len(data) < size: + chunk = stream.read(size - len(data)) + if not chunk: + raise EOFError + data.extend(chunk) + return bytes(data) + + +def read_message(stream: BinaryIO) -> tuple[int, bytes]: + size = struct.unpack(" None: + if source is not None: + frame = bytearray(source) + frame[8] = kind + else: + header = ( + MAGIC + + struct.pack(" int: + mode = sys.argv[1] if len(sys.argv) > 1 else "healthy" + reader = sys.stdin.buffer + writer = sys.stdout.buffer + kind, _ = read_message(reader) + if kind != KINDS["hello"]: + return 2 + write_message(writer, KINDS["hello"]) + if mode == "malformed": + writer.write(struct.pack(" None: + subprocess.run(arguments, cwd=cwd, check=True) + + +def main() -> int: + uv = shutil.which("uv") + if uv is None: + raise SystemExit("uv is required for installed-wheel conformance") + + with tempfile.TemporaryDirectory(prefix="pks-w21-stream-") as temporary: + root = Path(temporary) + wheelhouse = root / "wheelhouse" + environment = root / "environment" + wheelhouse.mkdir() + + _run( + [ + uv, + "run", + "maturin", + "build", + "--release", + "--features", + "conformance-fixtures", + "--out", + os.fspath(wheelhouse), + ], + cwd=REPOSITORY, + ) + wheels = tuple(wheelhouse.glob("pocketstation-*.whl")) + if len(wheels) != 1: + raise SystemExit(f"expected one wheel, found {len(wheels)}") + + _run( + [uv, "venv", os.fspath(environment), "--python", sys.executable], + cwd=root, + ) + interpreter = ( + environment / "Scripts" / "python.exe" + if os.name == "nt" + else environment / "bin" / "python" + ) + _run( + [ + uv, + "pip", + "install", + "--python", + os.fspath(interpreter), + os.fspath(wheels[0]), + "pytest", + "pytest-asyncio", + ], + cwd=root, + ) + _run( + [ + os.fspath(interpreter), + "-m", + "pytest", + "-q", + "--import-mode=importlib", + *(os.fspath(test) for test in TESTS), + "-k", + "canonical_native_session", + "-rs", + ], + cwd=root, + ) + package_path = subprocess.run( + [ + os.fspath(interpreter), + "-c", + "import pocketstation; print(pocketstation.__file__)", + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if not Path(package_path).is_relative_to(environment): + raise SystemExit( + f"PocketStation was not imported from the wheel: {package_path}" + ) + print(f"installed_package={package_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_relay_e2e_publisher.py b/tests/run_relay_e2e_publisher.py new file mode 100644 index 0000000..85f2e68 --- /dev/null +++ b/tests/run_relay_e2e_publisher.py @@ -0,0 +1,157 @@ +"""Run the real Python → Rust connector → relay path for aggregate E2E.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from time import sleep +from typing import Any + +import pocketstation as pks + + +def emit(message_type: str, **fields: Any) -> None: + print(json.dumps({"type": message_type, **fields}, sort_keys=True), flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--control-plane-url", required=True) + parser.add_argument("--relay-url", required=True) + parser.add_argument("--recording-root", type=Path, required=True) + parser.add_argument("--active-seconds", type=float, default=2.0) + arguments = parser.parse_args() + if arguments.active_seconds <= 0: + parser.error("--active-seconds must be positive") + if not hasattr(pks._native.Session, "conformance"): + emit("failure", code="relay.conformance_fixture_unavailable") + return 2 + + remote: pks.RelaySession | None = None + running: pks.RunningSession | None = None + try: + remote = pks.RelaySession.create( + control_plane_url=arguments.control_plane_url, + relay_url=arguments.relay_url, + ) + session = pks.Session._from_native( + pks._native.Session.conformance(arguments.recording_root) + ) + application = session.capture( + pks.Source.application("PocketStation Python Fixture") + ) + microphone = session.capture(pks.Source.microphone_default()) + publisher = session.relay(remote) + routes = ( + application.publish(publisher, "application"), + microphone.publish(publisher, "microphone"), + ) + application.record("application") + microphone.record("microphone") + + running = session.start() + invitation = remote.wait_for_publisher_and_invitation( + timeout_seconds=15.0, + poll_interval_seconds=0.05, + ) + emit( + "invitation", + session_id=str(remote.session_id), + join_code=invitation.join_code, + join_url=invitation.join_url, + buses=[route.bus_id for route in routes], + route_ids=[route.route_id for route in routes], + ) + + receiver = remote.wait_for_receiver( + timeout_seconds=20.0, + poll_interval_seconds=0.05, + ) + emit( + "receiver-active", + session_id=str(remote.session_id), + source_active=receiver.snapshot.source_active, + subscription_count=receiver.snapshot.subscription_count, + ) + sleep(arguments.active_seconds) + + stop = running.stop() + running = None + recording = stop.recording + relay_outcomes = [ + { + "bus_id": outcome.bus_id, + "frames_received_total": outcome.frames_received_total, + "rtp_packets_sent_total": outcome.rtp_packets_sent_total, + "rtp_payload_bytes_sent_total": outcome.rtp_payload_bytes_sent_total, + "ingress_queue_drops_total": outcome.ingress_queue_drops_total, + "publisher_stale_drops_total": outcome.publisher_stale_drops_total, + "failures_total": outcome.failures_total, + "error": outcome.error, + } + for outcome in stop.relay_outcomes + ] + recording_stems = ( + [] + if recording is None + else [ + { + "stem_name": stem.stem_name, + "frames_written_total": stem.frames_written_total, + "frames_dropped_total": stem.frames_dropped_total, + "discontinuities_total": stem.discontinuities_total, + "error": stem.error, + } + for stem in recording.stems + ] + ) + expected_buses = {"application", "microphone"} + success = ( + stop.success + and recording is not None + and recording.complete + and {stem["stem_name"] for stem in recording_stems} == expected_buses + and all(stem["frames_written_total"] > 0 for stem in recording_stems) + and {outcome["bus_id"] for outcome in relay_outcomes} == expected_buses + and all( + outcome["frames_received_total"] > 0 + and outcome["rtp_packets_sent_total"] > 0 + and outcome["failures_total"] == 0 + and outcome["error"] is None + for outcome in relay_outcomes + ) + ) + remote.close() + remote = None + emit( + "final", + success=success, + recording_complete=recording is not None and recording.complete, + recording_stems=recording_stems, + relay_outcomes=relay_outcomes, + ) + return 0 if success else 3 + except BaseException as error: + emit( + "failure", + error_type=type(error).__name__, + code=getattr(error, "code", "relay.e2e_failure"), + ) + return 1 + finally: + if running is not None: + try: + running.cancel() + except BaseException: + pass + if remote is not None: + try: + remote.close() + except BaseException: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_aio_capture.py b/tests/test_aio_capture.py new file mode 100644 index 0000000..85ffdf4 --- /dev/null +++ b/tests/test_aio_capture.py @@ -0,0 +1,95 @@ +"""Progressive asyncio capture recipe tests.""" + +from __future__ import annotations + +import importlib + +import pytest + +capture_module = importlib.import_module("pocketstation.aio.capture") + + +class FakeStopResult: + success = True + recording = None + + +class FakeRunning: + def __init__(self) -> None: + self.is_stopped = False + self.stop_result = None + self.stop_calls = 0 + self.audio = object() + + async def stop(self): + self.stop_calls += 1 + self.is_stopped = True + self.stop_result = FakeStopResult() + return self.stop_result + + async def aclose(self): + await self.stop() + + +class FakeEndpoint: + pass + + +class FakeStem: + next_id = 1 + + def __init__(self) -> None: + self.id = FakeStem.next_id + FakeStem.next_id += 1 + self.sent = [] + self.recorded = [] + + def send(self, endpoint): + self.sent.append(endpoint) + return self.id + 100 + + def record(self, name): + self.recorded.append(name) + return FakeEndpoint() + + +class FakeSession: + latest = None + + def __init__(self, *, recording_root=None) -> None: + FakeSession.latest = self + self.recording_root = recording_root + self.stems = [] + self.endpoint = FakeEndpoint() + self.running = FakeRunning() + + def capture(self, source): + stem = FakeStem() + self.stems.append(stem) + return stem + + def polled_audio(self): + return self.endpoint + + async def start(self): + return self.running + + +@pytest.mark.asyncio +async def test_given_async_recipe_when_exited_then_native_session_stops( + monkeypatch, tmp_path +): + monkeypatch.setattr(capture_module, "Session", FakeSession) + + async with capture_module.capture( + application="Spotify", + microphone=True, + record_to=tmp_path, + ) as live: + declared = FakeSession.latest + assert declared is not None + assert len(declared.stems) == 2 + assert live.is_running + assert live.audio is declared.running.audio + + assert declared.running.stop_calls == 1 diff --git a/tests/test_aio_observations.py b/tests/test_aio_observations.py new file mode 100644 index 0000000..e37e170 --- /dev/null +++ b/tests/test_aio_observations.py @@ -0,0 +1,139 @@ +"""Asyncio event stream ownership tests.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from pocketstation import StreamInUseError, StreamModeError, _native +from pocketstation.aio import EventStream, RunningSession +from pocketstation.aio.session import _native_async + + +def _event_stream(events): + remaining = list(events) + state = {"closed": False, "waits": 0} + + async def wait_event(_timeout_ms): + state["waits"] += 1 + await asyncio.sleep(0) + if remaining: + return remaining.pop(0) + state["closed"] = True + return None + + async def poll_event(): + return remaining.pop(0) if remaining else None + + return ( + EventStream( + poll_event=poll_event, + wait_event=wait_event, + is_closed=lambda: state["closed"], + ), + state, + ) + + +@pytest.mark.asyncio +async def test_async_event_iteration_uses_bounded_native_waits() -> None: + stream, state = _event_stream(["started", "failed"]) + + assert [event async for event in stream] == ["started", "failed"] + assert state["waits"] == 3 + assert stream.reader_mode == "events" + + +@pytest.mark.asyncio +async def test_async_event_modes_cannot_be_mixed() -> None: + stream, _ = _event_stream(["started"]) + + assert await stream.read() == "started" + with pytest.raises(StreamModeError): + await anext(stream.iter_events()) + + +@pytest.mark.asyncio +async def test_concurrent_async_event_reader_fails_immediately() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + async def wait_event(_timeout_ms): + entered.set() + await release.wait() + return "started" + + async def poll_event(): + return None + + stream = EventStream( + poll_event=poll_event, + wait_event=wait_event, + is_closed=lambda: False, + ) + first = asyncio.create_task(stream.read()) + await entered.wait() + + with pytest.raises(StreamInUseError): + await stream.read(timeout_s=0.0) + + release.set() + assert await first == "started" + + +@pytest.mark.asyncio +async def test_async_running_session_exposes_event_stream() -> None: + class NativeRunning: + def poll_audio(self): + return None + + def wait_audio(self, _timeout_ms): + return None + + def poll_event(self): + return None + + def wait_event(self, _timeout_ms): + return SimpleNamespace( + kind="lifecycle", + lifecycle_state="running", + session_id=1, + stem_id=None, + endpoint_id=None, + route_id=None, + failures_total=0, + terminal_state=None, + source_event_kind=None, + failures=lambda: [], + ) + + running = RunningSession(NativeRunning()) + + assert (await running.events.read()).lifecycle_state == "running" + with pytest.raises(StreamModeError): + await anext(running.events.iter_events()) + + +@pytest.mark.asyncio +async def test_async_event_wait_uses_the_canonical_native_session(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + session = _native.Session.conformance(tmp_path) + application = session.capture( + _native.Source.application("PocketStation Python Fixture") + ) + microphone = session.capture(_native.Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + running = RunningSession(await _native_async(session.start)) + try: + event = await running.events.read(timeout_s=1.0) + assert event is not None + assert event.session_id > 0 + assert event.kind + finally: + assert (await running.stop()).success diff --git a/tests/test_aio_relay.py b/tests/test_aio_relay.py new file mode 100644 index 0000000..3a1a4da --- /dev/null +++ b/tests/test_aio_relay.py @@ -0,0 +1,103 @@ +"""Async relay surface symmetry over the same Rust and service contracts.""" + +from __future__ import annotations + +import httpx +import pytest + +from pocketstation import Source +from pocketstation.aio import ControlClient, RelaySession, Session + +CREATE_RESPONSE = { + "session_id": "session_123", + "source_token": "source-secret", + "subscriber_token": "subscriber-secret", + "whip_url": "https://relay.example/v1/sessions/session_123/whip", + "whep_url": "https://relay.example/v1/sessions/session_123/whep", + "ice_servers": [], +} + + +@pytest.mark.asyncio +async def test_async_relay_composes_native_routes_and_real_readiness() -> None: + control_requests: list[httpx.Request] = [] + relay_requests: list[httpx.Request] = [] + snapshots = iter([_snapshot(True, 0), _snapshot(True, 1)]) + + async def control_handler(request: httpx.Request) -> httpx.Response: + control_requests.append(request) + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + return httpx.Response(200, json=next(snapshots)) + return httpx.Response(204) + + async def relay_handler(request: httpx.Request) -> httpx.Response: + relay_requests.append(request) + assert request.headers["authorization"] == "Bearer source-secret" + return httpx.Response( + 201, + json={ + "session_id": "session_123", + "join_code": "opaque-code", + "join_url": "https://receiver.example/?join=opaque-code", + }, + ) + + async with ( + httpx.AsyncClient( + transport=httpx.MockTransport(control_handler) + ) as control_http, + httpx.AsyncClient(transport=httpx.MockTransport(relay_handler)) as relay_http, + ): + control = ControlClient( + "https://control.example", + http_client=control_http, + ) + remote = await RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example/", + control_client=control, + relay_http_client=relay_http, + ) + + session = Session() + application = session.capture(Source.application("PocketStation Fixture")) + microphone = session.capture(Source.microphone_default()) + publisher = session.relay(remote) + app_route = application.publish(publisher, "application") + mic_route = microphone.publish(publisher, "microphone") + + invitation = await remote.wait_for_publisher_and_invitation( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + receiver = await remote.wait_for_receiver( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + assert app_route.route_id != mic_route.route_id + assert invitation.join_code == "opaque-code" + assert receiver.snapshot.subscription_count == 1 + assert "source-secret" not in repr(remote) + + await remote.aclose() + await remote.aclose() + + assert [(request.method, request.url.path) for request in control_requests] == [ + ("POST", "/v1/sessions"), + ("GET", "/v1/sessions/session_123"), + ("GET", "/v1/sessions/session_123"), + ("DELETE", "/v1/sessions/session_123"), + ] + assert len(relay_requests) == 1 + + +def _snapshot(source_active: bool, subscription_count: int) -> dict[str, object]: + return { + "session_id": "session_123", + "source_active": source_active, + "subscription_count": subscription_count, + "codec": "opus", + } diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py new file mode 100644 index 0000000..b9b6d93 --- /dev/null +++ b/tests/test_aio_session.py @@ -0,0 +1,57 @@ +"""Asyncio Session ownership and cancellation tests.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from array import array + +import pytest +from pocketstation.aio import Session + + +@pytest.mark.asyncio +async def test_cancelled_start_requests_the_native_token() -> None: + native_started = threading.Event() + native_cancelled = threading.Event() + + class BlockingNativeSession: + def start(self, cancellation): + native_started.set() + while not cancellation.is_requested(): + time.sleep(0.001) + native_cancelled.set() + raise RuntimeError("session.start_cancelled") + + session = Session.__new__(Session) + session._native = BlockingNativeSession() + start = asyncio.create_task(session.start()) + assert await asyncio.to_thread(native_started.wait, 1.0) + + start.cancel() + with pytest.raises(asyncio.CancelledError): + await start + + assert await asyncio.to_thread(native_cancelled.wait, 1.0) + + +@pytest.mark.asyncio +async def test_application_owned_pcm_has_an_async_writer() -> None: + session = Session() + audio = session.audio_input( + "playback", + capacity_frames=2, + frame_samples_per_channel=4, + ) + audio.output.send(session.polled_audio()) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + frame = await running.audio.read(timeout_s=1.0) + await running.stop() + + assert frame is not None + assert frame.source_id == audio.source_id + assert frame.stream_id == audio.stream_id + assert list(frame.samples.cast("f")) == pytest.approx([0.1, 0.2, 0.3, 0.4]) diff --git a/tests/test_aio_streams.py b/tests/test_aio_streams.py new file mode 100644 index 0000000..04e1f1d --- /dev/null +++ b/tests/test_aio_streams.py @@ -0,0 +1,241 @@ +"""Asyncio bounded audio-stream ownership and cancellation tests.""" + +from __future__ import annotations + +import asyncio +import threading +from time import monotonic + +import pytest + +from pocketstation import StreamInUseError, StreamModeError, _native +from pocketstation.aio import AudioStream, RunningSession +from pocketstation.aio.session import _native_async + + +def _stream_from_batches(batches): + remaining = list(batches) + state = {"closed": False, "waits": 0} + + async def wait_batch(_timeout_ms): + state["waits"] += 1 + await asyncio.sleep(0) + if remaining: + return remaining.pop(0) + state["closed"] = True + return None + + async def poll_batch(): + return None + + return ( + AudioStream( + poll_batch=poll_batch, + wait_batch=wait_batch, + is_closed=lambda: state["closed"], + ), + state, + ) + + +async def _canonical_running_session(recording_root) -> RunningSession: + session = _native.Session.conformance(recording_root) + application = session.capture( + _native.Source.application("PocketStation Python Fixture") + ) + microphone = session.capture(_native.Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + return RunningSession(await _native_async(session.start)) + + +@pytest.mark.asyncio +async def test_async_iteration_flattens_native_batches() -> None: + stream, state = _stream_from_batches([["a", "b"], ["c"]]) + + observed = [frame async for frame in stream] + + assert observed == ["a", "b", "c"] + assert state["waits"] == 3 + assert stream.reader_mode == "frames" + + +@pytest.mark.asyncio +async def test_async_running_session_exposes_the_same_exclusive_stream() -> None: + class NativeRunning: + def __init__(self) -> None: + self.batches = [["a"]] + + def poll_audio(self): + return None + + def wait_audio(self, _timeout_ms): + return self.batches.pop(0) if self.batches else None + + running = RunningSession(NativeRunning()) + + assert await running.audio.read() == "a" + with pytest.raises(StreamModeError): + await running.wait_audio() + + +@pytest.mark.asyncio +async def test_concurrent_async_read_fails_immediately() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + async def wait_batch(_timeout_ms): + entered.set() + await release.wait() + return ["a"] + + async def poll_batch(): + return None + + stream = AudioStream( + poll_batch=poll_batch, + wait_batch=wait_batch, + is_closed=lambda: False, + ) + first = asyncio.create_task(stream.read()) + await entered.wait() + + with pytest.raises(StreamInUseError): + await stream.read(timeout_s=0.0) + + release.set() + assert await first == "a" + + +@pytest.mark.asyncio +async def test_cancelled_reader_settles_before_releasing_ownership() -> None: + entered = asyncio.Event() + settled = asyncio.Event() + + async def wait_batch(_timeout_ms): + entered.set() + try: + await asyncio.Future() + finally: + settled.set() + + async def poll_batch(): + return None + + stream = AudioStream( + poll_batch=poll_batch, + wait_batch=wait_batch, + is_closed=lambda: False, + ) + reader = asyncio.create_task(stream.read()) + await entered.wait() + reader.cancel() + + with pytest.raises(asyncio.CancelledError): + await reader + + assert settled.is_set() + + +@pytest.mark.asyncio +async def test_async_reader_mode_cannot_change() -> None: + stream, _ = _stream_from_batches([["a"]]) + assert await stream.read() == "a" + + with pytest.raises(StreamModeError): + await anext(stream.frames()) + + +@pytest.mark.asyncio +async def test_native_cancellation_waits_for_bounded_thread_cleanup() -> None: + entered = threading.Event() + release = threading.Event() + finished = threading.Event() + + def operation() -> str: + entered.set() + assert release.wait(1.0) + finished.set() + return "done" + + 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() + + +@pytest.mark.asyncio +async def test_async_iteration_rejects_a_busy_poll_timeout() -> None: + stream, _ = _stream_from_batches([]) + with pytest.raises(ValueError, match=r"at least 0\.001"): + await anext(stream.frames(wait_timeout_s=0.0)) + + +@pytest.mark.asyncio +async def test_async_frame_stream_preserves_two_stems_from_canonical_native_session( + tmp_path, +) -> None: + """Exercise async iteration over Rust's deterministic Session engine.""" + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + running = await _canonical_running_session(tmp_path) + frames = running.audio.frames(wait_timeout_s=0.1) + observed_stems: set[int] = set() + try: + first = await anext(frames) + observed_stems.add(first.stem_id) + assert first.source_id > 0 + assert first.sequence_number >= 0 + assert first.timestamp_start_ns >= 0 + assert first.discontinuity_epoch >= 0 + assert first.samples.readonly + + with pytest.raises(StreamInUseError): + await anext(running.audio.frames(wait_timeout_s=0.1)) + with pytest.raises(StreamModeError): + await running.audio.read(timeout_s=0.1) + + async for frame in frames: + observed_stems.add(frame.stem_id) + if len(observed_stems) == 2: + break + finally: + await frames.aclose() + stop = await running.stop() + + assert len(observed_stems) == 2 + assert stop.success + + +@pytest.mark.asyncio +async def test_async_read_and_batch_modes_use_canonical_native_session( + tmp_path, +) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + direct = await _canonical_running_session(tmp_path / "direct") + frame = await direct.audio.read(timeout_s=1.0) + assert frame is not None + assert frame.samples.readonly + assert (await direct.stop()).success + + batched = await _canonical_running_session(tmp_path / "batched") + batches = batched.audio.batches(wait_timeout_s=0.1) + try: + batch = await anext(batches) + assert len(batch) > 0 + assert all(frame.samples.readonly for frame in batch) + finally: + await batches.aclose() + stop = await batched.stop() + assert stop.success diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..54f98b3 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,126 @@ +"""Progressive synchronous capture recipe tests.""" + +from __future__ import annotations + +import importlib + +import pytest + +capture_module = importlib.import_module("pocketstation.capture") + + +class FakeStopResult: + success = True + recording = None + + +class FakeRunning: + def __init__(self) -> None: + self.is_stopped = False + self.stop_result = None + self.stop_calls = 0 + self.audio = object() + + def stop(self): + self.stop_calls += 1 + self.is_stopped = True + self.stop_result = FakeStopResult() + return self.stop_result + + def close(self): + self.stop() + + def poll_audio(self): + return None + + def audio_batches(self, *, wait_timeout_ms=100): + return iter(()) + + def poll_event(self): + return None + + def metrics(self): + return {"source_count": 2} + + +class FakeEndpoint: + pass + + +class FakeStem: + next_id = 1 + + def __init__(self, source) -> None: + self.source = source + self.id = FakeStem.next_id + FakeStem.next_id += 1 + self.sent = [] + self.recorded = [] + + def send(self, endpoint): + self.sent.append(endpoint) + return self.id + 100 + + def record(self, name): + self.recorded.append(name) + return FakeEndpoint() + + +class FakeSession: + latest = None + + def __init__(self, *, recording_root=None) -> None: + FakeSession.latest = self + self.recording_root = recording_root + self.stems = [] + self.endpoint = FakeEndpoint() + self.running = FakeRunning() + + def capture(self, source): + stem = FakeStem(source) + self.stems.append(stem) + return stem + + def polled_audio(self): + return self.endpoint + + def start(self): + return self.running + + +def test_given_recipe_when_entered_then_one_session_owns_two_stems( + monkeypatch, tmp_path +): + monkeypatch.setattr(capture_module, "Session", FakeSession) + + with capture_module.capture( + application="Spotify", + microphone=True, + record_to=tmp_path, + ) as live: + declared = FakeSession.latest + assert declared is not None + assert len(declared.stems) == 2 + assert declared.stems[0].sent == [declared.endpoint] + assert declared.stems[1].sent == [declared.endpoint] + assert declared.stems[0].recorded == ["application"] + assert declared.stems[1].recorded == ["microphone"] + assert live.application_stem.id != live.microphone_stem.id + assert live.audio is declared.running.audio + + assert declared.running.stop_calls == 1 + + +def test_given_recipe_without_microphone_when_declared_then_one_stem(monkeypatch): + monkeypatch.setattr(capture_module, "Session", FakeSession) + live = capture_module.capture(application="Spotify", microphone=False) + + assert len(FakeSession.latest.stems) == 1 + assert live.microphone_stem is None + assert live.microphone_route_id is None + + +@pytest.mark.parametrize("microphone", [None, 3, object()]) +def test_given_invalid_microphone_selector_when_declared_then_rejected(microphone): + with pytest.raises(TypeError, match="microphone must be"): + capture_module.capture(application="Spotify", microphone=microphone) diff --git a/tests/test_control.py b/tests/test_control.py new file mode 100644 index 0000000..f1270e6 --- /dev/null +++ b/tests/test_control.py @@ -0,0 +1,170 @@ +"""Bounded, typed control-plane client contract tests.""" + +from __future__ import annotations + +import httpx +import pytest + +from pocketstation import ( + ControlClient, + ControlPlaneError, + SecretToken, + SessionId, +) +from pocketstation.aio import ControlClient as AsyncControlClient + +CREATE_RESPONSE = { + "session_id": "session_123", + "source_token": "source-secret", + "subscriber_token": "subscriber-secret", + "whip_url": "https://relay.example/v1/sessions/session_123/whip", + "whep_url": "https://relay.example/v1/sessions/session_123/whep", + "ice_servers": [ + { + "urls": ["turn:turn.example:3478"], + "username": "session_123", + "credential": "turn-secret", + } + ], +} + + +def test_sync_client_maps_the_exact_session_contract_and_redacts_tokens() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and request.url.path.endswith("/v1/sessions"): + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "session_id": "session_123", + "source_active": True, + "subscription_count": 2, + "codec": "opus", + }, + ) + if request.url.path.endswith("/subscribe"): + return httpx.Response( + 200, + json={ + "session_id": "session_123", + "subscriber_token": "next-subscriber-secret", + }, + ) + assert request.headers["authorization"] == "Bearer source-secret" + return httpx.Response(204) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as http_client: + with ControlClient( + "https://control.example/base?discarded=yes", + http_client=http_client, + ) as client: + credentials = client.create_session() + snapshot = client.session(credentials.session_id) + subscriber = client.issue_subscriber_credentials(credentials.session_id) + client.delete_session(credentials.session_id, credentials.source_token) + + assert credentials.session_id == SessionId("session_123") + assert credentials.source_token.expose_secret() == "source-secret" + assert "source-secret" not in repr(credentials.source_token) + assert credentials.ice_servers[0].urls == ("turn:turn.example:3478",) + assert snapshot.source_active is True + assert snapshot.subscription_count == 2 + assert subscriber.subscriber_token.expose_secret() == "next-subscriber-secret" + assert [(request.method, request.url.path) for request in requests] == [ + ("POST", "/base/v1/sessions"), + ("GET", "/base/v1/sessions/session_123"), + ("POST", "/base/v1/sessions/session_123/subscribe"), + ("DELETE", "/base/v1/sessions/session_123"), + ] + + +@pytest.mark.asyncio +async def test_async_client_has_the_same_wire_contract() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and request.url.path == "/v1/sessions": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "session_id": "session_123", + "source_active": False, + "subscription_count": 0, + "codec": "opus", + }, + ) + if request.url.path.endswith("/subscribe"): + return httpx.Response( + 200, + json={ + "session_id": "session_123", + "subscriber_token": "next-subscriber-secret", + }, + ) + assert request.headers["authorization"] == "Bearer source-secret" + return httpx.Response(204) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as http_client: + async with AsyncControlClient( + "https://control.example", + http_client=http_client, + ) as client: + credentials = await client.create_session() + snapshot = await client.session(credentials.session_id) + subscriber = await client.issue_subscriber_credentials( + credentials.session_id + ) + await client.delete_session( + credentials.session_id, + credentials.source_token, + ) + + assert snapshot.source_active is False + assert subscriber.subscriber_token.expose_secret() == "next-subscriber-secret" + assert [(request.method, request.url.path) for request in requests] == [ + ("POST", "/v1/sessions"), + ("GET", "/v1/sessions/session_123"), + ("POST", "/v1/sessions/session_123/subscribe"), + ("DELETE", "/v1/sessions/session_123"), + ] + + +def test_control_client_bounds_response_bodies() -> None: + transport = httpx.MockTransport( + lambda _request: httpx.Response(201, content=b"x" * 65_537) + ) + with httpx.Client(transport=transport) as http_client: + client = ControlClient("https://control.example", http_client=http_client) + with pytest.raises(ControlPlaneError) as raised: + client.create_session() + + assert raised.value.code == "control.response_too_large" + + +def test_control_client_redacts_authorization_from_http_error() -> None: + token = SecretToken("must-not-leak") + transport = httpx.MockTransport( + lambda _request: httpx.Response(401, text="rejected must-not-leak") + ) + with httpx.Client(transport=transport) as http_client: + client = ControlClient("https://control.example", http_client=http_client) + with pytest.raises(ControlPlaneError) as raised: + client.delete_session("session_123", token) + + assert "must-not-leak" not in str(raised.value) + assert "[redacted]" in str(raised.value) + + +@pytest.mark.parametrize("value", ["", "../escape", "with/slash", "café"]) +def test_session_id_rejects_unsafe_path_values(value: str) -> None: + with pytest.raises(ValueError): + SessionId(value) diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..d530115 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import pocketstation +from pocketstation import SourceKind, SourceQuery + + +def test_discovery_returns_an_immutable_typed_native_snapshot() -> None: + sources = pocketstation.discover_sources() + + assert isinstance(sources, tuple) + for source in sources: + assert source.stable_id.stable_key + assert source.stable_id.source_id is not None + assert source.sample_rate_hz > 0 + assert source.channel_count > 0 + with pytest.raises(FrozenInstanceError): + source.name = "changed" + + +def test_discovery_query_executes_in_native_and_preserves_exact_identity() -> None: + sources = pocketstation.discover_sources() + if not sources: + pytest.skip("this build exposes no native discovery sources") + expected = sources[0] + + result = pocketstation.discover_sources( + SourceQuery.stable_key(expected.stable_id.stable_key) + ) + + assert result + assert all( + item.stable_id.stable_key == expected.stable_id.stable_key for item in result + ) + + +def test_kind_query_and_capability_query_are_typed() -> None: + applications = pocketstation.discover_sources( + SourceQuery.kind(SourceKind.APPLICATION) + ) + + assert all(item.stable_id.kind is SourceKind.APPLICATION for item in applications) + assert isinstance(pocketstation.application_capture_available(), bool) + + +@pytest.mark.asyncio +async def test_async_discovery_shares_the_synchronous_native_policy() -> None: + synchronous = pocketstation.discover_sources( + SourceQuery.kind(SourceKind.SYSTEM_MIX) + ) + asynchronous = await pocketstation.aio.discover_sources( + SourceQuery.kind(SourceKind.SYSTEM_MIX) + ) + + assert asynchronous == synchronous + + +@pytest.mark.parametrize("builder", [SourceQuery.application, SourceQuery.stable_key]) +def test_query_rejects_empty_values_before_native_work(builder) -> None: + with pytest.raises(ValueError, match="must not be empty"): + builder(" ") diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..b49fd0c --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import time +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pocketstation as pks +import pocketstation.aio as aio +import pytest + +SOURCE_ID = "dev.pocketstation.source.fixture.v1" +OPERATOR_ID = "dev.pocketstation.fixture.operator.v1" +ENDPOINT_ID = "dev.pocketstation.fixture.endpoint.v1" + + +@pytest.fixture(scope="module") +def native_extension_library( + tmp_path_factory: pytest.TempPathFactory, +) -> tuple[Path, Path]: + directory = tmp_path_factory.mktemp("native-extension") + marker = directory / "lifecycle.log" + suffix = ( + ".dll" + if sys.platform == "win32" + else ".dylib" + if sys.platform == "darwin" + else ".so" + ) + prefix = "" if sys.platform == "win32" else "lib" + library = directory / f"{prefix}pks_python_fixture{suffix}" + source = ( + Path(__file__).resolve().parents[1] + / ".." + / "pocketstation" + / "tests" + / "fixtures" + / "native_extension_plugin.rs" + ).resolve() + environment = os.environ.copy() + environment["PKS_FIXTURE_MARKER"] = str(marker) + subprocess.run( + [ + "rustc", + "--crate-type=cdylib", + "--edition=2021", + "-C", + "debuginfo=0", + str(source), + "-o", + str(library), + ], + check=True, + env=environment, + capture_output=True, + text=True, + ) + return library, marker + + +def port( + name: str, + direction: pks.ExtensionPortDirection, +) -> pks.ExtensionPort: + return pks.ExtensionPort( + name=name, + direction=direction, + signal_id="pks.signal.text.utf8.v1", + semantic_role="transcript", + schema="text/plain; charset=utf-8", + ) + + +def test_linked_native_extension_abi_is_authoritative() -> None: + current = pks.ExtensionAbiVersion.current() + + assert current.abi_major == 1 + assert current.abi_minor == 2 + assert current.struct_size_bytes == 8 + current.require_compatible() + + +@pytest.mark.parametrize( + ("kind", "ports"), + [ + ( + pks.ExtensionKind.SOURCE, + (port("output", pks.ExtensionPortDirection.OUTPUT),), + ), + ( + pks.ExtensionKind.OPERATOR, + ( + port("input", pks.ExtensionPortDirection.INPUT), + port("output", pks.ExtensionPortDirection.OUTPUT), + ), + ), + ( + pks.ExtensionKind.ENDPOINT, + (port("input", pks.ExtensionPortDirection.INPUT),), + ), + ], +) +def test_complete_descriptor_is_validated_by_native_abi( + kind: pks.ExtensionKind, + ports: tuple[pks.ExtensionPort, ...], +) -> None: + descriptor = pks.ExtensionDescriptor( + extension_id=f"io.pocketstation.python.test.{kind.value}.v1", + kind=kind, + ports=ports, + revision=2, + generation=3, + ) + + assert descriptor.abi_major == 1 + assert descriptor.abi_minor == 2 + assert descriptor.revision == 2 + assert descriptor.generation == 3 + + +def test_native_validator_rejects_duplicate_ports() -> None: + duplicate = port("signal", pks.ExtensionPortDirection.INPUT) + + with pytest.raises(pks.ExtensionError) as caught: + pks.ExtensionDescriptor( + extension_id="io.pocketstation.python.test.operator.v1", + kind=pks.ExtensionKind.OPERATOR, + ports=( + duplicate, + duplicate, + port("output", pks.ExtensionPortDirection.OUTPUT), + ), + ) + + assert caught.value.code == "extension.invalid_descriptor" + + +def test_native_validator_rejects_incompatible_versions() -> None: + with pytest.raises(pks.ExtensionError) as major: + pks.ExtensionAbiVersion(8, 2, 0).require_compatible() + with pytest.raises(pks.ExtensionError) as minor: + pks.ExtensionAbiVersion(8, 1, 3).require_compatible() + + assert major.value.code == "extension.unsupported_abi_major" + assert minor.value.code == "extension.unsupported_abi_minor" + + +def test_descriptor_is_an_immutable_contract_not_a_python_callback() -> None: + descriptor = pks.ExtensionDescriptor( + extension_id="io.pocketstation.python.test.endpoint.v1", + kind=pks.ExtensionKind.ENDPOINT, + ports=(port("input", pks.ExtensionPortDirection.INPUT),), + ) + + with pytest.raises(FrozenInstanceError): + descriptor.revision = 9 # type: ignore[misc] + assert not hasattr(descriptor, "callback") + assert not hasattr(descriptor, "execute") + + +def test_async_namespace_uses_the_same_descriptor_types() -> None: + assert aio.ExtensionDescriptor is pks.ExtensionDescriptor + assert aio.ExtensionAbiVersion is pks.ExtensionAbiVersion + + +def test_relative_native_library_path_is_rejected_by_core() -> None: + with pytest.raises(pks.ExtensionError) as failure: + pks.Session().load_native_extension_library("fixture-extension") + + assert failure.value.code == "extension.path_not_absolute" + + +def test_native_library_receipt_is_typed_and_immutable( + native_extension_library: tuple[Path, Path], +) -> None: + library, _ = native_extension_library + receipt = pks.Session().load_native_extension_library(library) + + assert receipt.canonical_path == library.resolve() + assert receipt.registrations == ( + pks.NativeExtensionRegistration(SOURCE_ID, pks.ExtensionKind.SOURCE, 1, 1), + pks.NativeExtensionRegistration( + OPERATOR_ID, + pks.ExtensionKind.OPERATOR, + 1, + 1, + ), + pks.NativeExtensionRegistration( + ENDPOINT_ID, + pks.ExtensionKind.ENDPOINT, + 1, + 1, + ), + ) + with pytest.raises(FrozenInstanceError): + receipt.canonical_path = Path("changed") # type: ignore[misc] + + +def test_loaded_native_source_operator_and_endpoint_execute_in_one_session( + native_extension_library: tuple[Path, Path], +) -> None: + library, marker = native_extension_library + session = pks.Session() + session.load_native_extension_library(library) + source = session.source(SOURCE_ID) + operator = session.operator(pks.Operator(OPERATOR_ID)) + source.output("out").connect(operator.input("in")) + endpoint = session.endpoint(pks.EndpointDescriptor(ENDPOINT_ID, ENDPOINT_ID)) + operator.output("out").send(endpoint, input_port="in") + + with session.start(): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if marker.exists() and "consume:hello" in marker.read_text(): + break + time.sleep(0.01) + + assert "consume:hello" in marker.read_text() + + +def test_duplicate_native_library_import_is_transactional( + native_extension_library: tuple[Path, Path], +) -> None: + library, _ = native_extension_library + session = pks.Session() + session.load_native_extension_library(library) + + with pytest.raises(pks.ExtensionError) as failure: + session.load_native_extension_library(library) + + assert failure.value.code == "extension.duplicate_registration" + + +def test_async_session_uses_the_same_native_library_declaration( + native_extension_library: tuple[Path, Path], +) -> None: + library, _ = native_extension_library + receipt = aio.Session().load_native_extension_library(library) + + assert receipt.canonical_path == library.resolve() + assert [registration.kind for registration in receipt.registrations] == [ + pks.ExtensionKind.SOURCE, + pks.ExtensionKind.OPERATOR, + pks.ExtensionKind.ENDPOINT, + ] diff --git a/tests/test_generated_audio.py b/tests/test_generated_audio.py new file mode 100644 index 0000000..28dcf5a --- /dev/null +++ b/tests/test_generated_audio.py @@ -0,0 +1,98 @@ +"""Generated PCM crosses the Rust bridge without Python hot-path callbacks.""" + +from __future__ import annotations + +from time import monotonic + +import pytest + +from pocketstation import Operator, PocketStationError, Session, Source, _native + +GRAPH_OPERATOR_ID = "org.pocketstation.python.conformance.audio-pass-through.v1" +NONCONCRETE_OPERATOR_ID = "org.pocketstation.python.conformance.nonconcrete-audio.v1" + + +def _conformance_session(tmp_path) -> Session: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + return Session._from_native(_native.Session.conformance(tmp_path)) + + +def test_registered_operator_reenters_generated_audio_with_lineage_and_recording( + tmp_path, +) -> None: + session = _conformance_session(tmp_path) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + + application.send(endpoint) + application.record("application") + derived = microphone.through( + Operator(GRAPH_OPERATOR_ID), + input_port="audio-in", + output_port="audio-out", + ) + generated = derived.reenter_audio() + generated.send(endpoint) + generated.record("generated") + + frames_by_stem = {} + running = session.start() + deadline = monotonic() + 5.0 + try: + while monotonic() < deadline and len(frames_by_stem) < 2: + frame = running.audio.read(timeout_s=0.1) + if frame is not None: + frames_by_stem.setdefault(frame.stem_id, frame) + finally: + stop = running.stop() + + assert set(frames_by_stem) == {application.id, generated.id} + assert all(frame.source_id > 0 for frame in frames_by_stem.values()) + assert all(frame.sequence_number >= 0 for frame in frames_by_stem.values()) + assert all(frame.timestamp_start_ns >= 0 for frame in frames_by_stem.values()) + assert all(frame.samples.readonly for frame in frames_by_stem.values()) + assert stop.success + assert stop.recording is not None + assert stop.recording.complete + assert {stem.stem_name for stem in stop.recording.stems} == { + "application", + "generated", + } + + +def test_nonconcrete_operator_output_is_rejected_before_runtime_start(tmp_path) -> None: + session = _conformance_session(tmp_path) + microphone = session.capture(Source.microphone_default()) + output = microphone.through( + Operator(NONCONCRETE_OPERATOR_ID), + input_port="audio-in", + output_port="audio-out", + ) + output.reenter_audio().send(session.polled_audio()) + + with pytest.raises(PocketStationError) as failure: + session.start() + assert failure.value.code == "session.compile_failed" + assert "cannot enter the audio bridge because it is not concrete PCM" in str( + failure.value + ) + + +def test_generated_audio_output_must_remain_exclusive(tmp_path) -> None: + session = _conformance_session(tmp_path) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + output = microphone.through( + Operator(GRAPH_OPERATOR_ID), + input_port="audio-in", + output_port="audio-out", + ) + output.reenter_audio().send(endpoint) + output.send(endpoint) + + with pytest.raises(PocketStationError) as failure: + session.start() + assert failure.value.code == "session.compile_failed" + assert "must have exactly one generated-audio consumer" in str(failure.value) diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..08020bc --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,273 @@ +"""Exact typed graph declarations owned by the canonical Rust Session.""" + +from __future__ import annotations + +import pytest +from pocketstation import ( + AudioCaps, + BackpressurePolicy, + BinaryFormat, + ChannelLayout, + ClockDomain, + Codec, + CopyPolicy, + DeliverySemantics, + EdgeContract, + EdgeObservabilityLevel, + EndpointConfiguration, + EndpointDescriptor, + EventFormat, + LossPolicy, + MediaCaps, + Multiplicity, + Operator, + OperatorConfiguration, + PocketStationError, + PortDirection, + PortSpec, + Session, + SignalSpec, + Source, + SourceConfiguration, + TextFormat, + aio, +) + + +@pytest.mark.parametrize( + ("signal", "wire_id", "is_audio"), + [ + (SignalSpec.any(), "pks.signal.any.v1", False), + (SignalSpec.audio(), "pks.signal.pcm-audio.v1", True), + ( + SignalSpec.encoded_audio(Codec.OPUS), + "pks.signal.encoded.opus.v1", + True, + ), + (SignalSpec.text(TextFormat.JSON), "pks.signal.text.json.v1", False), + (SignalSpec.event(EventFormat.CBOR), "pks.signal.event.cbor.v1", False), + (SignalSpec.metrics(), "pks.signal.metrics.v1", False), + (SignalSpec.control(), "pks.signal.control.v1", False), + ( + SignalSpec.binary(BinaryFormat.FLATBUFFERS), + "pks.signal.binary.flatbuffers.v1", + False, + ), + ( + SignalSpec.custom("org.example.embedding.v1"), + "org.example.embedding.v1", + False, + ), + ], +) +def test_signal_specs_preserve_rust_wire_identity( + signal: SignalSpec, + wire_id: str, + is_audio: bool, +) -> None: + assert signal.wire_id == wire_id + assert signal.is_audio is is_audio + + +def test_signal_role_schema_and_compatibility_remain_open_and_typed() -> None: + partial = SignalSpec.text( + TextFormat.JSON, + role="transcript.partial", + schema="https://example.test/transcript.schema.json", + ) + final = SignalSpec.text(TextFormat.JSON, role="transcript.final") + + assert partial.role == "transcript.partial" + assert partial.schema == "https://example.test/transcript.schema.json" + assert partial.is_compatible_with(final) + assert SignalSpec.any().is_compatible_with(SignalSpec.audio()) + assert not partial.is_compatible_with(SignalSpec.audio()) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: SignalSpec.custom(""), + lambda: SignalSpec.text(role=""), + lambda: SignalSpec.event(schema=""), + ], +) +def test_invalid_signal_contracts_return_stable_rust_error(factory) -> None: + with pytest.raises(PocketStationError) as failure: + factory() + assert failure.value.code == "graph.invalid_contract" + + +def test_media_caps_and_port_specs_are_rust_validated() -> None: + exact = MediaCaps.audio( + AudioCaps( + sample_rate_hz=48_000, + frame_samples=960, + channel_layout=ChannelLayout.MONO, + ) + ) + wildcard = MediaCaps.audio() + stereo = MediaCaps.audio( + AudioCaps( + sample_rate_hz=48_000, + frame_samples=960, + channel_layout=ChannelLayout.STEREO, + ) + ) + + assert wildcard.is_compatible_with(exact) + assert not exact.is_compatible_with(stereo) + assert exact.supports_signal(SignalSpec.audio()) + port = PortSpec( + "audio-in", + PortDirection.INPUT, + SignalSpec.audio(), + exact, + Multiplicity.MANY, + required=True, + ) + assert port.name == "audio-in" + with pytest.raises(PocketStationError) as failure: + PortSpec( + "bad", + PortDirection.INPUT, + SignalSpec.text(), + exact, + ) + assert failure.value.code == "graph.invalid_contract" + + +def test_edge_presets_and_modifiers_preserve_bounded_contracts() -> None: + realtime = EdgeContract.realtime_audio() + assert realtime.clock is ClockDomain.CAPTURE + assert realtime.backpressure is BackpressurePolicy.DROP_NEWEST + assert realtime.delivery is DeliverySemantics.ORDERED + assert realtime.loss is LossPolicy.CONCEAL_FOR_AUDIO + assert realtime.copy_policy is CopyPolicy.SHARE_READ_ONLY + assert realtime.observability is EdgeObservabilityLevel.COUNTERS + assert realtime.max_payload_bytes is None + + bounded = EdgeContract.bounded_async() + assert bounded.clock is ClockDomain.INHERITED + assert bounded.backpressure is BackpressurePolicy.BOUNDED_QUEUE + assert bounded.delivery is DeliverySemantics.ORDERED + assert bounded.loss is LossPolicy.MUST_DELIVER_OR_FAIL + assert bounded.max_payload_bytes == 1_048_576 + + changed = ( + bounded.with_backpressure(BackpressurePolicy.DROP_OLDEST) + .with_copy_policy(CopyPolicy.COPY_TO_BRANCH_POOL) + .with_jitter_budget_ms(25) + .with_max_payload_bytes(4096) + ) + assert changed.backpressure is BackpressurePolicy.DROP_OLDEST + assert changed.copy_policy is CopyPolicy.COPY_TO_BRANCH_POOL + assert changed.jitter_budget_ms == 25 + assert changed.max_payload_bytes == 4096 + assert bounded.backpressure is BackpressurePolicy.BOUNDED_QUEUE + + +def test_configuration_values_are_immutable_snapshots() -> None: + original = {"model": "small"} + operator = OperatorConfiguration(original) + source = SourceConfiguration(original) + endpoint = EndpointConfiguration(original) + original["model"] = "large" + + assert operator.values == (("model", "small"),) + assert source.values == (("model", "small"),) + assert endpoint.values == (("model", "small"),) + assert operator.with_value("model", "large").values == (("model", "large"),) + + +def test_graph_declarations_lower_immediately_to_one_rust_session(tmp_path) -> None: + session = Session(recording_root=tmp_path) + application = session.capture(Source.application("PocketStation Fixture")) + microphone = session.capture(Source.microphone_default()) + operator = session.operator( + Operator( + "org.example.transcriber.v1", + OperatorConfiguration({"language": "en"}), + ) + ) + connector = session.connector( + "org.example.connector.v1", + EndpointConfiguration({"region": "local"}), + ) + browser = session.browser("https://receiver.example.test") + endpoint = session.endpoint( + EndpointDescriptor( + "org.example.endpoint-node.v1", + "org.example.endpoint.v1", + EndpointConfiguration({"mode": "events"}), + EdgeContract.bounded_async(), + ) + ) + + first_route = application.connect(operator.input("audio-in")) + output = operator.output("transcript") + second_route = output.send(connector, input_port="events") + assert application.session_id == session.id + assert microphone.session_id == session.id + assert operator.session_id == session.id + assert connector.session_id == session.id + assert browser.session_id == session.id + assert endpoint.session_id == session.id + assert output.output_port == "transcript" + assert first_route != second_route + + +def test_open_external_source_declaration_and_routes_are_session_owned() -> None: + session = Session() + source = session.source( + "org.example.source.external.v1", + SourceConfiguration({"uri": "fixture://source"}), + ) + output = source.output("audio-out") + operator = session.operator(Operator("org.example.operator.v1")) + + route = output.connect(operator.input("audio-in")) + assert source.session_id == session.id + assert output.session_id == session.id + assert output.output_port == "audio-out" + assert route > 0 + + +def test_cross_session_graph_handles_are_rejected_by_rust() -> None: + first = Session() + second = Session() + stem = first.capture(Source.microphone_default()) + foreign_endpoint = second.polled_audio() + foreign_input = second.operator(Operator("org.example.operator.v1")).input( + "audio-in" + ) + + with pytest.raises(PocketStationError) as endpoint_failure: + stem.send(foreign_endpoint) + with pytest.raises(PocketStationError) as input_failure: + stem.connect(foreign_input) + assert endpoint_failure.value.code.startswith("session.") + assert input_failure.value.code.startswith("session.") + + +def test_unknown_operator_is_rejected_by_the_canonical_compiler() -> None: + session = Session() + microphone = session.capture(Source.microphone_default()) + derived = microphone.through(Operator("org.example.missing.v1")) + derived.send(session.polled_audio()) + + with pytest.raises(PocketStationError) as failure: + session.start() + assert failure.value.code == "session.compile_failed" + assert "operator org.example.missing.v1 is not registered" in str(failure.value) + + +def test_sync_and_async_sessions_share_the_same_graph_declaration_surface() -> None: + sync_session = Session() + async_session = aio.Session() + + for session in (sync_session, async_session): + assert session.id > 0 + declared = session.operator(Operator("org.example.operator.v1")) + assert declared.session_id == session.id + assert session.connector("org.example.connector.v1").session_id == session.id diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..5d8d804 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,145 @@ +"""Stop, cancel, and bounded diagnostic trace lifecycle tests.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from pocketstation import ( + EndpointFailureStage, + Session, + SessionEvent, + SessionFailureKind, + SessionTerminalState, + SessionTrace, + SessionTraceConfiguration, + Source, + TerminationDisposition, + _native, +) + + +def _running_conformance_session(tmp_path, *, trace_path=None): + native = _native.Session.conformance(tmp_path, trace_path, 256) + session = Session._from_native(native) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + running = session.start() + assert running.audio.read(timeout_s=1.0) is not None + return running + + +def test_stop_and_cancel_have_distinct_typed_dispositions(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + stopped_running = _running_conformance_session(tmp_path / "stopped") + cancelled_running = _running_conformance_session(tmp_path / "cancelled") + assert stopped_running.session_id > 0 + assert cancelled_running.session_id > 0 + stopped = stopped_running.stop() + cancelled = cancelled_running.cancel() + + declared = Session() + assert declared.id > 0 + + assert stopped.success + assert stopped.disposition is TerminationDisposition.STOPPED + assert cancelled.success + assert cancelled.disposition is TerminationDisposition.CANCELLED + assert not stopped.runtime_worker_panicked + assert not cancelled.runtime_worker_panicked + assert stopped.terminal_event is not None + assert stopped.terminal_event.terminal_state is SessionTerminalState.STOPPED + assert cancelled.terminal_event is not None + assert cancelled.terminal_event.terminal_state is SessionTerminalState.STOPPED + assert stopped.terminal_event.failures == () + assert cancelled.terminal_event.failures == () + + +def test_trace_round_trip_preserves_terminal_lifecycle_and_hash(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + trace_path = tmp_path / "session.trace" + stop = _running_conformance_session( + tmp_path / "recordings", trace_path=trace_path + ).stop() + + assert stop.trace_error is None + assert stop.trace is not None + assert stop.trace.complete + assert stop.trace.path == trace_path + trace = SessionTrace.read(trace_path) + validation = trace.validate() + assert trace.session_id == validation.session_id + assert trace.records_total == validation.records_validated_total + assert trace.outcome.rolling_hash == stop.trace.rolling_hash + assert validation.terminal_state is SessionTerminalState.STOPPED + assert validation.source_failures_total == 0 + assert validation.endpoint_failures_total == 0 + + +def test_trace_configuration_rejects_unbounded_or_zero_capacity(tmp_path) -> None: + with pytest.raises(ValueError, match="positive integer"): + SessionTraceConfiguration(tmp_path / "trace", capacity_records=0) + with pytest.raises(ValueError, match="positive integer"): + SessionTraceConfiguration(tmp_path / "trace", capacity_records=1.5) # type: ignore[arg-type] + + +def test_terminal_event_keeps_fault_categories_and_owner_ids_separate() -> None: + def failure(kind, stage, **identifiers): + return SimpleNamespace( + kind=kind, + stage=stage, + operation="finalize" if kind == "finalization" else None, + error_class="fixture-failure", + component="Runtime" if kind == "finalization" else None, + message="endpoint failed" if kind == "endpoint" else None, + stem_id=identifiers.get("stem_id"), + route_id=identifiers.get("route_id"), + endpoint_id=identifiers.get("endpoint_id"), + operator_instance_id=None, + sidecar_id=None, + source_event_kind=None, + source_platform=None, + source_kind=None, + source_stable_key=None, + source_source_id=None, + source_generation=None, + source_recovery_requirement=None, + source_failure_operation=None, + source_failure_class=None, + source_platform_status_code=None, + source_backend_class=None, + ) + + failures = [ + failure("endpoint", "join-finalize", route_id=3, endpoint_id=4), + failure("finalization", "finalize-endpoint"), + ] + event = SessionEvent._from_native( + SimpleNamespace( + kind="terminal", + lifecycle_state="failed", + terminal_state="failed", + session_id=1, + stem_id=None, + route_id=None, + endpoint_id=None, + failures_total=2, + failures=lambda: failures, + source_event_kind=None, + ) + ) + + assert event.terminal_state is SessionTerminalState.FAILED + assert event.failures[0].kind is SessionFailureKind.ENDPOINT + assert event.failures[0].stage is EndpointFailureStage.JOIN_FINALIZE + assert event.failures[0].route_id == 3 + assert event.failures[0].endpoint_id == 4 + assert event.failures[1].kind is SessionFailureKind.FINALIZATION diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..594e3f4 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,97 @@ +"""Complete immutable metrics projection tests.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from pocketstation import ( + EndpointObservationStage, + PocketStationError, + Session, + Source, + _native, +) + + +def test_metrics_preserve_bounded_source_route_and_polled_audio_truth(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + session = Session._from_native(_native.Session.conformance(tmp_path)) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + + with session.start() as running: + observed = set() + while len(observed) < 2: + frame = running.audio.read(timeout_s=1.0) + assert frame is not None + observed.add(frame.stem_id) + metrics = running.metrics() + + assert metrics.source_count == 2 + assert metrics.route_count == 2 + assert len(metrics.sources) == 2 + assert len(metrics.routes) == 2 + assert metrics.polled_audio.registered_endpoints == 2 + assert metrics.polled_audio.queue_capacity_frames > 0 + assert metrics.event_queue.capacity_count > 0 + assert all(route.edge.queue_capacity_frames > 0 for route in metrics.routes) + assert all( + route.endpoint.observation_stage is EndpointObservationStage.LIVE + for route in metrics.routes + ) + assert all(route.source_latency_unit == "nanoseconds" for route in metrics.routes) + with pytest.raises(FrozenInstanceError): + metrics.polled_audio.queue_capacity_frames = 0 + + +def test_metrics_count_mismatch_is_rejected_instead_of_hidden() -> None: + class InvalidMetrics: + source_count = 1 + external_source_count = 0 + route_count = 0 + operator_count = 0 + derived_route_count = 0 + audio_reentry_count = 0 + sources = () + external_sources = () + routes = () + operators = () + derived_routes = () + audio_reentries = () + event_capacity_count = 1 + event_maximum_event_owned_bytes = 1 + event_maximum_buffered_owned_bytes = 1 + event_depth_count = 0 + event_depth_owned_bytes = 0 + event_peak_depth_count = 0 + event_peak_depth_owned_bytes = 0 + events_enqueued_total = 0 + events_dropped_total = 0 + events_dropped_oversized_total = 0 + event_receiver_closed_total = 0 + audio_registered_endpoints = 0 + audio_queue_capacity_frames = 0 + audio_queue_depth_frames = 0 + audio_queue_peak_frames = 0 + audio_queue_depth_invariant_failures_total = 0 + audio_frames_received_total = 0 + audio_frames_delivered_total = 0 + audio_queue_full_drops_total = 0 + audio_invalid_ownership_drops_total = 0 + audio_lease_capacity_count = 0 + audio_outstanding_leases = 0 + audio_lease_exhausted_total = 0 + audio_batches_polled_total = 0 + audio_frames_polled_total = 0 + + from pocketstation.observations import SessionMetrics + + with pytest.raises(PocketStationError, match="counts are inconsistent"): + SessionMetrics._from_native(InvalidMetrics()) diff --git a/tests/test_native_module_structure.py b/tests/test_native_module_structure.py new file mode 100644 index 0000000..cfd2ce5 --- /dev/null +++ b/tests/test_native_module_structure.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NATIVE = ROOT / "native" / "src" + + +def test_native_binding_is_split_by_real_implemented_owner() -> None: + expected = { + "errors.rs", + "extensions.rs", + "graph.rs", + "lib.rs", + "observations.rs", + "relay.rs", + "session.rs", + "sidecar.rs", + "signals.rs", + "sources.rs", + "streams.rs", + } + assert expected <= {path.name for path in NATIVE.glob("*.rs")} + + +def test_process_sidecar_has_a_real_native_owner() -> None: + source = (NATIVE / "sidecar.rs").read_text() + assert "SidecarProcessSpec" in source + assert "wait_sidecar" in source + assert "sidecar_error_message" in source + assert len(source.splitlines()) >= 200 + + +def test_lib_rs_only_declares_and_registers_modules() -> None: + source = (NATIVE / "lib.rs").read_text() + assert len(source.splitlines()) <= 32 + assert "#[pymodule]" in source + assert "#[pyclass" not in source + assert "#[pymethods]" not in source + assert "#[pyfunction]" not in source + assert "include!(" not in source + for owner in ( + "extensions", + "sources", + "graph", + "relay", + "streams", + "observations", + "session", + "sidecar", + "signals", + ): + assert f"{owner}::register(module)?" in source + + +def test_each_registered_owner_contains_real_binding_behavior() -> None: + for owner in ( + "extensions.rs", + "graph.rs", + "observations.rs", + "relay.rs", + "session.rs", + "sidecar.rs", + "signals.rs", + "sources.rs", + "streams.rs", + ): + source = (NATIVE / owner).read_text() + assert "fn register(" in source + assert "include!(" not in source + assert len(source.splitlines()) >= 40 diff --git a/tests/test_observations.py b/tests/test_observations.py new file mode 100644 index 0000000..76c631c --- /dev/null +++ b/tests/test_observations.py @@ -0,0 +1,140 @@ +"""Synchronous event stream ownership and native-wait tests.""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace + +import pytest + +from pocketstation import ( + EventStream, + RunningSession, + StreamInUseError, + StreamModeError, + _native, +) + + +def _event_stream(events): + remaining = list(events) + state = {"closed": False, "waits": 0} + + def wait_event(_timeout_ms): + state["waits"] += 1 + if remaining: + return remaining.pop(0) + state["closed"] = True + return None + + return ( + EventStream( + poll_event=lambda: remaining.pop(0) if remaining else None, + wait_event=wait_event, + is_closed=lambda: state["closed"], + ), + state, + ) + + +def test_event_iteration_uses_bounded_waits() -> None: + stream, state = _event_stream(["started", "failed"]) + + assert list(stream) == ["started", "failed"] + assert state["waits"] == 3 + assert stream.reader_mode == "events" + + +def test_event_read_mode_is_exclusive() -> None: + stream, _ = _event_stream(["started"]) + + assert stream.read() == "started" + with pytest.raises(StreamModeError): + next(iter(stream)) + + +def test_concurrent_event_reader_fails_immediately() -> None: + entered = threading.Event() + release = threading.Event() + + def wait_event(_timeout_ms): + entered.set() + assert release.wait(1.0) + return "started" + + stream = EventStream( + poll_event=lambda: None, + wait_event=wait_event, + is_closed=lambda: False, + ) + observed = [] + first = threading.Thread(target=lambda: observed.append(stream.read())) + first.start() + assert entered.wait(1.0) + + with pytest.raises(StreamInUseError): + stream.read(timeout_s=0.0) + + release.set() + first.join(timeout=1.0) + assert observed == ["started"] + + +def test_running_session_exposes_events_without_public_poll_loop() -> None: + class NativeRunning: + def poll_audio(self): + return None + + def wait_audio(self, _timeout_ms): + return None + + def poll_event(self): + return None + + def wait_event(self, _timeout_ms): + return SimpleNamespace( + kind="lifecycle", + lifecycle_state="running", + session_id=1, + stem_id=None, + endpoint_id=None, + route_id=None, + failures_total=0, + terminal_state=None, + source_event_kind=None, + failures=lambda: [], + ) + + running = RunningSession(NativeRunning()) + + assert running.events.read().lifecycle_state == "running" + with pytest.raises(StreamModeError): + next(iter(running.events)) + + +def test_event_wait_timeout_remains_bounded() -> None: + stream, _ = _event_stream([]) + with pytest.raises(ValueError, match=r"between 0\.0 and 1\.0"): + stream.read(timeout_s=1.1) + + +def test_event_wait_uses_the_canonical_native_session(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + session = _native.Session.conformance(tmp_path) + application = session.capture( + _native.Source.application("PocketStation Python Fixture") + ) + microphone = session.capture(_native.Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + running = RunningSession(session.start()) + try: + event = running.events.read(timeout_s=1.0) + assert event is not None + assert event.session_id > 0 + assert event.kind + finally: + assert running.stop().success diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py new file mode 100644 index 0000000..308e45b --- /dev/null +++ b/tests/test_package_structure.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pocketstation +from pocketstation import signal + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "python" / "pocketstation" + + +def test_implemented_capabilities_have_explicit_python_owners() -> None: + expected = { + "capture.py", + "control.py", + "errors.py", + "extensions.py", + "graph.py", + "observations.py", + "relay.py", + "session.py", + "sidecar.py", + "sources.py", + "streams.py", + } + assert expected <= {path.name for path in PACKAGE.glob("*.py")} + + asynchronous_expected = { + "capture.py", + "control.py", + "extensions.py", + "observations.py", + "relay.py", + "session.py", + "sidecar.py", + "sources.py", + "streams.py", + } + assert asynchronous_expected <= { + path.name for path in (PACKAGE / "aio").glob("*.py") + } + + +def test_relay_modules_are_real_owners_not_empty_parity_scaffolds() -> None: + synchronous = (PACKAGE / "relay.py").read_text() + asynchronous = (PACKAGE / "aio" / "relay.py").read_text() + assert "class RelaySession" in synchronous + assert "class RelaySession" in asynchronous + assert "def create_receiver_invitation" in synchronous + assert "async def create_receiver_invitation" in asynchronous + + +def test_public_declarations_report_their_canonical_owner() -> None: + assert pocketstation.Source.__module__ == "pocketstation.sources" + assert pocketstation.Endpoint.__module__ == "pocketstation.graph" + assert pocketstation.Stem.__module__ == "pocketstation.graph" + assert pocketstation.SignalSpec.__module__ == "pocketstation.graph" + assert pocketstation.RelaySession.__module__ == "pocketstation.relay" + assert signal.SignalSpec is pocketstation.SignalSpec + + +def test_session_modules_do_not_redeclare_source_or_graph_types() -> None: + synchronous = (PACKAGE / "session.py").read_text() + asynchronous = (PACKAGE / "aio" / "session.py").read_text() + for declaration in ("class Source", "class Endpoint", "class Stem"): + assert declaration not in synchronous + assert declaration not in asynchronous + + +def test_native_error_policy_has_one_python_owner() -> None: + errors = (PACKAGE / "errors.py").read_text() + assert "def _native_call" in errors + assert "def _normalize_native_error" in errors + assert "def _native_call" not in (PACKAGE / "session.py").read_text() + assert "def _native_sync" not in (PACKAGE / "aio" / "session.py").read_text() diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..05d666b --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys + +import pytest + +import pocketstation +from pocketstation import PermissionObservation + + +def test_permission_observation_is_typed_and_has_no_prompt_api() -> None: + observation = pocketstation.microphone_permission_observation() + + assert isinstance(observation, PermissionObservation) + assert "request_microphone_permission" not in pocketstation.__all__ + assert "prompt_microphone_permission" not in pocketstation.__all__ + + +def test_permission_states_do_not_collapse_to_a_boolean() -> None: + assert {item.value for item in PermissionObservation} == { + "allowed", + "denied", + "restricted", + "not-determined", + "revoked", + "not-observable", + "not-applicable", + } + assert not issubclass(PermissionObservation, bool) + + +def test_linux_truth_is_not_reinterpreted_as_allowed_or_denied() -> None: + if sys.platform != "linux": + pytest.skip("Linux-specific platform contract") + assert ( + pocketstation.microphone_permission_observation() + is PermissionObservation.NOT_OBSERVABLE + ) + + +@pytest.mark.asyncio +async def test_async_permission_observation_shares_native_policy() -> None: + assert ( + await pocketstation.aio.microphone_permission_observation() + is pocketstation.microphone_permission_observation() + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..85038ce --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import ast +import inspect +from pathlib import Path + +import pocketstation +import pocketstation._native as native + +ROOT = Path(__file__).resolve().parents[1] + + +def test_root_exports_are_an_intentional_stable_snapshot() -> None: + assert set(pocketstation.__all__) == { + "AudioBatch", + "AudioCaps", + "AudioFrame", + "AudioInput", + "AudioInputBufferError", + "AudioInputCancelledError", + "AudioInputClosedError", + "AudioInputConfig", + "AudioInputError", + "AudioInputFullError", + "AudioInputObservations", + "AudioReentryMetrics", + "AudioStream", + "BackpressurePolicy", + "BinaryFormat", + "BusSubscription", + "Capture", + "ChannelLayout", + "ClockDomain", + "Codec", + "ControlClient", + "ControlPlaneError", + "CopyPolicy", + "DeliverySemantics", + "DerivedStream", + "DerivedRouteMetrics", + "DiscoveredSource", + "EdgeContract", + "EdgeMetrics", + "EdgeObservabilityLevel", + "Endpoint", + "EndpointConfiguration", + "EndpointDescriptor", + "EndpointFailureStage", + "EndpointMetrics", + "EndpointObservationStage", + "EndOfStream", + "EventFormat", + "EventStream", + "EventQueueMetrics", + "ExternalSourceMetrics", + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionError", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "IceServer", + "LossPolicy", + "LatencyHistogram", + "MediaCaps", + "MediaKind", + "Multiplicity", + "NativeExtensionLibrary", + "NativeExtensionRegistration", + "Operator", + "OperatorConfiguration", + "OperatorInput", + "OperatorInputMetrics", + "OperatorInstance", + "OperatorMetrics", + "OperatorWorkerMetrics", + "PcmSource", + "PocketStationError", + "PermissionObservation", + "Platform", + "PortDirection", + "PortSpec", + "PolledAudioMetrics", + "ProcessInstanceSelector", + "ProcessTreeScope", + "PublisherActivation", + "ReceiverActivation", + "ReceiverInvitation", + "RecordingOutcome", + "RecordingDiscontinuity", + "RecordingDiscontinuityKind", + "RecordingState", + "RecordingStemOutcome", + "RelayError", + "RelayPublishOutcome", + "RelayPublisher", + "RelayRoute", + "RelaySession", + "RelayTimeoutError", + "RouteLatencyBoundary", + "RouteLatencyUnit", + "RouteMetrics", + "RouteObservationInterval", + "RunningSession", + "SampleFormat", + "SecretToken", + "SelectorPersistenceScope", + "Session", + "SessionCredentials", + "SessionEvent", + "SessionEventType", + "SessionFailure", + "SessionFailureKind", + "SessionFinalizationStage", + "SessionId", + "SessionLifecycleState", + "SessionMetrics", + "SessionRollbackStage", + "SessionSnapshot", + "SessionTerminalState", + "SessionTrace", + "SessionTraceConfiguration", + "SessionTraceRecorderOutcome", + "SessionTraceValidation", + "SidecarBackpressureError", + "SidecarConnection", + "SidecarDeadlines", + "SidecarError", + "SidecarHandle", + "SidecarMessage", + "SidecarMessageKind", + "SidecarProcessSpec", + "SidecarProtocolError", + "SidecarProtocolLimits", + "SidecarReadResult", + "SidecarSnapshot", + "SidecarState", + "SidecarStream", + "SidecarTimeoutError", + "STREAM_EOF", + "SignalAudioPayload", + "SignalDerivation", + "SignalEnvelope", + "SignalKind", + "SignalLineage", + "SignalPayload", + "SignalReadResult", + "SignalSpec", + "SignalStream", + "SignalSubscriptionMetrics", + "SignalTiming", + "Source", + "SourceConfiguration", + "SourceFailureClass", + "SourceIdentityStrength", + "SourceInstance", + "SourceKind", + "SourceMetrics", + "SourceOutput", + "SourceQuery", + "SourceRecoveryRequirement", + "SourceRuntimeEvent", + "SourceRuntimeEventKind", + "SourceSelectorKind", + "SourceState", + "StableSourceId", + "Stem", + "StopResult", + "StreamError", + "StreamInUseError", + "StreamModeError", + "SubscriberCredentials", + "TextFormat", + "TerminationDisposition", + "TypedEdgeMetrics", + "aio", + "application_capture_available", + "capture", + "discover_sources", + "microphone_permission_observation", + } + + +def test_private_native_runtime_and_stub_export_the_same_classes() -> None: + stub = ast.parse((ROOT / "python" / "pocketstation" / "_native.pyi").read_text()) + stub_classes = {node.name for node in stub.body if isinstance(node, ast.ClassDef)} + runtime_classes = { + name for name in dir(native) if isinstance(getattr(native, name), type) + } + feature_only_test_classes = {"ExtensionConformanceReport"} + assert runtime_classes - feature_only_test_classes == stub_classes + assert not feature_only_test_classes & stub_classes + + +def test_private_native_runtime_and_stub_export_the_same_functions() -> None: + stub = ast.parse((ROOT / "python" / "pocketstation" / "_native.pyi").read_text()) + stub_functions = { + node.name for node in stub.body if isinstance(node, ast.FunctionDef) + } + runtime_functions = { + name for name in dir(native) if inspect.isbuiltin(getattr(native, name)) + } + feature_only_test_functions = {"run_extension_conformance"} + assert runtime_functions - feature_only_test_functions == stub_functions + assert not feature_only_test_functions & stub_functions + + +def test_relay_members_already_exported_by_native_are_typed() -> None: + stub = (ROOT / "python" / "pocketstation" / "_native.pyi").read_text() + for declaration in ( + "class RelayPublisher", + "class RelayPublishOutcome", + "def publish(self, publisher: RelayPublisher, bus_id: str) -> int", + "def relay_outcomes(self) -> list[RelayPublishOutcome]", + "def relay(", + ): + assert declaration in stub diff --git a/tests/test_realtime_boundary.py b/tests/test_realtime_boundary.py new file mode 100644 index 0000000..1ff23a8 --- /dev/null +++ b/tests/test_realtime_boundary.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sys +import threading +from pathlib import Path +from time import monotonic + +import pocketstation as pks +from pocketstation._native import Session as NativeSession + +ROOT = Path(__file__).parents[1] +CORE = ROOT.parent / "pocketstation" +CHILD = Path(__file__).with_name("_pkss_child.py") + + +def session_with_hung_sidecar(tmp_path: Path) -> pks.RunningSession: + session = pks.Session._from_native(NativeSession.conformance(tmp_path)) + audio = session.polled_audio() + session.capture(pks.Source.application("PocketStation Python Fixture")).send(audio) + session.capture(pks.Source.microphone_default()).send(audio) + session.register_sidecar( + pks.SidecarProcessSpec( + 91, + sys.executable, + (str(CHILD), "hang"), + deadlines=pks.SidecarDeadlines( + ready_s=1.0, + processing_s=1.0, + shutdown_s=0.1, + ), + ) + ) + return session.start() + + +def test_sidecar_binding_contains_no_python_callback_contract() -> None: + sidecar_source = (ROOT / "native/src/sidecar.rs").read_text() + core_extension = (CORE / "src/abi/executable_extension.rs").read_text() + + assert "PyAny" not in sidecar_source + assert "PyObject" not in sidecar_source + assert "callable" not in sidecar_source.lower() + assert ( + "PCM audio remains on the native fixed-capacity realtime lane" in core_extension + ) + assert "blocking/async Session" in core_extension + + +def test_blocking_sidecar_reap_detaches_from_python(tmp_path: Path) -> None: + running = session_with_hung_sidecar(tmp_path) + stopping = threading.Event() + stopped = threading.Event() + observed: list[float] = [] + + def heartbeat() -> None: + stopping.wait() + while not stopped.is_set(): + observed.append(monotonic()) + + thread = threading.Thread(target=heartbeat, name="python-heartbeat") + thread.start() + started = monotonic() + stopping.set() + result = running.stop() + ended = monotonic() + stopped.set() + thread.join(timeout=1.0) + + assert not result.success + assert ended - started >= 0.05 + assert any(started <= timestamp <= ended for timestamp in observed) + [final] = result.sidecar_outcomes + assert final.forced_kills_total == 1 + assert final.reaps_total == 1 diff --git a/tests/test_recording.py b/tests/test_recording.py new file mode 100644 index 0000000..08891c3 --- /dev/null +++ b/tests/test_recording.py @@ -0,0 +1,94 @@ +"""Multistem recording remains attached to source-aware Rust Session stems.""" + +from __future__ import annotations + +from time import monotonic +from types import SimpleNamespace + +import pytest + +from pocketstation import ( + RecordingDiscontinuityKind, + RecordingOutcome, + RecordingState, + Session, + Source, + _native, +) + + +def test_application_and_microphone_record_as_independent_stems(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + session = Session._from_native(_native.Session.conformance(tmp_path)) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + application.record("application") + microphone.record("microphone") + + running = session.start() + observed_stems: set[int] = set() + deadline = monotonic() + 5.0 + try: + while monotonic() < deadline and len(observed_stems) < 2: + frame = running.audio.read(timeout_s=0.1) + if frame is not None: + observed_stems.add(frame.stem_id) + finally: + stop = running.stop() + + assert observed_stems == {application.id, microphone.id} + assert stop.success + assert stop.recording is not None + assert stop.recording.complete + outcomes = {stem.stem_name: stem for stem in stop.recording.stems} + assert set(outcomes) == {"application", "microphone"} + assert all(stem.frames_written_total > 0 for stem in outcomes.values()) + assert all(stem.error is None for stem in outcomes.values()) + assert all(stem.discontinuities == () for stem in outcomes.values()) + assert stop.recording.error_code is None + + +def test_incomplete_recording_preserves_stable_code_and_gap_detail(tmp_path) -> None: + gap = SimpleNamespace( + stem_id=7, + label="application", + kind="timestamp-gap", + timestamp_start_ns=100, + timestamp_end_ns=200, + sequence_start=4, + sequence_end=5, + ) + stem = SimpleNamespace( + stem_name="application", + frames_written_total=10, + stale_frames_total=1, + error="fixture failure", + queue_capacity_frames=8, + queue_peak_frames=4, + frames_delivered_total=10, + frames_dropped_total=1, + queue_full_drops_total=1, + discontinuities_total=1, + discontinuities=lambda: [gap], + ) + outcome = RecordingOutcome._from_native( + SimpleNamespace( + state="incomplete", + complete=False, + completed_stems=0, + failed_stems=1, + session_directory=str(tmp_path), + error_code="recording.incomplete", + stems=lambda: [stem], + ) + ) + + assert outcome.state is RecordingState.INCOMPLETE + assert outcome.error_code == "recording.incomplete" + [record] = outcome.stems[0].discontinuities + assert record.kind is RecordingDiscontinuityKind.TIMESTAMP_GAP + assert record.timestamp_end_ns - record.timestamp_start_ns == 100 diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..f2ab7a2 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,249 @@ +"""Real relay declaration, invitation, readiness, and secrecy contracts.""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from pocketstation import ( + ControlClient, + RelayError, + RelaySession, + RelayTimeoutError, + Session, + Source, +) + +CREATE_RESPONSE = { + "session_id": "session_123", + "source_token": "source-secret", + "subscriber_token": "subscriber-secret", + "whip_url": "https://relay.example/v1/sessions/session_123/whip", + "whep_url": "https://relay.example/v1/sessions/session_123/whep", + "ice_servers": [], +} + + +def test_relay_composes_two_native_buses_with_authoritative_readiness() -> None: + control_requests: list[httpx.Request] = [] + relay_requests: list[httpx.Request] = [] + snapshots = iter( + [ + _snapshot(source_active=True, subscription_count=0), + _snapshot(source_active=True, subscription_count=1), + ] + ) + + def control_handler(request: httpx.Request) -> httpx.Response: + control_requests.append(request) + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + return httpx.Response(200, json=next(snapshots)) + assert request.headers["authorization"] == "Bearer source-secret" + return httpx.Response(204) + + def relay_handler(request: httpx.Request) -> httpx.Response: + relay_requests.append(request) + assert request.method == "POST" + assert request.url.path == "/v1/sessions/session_123/invitations" + assert request.headers["authorization"] == "Bearer source-secret" + return httpx.Response( + 201, + json={ + "session_id": "session_123", + "join_code": "opaque-code", + "join_url": ( + "https://receiver.example/?join=opaque-code" + "&relay=https%3A%2F%2Frelay.example" + ), + }, + ) + + with ( + httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, + httpx.Client(transport=httpx.MockTransport(relay_handler)) as relay_http, + ): + control = ControlClient( + "https://control.example", + http_client=control_http, + ) + remote = RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example/", + control_client=control, + relay_http_client=relay_http, + ) + + session = Session() + application = session.capture(Source.application("PocketStation Fixture")) + microphone = session.capture(Source.microphone_default()) + publisher = session.relay(remote) + app_route = application.publish(publisher, "application") + mic_route = microphone.publish(publisher, "microphone") + + with pytest.raises(RelayError) as early_invitation: + remote.create_receiver_invitation() + assert early_invitation.value.code == "relay.publisher_not_active" + + publisher_ready = remote.wait_for_publisher( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + invitation = remote.create_receiver_invitation() + receiver_ready = remote.wait_for_receiver( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + assert app_route.bus_id == "application" + assert mic_route.bus_id == "microphone" + assert app_route.route_id != mic_route.route_id + assert publisher_ready.snapshot.source_active is True + assert publisher_ready.snapshot.subscription_count == 0 + assert receiver_ready.snapshot.subscription_count == 1 + assert remote.relay_url == "https://relay.example" + assert "source-secret" not in repr(remote) + assert "subscriber-secret" not in repr(remote) + + parsed = urlparse(invitation.join_url) + assert parse_qs(parsed.query)["join"] == ["opaque-code"] + assert "token" not in parsed.query + assert "session_123" not in invitation.join_url + + remote.close() + remote.close() + + assert [(request.method, request.url.path) for request in control_requests] == [ + ("POST", "/v1/sessions"), + ("GET", "/v1/sessions/session_123"), + ("GET", "/v1/sessions/session_123"), + ("DELETE", "/v1/sessions/session_123"), + ] + assert len(relay_requests) == 1 + + +def test_relay_wait_uses_a_single_bounded_deadline() -> None: + def control_handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + return httpx.Response(200, json=_snapshot(False, 0)) + return httpx.Response(204) + + with ( + httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, + httpx.Client( + transport=httpx.MockTransport(lambda _request: httpx.Response(500)) + ) as relay_http, + ): + control = ControlClient( + "https://control.example", + http_client=control_http, + ) + remote = RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + control_client=control, + relay_http_client=relay_http, + ) + with pytest.raises(RelayTimeoutError) as timeout: + remote.wait_for_publisher( + timeout_seconds=0.005, + poll_interval_seconds=0.001, + ) + assert timeout.value.code == "relay.publisher_timeout" + remote.close() + + +@pytest.mark.parametrize( + "join_url", + [ + "https://receiver.example/?join=wrong-code", + "https://receiver.example/?join=opaque-code&token=subscriber-secret", + "https://receiver.example/?join=opaque-code&session_id=session_123", + "https://receiver.example/?join=opaque-code#session_123", + ], +) +def test_relay_rejects_unsafe_or_mismatched_invitations(join_url: str) -> None: + get_calls = 0 + + def control_handler(request: httpx.Request) -> httpx.Response: + nonlocal get_calls + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + get_calls += 1 + return httpx.Response(200, json=_snapshot(True, 0)) + return httpx.Response(204) + + relay_transport = httpx.MockTransport( + lambda _request: httpx.Response( + 201, + json={ + "session_id": "session_123", + "join_code": "opaque-code", + "join_url": join_url, + }, + ) + ) + with ( + httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, + httpx.Client(transport=relay_transport) as relay_http, + ): + control = ControlClient( + "https://control.example", + http_client=control_http, + ) + remote = RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + control_client=control, + relay_http_client=relay_http, + ) + remote.wait_for_publisher( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + with pytest.raises(RelayError) as unsafe: + remote.create_receiver_invitation() + assert unsafe.value.code in { + "relay.response_identity", + "relay.unsafe_invitation", + } + remote.close() + assert get_calls == 1 + + +def test_invalid_relay_origin_fails_before_remote_session_creation() -> None: + requests: list[httpx.Request] = [] + transport = httpx.MockTransport( + lambda request: ( + requests.append(request), + httpx.Response(500), + )[1] + ) + with httpx.Client(transport=transport) as http_client: + control = ControlClient( + "https://control.example", + http_client=http_client, + ) + with pytest.raises(ValueError, match="must not include a path"): + RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example/not-an-origin", + control_client=control, + relay_http_client=http_client, + ) + assert requests == [] + + +def _snapshot(source_active: bool, subscription_count: int) -> dict[str, object]: + return { + "session_id": "session_123", + "source_active": source_active, + "subscription_count": subscription_count, + "codec": "opus", + } diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..e38c2c5 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,59 @@ +"""Synchronous public Session contract tests.""" + +from __future__ import annotations + +import pytest + +from pocketstation import PocketStationError, Session, Source + + +def test_given_app_and_mic_when_routed_then_native_session_owns_routes(tmp_path): + session = Session(recording_root=tmp_path) + application = session.capture(Source.application("PocketStation Fixture")) + microphone = session.capture(Source.microphone_default()) + audio = session.polled_audio() + + application_route = application.send(audio) + microphone_route = microphone.send(audio) + application.record("application") + microphone.record("microphone") + + assert application.id != microphone.id + assert application_route != microphone_route + + +@pytest.mark.parametrize("name", ["", " ", "\t"]) +def test_given_empty_application_name_when_declared_then_rejected(name): + with pytest.raises(PocketStationError, match="must not be empty") as failure: + Source.application(name) + assert failure.value.code == "session.invalid_selector" + + +def test_given_selector_family_when_declared_then_each_shape_is_available(): + assert Source.application_bundle_id("com.spotify.client") + assert Source.application_process_id(42) + assert Source.application_stable_id("macos", "bundle:com.spotify.client") + assert Source.application_process_instance( + 42, + "macos", + "bundle:com.spotify.client", + ) + assert Source.microphone_id("device-42") + + +def test_given_invalid_process_or_platform_when_declared_then_rejected(): + with pytest.raises(PocketStationError, match="non-zero"): + Source.application_process_id(0) + with pytest.raises(PocketStationError, match="platform must be"): + Source.application_stable_id("plan9", "app:42") + + +def test_given_session_after_start_attempt_when_reused_then_rejected(): + session = Session() + + with pytest.raises(PocketStationError): + session.start() + + with pytest.raises(PocketStationError, match="already started") as failure: + session.capture(Source.microphone_default()) + assert failure.value.code == "session.draft_frozen" diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py new file mode 100644 index 0000000..c50e4a3 --- /dev/null +++ b/tests/test_sidecar.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from time import monotonic + +import pytest + +import pocketstation as pks +import pocketstation.aio as aio +from pocketstation._native import Session as NativeSession + +CHILD = Path(__file__).with_name("_pkss_child.py") + + +def session_with_product_sources(tmp_path: Path) -> pks.Session: + session = pks.Session._from_native(NativeSession.conformance(tmp_path)) + audio = session.polled_audio() + session.capture(pks.Source.application("PocketStation Python Fixture")).send(audio) + session.capture(pks.Source.microphone_default()).send(audio) + return session + + +def sidecar_spec( + mode: str, + *, + sidecar_id: int = 7, + capacity: int = 2, + shutdown_s: float = 0.2, +) -> pks.SidecarProcessSpec: + return pks.SidecarProcessSpec( + sidecar_id, + sys.executable, + (str(CHILD), mode), + data_capacity_messages=capacity, + deadlines=pks.SidecarDeadlines( + ready_s=1.0, + processing_s=1.0, + shutdown_s=shutdown_s, + ), + ) + + +def message(*, sequence: int = 1, payload: bytes = b"hello") -> pks.SidecarMessage: + return pks.SidecarMessage.signal( + payload, + signal_id="io.pocketstation.test.signal.v1", + stream_id=11, + sequence_number=sequence, + timestamp_ns=sequence * 1_000, + role="transcript", + schema="application/octet-stream", + ) + + +def test_session_owned_sidecar_round_trip_and_graceful_reap(tmp_path: Path) -> None: + session = session_with_product_sources(tmp_path) + handle = session.register_sidecar(sidecar_spec("healthy")) + + running = session.start() + sidecar = running.sidecar(handle) + sidecar.send(message()) + received = sidecar.messages.read(timeout_s=1.0) + + assert isinstance(received, pks.SidecarMessage) + assert received.payload == b"hello" + assert received.stream_id == 11 + assert received.sequence_number == 1 + assert received.role == "transcript" + live = sidecar.snapshot() + assert live.state is pks.SidecarState.RUNNING + assert live.data_enqueued_total == 1 + assert live.data_received_total == 1 + + stopped = running.stop() + assert stopped.success + [final] = stopped.sidecar_outcomes + assert final.state == pks.SidecarState.REAPED.value + assert final.reaps_total == 1 + assert final.visited(pks.SidecarState.CLOSING.value) + assert final.visited(pks.SidecarState.CLOSED.value) + assert final.visited(pks.SidecarState.REAPED.value) + + +def test_sidecar_data_queue_saturation_is_typed_and_counted(tmp_path: Path) -> None: + session = session_with_product_sources(tmp_path) + handle = session.register_sidecar( + sidecar_spec("saturated", capacity=1, shutdown_s=0.2) + ) + running = session.start() + sidecar = running.sidecar(handle) + saturated = False + payload = b"x" * 65_536 + for sequence in range(1, 1_001): + try: + sidecar.send(message(sequence=sequence, payload=payload)) + except pks.SidecarBackpressureError as error: + assert error.code == "sidecar.queue_full" + saturated = True + break + assert saturated, "the finite native sidecar queue must expose saturation" + assert sidecar.snapshot().data_dropped_total >= 1 + running.cancel() + + +def test_malformed_sidecar_fails_transactional_start(tmp_path: Path) -> None: + session = session_with_product_sources(tmp_path) + session.register_sidecar(sidecar_spec("malformed")) + + with pytest.raises(pks.PocketStationError) as caught: + session.start() + + assert caught.value.code == "session.runtime_start_failed" + assert "sidecar" in str(caught.value).lower() + + +def test_hung_sidecar_is_killed_and_reaped_within_deadline(tmp_path: Path) -> None: + session = session_with_product_sources(tmp_path) + handle = session.register_sidecar(sidecar_spec("hang", shutdown_s=0.05)) + running = session.start() + assert running.sidecar(handle).snapshot().state is pks.SidecarState.RUNNING + + started = monotonic() + stopped = running.stop() + elapsed = monotonic() - started + + assert elapsed < 1.0 + assert not stopped.success + [final] = stopped.sidecar_outcomes + assert final.state == pks.SidecarState.REAPED.value + assert final.timeouts_total >= 1 + assert final.forced_kills_total == 1 + assert final.reaps_total == 1 + + +def test_cancel_uses_cancel_protocol_and_reaps(tmp_path: Path) -> None: + session = session_with_product_sources(tmp_path) + session.register_sidecar(sidecar_spec("healthy")) + running = session.start() + + cancelled = running.cancel() + + assert cancelled.success + [final] = cancelled.sidecar_outcomes + assert final.visited(pks.SidecarState.CANCELLING.value) + assert final.visited(pks.SidecarState.REAPED.value) + assert final.reaps_total == 1 + + +def test_sidecar_handle_is_session_scoped(tmp_path: Path) -> None: + first = session_with_product_sources(tmp_path / "first") + second = session_with_product_sources(tmp_path / "second") + handle = first.register_sidecar(sidecar_spec("healthy")) + running = second.start() + try: + with pytest.raises(ValueError, match="different Session"): + running.sidecar(handle) + finally: + running.stop() + + +def test_asyncio_sidecar_uses_same_native_owner(tmp_path: Path) -> None: + async def scenario() -> None: + session = aio.Session._from_native(NativeSession.conformance(tmp_path)) + audio = session.polled_audio() + session.capture(pks.Source.application("PocketStation Python Fixture")).send( + audio + ) + session.capture(pks.Source.microphone_default()).send(audio) + handle = session.register_sidecar(sidecar_spec("healthy")) + + running = await session.start() + sidecar = running.sidecar(handle) + await sidecar.send(message()) + received = await sidecar.messages.read(timeout_s=1.0) + assert isinstance(received, pks.SidecarMessage) + assert received.payload == b"hello" + snapshot = await sidecar.snapshot() + assert snapshot.data_received_total == 1 + cancelled = await running.cancel() + [final] = cancelled.sidecar_outcomes + assert final.reaps_total == 1 + + asyncio.run(scenario()) diff --git a/tests/test_signal_streams.py b/tests/test_signal_streams.py new file mode 100644 index 0000000..3132fe7 --- /dev/null +++ b/tests/test_signal_streams.py @@ -0,0 +1,209 @@ +"""Real Session conformance for bounded typed-signal subscriptions.""" + +from __future__ import annotations + +from pathlib import Path +from time import monotonic + +import pytest +from pocketstation import ( + STREAM_EOF, + BackpressurePolicy, + BinaryFormat, + Operator, + PocketStationError, + Session, + SignalAudioPayload, + SignalEnvelope, + SignalSpec, + Source, + TextFormat, + _native, + aio, +) + +AUDIO_OPERATOR = "org.pocketstation.python.conformance.audio-pass-through.v1" +TEXT_OPERATOR = "org.pocketstation.python.conformance.audio-to-text.v1" +BYTES_OPERATOR = "org.pocketstation.python.conformance.audio-to-bytes.v1" + + +def _native_conformance_session(recording_root: Path): + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + return _native.Session.conformance(recording_root) + + +def _declared_session(recording_root: Path, *, asynchronous: bool = False): + native = _native_conformance_session(recording_root) + session = ( + aio.Session._from_native(native) + if asynchronous + else Session._from_native(native) + ) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + application.send(session.polled_audio()) + + audio = microphone.through( + Operator(AUDIO_OPERATOR), + input_port="audio-in", + output_port="audio-out", + ) + text = microphone.through( + Operator(TEXT_OPERATOR), + input_port="audio-in", + output_port="text-out", + ) + binary = microphone.through( + Operator(BYTES_OPERATOR), + input_port="audio-in", + output_port="bytes-out", + ) + return session, { + "audio": session.subscribe(audio, signal=SignalSpec.audio()), + "text": session.subscribe(text, signal=SignalSpec.text()), + "bytes": session.subscribe( + binary, + signal=SignalSpec.binary(BinaryFormat.RAW), + ), + } + + +def _read_envelope(stream, timeout_s: float = 2.0) -> SignalEnvelope: + deadline = monotonic() + timeout_s + while monotonic() < deadline: + value = stream.read(timeout_s=0.1) + if isinstance(value, SignalEnvelope): + return value + assert value is not STREAM_EOF + raise AssertionError("typed signal did not arrive before the bounded deadline") + + +def test_real_session_delivers_audio_text_and_bytes_with_complete_provenance( + tmp_path: Path, +) -> None: + session, subscriptions = _declared_session(tmp_path) + running = session.start() + audio_stream = running.signals(subscriptions["audio"]) + text_stream = running.signals(subscriptions["text"]) + bytes_stream = running.signals(subscriptions["bytes"]) + + audio = _read_envelope(audio_stream) + text = _read_envelope(text_stream) + binary = _read_envelope(bytes_stream) + audio_payload = audio.payload + assert isinstance(audio_payload, SignalAudioPayload) + snapshot = audio_payload.samples_f32le + + assert audio.signal == SignalSpec.audio() + assert audio.lineage is not None + assert audio.derivation is not None + assert audio.derivation.upstream_lineage == audio.lineage + assert audio_payload.source_id == audio.lineage.source_id + assert audio_payload.stream_id == audio.lineage.stream_id + assert audio_payload.sequence_number == audio.lineage.sequence_number + assert audio_payload.samples.readonly + assert audio_payload.sample_count > 0 + + assert text.signal == SignalSpec.text(TextFormat.UTF8) + assert isinstance(text.payload, str) + assert text.lineage is not None + assert f"source={text.lineage.source_id}" in text.payload + assert text.derivation is not None + assert text.derivation.operator_id == TEXT_OPERATOR + + assert binary.signal == SignalSpec.binary(BinaryFormat.RAW) + assert isinstance(binary.payload, bytes) + assert len(binary.payload) == 8 + assert binary.lineage is not None + assert int.from_bytes(binary.payload, "little") == binary.lineage.sequence_number + assert binary.derivation is not None + assert binary.derivation.operator_id == BYTES_OPERATOR + + assert running.signals(subscriptions["audio"]) is audio_stream + streams = { + "audio": audio_stream, + "text": text_stream, + "bytes": bytes_stream, + } + for name, subscription in subscriptions.items(): + assert subscription.edge.backpressure is BackpressurePolicy.BOUNDED_QUEUE + assert subscription.edge.max_payload_bytes == 1_048_576 + metrics = streams[name].metrics() + assert metrics.capacity_signals > 0 + assert metrics.max_payload_bytes == 1_048_576 + assert metrics.maximum_buffered_payload_bytes == ( + metrics.capacity_signals * metrics.max_payload_bytes + ) + assert metrics.enqueued_total >= metrics.received_total > 0 + assert metrics.dropped_total >= 0 + assert running.stop().success + assert audio_stream.read(timeout_s=0.0) is STREAM_EOF + assert text_stream.read(timeout_s=0.0) is STREAM_EOF + assert bytes_stream.read(timeout_s=0.0) is STREAM_EOF + assert audio_payload.samples_f32le == snapshot + + +def test_subscription_close_is_idempotent_and_does_not_stop_other_routes( + tmp_path: Path, +) -> None: + session, subscriptions = _declared_session(tmp_path) + running = session.start() + text_stream = running.signals(subscriptions["text"]) + bytes_stream = running.signals(subscriptions["bytes"]) + + text_stream.close() + text_stream.close() + assert text_stream.poll() is STREAM_EOF + assert isinstance(_read_envelope(bytes_stream), SignalEnvelope) + assert running.stop().success + + +def test_external_source_outputs_have_the_same_subscription_declaration() -> None: + session = Session() + source = session.source("org.example.source.typed.v1") + subscription = session.subscribe( + source.output("events"), + signal=SignalSpec.text(TextFormat.JSON), + ) + + assert subscription.session_id == session.id + assert subscription.signal == SignalSpec.text(TextFormat.JSON) + assert subscription.edge.media.supports_signal(subscription.signal) + assert subscription.route_id > 0 + + +def test_running_session_rejects_a_foreign_subscription(tmp_path: Path) -> None: + session, _ = _declared_session(tmp_path / "local") + _, foreign = _declared_session(tmp_path / "foreign") + running = session.start() + try: + with pytest.raises(PocketStationError) as failure: + running.signals(foreign["text"]).poll() + assert failure.value.code == "session.invalid_route" + finally: + assert running.stop().success + + +@pytest.mark.asyncio +async def test_async_real_session_preserves_the_same_signal_contract( + tmp_path: Path, +) -> None: + session, subscriptions = _declared_session(tmp_path, asynchronous=True) + running = await session.start() + stream = running.signals(subscriptions["text"]) + deadline = monotonic() + 2.0 + envelope = None + while monotonic() < deadline: + value = await stream.read(timeout_s=0.1) + if isinstance(value, SignalEnvelope): + envelope = value + break + assert value is not STREAM_EOF + + assert envelope is not None + assert isinstance(envelope.payload, str) + assert envelope.lineage is not None + assert envelope.derivation is not None + assert (await running.stop()).success + assert await stream.read(timeout_s=0.0) is STREAM_EOF diff --git a/tests/test_source_lifecycle.py b/tests/test_source_lifecycle.py new file mode 100644 index 0000000..1bcd8b8 --- /dev/null +++ b/tests/test_source_lifecycle.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from types import SimpleNamespace + +import pytest + +from pocketstation import ( + Platform, + RunningSession, + Session, + Source, + SourceFailureClass, + SourceKind, + SourceRecoveryRequirement, + SourceRuntimeEvent, + SourceRuntimeEventKind, + _native, +) +from pocketstation.observations import SessionEvent + + +def _native_source_event(): + source = dict( + source_event_kind="source-unavailable", + source_platform="windows", + source_kind="application", + source_stable_key="aumid:fixture", + source_source_id=42, + source_generation=7, + source_recovery_requirement="explicit-rediscovery-and-new-session", + source_failure_operation="capture", + source_failure_class="platform-status", + source_platform_status_code=-42, + source_backend_class=None, + ) + failure = SimpleNamespace( + kind="source", + stage=None, + operation=None, + error_class=None, + component=None, + message=None, + stem_id=3, + route_id=None, + endpoint_id=None, + operator_instance_id=None, + sidecar_id=None, + **source, + ) + return SimpleNamespace( + kind="source_failure", + lifecycle_state=None, + terminal_state=None, + session_id=9, + stem_id=3, + endpoint_id=None, + route_id=None, + failures_total=1, + failures=lambda: [failure], + **source, + ) + + +def test_source_disappearance_is_a_typed_immutable_session_event() -> None: + event = SessionEvent._from_native(_native_source_event()) + + assert isinstance(event.source, SourceRuntimeEvent) + assert event.source.kind is SourceRuntimeEventKind.SOURCE_UNAVAILABLE + assert event.source.stable_id.platform is Platform.WINDOWS + assert event.source.stable_id.kind is SourceKind.APPLICATION + assert event.source.stable_id.source_id == 42 + assert event.source.generation == 7 + assert ( + event.source.recovery_requirement + is SourceRecoveryRequirement.EXPLICIT_REDISCOVERY_AND_NEW_SESSION + ) + assert event.source.failure_class is SourceFailureClass.PLATFORM_STATUS + assert event.source.platform_status_code == -42 + with pytest.raises(FrozenInstanceError): + event.source.generation = 8 + + +def test_non_source_session_event_has_no_fabricated_source_payload() -> None: + native = _native_source_event() + native.kind = "lifecycle" + native.lifecycle_state = "running" + native.source_event_kind = None + native.failures_total = 0 + native.failures = lambda: [] + + event = SessionEvent._from_native(native) + + assert event.lifecycle_state == "running" + assert event.source is None + + +def test_application_and_microphone_lower_through_canonical_session_path( + tmp_path, +) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + native = _native.Session.conformance(tmp_path) + session = Session._from_native(native) + application = session.capture(Source.application("PocketStation Python Fixture")) + microphone = session.capture(Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + + with session.start() as running: + assert isinstance(running, RunningSession) + observed_stems = set() + while len(observed_stems) < 2: + frame = running.audio.read(timeout_s=1.0) + assert frame is not None + observed_stems.add(frame.stem_id) + + assert len(observed_stems) == 2 diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 0000000..6f81a21 --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from array import array +from dataclasses import FrozenInstanceError + +import pytest +from pocketstation import ( + AudioInputBufferError, + AudioInputClosedError, + AudioInputConfig, + AudioInputFullError, + DiscoveredSource, + Platform, + PocketStationError, + ProcessTreeScope, + SelectorPersistenceScope, + Session, + Source, + SourceIdentityStrength, + SourceKind, + SourceSelectorKind, + SourceState, + StableSourceId, +) + + +def _discovered(kind: SourceKind) -> DiscoveredSource: + return DiscoveredSource( + stable_id=StableSourceId( + platform=Platform.MACOS, + kind=kind, + stable_key=f"fixture:{kind.value}", + source_id=42, + ), + name="Fixture", + process_id=123 if kind is SourceKind.APPLICATION else None, + application_id="io.pocketstation.fixture" + if kind is SourceKind.APPLICATION + else None, + device_uid="device-42" if kind is SourceKind.INPUT_DEVICE else None, + state=SourceState.AVAILABLE, + sample_rate_hz=48_000, + channel_count=2, + identity_strength=SourceIdentityStrength.PLATFORM_STABLE_ID, + selector_persistence_scope=SelectorPersistenceScope.PLATFORM_IDENTITY, + process_tree_scope=ProcessTreeScope.NOT_APPLICABLE, + ) + + +def test_source_declarations_are_immutable_and_descriptive() -> None: + application = Source.application("PocketStation Fixture") + 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 microphone.kind is SourceKind.INPUT_DEVICE + assert microphone.selector_kind is SourceSelectorKind.MICROPHONE_DEFAULT + with pytest.raises(FrozenInstanceError): + application.selector_value = "changed" + + +def test_discovered_application_uses_exact_process_and_stable_identity() -> None: + selected = Source.from_discovered(_discovered(SourceKind.APPLICATION)) + + assert selected.selector_kind is SourceSelectorKind.APPLICATION_PROCESS_INSTANCE + assert selected.kind is SourceKind.APPLICATION + + +def test_discovered_input_device_lowers_to_microphone_id() -> None: + selected = Source.from_discovered(_discovered(SourceKind.INPUT_DEVICE)) + + assert selected.selector_kind is SourceSelectorKind.MICROPHONE_ID + assert selected.selector_value == "device-42" + + +@pytest.mark.parametrize("kind", [SourceKind.SYSTEM_MIX, SourceKind.OUTPUT_DEVICE]) +def test_discovery_does_not_fabricate_unsupported_builtin_session_sources( + kind: SourceKind, +) -> None: + with pytest.raises(PocketStationError) as failure: + Source.from_discovered(_discovered(kind)) + assert failure.value.code == "source.unsupported_session_kind" + + +def test_stable_identity_accepts_typed_platform_without_losing_selector_truth() -> None: + selected = Source.application_stable_id(Platform.LINUX, "pw-app:42") + + assert selected.selector_kind is SourceSelectorKind.APPLICATION_STABLE_ID + assert isinstance(selected.selector_value, StableSourceId) + assert selected.selector_value.platform is Platform.LINUX + assert selected.selector_value.source_id is None + + +def test_application_owned_pcm_uses_the_canonical_source_and_recording_path( + tmp_path, +) -> None: + session = Session(recording_root=tmp_path) + audio = session.audio_input( + "playback", + capacity_frames=2, + frame_samples_per_channel=4, + ) + audio.output.send(session.polled_audio()) + audio.output.record("playback") + + running = session.start() + audio.write(array("f", [0.25, -0.25, 0.5, -0.5]), discontinuity=True) + frame = running.audio.read(timeout_s=1.0) + stop = running.stop() + + assert frame is not None + assert frame.source_id == audio.source_id + assert frame.stream_id == audio.stream_id + assert frame.sequence_number == 0 + assert frame.discontinuity_epoch == 1 + assert list(frame.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) + assert audio.observations().accepted_total == 1 + assert stop.success + assert stop.recording is not None + assert stop.recording.complete + assert [stem.stem_name for stem in stop.recording.stems] == ["playback"] + + +def test_audio_input_reports_invalid_full_and_closed_without_blocking() -> None: + session = Session() + source = session.pcm_source( + AudioInputConfig( + name="generated", + capacity_frames=1, + frame_samples_per_channel=4, + ) + ) + + with pytest.raises(AudioInputBufferError) as invalid: + source.try_write(array("f", [0.0, 1.0])) + assert invalid.value.code == "audio_input.invalid_buffer" + + source.try_write(array("f", [0.0, 0.0, 0.0, 0.0])) + with pytest.raises(AudioInputFullError) as full: + source.try_write(array("f", [1.0, 1.0, 1.0, 1.0])) + assert full.value.code == "audio_input.full" + + source.close() + with pytest.raises(AudioInputClosedError) as closed: + source.try_write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert closed.value.code == "audio_input.closed" + observations = source.observations() + assert observations.accepted_total == 1 + assert observations.full_total == 1 + assert observations.invalid_total == 1 + assert observations.closed diff --git a/tests/test_station.py b/tests/test_station.py index 222a32c..1fa001e 100644 --- a/tests/test_station.py +++ b/tests/test_station.py @@ -1,339 +1,18 @@ -"""Unit tests for pocketstation.station — spec §12.1 voice agent pattern.""" -from __future__ import annotations - -import json -import struct -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from pocketstation import AudioFrame, AudioMode, PocketStation -from pocketstation.types import RoomCredentials - - -VALID_ROOM_RESPONSE = { - "room_id": "room-station-001", - "source_token": "src-station", - "listener_token": "lst-station", -} - - -def _make_pcm(n_samples: int = 4) -> bytes: - """Build n_samples of f32-LE PCM silence.""" - return struct.pack(f"<{n_samples}f", *([0.0] * n_samples)) - - -def _mock_http_client(json_response: dict, status_code: int = 200): - """Return a context-manager mock for httpx.AsyncClient.""" - mock_response = MagicMock() - mock_response.is_success = status_code < 400 - mock_response.status_code = status_code - mock_response.text = "" - mock_response.json = MagicMock(return_value=json_response) - - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - return mock_client - - -class _FakeWebSocket: - """Minimal WebSocket stand-in for tests. - - Supports: send (AsyncMock), close (AsyncMock), async iteration over a - fixed message list. Not a MagicMock so dunder methods work correctly. - """ - - def __init__(self, messages: list) -> None: - self._messages = messages - self.send = AsyncMock() - self.close = AsyncMock() - - def __aiter__(self): - return self._gen() - - async def _gen(self): - for msg in self._messages: - yield msg - - -def _make_connect_mock(fake_ws: "_FakeWebSocket") -> AsyncMock: - """ - Return an AsyncMock that, when called and awaited, returns fake_ws. - - Patch usage: - with patch("pocketstation.station.websockets.connect", connect_mock): - ... - Then ``await websockets.connect(url)`` in the implementation resolves to fake_ws. - """ - connect_mock = AsyncMock(return_value=fake_ws) - return connect_mock - - -# --------------------------------------------------------------------------- -# Construction -# --------------------------------------------------------------------------- - - -def test_given_voice_agent_mode_when_created_then_fields_set(): - # Given / When - station = PocketStation( - room_id="abc123", - relay_url="ws://relay.example.com", - mode=AudioMode.VOICE_AGENT, - ) - # Then - assert station.room_id == "abc123" - assert station.mode is AudioMode.VOICE_AGENT - assert station._ws is None - - -# --------------------------------------------------------------------------- -# AudioFrame -# --------------------------------------------------------------------------- - - -def test_given_audio_frame_when_samples_then_correct_count(): - # Given - n = 8 - pcm = _make_pcm(n) - frame = AudioFrame(pcm=pcm) - # When - samples = frame.samples - # Then - assert len(samples) == n - assert all(s == 0.0 for s in samples) - - -def test_given_audio_frame_with_known_values_when_samples_then_decoded_correctly(): - # Given - values = [0.5, -0.5, 1.0, -1.0] - pcm = struct.pack("<4f", *values) - frame = AudioFrame(pcm=pcm) - # When - samples = frame.samples - # Then - assert len(samples) == 4 - for actual, expected in zip(samples, values): - assert abs(actual - expected) < 1e-6 - - -def test_given_audio_frame_when_constructed_then_timestamp_and_sequence_present(): - # Given / When - frame = AudioFrame(pcm=b"\x00" * 16, sequence=7, timestamp_ns=123456789) - # Then - assert frame.sequence == 7 - assert frame.timestamp_ns == 123456789 - - -# --------------------------------------------------------------------------- -# connect() — POST /v1/rooms + WebSocket SUBSCRIBE -# --------------------------------------------------------------------------- - +"""Root package ownership and vocabulary tests.""" -@pytest.mark.asyncio -async def test_given_voice_agent_mode_when_connect_then_posts_room_and_opens_websocket(): - """ - Given a PocketStation in VOICE_AGENT mode, - When connect() is called, - Then it POSTs to /v1/rooms and sends a SUBSCRIBE message over the WebSocket. - """ - # Given - mock_http = _mock_http_client(VALID_ROOM_RESPONSE, status_code=201) - fake_ws = _FakeWebSocket(messages=[]) - connect_mock = _make_connect_mock(fake_ws) - - station = PocketStation( - room_id="room-station-001", - relay_url="ws://relay.example.com", - mode=AudioMode.VOICE_AGENT, - ) - - with patch("pocketstation.station.httpx.AsyncClient", return_value=mock_http), \ - patch("pocketstation.station.websockets.connect", connect_mock): - # When - await station.connect() - - # Then — HTTP room creation happened - mock_http.post.assert_called_once() - posted_url = mock_http.post.call_args[0][0] - assert "/v1/rooms" in posted_url - - # Then — WebSocket was opened and SUBSCRIBE was sent - connect_mock.assert_called_once_with("ws://relay.example.com") - fake_ws.send.assert_called_once() - sent = json.loads(fake_ws.send.call_args[0][0]) - assert sent["type"] == "SUBSCRIBE" - assert sent["room_id"] == "room-station-001" - assert "token" in sent - - await station.disconnect() - - -# --------------------------------------------------------------------------- -# broadcast() — must send binary bytes, not base64 JSON -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_given_connected_when_broadcast_then_sends_binary_bytes_not_base64(): - """ - Given a connected PocketStation, - When broadcast(audio_bytes) is called, - Then the WebSocket send receives raw bytes, not a base64-encoded JSON string. - """ - # Given - mock_http = _mock_http_client(VALID_ROOM_RESPONSE, status_code=201) - fake_ws = _FakeWebSocket(messages=[]) - connect_mock = _make_connect_mock(fake_ws) - - station = PocketStation( - room_id="room-station-001", - relay_url="ws://relay.example.com", - mode=AudioMode.VOICE_AGENT, - ) - audio = _make_pcm(n_samples=16) - - with patch("pocketstation.station.httpx.AsyncClient", return_value=mock_http), \ - patch("pocketstation.station.websockets.connect", connect_mock): - await station.connect() - # Reset after the SUBSCRIBE send that happens in connect() - fake_ws.send.reset_mock() - - # When - await station.broadcast(audio) - - # Then — exactly one send call with the raw bytes payload - fake_ws.send.assert_called_once_with(audio) - sent_arg = fake_ws.send.call_args[0][0] - assert isinstance(sent_arg, bytes), ( - f"broadcast() must send bytes, not {type(sent_arg).__name__}" - ) - # Guard: must not be a JSON / base64 string - assert not isinstance(sent_arg, str) - - await station.disconnect() - - -# --------------------------------------------------------------------------- -# __aenter__ / __aexit__ — context manager sends LEAVE on exit -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_given_context_manager_when_exit_then_sends_leave(): - """ - Given a PocketStation used as an async context manager, - When the block exits normally, - Then a LEAVE message is sent and the WebSocket is closed. - """ - # Given - mock_http = _mock_http_client(VALID_ROOM_RESPONSE, status_code=201) - fake_ws = _FakeWebSocket(messages=[]) - connect_mock = _make_connect_mock(fake_ws) - - with patch("pocketstation.station.httpx.AsyncClient", return_value=mock_http), \ - patch("pocketstation.station.websockets.connect", connect_mock): - # When - async with PocketStation( - room_id="room-station-001", - relay_url="ws://relay.example.com", - ) as station: - pass # nothing inside the block - - # Then — LEAVE was sent exactly once - all_sends = fake_ws.send.call_args_list - leave_sends = [ - c for c in all_sends - if isinstance(c[0][0], str) and json.loads(c[0][0]).get("type") == "LEAVE" - ] - assert len(leave_sends) == 1, ( - f"Expected exactly one LEAVE message, got {all_sends}" - ) - # Then — WebSocket was closed - fake_ws.close.assert_called_once() - - -# --------------------------------------------------------------------------- -# listen() — errors must propagate, not be swallowed -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_given_websocket_closed_when_listen_then_raises_connection_error_not_swallows(): - """ - Given that the WebSocket raises a ConnectionError during iteration, - When station.listen() is consumed, - Then the error propagates to the caller instead of being swallowed. - """ - # Given - mock_http = _mock_http_client(VALID_ROOM_RESPONSE, status_code=201) - - class _FailingWebSocket(_FakeWebSocket): - async def _gen(self): - yield _make_pcm(4) # one good frame before the error - raise ConnectionError("relay dropped connection") - - fake_ws = _FailingWebSocket(messages=[]) - connect_mock = _make_connect_mock(fake_ws) - - station = PocketStation( - room_id="room-station-001", - relay_url="ws://relay.example.com", - ) - - with patch("pocketstation.station.httpx.AsyncClient", return_value=mock_http), \ - patch("pocketstation.station.websockets.connect", connect_mock): - await station.connect() - - # When / Then — ConnectionError propagates; only the first frame arrives - frames: list[AudioFrame] = [] - with pytest.raises(ConnectionError, match="relay dropped connection"): - async for frame in station.listen(): - frames.append(frame) - - # One frame was produced before the error - assert len(frames) == 1 - - -# --------------------------------------------------------------------------- -# listen() — binary frames are yielded as AudioFrame objects -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_given_connected_when_listen_then_yields_audio_frames_from_binary_messages(): - """ - Given a WebSocket that delivers three binary PCM frames, - When station.listen() is iterated, - Then three AudioFrame objects are yielded with monotonically increasing sequences. - """ - # Given - mock_http = _mock_http_client(VALID_ROOM_RESPONSE, status_code=201) - pcm_frames = [_make_pcm(4), _make_pcm(4), _make_pcm(4)] - fake_ws = _FakeWebSocket(messages=pcm_frames) - connect_mock = _make_connect_mock(fake_ws) +from __future__ import annotations - station = PocketStation( - room_id="room-station-001", - relay_url="ws://relay.example.com", - ) +import pocketstation - with patch("pocketstation.station.httpx.AsyncClient", return_value=mock_http), \ - patch("pocketstation.station.websockets.connect", connect_mock): - await station.connect() - # When - frames: list[AudioFrame] = [] - async for frame in station.listen(): - frames.append(frame) +def test_given_primary_exports_when_inspected_then_room_vocabulary_is_absent(): + assert "PocketStation" not in pocketstation.__all__ + assert "RoomCredentials" not in pocketstation.__all__ + assert "ControlClient" in pocketstation.__all__ + assert "Session" in pocketstation.__all__ - # Then - assert len(frames) == 3 - for i, frame in enumerate(frames): - assert isinstance(frame, AudioFrame) - assert frame.pcm == pcm_frames[i] - assert frame.sequence == i - await station.disconnect() +def test_given_retired_room_api_when_inspected_then_it_is_not_shipped(): + assert not hasattr(pocketstation, "PocketStation") + assert not hasattr(pocketstation, "RoomCredentials") + assert not hasattr(pocketstation, "AudioMode") diff --git a/tests/test_stream_state_machine.py b/tests/test_stream_state_machine.py new file mode 100644 index 0000000..f41d077 --- /dev/null +++ b/tests/test_stream_state_machine.py @@ -0,0 +1,140 @@ +"""Protocol state-machine tests independent of platform capture timing.""" + +from __future__ import annotations + +import asyncio +import threading +from dataclasses import dataclass + +import pytest + +from pocketstation import STREAM_EOF, StreamError, StreamInUseError, StreamModeError +from pocketstation.aio import SignalStream as AsyncSignalStream +from pocketstation.streams import SignalStream + + +@dataclass +class _Read: + status: str + envelope: object | None = None + error: str | None = None + + +@dataclass +class _Metrics: + capacity_signals: int = 16 + max_payload_bytes: int = 1024 + maximum_buffered_payload_bytes: int = 16_384 + depth_signals: int = 0 + peak_depth_signals: int = 0 + enqueued_total: int = 0 + received_total: int = 0 + dropped_total: int = 0 + + +def test_timeout_eof_fault_and_close_are_distinct_and_sticky() -> None: + reads = [_Read("empty"), _Read("closed")] + closes = 0 + + def close() -> None: + nonlocal closes + closes += 1 + + stream = SignalStream( + poll_signal=lambda: reads.pop(0), # type: ignore[arg-type] + wait_signal=lambda _timeout_ms: reads.pop(0), # type: ignore[arg-type] + close_signal=close, + signal_metrics=_Metrics, # type: ignore[arg-type] + ) + assert stream.poll() is None + assert stream.read(timeout_s=0.0) is STREAM_EOF + assert stream.poll() is STREAM_EOF + stream.close() + assert closes == 0 + + fault = SignalStream( + poll_signal=lambda: _Read("fault", error="fixture failed"), # type: ignore[arg-type] + wait_signal=lambda _timeout_ms: _Read("empty"), # type: ignore[arg-type] + close_signal=lambda: None, + signal_metrics=_Metrics, # type: ignore[arg-type] + ) + with pytest.raises(StreamError) as failure: + fault.poll() + assert failure.value.code == "stream.fault" + assert fault.is_closed + + +def test_signal_reader_mode_and_concurrent_ownership_fail_fast() -> None: + entered = threading.Event() + release = threading.Event() + + def wait(_timeout_ms: int) -> _Read: + entered.set() + assert release.wait(1.0) + return _Read("empty") + + stream = SignalStream( + poll_signal=lambda: _Read("empty"), # type: ignore[arg-type] + wait_signal=wait, # type: ignore[arg-type] + close_signal=lambda: None, + signal_metrics=_Metrics, # type: ignore[arg-type] + ) + first = threading.Thread(target=stream.read) + first.start() + assert entered.wait(1.0) + with pytest.raises(StreamInUseError): + stream.read(timeout_s=0.0) + release.set() + first.join(timeout=1.0) + assert not first.is_alive() + + with pytest.raises(StreamModeError): + next(stream.iter_signals()) + + +@pytest.mark.asyncio +async def test_cancelled_async_reader_releases_ownership_after_cleanup() -> None: + entered = asyncio.Event() + settled = asyncio.Event() + + async def wait(_timeout_ms: int) -> _Read: + entered.set() + try: + await asyncio.Future() + finally: + settled.set() + + async def poll() -> _Read: + return _Read("empty") + + async def close() -> None: + return None + + async def metrics() -> _Metrics: + return _Metrics() + + stream = AsyncSignalStream( + poll_signal=poll, # type: ignore[arg-type] + wait_signal=wait, # type: ignore[arg-type] + close_signal=close, + signal_metrics=metrics, # type: ignore[arg-type] + ) + reader = asyncio.create_task(stream.read()) + await entered.wait() + reader.cancel() + with pytest.raises(asyncio.CancelledError): + await reader + assert settled.is_set() + assert await stream.poll() is None + + +@pytest.mark.parametrize("timeout", [-0.1, 1.1]) +def test_signal_waits_remain_bounded(timeout: float) -> None: + stream = SignalStream( + poll_signal=lambda: _Read("empty"), # type: ignore[arg-type] + wait_signal=lambda _timeout_ms: _Read("empty"), # type: ignore[arg-type] + close_signal=lambda: None, + signal_metrics=_Metrics, # type: ignore[arg-type] + ) + with pytest.raises(ValueError, match=r"between 0\.0 and 1\.0"): + stream.read(timeout_s=timeout) diff --git a/tests/test_streams.py b/tests/test_streams.py new file mode 100644 index 0000000..3c6cca9 --- /dev/null +++ b/tests/test_streams.py @@ -0,0 +1,207 @@ +"""Synchronous bounded audio-stream ownership tests.""" + +from __future__ import annotations + +import threading + +import pytest + +from pocketstation import ( + AudioStream, + RunningSession, + StreamInUseError, + StreamModeError, + _native, +) + + +def _stream_from_batches(batches): + remaining = list(batches) + state = {"closed": False, "waits": 0} + + def wait_batch(_timeout_ms): + state["waits"] += 1 + if remaining: + return remaining.pop(0) + state["closed"] = True + return None + + return ( + AudioStream( + poll_batch=lambda: None, + wait_batch=wait_batch, + is_closed=lambda: state["closed"], + ), + state, + ) + + +def _canonical_running_session(recording_root) -> RunningSession: + session = _native.Session.conformance(recording_root) + application = session.capture( + _native.Source.application("PocketStation Python Fixture") + ) + microphone = session.capture(_native.Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + return RunningSession(session.start()) + + +def test_frame_iteration_flattens_only_the_current_native_batch() -> None: + stream, state = _stream_from_batches([["a", "b"], ["c"]]) + + assert list(stream) == ["a", "b", "c"] + assert state["waits"] == 3 + assert stream.reader_mode == "frames" + + +def test_direct_reads_reuse_one_batch_without_another_native_poll() -> None: + stream, state = _stream_from_batches([["a", "b"]]) + + assert stream.read() == "a" + assert stream.read() == "b" + assert state["waits"] == 1 + assert stream.reader_mode == "read" + + +def test_running_session_exposes_the_same_exclusive_stream() -> None: + class NativeRunning: + def __init__(self) -> None: + self.batches = [["a"]] + + def poll_audio(self): + return None + + def wait_audio(self, _timeout_ms): + return self.batches.pop(0) if self.batches else None + + running = RunningSession(NativeRunning()) + + assert running.audio.read() == "a" + with pytest.raises(StreamModeError): + running.wait_audio() + + +def test_reader_mode_cannot_change_after_first_consumption() -> None: + stream, _ = _stream_from_batches([["a"]]) + assert stream.read() == "a" + + with pytest.raises(StreamModeError) as failure: + next(stream.batches()) + + assert failure.value.code == "stream.mode_conflict" + assert failure.value.active_mode == "read" + assert failure.value.requested_mode == "batches" + + +def test_second_iterator_fails_instead_of_competing_for_frames() -> None: + stream, _ = _stream_from_batches([["a", "b"]]) + first = stream.frames() + assert next(first) == "a" + + with pytest.raises(StreamInUseError) as failure: + next(stream.frames()) + + assert failure.value.code == "stream.in_use" + first.close() + assert list(stream.frames()) == ["b"] + + +def test_concurrent_direct_read_fails_instead_of_waiting() -> None: + entered = threading.Event() + release = threading.Event() + + def wait_batch(_timeout_ms): + entered.set() + assert release.wait(1.0) + return ["a"] + + stream = AudioStream( + poll_batch=lambda: None, + wait_batch=wait_batch, + is_closed=lambda: False, + ) + first_result = [] + first = threading.Thread(target=lambda: first_result.append(stream.read())) + first.start() + assert entered.wait(1.0) + + with pytest.raises(StreamInUseError): + stream.read(timeout_s=0.0) + + release.set() + first.join(timeout=1.0) + assert not first.is_alive() + assert first_result == ["a"] + + +@pytest.mark.parametrize("timeout", [-0.1, 1.1]) +def test_timeout_must_remain_bounded(timeout: float) -> None: + stream, _ = _stream_from_batches([]) + with pytest.raises(ValueError, match=r"between 0\.0 and 1\.0"): + stream.read(timeout_s=timeout) + + +def test_iteration_rejects_a_busy_poll_timeout() -> None: + stream, _ = _stream_from_batches([]) + with pytest.raises(ValueError, match=r"at least 0\.001"): + next(stream.frames(wait_timeout_s=0.0)) + + +def test_frame_stream_preserves_two_stems_from_canonical_native_session( + tmp_path, +) -> None: + """Exercise the public stream over Rust's deterministic Session engine.""" + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + running = _canonical_running_session(tmp_path) + frames = running.audio.frames(wait_timeout_s=0.1) + observed_stems: set[int] = set() + try: + first = next(frames) + observed_stems.add(first.stem_id) + assert first.source_id > 0 + assert first.sequence_number >= 0 + assert first.timestamp_start_ns >= 0 + assert first.discontinuity_epoch >= 0 + assert first.samples.readonly + + with pytest.raises(StreamInUseError): + next(running.audio.frames(wait_timeout_s=0.1)) + with pytest.raises(StreamModeError): + running.audio.read(timeout_s=0.1) + + for frame in frames: + observed_stems.add(frame.stem_id) + if len(observed_stems) == 2: + break + finally: + frames.close() + stop = running.stop() + + assert len(observed_stems) == 2 + assert stop.success + + +def test_read_and_batch_modes_use_canonical_native_session(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + direct = _canonical_running_session(tmp_path / "direct") + frame = direct.audio.read(timeout_s=1.0) + assert frame is not None + assert frame.samples.readonly + assert direct.stop().success + + batched = _canonical_running_session(tmp_path / "batched") + batches = batched.audio.batches(wait_timeout_s=0.1) + try: + batch = next(batches) + assert len(batch) > 0 + assert all(frame.samples.readonly for frame in batch) + finally: + batches.close() + stop = batched.stop() + assert stop.success diff --git a/tests/test_types.py b/tests/test_types.py index 5613418..2fbc2d6 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,54 +1,12 @@ -"""Unit tests for pocketstation.types. Phase 5.""" -import pytest -from pocketstation.types import IceServer, PocketStationError, RoomCredentials - - -def test_given_valid_dict_when_from_dict_then_credentials_parsed(): - # Given - data = { - "room_id": "room-abc", - "source_token": "src-tok", - "listener_token": "lst-tok", - } - # When - creds = RoomCredentials.from_dict(data) - # Then - assert creds.room_id == "room-abc" - assert creds.source_token == "src-tok" - assert creds.listener_token == "lst-tok" - assert creds.ice_servers == [] +"""Stable exception contract tests.""" +import pytest -def test_given_ice_servers_when_from_dict_then_parsed(): - # Given - data = { - "room_id": "room-turn", - "source_token": "s", - "listener_token": "l", - "ice_servers": [ - {"urls": ["stun:relay.example.com:3478"]}, - {"urls": ["turn:relay.example.com:3478"], "username": "u", "credential": "p"}, - ], - } - # When - creds = RoomCredentials.from_dict(data) - # Then - assert len(creds.ice_servers) == 2 - assert creds.ice_servers[0].urls == ["stun:relay.example.com:3478"] - assert creds.ice_servers[1].username == "u" - assert creds.ice_servers[1].credential == "p" +from pocketstation import PocketStationError def test_given_pocketstation_error_when_raised_then_code_set(): - # Given / When / Then with pytest.raises(PocketStationError) as exc_info: raise PocketStationError("connection failed", "network_error") assert exc_info.value.code == "network_error" assert "connection failed" in str(exc_info.value) - - -def test_given_ice_server_when_constructed_then_fields_accessible(): - srv = IceServer(urls=["stun:example.com:3478"]) - assert srv.urls == ["stun:example.com:3478"] - assert srv.username is None - assert srv.credential is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ef22131 --- /dev/null +++ b/uv.lock @@ -0,0 +1,467 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pocketstation" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, +] +provides-extras = ["dev"] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From e5db11d6ab6ac241b4a09471afe6422f30529e9f Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 20:12:38 -0400 Subject: [PATCH 02/49] feat: add Python connector authoring --- .gitignore | 1 + README.md | 39 +- native/src/connector/driver.rs | 606 ++++++++++++ native/src/connector/mod.rs | 17 + native/src/connector/observations.rs | 181 ++++ native/src/connector/values.rs | 409 ++++++++ native/src/connector/worker.rs | 568 +++++++++++ native/src/lib.rs | 4 +- native/src/observations.rs | 28 +- native/src/session.rs | 32 + native/src/signals.rs | 7 +- native/src/streams.rs | 92 +- python/pocketstation/__init__.py | 78 ++ python/pocketstation/_native.pyi | 171 ++++ python/pocketstation/aio/__init__.py | 24 + python/pocketstation/aio/connector.py | 534 +++++++++++ python/pocketstation/aio/session.py | 31 +- python/pocketstation/connector.py | 1061 +++++++++++++++++++++ python/pocketstation/graph.py | 4 + python/pocketstation/observations.py | 16 + python/pocketstation/session.py | 22 + tests/run_installed_stream_conformance.py | 10 +- tests/test_aio_session.py | 155 ++- tests/test_connector.py | 459 +++++++++ tests/test_lifecycle.py | 5 +- tests/test_public_api.py | 38 + 26 files changed, 4553 insertions(+), 39 deletions(-) create mode 100644 native/src/connector/driver.rs create mode 100644 native/src/connector/mod.rs create mode 100644 native/src/connector/observations.rs create mode 100644 native/src/connector/values.rs create mode 100644 native/src/connector/worker.rs create mode 100644 python/pocketstation/aio/connector.py create mode 100644 python/pocketstation/connector.py create mode 100644 tests/test_connector.py diff --git a/.gitignore b/.gitignore index 46239e4..4bae1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ __pycache__/ /PHASE*_PROGRESS.md /docs/PYTHON_CAPABILITY_MATRIX.md /docs/PYTHON_SDK_DESIGN.md +/docs/PYTHON_SDK_CORE_PARITY_REFERENCE.md /docs/standards/FAKE_SCAFFOLD_INVENTORY.md /tests/test_capability_contract.py venv/ diff --git a/README.md b/README.md index 6edae50..9ec3139 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ canonical-Session evidence is not release evidence: - typed process-sidecar specs, messages, sync/async streams, bounded queue saturation, deadlines, cancellation, graceful close, forced kill, wait, reap, and live/final observations, all owned by the same native `Session`; +- in-process Python Connector authoring over Core's bounded off-realtime + Connector worker, with typed configuration, redacted secrets, full input + contracts, structured failures, readiness/health/recovery control, and + drain/abort shutdown; - native blocking waits release the interpreter, and executable tests prove Python remains responsive while a hung child is terminated and reaped; - immutable Session snapshots covering event and audio queues, source ingress, @@ -134,7 +138,40 @@ The receipt exposes the canonical path and immutable source, operator, and endpoint registrations. Loading is a synchronous pre-start declaration in both `pocketstation.Session` and `pocketstation.aio.Session`; it does not run Python in foreign callbacks or admit PCM callbacks onto realtime partitions. -Python-authored extensions use the Session-owned process-sidecar contract. +Process sidecars remain available when crash isolation or a separately managed +process is wanted. They are not required for ordinary Python Connector +authoring. + +## Python Connectors + +The concise path declares an audio contract and handles items directly. Core +still owns bounded receiver polling, route accounting, readiness, failure +containment, and shutdown; Python executes only on the Connector's +off-realtime worker. + +```python +import pocketstation + +manifest = pocketstation.ConnectorManifest.audio( + "io.example.connector.stdout.v1", + package_version="1.0.0", +) + +@pocketstation.connector(manifest) +def stdout(item, context): + print(item.input.port_name, item.audio.sequence_number) + +session = pocketstation.Session() +endpoint = session.register_connector(stdout).declare() +``` + +Stateful providers implement `ConnectorDriver` and register with +`Connector.with_driver(...)`. Their factory receives every resolved input +descriptor, including `SignalSpec`, `MediaCaps`, `EdgeContract`, route identity, +and typed configuration. `ConnectorConfigurationValue.secret(...)` is redacted +by default and requires explicit provider access. Provider exceptions can use +`ConnectorError` to preserve a stable error code, stage, and retryability in the +final Session outcome. The capability matrix distinguishes declaration-level `REAL` rows from component-only `PARTIAL` rows and completely `ABSENT` projections. A row marked diff --git a/native/src/connector/driver.rs b/native/src/connector/driver.rs new file mode 100644 index 0000000..3d1fb2c --- /dev/null +++ b/native/src/connector/driver.rs @@ -0,0 +1,606 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use pocketstation::connector::{ + Connector, ConnectorContext, ConnectorDeliveryOutcome, ConnectorDriver, ConnectorDriverFactory, + ConnectorError, ConnectorErrorCode, ConnectorErrorStage, ConnectorInputDescriptor, + ConnectorItem, ConnectorRetryability, RegisteredConnector, +}; +use pocketstation::{ + EndpointGroupId, EndpointPreparationGroup, EndpointShutdownMode, RouteId, Session, +}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::observations::{python_connector_observations, python_runtime_observations}; +use super::values::{ + configuration_values, PythonConnectorConfiguration, PythonConnectorConfigurationValue, + PythonConnectorManifest, +}; +use crate::errors::coded_reason; +use crate::graph::{PythonEdgeContract, PythonEndpoint, PythonMediaCaps, PythonSignalSpec}; +use crate::signals::{copy_envelope, python_envelope, PythonSignalEnvelope}; +use crate::streams::{owned_endpoint_audio_frame, python_audio_frame, PythonAudioFrame}; + +#[pyclass(name = "ConnectorInputDescriptor", frozen)] +pub(crate) struct PythonConnectorInputDescriptor { + #[pyo3(get)] + pub(super) endpoint_id: u64, + #[pyo3(get)] + pub(super) connector_id: Option, + #[pyo3(get)] + pub(super) route_id: u64, + #[pyo3(get)] + pub(super) port_name: String, + #[pyo3(get)] + pub(super) signal_wire_id: String, + pub(super) signal: Py, + pub(super) media: Py, + pub(super) edge: Py, + pub(super) configuration: Vec<(String, Py)>, +} + +#[pymethods] +impl PythonConnectorInputDescriptor { + #[getter] + fn configuration(&self, py: Python<'_>) -> PyResult> { + let values = PyDict::new(py); + for (name, value) in &self.configuration { + values.set_item(name, value.clone_ref(py))?; + } + Ok(values.unbind()) + } + + #[getter] + fn signal(&self, py: Python<'_>) -> Py { + self.signal.clone_ref(py) + } + + #[getter] + fn media(&self, py: Python<'_>) -> Py { + self.media.clone_ref(py) + } + + #[getter] + fn edge(&self, py: Python<'_>) -> Py { + self.edge.clone_ref(py) + } +} + +#[pyclass(name = "ConnectorItem", frozen)] +pub(crate) struct PythonConnectorItem { + #[pyo3(get)] + pub(super) kind: &'static str, + pub(super) input: Py, + pub(super) audio: Option>, + pub(super) signal: Option>, +} + +#[pymethods] +impl PythonConnectorItem { + #[getter] + fn input(&self, py: Python<'_>) -> Py { + self.input.clone_ref(py) + } + + #[getter] + fn audio(&self, py: Python<'_>) -> Option> { + self.audio.as_ref().map(|value| value.clone_ref(py)) + } + + #[getter] + fn signal(&self, py: Python<'_>) -> Option> { + self.signal.as_ref().map(|value| value.clone_ref(py)) + } +} + +#[pyclass(name = "ConnectorContext", frozen)] +pub(crate) struct PythonConnectorContext { + context: ConnectorContext, + active: Arc, +} + +#[pymethods] +impl PythonConnectorContext { + #[getter] + fn stop_requested(&self) -> PyResult { + self.ensure_active()?; + Ok(self.context.is_stop_requested()) + } + + #[getter] + fn shutdown_mode(&self) -> PyResult> { + self.ensure_active()?; + Ok(match self.context.shutdown_mode() { + Some(EndpointShutdownMode::Drain) => Some("drain"), + Some(EndpointShutdownMode::Abort) => Some("abort"), + None => None, + }) + } + + fn set_ready(&self) -> PyResult { + self.ensure_active()?; + Ok(self.context.set_ready()) + } + + fn set_not_ready(&self, reason_code: Option) -> PyResult { + self.ensure_active()?; + Ok(self + .context + .set_not_ready(reason_code.map(make_error_code).transpose()?)) + } + + fn set_degraded(&self, reason_code: String) -> PyResult { + self.ensure_active()?; + Ok(self.context.set_degraded(make_error_code(reason_code)?)) + } + + fn set_healthy(&self) -> PyResult { + self.ensure_active()?; + Ok(self.context.set_healthy()) + } + + fn set_reconnecting(&self, reason_code: String) -> PyResult { + self.ensure_active()?; + Ok(self.context.set_reconnecting(make_error_code(reason_code)?)) + } + + fn set_connected(&self) -> PyResult { + self.ensure_active()?; + Ok(self.context.set_connected()) + } + + fn record_retry(&self) -> PyResult<()> { + self.ensure_active()?; + self.context.record_retry(); + Ok(()) + } +} + +impl PythonConnectorContext { + fn ensure_active(&self) -> PyResult<()> { + if self.active.load(Ordering::Acquire) { + Ok(()) + } else { + Err(PyRuntimeError::new_err(coded_reason( + "connector.context_closed", + "Connector context is no longer active", + ))) + } + } +} + +struct PythonDriverFactory { + factory: Py, +} + +impl ConnectorDriverFactory for PythonDriverFactory { + fn preparation_group( + &self, + route_id: RouteId, + configuration: &pocketstation::connector::ResolvedConnectorConfiguration, + ) -> Result { + Python::attach(|py| { + let result = (|| -> PyResult { + let values = configuration_values(configuration) + .into_iter() + .map(|(name, value)| Py::new(py, value).map(|value| (name, value))) + .collect::>>()?; + let configuration = PyDict::new(py); + for (name, value) in values { + configuration.set_item(name, value)?; + } + let group = self + .factory + .bind(py) + .call_method1("preparation_group", (route_id.get(), configuration))?; + if group.is_none() { + Ok(EndpointPreparationGroup::Route(route_id)) + } else { + let group = group.extract::()?; + if group.trim().is_empty() { + return Err(PyValueError::new_err(coded_reason( + "connector.invalid_preparation_group", + "Connector preparation group cannot be empty", + ))); + } + Ok(EndpointPreparationGroup::Shared(EndpointGroupId::new( + group, + ))) + } + })(); + result.map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare)) + }) + } + + fn prepare( + &self, + inputs: &[ConnectorInputDescriptor], + ) -> Result, ConnectorError> { + Python::attach(|py| { + let descriptors = inputs + .iter() + .map(|input| python_input_descriptor(py, input)) + .collect::>>() + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare))?; + let driver = self + .factory + .bind(py) + .call_method1("prepare", (descriptors,)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare))? + .unbind(); + let idle_enabled = driver + .bind(py) + .getattr("_pocketstation_idle_enabled") + .and_then(|value| value.extract::()) + .unwrap_or(false); + Ok(Box::new(PythonDriver { + driver, + active: Arc::new(AtomicBool::new(true)), + idle_enabled, + }) as Box) + }) + } +} + +struct PythonDriver { + driver: Py, + active: Arc, + idle_enabled: bool, +} + +impl Drop for PythonDriver { + fn drop(&mut self) { + self.active.store(false, Ordering::Release); + } +} + +impl ConnectorDriver for PythonDriver { + fn start(&mut self, context: &ConnectorContext) -> Result<(), ConnectorError> { + self.call_context_method("start", context, ConnectorErrorStage::Startup) + } + + fn deliver( + &mut self, + item: ConnectorItem<'_>, + context: &ConnectorContext, + ) -> Result { + Python::attach(|py| { + let item = python_item(py, item) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + let outcome = self + .driver + .bind(py) + .call_method1("deliver", (item, context)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + if outcome.is_none() { + return Ok(ConnectorDeliveryOutcome::Delivered); + } + match outcome + .extract::() + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))? + .as_str() + { + "delivered" => Ok(ConnectorDeliveryOutcome::Delivered), + "dropped" => Ok(ConnectorDeliveryOutcome::Dropped), + _ => Err(internal_error( + "python.invalid_delivery_outcome", + ConnectorErrorStage::Delivery, + "Python Connector deliver() must return 'delivered', 'dropped', or None", + )), + } + }) + } + + fn idle(&mut self, context: &ConnectorContext) -> Result<(), ConnectorError> { + if self.idle_enabled { + self.call_context_method("idle", context, ConnectorErrorStage::Delivery) + } else { + Ok(()) + } + } + + fn shutdown( + &mut self, + mode: EndpointShutdownMode, + context: &ConnectorContext, + ) -> Result<(), ConnectorError> { + Python::attach(|py| { + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Shutdown))?; + let mode = match mode { + EndpointShutdownMode::Drain => "drain", + EndpointShutdownMode::Abort => "abort", + }; + self.driver + .bind(py) + .call_method1("shutdown", (mode, context)) + .map(|_| ()) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Shutdown)) + }) + } + + fn cancel_preparation(self: Box) -> Result<(), ConnectorError> { + Python::attach(|py| { + let result = self + .driver + .bind(py) + .call_method0("cancel_preparation") + .map(|_| ()) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare)); + self.active.store(false, Ordering::Release); + result + }) + } +} + +impl PythonDriver { + fn call_context_method( + &self, + method: &str, + context: &ConnectorContext, + stage: ConnectorErrorStage, + ) -> Result<(), ConnectorError> { + Python::attach(|py| { + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, stage))?; + self.driver + .bind(py) + .call_method1(method, (context,)) + .map(|_| ()) + .map_err(|error| python_error(py, error, stage)) + }) + } +} + +#[pyclass(name = "_RegisteredConnector", frozen)] +pub(crate) struct PythonRegisteredConnector { + pub(super) registered: RegisteredConnector, +} + +#[pymethods] +impl PythonRegisteredConnector { + #[getter] + fn session_id(&self) -> u64 { + self.registered.session_id().get() + } + + fn observations( + &self, + py: Python<'_>, + ) -> PyResult>> { + self.registered + .observations() + .map_err(|error| PyRuntimeError::new_err(error.to_string()))? + .into_iter() + .map(|value| python_runtime_observations(py, value)) + .collect() + } + + fn observation( + &self, + py: Python<'_>, + endpoint: &PythonEndpoint, + ) -> PyResult>> { + self.registered + .observation(endpoint.handle) + .map_err(|error| PyValueError::new_err(error.to_string()))? + .map(|handle| { + handle + .snapshot() + .map_err(|error| PyRuntimeError::new_err(error.to_string())) + .and_then(|value| python_connector_observations(py, value)) + }) + .transpose() + } +} + +pub(crate) fn register_connector( + session: &Session, + manifest: &PythonConnectorManifest, + factory: Py, +) -> PyResult { + let connector = Connector::with_driver( + manifest.value.clone(), + Arc::new(PythonDriverFactory { factory }), + ) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + session + .register_connector(connector) + .map(|registered| PythonRegisteredConnector { registered }) + .map_err(|error| PyValueError::new_err(error.to_string())) +} + +pub(crate) fn declare_connector( + registered: &PythonRegisteredConnector, + session: &Session, + configuration: &PythonConnectorConfiguration, + edge: &PythonEdgeContract, +) -> PyResult { + registered + .registered + .declare(session, configuration.value.clone(), edge.value) + .map(|handle| PythonEndpoint { handle }) + .map_err(|error| PyValueError::new_err(error.to_string())) +} + +fn python_input_descriptor( + py: Python<'_>, + input: &ConnectorInputDescriptor, +) -> PyResult> { + let configuration = configuration_values(input.configuration()) + .into_iter() + .map(|(name, value)| Py::new(py, value).map(|value| (name, value))) + .collect::>>()?; + let signal = Py::new( + py, + PythonSignalSpec { + value: input.signal_spec().clone(), + }, + )?; + let media = Py::new( + py, + PythonMediaCaps { + value: input.media(), + }, + )?; + let edge = Py::new( + py, + PythonEdgeContract { + value: input.edge_contract(), + }, + )?; + Py::new( + py, + PythonConnectorInputDescriptor { + endpoint_id: input.endpoint_id().get(), + connector_id: input.connector_id().map(pocketstation::ConnectorId::get), + route_id: input.route_id().get(), + port_name: input.port_name().to_owned(), + signal_wire_id: input.signal_spec().wire_id().to_owned(), + signal, + media, + edge, + configuration, + }, + ) +} + +fn python_item(py: Python<'_>, item: ConnectorItem<'_>) -> PyResult> { + match item { + ConnectorItem::Audio { input, frame } => { + let descriptor = python_input_descriptor(py, input)?; + let frame = owned_endpoint_audio_frame(frame, input); + let audio = Py::new(py, python_audio_frame(py, frame))?; + Py::new( + py, + PythonConnectorItem { + kind: "audio", + input: descriptor, + audio: Some(audio), + signal: None, + }, + ) + } + ConnectorItem::Signal { input, signal } => { + let descriptor = python_input_descriptor(py, input)?; + let signal = Py::new(py, python_envelope(py, copy_envelope(&signal))?)?; + Py::new( + py, + PythonConnectorItem { + kind: "signal", + input: descriptor, + audio: None, + signal: Some(signal), + }, + ) + } + } +} + +pub(super) fn python_context( + py: Python<'_>, + context: &ConnectorContext, + active: Arc, +) -> PyResult> { + Py::new( + py, + PythonConnectorContext { + context: context.clone(), + active, + }, + ) +} + +fn make_error_code(value: String) -> PyResult { + ConnectorErrorCode::new(value).map_err(|error| PyValueError::new_err(error.to_string())) +} + +pub(super) fn python_error( + py: Python<'_>, + error: PyErr, + default_stage: ConnectorErrorStage, +) -> ConnectorError { + let value = error.value(py); + let code = value + .getattr("code") + .and_then(|value| value.extract::()) + .ok() + .and_then(|value| ConnectorErrorCode::new(value).ok()) + .unwrap_or_else(|| ConnectorErrorCode::new("python.exception").expect("valid constant")); + let stage = value + .getattr("stage") + .and_then(|value| value.extract::()) + .ok() + .and_then(|value| parse_stage(&value)) + .unwrap_or(default_stage); + let retryability = value + .getattr("retryability") + .and_then(|value| value.extract::()) + .ok() + .and_then(|value| parse_retryability(&value)) + .unwrap_or(ConnectorRetryability::Never); + let mut message = value + .getattr("message") + .and_then(|value| value.extract::()) + .unwrap_or_else(|_| error.to_string()); + const MAXIMUM_MESSAGE_BYTES: usize = + pocketstation::connector::MAX_CONNECTOR_ERROR_MESSAGE_BYTES; + if message.len() > MAXIMUM_MESSAGE_BYTES { + let mut boundary = MAXIMUM_MESSAGE_BYTES; + while boundary > 0 && !message.is_char_boundary(boundary) { + boundary -= 1; + } + message.truncate(boundary); + } + ConnectorError::new(code, stage, retryability, message) + .unwrap_or_else(|_| internal_error("python.exception", stage, "Python Connector failed")) +} + +fn parse_stage(value: &str) -> Option { + match value { + "configuration" => Some(ConnectorErrorStage::Configuration), + "prepare" => Some(ConnectorErrorStage::Prepare), + "startup" => Some(ConnectorErrorStage::Startup), + "readiness" => Some(ConnectorErrorStage::Readiness), + "delivery" => Some(ConnectorErrorStage::Delivery), + "retry" => Some(ConnectorErrorStage::Retry), + "shutdown" => Some(ConnectorErrorStage::Shutdown), + "join" => Some(ConnectorErrorStage::Join), + _ => None, + } +} + +fn parse_retryability(value: &str) -> Option { + match value { + "never" => Some(ConnectorRetryability::Never), + "retryable" => Some(ConnectorRetryability::Retryable), + "retry-after-reconfiguration" => Some(ConnectorRetryability::RetryAfterReconfiguration), + _ => None, + } +} + +pub(super) fn internal_error( + code: &str, + stage: ConnectorErrorStage, + message: &str, +) -> ConnectorError { + ConnectorError::new( + ConnectorErrorCode::new(code).expect("internal Connector code is valid"), + stage, + ConnectorRetryability::Never, + message, + ) + .expect("internal Connector error is valid") +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/connector/mod.rs b/native/src/connector/mod.rs new file mode 100644 index 0000000..a3102e9 --- /dev/null +++ b/native/src/connector/mod.rs @@ -0,0 +1,17 @@ +mod driver; +mod observations; +mod values; +mod worker; + +use pyo3::prelude::*; + +pub(crate) use driver::{declare_connector, register_connector, PythonRegisteredConnector}; +pub(crate) use values::{PythonConnectorConfiguration, PythonConnectorManifest}; +pub(crate) use worker::register_worker_connector; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + values::register(module)?; + observations::register(module)?; + driver::register(module)?; + Ok(()) +} diff --git a/native/src/connector/observations.rs b/native/src/connector/observations.rs new file mode 100644 index 0000000..66db534 --- /dev/null +++ b/native/src/connector/observations.rs @@ -0,0 +1,181 @@ +use pocketstation::connector::{ + ConnectorError, ConnectorErrorStage, ConnectorObservations, ConnectorRetryability, + ConnectorRuntimeObservations, ConnectorServiceStatus, +}; +use pyo3::prelude::*; + +#[pyclass(name = "_ConnectorErrorSnapshot", frozen)] +pub(crate) struct PythonConnectorErrorSnapshot { + #[pyo3(get)] + code: String, + #[pyo3(get)] + stage: &'static str, + #[pyo3(get)] + retryability: &'static str, + #[pyo3(get)] + message: String, +} + +#[pyclass(name = "_ConnectorServiceStatus", frozen)] +pub(crate) struct PythonConnectorServiceStatus { + #[pyo3(get)] + delivery_readiness: &'static str, + #[pyo3(get)] + health: &'static str, + #[pyo3(get)] + recovery: &'static str, + #[pyo3(get)] + readiness_reason_code: Option, + #[pyo3(get)] + health_reason_code: Option, + #[pyo3(get)] + recovery_reason_code: Option, + #[pyo3(get)] + revision: u64, + #[pyo3(get)] + last_transition_elapsed_ns: u64, + #[pyo3(get)] + accepts_delivery: bool, +} + +#[pyclass(name = "_ConnectorObservations", frozen)] +pub(crate) struct PythonConnectorObservations { + #[pyo3(get)] + service_status: Py, + #[pyo3(get)] + status_transitions_total: u64, + #[pyo3(get)] + retry_attempts_total: u64, + #[pyo3(get)] + reconnects_total: u64, + #[pyo3(get)] + failures_total: u64, + #[pyo3(get)] + last_error: Option>, +} + +#[pyclass(name = "_ConnectorRuntimeObservations", frozen)] +pub(crate) struct PythonConnectorRuntimeObservations { + #[pyo3(get)] + endpoint_ids: Vec, + #[pyo3(get)] + connector: Py, + #[pyo3(get)] + frames_received_total: u64, + #[pyo3(get)] + frames_delivered_total: u64, + #[pyo3(get)] + frames_dropped_total: u64, + #[pyo3(get)] + discontinuities_total: u64, + #[pyo3(get)] + endpoint_failures_total: u64, +} + +pub(crate) fn python_connector_observations( + py: Python<'_>, + value: ConnectorObservations, +) -> PyResult> { + let service_status = Py::new(py, python_service_status(value.service_status))?; + let last_error = value + .last_error + .map(|error| Py::new(py, python_error_snapshot(error))) + .transpose()?; + Py::new( + py, + PythonConnectorObservations { + service_status, + status_transitions_total: value.status_transitions_total, + retry_attempts_total: value.retry_attempts_total, + reconnects_total: value.reconnects_total, + failures_total: value.failures_total, + last_error, + }, + ) +} + +pub(crate) fn python_runtime_observations( + py: Python<'_>, + value: ConnectorRuntimeObservations, +) -> PyResult> { + let connector = python_connector_observations(py, value.connector)?; + Py::new( + py, + PythonConnectorRuntimeObservations { + endpoint_ids: value.endpoint_ids.iter().map(|id| id.get()).collect(), + connector, + frames_received_total: value.endpoint.frames_received_total, + frames_delivered_total: value.endpoint.frames_delivered_total, + frames_dropped_total: value.endpoint.frames_dropped_total, + discontinuities_total: value.endpoint.discontinuities_total, + endpoint_failures_total: value.endpoint.failures_total, + }, + ) +} + +fn python_service_status(value: ConnectorServiceStatus) -> PythonConnectorServiceStatus { + PythonConnectorServiceStatus { + delivery_readiness: match value.delivery_readiness() { + pocketstation::connector::ConnectorDeliveryReadiness::NotReady => "not-ready", + pocketstation::connector::ConnectorDeliveryReadiness::Ready => "ready", + }, + health: match value.health() { + pocketstation::connector::ConnectorHealth::Healthy => "healthy", + pocketstation::connector::ConnectorHealth::Degraded => "degraded", + }, + recovery: match value.recovery() { + pocketstation::connector::ConnectorRecovery::Idle => "idle", + pocketstation::connector::ConnectorRecovery::Reconnecting => "reconnecting", + }, + readiness_reason_code: value + .readiness_reason_code() + .map(|code| code.as_str().to_owned()), + health_reason_code: value + .health_reason_code() + .map(|code| code.as_str().to_owned()), + recovery_reason_code: value + .recovery_reason_code() + .map(|code| code.as_str().to_owned()), + revision: value.revision(), + last_transition_elapsed_ns: value.last_transition_elapsed_ns(), + accepts_delivery: value.accepts_delivery(), + } +} + +fn python_error_snapshot(value: ConnectorError) -> PythonConnectorErrorSnapshot { + PythonConnectorErrorSnapshot { + code: value.code().as_str().to_owned(), + stage: stage_name(value.stage()), + retryability: retryability_name(value.retryability()), + message: value.message().to_owned(), + } +} + +fn stage_name(value: ConnectorErrorStage) -> &'static str { + match value { + ConnectorErrorStage::Configuration => "configuration", + ConnectorErrorStage::Prepare => "prepare", + ConnectorErrorStage::Startup => "startup", + ConnectorErrorStage::Readiness => "readiness", + ConnectorErrorStage::Delivery => "delivery", + ConnectorErrorStage::Retry => "retry", + ConnectorErrorStage::Shutdown => "shutdown", + ConnectorErrorStage::Join => "join", + } +} + +fn retryability_name(value: ConnectorRetryability) -> &'static str { + match value { + ConnectorRetryability::Never => "never", + ConnectorRetryability::Retryable => "retryable", + ConnectorRetryability::RetryAfterReconfiguration => "retry-after-reconfiguration", + } +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/connector/values.rs b/native/src/connector/values.rs new file mode 100644 index 0000000..961302d --- /dev/null +++ b/native/src/connector/values.rs @@ -0,0 +1,409 @@ +use std::time::Duration; + +use pocketstation::connector::ConnectorCapability; +use pocketstation::connector::{ + ConnectorConfiguration, ConnectorConfigurationConstraint, ConnectorConfigurationField, + ConnectorConfigurationRequirement, ConnectorConfigurationSchema, ConnectorConfigurationValue, + ConnectorConfigurationValueKind, ConnectorManifest, ConnectorReadinessPolicy, + ConnectorRequirement, ConnectorSecret, +}; +use pocketstation::{ + ExecutionPartition, NodeDescriptor, NodeTypeId, OperatorId, PortDirection, SafetyContract, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::errors::coded_reason; +use crate::graph::PythonPortSpec; + +fn invalid_connector(reason: impl Into) -> PyErr { + PyValueError::new_err(coded_reason("connector.invalid_contract", reason.into())) +} + +#[pyclass(name = "_ConnectorConfigurationValue", frozen)] +#[derive(Clone)] +pub(crate) struct PythonConnectorConfigurationValue { + pub(crate) value: ConnectorConfigurationValue, +} + +#[pymethods] +impl PythonConnectorConfigurationValue { + #[staticmethod] + fn text(value: String) -> Self { + Self { + value: ConnectorConfigurationValue::Text(value), + } + } + + #[staticmethod] + fn boolean(value: bool) -> Self { + Self { + value: ConnectorConfigurationValue::Boolean(value), + } + } + + #[staticmethod] + fn signed_integer(value: i64) -> Self { + Self { + value: ConnectorConfigurationValue::SignedInteger(value), + } + } + + #[staticmethod] + fn unsigned_integer(value: u64) -> Self { + Self { + value: ConnectorConfigurationValue::UnsignedInteger(value), + } + } + + #[staticmethod] + fn duration_milliseconds(value: u64) -> Self { + Self { + value: ConnectorConfigurationValue::DurationMilliseconds(value), + } + } + + #[staticmethod] + fn byte_count(value: u64) -> Self { + Self { + value: ConnectorConfigurationValue::ByteCount(value), + } + } + + #[staticmethod] + fn secret(value: String) -> PyResult { + ConnectorSecret::new(value) + .map(|value| Self { + value: ConnectorConfigurationValue::Secret(value), + }) + .map_err(|error| invalid_connector(error.to_string())) + } + + #[getter] + fn kind(&self) -> &'static str { + kind_name(self.value.kind()) + } + + fn expose_secret(&self) -> PyResult { + match &self.value { + ConnectorConfigurationValue::Secret(value) => Ok(value.expose_secret().to_owned()), + _ => Err(PyValueError::new_err(coded_reason( + "connector.configuration.not_secret", + "only a secret connector value can be exposed", + ))), + } + } + + fn as_text(&self) -> Option { + match &self.value { + ConnectorConfigurationValue::Text(value) => Some(value.clone()), + _ => None, + } + } + + fn as_boolean(&self) -> Option { + match &self.value { + ConnectorConfigurationValue::Boolean(value) => Some(*value), + _ => None, + } + } + + fn as_signed_integer(&self) -> Option { + match &self.value { + ConnectorConfigurationValue::SignedInteger(value) => Some(*value), + _ => None, + } + } + + fn as_unsigned_integer(&self) -> Option { + match &self.value { + ConnectorConfigurationValue::UnsignedInteger(value) + | ConnectorConfigurationValue::DurationMilliseconds(value) + | ConnectorConfigurationValue::ByteCount(value) => Some(*value), + _ => None, + } + } + + fn __repr__(&self) -> String { + match &self.value { + ConnectorConfigurationValue::Secret(_) => { + "ConnectorConfigurationValue.secret()".to_owned() + } + _ => format!( + "ConnectorConfigurationValue.{}(...)", + kind_name(self.value.kind()) + ), + } + } +} + +#[pyclass(name = "_ConnectorConfigurationConstraint", frozen)] +#[derive(Clone)] +pub(crate) struct PythonConnectorConfigurationConstraint { + value: ConnectorConfigurationConstraint, +} + +#[pymethods] +impl PythonConnectorConfigurationConstraint { + #[staticmethod] + fn non_empty() -> Self { + Self { + value: ConnectorConfigurationConstraint::NonEmpty, + } + } + + #[staticmethod] + fn text_length_bytes(minimum: usize, maximum: usize) -> Self { + Self { + value: ConnectorConfigurationConstraint::TextLengthBytes { minimum, maximum }, + } + } + + #[staticmethod] + fn signed_range(minimum: i64, maximum: i64) -> Self { + Self { + value: ConnectorConfigurationConstraint::SignedRange { minimum, maximum }, + } + } + + #[staticmethod] + fn unsigned_range(minimum: u64, maximum: u64) -> Self { + Self { + value: ConnectorConfigurationConstraint::UnsignedRange { minimum, maximum }, + } + } + + #[staticmethod] + fn one_of(values: Vec) -> Self { + Self { + value: ConnectorConfigurationConstraint::OneOf(values), + } + } +} + +#[pyclass(name = "_ConnectorConfigurationField", frozen)] +#[derive(Clone)] +pub(crate) struct PythonConnectorConfigurationField { + value: ConnectorConfigurationField, +} + +#[pymethods] +impl PythonConnectorConfigurationField { + #[new] + #[pyo3(signature = (name, kind, requirement, documentation, default=None, constraints=Vec::new(), deprecation=None))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + name: String, + kind: String, + requirement: String, + documentation: String, + default: Option>, + constraints: Vec>, + deprecation: Option, + ) -> PyResult { + let kind = parse_kind(&kind)?; + let has_default = default.is_some(); + let requirement = match requirement.as_str() { + "required" => ConnectorConfigurationRequirement::Required, + "optional" => ConnectorConfigurationRequirement::Optional, + "default" => ConnectorConfigurationRequirement::Default( + default + .ok_or_else(|| invalid_connector("default requirement needs a value"))? + .borrow(py) + .value + .clone(), + ), + _ => return Err(invalid_connector("configuration requirement is invalid")), + }; + if !matches!(requirement, ConnectorConfigurationRequirement::Default(_)) && has_default { + return Err(invalid_connector( + "only a default field can provide a default value", + )); + } + let mut value = ConnectorConfigurationField::new(name, kind, requirement, documentation); + for constraint in constraints { + value = value.with_constraint(constraint.borrow(py).value.clone()); + } + if let Some(message) = deprecation { + value = value.deprecated(message); + } + Ok(Self { value }) + } +} + +#[pyclass(name = "_ConnectorConfigurationSchema", frozen)] +#[derive(Clone)] +pub(crate) struct PythonConnectorConfigurationSchema { + pub(crate) value: ConnectorConfigurationSchema, +} + +#[pymethods] +impl PythonConnectorConfigurationSchema { + #[new] + #[pyo3(signature = (revision=1, fields=Vec::new()))] + fn new( + py: Python<'_>, + revision: u32, + fields: Vec>, + ) -> PyResult { + let fields = fields + .iter() + .map(|field| field.borrow(py).value.clone()) + .collect(); + ConnectorConfigurationSchema::new(revision, fields) + .map(|value| Self { value }) + .map_err(|error| invalid_connector(error.to_string())) + } +} + +#[pyclass(name = "_ConnectorConfiguration", frozen)] +#[derive(Clone, Default)] +pub(crate) struct PythonConnectorConfiguration { + pub(crate) value: ConnectorConfiguration, +} + +#[pymethods] +impl PythonConnectorConfiguration { + #[new] + #[pyo3(signature = (entries=Vec::new()))] + fn new(py: Python<'_>, entries: Vec<(String, Py)>) -> Self { + let mut value = ConnectorConfiguration::new(); + for (name, entry) in entries { + value.insert(name, entry.borrow(py).value.clone()); + } + Self { value } + } +} + +#[pyclass(name = "_ConnectorManifest", frozen)] +#[derive(Clone)] +pub(crate) struct PythonConnectorManifest { + pub(crate) value: ConnectorManifest, +} + +#[pymethods] +impl PythonConnectorManifest { + #[new] + #[pyo3(signature = (operator_id, node_type_id, package_version, inputs, configuration, manifest_revision=1, startup_timeout_ms=5_000, probe_interval_ms=100, success_threshold=1, failure_threshold=1, capabilities=Vec::new(), requirements=Vec::new()))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + operator_id: String, + node_type_id: String, + package_version: String, + inputs: Vec>, + configuration: &PythonConnectorConfigurationSchema, + manifest_revision: u32, + startup_timeout_ms: u64, + probe_interval_ms: u64, + success_threshold: u32, + failure_threshold: u32, + capabilities: Vec<(String, String)>, + requirements: Vec<(String, bool, String)>, + ) -> PyResult { + let inputs = inputs + .iter() + .map(|port| port.borrow(py).value.clone()) + .collect::>(); + if inputs + .iter() + .any(|port| port.direction() != PortDirection::Input) + { + return Err(invalid_connector( + "connector manifest ports must all be inputs", + )); + } + let node = NodeDescriptor::new( + NodeTypeId::from(node_type_id.as_str()), + "Python Connector", + inputs, + Vec::new(), + ExecutionPartition::AsyncWorker, + SafetyContract::AllocationAllowed, + true, + ) + .map_err(|error| invalid_connector(error.to_string()))?; + let readiness = ConnectorReadinessPolicy::new( + Duration::from_millis(startup_timeout_ms), + Duration::from_millis(probe_interval_ms), + success_threshold, + failure_threshold, + ) + .map_err(|error| invalid_connector(error.to_string()))?; + let mut manifest = ConnectorManifest::new( + manifest_revision, + OperatorId::new(operator_id), + package_version, + node, + configuration.value.clone(), + readiness, + ) + .map_err(|error| invalid_connector(error.to_string()))?; + for (id, documentation) in capabilities { + let capability = ConnectorCapability::new(id, documentation) + .map_err(|error| invalid_connector(error.to_string()))?; + manifest = manifest.with_capability(capability); + } + for (id, required, documentation) in requirements { + let requirement = ConnectorRequirement::new(id, required, documentation) + .map_err(|error| invalid_connector(error.to_string()))?; + manifest = manifest.with_requirement(requirement); + } + manifest + .validate() + .map(|()| Self { value: manifest }) + .map_err(|error| invalid_connector(error.to_string())) + } +} + +pub(crate) fn configuration_values( + configuration: &pocketstation::connector::ResolvedConnectorConfiguration, +) -> Vec<(String, PythonConnectorConfigurationValue)> { + configuration + .iter() + .map(|(name, value)| { + ( + name.to_owned(), + PythonConnectorConfigurationValue { + value: value.clone(), + }, + ) + }) + .collect() +} + +fn parse_kind(value: &str) -> PyResult { + match value { + "text" => Ok(ConnectorConfigurationValueKind::Text), + "boolean" => Ok(ConnectorConfigurationValueKind::Boolean), + "signed-integer" => Ok(ConnectorConfigurationValueKind::SignedInteger), + "unsigned-integer" => Ok(ConnectorConfigurationValueKind::UnsignedInteger), + "duration-milliseconds" => Ok(ConnectorConfigurationValueKind::DurationMilliseconds), + "byte-count" => Ok(ConnectorConfigurationValueKind::ByteCount), + "secret" => Ok(ConnectorConfigurationValueKind::Secret), + _ => Err(invalid_connector("connector configuration kind is invalid")), + } +} + +const fn kind_name(value: ConnectorConfigurationValueKind) -> &'static str { + match value { + ConnectorConfigurationValueKind::Text => "text", + ConnectorConfigurationValueKind::Boolean => "boolean", + ConnectorConfigurationValueKind::SignedInteger => "signed-integer", + ConnectorConfigurationValueKind::UnsignedInteger => "unsigned-integer", + ConnectorConfigurationValueKind::DurationMilliseconds => "duration-milliseconds", + ConnectorConfigurationValueKind::ByteCount => "byte-count", + ConnectorConfigurationValueKind::Secret => "secret", + } +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/connector/worker.rs b/native/src/connector/worker.rs new file mode 100644 index 0000000..230b31c --- /dev/null +++ b/native/src/connector/worker.rs @@ -0,0 +1,568 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use pocketstation::connector::{ + Connector, ConnectorConfiguration, ConnectorConfigurationValue, + ConnectorConfigurationValueKind, ConnectorContext, ConnectorError, ConnectorErrorCode, + ConnectorErrorStage, ConnectorFactory, ConnectorRetryability, ConnectorRunOutcome, + ConnectorSecret, ConnectorWorker, ResolvedConnectorConfiguration, +}; +use pocketstation::{ + EndpointGroupId, EndpointPortInput, EndpointPreparationGroup, EndpointReceiver, + EndpointShutdownMode, RouteId, Session, +}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::driver::{ + internal_error, python_context, python_error, PythonConnectorInputDescriptor, + PythonConnectorItem, PythonRegisteredConnector, +}; +use super::values::{ + configuration_values, PythonConnectorConfigurationValue, PythonConnectorManifest, +}; +use crate::graph::{PythonEdgeContract, PythonMediaCaps, PythonSignalSpec}; +use crate::signals::{copy_envelope, python_envelope}; +use crate::streams::{owned_endpoint_audio_frame_for_route, python_audio_frame, PythonAudioFrame}; + +const WORKER_IDLE_WAIT: Duration = Duration::from_millis(1); +const MAXIMUM_BATCH_ITEMS: usize = 1_024; + +struct PythonConnectorFactory { + factory: Py, + manifest: pocketstation::connector::ConnectorManifest, + maximum_batch_items: usize, +} + +impl ConnectorFactory for PythonConnectorFactory { + fn preparation_group( + &self, + route_id: RouteId, + configuration: &pocketstation::graph::NodeConfig, + ) -> Result { + let resolved = resolve_configuration(&self.manifest, configuration)?; + Python::attach(|py| { + let result = (|| -> PyResult { + let configuration = python_configuration(py, &resolved)?; + let group = self + .factory + .bind(py) + .call_method1("preparation_group", (route_id.get(), configuration))?; + if group.is_none() { + return Ok(EndpointPreparationGroup::Route(route_id)); + } + let group = group.extract::()?; + if group.trim().is_empty() { + return Err(PyValueError::new_err( + "Connector preparation group cannot be empty", + )); + } + Ok(EndpointPreparationGroup::Shared(EndpointGroupId::new( + group, + ))) + })(); + result.map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare)) + }) + } + + fn prepare( + &self, + inputs: Vec, + ) -> Result, ConnectorError> { + Python::attach(|py| { + let mut worker_inputs = Vec::with_capacity(inputs.len()); + let mut descriptors = Vec::with_capacity(inputs.len()); + for input in inputs { + let resolved = + resolve_configuration(&self.manifest, input.context().node_configuration())?; + let descriptor = python_input_descriptor(py, &input, &resolved) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare))?; + let endpoint_id = input.context().endpoint_id().get(); + let connector_id = input + .context() + .connector_id() + .map_or(0, pocketstation::ConnectorId::get); + let route_id = input.context().route_context().route_id().get(); + let (receiver, _) = input.into_parts(); + descriptors.push(descriptor.clone_ref(py)); + worker_inputs.push(WorkerInput { + descriptor, + endpoint_id, + connector_id, + route_id, + receiver, + last_discontinuity_epoch: None, + }); + } + let worker = self + .factory + .bind(py) + .call_method1("prepare", (descriptors,)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare))? + .unbind(); + let idle_enabled = worker + .bind(py) + .getattr("_pocketstation_idle_enabled") + .and_then(|value| value.extract::()) + .unwrap_or(false); + Ok(Box::new(PythonConnectorWorker { + worker, + inputs: worker_inputs, + maximum_batch_items: self.maximum_batch_items, + active: Arc::new(AtomicBool::new(true)), + idle_enabled, + }) as Box) + }) + } +} + +struct WorkerInput { + descriptor: Py, + endpoint_id: u64, + connector_id: u64, + route_id: u64, + receiver: EndpointReceiver, + last_discontinuity_epoch: Option, +} + +enum PendingItem { + Audio { + input_index: usize, + frame: pocketstation::EndpointAudioFrame, + }, + Signal { + input_index: usize, + signal: Arc, + }, +} + +struct PythonConnectorWorker { + worker: Py, + inputs: Vec, + maximum_batch_items: usize, + active: Arc, + idle_enabled: bool, +} + +impl Drop for PythonConnectorWorker { + fn drop(&mut self) { + self.active.store(false, Ordering::Release); + } +} + +impl ConnectorWorker for PythonConnectorWorker { + fn run(mut self: Box, context: ConnectorContext) -> ConnectorRunOutcome { + if let Err(error) = + self.call_context_method("start", &context, ConnectorErrorStage::Startup) + { + return ConnectorRunOutcome::failure(error); + } + loop { + if context.is_abort_requested() { + break; + } + let pending = self.collect_batch(&context); + if !pending.is_empty() { + let amount = u64::try_from(pending.len()).unwrap_or(u64::MAX); + context.record_frame_received(amount); + match self.deliver_batch(pending, &context) { + Ok((delivered, dropped)) => { + context.record_frame_delivered(delivered); + context.record_frame_dropped(dropped); + } + Err(error) => return ConnectorRunOutcome::failure(error), + } + continue; + } + if context.shutdown_mode() == Some(EndpointShutdownMode::Drain) { + break; + } + if self.idle_enabled { + if let Err(error) = + self.call_context_method("idle", &context, ConnectorErrorStage::Delivery) + { + return ConnectorRunOutcome::failure(error); + } + } + let _ = context.wait_for_stop(WORKER_IDLE_WAIT); + } + let mode = context + .shutdown_mode() + .unwrap_or(EndpointShutdownMode::Abort); + match self.shutdown(mode, &context) { + Ok(()) => ConnectorRunOutcome::success(), + Err(error) => ConnectorRunOutcome::failure(error), + } + } + + fn cancel_preparation(self: Box) -> Result<(), ConnectorError> { + Python::attach(|py| { + let result = self + .worker + .bind(py) + .call_method0("cancel_preparation") + .map(|_| ()) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Prepare)); + self.active.store(false, Ordering::Release); + result + }) + } +} + +impl PythonConnectorWorker { + fn collect_batch(&mut self, context: &ConnectorContext) -> Vec { + let mut batch = Vec::with_capacity(self.maximum_batch_items); + while batch.len() < self.maximum_batch_items { + let mut progressed = false; + for (input_index, input) in self.inputs.iter_mut().enumerate() { + if batch.len() >= self.maximum_batch_items { + break; + } + let pending = match &mut input.receiver { + EndpointReceiver::Audio { receiver, .. } => receiver.try_recv().map(|frame| { + record_discontinuity( + context, + &mut input.last_discontinuity_epoch, + frame.lineage().discontinuity_epoch(), + ); + PendingItem::Audio { input_index, frame } + }), + EndpointReceiver::Signal(receiver) => receiver.try_recv().map(|signal| { + if let Some(lineage) = signal.lineage() { + record_discontinuity( + context, + &mut input.last_discontinuity_epoch, + lineage.discontinuity_epoch(), + ); + } + PendingItem::Signal { + input_index, + signal, + } + }), + }; + if let Some(pending) = pending { + batch.push(pending); + progressed = true; + } + } + if !progressed { + break; + } + } + batch + } + + fn deliver_batch( + &self, + pending: Vec, + context: &ConnectorContext, + ) -> Result<(u64, u64), ConnectorError> { + Python::attach(|py| { + let count = pending.len(); + let items = pending + .into_iter() + .map(|item| self.python_item(py, item)) + .collect::>>() + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + let outcome = self + .worker + .bind(py) + .call_method1("deliver_batch", (items, context)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Delivery))?; + parse_batch_outcomes(outcome, count) + }) + } + + fn python_item(&self, py: Python<'_>, item: PendingItem) -> PyResult> { + match item { + PendingItem::Audio { input_index, frame } => { + let input = &self.inputs[input_index]; + let frame = owned_endpoint_audio_frame_for_route( + frame, + input.endpoint_id, + input.connector_id, + input.route_id, + ); + let audio: Py = Py::new(py, python_audio_frame(py, frame))?; + Py::new( + py, + PythonConnectorItem { + kind: "audio", + input: input.descriptor.clone_ref(py), + audio: Some(audio), + signal: None, + }, + ) + } + PendingItem::Signal { + input_index, + signal, + } => { + let input = &self.inputs[input_index]; + let signal = Py::new(py, python_envelope(py, copy_envelope(&signal))?)?; + Py::new( + py, + PythonConnectorItem { + kind: "signal", + input: input.descriptor.clone_ref(py), + audio: None, + signal: Some(signal), + }, + ) + } + } + } + + fn call_context_method( + &self, + method: &str, + context: &ConnectorContext, + stage: ConnectorErrorStage, + ) -> Result<(), ConnectorError> { + Python::attach(|py| { + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, stage))?; + self.worker + .bind(py) + .call_method1(method, (context,)) + .map(|_| ()) + .map_err(|error| python_error(py, error, stage)) + }) + } + + fn shutdown( + &self, + mode: EndpointShutdownMode, + context: &ConnectorContext, + ) -> Result<(), ConnectorError> { + Python::attach(|py| { + let context = python_context(py, context, Arc::clone(&self.active)) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Shutdown))?; + let mode = match mode { + EndpointShutdownMode::Drain => "drain", + EndpointShutdownMode::Abort => "abort", + }; + self.worker + .bind(py) + .call_method1("shutdown", (mode, context)) + .map(|_| ()) + .map_err(|error| python_error(py, error, ConnectorErrorStage::Shutdown)) + }) + } +} + +fn python_input_descriptor( + py: Python<'_>, + input: &EndpointPortInput, + configuration: &pocketstation::connector::ResolvedConnectorConfiguration, +) -> PyResult> { + let configuration = configuration_values(configuration) + .into_iter() + .map(|(name, value)| Py::new(py, value).map(|value| (name, value))) + .collect::>>()?; + let signal = Py::new( + py, + PythonSignalSpec { + value: input.signal_spec().clone(), + }, + )?; + let media = Py::new( + py, + PythonMediaCaps { + value: *input.media(), + }, + )?; + let edge = Py::new( + py, + PythonEdgeContract { + value: *input.edge_contract(), + }, + )?; + Py::new( + py, + PythonConnectorInputDescriptor { + endpoint_id: input.context().endpoint_id().get(), + connector_id: input + .context() + .connector_id() + .map(pocketstation::ConnectorId::get), + route_id: input.context().route_context().route_id().get(), + port_name: input.port_name().to_owned(), + signal_wire_id: input.signal_spec().wire_id().to_owned(), + signal, + media, + edge, + configuration, + }, + ) +} + +fn python_configuration( + py: Python<'_>, + configuration: &pocketstation::connector::ResolvedConnectorConfiguration, +) -> PyResult> { + let values = PyDict::new(py); + for (name, value) in configuration_values(configuration) { + let value: Py = Py::new(py, value)?; + values.set_item(name, value)?; + } + Ok(values.unbind()) +} + +fn parse_batch_outcomes( + value: Bound<'_, PyAny>, + count: usize, +) -> Result<(u64, u64), ConnectorError> { + if value.is_none() { + return Ok((u64::try_from(count).unwrap_or(u64::MAX), 0)); + } + if let Ok(outcome) = value.extract::() { + return match outcome.as_str() { + "delivered" => Ok((u64::try_from(count).unwrap_or(u64::MAX), 0)), + "dropped" => Ok((0, u64::try_from(count).unwrap_or(u64::MAX))), + _ => Err(invalid_batch_outcome()), + }; + } + let outcomes = value + .extract::>() + .map_err(|_| invalid_batch_outcome())?; + if outcomes.len() != count { + return Err(internal_error( + "python.invalid_batch_outcome_count", + ConnectorErrorStage::Delivery, + "Python Connector deliver_batch() returned the wrong number of outcomes", + )); + } + let mut delivered = 0_u64; + let mut dropped = 0_u64; + for outcome in outcomes { + match outcome.as_str() { + "delivered" => delivered = delivered.saturating_add(1), + "dropped" => dropped = dropped.saturating_add(1), + _ => return Err(invalid_batch_outcome()), + } + } + Ok((delivered, dropped)) +} + +fn invalid_batch_outcome() -> ConnectorError { + internal_error( + "python.invalid_batch_outcome", + ConnectorErrorStage::Delivery, + "Python Connector deliver_batch() must return None, one outcome, or one outcome per item", + ) +} + +fn configuration_error( + error: pocketstation::connector::ConnectorConfigurationError, +) -> ConnectorError { + ConnectorError::new( + ConnectorErrorCode::new(error.code().as_str()).unwrap_or_else(|_| { + ConnectorErrorCode::new("connector.configuration.invalid") + .expect("valid internal error code") + }), + ConnectorErrorStage::Configuration, + ConnectorRetryability::RetryAfterReconfiguration, + error.to_string(), + ) + .unwrap_or_else(|_| { + internal_error( + "connector.configuration.invalid", + ConnectorErrorStage::Configuration, + "Connector configuration is invalid", + ) + }) +} + +fn resolve_configuration( + manifest: &pocketstation::connector::ConnectorManifest, + node: &pocketstation::graph::NodeConfig, +) -> Result { + let mut configuration = ConnectorConfiguration::new(); + for field in manifest.configuration().fields() { + let Some(encoded) = node.get(field.name()) else { + continue; + }; + let sensitive = node.is_sensitive(field.name()); + let value = match field.value_kind() { + ConnectorConfigurationValueKind::Text if !sensitive => { + ConnectorConfigurationValue::Text(encoded.to_owned()) + } + ConnectorConfigurationValueKind::Boolean if !sensitive => encoded + .parse() + .map(ConnectorConfigurationValue::Boolean) + .map_err(|_| invalid_encoded_configuration())?, + ConnectorConfigurationValueKind::SignedInteger if !sensitive => encoded + .parse() + .map(ConnectorConfigurationValue::SignedInteger) + .map_err(|_| invalid_encoded_configuration())?, + ConnectorConfigurationValueKind::UnsignedInteger if !sensitive => encoded + .parse() + .map(ConnectorConfigurationValue::UnsignedInteger) + .map_err(|_| invalid_encoded_configuration())?, + ConnectorConfigurationValueKind::DurationMilliseconds if !sensitive => encoded + .parse() + .map(ConnectorConfigurationValue::DurationMilliseconds) + .map_err(|_| invalid_encoded_configuration())?, + ConnectorConfigurationValueKind::ByteCount if !sensitive => encoded + .parse() + .map(ConnectorConfigurationValue::ByteCount) + .map_err(|_| invalid_encoded_configuration())?, + ConnectorConfigurationValueKind::Secret if sensitive => ConnectorSecret::new(encoded) + .map(ConnectorConfigurationValue::Secret) + .map_err(|_| invalid_encoded_configuration())?, + _ => return Err(invalid_encoded_configuration()), + }; + configuration.insert(field.name(), value); + } + manifest + .configuration() + .resolve(&configuration) + .map_err(configuration_error) +} + +fn invalid_encoded_configuration() -> ConnectorError { + internal_error( + "connector.configuration.invalid_representation", + ConnectorErrorStage::Configuration, + "Connector configuration has an invalid encoded representation", + ) +} + +fn record_discontinuity(context: &ConnectorContext, previous: &mut Option, current: u64) { + if previous.is_some_and(|value| value != current) { + context.record_discontinuity(1); + } + *previous = Some(current); +} + +pub(crate) fn register_worker_connector( + session: &Session, + manifest: &PythonConnectorManifest, + factory: Py, + maximum_batch_items: usize, +) -> PyResult { + if !(1..=MAXIMUM_BATCH_ITEMS).contains(&maximum_batch_items) { + return Err(PyValueError::new_err(format!( + "maximum_batch_items must be between 1 and {MAXIMUM_BATCH_ITEMS}" + ))); + } + let connector = Connector::new( + manifest.value.clone(), + Arc::new(PythonConnectorFactory { + factory, + manifest: manifest.value.clone(), + maximum_batch_items, + }), + ) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + session + .register_connector(connector) + .map(|registered| PythonRegisteredConnector { registered }) + .map_err(|error| PyRuntimeError::new_err(error.to_string())) +} diff --git a/native/src/lib.rs b/native/src/lib.rs index 67696e7..c7c184f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,8 +1,7 @@ -// The extension is a private cdylib: cross-owner items are crate-visible but -// intentionally not an external Rust API. #![allow(clippy::redundant_pub_crate)] pub(crate) mod audio_input; +pub(crate) mod connector; pub(crate) mod errors; pub(crate) mod extensions; pub(crate) mod graph; @@ -19,6 +18,7 @@ use pyo3::prelude::*; #[pymodule] fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { audio_input::register(module)?; + connector::register(module)?; extensions::register(module)?; sources::register(module)?; graph::register(module)?; diff --git a/native/src/observations.rs b/native/src/observations.rs index 7461010..b853bb3 100644 --- a/native/src/observations.rs +++ b/native/src/observations.rs @@ -135,6 +135,10 @@ pub(crate) struct PythonSessionFailure { #[pyo3(get)] error_class: Option, #[pyo3(get)] + error_code: Option, + #[pyo3(get)] + retryability: Option, + #[pyo3(get)] component: Option, #[pyo3(get)] message: Option, @@ -887,6 +891,8 @@ struct OwnedSessionFailure { stage: Option, operation: Option, error_class: Option, + error_code: Option, + retryability: Option, component: Option, message: Option, stem_id: Option, @@ -1287,7 +1293,7 @@ fn owned_session_event(event: &pocketstation::SessionEvent) -> OwnedSessionEvent failure.route_id().get(), failure.endpoint_id().get(), endpoint_failure_stage_name(failure.stage()).to_owned(), - failure.failure().message(), + failure.failure(), )); } pocketstation::SessionEventKind::Rollback(failure) => { @@ -1330,7 +1336,7 @@ fn owned_session_event(event: &pocketstation::SessionEvent) -> OwnedSessionEvent failure.route_id().get(), failure.endpoint_id().get(), endpoint_failure_stage_name(failure.stage()).to_owned(), - failure.failure().message(), + failure.failure(), ) })); output @@ -1409,13 +1415,25 @@ fn owned_endpoint_failure( route_id: u64, endpoint_id: u64, stage: String, - message: &str, + failure: &pocketstation::EndpointFailure, ) -> OwnedSessionFailure { + let retryability = failure.retryability().map(|value| { + match value { + pocketstation::EndpointFailureRetryability::Never => "never", + pocketstation::EndpointFailureRetryability::Retryable => "retryable", + pocketstation::EndpointFailureRetryability::ReconfigurationRequired => { + "retry-after-reconfiguration" + } + } + .to_owned() + }); OwnedSessionFailure { kind: "endpoint".to_owned(), stage: Some(stage), error_class: Some("endpoint-failure".to_owned()), - message: Some(message.to_owned()), + error_code: failure.code().map(str::to_owned), + retryability, + message: Some(failure.message().to_owned()), route_id: Some(route_id), endpoint_id: Some(endpoint_id), ..OwnedSessionFailure::default() @@ -1503,6 +1521,8 @@ pub(crate) fn python_session_event( stage: failure.stage, operation: failure.operation, error_class: failure.error_class, + error_code: failure.error_code, + retryability: failure.retryability, component: failure.component, message: failure.message, stem_id: failure.stem_id, diff --git a/native/src/session.rs b/native/src/session.rs index b26d619..939fdf8 100644 --- a/native/src/session.rs +++ b/native/src/session.rs @@ -10,6 +10,10 @@ use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use crate::audio_input::{configuration as audio_input_configuration, PythonAudioInput}; +use crate::connector::{ + declare_connector, register_connector, register_worker_connector, PythonConnectorConfiguration, + PythonConnectorManifest, PythonRegisteredConnector, +}; use crate::errors::{ coded_reason, native_extension_error, session_error, session_start_error, validate_nonempty, }; @@ -353,6 +357,34 @@ impl PythonSession { }) } + fn register_connector( + &self, + manifest: &PythonConnectorManifest, + factory: Py, + ) -> PyResult { + self.with_session(|session| register_connector(session, manifest, factory)) + } + + fn register_connector_worker( + &self, + manifest: &PythonConnectorManifest, + factory: Py, + maximum_batch_items: usize, + ) -> PyResult { + self.with_session(|session| { + register_worker_connector(session, manifest, factory, maximum_batch_items) + }) + } + + fn declare_connector( + &self, + registered: &PythonRegisteredConnector, + configuration: &PythonConnectorConfiguration, + edge: &PythonEdgeContract, + ) -> PyResult { + self.with_session(|session| declare_connector(registered, session, configuration, edge)) + } + fn register_sidecar(&self, spec: &PythonSidecarProcessSpec) -> PyResult { self.with_session(|session| { session.register_sidecar(spec.to_core()).map_err(|error| { diff --git a/native/src/signals.rs b/native/src/signals.rs index 9131731..2d76992 100644 --- a/native/src/signals.rs +++ b/native/src/signals.rs @@ -700,7 +700,7 @@ fn copy_lineage(value: pocketstation::SignalLineage) -> OwnedSignalLineage { } } -fn copy_envelope(value: &SignalEnvelope) -> OwnedSignalEnvelope { +pub(crate) fn copy_envelope(value: &SignalEnvelope) -> OwnedSignalEnvelope { let payload = match value.payload() { SignalPayload::Audio(frame) => OwnedSignalPayload::Audio(OwnedSignalAudio { samples_f32le: f32_samples_to_le_bytes(frame.samples()), @@ -770,7 +770,10 @@ fn python_lineage(py: Python<'_>, value: OwnedSignalLineage) -> PyResult, value: OwnedSignalEnvelope) -> PyResult { +pub(crate) fn python_envelope( + py: Python<'_>, + value: OwnedSignalEnvelope, +) -> PyResult { let timing = python_timing(py, value.timing)?; let lineage = value .lineage diff --git a/native/src/streams.rs b/native/src/streams.rs index 4a00ba1..0f5d159 100644 --- a/native/src/streams.rs +++ b/native/src/streams.rs @@ -229,35 +229,77 @@ pub(crate) fn python_audio_batch( }; let frames = owned .into_iter() - .map(|frame| { - Py::new( - py, - PythonAudioFrame { - sample_count: frame.sample_count, - samples_f32le: PyBytes::new(py, &frame.samples_f32le).unbind(), - sample_rate_hz: frame.sample_rate_hz, - channel_count: frame.channel_count, - session_id: frame.session_id, - stream_id: frame.stream_id, - source_id: frame.source_id, - stem_id: frame.stem_id, - clock_id: frame.clock_id, - sequence_num: frame.sequence_num, - timestamp_start_ns: frame.timestamp_start_ns, - duration_ns: frame.duration_ns, - source_generation: frame.source_generation, - discontinuity_epoch: frame.discontinuity_epoch, - permission_epoch: frame.permission_epoch, - endpoint_id: frame.endpoint_id, - connector_id: frame.connector_id, - route_id: frame.route_id, - }, - ) - }) + .map(|frame| Py::new(py, python_audio_frame(py, frame))) .collect::>>()?; Ok(Some(PythonAudioBatch { frames })) } +pub(crate) fn owned_endpoint_audio_frame( + frame: pocketstation::EndpointAudioFrame, + input: &pocketstation::connector::ConnectorInputDescriptor, +) -> OwnedAudioFrame { + owned_endpoint_audio_frame_for_route( + frame, + input.endpoint_id().get(), + input + .connector_id() + .map_or(0, pocketstation::ConnectorId::get), + input.route_id().get(), + ) +} + +pub(crate) fn owned_endpoint_audio_frame_for_route( + frame: pocketstation::EndpointAudioFrame, + endpoint_id: u64, + connector_id: u64, + route_id: u64, +) -> OwnedAudioFrame { + let lineage = frame.lineage(); + OwnedAudioFrame { + samples_f32le: f32_samples_to_le_bytes(frame.samples()), + sample_count: frame.samples().len(), + sample_rate_hz: frame.sample_rate_hz(), + channel_count: frame.channels(), + session_id: lineage.session_id().get(), + stream_id: frame.stream_id().get(), + source_id: lineage.source_id().get(), + stem_id: lineage.stem_id().get(), + clock_id: lineage.clock_id().get(), + sequence_num: lineage.sequence_number(), + timestamp_start_ns: lineage.timestamp_start_ns(), + duration_ns: lineage.duration_ns(), + source_generation: lineage.source_generation(), + discontinuity_epoch: lineage.discontinuity_epoch(), + permission_epoch: lineage.permission_epoch(), + endpoint_id, + connector_id, + route_id, + } +} + +pub(crate) fn python_audio_frame(py: Python<'_>, frame: OwnedAudioFrame) -> PythonAudioFrame { + PythonAudioFrame { + sample_count: frame.sample_count, + samples_f32le: PyBytes::new(py, &frame.samples_f32le).unbind(), + sample_rate_hz: frame.sample_rate_hz, + channel_count: frame.channel_count, + session_id: frame.session_id, + stream_id: frame.stream_id, + source_id: frame.source_id, + stem_id: frame.stem_id, + clock_id: frame.clock_id, + sequence_num: frame.sequence_num, + timestamp_start_ns: frame.timestamp_start_ns, + duration_ns: frame.duration_ns, + source_generation: frame.source_generation, + discontinuity_epoch: frame.discontinuity_epoch, + permission_epoch: frame.permission_epoch, + endpoint_id: frame.endpoint_id, + connector_id: frame.connector_id, + route_id: frame.route_id, + } +} + fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { let mut bytes = Vec::with_capacity(std::mem::size_of_val(samples)); for sample in samples { diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index e0cc7d5..1debb55 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -5,6 +5,45 @@ from . import aio as aio from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource from .capture import Capture, capture +from .connector import ( + Connector, + ConnectorBatchOutcome, + ConnectorCapability, + ConnectorConfigurationConstraint, + ConnectorConfigurationField, + ConnectorConfigurationInput, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorContext, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, + ConnectorDriver, + ConnectorDriverBuilder, + ConnectorDriverFactory, + ConnectorError, + ConnectorErrorSnapshot, + ConnectorErrorStage, + ConnectorFactory, + ConnectorHandler, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorObservations, + ConnectorPreparationGroup, + ConnectorRecovery, + ConnectorRequirement, + ConnectorRetryability, + ConnectorRuntimeObservations, + ConnectorServiceStatus, + ConnectorShutdownMode, + ConnectorWorker, + ConnectorWorkerBuilder, + RegisteredConnector, + connector, +) from .control import ( ControlClient, ControlPlaneError, @@ -79,6 +118,7 @@ AudioReentryMetrics, DerivedRouteMetrics, EdgeMetrics, + EndpointFailureRetryability, EndpointFailureStage, EndpointMetrics, EndpointObservationStage, @@ -207,6 +247,41 @@ "ChannelLayout", "ClockDomain", "Codec", + "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", + "ConnectorWorker", + "ConnectorWorkerBuilder", "ControlClient", "ControlPlaneError", "CopyPolicy", @@ -221,6 +296,7 @@ "Endpoint", "EndpointConfiguration", "EndpointDescriptor", + "EndpointFailureRetryability", "EndpointFailureStage", "EndpointMetrics", "EndpointObservationStage", @@ -266,6 +342,7 @@ "RecordingOutcome", "RecordingState", "RecordingStemOutcome", + "RegisteredConnector", "RelayError", "RelayPublishOutcome", "RelayPublisher", @@ -350,6 +427,7 @@ "aio", "application_capture_available", "capture", + "connector", "discover_sources", "microphone_permission_observation", ] diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index f0a373c..073ceb8 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -227,6 +227,158 @@ class _EndpointDescriptor: input_edge: _EdgeContract | None = None, ) -> None: ... +class _ConnectorConfigurationValue: + @staticmethod + def text(value: str) -> _ConnectorConfigurationValue: ... + @staticmethod + def boolean(value: bool) -> _ConnectorConfigurationValue: ... + @staticmethod + def signed_integer(value: int) -> _ConnectorConfigurationValue: ... + @staticmethod + def unsigned_integer(value: int) -> _ConnectorConfigurationValue: ... + @staticmethod + def duration_milliseconds(value: int) -> _ConnectorConfigurationValue: ... + @staticmethod + def byte_count(value: int) -> _ConnectorConfigurationValue: ... + @staticmethod + def secret(value: str) -> _ConnectorConfigurationValue: ... + kind: str + def expose_secret(self) -> str: ... + def as_text(self) -> str | None: ... + def as_boolean(self) -> bool | None: ... + def as_signed_integer(self) -> int | None: ... + def as_unsigned_integer(self) -> int | None: ... + +class _ConnectorConfigurationConstraint: + @staticmethod + def non_empty() -> _ConnectorConfigurationConstraint: ... + @staticmethod + def text_length_bytes( + minimum: int, + maximum: int, + ) -> _ConnectorConfigurationConstraint: ... + @staticmethod + def signed_range( + minimum: int, + maximum: int, + ) -> _ConnectorConfigurationConstraint: ... + @staticmethod + def unsigned_range( + minimum: int, + maximum: int, + ) -> _ConnectorConfigurationConstraint: ... + @staticmethod + def one_of(values: list[str]) -> _ConnectorConfigurationConstraint: ... + +class _ConnectorConfigurationField: + def __init__( + self, + name: str, + kind: str, + requirement: str, + documentation: str, + default: _ConnectorConfigurationValue | None = None, + constraints: list[_ConnectorConfigurationConstraint] = [], + deprecation: str | None = None, + ) -> None: ... + +class _ConnectorConfigurationSchema: + def __init__( + self, + revision: int = 1, + fields: list[_ConnectorConfigurationField] = [], + ) -> None: ... + +class _ConnectorConfiguration: + def __init__( + self, + entries: list[tuple[str, _ConnectorConfigurationValue]] = [], + ) -> None: ... + +class _ConnectorManifest: + def __init__( + self, + operator_id: str, + node_type_id: str, + package_version: str, + inputs: list[_PortSpec], + configuration: _ConnectorConfigurationSchema, + manifest_revision: int = 1, + startup_timeout_ms: int = 5_000, + probe_interval_ms: int = 100, + success_threshold: int = 1, + failure_threshold: int = 1, + capabilities: list[tuple[str, str]] = [], + requirements: list[tuple[str, bool, str]] = [], + ) -> None: ... + +class ConnectorInputDescriptor: + endpoint_id: int + connector_id: int | None + route_id: int + port_name: str + signal_wire_id: str + signal: _SignalSpec + media: _MediaCaps + edge: _EdgeContract + configuration: dict[str, _ConnectorConfigurationValue] + +class ConnectorItem: + kind: str + input: ConnectorInputDescriptor + audio: AudioFrame | None + signal: _SignalEnvelope | None + +class ConnectorContext: + stop_requested: bool + shutdown_mode: str | None + def set_ready(self) -> bool: ... + def set_not_ready(self, reason_code: str | None = None) -> bool: ... + def set_degraded(self, reason_code: str) -> bool: ... + def set_healthy(self) -> bool: ... + def set_reconnecting(self, reason_code: str) -> bool: ... + def set_connected(self) -> bool: ... + def record_retry(self) -> None: ... + +class _ConnectorErrorSnapshot: + code: str + stage: str + retryability: str + message: str + +class _ConnectorServiceStatus: + delivery_readiness: str + health: str + recovery: str + readiness_reason_code: str | None + health_reason_code: str | None + recovery_reason_code: str | None + revision: int + last_transition_elapsed_ns: int + accepts_delivery: bool + +class _ConnectorObservations: + service_status: _ConnectorServiceStatus + status_transitions_total: int + retry_attempts_total: int + reconnects_total: int + failures_total: int + last_error: _ConnectorErrorSnapshot | None + +class _ConnectorRuntimeObservations: + endpoint_ids: list[int] + connector: _ConnectorObservations + frames_received_total: int + frames_delivered_total: int + frames_dropped_total: int + discontinuities_total: int + endpoint_failures_total: int + +class _RegisteredConnector: + session_id: int + def observations(self) -> list[_ConnectorRuntimeObservations]: ... + def observation(self, endpoint: Endpoint) -> _ConnectorObservations | None: ... + class Endpoint: id: int session_id: int @@ -425,6 +577,8 @@ class _SessionFailure: stage: str | None operation: str | None error_class: str | None + error_code: str | None + retryability: str | None component: str | None message: str | None stem_id: int | None @@ -858,6 +1012,23 @@ class Session: ) -> Endpoint: ... def browser(self, receiver_uri: str) -> Endpoint: ... def polled_audio(self) -> Endpoint: ... + def register_connector( + self, + manifest: _ConnectorManifest, + factory: object, + ) -> _RegisteredConnector: ... + def register_connector_worker( + self, + manifest: _ConnectorManifest, + factory: object, + maximum_batch_items: int, + ) -> _RegisteredConnector: ... + def declare_connector( + self, + registered: _RegisteredConnector, + configuration: _ConnectorConfiguration, + edge: _EdgeContract, + ) -> Endpoint: ... def load_native_extension_library( self, path: Path, diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index fa36a41..8c9357a 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -2,6 +2,19 @@ from .audio_input import AudioInput, PcmSource from .capture import Capture, capture +from .connector import ( + Connector, + ConnectorDeadlines, + ConnectorDriver, + ConnectorDriverBuilder, + ConnectorDriverFactory, + ConnectorFactory, + ConnectorHandler, + ConnectorWorker, + ConnectorWorkerBuilder, + RegisteredConnector, + connector, +) from .control import ControlClient from .extensions import ( ExtensionAbiVersion, @@ -27,6 +40,15 @@ "AudioInput", "AudioStream", "Capture", + "Connector", + "ConnectorDeadlines", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorWorker", + "ConnectorWorkerBuilder", "ControlClient", "EventStream", "ExtensionAbiVersion", @@ -37,6 +59,7 @@ "NativeExtensionLibrary", "NativeExtensionRegistration", "PcmSource", + "RegisteredConnector", "RelaySession", "RunningSession", "Session", @@ -45,6 +68,7 @@ "SignalStream", "application_capture_available", "capture", + "connector", "discover_sources", "microphone_permission_observation", ] diff --git a/python/pocketstation/aio/connector.py b/python/pocketstation/aio/connector.py new file mode 100644 index 0000000..6d04680 --- /dev/null +++ b/python/pocketstation/aio/connector.py @@ -0,0 +1,534 @@ +"""Bounded asyncio Connector authoring over the canonical Core worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine, Mapping, Sequence +from concurrent.futures import CancelledError as FutureCancelledError +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from dataclasses import dataclass +from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable + +from ..connector import ( + Connector as SyncConnector, +) +from ..connector import ( + ConnectorBatchOutcome, + ConnectorConfigurationInput, + ConnectorConfigurationValue, + ConnectorContext, + ConnectorDeliveryOutcome, + ConnectorError, + ConnectorErrorStage, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorObservations, + ConnectorPreparationGroup, + ConnectorRetryability, + ConnectorRuntimeObservations, + ConnectorShutdownMode, +) +from ..connector import ( + ConnectorDriver as SyncConnectorDriver, +) +from ..connector import ( + ConnectorWorker as SyncConnectorWorker, +) +from ..connector import ( + RegisteredConnector as SyncRegisteredConnector, +) +from ..graph import EdgeContract, Endpoint + + +@dataclass(frozen=True, slots=True) +class ConnectorDeadlines: + """Finite waits applied while a Core worker awaits asyncio provider work.""" + + prepare_s: float = 5.0 + start_s: float = 5.0 + delivery_s: float = 30.0 + shutdown_s: float = 5.0 + + def __post_init__(self) -> None: + for name, value in ( + ("prepare_s", self.prepare_s), + ("start_s", self.start_s), + ("delivery_s", self.delivery_s), + ("shutdown_s", self.shutdown_s), + ): + if not 0 < value <= 300: + raise ValueError(f"{name} must be greater than 0 and at most 300") + + +class ConnectorDriver: + """Async provider behavior; Core retains queues and Endpoint lifecycle.""" + + async def start(self, context: ConnectorContext) -> None: + context.set_ready() + + async def deliver( + self, item: ConnectorItem, context: ConnectorContext + ) -> ConnectorDeliveryOutcome | None: + raise NotImplementedError + + async def idle(self, context: ConnectorContext) -> None: + """Optional finite idle work; override only when required.""" + + async def shutdown( + self, mode: ConnectorShutdownMode, context: ConnectorContext + ) -> None: + """Finalize provider state after Core requests drain or abort.""" + + async def cancel_preparation(self) -> None: + """Release prepared state during transactional startup rollback.""" + + +@runtime_checkable +class ConnectorDriverFactory(Protocol): + async def prepare( + self, inputs: Sequence[ConnectorInputDescriptor] + ) -> ConnectorDriver: ... + + +ConnectorDriverBuilder: TypeAlias = Callable[ + [Sequence[ConnectorInputDescriptor]], Coroutine[Any, Any, ConnectorDriver] +] +ConnectorHandler: TypeAlias = Callable[ + [ConnectorItem, ConnectorContext], + Coroutine[Any, Any, ConnectorDeliveryOutcome | None], +] +_Result = TypeVar("_Result") + + +class ConnectorWorker: + """Advanced asyncio provider receiving finite native-owned batches.""" + + async def start(self, context: ConnectorContext) -> None: + context.set_ready() + + async def deliver_batch( + self, items: Sequence[ConnectorItem], context: ConnectorContext + ) -> ConnectorBatchOutcome: + raise NotImplementedError + + async def idle(self, context: ConnectorContext) -> None: + """Optional finite idle work; override only when required.""" + + async def shutdown( + self, mode: ConnectorShutdownMode, context: ConnectorContext + ) -> None: + """Finalize provider state after Core requests drain or abort.""" + + async def cancel_preparation(self) -> None: + """Release prepared state during transactional startup rollback.""" + + +@runtime_checkable +class ConnectorFactory(Protocol): + async def prepare( + self, inputs: Sequence[ConnectorInputDescriptor] + ) -> ConnectorWorker: ... + + +ConnectorWorkerBuilder: TypeAlias = Callable[ + [Sequence[ConnectorInputDescriptor]], Coroutine[Any, Any, ConnectorWorker] +] + + +class _HandlerDriver(ConnectorDriver): + __slots__ = ("_handler",) + + def __init__(self, handler: ConnectorHandler) -> None: + self._handler = handler + + async def deliver( + self, item: ConnectorItem, context: ConnectorContext + ) -> ConnectorDeliveryOutcome | None: + return await self._handler(item, context) + + +class _DriverAdapter(SyncConnectorDriver): + __slots__ = ( + "_deadlines", + "_driver", + "_loop", + "_pocketstation_idle_enabled", + ) + + def __init__( + self, + driver: ConnectorDriver, + loop: asyncio.AbstractEventLoop, + deadlines: ConnectorDeadlines, + ) -> None: + self._driver = driver + self._loop = loop + self._deadlines = deadlines + idle = getattr(type(driver), "idle", None) + self._pocketstation_idle_enabled = ( + idle is not None and idle is not ConnectorDriver.idle + ) + + def start(self, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._driver.start(context), + timeout_s=self._deadlines.start_s, + stage=ConnectorErrorStage.STARTUP, + ) + + def deliver( + self, item: ConnectorItem, context: ConnectorContext + ) -> ConnectorDeliveryOutcome | None: + return _wait_for_provider( + self._loop, + self._driver.deliver(item, context), + timeout_s=self._deadlines.delivery_s, + stage=ConnectorErrorStage.DELIVERY, + ) + + def idle(self, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._driver.idle(context), + timeout_s=self._deadlines.delivery_s, + stage=ConnectorErrorStage.DELIVERY, + ) + + def shutdown(self, mode: ConnectorShutdownMode, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._driver.shutdown(mode, context), + timeout_s=self._deadlines.shutdown_s, + stage=ConnectorErrorStage.SHUTDOWN, + ) + + def cancel_preparation(self) -> None: + _wait_for_provider( + self._loop, + self._driver.cancel_preparation(), + timeout_s=self._deadlines.shutdown_s, + stage=ConnectorErrorStage.PREPARE, + ) + + +class _FactoryAdapter: + __slots__ = ("_deadlines", "_factory", "_loop") + + def __init__( + self, + factory: ConnectorDriverFactory | ConnectorDriverBuilder, + loop: asyncio.AbstractEventLoop, + deadlines: ConnectorDeadlines, + ) -> None: + self._factory = factory + self._loop = loop + self._deadlines = deadlines + + def prepare(self, inputs: Sequence[ConnectorInputDescriptor]) -> _DriverAdapter: + prepare = getattr(self._factory, "prepare", None) + awaitable = ( + self._factory(inputs) # type: ignore[operator] + if prepare is None + else prepare(inputs) + ) + driver = _wait_for_provider( + self._loop, + awaitable, + timeout_s=self._deadlines.prepare_s, + stage=ConnectorErrorStage.PREPARE, + ) + if not hasattr(driver, "deliver"): + raise TypeError( + "async Connector factory must return a driver with deliver()" + ) + return _DriverAdapter(driver, self._loop, self._deadlines) + + def preparation_group( + self, + route_id: int, + configuration: Mapping[str, ConnectorConfigurationValue], + ) -> str | None: + group: ConnectorPreparationGroup | None = getattr( + self._factory, "preparation_group", None + ) + return None if group is None else group(route_id, configuration) + + +class _WorkerAdapter(SyncConnectorWorker): + __slots__ = ( + "_deadlines", + "_loop", + "_pocketstation_idle_enabled", + "_worker", + ) + + def __init__( + self, + worker: ConnectorWorker, + loop: asyncio.AbstractEventLoop, + deadlines: ConnectorDeadlines, + ) -> None: + self._worker = worker + self._loop = loop + self._deadlines = deadlines + idle = getattr(type(worker), "idle", None) + self._pocketstation_idle_enabled = ( + idle is not None and idle is not ConnectorWorker.idle + ) + + def start(self, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._worker.start(context), + timeout_s=self._deadlines.start_s, + stage=ConnectorErrorStage.STARTUP, + ) + + def deliver_batch( + self, items: Sequence[ConnectorItem], context: ConnectorContext + ) -> ConnectorBatchOutcome: + return _wait_for_provider( + self._loop, + self._worker.deliver_batch(items, context), + timeout_s=self._deadlines.delivery_s, + stage=ConnectorErrorStage.DELIVERY, + ) + + def idle(self, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._worker.idle(context), + timeout_s=self._deadlines.delivery_s, + stage=ConnectorErrorStage.DELIVERY, + ) + + def shutdown(self, mode: ConnectorShutdownMode, context: ConnectorContext) -> None: + _wait_for_provider( + self._loop, + self._worker.shutdown(mode, context), + timeout_s=self._deadlines.shutdown_s, + stage=ConnectorErrorStage.SHUTDOWN, + ) + + def cancel_preparation(self) -> None: + _wait_for_provider( + self._loop, + self._worker.cancel_preparation(), + timeout_s=self._deadlines.shutdown_s, + stage=ConnectorErrorStage.PREPARE, + ) + + +class _WorkerFactoryAdapter: + __slots__ = ("_deadlines", "_factory", "_loop") + + def __init__( + self, + factory: ConnectorFactory | ConnectorWorkerBuilder, + loop: asyncio.AbstractEventLoop, + deadlines: ConnectorDeadlines, + ) -> None: + self._factory = factory + self._loop = loop + self._deadlines = deadlines + + def prepare(self, inputs: Sequence[ConnectorInputDescriptor]) -> _WorkerAdapter: + prepare = getattr(self._factory, "prepare", None) + awaitable = ( + self._factory(inputs) # type: ignore[operator] + if prepare is None + else prepare(inputs) + ) + worker = _wait_for_provider( + self._loop, + awaitable, + timeout_s=self._deadlines.prepare_s, + stage=ConnectorErrorStage.PREPARE, + ) + if not hasattr(worker, "deliver_batch"): + raise TypeError( + "async Connector factory must return a worker with deliver_batch()" + ) + return _WorkerAdapter(worker, self._loop, self._deadlines) + + def preparation_group( + self, + route_id: int, + configuration: Mapping[str, ConnectorConfigurationValue], + ) -> str | None: + group: ConnectorPreparationGroup | None = getattr( + self._factory, "preparation_group", None + ) + return None if group is None else group(route_id, configuration) + + +@dataclass(frozen=True, slots=True) +class Connector: + """Reusable asyncio provider implementation bound at Session registration.""" + + manifest: ConnectorManifest + factory: ( + ConnectorDriverFactory + | ConnectorDriverBuilder + | ConnectorFactory + | ConnectorWorkerBuilder + ) + deadlines: ConnectorDeadlines = ConnectorDeadlines() + maximum_batch_items: int | None = None + + @classmethod + def with_driver( + cls, + manifest: ConnectorManifest, + factory: ConnectorDriverFactory | ConnectorDriverBuilder, + *, + deadlines: ConnectorDeadlines | None = None, + ) -> Connector: + return cls(manifest, factory, deadlines or ConnectorDeadlines()) + + @classmethod + def from_handler( + cls, + manifest: ConnectorManifest, + handler: ConnectorHandler, + *, + deadlines: ConnectorDeadlines | None = None, + ) -> Connector: + async def prepare( + _inputs: Sequence[ConnectorInputDescriptor], + ) -> ConnectorDriver: + return _HandlerDriver(handler) + + return cls.with_driver(manifest, prepare, deadlines=deadlines) + + @classmethod + def with_worker( + cls, + manifest: ConnectorManifest, + factory: ConnectorFactory | ConnectorWorkerBuilder, + *, + maximum_batch_items: int = 32, + deadlines: ConnectorDeadlines | None = None, + ) -> Connector: + if not 1 <= maximum_batch_items <= 1_024: + raise ValueError("maximum_batch_items must be between 1 and 1024") + return cls( + manifest, + factory, + deadlines or ConnectorDeadlines(), + maximum_batch_items, + ) + + def _bind(self, loop: asyncio.AbstractEventLoop) -> SyncConnector: + if not loop.is_running(): + raise RuntimeError("async Connector requires a running event loop") + if self.maximum_batch_items is None: + return SyncConnector.with_driver( + self.manifest, + _FactoryAdapter( + self.factory, # type: ignore[arg-type] + loop, + self.deadlines, + ), + ) + return SyncConnector.with_worker( + self.manifest, + _WorkerFactoryAdapter( + self.factory, # type: ignore[arg-type] + loop, + self.deadlines, + ), + maximum_batch_items=self.maximum_batch_items, + ) + + +class RegisteredConnector: + """Async observation view over one Core-registered Connector.""" + + __slots__ = ("_registered",) + + def __init__(self, registered: SyncRegisteredConnector) -> None: + self._registered = registered + + @property + def session_id(self) -> int: + return self._registered.session_id + + def declare( + self, + configuration: ConnectorConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + return self._registered.declare(configuration, edge=edge) + + async def observations(self) -> tuple[ConnectorRuntimeObservations, ...]: + return await asyncio.to_thread(self._registered.observations) + + async def observation(self, endpoint: Endpoint) -> ConnectorObservations | None: + return await asyncio.to_thread(self._registered.observation, endpoint) + + +def connector( + manifest: ConnectorManifest, + *, + deadlines: ConnectorDeadlines | None = None, +) -> Callable[[ConnectorHandler], Connector]: + """Decorate one coroutine handler into a bounded asyncio Connector.""" + + def define(handler: ConnectorHandler) -> Connector: + return Connector.from_handler(manifest, handler, deadlines=deadlines) + + return define + + +def _wait_for_provider( + loop: asyncio.AbstractEventLoop, + awaitable: Coroutine[Any, Any, _Result], + *, + timeout_s: float, + stage: ConnectorErrorStage, +) -> _Result: + try: + future: Future[_Result] = asyncio.run_coroutine_threadsafe(awaitable, loop) + except RuntimeError as error: + awaitable.close() + raise ConnectorError( + "asyncio Connector event loop is not available", + code="python.async.loop_unavailable", + stage=stage, + ) from error + try: + return future.result(timeout_s) + except FutureTimeoutError as error: + future.cancel() + raise ConnectorError( + f"asyncio Connector operation exceeded {timeout_s:g} seconds", + code="python.async.timeout", + stage=stage, + retryability=ConnectorRetryability.RETRYABLE, + ) from error + except FutureCancelledError as error: + raise ConnectorError( + "asyncio Connector operation was cancelled", + code="python.async.cancelled", + stage=stage, + ) from error + + +__all__ = [ + "Connector", + "ConnectorDeadlines", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorWorker", + "ConnectorWorkerBuilder", + "RegisteredConnector", + "connector", +] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index a00792f..d883cc8 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -20,6 +20,8 @@ ) from ..audio_input import AudioInputConfig from ..audio_input import PcmSource as SyncPcmSource +from ..connector import Connector as SyncConnector +from ..connector import RegisteredConnector as SyncRegisteredConnector from ..errors import PocketStationError, _native_call, _normalize_native_error from ..extensions import NativeExtensionLibrary from ..graph import ( @@ -37,6 +39,7 @@ from ..signal import BusSubscription from ..sources import Source from .audio_input import AudioInput, PcmSource +from .connector import Connector, RegisteredConnector from .observations import EventStream from .sidecar import SidecarConnection from .streams import AudioStream, SignalStream @@ -316,6 +319,32 @@ def polled_audio(self) -> Endpoint: """Declare the bounded managed-language polling endpoint.""" return _native_call(lambda: Endpoint(self._native.polled_audio())) + def register_connector( + self, connector: Connector | SyncConnector + ) -> RegisteredConnector: + """Register an asyncio or synchronous in-process Connector.""" + bound = ( + connector._bind(asyncio.get_running_loop()) + if isinstance(connector, Connector) + else connector + ) + maximum_batch_items = bound.maximum_batch_items + if maximum_batch_items is None: + native = _native_call( + lambda: self._native.register_connector( + bound.manifest._native, bound._native_factory + ) + ) + else: + native = _native_call( + lambda: self._native.register_connector_worker( + bound.manifest._native, + bound._native_factory, + maximum_batch_items, + ) + ) + return RegisteredConnector(SyncRegisteredConnector(self, bound, native)) + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: """Register a bounded PKSS child to spawn during transactional start.""" sidecar_id = _native_call( @@ -386,4 +415,4 @@ async def _native_async(operation: Callable[[], _Result]) -> _Result: raise _normalize_native_error(error) from error -__all__ = ["RunningSession", "Session"] +__all__ = ["Connector", "RegisteredConnector", "RunningSession", "Session"] diff --git a/python/pocketstation/connector.py b/python/pocketstation/connector.py new file mode 100644 index 0000000..55f87ee --- /dev/null +++ b/python/pocketstation/connector.py @@ -0,0 +1,1061 @@ +"""In-process Python Connector authoring over the canonical Core worker.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Protocol, TypeAlias, cast, runtime_checkable + +from ._native import AudioFrame +from ._native import ConnectorContext as _NativeConnectorContext +from ._native import ConnectorInputDescriptor as _NativeConnectorInputDescriptor +from ._native import ConnectorItem as _NativeConnectorItem +from ._native import Session as _NativeSession +from ._native import _ConnectorConfiguration as _NativeConnectorConfiguration +from ._native import ( + _ConnectorConfigurationConstraint as _NativeConnectorConfigurationConstraint, +) +from ._native import _ConnectorConfigurationField as _NativeConnectorConfigurationField +from ._native import ( + _ConnectorConfigurationSchema as _NativeConnectorConfigurationSchema, +) +from ._native import _ConnectorConfigurationValue as _NativeConnectorConfigurationValue +from ._native import _ConnectorManifest as _NativeConnectorManifest +from ._native import _ConnectorObservations as _NativeConnectorObservations +from ._native import ( + _ConnectorRuntimeObservations as _NativeConnectorRuntimeObservations, +) +from ._native import _RegisteredConnector as _NativeRegisteredConnector +from .errors import PocketStationError, _native_call +from .graph import ( + EdgeContract, + Endpoint, + MediaCaps, + Multiplicity, + PortDirection, + PortSpec, + SignalSpec, +) +from .signal import SignalEnvelope + + +class _SessionOwner(Protocol): + _native: _NativeSession + + +class ConnectorConfigurationValueKind(StrEnum): + TEXT = "text" + BOOLEAN = "boolean" + SIGNED_INTEGER = "signed-integer" + UNSIGNED_INTEGER = "unsigned-integer" + DURATION_MILLISECONDS = "duration-milliseconds" + BYTE_COUNT = "byte-count" + SECRET = "secret" + + +class ConnectorConfigurationRequirement(StrEnum): + REQUIRED = "required" + OPTIONAL = "optional" + DEFAULT = "default" + + +class ConnectorErrorStage(StrEnum): + CONFIGURATION = "configuration" + PREPARE = "prepare" + STARTUP = "startup" + READINESS = "readiness" + DELIVERY = "delivery" + RETRY = "retry" + SHUTDOWN = "shutdown" + JOIN = "join" + + +class ConnectorRetryability(StrEnum): + NEVER = "never" + RETRYABLE = "retryable" + RETRY_AFTER_RECONFIGURATION = "retry-after-reconfiguration" + + +class ConnectorDeliveryOutcome(StrEnum): + DELIVERED = "delivered" + DROPPED = "dropped" + + +class ConnectorShutdownMode(StrEnum): + DRAIN = "drain" + ABORT = "abort" + + +class ConnectorDeliveryReadiness(StrEnum): + NOT_READY = "not-ready" + READY = "ready" + + +class ConnectorHealth(StrEnum): + HEALTHY = "healthy" + DEGRADED = "degraded" + + +class ConnectorRecovery(StrEnum): + IDLE = "idle" + RECONNECTING = "reconnecting" + + +class ConnectorError(PocketStationError): + """Structured provider failure preserved through Core finalization.""" + + def __init__( + self, + message: str, + *, + code: str, + stage: ConnectorErrorStage, + retryability: ConnectorRetryability = ConnectorRetryability.NEVER, + ) -> None: + super().__init__(message, code) + self.message = message + self.stage = stage.value + self.retryability = retryability.value + + +@dataclass(frozen=True, slots=True) +class ConnectorErrorSnapshot: + """One structured provider failure retained by Core observations.""" + + code: str + stage: ConnectorErrorStage + retryability: ConnectorRetryability + message: str + + +@dataclass(frozen=True, slots=True) +class ConnectorServiceStatus: + """Orthogonal delivery, health, and recovery state from Core.""" + + delivery_readiness: ConnectorDeliveryReadiness + health: ConnectorHealth + recovery: ConnectorRecovery + readiness_reason_code: str | None + health_reason_code: str | None + recovery_reason_code: str | None + revision: int + last_transition_elapsed_ns: int + accepts_delivery: bool + + +@dataclass(frozen=True, slots=True) +class ConnectorObservations: + """Immutable provider-service observations for one Connector worker.""" + + service_status: ConnectorServiceStatus + status_transitions_total: int + retry_attempts_total: int + reconnects_total: int + failures_total: int + last_error: ConnectorErrorSnapshot | None + + @classmethod + def _from_native(cls, value: _NativeConnectorObservations) -> ConnectorObservations: + status = value.service_status + error = value.last_error + return cls( + service_status=ConnectorServiceStatus( + delivery_readiness=ConnectorDeliveryReadiness( + status.delivery_readiness + ), + health=ConnectorHealth(status.health), + recovery=ConnectorRecovery(status.recovery), + readiness_reason_code=status.readiness_reason_code, + health_reason_code=status.health_reason_code, + recovery_reason_code=status.recovery_reason_code, + revision=status.revision, + last_transition_elapsed_ns=status.last_transition_elapsed_ns, + accepts_delivery=status.accepts_delivery, + ), + status_transitions_total=value.status_transitions_total, + retry_attempts_total=value.retry_attempts_total, + reconnects_total=value.reconnects_total, + failures_total=value.failures_total, + last_error=( + None + if error is None + else ConnectorErrorSnapshot( + code=error.code, + stage=ConnectorErrorStage(error.stage), + retryability=ConnectorRetryability(error.retryability), + message=error.message, + ) + ), + ) + + +@dataclass(frozen=True, slots=True) +class ConnectorRuntimeObservations: + """Connector and Endpoint counters for one prepared worker group.""" + + endpoint_ids: tuple[int, ...] + connector: ConnectorObservations + frames_received_total: int + frames_delivered_total: int + frames_dropped_total: int + discontinuities_total: int + endpoint_failures_total: int + + @classmethod + def _from_native( + cls, value: _NativeConnectorRuntimeObservations + ) -> ConnectorRuntimeObservations: + return cls( + endpoint_ids=tuple(value.endpoint_ids), + connector=ConnectorObservations._from_native(value.connector), + frames_received_total=value.frames_received_total, + frames_delivered_total=value.frames_delivered_total, + frames_dropped_total=value.frames_dropped_total, + discontinuities_total=value.discontinuities_total, + endpoint_failures_total=value.endpoint_failures_total, + ) + + +class ConnectorConfigurationValue: + """One exact typed configuration value owned and validated by Core.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeConnectorConfigurationValue) -> None: + self._native = native + + @classmethod + def text(cls, value: str) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.text(value)) + + @classmethod + def boolean(cls, value: bool) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.boolean(value)) + + @classmethod + def signed_integer(cls, value: int) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.signed_integer(value)) + + @classmethod + def unsigned_integer(cls, value: int) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.unsigned_integer(value)) + + @classmethod + def duration_milliseconds(cls, value: int) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.duration_milliseconds(value)) + + @classmethod + def byte_count(cls, value: int) -> ConnectorConfigurationValue: + return cls(_NativeConnectorConfigurationValue.byte_count(value)) + + @classmethod + def secret(cls, value: str) -> ConnectorConfigurationValue: + native = _native_call(lambda: _NativeConnectorConfigurationValue.secret(value)) + return cls(native) + + @property + def kind(self) -> ConnectorConfigurationValueKind: + return ConnectorConfigurationValueKind(self._native.kind) + + def expose_secret(self) -> str: + """Explicitly reveal a secret to the provider that owns this value.""" + return _native_call(self._native.expose_secret) + + @property + def value(self) -> str | bool | int: + """Return a non-secret value; secret access stays explicit.""" + if self.kind is ConnectorConfigurationValueKind.SECRET: + raise ConnectorError( + "secret configuration requires expose_secret()", + code="connector.configuration.secret_access", + stage=ConnectorErrorStage.CONFIGURATION, + ) + text = self._native.as_text() + if text is not None: + return text + boolean = self._native.as_boolean() + if boolean is not None: + return boolean + signed = self._native.as_signed_integer() + if signed is not None: + return signed + unsigned = self._native.as_unsigned_integer() + if unsigned is not None: + return unsigned + raise AssertionError("native Connector value has no compatible projection") + + def __repr__(self) -> str: + if self.kind is ConnectorConfigurationValueKind.SECRET: + return "ConnectorConfigurationValue.secret()" + return f"ConnectorConfigurationValue.{self.kind.value}({self.value!r})" + + +ConnectorConfigurationInput: TypeAlias = ( + Mapping[str, ConnectorConfigurationValue | str | bool | int] + | Sequence[tuple[str, ConnectorConfigurationValue | str | bool | int]] +) + + +class ConnectorConfigurationConstraint: + """One Core-enforced constraint on a connector configuration field.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeConnectorConfigurationConstraint) -> None: + self._native = native + + @classmethod + def non_empty(cls) -> ConnectorConfigurationConstraint: + return cls(_NativeConnectorConfigurationConstraint.non_empty()) + + @classmethod + def text_length_bytes( + cls, minimum: int, maximum: int + ) -> ConnectorConfigurationConstraint: + return cls( + _NativeConnectorConfigurationConstraint.text_length_bytes(minimum, maximum) + ) + + @classmethod + def signed_range( + cls, minimum: int, maximum: int + ) -> ConnectorConfigurationConstraint: + native = _NativeConnectorConfigurationConstraint.signed_range(minimum, maximum) + return cls(native) + + @classmethod + def unsigned_range( + cls, minimum: int, maximum: int + ) -> ConnectorConfigurationConstraint: + native = _NativeConnectorConfigurationConstraint.unsigned_range( + minimum, maximum + ) + return cls(native) + + @classmethod + def one_of(cls, values: Sequence[str]) -> ConnectorConfigurationConstraint: + return cls(_NativeConnectorConfigurationConstraint.one_of(list(values))) + + +@dataclass(frozen=True, slots=True) +class ConnectorConfigurationField: + """One documented typed field in a connector's configuration schema.""" + + name: str + kind: ConnectorConfigurationValueKind + documentation: str + requirement: ConnectorConfigurationRequirement = ( + ConnectorConfigurationRequirement.REQUIRED + ) + default: ConnectorConfigurationValue | str | bool | int | None = None + constraints: tuple[ConnectorConfigurationConstraint, ...] = () + deprecation: str | None = None + _native: _NativeConnectorConfigurationField = field( + init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + default = None + if self.default is not None: + default = _coerce_configuration_value(self.kind, self.default)._native + native = _native_call( + lambda: _NativeConnectorConfigurationField( + self.name, + self.kind.value, + self.requirement.value, + self.documentation, + default, + [constraint._native for constraint in self.constraints], + self.deprecation, + ) + ) + object.__setattr__(self, "_native", native) + + +@dataclass(frozen=True, slots=True) +class ConnectorConfigurationSchema: + """Finite configuration schema resolved by Core before provider setup.""" + + fields: tuple[ConnectorConfigurationField, ...] = () + revision: int = 1 + _native: _NativeConnectorConfigurationSchema = field( + init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeConnectorConfigurationSchema( + self.revision, [entry._native for entry in self.fields] + ) + ) + object.__setattr__(self, "_native", native) + + def configuration( + self, values: ConnectorConfigurationInput = () + ) -> _NativeConnectorConfiguration: + entries = values.items() if isinstance(values, Mapping) else values + by_name = {entry.name: entry for entry in self.fields} + native_entries: list[tuple[str, _NativeConnectorConfigurationValue]] = [] + for name, value in entries: + field = by_name.get(name) + if field is None: + raise ConnectorError( + f"unknown Connector configuration field {name!r}", + code="connector.configuration.unknown_field", + stage=ConnectorErrorStage.CONFIGURATION, + ) + native_entries.append( + (name, _coerce_configuration_value(field.kind, value)._native) + ) + return _NativeConnectorConfiguration(native_entries) + + +@dataclass(frozen=True, slots=True) +class ConnectorCapability: + """One stable capability advertised by a Connector implementation.""" + + id: str + documentation: str + + +@dataclass(frozen=True, slots=True) +class ConnectorRequirement: + """One declared external or host requirement for a Connector.""" + + id: str + documentation: str + required: bool = True + + +@dataclass(frozen=True, slots=True) +class ConnectorManifest: + """Provider-neutral outbound Connector contract compiled by Core.""" + + operator_id: str + package_version: str + inputs: tuple[PortSpec, ...] + configuration: ConnectorConfigurationSchema = field( + default_factory=ConnectorConfigurationSchema + ) + node_type_id: str | None = None + manifest_revision: int = 1 + startup_timeout_ms: int = 5_000 + probe_interval_ms: int = 100 + success_threshold: int = 1 + failure_threshold: int = 1 + capabilities: tuple[ConnectorCapability, ...] = () + requirements: tuple[ConnectorRequirement, ...] = () + _native: _NativeConnectorManifest = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + node_type_id = self.node_type_id or self.operator_id + native = _native_call( + lambda: _NativeConnectorManifest( + self.operator_id, + node_type_id, + self.package_version, + [port._native for port in self.inputs], + self.configuration._native, + self.manifest_revision, + self.startup_timeout_ms, + self.probe_interval_ms, + self.success_threshold, + self.failure_threshold, + [ + (capability.id, capability.documentation) + for capability in self.capabilities + ], + [ + (requirement.id, requirement.required, requirement.documentation) + for requirement in self.requirements + ], + ) + ) + object.__setattr__(self, "_native", native) + + @classmethod + def audio( + cls, + operator_id: str, + *, + package_version: str, + configuration: ConnectorConfigurationSchema | None = None, + port_name: str = "audio", + multiplicity: Multiplicity = Multiplicity.ONE, + ) -> ConnectorManifest: + """Create the common one-input PCM Connector manifest.""" + return cls( + operator_id=operator_id, + package_version=package_version, + inputs=( + PortSpec( + port_name, + PortDirection.INPUT, + SignalSpec.audio(), + MediaCaps.audio(), + multiplicity, + ), + ), + configuration=configuration or ConnectorConfigurationSchema(), + ) + + +class ConnectorInputDescriptor: + """Immutable Session route and resolved configuration for one input.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeConnectorInputDescriptor) -> None: + self._native = native + + @property + def endpoint_id(self) -> int: + return self._native.endpoint_id + + @property + def connector_id(self) -> int | None: + return self._native.connector_id + + @property + def route_id(self) -> int: + return self._native.route_id + + @property + def port_name(self) -> str: + return self._native.port_name + + @property + def signal_wire_id(self) -> str: + return self._native.signal_wire_id + + @property + def signal(self) -> SignalSpec: + return SignalSpec._from_native(self._native.signal) + + @property + def media(self) -> MediaCaps: + return MediaCaps._from_native(self._native.media) + + @property + def edge(self) -> EdgeContract: + return EdgeContract(self._native.edge) + + @property + def configuration(self) -> Mapping[str, ConnectorConfigurationValue]: + return { + name: ConnectorConfigurationValue(value) + for name, value in self._native.configuration.items() + } + + +class ConnectorItem: + """One owned audio frame or typed signal delivered off realtime threads.""" + + __slots__ = ("_native", "input") + + def __init__(self, native: _NativeConnectorItem) -> None: + self._native = native + self.input = ConnectorInputDescriptor(native.input) + + @property + def kind(self) -> str: + return self._native.kind + + @property + def audio(self) -> AudioFrame | None: + return self._native.audio + + @property + def signal(self) -> SignalEnvelope | None: + native = self._native.signal + return None if native is None else SignalEnvelope._from_native(native) + + +class ConnectorContext: + """Finite lifecycle, readiness, health, and recovery control for a driver.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeConnectorContext) -> None: + self._native = native + + @property + def stop_requested(self) -> bool: + return _native_call(lambda: self._native.stop_requested) + + @property + def shutdown_mode(self) -> ConnectorShutdownMode | None: + value = _native_call(lambda: self._native.shutdown_mode) + return None if value is None else ConnectorShutdownMode(value) + + def set_ready(self) -> bool: + return _native_call(self._native.set_ready) + + def set_not_ready(self, reason_code: str | None = None) -> bool: + return _native_call(lambda: self._native.set_not_ready(reason_code)) + + def set_degraded(self, reason_code: str) -> bool: + return _native_call(lambda: self._native.set_degraded(reason_code)) + + def set_healthy(self) -> bool: + return _native_call(self._native.set_healthy) + + def set_reconnecting(self, reason_code: str) -> bool: + return _native_call(lambda: self._native.set_reconnecting(reason_code)) + + def set_connected(self) -> bool: + return _native_call(self._native.set_connected) + + def record_retry(self) -> None: + _native_call(self._native.record_retry) + + +class ConnectorDriver: + """Idiomatic driver defaults; Core owns polling, bounds, and lifecycle.""" + + def start(self, context: ConnectorContext) -> None: + context.set_ready() + + def deliver( + self, item: ConnectorItem, context: ConnectorContext + ) -> ConnectorDeliveryOutcome | None: + raise NotImplementedError + + def idle(self, context: ConnectorContext) -> None: + """Optional finite idle work; override only when the provider needs it.""" + + def shutdown(self, mode: ConnectorShutdownMode, context: ConnectorContext) -> None: + """Optional finite provider shutdown after Core requests drain or abort.""" + + def cancel_preparation(self) -> None: + """Release prepared provider state when Session startup rolls back.""" + + +@runtime_checkable +class ConnectorDriverFactory(Protocol): + def prepare( + self, inputs: Sequence[ConnectorInputDescriptor] + ) -> ConnectorDriver: ... + + +ConnectorDriverBuilder: TypeAlias = Callable[ + [Sequence[ConnectorInputDescriptor]], ConnectorDriver +] +ConnectorHandler: TypeAlias = Callable[ + [ConnectorItem, ConnectorContext], ConnectorDeliveryOutcome | None +] +ConnectorPreparationGroup: TypeAlias = Callable[ + [int, Mapping[str, ConnectorConfigurationValue]], str | None +] +ConnectorBatchOutcome: TypeAlias = ( + ConnectorDeliveryOutcome | Sequence[ConnectorDeliveryOutcome] | None +) + + +class ConnectorWorker: + """Advanced finite-batch provider with receivers still owned by Core.""" + + def start(self, context: ConnectorContext) -> None: + context.set_ready() + + def deliver_batch( + self, items: Sequence[ConnectorItem], context: ConnectorContext + ) -> ConnectorBatchOutcome: + raise NotImplementedError + + def idle(self, context: ConnectorContext) -> None: + """Optional finite idle work; override only when required.""" + + def shutdown(self, mode: ConnectorShutdownMode, context: ConnectorContext) -> None: + """Finalize the provider after Core requests drain or abort.""" + + def cancel_preparation(self) -> None: + """Release prepared state during transactional startup rollback.""" + + +@runtime_checkable +class ConnectorFactory(Protocol): + def prepare( + self, inputs: Sequence[ConnectorInputDescriptor] + ) -> ConnectorWorker: ... + + +ConnectorWorkerBuilder: TypeAlias = Callable[ + [Sequence[ConnectorInputDescriptor]], ConnectorWorker +] + + +class _HandlerDriver(ConnectorDriver): + __slots__ = ("_handler",) + + def __init__(self, handler: ConnectorHandler) -> None: + self._handler = handler + + def deliver( + self, item: ConnectorItem, context: ConnectorContext + ) -> ConnectorDeliveryOutcome | None: + return self._handler(item, context) + + +class _DriverAdapter: + __slots__ = ("_driver", "_pocketstation_idle_enabled") + + def __init__(self, driver: ConnectorDriver) -> None: + self._driver = driver + declared_idle = getattr(driver, "_pocketstation_idle_enabled", None) + idle = getattr(type(driver), "idle", None) + self._pocketstation_idle_enabled = ( + bool(declared_idle) + if declared_idle is not None + else idle is not None and idle is not ConnectorDriver.idle + ) + + def start(self, native: _NativeConnectorContext) -> None: + start = getattr(self._driver, "start", None) + context = ConnectorContext(native) + if start is None: + context.set_ready() + else: + start(context) + + def deliver( + self, native_item: _NativeConnectorItem, native_context: _NativeConnectorContext + ) -> str | None: + outcome = self._driver.deliver( + ConnectorItem(native_item), ConnectorContext(native_context) + ) + return None if outcome is None else outcome.value + + def idle(self, native: _NativeConnectorContext) -> None: + idle = getattr(self._driver, "idle", None) + if idle is not None: + idle(ConnectorContext(native)) + + def shutdown(self, mode: str, native_context: _NativeConnectorContext) -> None: + shutdown = getattr(self._driver, "shutdown", None) + if shutdown is not None: + shutdown(ConnectorShutdownMode(mode), ConnectorContext(native_context)) + + def cancel_preparation(self) -> None: + cancel = getattr(self._driver, "cancel_preparation", None) + if cancel is not None: + cancel() + + +class _FactoryAdapter: + __slots__ = ("_factory",) + + def __init__( + self, factory: ConnectorDriverFactory | ConnectorDriverBuilder + ) -> None: + self._factory = factory + + def prepare( + self, native_inputs: Sequence[_NativeConnectorInputDescriptor] + ) -> _DriverAdapter: + inputs = tuple(ConnectorInputDescriptor(value) for value in native_inputs) + prepare = getattr(self._factory, "prepare", None) + if prepare is None: + driver = self._factory(inputs) # type: ignore[operator] + else: + driver = prepare(inputs) + if not hasattr(driver, "deliver"): + raise TypeError("Connector factory must return a driver with deliver()") + return _DriverAdapter(driver) + + def preparation_group( + self, + route_id: int, + native_configuration: Mapping[str, _NativeConnectorConfigurationValue], + ) -> str | None: + group = getattr(self._factory, "preparation_group", None) + if group is None: + return None + configuration = { + name: ConnectorConfigurationValue(value) + for name, value in native_configuration.items() + } + return cast(str | None, group(route_id, configuration)) + + +class _WorkerAdapter: + __slots__ = ("_pocketstation_idle_enabled", "_worker") + + def __init__(self, worker: ConnectorWorker) -> None: + self._worker = worker + idle = getattr(type(worker), "idle", None) + self._pocketstation_idle_enabled = ( + idle is not None and idle is not ConnectorWorker.idle + ) + + def start(self, native_context: _NativeConnectorContext) -> None: + start = getattr(self._worker, "start", None) + context = ConnectorContext(native_context) + if start is None: + context.set_ready() + else: + start(context) + + def deliver_batch( + self, + native_items: Sequence[_NativeConnectorItem], + native_context: _NativeConnectorContext, + ) -> str | list[str] | None: + outcome = self._worker.deliver_batch( + tuple(ConnectorItem(item) for item in native_items), + ConnectorContext(native_context), + ) + if outcome is None: + return None + if isinstance(outcome, ConnectorDeliveryOutcome): + return outcome.value + return [value.value for value in outcome] + + def idle(self, native_context: _NativeConnectorContext) -> None: + idle = getattr(self._worker, "idle", None) + if idle is not None: + idle(ConnectorContext(native_context)) + + def shutdown(self, mode: str, native_context: _NativeConnectorContext) -> None: + shutdown = getattr(self._worker, "shutdown", None) + if shutdown is not None: + shutdown(ConnectorShutdownMode(mode), ConnectorContext(native_context)) + + def cancel_preparation(self) -> None: + cancel = getattr(self._worker, "cancel_preparation", None) + if cancel is not None: + cancel() + + +class _WorkerFactoryAdapter: + __slots__ = ("_factory",) + + def __init__(self, factory: ConnectorFactory | ConnectorWorkerBuilder) -> None: + self._factory = factory + + def prepare( + self, native_inputs: Sequence[_NativeConnectorInputDescriptor] + ) -> _WorkerAdapter: + inputs = tuple(ConnectorInputDescriptor(value) for value in native_inputs) + prepare = getattr(self._factory, "prepare", None) + worker = ( + self._factory(inputs) # type: ignore[operator] + if prepare is None + else prepare(inputs) + ) + if not hasattr(worker, "deliver_batch"): + raise TypeError( + "Connector factory must return a worker with deliver_batch()" + ) + return _WorkerAdapter(worker) + + def preparation_group( + self, + route_id: int, + native_configuration: Mapping[str, _NativeConnectorConfigurationValue], + ) -> str | None: + group = getattr(self._factory, "preparation_group", None) + if group is None: + return None + configuration = { + name: ConnectorConfigurationValue(value) + for name, value in native_configuration.items() + } + return cast(str | None, group(route_id, configuration)) + + +@dataclass(frozen=True, slots=True) +class Connector: + """A reusable Python provider implementation registered into one Session.""" + + manifest: ConnectorManifest + factory: ( + ConnectorDriverFactory + | ConnectorDriverBuilder + | ConnectorFactory + | ConnectorWorkerBuilder + ) + maximum_batch_items: int | None = field(default=None, repr=False) + _native_factory: _FactoryAdapter | _WorkerFactoryAdapter = field( + init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + if self.maximum_batch_items is None: + adapter: _FactoryAdapter | _WorkerFactoryAdapter = _FactoryAdapter( + self.factory # type: ignore[arg-type] + ) + else: + if not 1 <= self.maximum_batch_items <= 1_024: + raise ValueError("maximum_batch_items must be between 1 and 1024") + adapter = _WorkerFactoryAdapter(self.factory) # type: ignore[arg-type] + object.__setattr__(self, "_native_factory", adapter) + + @classmethod + def with_driver( + cls, + manifest: ConnectorManifest, + factory: ConnectorDriverFactory | ConnectorDriverBuilder, + ) -> Connector: + return cls(manifest, factory) + + @classmethod + def with_worker( + cls, + manifest: ConnectorManifest, + factory: ConnectorFactory | ConnectorWorkerBuilder, + *, + maximum_batch_items: int = 32, + ) -> Connector: + """Create an advanced Connector with finite native-owned batching.""" + return cls(manifest, factory, maximum_batch_items) + + @classmethod + def from_handler( + cls, + manifest: ConnectorManifest, + handler: ConnectorHandler, + ) -> Connector: + """Create the common stateless Connector directly from a handler.""" + return cls(manifest, lambda _inputs: _HandlerDriver(handler)) + + +class RegisteredConnector: + """One reusable Connector implementation bound to one Session draft.""" + + __slots__ = ("_connector", "_native", "_session") + + def __init__( + self, + session: _SessionOwner, + connector: Connector, + native: _NativeRegisteredConnector, + ) -> None: + self._session = session + self._connector = connector + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + def declare( + self, + configuration: ConnectorConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + """Declare one configured endpoint using the registered implementation.""" + native_configuration = self._connector.manifest.configuration.configuration( + configuration + ) + selected_edge = edge or _default_edge(self._connector.manifest) + native = _native_call( + lambda: self._session._native.declare_connector( + self._native, native_configuration, selected_edge._native + ) + ) + return Endpoint(native) + + def observations(self) -> tuple[ConnectorRuntimeObservations, ...]: + """Snapshot every distinct runtime group created for this Connector.""" + values = _native_call(self._native.observations) + return tuple( + ConnectorRuntimeObservations._from_native(value) for value in values + ) + + def observation(self, endpoint: Endpoint) -> ConnectorObservations | None: + """Snapshot the provider-service state for one declared endpoint.""" + value = _native_call(lambda: self._native.observation(endpoint._native)) + return None if value is None else ConnectorObservations._from_native(value) + + +def connector( + manifest: ConnectorManifest, +) -> Callable[[ConnectorHandler], Connector]: + """Decorate one item handler into a reusable in-process Connector.""" + + def define(handler: ConnectorHandler) -> Connector: + return Connector.from_handler(manifest, handler) + + return define + + +def _coerce_configuration_value( + kind: ConnectorConfigurationValueKind, + value: ConnectorConfigurationValue | str | bool | int, +) -> ConnectorConfigurationValue: + if isinstance(value, ConnectorConfigurationValue): + if value.kind is not kind: + raise ConnectorError( + f"configuration value is {value.kind.value}, expected {kind.value}", + code="connector.configuration.type_mismatch", + stage=ConnectorErrorStage.CONFIGURATION, + ) + return value + if kind is ConnectorConfigurationValueKind.TEXT and isinstance(value, str): + return ConnectorConfigurationValue.text(value) + if kind is ConnectorConfigurationValueKind.BOOLEAN and isinstance(value, bool): + return ConnectorConfigurationValue.boolean(value) + if isinstance(value, int) and not isinstance(value, bool): + if kind is ConnectorConfigurationValueKind.SIGNED_INTEGER: + return ConnectorConfigurationValue.signed_integer(value) + if kind is ConnectorConfigurationValueKind.UNSIGNED_INTEGER: + return ConnectorConfigurationValue.unsigned_integer(value) + if kind is ConnectorConfigurationValueKind.DURATION_MILLISECONDS: + return ConnectorConfigurationValue.duration_milliseconds(value) + if kind is ConnectorConfigurationValueKind.BYTE_COUNT: + return ConnectorConfigurationValue.byte_count(value) + raise ConnectorError( + f"configuration value cannot be represented as {kind.value}", + code="connector.configuration.type_mismatch", + stage=ConnectorErrorStage.CONFIGURATION, + ) + + +def _default_edge(manifest: ConnectorManifest) -> EdgeContract: + if len(manifest.inputs) == 1 and manifest.inputs[0].signal.is_audio: + return EdgeContract.realtime_audio() + return EdgeContract.bounded_async() + + +__all__ = [ + "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", + "ConnectorWorker", + "ConnectorWorkerBuilder", + "RegisteredConnector", + "connector", +] diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index a147745..3fe9d52 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -262,6 +262,10 @@ def __post_init__(self) -> None: ) object.__setattr__(self, "_native", native) + @classmethod + def _from_native(cls, native: _NativeMediaCaps) -> MediaCaps: + return _media_from_native(native) + @classmethod def audio(cls, caps: AudioCaps | None = None) -> MediaCaps: return cls(MediaKind.AUDIO_PCM, audio_caps=caps or AudioCaps()) diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py index 25019ba..03bfa38 100644 --- a/python/pocketstation/observations.py +++ b/python/pocketstation/observations.py @@ -79,6 +79,12 @@ class EndpointFailureStage(StrEnum): JOIN_FINALIZE = "join-finalize" +class EndpointFailureRetryability(StrEnum): + NEVER = "never" + RETRYABLE = "retryable" + RECONFIGURATION_REQUIRED = "retry-after-reconfiguration" + + class SessionRollbackStage(StrEnum): CANCEL_OPERATOR = "cancel-operator" CANCEL_ENDPOINT_PREPARATION = "cancel-endpoint-preparation" @@ -156,6 +162,8 @@ class SessionFailure: stage: FailureStage | None operation: str | None error_class: str | None + error_code: str | None + retryability: EndpointFailureRetryability | None component: str | None message: str | None stem_id: int | None @@ -168,11 +176,18 @@ class SessionFailure: @classmethod def _from_native(cls, failure: _NativeSessionFailure) -> SessionFailure: kind = SessionFailureKind(failure.kind) + retryability = getattr(failure, "retryability", None) return cls( kind=kind, stage=_failure_stage(kind, failure.stage), operation=failure.operation, error_class=failure.error_class, + error_code=getattr(failure, "error_code", None), + retryability=( + None + if retryability is None + else EndpointFailureRetryability(retryability) + ), component=failure.component, message=failure.message, stem_id=failure.stem_id, @@ -1054,6 +1069,7 @@ def iterate() -> Iterator[SessionEvent]: "AudioReentryMetrics", "DerivedRouteMetrics", "EdgeMetrics", + "EndpointFailureRetryability", "EndpointFailureStage", "EndpointMetrics", "EndpointObservationStage", diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index d12e378..bf1783f 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -18,6 +18,7 @@ Session as _NativeSession, ) from .audio_input import AudioInput, AudioInputConfig, PcmSource +from .connector import Connector, RegisteredConnector from .errors import PocketStationError, _native_call from .extensions import NativeExtensionLibrary from .graph import ( @@ -306,6 +307,25 @@ def polled_audio(self) -> Endpoint: """Declare the bounded managed-language polling endpoint.""" return _native_call(lambda: Endpoint(self._native.polled_audio())) + def register_connector(self, connector: Connector) -> RegisteredConnector: + """Register one in-process Python Connector implementation.""" + maximum_batch_items = connector.maximum_batch_items + if maximum_batch_items is None: + native = _native_call( + lambda: self._native.register_connector( + connector.manifest._native, connector._native_factory + ) + ) + else: + native = _native_call( + lambda: self._native.register_connector_worker( + connector.manifest._native, + connector._native_factory, + maximum_batch_items, + ) + ) + return RegisteredConnector(self, connector, native) + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: """Register a bounded PKSS child to spawn during transactional start.""" sidecar_id = _native_call( @@ -343,9 +363,11 @@ def start(self) -> RunningSession: "AudioFrame", "AudioInput", "AudioInputConfig", + "Connector", "Endpoint", "RecordingOutcome", "RecordingStemOutcome", + "RegisteredConnector", "RouteMetrics", "RunningSession", "Session", diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index de5f08b..31c015a 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build and test the stream surface from an isolated conformance wheel.""" +"""Build and test streams and provider authoring from an isolated wheel.""" from __future__ import annotations @@ -14,6 +14,8 @@ TESTS = ( REPOSITORY / "tests" / "test_streams.py", REPOSITORY / "tests" / "test_aio_streams.py", + REPOSITORY / "tests" / "test_connector.py", + REPOSITORY / "tests" / "test_aio_session.py", ) @@ -81,7 +83,11 @@ def main() -> int: "--import-mode=importlib", *(os.fspath(test) for test in TESTS), "-k", - "canonical_native_session", + ( + "canonical_native_session or " + "connector_worker_receives_finite_native_owned_batches or " + "async_connector_worker_receives_finite_native_batches" + ), "-rs", ], cwd=root, diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index b9b6d93..1a0257d 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -8,7 +8,15 @@ from array import array import pytest -from pocketstation.aio import Session +from pocketstation import Connector, ConnectorDeliveryOutcome, ConnectorManifest +from pocketstation.aio import ( + Connector as AsyncConnector, +) +from pocketstation.aio import ( + ConnectorDeadlines, + ConnectorWorker, + Session, +) @pytest.mark.asyncio @@ -55,3 +63,148 @@ async def test_application_owned_pcm_has_an_async_writer() -> None: assert frame.source_id == audio.source_id assert frame.stream_id == audio.stream_id assert list(frame.samples.cast("f")) == pytest.approx([0.1, 0.2, 0.3, 0.4]) + + +@pytest.mark.asyncio +async def test_async_session_registers_the_same_core_connector_contract() -> None: + delivered = threading.Event() + + def receive(item, context): + assert item.audio is not None + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.aio-connector.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("playback", frame_samples_per_channel=4) + endpoint = session.register_connector( + Connector.from_handler(manifest, receive) + ).declare() + audio.output.send(endpoint) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + assert await asyncio.to_thread(delivered.wait, 1.0) + assert (await running.stop()).success + + +@pytest.mark.asyncio +async def test_async_connector_runs_on_owning_loop_with_observations() -> None: + delivered = asyncio.Event() + owning_thread = threading.get_ident() + + async def receive(item, context): + assert threading.get_ident() == owning_thread + assert item.audio is not None + await asyncio.sleep(0) + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.aio-native-connector.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("playback", frame_samples_per_channel=4) + registered = session.register_connector( + AsyncConnector.from_handler(manifest, receive) + ) + endpoint = registered.declare() + audio.output.send(endpoint) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + await asyncio.wait_for(delivered.wait(), 1.0) + observation = await registered.observation(endpoint) + assert observation is not None + assert observation.service_status.accepts_delivery + [runtime] = await registered.observations() + assert runtime.frames_delivered_total == 1 + assert (await running.stop()).success + + +@pytest.mark.asyncio +async def test_async_connector_delivery_deadline_is_finite_and_structured() -> None: + started = asyncio.Event() + + async def hang(item, context): + started.set() + await asyncio.sleep(10) + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.aio-timeout.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("playback", frame_samples_per_channel=4) + audio.output.send( + session.register_connector( + AsyncConnector.from_handler( + manifest, + hang, + deadlines=ConnectorDeadlines(delivery_s=0.01), + ) + ).declare() + ) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + await asyncio.wait_for(started.wait(), 1.0) + await asyncio.sleep(0.05) + stop = await running.stop() + assert not stop.success + assert stop.terminal_event is not None + failure = next( + value + for value in stop.terminal_event.failures + if value.error_code == "python.async.timeout" + ) + assert failure.retryability is not None + assert failure.retryability.value == "retryable" + + +@pytest.mark.asyncio +async def test_async_connector_worker_receives_finite_native_batches() -> None: + finished = asyncio.Event() + batches: list[int] = [] + total = 8 + + class BatchWorker(ConnectorWorker): + async def deliver_batch(self, items, context): + await asyncio.sleep(0) + batches.append(len(items)) + if sum(batches) >= total: + finished.set() + return ConnectorDeliveryOutcome.DELIVERED + + async def prepare(_inputs): + return BatchWorker() + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.aio-batch.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input( + "playback", + capacity_frames=16, + frame_samples_per_channel=4, + ) + registered = session.register_connector( + AsyncConnector.with_worker( + manifest, + prepare, + maximum_batch_items=4, + ) + ) + audio.output.send(registered.declare()) + running = await session.start() + for sequence in range(total): + await audio.write(array("f", [float(sequence)] * 4)) + await asyncio.wait_for(finished.wait(), 1.0) + assert (await running.stop()).success + assert sum(batches) == total + assert all(1 <= size <= 4 for size in batches) diff --git a/tests/test_connector.py b/tests/test_connector.py new file mode 100644 index 0000000..e49bdcc --- /dev/null +++ b/tests/test_connector.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from array import array +from threading import Event +from time import monotonic + +import pytest +from pocketstation import ( + Connector, + ConnectorConfigurationField, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, + ConnectorDriver, + ConnectorError, + ConnectorErrorStage, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorRecovery, + ConnectorRetryability, + ConnectorShutdownMode, + ConnectorWorker, + PocketStationError, + Session, + connector, +) + + +class CollectingDriver(ConnectorDriver): + def __init__(self) -> None: + self.started = Event() + self.delivered = Event() + self.stopped = Event() + self.shutdown_mode: ConnectorShutdownMode | None = None + self.items: list[ConnectorItem] = [] + + def start(self, context) -> None: + super().start(context) + self.started.set() + + def deliver(self, item, context) -> ConnectorDeliveryOutcome: + self.items.append(item) + self.delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + def shutdown(self, mode, context) -> None: + self.shutdown_mode = mode + self.stopped.set() + + +def test_python_connector_receives_application_owned_pcm_with_lineage() -> None: + driver = CollectingDriver() + prepared: list[tuple[ConnectorInputDescriptor, ...]] = [] + manifest = ConnectorManifest.audio( + "io.pocketstation.test.collect.v1", + package_version="1.0.0", + ) + + def prepare( + inputs: tuple[ConnectorInputDescriptor, ...], + ) -> CollectingDriver: + prepared.append(inputs) + return driver + + session = Session() + audio = session.audio_input("playback", frame_samples_per_channel=4) + endpoint = session.register_connector( + Connector.with_driver(manifest, prepare) + ).declare() + route_id = audio.output.send(endpoint) + + running = session.start() + assert driver.started.wait(1.0) + audio.write(array("f", [0.25, -0.25, 0.5, -0.5]), discontinuity=True) + assert driver.delivered.wait(1.0) + stop = running.stop() + + assert stop.success + assert driver.stopped.wait(1.0) + assert driver.shutdown_mode is ConnectorShutdownMode.DRAIN + assert len(prepared) == 1 + assert len(prepared[0]) == 1 + descriptor = prepared[0][0] + assert descriptor.endpoint_id == endpoint.id + assert descriptor.connector_id == endpoint.connector_id + assert descriptor.route_id == route_id + assert descriptor.port_name == "audio" + assert descriptor.signal_wire_id == "pks.signal.pcm-audio.v1" + assert descriptor.signal.is_audio + assert descriptor.media.kind.value == "audio-pcm" + assert descriptor.edge.media.kind.value == "audio-pcm" + assert len(driver.items) == 1 + item = driver.items[0] + assert item.kind == "audio" + assert item.signal is None + assert item.audio is not None + assert item.audio.source_id == audio.source_id + assert item.audio.stream_id == audio.stream_id + assert item.audio.sequence_number == 0 + assert item.audio.discontinuity_epoch == 1 + assert list(item.audio.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) + + +def test_configuration_is_typed_validated_and_secret_safe() -> None: + schema = ConnectorConfigurationSchema( + fields=( + ConnectorConfigurationField( + "token", + ConnectorConfigurationValueKind.SECRET, + "Provider credential.", + ), + ConnectorConfigurationField( + "timeout_ms", + ConnectorConfigurationValueKind.DURATION_MILLISECONDS, + "Finite request timeout.", + requirement=ConnectorConfigurationRequirement.DEFAULT, + default=250, + ), + ) + ) + manifest = ConnectorManifest.audio( + "io.pocketstation.test.configuration.v1", + package_version="1.0.0", + configuration=schema, + ) + seen: list[dict[str, ConnectorConfigurationValue]] = [] + + def prepare(inputs): + seen.append(dict(inputs[0].configuration)) + return CollectingDriver() + + session = Session() + endpoint = session.register_connector( + Connector.with_driver(manifest, prepare) + ).declare({"token": ConnectorConfigurationValue.secret("very-secret")}) + audio = session.audio_input("playback", frame_samples_per_channel=4) + audio.output.send(endpoint) + running = session.start() + assert running.stop().success + + assert len(seen) == 1 + assert seen[0]["token"].expose_secret() == "very-secret" + assert "very-secret" not in repr(seen[0]["token"]) + assert seen[0]["timeout_ms"].value == 250 + + with pytest.raises(ConnectorError) as unknown: + schema.configuration({"unknown": "value"}) + assert unknown.value.code == "connector.configuration.unknown_field" + + with pytest.raises(ConnectorError) as mismatch: + schema.configuration({"token": "not-explicitly-secret"}) + assert mismatch.value.code == "connector.configuration.type_mismatch" + + +def test_connector_decorator_builds_an_in_process_provider() -> None: + delivered = Event() + manifest = ConnectorManifest.audio( + "io.pocketstation.test.decorator.v1", + package_version="1.0.0", + ) + + @connector(manifest) + def provider(item, context): + assert item.audio is not None + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + assert isinstance(provider, Connector) + session = Session() + audio = session.audio_input("generated", frame_samples_per_channel=4) + audio.output.send(session.register_connector(provider).declare()) + running = session.start() + audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert delivered.wait(1.0) + assert running.cancel().success + + +def test_connector_manifest_rejects_output_ports() -> None: + from pocketstation import MediaCaps, PortDirection, PortSpec, SignalSpec + + with pytest.raises(Exception, match="input"): + ConnectorManifest( + operator_id="io.pocketstation.test.invalid.v1", + package_version="1.0.0", + inputs=( + PortSpec( + "audio", + PortDirection.OUTPUT, + SignalSpec.audio(), + MediaCaps.audio(), + ), + ), + ) + + +def test_connector_failure_preserves_code_and_retryability_in_session_outcome() -> None: + attempted = Event() + + def fail(item, context): + attempted.set() + raise ConnectorError( + "provider request timed out", + code="provider.timeout", + stage=ConnectorErrorStage.DELIVERY, + retryability=ConnectorRetryability.RETRYABLE, + ) + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.failure.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("generated", frame_samples_per_channel=4) + audio.output.send( + session.register_connector(Connector.from_handler(manifest, fail)).declare() + ) + running = session.start() + audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert attempted.wait(1.0) + stop = running.stop() + + assert not stop.success + assert stop.terminal_event is not None + failures = stop.terminal_event.failures + endpoint = next(failure for failure in failures if failure.kind.value == "endpoint") + assert endpoint.error_code == "provider.timeout" + assert endpoint.retryability is not None + assert endpoint.retryability.value == "retryable" + assert endpoint.message == "provider.timeout: provider request timed out" + + +def test_connector_observations_preserve_service_state_and_delivery_counters() -> None: + delivered = Event() + + def handle(item, context): + context.set_degraded("provider.rate_limited") + context.set_reconnecting("provider.connection_lost") + context.record_retry() + context.set_connected() + context.set_healthy() + context.set_ready() + delivered.set() + return ConnectorDeliveryOutcome.DROPPED + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.observations.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("generated", frame_samples_per_channel=4) + registered = session.register_connector(Connector.from_handler(manifest, handle)) + endpoint = registered.declare() + audio.output.send(endpoint) + running = session.start() + audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert delivered.wait(1.0) + + observation = registered.observation(endpoint) + assert observation is not None + assert ( + observation.service_status.delivery_readiness + is ConnectorDeliveryReadiness.READY + ) + assert observation.service_status.health is ConnectorHealth.HEALTHY + assert observation.service_status.recovery is ConnectorRecovery.IDLE + assert observation.service_status.accepts_delivery + assert observation.retry_attempts_total == 1 + assert observation.reconnects_total == 1 + + [runtime] = registered.observations() + assert runtime.endpoint_ids == (endpoint.id,) + assert runtime.frames_received_total == 1 + assert runtime.frames_delivered_total == 0 + assert runtime.frames_dropped_total == 1 + assert runtime.connector == observation + assert running.stop().success + + +def test_connector_context_expires_when_its_driver_is_destroyed() -> None: + contexts = [] + delivered = Event() + + def handle(item, context): + contexts.append(context) + delivered.set() + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.context-lifetime.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("generated", frame_samples_per_channel=4) + audio.output.send( + session.register_connector(Connector.from_handler(manifest, handle)).declare() + ) + running = session.start() + audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert delivered.wait(1.0) + assert running.stop().success + + with pytest.raises(PocketStationError) as failure: + contexts[0].set_ready() + assert failure.value.code == "connector.context_closed" + + +def test_connector_preparation_is_cancelled_during_transactional_rollback() -> None: + cancelled = Event() + + class PreparedDriver(CollectingDriver): + def cancel_preparation(self) -> None: + cancelled.set() + + first = ConnectorManifest.audio( + "io.pocketstation.test.rollback-first.v1", + package_version="1.0.0", + ) + second = ConnectorManifest.audio( + "io.pocketstation.test.rollback-second.v1", + package_version="1.0.0", + ) + + def reject(_inputs): + raise ConnectorError( + "provider configuration is unavailable", + code="provider.unavailable", + stage=ConnectorErrorStage.PREPARE, + ) + + session = Session() + audio = session.audio_input("generated", frame_samples_per_channel=4) + audio.output.send( + session.register_connector( + Connector.with_driver(first, lambda _inputs: PreparedDriver()) + ).declare() + ) + audio.output.send( + session.register_connector(Connector.with_driver(second, reject)).declare() + ) + + with pytest.raises(PocketStationError) as failure: + session.start() + assert failure.value.code == "session.endpoint_prepare_failed" + assert cancelled.wait(1.0) + + +def test_connector_groups_multiple_routes_into_one_provider_lifecycle() -> None: + schema = ConnectorConfigurationSchema( + fields=( + ConnectorConfigurationField( + "publisher_group", + ConnectorConfigurationValueKind.TEXT, + "Stable provider lifecycle group.", + ), + ) + ) + manifest = ConnectorManifest.audio( + "io.pocketstation.test.grouped.v1", + package_version="1.0.0", + configuration=schema, + ) + driver = CollectingDriver() + prepared: list[tuple[ConnectorInputDescriptor, ...]] = [] + + class GroupedFactory: + def preparation_group(self, route_id, configuration): + assert route_id > 0 + return str(configuration["publisher_group"].value) + + def prepare(self, inputs): + prepared.append(tuple(inputs)) + return driver + + session = Session() + first = session.audio_input("application", frame_samples_per_channel=4) + second = session.audio_input("microphone", frame_samples_per_channel=4) + registered = session.register_connector( + Connector.with_driver(manifest, GroupedFactory()) + ) + first_endpoint = registered.declare({"publisher_group": "broadcast"}) + second_endpoint = registered.declare({"publisher_group": "broadcast"}) + first.output.send(first_endpoint) + second.output.send(second_endpoint) + + running = session.start() + first.write(array("f", [0.1, 0.2, 0.3, 0.4])) + second.write(array("f", [0.5, 0.6, 0.7, 0.8])) + deadline = monotonic() + 1.0 + while len(driver.items) < 2 and monotonic() < deadline: + driver.delivered.wait(0.01) + assert running.stop().success + + assert len(prepared) == 1 + assert len(prepared[0]) == 2 + assert len(driver.items) == 2 + [runtime] = registered.observations() + assert set(runtime.endpoint_ids) == {first_endpoint.id, second_endpoint.id} + assert runtime.frames_delivered_total == 2 + + +def test_connector_worker_receives_finite_native_owned_batches() -> None: + finished = Event() + batches: list[tuple[ConnectorItem, ...]] = [] + total = 8 + + class BatchWorker(ConnectorWorker): + def deliver_batch(self, items, context): + batches.append(tuple(items)) + if sum(len(batch) for batch in batches) >= total: + finished.set() + return [ConnectorDeliveryOutcome.DELIVERED for _ in items] + + manifest = ConnectorManifest.audio( + "io.pocketstation.test.batch-worker.v1", + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input( + "generated", + capacity_frames=16, + frame_samples_per_channel=4, + ) + registered = session.register_connector( + Connector.with_worker( + manifest, + lambda _inputs: BatchWorker(), + maximum_batch_items=4, + ) + ) + audio.output.send(registered.declare()) + running = session.start() + for sequence in range(total): + audio.write(array("f", [float(sequence)] * 4)) + assert finished.wait(1.0) + assert running.stop().success + + assert sum(len(batch) for batch in batches) == total + assert all(1 <= len(batch) <= 4 for batch in batches) + [runtime] = registered.observations() + assert runtime.frames_received_total == total + assert runtime.frames_delivered_total == total + assert runtime.frames_dropped_total == 0 + + +def test_connector_worker_rejects_unbounded_batch_sizes() -> None: + manifest = ConnectorManifest.audio( + "io.pocketstation.test.invalid-batch.v1", + package_version="1.0.0", + ) + with pytest.raises(ValueError, match="between 1 and 1024"): + Connector.with_worker( + manifest, + lambda _inputs: ConnectorWorker(), + maximum_batch_items=0, + ) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 5d8d804..ee6e601 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -5,7 +5,6 @@ from types import SimpleNamespace import pytest - from pocketstation import ( EndpointFailureStage, Session, @@ -98,6 +97,8 @@ def failure(kind, stage, **identifiers): stage=stage, operation="finalize" if kind == "finalization" else None, error_class="fixture-failure", + error_code="fixture.endpoint" if kind == "endpoint" else None, + retryability="retryable" if kind == "endpoint" else None, component="Runtime" if kind == "finalization" else None, message="endpoint failed" if kind == "endpoint" else None, stem_id=identifiers.get("stem_id"), @@ -142,4 +143,6 @@ def failure(kind, stage, **identifiers): assert event.failures[0].stage is EndpointFailureStage.JOIN_FINALIZE assert event.failures[0].route_id == 3 assert event.failures[0].endpoint_id == 4 + assert event.failures[0].error_code == "fixture.endpoint" + assert event.failures[0].retryability.value == "retryable" assert event.failures[1].kind is SessionFailureKind.FINALIZATION diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 85038ce..c0cc794 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -32,6 +32,41 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "ChannelLayout", "ClockDomain", "Codec", + "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorHandler", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", + "ConnectorFactory", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRetryability", + "ConnectorRequirement", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", + "ConnectorWorker", + "ConnectorWorkerBuilder", "ControlClient", "ControlPlaneError", "CopyPolicy", @@ -46,6 +81,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "EndpointConfiguration", "EndpointDescriptor", "EndpointFailureStage", + "EndpointFailureRetryability", "EndpointMetrics", "EndpointObservationStage", "EndOfStream", @@ -91,6 +127,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "RecordingDiscontinuityKind", "RecordingState", "RecordingStemOutcome", + "RegisteredConnector", "RelayError", "RelayPublishOutcome", "RelayPublisher", @@ -176,6 +213,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "aio", "application_capture_available", "capture", + "connector", "discover_sources", "microphone_permission_observation", } From ef0cdc75d81b91c9c856c07173aa71dcc22bd7df Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 21:52:30 -0400 Subject: [PATCH 03/49] feat: add Python source and operator authoring --- README.md | 56 +++ native/src/lib.rs | 4 + native/src/operator_authoring/driver.rs | 372 ++++++++++++++++++ native/src/operator_authoring/mod.rs | 15 + native/src/operator_authoring/values.rs | 208 ++++++++++ native/src/session.rs | 18 + native/src/source_authoring/driver.rs | 299 ++++++++++++++ native/src/source_authoring/mod.rs | 17 + native/src/source_authoring/values.rs | 221 +++++++++++ python/pocketstation/__init__.py | 50 +++ python/pocketstation/_native.pyi | 106 +++++ python/pocketstation/aio/__init__.py | 42 ++ .../pocketstation/aio/operator_authoring.py | 301 ++++++++++++++ python/pocketstation/aio/session.py | 50 +++ python/pocketstation/aio/source_authoring.py | 299 ++++++++++++++ python/pocketstation/graph.py | 64 +++ python/pocketstation/operator_authoring.py | 313 +++++++++++++++ python/pocketstation/session.py | 32 ++ python/pocketstation/source_authoring.py | 346 ++++++++++++++++ tests/run_installed_stream_conformance.py | 15 +- tests/test_graph.py | 17 + tests/test_native_module_structure.py | 5 +- tests/test_operator_authoring.py | 181 +++++++++ tests/test_public_api.py | 23 ++ tests/test_source_authoring.py | 185 +++++++++ 25 files changed, 3234 insertions(+), 5 deletions(-) create mode 100644 native/src/operator_authoring/driver.rs create mode 100644 native/src/operator_authoring/mod.rs create mode 100644 native/src/operator_authoring/values.rs create mode 100644 native/src/source_authoring/driver.rs create mode 100644 native/src/source_authoring/mod.rs create mode 100644 native/src/source_authoring/values.rs create mode 100644 python/pocketstation/aio/operator_authoring.py create mode 100644 python/pocketstation/aio/source_authoring.py create mode 100644 python/pocketstation/operator_authoring.py create mode 100644 python/pocketstation/source_authoring.py create mode 100644 tests/test_operator_authoring.py create mode 100644 tests/test_source_authoring.py diff --git a/README.md b/README.md index 9ec3139..5824086 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ canonical-Session evidence is not release evidence: Connector worker, with typed configuration, redacted secrets, full input contracts, structured failures, readiness/health/recovery control, and drain/abort shutdown; +- sync and asyncio Source authoring over Core's blocking Source worker, with + typed output contracts, Session-owned lineage, finite async deadlines, + cancellation, and exact provider cleanup; +- sync and asyncio Operator authoring over Core's bounded async Operator + runtime, with compiled port/edge preparation, typed derived outputs, + finite async deadlines, cancellation, and derivation metadata; - native blocking waits release the interpreter, and executable tests prove Python remains responsive while a hung child is terminated and reaped; - immutable Session snapshots covering event and audio queues, source ingress, @@ -142,6 +148,50 @@ Process sidecars remain available when crash isolation or a separately managed process is wanted. They are not required for ordinary Python Connector authoring. +## Python Sources and Operators + +Python can define typed non-PCM Sources and Operators without implementing a +second Session runtime. These providers execute only on Core-owned blocking or +async worker partitions; application-owned PCM continues to use the dedicated +bounded `Session.audio_input()` path. + +```python +import pocketstation + +text = pocketstation.SignalSpec.text(role="request") +source_manifest = pocketstation.SourceManifest( + "io.example.source.requests.v1", + outputs=(pocketstation.PortSpec.output("events", text),), +) + +@pocketstation.source(source_manifest) +def requests(configuration): + yield pocketstation.SourceEmission.text( + "events", configuration["text"], signal=text + ) +``` + +Operators receive immutable envelopes and emit values whose lineage and +derivation are attached by Core: + +```python +result = pocketstation.SignalSpec.text(role="result.final") +operator_manifest = pocketstation.OperatorManifest( + "io.example.operator.uppercase.v1", + inputs=(pocketstation.PortSpec.input("input", text),), + outputs=(pocketstation.PortSpec.output("output", result),), +) + +@pocketstation.operator(operator_manifest) +def uppercase(_port, envelope): + return (pocketstation.OperatorEmission.text(envelope.payload.upper(), signal=result),) +``` + +`pocketstation.aio.source` and `pocketstation.aio.operator` accept async +iterables and coroutine handlers with explicit finite deadlines. The same +native Session remains authoritative for registration, compilation, +backpressure, cancellation, and terminal outcomes. + ## Python Connectors The concise path declares an audio contract and handles items directly. Core @@ -393,6 +443,9 @@ pocketstation/ __init__.py synchronous public surface audio_input.py bounded application-owned PCM input capture.py concise app + mic recipe + connector.py provider-facing outbound Connector authoring + source_authoring.py typed non-PCM Source authoring on Core workers + operator_authoring.py typed Operator authoring on Core workers session.py explicit Session lifecycle and declarations sources.py source selectors, discovery, permission, failure identity graph.py Stem / Endpoint / SignalSpec and route declarations @@ -408,6 +461,9 @@ pocketstation/ py.typed PEP 561 marker native/src/ lib.rs module registration only + connector/ Connector values, worker adapter, and observations + source_authoring/ Source manifest and SourceFactory/SourceDriver adapter + operator_authoring/ Operator manifest and AsyncOperator adapter audio_input.rs Core AudioInput projection and buffer crossing graph.rs exact graph values, handles, routes, and reentry projection extensions.rs ABI validation and immutable library-registration receipts diff --git a/native/src/lib.rs b/native/src/lib.rs index c7c184f..2b2b156 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -6,10 +6,12 @@ pub(crate) mod errors; pub(crate) mod extensions; pub(crate) mod graph; pub(crate) mod observations; +pub(crate) mod operator_authoring; pub(crate) mod relay; pub(crate) mod session; pub(crate) mod sidecar; pub(crate) mod signals; +pub(crate) mod source_authoring; pub(crate) mod sources; pub(crate) mod streams; @@ -20,6 +22,8 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { audio_input::register(module)?; connector::register(module)?; extensions::register(module)?; + operator_authoring::register(module)?; + source_authoring::register(module)?; sources::register(module)?; graph::register(module)?; signals::register(module)?; diff --git a/native/src/operator_authoring/driver.rs b/native/src/operator_authoring/driver.rs new file mode 100644 index 0000000..553fae8 --- /dev/null +++ b/native/src/operator_authoring/driver.rs @@ -0,0 +1,372 @@ +use std::sync::Arc; + +use pocketstation::graph::NodeConfig; +use pocketstation::{ + AsyncNode, AsyncNodeFuture, AsyncOperatorFactory, AsyncOperatorPrepareContext, ConfigError, + NodeError, OperatorId, PortDirection, PortPrepareContext, SignalDerivation, SignalEnvelope, + SignalLineage, SignalTiming, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::values::{PythonOperatorEmission, PythonOperatorManifest}; +use crate::errors::coded_reason; +use crate::graph::{PythonEdgeContract, PythonMediaCaps, PythonSignalSpec}; +use crate::signals::{copy_envelope, python_envelope}; + +pub(crate) fn register_operator( + session: &pocketstation::Session, + manifest: &PythonOperatorManifest, + factory: Py, +) -> PyResult<()> { + session + .register_operator(Arc::new(PythonOperatorFactory { + manifest: manifest.value.clone(), + factory, + })) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "operator.registration_failed", + error.to_string(), + )) + }) +} + +#[pyclass(name = "_OperatorPortContext", frozen)] +pub(crate) struct PythonOperatorPortContext { + #[pyo3(get)] + edge_id: Option, + #[pyo3(get)] + port_name: String, + #[pyo3(get)] + direction: &'static str, + #[pyo3(get)] + capacity_signals: usize, + signal: Py, + media: Py, + edge: Py, +} + +#[pymethods] +impl PythonOperatorPortContext { + #[getter] + fn signal(&self, py: Python<'_>) -> Py { + self.signal.clone_ref(py) + } + + #[getter] + fn media(&self, py: Python<'_>) -> Py { + self.media.clone_ref(py) + } + + #[getter] + fn edge(&self, py: Python<'_>) -> Py { + self.edge.clone_ref(py) + } +} + +#[pyclass(name = "_OperatorPrepareContext", frozen)] +pub(crate) struct PythonOperatorPrepareContext { + #[pyo3(get)] + execution_partition: &'static str, + inputs: Vec>, + outputs: Vec>, +} + +#[pymethods] +impl PythonOperatorPrepareContext { + #[getter] + fn inputs(&self, py: Python<'_>) -> Vec> { + self.inputs + .iter() + .map(|value| value.clone_ref(py)) + .collect() + } + + #[getter] + fn outputs(&self, py: Python<'_>) -> Vec> { + self.outputs + .iter() + .map(|value| value.clone_ref(py)) + .collect() + } +} + +struct PythonOperatorFactory { + manifest: pocketstation::AsyncOperatorManifest, + factory: Py, +} + +impl AsyncOperatorFactory for PythonOperatorFactory { + fn manifest(&self) -> &pocketstation::AsyncOperatorManifest { + &self.manifest + } + + fn validate_config(&self, configuration: &NodeConfig) -> Result<(), ConfigError> { + Python::attach(|py| { + let values = configuration_dict(py, configuration) + .map_err(|error| config_error("configuration", error))?; + self.factory + .bind(py) + .call_method1("validate_config", (values,)) + .map(|_| ()) + .map_err(|error| config_error("configuration", error)) + }) + } + + fn create(&self, configuration: &NodeConfig) -> Result, NodeError> { + Python::attach(|py| { + let values = configuration_dict(py, configuration).map_err(node_process_error)?; + let node = self + .factory + .bind(py) + .call_method1("create", (values,)) + .map_err(node_process_error)? + .unbind(); + Ok(Box::new(PythonOperatorNode { + node, + operator_id: self.manifest.operator_id().clone(), + revision: self.manifest.revision(), + generation: self.manifest.generation(), + last_input: None, + }) as Box) + }) + } +} + +struct PythonOperatorNode { + node: Py, + operator_id: OperatorId, + revision: u32, + generation: u32, + last_input: Option<(SignalLineage, SignalTiming)>, +} + +impl AsyncNode for PythonOperatorNode { + fn prepare<'a>( + &'a mut self, + context: &'a AsyncOperatorPrepareContext, + ) -> AsyncNodeFuture<'a, Result<(), NodeError>> { + Box::pin(async move { + Python::attach(|py| { + let context = python_prepare_context(py, context).map_err(node_prepare_error)?; + self.node + .bind(py) + .call_method1("prepare", (context,)) + .map(|_| ()) + .map_err(node_prepare_error) + }) + }) + } + + fn process<'a>( + &'a mut self, + input: SignalEnvelope, + ) -> AsyncNodeFuture<'a, Result, NodeError>> { + self.process_port("input", input) + } + + fn process_port<'a>( + &'a mut self, + input_port: &'a str, + input: SignalEnvelope, + ) -> AsyncNodeFuture<'a, Result, NodeError>> { + Box::pin(async move { + let lineage = input + .lineage() + .ok_or_else(|| NodeError::Process("operator input has no lineage".to_owned()))?; + let timing = input.timing(); + self.last_input = Some((lineage, timing)); + let emissions = Python::attach(|py| { + let input = Py::new(py, python_envelope(py, copy_envelope(&input))?)?; + let output = self + .node + .bind(py) + .call_method1("process", (input_port, input))?; + extract_emissions(&output) + }) + .map_err(node_process_error)?; + self.build_outputs(emissions, lineage, timing) + }) + } + + fn flush<'a>(&'a mut self) -> AsyncNodeFuture<'a, Result, NodeError>> { + Box::pin(async move { + let emissions = Python::attach(|py| { + let output = self.node.bind(py).call_method0("flush")?; + extract_emissions(&output) + }) + .map_err(node_process_error)?; + if emissions.is_empty() { + return Ok(Vec::new()); + } + let (lineage, timing) = self.last_input.ok_or_else(|| { + NodeError::Process("operator cannot flush output before input".to_owned()) + })?; + self.build_outputs(emissions, lineage, timing) + }) + } + + fn cancel<'a>(&'a mut self) -> AsyncNodeFuture<'a, Result<(), NodeError>> { + Box::pin(async move { + Python::attach(|py| { + self.node + .bind(py) + .call_method0("cancel") + .map(|_| ()) + .map_err(node_process_error) + }) + }) + } + + fn close<'a>(&'a mut self) -> AsyncNodeFuture<'a, Result<(), NodeError>> { + Box::pin(async move { + Python::attach(|py| { + self.node + .bind(py) + .call_method0("close") + .map(|_| ()) + .map_err(node_process_error) + }) + }) + } +} + +impl PythonOperatorNode { + fn build_outputs( + &self, + emissions: Vec, + lineage: SignalLineage, + timing: SignalTiming, + ) -> Result, NodeError> { + emissions + .into_iter() + .map(|emission| { + let derivation = SignalDerivation::new( + lineage, + timing, + self.operator_id.clone(), + self.revision, + self.generation, + None, + ) + .map_err(|error| NodeError::Process(error.to_string()))?; + Ok(SignalEnvelope::untracked( + emission.payload.into_core(), + emission.signal, + timing.observed_timestamp_ns(), + ) + .with_lineage(lineage, timing) + .with_derivation(derivation)) + }) + .collect() + } +} + +fn python_prepare_context( + py: Python<'_>, + context: &AsyncOperatorPrepareContext, +) -> PyResult> { + let inputs = context + .inputs() + .iter() + .map(|value| python_port_context(py, value)) + .collect::>>()?; + let outputs = context + .outputs() + .iter() + .map(|value| python_port_context(py, value)) + .collect::>>()?; + Py::new( + py, + PythonOperatorPrepareContext { + execution_partition: "async-worker", + inputs, + outputs, + }, + ) +} + +fn python_port_context( + py: Python<'_>, + value: &PortPrepareContext, +) -> PyResult> { + Py::new( + py, + PythonOperatorPortContext { + edge_id: value.edge_id().map(|edge| u64::from(edge.index())), + port_name: value.port_name().to_owned(), + direction: match value.direction() { + PortDirection::Input => "input", + PortDirection::Output => "output", + }, + capacity_signals: value.capacity_signals(), + signal: Py::new( + py, + PythonSignalSpec { + value: value.signal().clone(), + }, + )?, + media: Py::new( + py, + PythonMediaCaps { + value: value.media(), + }, + )?, + edge: Py::new( + py, + PythonEdgeContract { + value: value.edge_contract(), + }, + )?, + }, + ) +} + +fn extract_emissions(value: &Bound<'_, PyAny>) -> PyResult> { + value + .try_iter()? + .map(|value| { + value? + .extract::>() + .map(|value| value.clone()) + .map_err(Into::into) + }) + .collect() +} + +fn configuration_dict<'py>( + py: Python<'py>, + configuration: &NodeConfig, +) -> PyResult> { + let values = PyDict::new(py); + for (key, value) in configuration.iter() { + values.set_item(key, value)?; + } + Ok(values) +} + +fn config_error(key: &str, error: PyErr) -> ConfigError { + ConfigError::Invalid { + key: key.to_owned(), + reason: python_error_message(error), + } +} + +fn node_prepare_error(error: PyErr) -> NodeError { + NodeError::Prepare(python_error_message(error)) +} + +fn node_process_error(error: PyErr) -> NodeError { + NodeError::Process(python_error_message(error)) +} + +fn python_error_message(error: PyErr) -> String { + Python::attach(|py| { + error.value(py).str().map_or_else( + |_| error.to_string(), + |value| value.to_string_lossy().into_owned(), + ) + }) +} diff --git a/native/src/operator_authoring/mod.rs b/native/src/operator_authoring/mod.rs new file mode 100644 index 0000000..961db2e --- /dev/null +++ b/native/src/operator_authoring/mod.rs @@ -0,0 +1,15 @@ +mod driver; +mod values; + +pub(crate) use driver::register_operator; +pub(crate) use values::PythonOperatorManifest; + +use pyo3::prelude::*; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/operator_authoring/values.rs b/native/src/operator_authoring/values.rs new file mode 100644 index 0000000..3f6853a --- /dev/null +++ b/native/src/operator_authoring/values.rs @@ -0,0 +1,208 @@ +use pocketstation::{ + AsyncOperatorManifest, BackpressurePolicy, CopyPolicy, EdgeContract, ExecutionPartition, + MediaCaps, NodeDescriptor, NodeTypeId, OperatorCancellationPolicy, OperatorDeadlinePolicy, + OperatorFailurePolicy, OperatorId, OperatorOutputRolePolicy, OperatorPermissionPolicy, + PortDirection, SafetyContract, SemanticRole, SignalPayload, SignalSpec, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::errors::coded_reason; +use crate::graph::{PythonPortSpec, PythonSignalSpec}; + +fn invalid_operator(reason: impl Into) -> PyErr { + PyValueError::new_err(coded_reason("operator.invalid_contract", reason.into())) +} + +#[pyclass(name = "_OperatorManifest", frozen)] +#[derive(Clone)] +pub(crate) struct PythonOperatorManifest { + pub(crate) value: AsyncOperatorManifest, +} + +#[pymethods] +impl PythonOperatorManifest { + #[new] + #[pyo3(signature = (operator_id, inputs, outputs, revision=1, implementation_generation=1, queue_capacity_signals=8, process_timeout_ms=30_000, network_allowed=false, filesystem_allowed=false, drain_queued=false, continue_on_failure=false, terminal_roles=Vec::new()))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + operator_id: String, + inputs: Vec>, + outputs: Vec>, + revision: u32, + implementation_generation: u32, + queue_capacity_signals: usize, + process_timeout_ms: u32, + network_allowed: bool, + filesystem_allowed: bool, + drain_queued: bool, + continue_on_failure: bool, + terminal_roles: Vec, + ) -> PyResult { + let inputs = inputs + .into_iter() + .map(|value| value.borrow(py).value.clone()) + .collect::>(); + let outputs = outputs + .into_iter() + .map(|value| value.borrow(py).value.clone()) + .collect::>(); + if inputs + .iter() + .any(|port| port.direction() != PortDirection::Input) + || outputs + .iter() + .any(|port| port.direction() != PortDirection::Output) + { + return Err(invalid_operator( + "operator inputs and outputs must use their corresponding port directions", + )); + } + let input_media = common_media(&inputs, "input")?; + let output_media = common_media(&outputs, "output")?; + let input_edge = if matches!(input_media, MediaCaps::Audio(_)) { + EdgeContract::realtime_audio() + .with_media(input_media) + .with_copy_policy(CopyPolicy::CopyToBranchPool) + } else { + EdgeContract::bounded_async() + .with_media(input_media) + .with_backpressure(BackpressurePolicy::DropNewest) + .with_copy_policy(CopyPolicy::CopyToBranchPool) + }; + let output_edge = EdgeContract::bounded_async() + .with_media(output_media) + .with_copy_policy(CopyPolicy::CopyToBranchPool); + let roles = outputs + .iter() + .filter_map(|port| { + port.signal() + .role() + .map(|role| SemanticRole::new(role.as_str())) + }) + .collect::>(); + let terminal = terminal_roles + .into_iter() + .map(SemanticRole::new) + .collect::>(); + let descriptor = NodeDescriptor::new( + NodeTypeId::from(operator_id.as_str()), + "Python operator", + inputs, + outputs, + ExecutionPartition::AsyncWorker, + SafetyContract::AllocationAllowed, + true, + ) + .map_err(|error| invalid_operator(error.to_string()))?; + AsyncOperatorManifest::new( + OperatorId::new(operator_id), + revision, + implementation_generation, + descriptor, + input_edge, + output_edge, + queue_capacity_signals, + OperatorPermissionPolicy { + network_allowed, + filesystem_allowed, + }, + OperatorDeadlinePolicy { process_timeout_ms }, + if drain_queued { + OperatorCancellationPolicy::DrainQueued + } else { + OperatorCancellationPolicy::DiscardQueued + }, + if continue_on_failure { + OperatorFailurePolicy::Continue + } else { + OperatorFailurePolicy::StopWorker + }, + OperatorOutputRolePolicy { + allowed: roles, + terminal, + }, + ) + .map(|value| Self { value }) + .map_err(|error| invalid_operator(error.to_string())) + } + + #[getter] + fn operator_id(&self) -> String { + self.value.operator_id().as_str().to_owned() + } +} + +fn common_media(ports: &[pocketstation::PortSpec], kind: &str) -> PyResult { + let first = ports + .first() + .ok_or_else(|| invalid_operator(format!("operator requires at least one {kind} port")))? + .media(); + if ports + .iter() + .any(|port| !port.media().is_compatible_with(&first)) + { + return Err(invalid_operator(format!( + "operator {kind} ports must share one compatible edge media contract" + ))); + } + Ok(first) +} + +#[derive(Clone)] +pub(super) enum PythonOperatorPayload { + Text(String), + Bytes(Vec), +} + +impl PythonOperatorPayload { + pub(super) fn into_core(self) -> SignalPayload { + match self { + Self::Text(value) => SignalPayload::Text(value), + Self::Bytes(value) => SignalPayload::Bytes(value), + } + } +} + +#[pyclass(name = "_OperatorEmission", frozen)] +#[derive(Clone)] +pub(crate) struct PythonOperatorEmission { + pub(super) signal: SignalSpec, + pub(super) payload: PythonOperatorPayload, +} + +#[pymethods] +impl PythonOperatorEmission { + #[staticmethod] + fn text(payload: String, signal: &PythonSignalSpec) -> PyResult { + Self::new(PythonOperatorPayload::Text(payload), signal) + } + + #[staticmethod] + fn bytes(payload: Vec, signal: &PythonSignalSpec) -> PyResult { + Self::new(PythonOperatorPayload::Bytes(payload), signal) + } +} + +impl PythonOperatorEmission { + fn new(payload: PythonOperatorPayload, signal: &PythonSignalSpec) -> PyResult { + let supported = match &payload { + PythonOperatorPayload::Text(_) => { + SignalPayload::Text(String::new()).supports(&signal.value) + } + PythonOperatorPayload::Bytes(_) => { + SignalPayload::Bytes(Vec::new()).supports(&signal.value) + } + }; + if !supported { + return Err(invalid_operator( + "operator emission payload does not match its SignalSpec", + )); + } + Ok(Self { + signal: signal.value.clone(), + payload, + }) + } +} diff --git a/native/src/session.rs b/native/src/session.rs index 939fdf8..a4d6567 100644 --- a/native/src/session.rs +++ b/native/src/session.rs @@ -29,6 +29,7 @@ use crate::observations::{ OwnedSessionMetrics, OwnedStopResult, PythonSessionEvent, PythonSessionMetrics, PythonStopResult, }; +use crate::operator_authoring::{register_operator, PythonOperatorManifest}; use crate::relay::{ owned_relay_outcomes, python_relay_outcome, PythonRelayPublisher, RelayRouteRegistration, RelayRuntime, @@ -44,6 +45,7 @@ use crate::signals::{ OwnedSignalSubscriptionMetrics, PythonBusSubscription, PythonSignalRead, PythonSignalSubscriptionMetrics, SignalReceipts, }; +use crate::source_authoring::{register_source, PythonRegisteredSource, PythonSourceManifest}; use crate::sources::PythonSource; use crate::streams::{ copy_audio_batch, copy_audio_batch_until, python_audio_batch, request_audio_batch, @@ -376,6 +378,22 @@ impl PythonSession { }) } + fn register_source_provider( + &self, + manifest: &PythonSourceManifest, + factory: Py, + ) -> PyResult { + self.with_session(|session| register_source(session, manifest, factory)) + } + + fn register_operator_provider( + &self, + manifest: &PythonOperatorManifest, + factory: Py, + ) -> PyResult<()> { + self.with_session(|session| register_operator(session, manifest, factory)) + } + fn declare_connector( &self, registered: &PythonRegisteredConnector, diff --git a/native/src/source_authoring/driver.rs b/native/src/source_authoring/driver.rs new file mode 100644 index 0000000..861b713 --- /dev/null +++ b/native/src/source_authoring/driver.rs @@ -0,0 +1,299 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use pocketstation::{ + ClockDomainId, ConfigError, SignalEnvelope, SignalLineage, SignalTiming, SourceCancellation, + SourceConfiguration, SourceDriver, SourceDriverError, SourceEmission, SourceFactory, + SourceOutputIdentity, SourcePrepareContext, SourceSessionContext, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::values::{PythonSourceEmission, PythonSourceManifest}; +use crate::errors::coded_reason; + +#[pyclass(name = "_SourceOutputIdentity", frozen)] +pub(crate) struct PythonSourceOutputIdentity { + #[pyo3(get)] + output_port: String, + #[pyo3(get)] + stream_id: u64, +} + +impl From<&SourceOutputIdentity> for PythonSourceOutputIdentity { + fn from(value: &SourceOutputIdentity) -> Self { + Self { + output_port: value.output_port.clone(), + stream_id: value.stream_id.get(), + } + } +} + +#[pyclass(name = "_SourcePrepareContext", frozen)] +pub(crate) struct PythonSourcePrepareContext { + #[pyo3(get)] + source_type_id: String, + #[pyo3(get)] + session_id: Option, + #[pyo3(get)] + source_id: Option, + outputs: Vec>, +} + +#[pymethods] +impl PythonSourcePrepareContext { + #[getter] + fn outputs(&self, py: Python<'_>) -> Vec> { + self.outputs + .iter() + .map(|value| value.clone_ref(py)) + .collect() + } +} + +#[pyclass(name = "_SourceCancellation", frozen)] +pub(crate) struct PythonSourceCancellation { + value: SourceCancellation, +} + +#[pymethods] +impl PythonSourceCancellation { + #[getter] + fn cancelled(&self) -> bool { + self.value.is_cancelled() + } +} + +#[pyclass(name = "_RegisteredSource", frozen)] +pub(crate) struct PythonRegisteredSource { + #[pyo3(get)] + source_type_id: String, +} + +pub(crate) fn register_source( + session: &pocketstation::Session, + manifest: &PythonSourceManifest, + factory: Py, +) -> PyResult { + let source_type_id = manifest.value.source_type_id().as_str().to_owned(); + session + .register_source(Arc::new(PythonSourceFactory { + manifest: manifest.value.clone(), + factory, + })) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "source.registration_failed", + error.to_string(), + )) + })?; + Ok(PythonRegisteredSource { source_type_id }) +} + +struct PythonSourceFactory { + manifest: pocketstation::SourceManifest, + factory: Py, +} + +impl SourceFactory for PythonSourceFactory { + fn manifest(&self) -> &pocketstation::SourceManifest { + &self.manifest + } + + fn validate_config(&self, configuration: &SourceConfiguration) -> Result<(), ConfigError> { + Python::attach(|py| { + let values = configuration_dict(py, configuration) + .map_err(|error| config_error("configuration", error))?; + self.factory + .bind(py) + .call_method1("validate_config", (values,)) + .map(|_| ()) + .map_err(|error| config_error("configuration", error)) + }) + } + + fn create( + &self, + configuration: &SourceConfiguration, + ) -> Result, SourceDriverError> { + Python::attach(|py| { + let values = configuration_dict(py, configuration).map_err(driver_error)?; + let driver = self + .factory + .bind(py) + .call_method1("create", (values,)) + .map_err(driver_error)? + .unbind(); + Ok(Box::new(PythonSourceDriver { + driver, + session: None, + sequences: BTreeMap::new(), + }) as Box) + }) + } +} + +struct PythonSourceDriver { + driver: Py, + session: Option, + sequences: BTreeMap, +} + +impl SourceDriver for PythonSourceDriver { + fn prepare(&mut self, context: &SourcePrepareContext) -> Result<(), SourceDriverError> { + Python::attach(|py| { + let outputs = context + .session + .as_ref() + .map(|session| { + session + .outputs + .iter() + .map(|output| Py::new(py, PythonSourceOutputIdentity::from(output))) + .collect::>>() + }) + .transpose() + .map_err(driver_error)? + .unwrap_or_default(); + let prepared = Py::new( + py, + PythonSourcePrepareContext { + source_type_id: context.manifest.source_type_id().as_str().to_owned(), + session_id: context.session.as_ref().map(|value| value.session_id.get()), + source_id: context.session.as_ref().map(|value| value.source_id.get()), + outputs, + }, + ) + .map_err(driver_error)?; + self.driver + .bind(py) + .call_method1("prepare", (prepared,)) + .map_err(driver_error)?; + self.session = context.session.clone(); + Ok(()) + }) + } + + fn next( + &mut self, + cancellation: &SourceCancellation, + ) -> Result, SourceDriverError> { + let emission = Python::attach(|py| { + let cancellation = Py::new( + py, + PythonSourceCancellation { + value: cancellation.clone(), + }, + ) + .map_err(driver_error)?; + let value = self + .driver + .bind(py) + .call_method1("next", (cancellation,)) + .map_err(driver_error)?; + if value.is_none() { + return Ok(None); + } + value + .extract::>() + .map(|value| Some(value.clone())) + .map_err(|error| driver_error(error.into())) + })?; + emission + .map(|emission| self.build_core_emission(emission)) + .transpose() + } + + fn close(&mut self) -> Result<(), SourceDriverError> { + Python::attach(|py| { + self.driver + .bind(py) + .call_method0("close") + .map(|_| ()) + .map_err(driver_error) + }) + } +} + +impl PythonSourceDriver { + fn build_core_emission( + &mut self, + emission: PythonSourceEmission, + ) -> Result { + let session = self.session.as_ref().ok_or_else(|| { + SourceDriverError::Failed("Python source has no Session prepare context".to_owned()) + })?; + let output = session.output(&emission.output_port).ok_or_else(|| { + SourceDriverError::Failed(format!( + "Python source emitted unknown output {:?}", + emission.output_port + )) + })?; + let sequence = self + .sequences + .entry(emission.output_port.clone()) + .or_default(); + let sequence_number = *sequence; + *sequence = sequence.saturating_add(1); + let observed_timestamp_ns = emission + .observed_timestamp_ns + .unwrap_or_else(pocketstation::timing::monotonic_timestamp_ns); + let timing = SignalTiming::try_new( + emission.source_timestamp_ns, + observed_timestamp_ns, + emission.source_timestamp_ns, + emission.duration_ns, + ) + .map_err(|error| SourceDriverError::Failed(error.to_string()))?; + let lineage = SignalLineage::try_new( + session.session_id, + output.stream_id, + session.source_id, + ClockDomainId::new(emission.clock_domain_id), + sequence_number, + emission.source_generation, + emission.discontinuity_epoch, + emission.policy_epoch, + ) + .map_err(|error| SourceDriverError::Failed(error.to_string()))?; + let envelope = SignalEnvelope::untracked( + emission.payload.into_core(), + emission.signal, + observed_timestamp_ns, + ) + .with_lineage(lineage, timing); + Ok(SourceEmission { + output_port: emission.output_port, + envelope, + terminal: emission.terminal, + }) + } +} + +fn configuration_dict<'py>( + py: Python<'py>, + configuration: &SourceConfiguration, +) -> PyResult> { + let values = PyDict::new(py); + for (key, value) in configuration.iter() { + values.set_item(key, value)?; + } + Ok(values) +} + +fn config_error(stage: &str, error: PyErr) -> ConfigError { + ConfigError::Invalid { + key: stage.to_owned(), + reason: Python::attach(|py| error.value(py).to_string()), + } +} + +fn driver_error(error: PyErr) -> SourceDriverError { + SourceDriverError::Failed(Python::attach(|py| { + error.value(py).str().map_or_else( + |_| error.to_string(), + |value| value.to_string_lossy().into_owned(), + ) + })) +} diff --git a/native/src/source_authoring/mod.rs b/native/src/source_authoring/mod.rs new file mode 100644 index 0000000..a4f089e --- /dev/null +++ b/native/src/source_authoring/mod.rs @@ -0,0 +1,17 @@ +mod driver; +mod values; + +pub(crate) use driver::{register_source, PythonRegisteredSource}; +pub(crate) use values::{PythonSourceEmission, PythonSourceManifest}; + +use pyo3::prelude::*; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/source_authoring/values.rs b/native/src/source_authoring/values.rs new file mode 100644 index 0000000..3e476e3 --- /dev/null +++ b/native/src/source_authoring/values.rs @@ -0,0 +1,221 @@ +use pocketstation::{ + ExecutionPartition, PortDirection, SafetyContract, SignalPayload, SignalSpec, SourceManifest, + SourceTypeId, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::errors::coded_reason; +use crate::graph::{PythonPortSpec, PythonSignalSpec}; + +fn invalid_source(reason: impl Into) -> PyErr { + PyValueError::new_err(coded_reason("source.invalid_contract", reason.into())) +} + +#[pyclass(name = "_SourceManifest", frozen)] +#[derive(Clone)] +pub(crate) struct PythonSourceManifest { + pub(crate) value: SourceManifest, +} + +#[pymethods] +impl PythonSourceManifest { + #[new] + #[pyo3(signature = (source_type_id, outputs, revision=1, implementation_generation=1))] + fn new( + py: Python<'_>, + source_type_id: String, + outputs: Vec>, + revision: u32, + implementation_generation: u32, + ) -> PyResult { + let outputs = outputs + .into_iter() + .map(|output| output.borrow(py).value.clone()) + .collect::>(); + if outputs.iter().any(|output| { + output.direction() != PortDirection::Output || output.signal().class().is_audio() + }) { + return Err(invalid_source( + "Python-authored sources require non-PCM output ports; use Session.audio_input() for application-owned PCM", + )); + } + let source_type_id = + SourceTypeId::new(source_type_id).map_err(|error| invalid_source(error.to_string()))?; + SourceManifest::new( + source_type_id, + revision, + implementation_generation, + outputs, + ExecutionPartition::BlockingWorker, + SafetyContract::AllocationAllowed, + ) + .map(|value| Self { value }) + .map_err(|error| invalid_source(error.to_string())) + } + + #[getter] + fn source_type_id(&self) -> String { + self.value.source_type_id().as_str().to_owned() + } + + #[getter] + fn revision(&self) -> u32 { + self.value.revision() + } + + #[getter] + fn implementation_generation(&self) -> u32 { + self.value.implementation_generation() + } +} + +#[derive(Clone)] +pub(super) enum PythonSourcePayload { + Text(String), + Bytes(Vec), +} + +impl PythonSourcePayload { + pub(super) fn into_core(self) -> SignalPayload { + match self { + Self::Text(value) => SignalPayload::Text(value), + Self::Bytes(value) => SignalPayload::Bytes(value), + } + } +} + +#[pyclass(name = "_SourceEmission", frozen)] +#[derive(Clone)] +pub(crate) struct PythonSourceEmission { + pub(super) output_port: String, + pub(super) signal: SignalSpec, + pub(super) payload: PythonSourcePayload, + pub(super) source_timestamp_ns: Option, + pub(super) observed_timestamp_ns: Option, + pub(super) duration_ns: Option, + pub(super) source_generation: u32, + pub(super) discontinuity_epoch: u64, + pub(super) policy_epoch: u64, + pub(super) clock_domain_id: u32, + pub(super) terminal: bool, +} + +#[pymethods] +impl PythonSourceEmission { + #[staticmethod] + #[pyo3(signature = (output_port, payload, signal, source_timestamp_ns=None, observed_timestamp_ns=None, duration_ns=None, source_generation=1, discontinuity_epoch=0, policy_epoch=0, clock_domain_id=1, terminal=false))] + #[allow(clippy::too_many_arguments)] + fn text( + output_port: String, + payload: String, + signal: &PythonSignalSpec, + source_timestamp_ns: Option, + observed_timestamp_ns: Option, + duration_ns: Option, + source_generation: u32, + discontinuity_epoch: u64, + policy_epoch: u64, + clock_domain_id: u32, + terminal: bool, + ) -> PyResult { + Self::new( + output_port, + PythonSourcePayload::Text(payload), + signal, + source_timestamp_ns, + observed_timestamp_ns, + duration_ns, + source_generation, + discontinuity_epoch, + policy_epoch, + clock_domain_id, + terminal, + ) + } + + #[staticmethod] + #[pyo3(signature = (output_port, payload, signal, source_timestamp_ns=None, observed_timestamp_ns=None, duration_ns=None, source_generation=1, discontinuity_epoch=0, policy_epoch=0, clock_domain_id=1, terminal=false))] + #[allow(clippy::too_many_arguments)] + fn bytes( + output_port: String, + payload: Vec, + signal: &PythonSignalSpec, + source_timestamp_ns: Option, + observed_timestamp_ns: Option, + duration_ns: Option, + source_generation: u32, + discontinuity_epoch: u64, + policy_epoch: u64, + clock_domain_id: u32, + terminal: bool, + ) -> PyResult { + Self::new( + output_port, + PythonSourcePayload::Bytes(payload), + signal, + source_timestamp_ns, + observed_timestamp_ns, + duration_ns, + source_generation, + discontinuity_epoch, + policy_epoch, + clock_domain_id, + terminal, + ) + } +} + +impl PythonSourceEmission { + #[allow(clippy::too_many_arguments)] + fn new( + output_port: String, + payload: PythonSourcePayload, + signal: &PythonSignalSpec, + source_timestamp_ns: Option, + observed_timestamp_ns: Option, + duration_ns: Option, + source_generation: u32, + discontinuity_epoch: u64, + policy_epoch: u64, + clock_domain_id: u32, + terminal: bool, + ) -> PyResult { + if output_port.trim().is_empty() { + return Err(invalid_source( + "source emission output port cannot be empty", + )); + } + if source_generation == 0 { + return Err(invalid_source( + "source emission generation must be greater than zero", + )); + } + let payload_supported = match &payload { + PythonSourcePayload::Text(_) => { + SignalPayload::Text(String::new()).supports(&signal.value) + } + PythonSourcePayload::Bytes(_) => { + SignalPayload::Bytes(Vec::new()).supports(&signal.value) + } + }; + if !payload_supported { + return Err(invalid_source( + "source emission payload does not match its SignalSpec", + )); + } + Ok(Self { + output_port, + signal: signal.value.clone(), + payload, + source_timestamp_ns, + observed_timestamp_ns, + duration_ns, + source_generation, + discontinuity_epoch, + policy_epoch, + clock_domain_id, + terminal, + }) + } +} diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index 1debb55..1c07bf2 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -152,6 +152,19 @@ TerminationDisposition, TypedEdgeMetrics, ) +from .operator_authoring import ( + OperatorConfigValidator, + OperatorEmission, + OperatorFactory, + OperatorHandler, + OperatorManifest, + OperatorNode, + OperatorPortContext, + OperatorPrepareContext, + OperatorProvider, + RegisteredOperator, + operator, +) from .relay import ( PublisherActivation, ReceiverActivation, @@ -200,6 +213,20 @@ SignalSubscriptionMetrics, SignalTiming, ) +from .source_authoring import ( + RegisteredSource, + SourceCancellation, + SourceConfigValidator, + SourceDriver, + SourceEmission, + SourceFactory, + SourceIterableFactory, + SourceManifest, + SourceOutputIdentity, + SourcePrepareContext, + SourceProvider, + source, +) from .sources import ( DiscoveredSource, PermissionObservation, @@ -319,11 +346,20 @@ "NativeExtensionLibrary", "NativeExtensionRegistration", "Operator", + "OperatorConfigValidator", "OperatorConfiguration", + "OperatorEmission", + "OperatorFactory", + "OperatorHandler", "OperatorInput", "OperatorInputMetrics", "OperatorInstance", + "OperatorManifest", "OperatorMetrics", + "OperatorNode", + "OperatorPortContext", + "OperatorPrepareContext", + "OperatorProvider", "OperatorWorkerMetrics", "PcmSource", "PermissionObservation", @@ -343,6 +379,8 @@ "RecordingState", "RecordingStemOutcome", "RegisteredConnector", + "RegisteredOperator", + "RegisteredSource", "RelayError", "RelayPublishOutcome", "RelayPublisher", @@ -401,13 +439,23 @@ "SignalSubscriptionMetrics", "SignalTiming", "Source", + "SourceCancellation", + "SourceConfigValidator", "SourceConfiguration", + "SourceDriver", + "SourceEmission", + "SourceFactory", "SourceFailureClass", "SourceIdentityStrength", "SourceInstance", + "SourceIterableFactory", "SourceKind", + "SourceManifest", "SourceMetrics", "SourceOutput", + "SourceOutputIdentity", + "SourcePrepareContext", + "SourceProvider", "SourceQuery", "SourceRecoveryRequirement", "SourceRuntimeEvent", @@ -430,4 +478,6 @@ "connector", "discover_sources", "microphone_permission_observation", + "operator", + "source", ] diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index 073ceb8..83b25e8 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -962,6 +962,102 @@ class _AudioInput: def close(self) -> None: ... def observations(self) -> _AudioInputObservations: ... +class _SourceManifest: + def __init__( + self, + source_type_id: str, + outputs: list[_PortSpec], + revision: int = 1, + implementation_generation: int = 1, + ) -> None: ... + source_type_id: str + revision: int + implementation_generation: int + +class _SourceEmission: + @staticmethod + def text( + output_port: str, + payload: str, + signal: _SignalSpec, + source_timestamp_ns: int | None = None, + observed_timestamp_ns: int | None = None, + duration_ns: int | None = None, + source_generation: int = 1, + discontinuity_epoch: int = 0, + policy_epoch: int = 0, + clock_domain_id: int = 1, + terminal: bool = False, + ) -> _SourceEmission: ... + @staticmethod + def bytes( + output_port: str, + payload: bytes, + signal: _SignalSpec, + source_timestamp_ns: int | None = None, + observed_timestamp_ns: int | None = None, + duration_ns: int | None = None, + source_generation: int = 1, + discontinuity_epoch: int = 0, + policy_epoch: int = 0, + clock_domain_id: int = 1, + terminal: bool = False, + ) -> _SourceEmission: ... + +class _SourceOutputIdentity: + output_port: str + stream_id: int + +class _SourcePrepareContext: + source_type_id: str + session_id: int | None + source_id: int | None + outputs: list[_SourceOutputIdentity] + +class _SourceCancellation: + cancelled: bool + +class _RegisteredSource: + source_type_id: str + +class _OperatorManifest: + def __init__( + self, + operator_id: str, + inputs: list[_PortSpec], + outputs: list[_PortSpec], + revision: int = 1, + implementation_generation: int = 1, + queue_capacity_signals: int = 8, + process_timeout_ms: int = 30_000, + network_allowed: bool = False, + filesystem_allowed: bool = False, + drain_queued: bool = False, + continue_on_failure: bool = False, + terminal_roles: list[str] = [], + ) -> None: ... + operator_id: str + +class _OperatorEmission: + @staticmethod + def text(payload: str, signal: _SignalSpec) -> _OperatorEmission: ... + @staticmethod + def bytes(payload: bytes, signal: _SignalSpec) -> _OperatorEmission: ... + +class _OperatorPortContext: + edge_id: int | None + port_name: str + direction: str + capacity_signals: int + signal: _SignalSpec + media: _MediaCaps + edge: _EdgeContract + +class _OperatorPrepareContext: + execution_partition: str + inputs: list[_OperatorPortContext] + outputs: list[_OperatorPortContext] + class Session: def __init__( self, @@ -1023,6 +1119,16 @@ class Session: factory: object, maximum_batch_items: int, ) -> _RegisteredConnector: ... + def register_source_provider( + self, + manifest: _SourceManifest, + factory: object, + ) -> _RegisteredSource: ... + def register_operator_provider( + self, + manifest: _OperatorManifest, + factory: object, + ) -> None: ... def declare_connector( self, registered: _RegisteredConnector, diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index 8c9357a..7fa6f1d 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -26,9 +26,32 @@ NativeExtensionRegistration, ) from .observations import EventStream +from .operator_authoring import ( + OperatorDeadlines, + OperatorFactory, + OperatorHandler, + OperatorManifest, + OperatorNode, + OperatorNodeBuilder, + OperatorProvider, + RegisteredOperator, + operator, +) from .relay import RelaySession from .session import RunningSession, Session from .sidecar import SidecarConnection, SidecarStream +from .source_authoring import ( + RegisteredSource, + SourceCancellation, + SourceDeadlines, + SourceDriver, + SourceDriverBuilder, + SourceFactory, + SourceIterableFactory, + SourceManifest, + SourceProvider, + source, +) from .sources import ( application_capture_available, discover_sources, @@ -58,17 +81,36 @@ "ExtensionPortDirection", "NativeExtensionLibrary", "NativeExtensionRegistration", + "OperatorDeadlines", + "OperatorFactory", + "OperatorHandler", + "OperatorManifest", + "OperatorNode", + "OperatorNodeBuilder", + "OperatorProvider", "PcmSource", "RegisteredConnector", + "RegisteredOperator", + "RegisteredSource", "RelaySession", "RunningSession", "Session", "SidecarConnection", "SidecarStream", "SignalStream", + "SourceCancellation", + "SourceDeadlines", + "SourceDriver", + "SourceDriverBuilder", + "SourceFactory", + "SourceIterableFactory", + "SourceManifest", + "SourceProvider", "application_capture_available", "capture", "connector", "discover_sources", "microphone_permission_observation", + "operator", + "source", ] diff --git a/python/pocketstation/aio/operator_authoring.py b/python/pocketstation/aio/operator_authoring.py new file mode 100644 index 0000000..99efc2e --- /dev/null +++ b/python/pocketstation/aio/operator_authoring.py @@ -0,0 +1,301 @@ +"""Asyncio Operator authoring over Core's bounded Operator runtime.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine, Mapping, Sequence +from concurrent.futures import CancelledError as FutureCancelledError +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from dataclasses import dataclass +from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable + +from ..operator_authoring import ( + OperatorConfigValidator, + OperatorEmission, + OperatorManifest, + OperatorPrepareContext, + RegisteredOperator, +) +from ..operator_authoring import OperatorNode as SyncOperatorNode +from ..operator_authoring import OperatorProvider as SyncOperatorProvider +from ..signal import SignalEnvelope + +_Result = TypeVar("_Result") + + +@dataclass(frozen=True, slots=True) +class OperatorDeadlines: + """Finite waits while Core awaits asyncio Operator work.""" + + create_s: float = 5.0 + prepare_s: float = 5.0 + process_s: float = 30.0 + close_s: float = 5.0 + + def __post_init__(self) -> None: + for name, value in ( + ("create_s", self.create_s), + ("prepare_s", self.prepare_s), + ("process_s", self.process_s), + ("close_s", self.close_s), + ): + if not 0 < value <= 300: + raise ValueError(f"{name} must be greater than 0 and at most 300") + + +class OperatorNode: + async def prepare(self, context: OperatorPrepareContext) -> None: + """Observe compiled port and edge contracts before processing.""" + + async def process( + self, input_port: str, envelope: SignalEnvelope + ) -> Sequence[OperatorEmission]: + raise NotImplementedError + + async def flush(self) -> Sequence[OperatorEmission]: + return () + + async def cancel(self) -> None: + """Cancel provider work after Core requests cancellation.""" + + async def close(self) -> None: + """Release provider resources exactly once.""" + + +@runtime_checkable +class OperatorFactory(Protocol): + def validate_config(self, configuration: Mapping[str, str]) -> None: ... + + async def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... + + +OperatorNodeBuilder: TypeAlias = Callable[ + [Mapping[str, str]], Coroutine[Any, Any, OperatorNode] +] +OperatorHandler: TypeAlias = Callable[ + [str, SignalEnvelope], Coroutine[Any, Any, Sequence[OperatorEmission]] +] + + +class _HandlerNode(OperatorNode): + __slots__ = ("_handler",) + + def __init__(self, handler: OperatorHandler) -> None: + self._handler = handler + + async def process( + self, input_port: str, envelope: SignalEnvelope + ) -> Sequence[OperatorEmission]: + return await self._handler(input_port, envelope) + + +class _HandlerFactory: + __slots__ = ("_handler", "_validator") + + def __init__( + self, + handler: OperatorHandler, + validator: OperatorConfigValidator | None, + ) -> None: + self._handler = handler + self._validator = validator + + def validate_config(self, configuration: Mapping[str, str]) -> None: + if self._validator is not None: + self._validator(configuration) + + async def create(self, _configuration: Mapping[str, str]) -> OperatorNode: + return _HandlerNode(self._handler) + + +class _NodeAdapter(SyncOperatorNode): + __slots__ = ("_deadlines", "_loop", "_node") + + def __init__( + self, + node: OperatorNode, + loop: asyncio.AbstractEventLoop, + deadlines: OperatorDeadlines, + ) -> None: + self._node = node + self._loop = loop + self._deadlines = deadlines + + def prepare(self, context: OperatorPrepareContext) -> None: + _wait_for_operator( + self._loop, + self._node.prepare(context), + timeout_s=self._deadlines.prepare_s, + ) + + def process( + self, input_port: str, envelope: SignalEnvelope + ) -> Sequence[OperatorEmission]: + return _wait_for_operator( + self._loop, + self._node.process(input_port, envelope), + timeout_s=self._deadlines.process_s, + ) + + def flush(self) -> Sequence[OperatorEmission]: + return _wait_for_operator( + self._loop, + self._node.flush(), + timeout_s=self._deadlines.process_s, + ) + + def cancel(self) -> None: + _wait_for_operator( + self._loop, + self._node.cancel(), + timeout_s=self._deadlines.close_s, + ) + + def close(self) -> None: + _wait_for_operator( + self._loop, + self._node.close(), + timeout_s=self._deadlines.close_s, + ) + + +class _FactoryAdapter: + __slots__ = ("_deadlines", "_factory", "_loop") + + def __init__( + self, + factory: OperatorFactory | OperatorNodeBuilder, + loop: asyncio.AbstractEventLoop, + deadlines: OperatorDeadlines, + ) -> None: + self._factory = factory + self._loop = loop + self._deadlines = deadlines + + def validate_config(self, configuration: Mapping[str, str]) -> None: + validator = getattr(self._factory, "validate_config", None) + if validator is not None: + validator(configuration) + + def create(self, configuration: Mapping[str, str]) -> _NodeAdapter: + create = getattr(self._factory, "create", None) + awaitable = ( + self._factory(configuration) # type: ignore[operator] + if create is None + else create(configuration) + ) + node = _wait_for_operator( + self._loop, + awaitable, + timeout_s=self._deadlines.create_s, + ) + if not hasattr(node, "process"): + raise TypeError("async Operator factory must return an OperatorNode") + return _NodeAdapter(node, self._loop, self._deadlines) + + +@dataclass(frozen=True, slots=True) +class OperatorProvider: + manifest: OperatorManifest + factory: OperatorFactory | OperatorNodeBuilder + deadlines: OperatorDeadlines = OperatorDeadlines() + + def __post_init__(self) -> None: + if self.deadlines.process_s * 1_000 > self.manifest.process_timeout_ms: + raise ValueError( + "async Operator process deadline cannot exceed the Core " + "manifest deadline" + ) + + @classmethod + def with_node( + cls, + manifest: OperatorManifest, + factory: OperatorFactory | OperatorNodeBuilder, + *, + deadlines: OperatorDeadlines | None = None, + ) -> OperatorProvider: + selected = deadlines or OperatorDeadlines( + process_s=manifest.process_timeout_ms / 1_000 + ) + return cls(manifest, factory, selected) + + @classmethod + def from_handler( + cls, + manifest: OperatorManifest, + handler: OperatorHandler, + *, + validate_config: OperatorConfigValidator | None = None, + deadlines: OperatorDeadlines | None = None, + ) -> OperatorProvider: + return cls.with_node( + manifest, + _HandlerFactory(handler, validate_config), + deadlines=deadlines, + ) + + def _bind(self, loop: asyncio.AbstractEventLoop) -> SyncOperatorProvider: + if not loop.is_running(): + raise RuntimeError("async Operator requires a running event loop") + return SyncOperatorProvider.with_node( + self.manifest, + _FactoryAdapter(self.factory, loop, self.deadlines), + ) + + +def operator( + manifest: OperatorManifest, + *, + validate_config: OperatorConfigValidator | None = None, + deadlines: OperatorDeadlines | None = None, +) -> Callable[[OperatorHandler], OperatorProvider]: + """Decorate one coroutine into a Core-backed typed Operator.""" + + def define(handler: OperatorHandler) -> OperatorProvider: + return OperatorProvider.from_handler( + manifest, + handler, + validate_config=validate_config, + deadlines=deadlines, + ) + + return define + + +def _wait_for_operator( + loop: asyncio.AbstractEventLoop, + awaitable: Coroutine[Any, Any, _Result], + *, + timeout_s: float, +) -> _Result: + try: + future: Future[_Result] = asyncio.run_coroutine_threadsafe(awaitable, loop) + except RuntimeError: + awaitable.close() + raise + try: + return future.result(timeout_s) + except FutureTimeoutError as error: + future.cancel() + raise TimeoutError( + f"asyncio Operator operation exceeded {timeout_s:g} seconds" + ) from error + except FutureCancelledError as error: + raise asyncio.CancelledError( + "asyncio Operator operation was cancelled" + ) from error + + +__all__ = [ + "OperatorDeadlines", + "OperatorFactory", + "OperatorHandler", + "OperatorManifest", + "OperatorNode", + "OperatorNodeBuilder", + "OperatorProvider", + "RegisteredOperator", + "operator", +] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index d883cc8..457f24a 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -35,13 +35,29 @@ SessionTraceConfiguration, StopResult, ) +from ..operator_authoring import OperatorProvider as SyncOperatorProvider +from ..operator_authoring import ( + RegisteredOperator, +) +from ..operator_authoring import ( + _NativeFactoryAdapter as _NativeOperatorFactoryAdapter, +) from ..sidecar import SidecarHandle, SidecarProcessSpec from ..signal import BusSubscription +from ..source_authoring import ( + RegisteredSource, +) +from ..source_authoring import SourceProvider as SyncSourceProvider +from ..source_authoring import ( + _NativeFactoryAdapter as _NativeSourceFactoryAdapter, +) from ..sources import Source from .audio_input import AudioInput, PcmSource from .connector import Connector, RegisteredConnector from .observations import EventStream +from .operator_authoring import OperatorProvider from .sidecar import SidecarConnection +from .source_authoring import SourceProvider from .streams import AudioStream, SignalStream if TYPE_CHECKING: @@ -345,6 +361,40 @@ def register_connector( ) return RegisteredConnector(SyncRegisteredConnector(self, bound, native)) + def register_source( + self, source: SourceProvider | SyncSourceProvider + ) -> RegisteredSource: + """Register an asyncio or synchronous typed Source implementation.""" + bound = ( + source._bind(asyncio.get_running_loop()) + if isinstance(source, SourceProvider) + else source + ) + native = _native_call( + lambda: self._native.register_source_provider( + bound.manifest._native, + _NativeSourceFactoryAdapter(bound.factory), + ) + ) + return RegisteredSource(self, bound, native) + + def register_operator( + self, operator: OperatorProvider | SyncOperatorProvider + ) -> RegisteredOperator: + """Register an asyncio or synchronous off-realtime Operator.""" + bound = ( + operator._bind(asyncio.get_running_loop()) + if isinstance(operator, OperatorProvider) + else operator + ) + _native_call( + lambda: self._native.register_operator_provider( + bound.manifest._native, + _NativeOperatorFactoryAdapter(bound.factory), + ) + ) + return RegisteredOperator(self, bound) + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: """Register a bounded PKSS child to spawn during transactional start.""" sidecar_id = _native_call( diff --git a/python/pocketstation/aio/source_authoring.py b/python/pocketstation/aio/source_authoring.py new file mode 100644 index 0000000..1acdc3f --- /dev/null +++ b/python/pocketstation/aio/source_authoring.py @@ -0,0 +1,299 @@ +"""Asyncio Source authoring over Core's blocking Source worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, AsyncIterator, Callable, Coroutine, Mapping +from concurrent.futures import CancelledError as FutureCancelledError +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from dataclasses import dataclass +from time import monotonic +from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable + +from ..source_authoring import ( + RegisteredSource, + SourceConfigValidator, + SourceEmission, + SourceManifest, + SourcePrepareContext, +) +from ..source_authoring import SourceCancellation as SyncSourceCancellation +from ..source_authoring import SourceDriver as SyncSourceDriver +from ..source_authoring import SourceProvider as SyncSourceProvider + +_Result = TypeVar("_Result") + + +@dataclass(frozen=True, slots=True) +class SourceDeadlines: + """Finite waits while a Core Source worker awaits asyncio provider work.""" + + create_s: float = 5.0 + prepare_s: float = 5.0 + next_s: float = 30.0 + close_s: float = 5.0 + + def __post_init__(self) -> None: + for name, value in ( + ("create_s", self.create_s), + ("prepare_s", self.prepare_s), + ("next_s", self.next_s), + ("close_s", self.close_s), + ): + if not 0 < value <= 300: + raise ValueError(f"{name} must be greater than 0 and at most 300") + + +class SourceCancellation: + """Async provider view of Core's cancellation state.""" + + __slots__ = ("_sync",) + + def __init__(self, sync: SyncSourceCancellation) -> None: + self._sync = sync + + @property + def cancelled(self) -> bool: + return self._sync.cancelled + + +class SourceDriver: + """Async Source behavior executed from the owning event loop.""" + + async def prepare(self, context: SourcePrepareContext) -> None: + """Acquire resources after Core assigns Session identities.""" + + async def next(self, cancellation: SourceCancellation) -> SourceEmission | None: + raise NotImplementedError + + async def close(self) -> None: + """Release provider resources exactly once.""" + + +@runtime_checkable +class SourceFactory(Protocol): + def validate_config(self, configuration: Mapping[str, str]) -> None: ... + + async def create(self, configuration: Mapping[str, str]) -> SourceDriver: ... + + +SourceDriverBuilder: TypeAlias = Callable[ + [Mapping[str, str]], Coroutine[Any, Any, SourceDriver] +] +SourceIterableFactory: TypeAlias = Callable[ + [Mapping[str, str]], AsyncIterable[SourceEmission] +] + + +class _AsyncIteratorDriver(SourceDriver): + __slots__ = ("_iterator",) + + def __init__(self, values: AsyncIterable[SourceEmission]) -> None: + self._iterator: AsyncIterator[SourceEmission] = aiter(values) + + async def next(self, cancellation: SourceCancellation) -> SourceEmission | None: + if cancellation.cancelled: + return None + try: + return await anext(self._iterator) + except StopAsyncIteration: + return None + + +class _AsyncIterableFactory: + __slots__ = ("_factory", "_validator") + + def __init__( + self, + factory: SourceIterableFactory, + validator: SourceConfigValidator | None, + ) -> None: + self._factory = factory + self._validator = validator + + def validate_config(self, configuration: Mapping[str, str]) -> None: + if self._validator is not None: + self._validator(configuration) + + async def create(self, configuration: Mapping[str, str]) -> SourceDriver: + return _AsyncIteratorDriver(self._factory(configuration)) + + +class _DriverAdapter(SyncSourceDriver): + __slots__ = ("_deadlines", "_driver", "_loop") + + def __init__( + self, + driver: SourceDriver, + loop: asyncio.AbstractEventLoop, + deadlines: SourceDeadlines, + ) -> None: + self._driver = driver + self._loop = loop + self._deadlines = deadlines + + def prepare(self, context: SourcePrepareContext) -> None: + _wait_for_source( + self._loop, + self._driver.prepare(context), + timeout_s=self._deadlines.prepare_s, + ) + + def next(self, cancellation: SyncSourceCancellation) -> SourceEmission | None: + return _wait_for_source( + self._loop, + self._driver.next(SourceCancellation(cancellation)), + timeout_s=self._deadlines.next_s, + cancellation=cancellation, + ) + + def close(self) -> None: + _wait_for_source( + self._loop, + self._driver.close(), + timeout_s=self._deadlines.close_s, + ) + + +class _FactoryAdapter: + __slots__ = ("_deadlines", "_factory", "_loop") + + def __init__( + self, + factory: SourceFactory | SourceDriverBuilder, + loop: asyncio.AbstractEventLoop, + deadlines: SourceDeadlines, + ) -> None: + self._factory = factory + self._loop = loop + self._deadlines = deadlines + + def validate_config(self, configuration: Mapping[str, str]) -> None: + validator = getattr(self._factory, "validate_config", None) + if validator is not None: + validator(configuration) + + def create(self, configuration: Mapping[str, str]) -> _DriverAdapter: + create = getattr(self._factory, "create", None) + awaitable = ( + self._factory(configuration) # type: ignore[operator] + if create is None + else create(configuration) + ) + driver = _wait_for_source( + self._loop, + awaitable, + timeout_s=self._deadlines.create_s, + ) + if not hasattr(driver, "next"): + raise TypeError("async Source factory must return a SourceDriver") + return _DriverAdapter(driver, self._loop, self._deadlines) + + +@dataclass(frozen=True, slots=True) +class SourceProvider: + """Reusable asyncio Source implementation bound during Session registration.""" + + manifest: SourceManifest + factory: SourceFactory | SourceDriverBuilder + deadlines: SourceDeadlines = SourceDeadlines() + + @classmethod + def with_driver( + cls, + manifest: SourceManifest, + factory: SourceFactory | SourceDriverBuilder, + *, + deadlines: SourceDeadlines | None = None, + ) -> SourceProvider: + return cls(manifest, factory, deadlines or SourceDeadlines()) + + @classmethod + def from_async_iterable( + cls, + manifest: SourceManifest, + factory: SourceIterableFactory, + *, + validate_config: SourceConfigValidator | None = None, + deadlines: SourceDeadlines | None = None, + ) -> SourceProvider: + return cls( + manifest, + _AsyncIterableFactory(factory, validate_config), + deadlines or SourceDeadlines(), + ) + + def _bind(self, loop: asyncio.AbstractEventLoop) -> SyncSourceProvider: + if not loop.is_running(): + raise RuntimeError("async Source requires a running event loop") + return SyncSourceProvider.with_driver( + self.manifest, + _FactoryAdapter(self.factory, loop, self.deadlines), + ) + + +def source( + manifest: SourceManifest, + *, + validate_config: SourceConfigValidator | None = None, + deadlines: SourceDeadlines | None = None, +) -> Callable[[SourceIterableFactory], SourceProvider]: + """Decorate an async iterable factory into a Core-backed Source.""" + + def define(factory: SourceIterableFactory) -> SourceProvider: + return SourceProvider.from_async_iterable( + manifest, + factory, + validate_config=validate_config, + deadlines=deadlines, + ) + + return define + + +def _wait_for_source( + loop: asyncio.AbstractEventLoop, + awaitable: Coroutine[Any, Any, _Result], + *, + timeout_s: float, + cancellation: SyncSourceCancellation | None = None, +) -> _Result: + try: + future: Future[_Result] = asyncio.run_coroutine_threadsafe(awaitable, loop) + except RuntimeError: + awaitable.close() + raise + deadline = monotonic() + timeout_s + while True: + if cancellation is not None and cancellation.cancelled: + future.cancel() + raise asyncio.CancelledError("Core cancelled the asyncio Source") + remaining = deadline - monotonic() + if remaining <= 0: + future.cancel() + raise TimeoutError( + f"asyncio Source operation exceeded {timeout_s:g} seconds" + ) + try: + return future.result(min(remaining, 0.05)) + except FutureTimeoutError: + continue + except FutureCancelledError as error: + raise asyncio.CancelledError( + "asyncio Source operation was cancelled" + ) from error + + +__all__ = [ + "RegisteredSource", + "SourceCancellation", + "SourceDeadlines", + "SourceDriver", + "SourceDriverBuilder", + "SourceFactory", + "SourceIterableFactory", + "SourceManifest", + "SourceProvider", + "source", +] diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index 3fe9d52..a55bdf0 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -298,6 +298,30 @@ def binary(cls, format: BinaryFormat = BinaryFormat.RAW) -> MediaCaps: def any(cls) -> MediaCaps: return cls(MediaKind.ANY) + @classmethod + def for_signal(cls, signal: SignalSpec) -> MediaCaps: + """Select the canonical wildcard media contract for a signal.""" + if signal.kind is SignalKind.PCM_AUDIO: + return cls.audio() + if signal.kind is SignalKind.ENCODED_AUDIO: + if not isinstance(signal.format, Codec): + raise ValueError("encoded-audio SignalSpec requires a Codec") + return cls.encoded_audio(signal.format) + if signal.kind is SignalKind.TEXT: + return cls.text() + if signal.kind is SignalKind.EVENT: + return cls.event() + if signal.kind is SignalKind.METRICS: + return cls.metrics() + if signal.kind is SignalKind.CONTROL: + return cls.control() + if signal.kind is SignalKind.BINARY: + format = signal.format + return cls.binary( + format if isinstance(format, BinaryFormat) else BinaryFormat.RAW + ) + return cls.any() + def is_compatible_with(self, other: MediaCaps) -> bool: return self._native.is_compatible_with(other._native) @@ -340,6 +364,46 @@ def __post_init__(self) -> None: ) object.__setattr__(self, "_native", native) + @classmethod + def input( + cls, + name: str, + signal: SignalSpec, + *, + media: MediaCaps | None = None, + multiplicity: Multiplicity = Multiplicity.ONE, + required: bool = True, + ) -> PortSpec: + """Declare an input port, inferring the normal media contract.""" + return cls( + name, + PortDirection.INPUT, + signal, + media or MediaCaps.for_signal(signal), + multiplicity, + required, + ) + + @classmethod + def output( + cls, + name: str, + signal: SignalSpec, + *, + media: MediaCaps | None = None, + multiplicity: Multiplicity = Multiplicity.ONE, + required: bool = True, + ) -> PortSpec: + """Declare an output port, inferring the normal media contract.""" + return cls( + name, + PortDirection.OUTPUT, + signal, + media or MediaCaps.for_signal(signal), + multiplicity, + required, + ) + class ClockDomain(StrEnum): CAPTURE = "capture" diff --git a/python/pocketstation/operator_authoring.py b/python/pocketstation/operator_authoring.py new file mode 100644 index 0000000..35d78f8 --- /dev/null +++ b/python/pocketstation/operator_authoring.py @@ -0,0 +1,313 @@ +"""Python-authored Operators over Core's bounded async-worker runtime.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Protocol, TypeAlias, runtime_checkable + +from ._native import _OperatorEmission as _NativeOperatorEmission +from ._native import _OperatorManifest as _NativeOperatorManifest +from ._native import _OperatorPortContext as _NativeOperatorPortContext +from ._native import _OperatorPrepareContext as _NativeOperatorPrepareContext +from ._native import _SignalEnvelope as _NativeSignalEnvelope +from .errors import _native_call +from .graph import ( + EdgeContract, + MediaCaps, + Operator, + OperatorConfiguration, + OperatorInstance, + PortDirection, + PortSpec, + SignalSpec, +) +from .signal import SignalEnvelope + + +class _SessionOwner(Protocol): + def operator(self, operator: Operator) -> OperatorInstance: ... + + +@dataclass(frozen=True, slots=True) +class OperatorManifest: + """Validated contract for one off-realtime Python Operator.""" + + operator_id: str + inputs: tuple[PortSpec, ...] + outputs: tuple[PortSpec, ...] + revision: int = 1 + implementation_generation: int = 1 + queue_capacity_signals: int = 8 + process_timeout_ms: int = 30_000 + network_allowed: bool = False + filesystem_allowed: bool = False + drain_queued: bool = False + continue_on_failure: bool = False + terminal_roles: tuple[str, ...] = () + _native: _NativeOperatorManifest = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeOperatorManifest( + self.operator_id, + [port._native for port in self.inputs], + [port._native for port in self.outputs], + self.revision, + self.implementation_generation, + self.queue_capacity_signals, + self.process_timeout_ms, + self.network_allowed, + self.filesystem_allowed, + self.drain_queued, + self.continue_on_failure, + list(self.terminal_roles), + ) + ) + object.__setattr__(self, "_native", native) + + +@dataclass(frozen=True, slots=True) +class OperatorPortContext: + edge_id: int | None + port_name: str + direction: PortDirection + capacity_signals: int + signal: SignalSpec + media: MediaCaps + edge: EdgeContract + + @classmethod + def _from_native(cls, value: _NativeOperatorPortContext) -> OperatorPortContext: + return cls( + edge_id=value.edge_id, + port_name=value.port_name, + direction=PortDirection(value.direction), + capacity_signals=value.capacity_signals, + signal=SignalSpec._from_native(value.signal), + media=MediaCaps._from_native(value.media), + edge=EdgeContract(value.edge), + ) + + +@dataclass(frozen=True, slots=True) +class OperatorPrepareContext: + execution_partition: str + inputs: tuple[OperatorPortContext, ...] + outputs: tuple[OperatorPortContext, ...] + + @classmethod + def _from_native( + cls, value: _NativeOperatorPrepareContext + ) -> OperatorPrepareContext: + return cls( + execution_partition=value.execution_partition, + inputs=tuple( + OperatorPortContext._from_native(item) for item in value.inputs + ), + outputs=tuple( + OperatorPortContext._from_native(item) for item in value.outputs + ), + ) + + +class OperatorEmission: + """One derived typed payload; Core attaches derivation and lineage.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeOperatorEmission) -> None: + self._native = native + + @classmethod + def text(cls, payload: str, *, signal: SignalSpec) -> OperatorEmission: + return cls( + _native_call(lambda: _NativeOperatorEmission.text(payload, signal._native)) + ) + + @classmethod + def bytes(cls, payload: bytes, *, signal: SignalSpec) -> OperatorEmission: + return cls( + _native_call(lambda: _NativeOperatorEmission.bytes(payload, signal._native)) + ) + + +class OperatorNode: + """Off-realtime computation hosted by Core's Operator worker.""" + + def prepare(self, context: OperatorPrepareContext) -> None: + """Observe compiled port and edge contracts before processing.""" + + def process( + self, input_port: str, envelope: SignalEnvelope + ) -> Sequence[OperatorEmission]: + raise NotImplementedError + + def flush(self) -> Sequence[OperatorEmission]: + return () + + def cancel(self) -> None: + """Cancel provider work after Core requests cancellation.""" + + def close(self) -> None: + """Release provider resources exactly once.""" + + +@runtime_checkable +class OperatorFactory(Protocol): + def validate_config(self, configuration: Mapping[str, str]) -> None: ... + + def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... + + +OperatorHandler: TypeAlias = Callable[[str, SignalEnvelope], Sequence[OperatorEmission]] +OperatorConfigValidator: TypeAlias = Callable[[Mapping[str, str]], None] + + +class _HandlerNode(OperatorNode): + __slots__ = ("_handler",) + + def __init__(self, handler: OperatorHandler) -> None: + self._handler = handler + + def process( + self, input_port: str, envelope: SignalEnvelope + ) -> Sequence[OperatorEmission]: + return self._handler(input_port, envelope) + + +class _HandlerFactory: + __slots__ = ("_handler", "_validator") + + def __init__( + self, + handler: OperatorHandler, + validator: OperatorConfigValidator | None, + ) -> None: + self._handler = handler + self._validator = validator + + def validate_config(self, configuration: Mapping[str, str]) -> None: + if self._validator is not None: + self._validator(configuration) + + def create(self, _configuration: Mapping[str, str]) -> OperatorNode: + return _HandlerNode(self._handler) + + +@dataclass(frozen=True, slots=True) +class OperatorProvider: + manifest: OperatorManifest + factory: OperatorFactory + + @classmethod + def with_node( + cls, manifest: OperatorManifest, factory: OperatorFactory + ) -> OperatorProvider: + return cls(manifest, factory) + + @classmethod + def from_handler( + cls, + manifest: OperatorManifest, + handler: OperatorHandler, + *, + validate_config: OperatorConfigValidator | None = None, + ) -> OperatorProvider: + return cls(manifest, _HandlerFactory(handler, validate_config)) + + +class _NativeNodeAdapter: + __slots__ = ("_node",) + + def __init__(self, node: OperatorNode) -> None: + self._node = node + + def prepare(self, context: _NativeOperatorPrepareContext) -> None: + self._node.prepare(OperatorPrepareContext._from_native(context)) + + def process( + self, input_port: str, envelope: _NativeSignalEnvelope + ) -> list[_NativeOperatorEmission]: + return [ + item._native + for item in self._node.process( + input_port, SignalEnvelope._from_native(envelope) + ) + ] + + def flush(self) -> list[_NativeOperatorEmission]: + return [item._native for item in self._node.flush()] + + def cancel(self) -> None: + self._node.cancel() + + def close(self) -> None: + self._node.close() + + +class _NativeFactoryAdapter: + __slots__ = ("_factory",) + + def __init__(self, factory: OperatorFactory) -> None: + self._factory = factory + + def validate_config(self, configuration: Mapping[str, str]) -> None: + self._factory.validate_config(configuration) + + def create(self, configuration: Mapping[str, str]) -> _NativeNodeAdapter: + node = self._factory.create(configuration) + if not hasattr(node, "process"): + raise TypeError("Operator factory must return an OperatorNode") + return _NativeNodeAdapter(node) + + +class RegisteredOperator: + __slots__ = ("_provider", "_session") + + def __init__(self, session: _SessionOwner, provider: OperatorProvider) -> None: + self._session = session + self._provider = provider + + @property + def operator_id(self) -> str: + return self._provider.manifest.operator_id + + def declare( + self, configuration: OperatorConfiguration | None = None + ) -> OperatorInstance: + return self._session.operator( + Operator(self.operator_id, configuration or OperatorConfiguration()) + ) + + +def operator( + manifest: OperatorManifest, + *, + validate_config: OperatorConfigValidator | None = None, +) -> Callable[[OperatorHandler], OperatorProvider]: + """Decorate one function into a Core-backed typed Operator.""" + + def define(handler: OperatorHandler) -> OperatorProvider: + return OperatorProvider.from_handler( + manifest, + handler, + validate_config=validate_config, + ) + + return define + + +__all__ = [ + "OperatorConfigValidator", + "OperatorEmission", + "OperatorFactory", + "OperatorHandler", + "OperatorManifest", + "OperatorNode", + "OperatorPortContext", + "OperatorPrepareContext", + "OperatorProvider", + "RegisteredOperator", + "operator", +] diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index bf1783f..2155f40 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -36,8 +36,16 @@ SessionTraceConfiguration, StopResult, ) +from .operator_authoring import ( + OperatorProvider, + RegisteredOperator, +) +from .operator_authoring import ( + _NativeFactoryAdapter as _NativeOperatorFactoryAdapter, +) from .sidecar import SidecarConnection, SidecarHandle, SidecarProcessSpec from .signal import BusSubscription +from .source_authoring import RegisteredSource, SourceProvider, _NativeFactoryAdapter from .sources import Source from .streams import AudioStream, SignalStream @@ -326,6 +334,26 @@ def register_connector(self, connector: Connector) -> RegisteredConnector: ) return RegisteredConnector(self, connector, native) + def register_source(self, source: SourceProvider) -> RegisteredSource: + """Register one Python-authored typed Source implementation.""" + native = _native_call( + lambda: self._native.register_source_provider( + source.manifest._native, + _NativeFactoryAdapter(source.factory), + ) + ) + return RegisteredSource(self, source, native) + + def register_operator(self, operator: OperatorProvider) -> RegisteredOperator: + """Register one Python-authored off-realtime Operator.""" + _native_call( + lambda: self._native.register_operator_provider( + operator.manifest._native, + _NativeOperatorFactoryAdapter(operator.factory), + ) + ) + return RegisteredOperator(self, operator) + def register_sidecar(self, spec: SidecarProcessSpec) -> SidecarHandle: """Register a bounded PKSS child to spawn during transactional start.""" sidecar_id = _native_call( @@ -365,9 +393,12 @@ def start(self) -> RunningSession: "AudioInputConfig", "Connector", "Endpoint", + "OperatorProvider", "RecordingOutcome", "RecordingStemOutcome", "RegisteredConnector", + "RegisteredOperator", + "RegisteredSource", "RouteMetrics", "RunningSession", "Session", @@ -378,6 +409,7 @@ def start(self) -> RunningSession: "SidecarProcessSpec", "SignalStream", "Source", + "SourceProvider", "Stem", "StopResult", ] diff --git a/python/pocketstation/source_authoring.py b/python/pocketstation/source_authoring.py new file mode 100644 index 0000000..d32562f --- /dev/null +++ b/python/pocketstation/source_authoring.py @@ -0,0 +1,346 @@ +"""Python-authored typed Sources over the canonical Core source lifecycle.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import dataclass, field +from typing import Protocol, TypeAlias, runtime_checkable + +from ._native import Session as _NativeSession +from ._native import _RegisteredSource as _NativeRegisteredSource +from ._native import _SourceCancellation as _NativeSourceCancellation +from ._native import _SourceEmission as _NativeSourceEmission +from ._native import _SourceManifest as _NativeSourceManifest +from ._native import _SourceOutputIdentity as _NativeSourceOutputIdentity +from ._native import _SourcePrepareContext as _NativeSourcePrepareContext +from .errors import _native_call +from .graph import PortSpec, SignalSpec, SourceConfiguration, SourceInstance + + +class _SessionOwner(Protocol): + _native: _NativeSession + + def source( + self, source_type_id: str, configuration: SourceConfiguration | None = None + ) -> SourceInstance: ... + + +@dataclass(frozen=True, slots=True) +class SourceManifest: + """Stable contract for one Python-authored typed Source implementation.""" + + source_type_id: str + outputs: tuple[PortSpec, ...] + revision: int = 1 + implementation_generation: int = 1 + _native: _NativeSourceManifest = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeSourceManifest( + self.source_type_id, + [output._native for output in self.outputs], + self.revision, + self.implementation_generation, + ) + ) + object.__setattr__(self, "_native", native) + + +@dataclass(frozen=True, slots=True) +class SourceOutputIdentity: + """Session-owned identity assigned to one prepared Source output.""" + + output_port: str + stream_id: int + + @classmethod + def _from_native(cls, value: _NativeSourceOutputIdentity) -> SourceOutputIdentity: + return cls(value.output_port, value.stream_id) + + +@dataclass(frozen=True, slots=True) +class SourcePrepareContext: + """Immutable Session identity supplied before the Source starts.""" + + source_type_id: str + session_id: int | None + source_id: int | None + outputs: tuple[SourceOutputIdentity, ...] + + @classmethod + def _from_native(cls, value: _NativeSourcePrepareContext) -> SourcePrepareContext: + return cls( + source_type_id=value.source_type_id, + session_id=value.session_id, + source_id=value.source_id, + outputs=tuple( + SourceOutputIdentity._from_native(item) for item in value.outputs + ), + ) + + +class SourceCancellation: + """Read-only cancellation signal owned by the Core Source runtime.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeSourceCancellation) -> None: + self._native = native + + @property + def cancelled(self) -> bool: + return self._native.cancelled + + +class SourceEmission: + """One typed Source value; Core attaches exact Session lineage.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeSourceEmission) -> None: + self._native = native + + @classmethod + def text( + cls, + output_port: str, + payload: str, + *, + signal: SignalSpec, + source_timestamp_ns: int | None = None, + observed_timestamp_ns: int | None = None, + duration_ns: int | None = None, + source_generation: int = 1, + discontinuity_epoch: int = 0, + policy_epoch: int = 0, + clock_domain_id: int = 1, + terminal: bool = False, + ) -> SourceEmission: + return cls( + _native_call( + lambda: _NativeSourceEmission.text( + output_port, + payload, + signal._native, + source_timestamp_ns, + observed_timestamp_ns, + duration_ns, + source_generation, + discontinuity_epoch, + policy_epoch, + clock_domain_id, + terminal, + ) + ) + ) + + @classmethod + def bytes( + cls, + output_port: str, + payload: bytes, + *, + signal: SignalSpec, + source_timestamp_ns: int | None = None, + observed_timestamp_ns: int | None = None, + duration_ns: int | None = None, + source_generation: int = 1, + discontinuity_epoch: int = 0, + policy_epoch: int = 0, + clock_domain_id: int = 1, + terminal: bool = False, + ) -> SourceEmission: + return cls( + _native_call( + lambda: _NativeSourceEmission.bytes( + output_port, + payload, + signal._native, + source_timestamp_ns, + observed_timestamp_ns, + duration_ns, + source_generation, + discontinuity_epoch, + policy_epoch, + clock_domain_id, + terminal, + ) + ) + ) + + +class SourceDriver: + """Blocking-worker Source behavior invoked outside realtime partitions.""" + + def prepare(self, context: SourcePrepareContext) -> None: + """Acquire resources after Core has assigned Session identities.""" + + def next(self, cancellation: SourceCancellation) -> SourceEmission | None: + raise NotImplementedError + + def close(self) -> None: + """Release provider resources exactly once.""" + + +@runtime_checkable +class SourceFactory(Protocol): + """Reusable factory retained by one canonical Session.""" + + def validate_config(self, configuration: Mapping[str, str]) -> None: ... + + def create(self, configuration: Mapping[str, str]) -> SourceDriver: ... + + +SourceIterableFactory: TypeAlias = Callable[ + [Mapping[str, str]], Iterable[SourceEmission] +] +SourceConfigValidator: TypeAlias = Callable[[Mapping[str, str]], None] + + +class _IterableDriver(SourceDriver): + __slots__ = ("_iterator",) + + def __init__(self, values: Iterable[SourceEmission]) -> None: + self._iterator: Iterator[SourceEmission] = iter(values) + + def next(self, cancellation: SourceCancellation) -> SourceEmission | None: + if cancellation.cancelled: + return None + return next(self._iterator, None) + + +class _IterableFactory: + __slots__ = ("_factory", "_validator") + + def __init__( + self, + factory: SourceIterableFactory, + validator: SourceConfigValidator | None, + ) -> None: + self._factory = factory + self._validator = validator + + def validate_config(self, configuration: Mapping[str, str]) -> None: + if self._validator is not None: + self._validator(configuration) + + def create(self, configuration: Mapping[str, str]) -> SourceDriver: + return _IterableDriver(self._factory(configuration)) + + +@dataclass(frozen=True, slots=True) +class SourceProvider: + """Reusable Python implementation of one Core Source contract.""" + + manifest: SourceManifest + factory: SourceFactory + + @classmethod + def with_driver( + cls, manifest: SourceManifest, factory: SourceFactory + ) -> SourceProvider: + return cls(manifest, factory) + + @classmethod + def from_iterable( + cls, + manifest: SourceManifest, + factory: SourceIterableFactory, + *, + validate_config: SourceConfigValidator | None = None, + ) -> SourceProvider: + return cls(manifest, _IterableFactory(factory, validate_config)) + + +class _NativeDriverAdapter: + __slots__ = ("_driver",) + + def __init__(self, driver: SourceDriver) -> None: + self._driver = driver + + def prepare(self, context: _NativeSourcePrepareContext) -> None: + self._driver.prepare(SourcePrepareContext._from_native(context)) + + def next( + self, cancellation: _NativeSourceCancellation + ) -> _NativeSourceEmission | None: + emission = self._driver.next(SourceCancellation(cancellation)) + return None if emission is None else emission._native + + def close(self) -> None: + self._driver.close() + + +class _NativeFactoryAdapter: + __slots__ = ("_factory",) + + def __init__(self, factory: SourceFactory) -> None: + self._factory = factory + + def validate_config(self, configuration: Mapping[str, str]) -> None: + self._factory.validate_config(configuration) + + def create(self, configuration: Mapping[str, str]) -> _NativeDriverAdapter: + driver = self._factory.create(configuration) + if not hasattr(driver, "next"): + raise TypeError("Source factory must return a SourceDriver") + return _NativeDriverAdapter(driver) + + +class RegisteredSource: + """One Source implementation registered into one canonical Session.""" + + __slots__ = ("_native", "_provider", "_session") + + def __init__( + self, + session: _SessionOwner, + provider: SourceProvider, + native: _NativeRegisteredSource, + ) -> None: + self._session = session + self._provider = provider + self._native = native + + @property + def source_type_id(self) -> str: + return self._native.source_type_id + + def declare( + self, configuration: SourceConfiguration | None = None + ) -> SourceInstance: + return self._session.source(self.source_type_id, configuration) + + +def source( + manifest: SourceManifest, + *, + validate_config: SourceConfigValidator | None = None, +) -> Callable[[SourceIterableFactory], SourceProvider]: + """Decorate an iterable factory into a Core-backed typed Source.""" + + def define(factory: SourceIterableFactory) -> SourceProvider: + return SourceProvider.from_iterable( + manifest, + factory, + validate_config=validate_config, + ) + + return define + + +__all__ = [ + "RegisteredSource", + "SourceCancellation", + "SourceConfigValidator", + "SourceDriver", + "SourceEmission", + "SourceFactory", + "SourceIterableFactory", + "SourceManifest", + "SourceOutputIdentity", + "SourcePrepareContext", + "SourceProvider", + "source", +] diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index 31c015a..4df0e6b 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -15,6 +15,8 @@ REPOSITORY / "tests" / "test_streams.py", REPOSITORY / "tests" / "test_aio_streams.py", REPOSITORY / "tests" / "test_connector.py", + REPOSITORY / "tests" / "test_source_authoring.py", + REPOSITORY / "tests" / "test_operator_authoring.py", REPOSITORY / "tests" / "test_aio_session.py", ) @@ -27,6 +29,9 @@ def main() -> int: uv = shutil.which("uv") if uv is None: raise SystemExit("uv is required for installed-wheel conformance") + maturin = shutil.which("maturin") + if maturin is None: + raise SystemExit("maturin is required for installed-wheel conformance") with tempfile.TemporaryDirectory(prefix="pks-w21-stream-") as temporary: root = Path(temporary) @@ -36,9 +41,7 @@ def main() -> int: _run( [ - uv, - "run", - "maturin", + maturin, "build", "--release", "--features", @@ -86,7 +89,11 @@ def main() -> int: ( "canonical_native_session or " "connector_worker_receives_finite_native_owned_batches or " - "async_connector_worker_receives_finite_native_batches" + "async_connector_worker_receives_finite_native_batches or " + "iterable_source_runs_in_core or " + "async_iterable_source_runs_on_the_owning_event_loop or " + "python_operator_processes_source_signal_with_derivation or " + "async_operator_runs_on_owning_loop" ), "-rs", ], diff --git a/tests/test_graph.py b/tests/test_graph.py index 08020bc..cbc7e7a 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -137,6 +137,23 @@ def test_media_caps_and_port_specs_are_rust_validated() -> None: assert failure.value.code == "graph.invalid_contract" +def test_port_helpers_infer_media_without_hiding_explicit_contracts() -> None: + text = SignalSpec.text(role="request") + input_port = PortSpec.input("input", text) + output_port = PortSpec.output( + "output", + text, + media=MediaCaps.text(), + multiplicity=Multiplicity.MANY, + ) + + assert input_port.direction is PortDirection.INPUT + assert input_port.media.kind.value == "text" + assert output_port.direction is PortDirection.OUTPUT + assert output_port.media.kind.value == "text" + assert output_port.multiplicity is Multiplicity.MANY + + def test_edge_presets_and_modifiers_preserve_bounded_contracts() -> None: realtime = EdgeContract.realtime_audio() assert realtime.clock is ClockDomain.CAPTURE diff --git a/tests/test_native_module_structure.py b/tests/test_native_module_structure.py index cfd2ce5..907aabe 100644 --- a/tests/test_native_module_structure.py +++ b/tests/test_native_module_structure.py @@ -21,6 +21,10 @@ def test_native_binding_is_split_by_real_implemented_owner() -> None: "streams.rs", } assert expected <= {path.name for path in NATIVE.glob("*.rs")} + for owner in ("connector", "source_authoring", "operator_authoring"): + files = {path.name for path in (NATIVE / owner).glob("*.rs")} + assert "mod.rs" in files + assert len(files) >= 3 def test_process_sidecar_has_a_real_native_owner() -> None: @@ -33,7 +37,6 @@ def test_process_sidecar_has_a_real_native_owner() -> None: def test_lib_rs_only_declares_and_registers_modules() -> None: source = (NATIVE / "lib.rs").read_text() - assert len(source.splitlines()) <= 32 assert "#[pymodule]" in source assert "#[pyclass" not in source assert "#[pymethods]" not in source diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py new file mode 100644 index 0000000..e9085e2 --- /dev/null +++ b/tests/test_operator_authoring.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from threading import Event + +import pocketstation.aio as pks_aio +import pytest +from pocketstation import ( + MediaCaps, + OperatorEmission, + OperatorManifest, + OperatorNode, + OperatorPrepareContext, + OperatorProvider, + PortDirection, + PortSpec, + Session, + SignalEnvelope, + SignalSpec, + SourceEmission, + SourceManifest, + SourceProvider, +) + + +def test_python_operator_processes_source_signal_with_derivation() -> None: + input_signal = SignalSpec.text(role="request") + output_signal = SignalSpec.text(role="result.final") + source = SourceProvider.from_iterable( + SourceManifest( + "io.pocketstation.source.operator-input-test.v1", + outputs=( + PortSpec( + "events", + PortDirection.OUTPUT, + input_signal, + MediaCaps.text(), + ), + ), + ), + lambda _configuration: ( + SourceEmission.text("events", "hello", signal=input_signal), + ), + ) + + class Uppercase(OperatorNode): + def __init__(self) -> None: + self.prepared: OperatorPrepareContext | None = None + self.closed = Event() + + def prepare(self, context: OperatorPrepareContext) -> None: + self.prepared = context + + def process(self, input_port, envelope): + assert input_port == "input" + assert envelope.payload == "hello" + return (OperatorEmission.text("HELLO", signal=output_signal),) + + def close(self) -> None: + self.closed.set() + + node = Uppercase() + + class Factory: + def validate_config(self, _configuration) -> None: + pass + + def create(self, _configuration) -> Uppercase: + return node + + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.uppercase-test.v1", + inputs=( + PortSpec( + "input", + PortDirection.INPUT, + input_signal, + MediaCaps.text(), + ), + ), + outputs=( + PortSpec( + "output", + PortDirection.OUTPUT, + output_signal, + MediaCaps.text(), + ), + ), + terminal_roles=("result.final",), + ), + Factory(), + ) + + session = Session() + source_instance = session.register_source(source).declare() + operator_instance = session.register_operator(provider).declare() + source_instance.output("events").connect(operator_instance.input("input")) + subscription = session.subscribe( + operator_instance.output("output"), signal=output_signal + ) + + running = session.start() + value = running.signals(subscription).read(timeout_s=1.0) + stop = running.stop() + assert stop.success + if not isinstance(value, SignalEnvelope): + raise AssertionError(stop) + + assert isinstance(value, SignalEnvelope) + assert value.payload == "HELLO" + assert value.lineage is not None + assert value.derivation is not None + assert value.derivation.operator_id == provider.manifest.operator_id + assert node.prepared is not None + assert node.prepared.execution_partition == "async-worker" + assert node.prepared.inputs[0].port_name == "input" + assert node.prepared.outputs[0].port_name == "output" + assert node.closed.wait(1.0) + + +@pytest.mark.asyncio +async def test_async_operator_runs_on_owning_loop() -> None: + input_signal = SignalSpec.text(role="async.request") + output_signal = SignalSpec.text(role="async.result") + source = SourceProvider.from_iterable( + SourceManifest( + "io.pocketstation.source.async-operator-input-test.v1", + outputs=( + PortSpec( + "events", + PortDirection.OUTPUT, + input_signal, + MediaCaps.text(), + ), + ), + ), + lambda _configuration: ( + SourceEmission.text("events", "hello", signal=input_signal), + ), + ) + manifest = OperatorManifest( + "io.pocketstation.operator.async-uppercase-test.v1", + inputs=( + PortSpec( + "input", + PortDirection.INPUT, + input_signal, + MediaCaps.text(), + ), + ), + outputs=( + PortSpec( + "output", + PortDirection.OUTPUT, + output_signal, + MediaCaps.text(), + ), + ), + ) + + @pks_aio.operator(manifest) + async def uppercase(input_port, envelope): + assert input_port == "input" + return ( + OperatorEmission.text(str(envelope.payload).upper(), signal=output_signal), + ) + + session = pks_aio.Session() + source_instance = session.register_source(source).declare() + operator_instance = session.register_operator(uppercase).declare() + source_instance.output("events").connect(operator_instance.input("input")) + subscription = session.subscribe( + operator_instance.output("output"), signal=output_signal + ) + running = await session.start() + value = await running.signals(subscription).read(timeout_s=1.0) + stop = await running.stop() + + assert stop.success + assert isinstance(value, SignalEnvelope) + assert value.payload == "HELLO" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index c0cc794..06fafa4 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -104,11 +104,20 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "NativeExtensionLibrary", "NativeExtensionRegistration", "Operator", + "OperatorConfigValidator", "OperatorConfiguration", + "OperatorEmission", + "OperatorFactory", + "OperatorHandler", "OperatorInput", "OperatorInputMetrics", "OperatorInstance", "OperatorMetrics", + "OperatorManifest", + "OperatorNode", + "OperatorPortContext", + "OperatorPrepareContext", + "OperatorProvider", "OperatorWorkerMetrics", "PcmSource", "PocketStationError", @@ -128,6 +137,8 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "RecordingState", "RecordingStemOutcome", "RegisteredConnector", + "RegisteredOperator", + "RegisteredSource", "RelayError", "RelayPublishOutcome", "RelayPublisher", @@ -187,13 +198,23 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "SignalSubscriptionMetrics", "SignalTiming", "Source", + "SourceCancellation", + "SourceConfigValidator", "SourceConfiguration", + "SourceDriver", + "SourceEmission", + "SourceFactory", "SourceFailureClass", "SourceIdentityStrength", "SourceInstance", + "SourceIterableFactory", "SourceKind", + "SourceManifest", "SourceMetrics", "SourceOutput", + "SourceOutputIdentity", + "SourcePrepareContext", + "SourceProvider", "SourceQuery", "SourceRecoveryRequirement", "SourceRuntimeEvent", @@ -216,6 +237,8 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "connector", "discover_sources", "microphone_permission_observation", + "operator", + "source", } diff --git a/tests/test_source_authoring.py b/tests/test_source_authoring.py new file mode 100644 index 0000000..fd16c1e --- /dev/null +++ b/tests/test_source_authoring.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from threading import Event + +import pocketstation.aio as pks_aio +import pytest +from pocketstation import ( + MediaCaps, + Multiplicity, + PortDirection, + PortSpec, + Session, + SignalEnvelope, + SignalSpec, + SourceCancellation, + SourceConfiguration, + SourceDriver, + SourceEmission, + SourceManifest, + SourcePrepareContext, + SourceProvider, + TextFormat, + source, +) + + +def text_manifest(source_type_id: str) -> SourceManifest: + signal = SignalSpec.text(TextFormat.UTF8, role="transcript") + return SourceManifest( + source_type_id, + outputs=( + PortSpec( + "events", + PortDirection.OUTPUT, + signal, + MediaCaps.text(), + Multiplicity.MANY, + ), + ), + ) + + +def test_iterable_source_runs_in_core_and_receives_session_lineage() -> None: + signal = SignalSpec.text(TextFormat.UTF8, role="transcript") + + @source(text_manifest("io.pocketstation.source.python-test.v1")) + def transcript(configuration): + yield SourceEmission.text( + "events", + configuration["text"], + signal=signal, + source_timestamp_ns=10, + observed_timestamp_ns=12, + duration_ns=5, + discontinuity_epoch=2, + terminal=True, + ) + + session = Session() + registered = session.register_source(transcript) + instance = registered.declare(SourceConfiguration({"text": "hello"})) + output = instance.output("events") + subscription = session.subscribe(output, signal=signal) + session_id = session.id + + with session.start() as running: + value = running.signals(subscription).read(timeout_s=1.0) + + assert isinstance(value, SignalEnvelope) + assert value.payload == "hello" + assert value.lineage is not None + assert value.lineage.session_id == session_id + assert value.lineage.source_id == instance.source_id + assert value.lineage.stream_id == output.stream_id + assert value.lineage.sequence_number == 0 + assert value.lineage.discontinuity_epoch == 2 + assert value.timing.source_timestamp_ns == 10 + assert value.timing.observed_timestamp_ns == 12 + assert value.timing.duration_ns == 5 + + +class RecordingDriver(SourceDriver): + def __init__(self, signal: SignalSpec) -> None: + self.signal = signal + self.prepared: SourcePrepareContext | None = None + self.closed = Event() + self.sent = False + + def prepare(self, context: SourcePrepareContext) -> None: + self.prepared = context + + def next(self, cancellation: SourceCancellation) -> SourceEmission | None: + assert not cancellation.cancelled + if self.sent: + return None + self.sent = True + return SourceEmission.text("events", "ready", signal=self.signal) + + def close(self) -> None: + self.closed.set() + + +class RecordingFactory: + def __init__(self, driver: RecordingDriver) -> None: + self.driver = driver + self.configurations: list[dict[str, str]] = [] + + def validate_config(self, configuration) -> None: + if configuration.get("mode") != "strict": + raise ValueError("mode must be strict") + + def create(self, configuration) -> RecordingDriver: + self.configurations.append(dict(configuration)) + return self.driver + + +def test_driver_source_preparation_validation_and_exact_close() -> None: + signal = SignalSpec.text() + driver = RecordingDriver(signal) + provider = SourceProvider.with_driver( + text_manifest("io.pocketstation.source.python-driver-test.v1"), + RecordingFactory(driver), + ) + session = Session() + registered = session.register_source(provider) + + instance = registered.declare(SourceConfiguration({"mode": "strict"})) + subscription = session.subscribe(instance.output("events"), signal=signal) + session_id = session.id + with session.start() as running: + value = running.signals(subscription).read(timeout_s=1.0) + assert isinstance(value, SignalEnvelope) + + assert driver.prepared is not None + assert driver.prepared.session_id == session_id + assert driver.prepared.source_id == instance.source_id + assert driver.prepared.outputs[0].output_port == "events" + assert driver.closed.wait(1.0) + + +def test_source_manifest_rejects_pcm_and_points_to_audio_input() -> None: + with pytest.raises(Exception, match=r"Session\.audio_input"): + SourceManifest( + "io.pocketstation.source.invalid-audio-test.v1", + outputs=( + PortSpec( + "audio", + PortDirection.OUTPUT, + SignalSpec.audio(), + MediaCaps.audio(), + ), + ), + ) + + +@pytest.mark.asyncio +async def test_async_iterable_source_runs_on_the_owning_event_loop() -> None: + signal = SignalSpec.text(role="async-source") + manifest = SourceManifest( + "io.pocketstation.source.python-async-test.v1", + outputs=( + PortSpec( + "events", + PortDirection.OUTPUT, + signal, + MediaCaps.text(), + ), + ), + ) + + @pks_aio.source(manifest) + async def events(_configuration): + yield SourceEmission.text("events", "async", signal=signal) + + session = pks_aio.Session() + registered = session.register_source(events) + instance = registered.declare() + subscription = session.subscribe(instance.output("events"), signal=signal) + + async with await session.start() as running: + value = await running.signals(subscription).read(timeout_s=1.0) + + assert isinstance(value, SignalEnvelope) + assert value.payload == "async" + assert value.lineage is not None From 46bc2ea8c23f25fb1236d6d4e76af8724b98efb5 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 22:27:53 -0400 Subject: [PATCH 04/49] feat: add source-aware transcription proof --- README.md | 27 +- examples/__init__.py | 1 + examples/notebooks/__init__.py | 1 + examples/notebooks/execute.py | 51 +++ .../source_aware_transcription.ipynb | 74 ++++ examples/transcription/README.md | 26 ++ examples/transcription/__init__.py | 9 + examples/transcription/run.py | 115 ++++++ examples/transcription/whisper_cpp.py | 372 ++++++++++++++++++ pyproject.toml | 1 + python/pocketstation/aio/audio_input.py | 17 +- tests/run_installed_stream_conformance.py | 19 +- tests/test_aio_session.py | 21 + tests/test_transcription_example.py | 71 ++++ 14 files changed, 798 insertions(+), 7 deletions(-) create mode 100644 examples/__init__.py create mode 100644 examples/notebooks/__init__.py create mode 100644 examples/notebooks/execute.py create mode 100644 examples/notebooks/source_aware_transcription.ipynb create mode 100644 examples/transcription/README.md create mode 100644 examples/transcription/__init__.py create mode 100644 examples/transcription/run.py create mode 100644 examples/transcription/whisper_cpp.py create mode 100644 tests/test_transcription_example.py diff --git a/README.md b/README.md index 5824086..b183c37 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,27 @@ not require Rust on the user's machine. The sdist contains only this SDK's Rust and Python sources and rebuilds against those immutable registry releases; it does not depend on a sibling checkout. +## Real source-aware transcription example + +The example-owned whisper.cpp Operator consumes finite PCM windows outside the +realtime partition and emits JSON transcript signals carrying source, stream, +sequence, and discontinuity identity. It uses the same public `aio` Operator +API available to SDK users; no provider implementation is embedded in Core. + +```bash +python -m examples.transcription.run \ + --whisper-cli "$(command -v whisper-cli)" \ + --model /path/to/ggml-tiny.en.bin \ + --wav /path/to/speech.wav \ + --record-to recordings +``` + +The executable notebook at +`examples/notebooks/source_aware_transcription.ipynb` calls the same function +and stores no fabricated output. The async PCM `write()` operation waits only +within its explicit finite timeout and uses the native preallocated buffer; +`try_write()` remains the immediate nonblocking mode. + ## Development gates From this repository: @@ -488,9 +509,9 @@ From this repository: cargo fmt --manifest-path native/Cargo.toml -- --check cargo test --manifest-path native/Cargo.toml --all-features --locked cargo clippy --manifest-path native/Cargo.toml --all-targets --all-features --locked -- -D warnings -ruff check python tests -ruff format --check python tests -mypy python +ruff check python tests examples +ruff format --check python tests examples +mypy python examples python -m pytest -q uv build ``` diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..ca3b96f --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Executable PocketStation examples; provider code does not enter the SDK package.""" diff --git a/examples/notebooks/__init__.py b/examples/notebooks/__init__.py new file mode 100644 index 0000000..7dfe806 --- /dev/null +++ b/examples/notebooks/__init__.py @@ -0,0 +1 @@ +"""Executable notebook proof helpers.""" diff --git a/examples/notebooks/execute.py b/examples/notebooks/execute.py new file mode 100644 index 0000000..a68afd3 --- /dev/null +++ b/examples/notebooks/execute.py @@ -0,0 +1,51 @@ +"""Dependency-free executor for PocketStation notebooks without IPython magic.""" + +from __future__ import annotations + +import argparse +import ast +import asyncio +import inspect +import json +from collections.abc import Awaitable +from pathlib import Path +from typing import Any + + +def execute(path: Path) -> None: + notebook = json.loads(path.read_text()) + if notebook.get("nbformat") != 4 or not isinstance(notebook.get("cells"), list): + raise ValueError("expected a version 4 notebook with a cells array") + namespace: dict[str, object] = {"__name__": "__notebook__"} + for index, cell in enumerate(notebook["cells"]): + if cell.get("cell_type") != "code": + continue + source = cell.get("source") + if not isinstance(source, list) or not all( + isinstance(line, str) for line in source + ): + raise ValueError(f"code cell {index} has invalid source") + code = compile( + "".join(source), + f"{path}#cell-{index}", + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + result = eval(code, namespace) + if inspect.isawaitable(result): + asyncio.run(_await_result(result)) + + +async def _await_result(result: Awaitable[Any]) -> Any: + return await result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("notebook", type=Path) + arguments = parser.parse_args() + execute(arguments.notebook) + + +if __name__ == "__main__": + main() diff --git a/examples/notebooks/source_aware_transcription.ipynb b/examples/notebooks/source_aware_transcription.ipynb new file mode 100644 index 0000000..0478cfd --- /dev/null +++ b/examples/notebooks/source_aware_transcription.ipynb @@ -0,0 +1,74 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "source": [ + "# Source-aware transcription with PocketStation\n", + "\n", + "This notebook sends application-owned PCM through the real bounded Session, records it as an independent stem, and transcribes it with a local whisper.cpp Operator. Set the three environment variables below to real installed artifacts; no output is fabricated or embedded in this notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "from examples.transcription.run import transcribe_wav" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a63283cbaf04dbcab1f6479b197f3a8", + "metadata": {}, + "outputs": [], + "source": [ + "whisper_cli = Path(os.environ[\"POCKETSTATION_WHISPER_CLI\"])\n", + "model = Path(os.environ[\"POCKETSTATION_WHISPER_MODEL\"])\n", + "wav = Path(os.environ[\"POCKETSTATION_WHISPER_WAV\"])\n", + "record_to = Path(\n", + " os.environ.get(\"POCKETSTATION_NOTEBOOK_RECORDINGS\", \"recordings/notebook\")\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8dd0d8092fe74a7c96281538738b07e2", + "metadata": {}, + "outputs": [], + "source": [ + "transcript = await transcribe_wav(\n", + " whisper_cli=whisper_cli,\n", + " model=model,\n", + " wav=wav,\n", + " record_to=record_to,\n", + ")\n", + "assert transcript[\"source_id\"] > 0\n", + "assert transcript[\"stream_id\"] > 0\n", + "print(json.dumps(transcript, indent=2))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/transcription/README.md b/examples/transcription/README.md new file mode 100644 index 0000000..c455c63 --- /dev/null +++ b/examples/transcription/README.md @@ -0,0 +1,26 @@ +# Source-aware transcription + +This example consumes PocketStation PCM signals on a bounded async Operator, +runs a local `whisper-cli` process outside capture and realtime partitions, and +emits finite JSON transcript signals containing source, stream, sequence, and +discontinuity identity. + +```sh +python -m examples.transcription.run \ + --whisper-cli "$(command -v whisper-cli)" \ + --model /path/to/ggml-tiny.en.bin \ + --wav /path/to/speech.wav \ + --record-to recordings +``` + +The example defaults to CPU inference. Provider processes have finite startup, +execution, output, and shutdown limits. Each Session route retains its own +bounded queue, so a slow transcription branch does not become the Relay or +recording queue. + +The notebook uses the same function and can be executed without storing output: + +```sh +python -m examples.notebooks.execute \ + examples/notebooks/source_aware_transcription.ipynb +``` diff --git a/examples/transcription/__init__.py b/examples/transcription/__init__.py new file mode 100644 index 0000000..dcc98ea --- /dev/null +++ b/examples/transcription/__init__.py @@ -0,0 +1,9 @@ +"""Source-aware transcription examples built on the public PocketStation SDK.""" + +from .whisper_cpp import ( + TRANSCRIPT_SIGNAL, + WhisperCpp, + WhisperCppConfiguration, +) + +__all__ = ["TRANSCRIPT_SIGNAL", "WhisperCpp", "WhisperCppConfiguration"] diff --git a/examples/transcription/run.py b/examples/transcription/run.py new file mode 100644 index 0000000..941a09c --- /dev/null +++ b/examples/transcription/run.py @@ -0,0 +1,115 @@ +"""Run real source-aware whisper.cpp transcription from an installed SDK.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import wave +from array import array +from pathlib import Path + +import pocketstation +import pocketstation.aio as pks_aio + +from examples.transcription.whisper_cpp import ( + TRANSCRIPT_SIGNAL, + WhisperCpp, + WhisperCppConfiguration, +) + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--whisper-cli", type=Path, required=True) + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--wav", type=Path, required=True) + parser.add_argument("--record-to", type=Path, required=True) + return parser.parse_args() + + +async def main() -> None: + arguments = _arguments() + result = await transcribe_wav( + whisper_cli=arguments.whisper_cli, + model=arguments.model, + wav=arguments.wav, + record_to=arguments.record_to, + ) + print(json.dumps(result, indent=2)) + + +async def transcribe_wav( + *, + whisper_cli: Path, + model: Path, + wav: Path, + record_to: Path, +) -> dict[str, object]: + """Transcribe one real WAV through Session audio input and recording.""" + with wave.open(str(wav), "rb") as source: + if source.getsampwidth() != 2: + raise ValueError("input WAV must contain 16-bit PCM") + sample_rate_hz = source.getframerate() + channels = source.getnchannels() + source_frames = source.getnframes() + pcm = array("h") + pcm.frombytes(source.readframes(source_frames)) + samples = array("f", (value / 32_768 for value in pcm)) + frame_samples_per_channel = max(1, sample_rate_hz // 50) + frame_values = frame_samples_per_channel * channels + duration_s = source_frames / sample_rate_hz + + session = pks_aio.Session( + recording_root=record_to, + sample_rate_hz=sample_rate_hz, + channels=channels, + ) + audio = session.audio_input( + "application-owned-speech", + capacity_frames=32, + frame_samples_per_channel=frame_samples_per_channel, + ) + whisper = WhisperCpp( + WhisperCppConfiguration( + executable=whisper_cli, + model=model, + window_seconds=min(30, max(0.1, duration_s)), + ) + ) + operator = session.register_operator(whisper.provider()).declare() + audio.output.connect(operator.input("audio")) + audio.output.record("application-owned-speech") + subscription = session.subscribe( + operator.output("transcript"), signal=TRANSCRIPT_SIGNAL + ) + + running = await session.start() + try: + for offset in range(0, len(samples), frame_values): + frame = samples[offset : offset + frame_values] + if len(frame) < frame_values: + frame.extend([0.0] * (frame_values - len(frame))) + await audio.write(frame, timeout_s=2) + # A file has no capture clock. Yield finite pacing so this proof + # exercises normal live ingestion instead of artificial burst loss. + await asyncio.sleep(frame_samples_per_channel / sample_rate_hz / 10) + await audio.close() + result = await asyncio.wait_for( + anext(running.signals(subscription).__aiter__()), + timeout=whisper.configuration.process_timeout_s, + ) + if not isinstance(result, pocketstation.SignalEnvelope): + raise RuntimeError("transcription ended without a transcript") + transcript = json.loads(str(result.payload)) + finally: + outcome = await running.stop() + if not outcome.success: + raise RuntimeError(outcome) + if not isinstance(transcript, dict): + raise RuntimeError("transcription result must be a JSON object") + return transcript + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/transcription/whisper_cpp.py b/examples/transcription/whisper_cpp.py new file mode 100644 index 0000000..f80ee7a --- /dev/null +++ b/examples/transcription/whisper_cpp.py @@ -0,0 +1,372 @@ +"""Bounded source-aware speech transcription using a local whisper.cpp process.""" + +from __future__ import annotations + +import asyncio +import json +import sys +import wave +from array import array +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from tempfile import TemporaryDirectory + +import pocketstation +import pocketstation.aio as pks_aio + +TRANSCRIPT_SIGNAL = pocketstation.SignalSpec.text( + pocketstation.TextFormat.JSON, + role="transcript.final", + schema="io.pocketstation.transcript.batch.v1", +) + + +@dataclass(frozen=True, slots=True) +class WhisperCppConfiguration: + """Finite process, buffering, and output limits for whisper.cpp.""" + + executable: Path + model: Path + language: str = "en" + window_seconds: float = 5.0 + process_timeout_s: float = 90.0 + shutdown_timeout_s: float = 2.0 + threads: int = 4 + queue_capacity_signals: int = 512 + maximum_sources: int = 8 + maximum_output_bytes: int = 1_048_576 + maximum_error_bytes: int = 65_536 + use_gpu: bool = False + + def __post_init__(self) -> None: + if not self.executable.is_file(): + raise ValueError(f"whisper executable does not exist: {self.executable}") + if not self.model.is_file(): + raise ValueError(f"whisper model does not exist: {self.model}") + if not self.language or not self.language.isascii(): + raise ValueError("language must be non-empty ASCII") + if not 0.1 <= self.window_seconds <= 30: + raise ValueError("window_seconds must be between 0.1 and 30") + if not 1 <= self.process_timeout_s <= 300: + raise ValueError("process_timeout_s must be between 1 and 300") + if not 0.1 <= self.shutdown_timeout_s <= 10: + raise ValueError("shutdown_timeout_s must be between 0.1 and 10") + if not 1 <= self.threads <= 64: + raise ValueError("threads must be between 1 and 64") + if not 8 <= self.queue_capacity_signals <= 4_096: + raise ValueError("queue_capacity_signals must be between 8 and 4096") + if not 1 <= self.maximum_sources <= 64: + raise ValueError("maximum_sources must be between 1 and 64") + if not 1_024 <= self.maximum_output_bytes <= 16_777_216: + raise ValueError("maximum_output_bytes must be between 1024 and 16777216") + if not 1_024 <= self.maximum_error_bytes <= 1_048_576: + raise ValueError("maximum_error_bytes must be between 1024 and 1048576") + + +@dataclass(slots=True) +class _AudioWindow: + sample_rate_hz: int + channel_count: int + source_id: int + stream_id: int + sequence_start: int + sequence_end: int + discontinuity_epoch: int + samples: array[float] = field(default_factory=lambda: array("f")) + + +class _WhisperNode(pks_aio.OperatorNode): + def __init__(self, configuration: WhisperCppConfiguration) -> None: + self._configuration = configuration + self._windows: dict[tuple[int, int], _AudioWindow] = {} + self._children: set[asyncio.subprocess.Process] = set() + self._cancelled = False + + async def process( + self, + input_port: str, + envelope: pocketstation.SignalEnvelope, + ) -> tuple[pocketstation.OperatorEmission, ...]: + if input_port != "audio": + raise ValueError(f"unexpected input port: {input_port}") + payload = envelope.payload + if not isinstance(payload, pocketstation.SignalAudioPayload): + raise TypeError("WhisperCpp accepts only PCM audio signals") + lineage = envelope.lineage + if lineage is None: + raise ValueError("WhisperCpp requires source-aware audio lineage") + if self._cancelled: + raise asyncio.CancelledError + + key = (payload.source_id, payload.stream_id) + window = self._windows.get(key) + incompatible = window is not None and ( + window.sample_rate_hz != payload.sample_rate_hz + or window.channel_count != payload.channel_count + or window.discontinuity_epoch != lineage.discontinuity_epoch + ) + emissions: list[pocketstation.OperatorEmission] = [] + if incompatible and window is not None: + if window.samples: + emissions.append(await self._transcribe(window)) + del self._windows[key] + window = None + if window is None: + if len(self._windows) >= self._configuration.maximum_sources: + raise RuntimeError("maximum concurrent transcription sources exceeded") + window = _AudioWindow( + sample_rate_hz=payload.sample_rate_hz, + channel_count=payload.channel_count, + source_id=payload.source_id, + stream_id=payload.stream_id, + sequence_start=payload.sequence_number, + sequence_end=payload.sequence_number, + discontinuity_epoch=lineage.discontinuity_epoch, + ) + self._windows[key] = window + + samples = array("f") + samples.frombytes(payload.samples_f32le) + if sys.byteorder != "little": + samples.byteswap() + if len(samples) != payload.sample_count: + raise ValueError("audio payload size does not match sample_count") + window.samples.extend(samples) + window.sequence_end = payload.sequence_number + + target_samples = int( + window.sample_rate_hz + * window.channel_count + * self._configuration.window_seconds + ) + if len(window.samples) >= target_samples: + batch = _AudioWindow( + sample_rate_hz=window.sample_rate_hz, + channel_count=window.channel_count, + source_id=window.source_id, + stream_id=window.stream_id, + sequence_start=window.sequence_start, + sequence_end=window.sequence_end, + discontinuity_epoch=window.discontinuity_epoch, + samples=array("f", window.samples[:target_samples]), + ) + del window.samples[:target_samples] + window.sequence_start = payload.sequence_number + emissions.append(await self._transcribe(batch)) + return tuple(emissions) + + async def flush(self) -> tuple[pocketstation.OperatorEmission, ...]: + emissions: list[pocketstation.OperatorEmission] = [] + for window in tuple(self._windows.values()): + if window.samples and not self._cancelled: + emissions.append(await self._transcribe(window)) + self._windows.clear() + return tuple(emissions) + + async def cancel(self) -> None: + self._cancelled = True + await asyncio.gather( + *(self._stop_child(child) for child in tuple(self._children)), + return_exceptions=True, + ) + self._windows.clear() + + async def close(self) -> None: + await self.cancel() + + async def _transcribe(self, window: _AudioWindow) -> pocketstation.OperatorEmission: + if self._cancelled: + raise asyncio.CancelledError + with TemporaryDirectory(prefix="pocketstation-whisper-") as directory: + root = Path(directory) + wav_path = root / "input.wav" + output_prefix = root / "transcript" + stdout_path = root / "stdout.log" + stderr_path = root / "stderr.log" + await asyncio.to_thread(_write_whisper_wav, wav_path, window) + arguments = [ + str(self._configuration.executable), + "-m", + str(self._configuration.model), + "-f", + str(wav_path), + "-oj", + "-of", + str(output_prefix), + "-np", + "-nt", + "-l", + self._configuration.language, + "-t", + str(self._configuration.threads), + ] + if not self._configuration.use_gpu: + arguments.insert(1, "-ng") + with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr: + child = await asyncio.create_subprocess_exec( + *arguments, + stdin=asyncio.subprocess.DEVNULL, + stdout=stdout, + stderr=stderr, + ) + self._children.add(child) + try: + await asyncio.wait_for( + child.wait(), timeout=self._configuration.process_timeout_s + ) + except (asyncio.CancelledError, TimeoutError): + await self._stop_child(child) + raise + finally: + self._children.discard(child) + + if child.returncode != 0: + provider_error = await asyncio.to_thread( + _read_bounded, + stderr_path, + self._configuration.maximum_error_bytes, + ) + raise RuntimeError( + f"whisper-cli exited with status {child.returncode}: " + f"{provider_error.decode('utf-8', errors='replace').strip()}" + ) + result_bytes = await asyncio.to_thread( + _read_bounded, + output_prefix.with_suffix(".json"), + self._configuration.maximum_output_bytes, + ) + + provider_result = json.loads(result_bytes) + segments = provider_result.get("transcription", ()) + text = " ".join( + str(segment.get("text", "")).strip() + for segment in segments + if isinstance(segment, dict) + ).strip() + output = json.dumps( + { + "channel_count": window.channel_count, + "discontinuity_epoch": window.discontinuity_epoch, + "duration_ms": round( + len(window.samples) + * 1_000 + / (window.sample_rate_hz * window.channel_count) + ), + "language": provider_result.get("result", {}).get( + "language", self._configuration.language + ), + "sample_rate_hz": window.sample_rate_hz, + "sequence_end": window.sequence_end, + "sequence_start": window.sequence_start, + "source_id": window.source_id, + "stream_id": window.stream_id, + "text": text, + }, + separators=(",", ":"), + sort_keys=True, + ) + if len(output.encode()) > self._configuration.maximum_output_bytes: + raise RuntimeError("transcript envelope exceeds maximum_output_bytes") + return pocketstation.OperatorEmission.text(output, signal=TRANSCRIPT_SIGNAL) + + async def _stop_child(self, child: asyncio.subprocess.Process) -> None: + if child.returncode is not None: + return + child.terminate() + try: + await asyncio.wait_for( + child.wait(), timeout=self._configuration.shutdown_timeout_s + ) + except TimeoutError: + child.kill() + await child.wait() + + +class WhisperCpp: + """Example-owned provider that registers as one bounded async Operator.""" + + def __init__(self, configuration: WhisperCppConfiguration) -> None: + self.configuration = configuration + timeout_ms = round((configuration.process_timeout_s + 1) * 1_000) + self.manifest = pocketstation.OperatorManifest( + "community.whisper.cpp.stt.v1", + inputs=( + pocketstation.PortSpec.input("audio", pocketstation.SignalSpec.audio()), + ), + outputs=(pocketstation.PortSpec.output("transcript", TRANSCRIPT_SIGNAL),), + queue_capacity_signals=configuration.queue_capacity_signals, + process_timeout_ms=timeout_ms, + filesystem_allowed=True, + terminal_roles=("transcript.final",), + ) + + def provider(self) -> pks_aio.OperatorProvider: + async def create(_configuration: Mapping[str, str]) -> _WhisperNode: + return _WhisperNode(self.configuration) + + return pks_aio.OperatorProvider.with_node( + self.manifest, + create, + deadlines=pks_aio.OperatorDeadlines( + create_s=5, + prepare_s=5, + process_s=self.configuration.process_timeout_s + 0.5, + close_s=self.configuration.shutdown_timeout_s + 0.5, + ), + ) + + +def _write_whisper_wav(path: Path, window: _AudioWindow) -> None: + mono = _downmix(window.samples, window.channel_count) + resampled = _resample(mono, window.sample_rate_hz, 16_000) + pcm = array( + "h", (round(max(-1.0, min(1.0, value)) * 32_767) for value in resampled) + ) + if sys.byteorder != "little": + pcm.byteswap() + with wave.open(str(path), "wb") as output: + output.setnchannels(1) + output.setsampwidth(2) + output.setframerate(16_000) + output.writeframes(pcm.tobytes()) + + +def _downmix(samples: array[float], channels: int) -> array[float]: + if channels == 1: + return array("f", samples) + return array( + "f", + ( + sum(samples[index : index + channels]) / channels + for index in range(0, len(samples), channels) + ), + ) + + +def _resample( + samples: array[float], source_rate_hz: int, target_rate_hz: int +) -> array[float]: + if source_rate_hz == target_rate_hz: + return samples + output_count = round(len(samples) * target_rate_hz / source_rate_hz) + if not samples or output_count == 0: + return array("f") + if len(samples) == 1: + return array("f", [samples[0]] * output_count) + scale = source_rate_hz / target_rate_hz + output = array("f") + for output_index in range(output_count): + position = min(output_index * scale, len(samples) - 1) + lower = int(position) + upper = min(lower + 1, len(samples) - 1) + fraction = position - lower + output.append(samples[lower] + (samples[upper] - samples[lower]) * fraction) + return output + + +def _read_bounded(path: Path, maximum_bytes: int) -> bytes: + size = path.stat().st_size + if size > maximum_bytes: + raise RuntimeError(f"provider output exceeds {maximum_bytes} bytes") + return path.read_bytes() diff --git a/pyproject.toml b/pyproject.toml index c714f0e..0a2d645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" +pythonpath = ["."] [tool.mypy] packages = ["pocketstation"] diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py index e715589..d5929f1 100644 --- a/python/pocketstation/aio/audio_input.py +++ b/python/pocketstation/aio/audio_input.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from time import monotonic from ..audio_input import ( AudioInputConfig, @@ -11,6 +12,7 @@ from ..audio_input import ( PcmSource as SyncPcmSource, ) +from ..errors import AudioInputFullError from ..graph import SourceOutput @@ -63,8 +65,21 @@ async def write( samples: object, *, discontinuity: bool = False, + timeout_s: float = 1.0, ) -> None: - await self.try_write(samples, discontinuity=discontinuity) + """Wait finitely for one native buffer without growing a Python queue.""" + if not 0 <= timeout_s <= 60: + raise ValueError("timeout_s must be between 0 and 60") + deadline = monotonic() + timeout_s + while True: + try: + await self.try_write(samples, discontinuity=discontinuity) + return + except AudioInputFullError: + remaining = deadline - monotonic() + if remaining <= 0: + raise + await asyncio.sleep(min(0.001, remaining)) __all__ = ["AudioInput", "PcmSource"] diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index 4df0e6b..871d2cc 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -18,11 +18,17 @@ REPOSITORY / "tests" / "test_source_authoring.py", REPOSITORY / "tests" / "test_operator_authoring.py", REPOSITORY / "tests" / "test_aio_session.py", + REPOSITORY / "tests" / "test_transcription_example.py", ) -def _run(arguments: list[str], *, cwd: Path) -> None: - subprocess.run(arguments, cwd=cwd, check=True) +def _run( + arguments: list[str], + *, + cwd: Path, + environment: dict[str, str] | None = None, +) -> None: + subprocess.run(arguments, cwd=cwd, check=True, env=environment) def main() -> int: @@ -37,7 +43,10 @@ def main() -> int: root = Path(temporary) wheelhouse = root / "wheelhouse" environment = root / "environment" + process_environment = os.environ.copy() + process_environment["UV_CACHE_DIR"] = os.fspath(root / "uv-cache") wheelhouse.mkdir() + shutil.copytree(REPOSITORY / "examples", root / "examples") _run( [ @@ -58,6 +67,7 @@ def main() -> int: _run( [uv, "venv", os.fspath(environment), "--python", sys.executable], cwd=root, + environment=process_environment, ) interpreter = ( environment / "Scripts" / "python.exe" @@ -76,6 +86,7 @@ def main() -> int: "pytest-asyncio", ], cwd=root, + environment=process_environment, ) _run( [ @@ -93,7 +104,9 @@ def main() -> int: "iterable_source_runs_in_core or " "async_iterable_source_runs_on_the_owning_event_loop or " "python_operator_processes_source_signal_with_derivation or " - "async_operator_runs_on_owning_loop" + "async_operator_runs_on_owning_loop or " + "whisper_example_declares_a_bounded_source_aware_operator or " + "real_whisper_process_preserves_source_identity" ), "-rs", ], diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 1a0257d..6a72022 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -17,6 +17,7 @@ ConnectorWorker, Session, ) +from pocketstation.errors import AudioInputFullError @pytest.mark.asyncio @@ -65,6 +66,26 @@ async def test_application_owned_pcm_has_an_async_writer() -> None: assert list(frame.samples.cast("f")) == pytest.approx([0.1, 0.2, 0.3, 0.4]) +@pytest.mark.asyncio +async def test_async_audio_write_wait_is_finite_and_adds_no_python_queue() -> None: + session = Session() + audio = session.audio_input( + "playback", + capacity_frames=1, + frame_samples_per_channel=4, + ) + samples = array("f", [0.1, 0.2, 0.3, 0.4]) + await audio.try_write(samples) + + with pytest.raises(AudioInputFullError): + await audio.write(samples, timeout_s=0.01) + + observations = await audio.observations() + assert observations.capacity_frames == 1 + assert observations.accepted_total == 1 + assert observations.full_total > 0 + + @pytest.mark.asyncio async def test_async_session_registers_the_same_core_connector_contract() -> None: delivered = threading.Event() diff --git a/tests/test_transcription_example.py b/tests/test_transcription_example.py new file mode 100644 index 0000000..00ff95c --- /dev/null +++ b/tests/test_transcription_example.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +import pytest + +from examples.transcription import WhisperCpp, WhisperCppConfiguration + + +def test_whisper_example_declares_a_bounded_source_aware_operator( + tmp_path: Path, +) -> None: + executable = tmp_path / "whisper-cli" + model = tmp_path / "model.bin" + executable.touch() + model.touch() + configuration = WhisperCppConfiguration( + executable=executable, + model=model, + window_seconds=2, + process_timeout_s=10, + queue_capacity_signals=128, + ) + whisper = WhisperCpp(configuration) + + assert whisper.manifest.inputs[0].name == "audio" + assert whisper.manifest.inputs[0].signal.is_audio + assert whisper.manifest.outputs[0].name == "transcript" + assert whisper.manifest.outputs[0].signal.role == "transcript.final" + assert whisper.manifest.queue_capacity_signals == 128 + assert whisper.manifest.filesystem_allowed + + +@pytest.mark.asyncio +async def test_real_whisper_process_preserves_source_identity(tmp_path: Path) -> None: + executable_value = os.environ.get("POCKETSTATION_WHISPER_CLI") + model_value = os.environ.get("POCKETSTATION_WHISPER_MODEL") + wav_value = os.environ.get("POCKETSTATION_WHISPER_WAV") + if not executable_value or not model_value or not wav_value: + pytest.skip("real whisper paths were not supplied") + + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "examples.transcription.run", + "--whisper-cli", + executable_value, + "--model", + model_value, + "--wav", + wav_value, + "--record-to", + str(tmp_path / "recordings"), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) + assert process.returncode == 0, stderr.decode(errors="replace") + transcript = json.loads(stdout) + assert transcript["source_id"] > 0 + assert transcript["stream_id"] > 0 + assert transcript["sequence_start"] == 0 + assert transcript["sequence_end"] >= transcript["sequence_start"] + assert transcript["discontinuity_epoch"] == 0 + assert "pocket station" in transcript["text"].lower() + stems = list((tmp_path / "recordings").glob("session-*/stems/*.wav")) + assert len(stems) == 1 From 8ae39109f230aa4aa27dbd719544c5cd3762ce0c Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 22:58:12 -0400 Subject: [PATCH 05/49] test physical capture relay workflow --- examples/physical_capture.py | 98 ++++++++++++++++++++++++++++++++ tests/run_relay_e2e_publisher.py | 48 +++++++++++++--- 2 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 examples/physical_capture.py diff --git a/examples/physical_capture.py b/examples/physical_capture.py new file mode 100644 index 0000000..ce1b593 --- /dev/null +++ b/examples/physical_capture.py @@ -0,0 +1,98 @@ +"""Capture one playing macOS application and the default microphone.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from time import monotonic + +import pocketstation + + +def _application_source(name: str) -> pocketstation.DiscoveredSource: + matches = tuple( + source + for source in pocketstation.discover_sources() + if source.name == name + and source.stable_id.kind is pocketstation.SourceKind.APPLICATION + ) + if len(matches) != 1: + raise RuntimeError( + f"expected one application named {name!r}, found {len(matches)}" + ) + return matches[0] + + +def capture_physical_sources( + *, + application_name: str, + recording_root: Path, + duration_s: float = 5.0, +) -> dict[str, object]: + """Run the physical app+microphone path and return bounded observations.""" + if not 0.1 <= duration_s <= 300: + raise ValueError("duration_s must be between 0.1 and 300") + application_source = _application_source(application_name) + session = pocketstation.Session(recording_root=recording_root) + application = session.capture( + pocketstation.Source.from_discovered(application_source) + ) + microphone = session.capture(pocketstation.Source.microphone_default()) + endpoint = session.polled_audio() + application.send(endpoint) + microphone.send(endpoint) + application.record("application") + microphone.record("microphone") + + running = session.start() + frames_by_stem: dict[int, int] = {} + deadline = monotonic() + duration_s + try: + while monotonic() < deadline: + frame = running.audio.read(timeout_s=0.1) + if frame is not None: + frames_by_stem[frame.stem_id] = frames_by_stem.get(frame.stem_id, 0) + 1 + finally: + outcome = running.stop() + + expected_stems = {application.id, microphone.id} + recording = outcome.recording + success = ( + outcome.success + and set(frames_by_stem) == expected_stems + and recording is not None + and recording.complete + and {stem.stem_name for stem in recording.stems} + == {"application", "microphone"} + and all(stem.frames_written_total > 0 for stem in recording.stems) + ) + result: dict[str, object] = { + "application": application_source.name, + "application_process_id": application_source.process_id, + "frames_by_stem": frames_by_stem, + "microphone_permission": str(pocketstation.microphone_permission_observation()), + "recording_complete": recording is not None and recording.complete, + "success": success, + } + if not success: + raise RuntimeError(json.dumps(result, sort_keys=True)) + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--application", required=True) + parser.add_argument("--record-to", type=Path, required=True) + parser.add_argument("--duration", type=float, default=5) + arguments = parser.parse_args() + result = capture_physical_sources( + application_name=arguments.application, + recording_root=arguments.record_to, + duration_s=arguments.duration, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/run_relay_e2e_publisher.py b/tests/run_relay_e2e_publisher.py index 85f2e68..fc7cf53 100644 --- a/tests/run_relay_e2e_publisher.py +++ b/tests/run_relay_e2e_publisher.py @@ -22,10 +22,17 @@ def main() -> int: parser.add_argument("--relay-url", required=True) parser.add_argument("--recording-root", type=Path, required=True) parser.add_argument("--active-seconds", type=float, default=2.0) + application_selector = parser.add_mutually_exclusive_group() + application_selector.add_argument("--application-name") + application_selector.add_argument("--application-process-id", type=int) arguments = parser.parse_args() if arguments.active_seconds <= 0: parser.error("--active-seconds must be positive") - if not hasattr(pks._native.Session, "conformance"): + if ( + arguments.application_name is None + and arguments.application_process_id is None + and not hasattr(pks._native.Session, "conformance") + ): emit("failure", code="relay.conformance_fixture_unavailable") return 2 @@ -36,12 +43,38 @@ def main() -> int: control_plane_url=arguments.control_plane_url, relay_url=arguments.relay_url, ) - session = pks.Session._from_native( - pks._native.Session.conformance(arguments.recording_root) - ) - application = session.capture( - pks.Source.application("PocketStation Python Fixture") - ) + if ( + arguments.application_name is None + and arguments.application_process_id is None + ): + session = pks.Session._from_native( + pks._native.Session.conformance(arguments.recording_root) + ) + application_source = pks.Source.application("PocketStation Python Fixture") + source_mode = "conformance-fixture" + elif arguments.application_process_id is not None: + session = pks.Session(recording_root=arguments.recording_root) + application_source = pks.Source.application_process_id( + arguments.application_process_id + ) + source_mode = "physical" + else: + assert arguments.application_name is not None + matches = tuple( + source + for source in pks.discover_sources() + if source.name == arguments.application_name + and source.stable_id.kind is pks.SourceKind.APPLICATION + ) + if len(matches) != 1: + raise RuntimeError( + "expected one application named " + f"{arguments.application_name!r}, found {len(matches)}" + ) + session = pks.Session(recording_root=arguments.recording_root) + application_source = pks.Source.from_discovered(matches[0]) + source_mode = "physical" + application = session.capture(application_source) microphone = session.capture(pks.Source.microphone_default()) publisher = session.relay(remote) routes = ( @@ -63,6 +96,7 @@ def main() -> int: join_url=invitation.join_url, buses=[route.bus_id for route in routes], route_ids=[route.route_id for route in routes], + source_mode=source_mode, ) receiver = remote.wait_for_receiver( From 52431a435fc933c4cc0e704d8c96be51f57fd43e Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 23:08:48 -0400 Subject: [PATCH 06/49] test Python runtime resource bounds --- README.md | 38 ++- tests/qualification/__init__.py | 1 + tests/qualification/runtime_resources.py | 333 ++++++++++++++++++++++ tests/run_installed_stream_conformance.py | 16 ++ tests/test_runtime_qualification.py | 23 ++ 5 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 tests/qualification/__init__.py create mode 100644 tests/qualification/runtime_resources.py create mode 100644 tests/test_runtime_qualification.py diff --git a/README.md b/README.md index b183c37..57b25cf 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ > typed source lifecycle, Rust-backed graph declarations, bounded typed signal > streams, process sidecars, compiled native extensions, complete observations, > application-owned PCM ingress, and independently installable wheel/sdist -> artifacts. Real relay/browser composition, notebook proof, platform -> qualification, and OSS readiness remain gated. +> artifacts. The real Relay/browser path, source-aware transcription notebook, +> and physical macOS application-plus-microphone path are proven. Linux and +> Windows artifact qualification and final OSS readiness remain gated. Capture one application and one microphone as independent, source-aware live audio stems. Consume both from a bounded Python endpoint while the native Rust @@ -93,6 +94,14 @@ canonical-Session evidence is not release evidence: queue/write/drop counters, and typed gap detail; - synchronous and `asyncio` ownership models; - typed Session lifecycle client for the current control-plane HTTP API. +- shared native Relay publication of independent application and microphone + buses, opaque receiver invitation after publication readiness, real browser + receipt, and complete two-stem recording; +- real source-aware whisper.cpp inference and an output-free executable + notebook over the public async Operator API; +- current-host sync/async boundary and slow-consumer qualification with exact + units, bounded queue peaks, explicit drops, and post-shutdown descriptor, + thread, native-buffer, and Python-allocation observations. The SDK is not complete: @@ -104,11 +113,10 @@ The SDK is not complete: - capture authorization snapshots and permission-transition ownership are not attached to the canonical running Session; the SDK preserves discovery and the authoritative seven-state platform observation without inventing either; -- real relay/browser composition and notebook proof remain later gates; - isolated macOS wheel and independently rebuilt sdist consumers exist; Linux, - Windows, and real-device matrices remain release gates; -- the control client creates remote Session credentials but does not publish - media or invent a browser join URL. + Windows, and broader real-device matrices remain release gates; +- the real browser proof is same-host. It does not establish WAN, TURN, or + multi-region operation. The ordinary API is frame iteration over the native bounded endpoint; explicit reads and native batch iteration remain advanced modes. The accepted stream @@ -501,6 +509,24 @@ and stores no fabricated output. The async PCM `write()` operation waits only within its explicit finite timeout and uses the native preallocated buffer; `try_write()` remains the immediate nonblocking mode. +## Runtime qualification + +The qualification runner executes the installed public Session API. It measures +sync and asyncio write-to-read boundaries with nanosecond units, verifies exact +source/stream/sequence identity, checks that every native input buffer returns +after shutdown, and deliberately saturates a slow consumer to prove queue peaks +remain within capacity and drops remain observable. + +```bash +python -m tests.qualification.runtime_resources \ + --frames 500 \ + --output runtime-qualification.json +``` + +The output is a machine-readable measurement artifact, not a cross-machine +performance claim. The installed-wheel conformance runner executes the same +qualification outside the repository package path. + ## Development gates From this repository: diff --git a/tests/qualification/__init__.py b/tests/qualification/__init__.py new file mode 100644 index 0000000..0858320 --- /dev/null +++ b/tests/qualification/__init__.py @@ -0,0 +1 @@ +"""Executable installed-SDK qualification helpers.""" diff --git a/tests/qualification/runtime_resources.py b/tests/qualification/runtime_resources.py new file mode 100644 index 0000000..a17c9ae --- /dev/null +++ b/tests/qualification/runtime_resources.py @@ -0,0 +1,333 @@ +"""Measure Python boundary cost and prove bounded slow-consumer behavior.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import threading +import tracemalloc +from array import array +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from time import perf_counter_ns, process_time_ns, sleep + +import pocketstation +import pocketstation.aio as aio +from pocketstation.errors import AudioInputFullError + +try: + import resource +except ImportError: # pragma: no cover - Windows has no resource module. + resource = None # type: ignore[assignment] + + +@dataclass(frozen=True, slots=True) +class BoundaryResult: + mode: str + frames_total: int + samples_per_frame: int + wall_time_ns: int + process_cpu_time_ns: int + frame_latency_p50_ns: int + frame_latency_p95_ns: int + frame_latency_p99_ns: int + frame_latency_max_ns: int + input_full_retries_total: int + route_drops_total: int + python_traced_peak_bytes: int + resident_set_peak_delta_bytes: int | None + thread_count_delta: int + descriptor_count_delta: int | None + + +@dataclass(frozen=True, slots=True) +class SaturationResult: + frames_attempted_total: int + frames_accepted_total: int + input_full_retries_total: int + route_capacity_frames: int + route_peak_frames: int + route_drops_total: int + stop_success: bool + + +def _percentile(values: list[int], percentile: int) -> int: + if not values: + raise ValueError("values must not be empty") + if not 0 <= percentile <= 100: + raise ValueError("percentile must be between 0 and 100") + ordered = sorted(values) + index = max(0, (len(ordered) * percentile + 99) // 100 - 1) + return ordered[index] + + +def _descriptor_count() -> int | None: + descriptor_root = Path("/dev/fd") + if not descriptor_root.is_dir(): + return None + return len(tuple(descriptor_root.iterdir())) + + +def _optional_delta(before: int | None, after: int | None) -> int | None: + if before is None or after is None: + return None + return after - before + + +def _resident_set_peak_bytes() -> int | None: + if resource is None: + return None + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + if os.uname().sysname == "Darwin": + return peak + return peak * 1_024 + + +def _write_sync( + audio: pocketstation.AudioInput, + samples: array[float], +) -> int: + retries = 0 + deadline_ns = perf_counter_ns() + 1_000_000_000 + while True: + try: + audio.write(samples) + return retries + except AudioInputFullError: + retries += 1 + if perf_counter_ns() >= deadline_ns: + raise + sleep(0) + + +def qualify_sync(frames_total: int, samples_per_frame: int) -> BoundaryResult: + session = pocketstation.Session() + audio = session.audio_input( + "qualification", + capacity_frames=8, + frame_samples_per_channel=samples_per_frame, + ) + audio.output.send(session.polled_audio()) + samples = array("f", [0.125] * samples_per_frame) + threads_before = threading.active_count() + descriptors_before = _descriptor_count() + resident_set_before_bytes = _resident_set_peak_bytes() + latencies_ns: list[int] = [] + retries_total = 0 + running = session.start() + tracemalloc.start() + wall_started_ns = perf_counter_ns() + cpu_started_ns = process_time_ns() + try: + for expected_sequence in range(frames_total): + frame_started_ns = perf_counter_ns() + retries_total += _write_sync(audio, samples) + frame = running.audio.read(timeout_s=1.0) + if frame is None: + raise RuntimeError("sync qualification timed out waiting for audio") + if frame.source_id != audio.source_id: + raise RuntimeError("sync qualification changed source identity") + if frame.stream_id != audio.stream_id: + raise RuntimeError("sync qualification changed stream identity") + if frame.sequence_number != expected_sequence: + raise RuntimeError("sync qualification changed sequence identity") + latencies_ns.append(perf_counter_ns() - frame_started_ns) + metrics = running.metrics() + finally: + stop = running.stop() + wall_time_ns = perf_counter_ns() - wall_started_ns + process_cpu_time_ns = process_time_ns() - cpu_started_ns + _, traced_peak_bytes = tracemalloc.get_traced_memory() + tracemalloc.stop() + if not stop.success: + raise RuntimeError("sync qualification Session did not stop successfully") + route_drops_total = sum(route.frames_dropped_total for route in metrics.routes) + if route_drops_total != 0: + raise RuntimeError("sync qualification unexpectedly dropped audio") + observations = audio.observations() + if observations.available_buffers != observations.buffer_slots: + raise RuntimeError("sync qualification did not recover every native buffer") + return BoundaryResult( + mode="sync", + frames_total=frames_total, + samples_per_frame=samples_per_frame, + wall_time_ns=wall_time_ns, + process_cpu_time_ns=process_cpu_time_ns, + frame_latency_p50_ns=_percentile(latencies_ns, 50), + frame_latency_p95_ns=_percentile(latencies_ns, 95), + frame_latency_p99_ns=_percentile(latencies_ns, 99), + frame_latency_max_ns=max(latencies_ns), + input_full_retries_total=retries_total, + route_drops_total=route_drops_total, + python_traced_peak_bytes=traced_peak_bytes, + resident_set_peak_delta_bytes=_optional_delta( + resident_set_before_bytes, + _resident_set_peak_bytes(), + ), + thread_count_delta=threading.active_count() - threads_before, + descriptor_count_delta=_optional_delta( + descriptors_before, + _descriptor_count(), + ), + ) + + +async def qualify_async( + frames_total: int, + samples_per_frame: int, +) -> BoundaryResult: + session = aio.Session() + audio = session.audio_input( + "qualification", + capacity_frames=8, + frame_samples_per_channel=samples_per_frame, + ) + audio.output.send(session.polled_audio()) + samples = array("f", [0.125] * samples_per_frame) + latencies_ns: list[int] = [] + running = await session.start() + tracemalloc.start() + wall_started_ns = perf_counter_ns() + cpu_started_ns = process_time_ns() + try: + for expected_sequence in range(frames_total): + frame_started_ns = perf_counter_ns() + await audio.write(samples, timeout_s=1.0) + frame = await running.audio.read(timeout_s=1.0) + if frame is None: + raise RuntimeError("async qualification timed out waiting for audio") + if frame.source_id != audio.source_id: + raise RuntimeError("async qualification changed source identity") + if frame.stream_id != audio.stream_id: + raise RuntimeError("async qualification changed stream identity") + if frame.sequence_number != expected_sequence: + raise RuntimeError("async qualification changed sequence identity") + latencies_ns.append(perf_counter_ns() - frame_started_ns) + metrics = await running.metrics() + finally: + stop = await running.stop() + wall_time_ns = perf_counter_ns() - wall_started_ns + process_cpu_time_ns = process_time_ns() - cpu_started_ns + _, traced_peak_bytes = tracemalloc.get_traced_memory() + tracemalloc.stop() + if not stop.success: + raise RuntimeError("async qualification Session did not stop successfully") + route_drops_total = sum(route.frames_dropped_total for route in metrics.routes) + if route_drops_total != 0: + raise RuntimeError("async qualification unexpectedly dropped audio") + observations = await audio.observations() + if observations.available_buffers != observations.buffer_slots: + raise RuntimeError("async qualification did not recover every native buffer") + return BoundaryResult( + mode="asyncio", + frames_total=frames_total, + samples_per_frame=samples_per_frame, + wall_time_ns=wall_time_ns, + process_cpu_time_ns=process_cpu_time_ns, + frame_latency_p50_ns=_percentile(latencies_ns, 50), + frame_latency_p95_ns=_percentile(latencies_ns, 95), + frame_latency_p99_ns=_percentile(latencies_ns, 99), + frame_latency_max_ns=max(latencies_ns), + input_full_retries_total=0, + route_drops_total=route_drops_total, + python_traced_peak_bytes=traced_peak_bytes, + resident_set_peak_delta_bytes=None, + thread_count_delta=0, + descriptor_count_delta=None, + ) + + +def qualify_async_owned( + frames_total: int, + samples_per_frame: int, +) -> BoundaryResult: + threads_before = threading.active_count() + descriptors_before = _descriptor_count() + resident_set_before_bytes = _resident_set_peak_bytes() + result = asyncio.run(qualify_async(frames_total, samples_per_frame)) + return replace( + result, + resident_set_peak_delta_bytes=_optional_delta( + resident_set_before_bytes, + _resident_set_peak_bytes(), + ), + thread_count_delta=threading.active_count() - threads_before, + descriptor_count_delta=_optional_delta( + descriptors_before, + _descriptor_count(), + ), + ) + + +def qualify_slow_consumer( + frames_total: int, + samples_per_frame: int, +) -> SaturationResult: + session = pocketstation.Session() + audio = session.audio_input( + "slow-consumer", + capacity_frames=8, + frame_samples_per_channel=samples_per_frame, + ) + audio.output.send(session.polled_audio()) + samples = array("f", [0.25] * samples_per_frame) + running = session.start() + retries_total = 0 + for _ in range(frames_total): + retries_total += _write_sync(audio, samples) + sleep(0.05) + metrics = running.metrics() + stop = running.stop() + if len(metrics.routes) != 1: + raise RuntimeError("slow-consumer qualification expected one route") + route = metrics.routes[0] + if route.edge.queue_peak_frames > route.queue_capacity_frames: + raise RuntimeError("slow-consumer queue exceeded its declared capacity") + if route.frames_dropped_total == 0: + raise RuntimeError("slow-consumer saturation was not observed") + observations = audio.observations() + if observations.available_buffers != observations.buffer_slots: + raise RuntimeError("slow-consumer qualification leaked native buffers") + return SaturationResult( + frames_attempted_total=frames_total, + frames_accepted_total=observations.accepted_total, + input_full_retries_total=retries_total, + route_capacity_frames=route.queue_capacity_frames, + route_peak_frames=route.edge.queue_peak_frames, + route_drops_total=route.frames_dropped_total, + stop_success=stop.success, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--frames", type=int, default=500) + parser.add_argument("--samples-per-frame", type=int, default=480) + parser.add_argument("--output", type=Path) + arguments = parser.parse_args() + if not 10 <= arguments.frames <= 100_000: + parser.error("--frames must be between 10 and 100000") + if not 1 <= arguments.samples_per_frame <= 28_800: + parser.error("--samples-per-frame must be between 1 and 28800") + report = { + "schema": "io.pocketstation.python.runtime-qualification.v1", + "process_id": os.getpid(), + "sync": asdict(qualify_sync(arguments.frames, arguments.samples_per_frame)), + "asyncio": asdict( + qualify_async_owned(arguments.frames, arguments.samples_per_frame) + ), + "slow_consumer": asdict( + qualify_slow_consumer(arguments.frames, arguments.samples_per_frame) + ), + } + encoded = json.dumps(report, indent=2, sort_keys=True) + if arguments.output is None: + print(encoded) + else: + arguments.output.write_text(f"{encoded}\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index 871d2cc..b419958 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -112,6 +112,22 @@ def main() -> int: ], cwd=root, ) + runtime_report = root / "runtime-qualification.json" + _run( + [ + os.fspath(interpreter), + os.fspath( + REPOSITORY / "tests" / "qualification" / "runtime_resources.py" + ), + "--frames", + "100", + "--output", + os.fspath(runtime_report), + ], + cwd=root, + ) + if not runtime_report.is_file(): + raise SystemExit("installed runtime qualification produced no report") package_path = subprocess.run( [ os.fspath(interpreter), diff --git a/tests/test_runtime_qualification.py b/tests/test_runtime_qualification.py new file mode 100644 index 0000000..137cd3d --- /dev/null +++ b/tests/test_runtime_qualification.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest + +from tests.qualification.runtime_resources import _percentile + + +@pytest.mark.parametrize( + ("percentile", "expected"), + [(0, 1), (1, 1), (50, 2), (95, 4), (99, 4), (100, 4)], +) +def test_nearest_rank_percentile_is_deterministic( + percentile: int, + expected: int, +) -> None: + assert _percentile([4, 1, 3, 2], percentile) == expected + + +def test_percentile_rejects_empty_or_invalid_input() -> None: + with pytest.raises(ValueError, match="must not be empty"): + _percentile([], 50) + with pytest.raises(ValueError, match="between 0 and 100"): + _percentile([1], 101) From 6772022abb69877d3abd2c0f72b63492eb4a023e Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 23:21:16 -0400 Subject: [PATCH 07/49] test installed Python distributions --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++----- tests/installed_consumer.py | 73 ++++++++++++++++++++++++++++++++ tests/run_artifact_consumer.py | 76 ++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 tests/installed_consumer.py create mode 100644 tests/run_artifact_consumer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b205509..db2955a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,6 @@ jobs: with: path: sdk-python - - name: Check out frozen Rust core - uses: actions/checkout@v4 - with: - repository: pocketstation-io/pocketstation - ref: pocketstation-v1.1.1 - path: pocketstation - - name: Install Rust 1.95 uses: dtolnay/rust-toolchain@master with: @@ -73,13 +66,13 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' working-directory: sdk-python run: | - ruff check python tests - ruff format --check python tests + ruff check python tests examples + ruff format --check python tests examples - name: Python type contract if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' working-directory: sdk-python - run: mypy python + run: mypy python examples tests/qualification - name: Python tests working-directory: sdk-python @@ -89,9 +82,62 @@ jobs: working-directory: sdk-python run: maturin build --release --locked --out dist + - name: Test isolated release wheel + working-directory: sdk-python + run: >- + python tests/run_artifact_consumer.py + --artifact-kind wheel + --artifact-dir dist + - name: Upload wheel for inspection uses: actions/upload-artifact@v4 with: name: pocketstation-${{ matrix.os }}-py${{ matrix.python }} path: sdk-python/dist/*.whl if-no-files-found: error + + sdist: + name: source distribution / Python 3.11 + runs-on: ubuntu-latest + + steps: + - name: Check out Python SDK + uses: actions/checkout@v4 + with: + path: sdk-python + + - name: Install Rust 1.95 + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Linux native dependencies + run: | + sudo apt-get update + sudo apt-get install --yes libasound2-dev libpipewire-0.3-dev + + - name: Build source distribution + working-directory: sdk-python + run: | + python -m pip install --upgrade pip + python -m pip install "maturin>=1.9.4,<2.0" + maturin sdist --manifest-path native/Cargo.toml --out dist + + - name: Rebuild and test isolated source distribution + working-directory: sdk-python + run: >- + python tests/run_artifact_consumer.py + --artifact-kind sdist + --artifact-dir dist + + - name: Upload source distribution for inspection + uses: actions/upload-artifact@v4 + with: + name: pocketstation-sdist + path: sdk-python/dist/*.tar.gz + if-no-files-found: error diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py new file mode 100644 index 0000000..c823b0f --- /dev/null +++ b/tests/installed_consumer.py @@ -0,0 +1,73 @@ +"""Exercise the public SDK from an isolated installed artifact.""" + +from __future__ import annotations + +import json +import sys +from array import array +from pathlib import Path +from threading import Event + +import pocketstation + + +def main() -> None: + delivered = Event() + + def receive( + item: pocketstation.ConnectorItem, + _context: pocketstation.ConnectorContext, + ) -> pocketstation.ConnectorDeliveryOutcome: + if item.audio is None: + raise RuntimeError("installed Connector received no audio") + delivered.set() + return pocketstation.ConnectorDeliveryOutcome.DELIVERED + + session = pocketstation.Session() + audio = session.audio_input( + "installed-consumer", + capacity_frames=2, + frame_samples_per_channel=4, + ) + manifest = pocketstation.ConnectorManifest.audio( + "io.pocketstation.test.installed-consumer.v1", + package_version="1.0.0", + ) + endpoint = session.register_connector( + pocketstation.Connector.from_handler(manifest, receive) + ).declare() + audio.output.send(endpoint) + audio.output.send(session.polled_audio()) + + running = session.start() + audio.write(array("f", [0.25, -0.25, 0.5, -0.5])) + frame = running.audio.read(timeout_s=1.0) + if frame is None: + raise RuntimeError("installed consumer timed out waiting for audio") + if not delivered.wait(1.0): + raise RuntimeError("installed Connector did not receive audio") + stop = running.stop() + if not stop.success: + raise RuntimeError("installed consumer Session did not stop successfully") + if frame.source_id != audio.source_id or frame.stream_id != audio.stream_id: + raise RuntimeError("installed consumer lost source or stream identity") + package_path = Path(pocketstation.__file__).resolve() + environment_root = Path(sys.prefix).resolve() + if not package_path.is_relative_to(environment_root): + raise RuntimeError("PocketStation was not imported from the environment") + print( + json.dumps( + { + "package_path": str(package_path), + "python": sys.version.split()[0], + "source_id": frame.source_id, + "stream_id": frame.stream_id, + "success": True, + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/run_artifact_consumer.py b/tests/run_artifact_consumer.py new file mode 100644 index 0000000..1accb48 --- /dev/null +++ b/tests/run_artifact_consumer.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Install one wheel or sdist into a clean environment and execute it.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] + + +def _artifact(directory: Path, kind: str) -> Path: + pattern = "pocketstation-*.whl" if kind == "wheel" else "pocketstation-*.tar.gz" + matches = tuple(sorted(directory.glob(pattern))) + if len(matches) != 1: + raise SystemExit( + f"expected one PocketStation {kind} in {directory}, found {len(matches)}" + ) + return matches[0].resolve() + + +def _interpreter(environment: Path) -> Path: + return ( + environment / "Scripts" / "python.exe" + if os.name == "nt" + else environment / "bin" / "python" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--artifact-kind", choices=("wheel", "sdist"), required=True) + parser.add_argument("--artifact-dir", type=Path, required=True) + arguments = parser.parse_args() + artifact = _artifact(arguments.artifact_dir, arguments.artifact_kind) + + with tempfile.TemporaryDirectory(prefix="pks-artifact-consumer-") as temporary: + root = Path(temporary) + environment = root / "environment" + venv.EnvBuilder(with_pip=True, clear=True).create(environment) + interpreter = _interpreter(environment) + process_environment = os.environ.copy() + process_environment["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + subprocess.run( + [ + os.fspath(interpreter), + "-m", + "pip", + "install", + os.fspath(artifact), + ], + cwd=root, + env=process_environment, + check=True, + timeout=900, + ) + consumer = root / "installed_consumer.py" + shutil.copyfile(REPOSITORY / "tests" / "installed_consumer.py", consumer) + subprocess.run( + [os.fspath(interpreter), os.fspath(consumer)], + cwd=root, + env=process_environment, + check=True, + timeout=60, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f6588051c69ff181fe553972a7f3bb603e3e4632 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 19 Aug 2026 23:50:14 -0400 Subject: [PATCH 08/49] feat: simplify Python audio integrations --- README.md | 40 +- examples/integrations/README.md | 33 ++ examples/integrations/__init__.py | 5 + examples/integrations/audio_transport.py | 61 +++ .../source_aware_transcription.ipynb | 13 +- examples/transcription/README.md | 53 +- examples/transcription/__init__.py | 16 +- examples/transcription/audio_windows.py | 154 ++++++ examples/transcription/faster_whisper.py | 272 ++++++++++ examples/transcription/run.py | 36 +- examples/transcription/run_faster_whisper.py | 97 ++++ examples/transcription/transcript.py | 11 + examples/transcription/wav_input.py | 66 +++ examples/transcription/whisper_cpp.py | 144 +----- pyproject.toml | 3 + python/pocketstation/__init__.py | 2 + python/pocketstation/aio/__init__.py | 2 + python/pocketstation/aio/connector.py | 40 ++ python/pocketstation/connector.py | 36 ++ tests/test_aio_session.py | 30 ++ tests/test_audio_transport_example.py | 46 ++ tests/test_connector.py | 27 + tests/test_public_api.py | 1 + tests/test_transcription_example.py | 82 ++- uv.lock | 486 +++++++++++++++++- 25 files changed, 1569 insertions(+), 187 deletions(-) create mode 100644 examples/integrations/README.md create mode 100644 examples/integrations/__init__.py create mode 100644 examples/integrations/audio_transport.py create mode 100644 examples/transcription/audio_windows.py create mode 100644 examples/transcription/faster_whisper.py create mode 100644 examples/transcription/run_faster_whisper.py create mode 100644 examples/transcription/transcript.py create mode 100644 examples/transcription/wav_input.py create mode 100644 tests/test_audio_transport_example.py diff --git a/README.md b/README.md index 57b25cf..860e12c 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ canonical-Session evidence is not release evidence: - shared native Relay publication of independent application and microphone buses, opaque receiver invitation after publication readiness, real browser receipt, and complete two-stem recording; -- real source-aware whisper.cpp inference and an output-free executable - notebook over the public async Operator API; +- source-aware faster-whisper integration over the normal Python model API, + plus a real hard-isolated whisper.cpp proof and output-free executable + notebook over the same public async Operator contract; - current-host sync/async boundary and slow-consumer qualification with exact units, bounded queue peaks, explicit drops, and post-shutdown descriptor, thread, native-buffer, and Python-allocation observations. @@ -202,7 +203,7 @@ backpressure, cancellation, and terminal outcomes. ## Python Connectors -The concise path declares an audio contract and handles items directly. Core +The concise path declares an audio contract and handles frames directly. Core still owns bounded receiver polling, route accounting, readiness, failure containment, and shutdown; Python executes only on the Connector's off-realtime worker. @@ -210,19 +211,20 @@ off-realtime worker. ```python import pocketstation -manifest = pocketstation.ConnectorManifest.audio( +publisher = pocketstation.Connector.from_audio_handler( "io.example.connector.stdout.v1", + lambda frame, context: print(frame.sequence_number), package_version="1.0.0", ) - -@pocketstation.connector(manifest) -def stdout(item, context): - print(item.input.port_name, item.audio.sequence_number) - session = pocketstation.Session() -endpoint = session.register_connector(stdout).declare() +endpoint = session.register_connector(publisher).declare() ``` +`pocketstation.aio.Connector.from_audio_handler(...)` accepts a coroutine and +enforces finite delivery deadlines. The complete manifest, typed +configuration, driver, grouped worker, and observation APIs remain available +for reusable provider packages. + Stateful providers implement `ConnectorDriver` and register with `Connector.with_driver(...)`. Their factory receives every resolved input descriptor, including `SignalSpec`, `MediaCaps`, `EdgeContract`, route identity, @@ -490,19 +492,23 @@ does not depend on a sibling checkout. ## Real source-aware transcription example -The example-owned whisper.cpp Operator consumes finite PCM windows outside the -realtime partition and emits JSON transcript signals carrying source, stream, -sequence, and discontinuity identity. It uses the same public `aio` Operator -API available to SDK users; no provider implementation is embedded in Core. +The primary Python example uses faster-whisper, the backend used by Pipecat's +standard local Whisper service. It consumes finite per-source PCM windows +outside the realtime partition and emits JSON transcript signals carrying +source, stream, sequence, and discontinuity identity. ```bash -python -m examples.transcription.run \ - --whisper-cli "$(command -v whisper-cli)" \ - --model /path/to/ggml-tiny.en.bin \ +pip install 'pocketstation[transcription]' +python -m examples.transcription.run_faster_whisper \ + --model base \ --wav /path/to/speech.wav \ --record-to recordings ``` +The existing whisper.cpp subprocess proof remains available when hard process +termination and reaping are required. It is no longer presented as the normal +Python integration. + The executable notebook at `examples/notebooks/source_aware_transcription.ipynb` calls the same function and stores no fabricated output. The async PCM `write()` operation waits only diff --git a/examples/integrations/README.md b/examples/integrations/README.md new file mode 100644 index 0000000..33785d9 --- /dev/null +++ b/examples/integrations/README.md @@ -0,0 +1,33 @@ +# Call and agent audio + +PocketStation does not need a second media engine or a provider enum to work +with LiveKit, Daily, Pipecat, Vapi, SIP, or a custom WebSocket. An adapter maps +the provider's decoded PCM into `AudioInput` and maps a Session stream into an +async audio Connector. + +```python +import pocketstation.aio as pks_aio + +session = pks_aio.Session(sample_rate_hz=16_000) +caller = session.audio_input("caller", sample_rate_hz=16_000) +publisher = attach_audio_sender( + session, + agent_audio, + call.send_pcm, + connector_id="io.acme.call.v1", + package_version="1.0.0", +) +await ingest_audio(caller, call.incoming_pcm()) +``` + +The provider adapter still owns authentication, codec conversion, track or +participant selection, resampling into the Session's one concrete sample +contract, reconnect policy, and remote metadata transport. Core +owns the source/stream/stem identity, bounded queues, discontinuities, routing, +recording, Operator execution, Connector lifecycle, and terminal outcome. + +`call.send_pcm` must accept PocketStation's `AudioFrame` and convert it to the +provider's required frame type. `call.incoming_pcm()` yields `IncomingAudio` +with C-contiguous float32 samples and marks provider reconnects or packet gaps +as discontinuities. No Python function runs on a capture callback or realtime +partition. diff --git a/examples/integrations/__init__.py b/examples/integrations/__init__.py new file mode 100644 index 0000000..3af3633 --- /dev/null +++ b/examples/integrations/__init__.py @@ -0,0 +1,5 @@ +"""Provider-neutral integration examples over the public Python SDK.""" + +from .audio_transport import IncomingAudio, attach_audio_sender, ingest_audio + +__all__ = ["IncomingAudio", "attach_audio_sender", "ingest_audio"] diff --git a/examples/integrations/audio_transport.py b/examples/integrations/audio_transport.py new file mode 100644 index 0000000..e6df2b3 --- /dev/null +++ b/examples/integrations/audio_transport.py @@ -0,0 +1,61 @@ +"""Bridge call, agent, or transport PCM without a provider-specific engine.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +from dataclasses import dataclass + +import pocketstation +import pocketstation.aio as pks_aio + + +@dataclass(frozen=True, slots=True) +class IncomingAudio: + """One provider-decoded float32 PCM frame and its continuity boundary.""" + + samples: object + discontinuity: bool = False + + +def attach_audio_sender( + session: pks_aio.Session, + stream: pocketstation.Stem + | pocketstation.SourceOutput + | pocketstation.DerivedStream, + sender: pks_aio.AudioConnectorHandler, + *, + connector_id: str, + package_version: str, + delivery_timeout_s: float = 5.0, +) -> pks_aio.RegisteredConnector: + """Route one Session stream into any coroutine-based audio transport.""" + connector = pks_aio.Connector.from_audio_handler( + connector_id, + sender, + package_version=package_version, + deadlines=pks_aio.ConnectorDeadlines(delivery_s=delivery_timeout_s), + ) + registered = session.register_connector(connector) + stream.send(registered.declare()) + return registered + + +async def ingest_audio( + target: pks_aio.AudioInput, + frames: AsyncIterable[IncomingAudio], + *, + write_timeout_s: float = 1.0, +) -> None: + """Feed provider-owned PCM into one bounded source and close it exactly once.""" + try: + async for frame in frames: + await target.write( + frame.samples, + discontinuity=frame.discontinuity, + timeout_s=write_timeout_s, + ) + finally: + await target.close() + + +__all__ = ["IncomingAudio", "attach_audio_sender", "ingest_audio"] diff --git a/examples/notebooks/source_aware_transcription.ipynb b/examples/notebooks/source_aware_transcription.ipynb index 0478cfd..9de1ddb 100644 --- a/examples/notebooks/source_aware_transcription.ipynb +++ b/examples/notebooks/source_aware_transcription.ipynb @@ -7,7 +7,7 @@ "source": [ "# Source-aware transcription with PocketStation\n", "\n", - "This notebook sends application-owned PCM through the real bounded Session, records it as an independent stem, and transcribes it with a local whisper.cpp Operator. Set the three environment variables below to real installed artifacts; no output is fabricated or embedded in this notebook." + "This notebook sends application-owned PCM through the real bounded Session, records it as an independent stem, and transcribes it through the normal faster-whisper Python API. Set a real WAV path below; the model may be a downloaded model name or local path. No output is fabricated or embedded in this notebook." ] }, { @@ -21,7 +21,7 @@ "import os\n", "from pathlib import Path\n", "\n", - "from examples.transcription.run import transcribe_wav" + "from examples.transcription.run_faster_whisper import transcribe_wav" ] }, { @@ -31,8 +31,9 @@ "metadata": {}, "outputs": [], "source": [ - "whisper_cli = Path(os.environ[\"POCKETSTATION_WHISPER_CLI\"])\n", - "model = Path(os.environ[\"POCKETSTATION_WHISPER_MODEL\"])\n", + "model = os.environ.get(\"POCKETSTATION_WHISPER_MODEL\", \"base\")\n", + "device = os.environ.get(\"POCKETSTATION_WHISPER_DEVICE\", \"auto\")\n", + "compute_type = os.environ.get(\"POCKETSTATION_WHISPER_COMPUTE_TYPE\", \"default\")\n", "wav = Path(os.environ[\"POCKETSTATION_WHISPER_WAV\"])\n", "record_to = Path(\n", " os.environ.get(\"POCKETSTATION_NOTEBOOK_RECORDINGS\", \"recordings/notebook\")\n", @@ -47,8 +48,10 @@ "outputs": [], "source": [ "transcript = await transcribe_wav(\n", - " whisper_cli=whisper_cli,\n", " model=model,\n", + " device=device,\n", + " compute_type=compute_type,\n", + " language=None,\n", " wav=wav,\n", " record_to=record_to,\n", ")\n", diff --git a/examples/transcription/README.md b/examples/transcription/README.md index c455c63..530873b 100644 --- a/examples/transcription/README.md +++ b/examples/transcription/README.md @@ -1,9 +1,52 @@ # Source-aware transcription -This example consumes PocketStation PCM signals on a bounded async Operator, -runs a local `whisper-cli` process outside capture and realtime partitions, and -emits finite JSON transcript signals containing source, stream, sequence, and -discontinuity identity. +The primary Python path uses `faster-whisper`, the local Whisper backend also +used by Pipecat's standard Python Whisper service. PocketStation hosts it as a +bounded async Operator and emits typed JSON transcript signals containing +source, stream, sequence, and discontinuity identity. + +```sh +pip install 'pocketstation[transcription]' +``` + +```python +transcriber = FasterWhisper(FasterWhisperConfiguration(model="base")) +transcripts = transcriber.attach(session, microphone) +``` + +Named models may be downloaded through the provider library. Deployments that +require an offline trust boundary can point at a provisioned model directory +and disable network access explicitly: + +```python +configuration = FasterWhisperConfiguration( + model="/opt/models/whisper-base-ct2", + allow_model_download=False, +) +``` + +Executable WAV proof: + +```sh +python -m examples.transcription.run_faster_whisper \ + --model base \ + --wav /path/to/speech.wav \ + --record-to recordings +``` + +The same two-line attachment accepts a captured `Stem`, application-owned +`SourceOutput`, or generated `DerivedStream`. Model loading and inference run +outside capture and realtime partitions. Core owns the finite Operator input +queue; `FasterWhisperConfiguration` bounds window duration, source count, +output size, and operation deadlines. An asyncio deadline bounds the Session +operation, but it cannot preempt an already-running CTranslate2 call in a +Python worker thread. Use the subprocess alternative below when forced provider +termination is required. + +## Hard-isolated whisper.cpp alternative + +The subprocess example remains useful when killing and reaping the provider at +a hard deadline matters more than the normal Python model API: ```sh python -m examples.transcription.run \ @@ -13,7 +56,7 @@ python -m examples.transcription.run \ --record-to recordings ``` -The example defaults to CPU inference. Provider processes have finite startup, +The subprocess defaults to CPU inference. Provider processes have finite startup, execution, output, and shutdown limits. Each Session route retains its own bounded queue, so a slow transcription branch does not become the Relay or recording queue. diff --git a/examples/transcription/__init__.py b/examples/transcription/__init__.py index dcc98ea..04e79f1 100644 --- a/examples/transcription/__init__.py +++ b/examples/transcription/__init__.py @@ -1,9 +1,13 @@ """Source-aware transcription examples built on the public PocketStation SDK.""" -from .whisper_cpp import ( - TRANSCRIPT_SIGNAL, - WhisperCpp, - WhisperCppConfiguration, -) +from .faster_whisper import FasterWhisper, FasterWhisperConfiguration +from .transcript import TRANSCRIPT_SIGNAL +from .whisper_cpp import WhisperCpp, WhisperCppConfiguration -__all__ = ["TRANSCRIPT_SIGNAL", "WhisperCpp", "WhisperCppConfiguration"] +__all__ = [ + "TRANSCRIPT_SIGNAL", + "FasterWhisper", + "FasterWhisperConfiguration", + "WhisperCpp", + "WhisperCppConfiguration", +] diff --git a/examples/transcription/audio_windows.py b/examples/transcription/audio_windows.py new file mode 100644 index 0000000..4130e5e --- /dev/null +++ b/examples/transcription/audio_windows.py @@ -0,0 +1,154 @@ +"""Finite source-aware PCM windows shared by transcription examples.""" + +from __future__ import annotations + +import sys +from array import array +from dataclasses import dataclass, field + +import pocketstation + + +@dataclass(slots=True) +class AudioWindow: + sample_rate_hz: int + channel_count: int + source_id: int + stream_id: int + sequence_start: int + sequence_end: int + discontinuity_epoch: int + samples: array[float] = field(default_factory=lambda: array("f")) + + @property + def duration_ms(self) -> int: + return round( + len(self.samples) * 1_000 / (self.sample_rate_hz * self.channel_count) + ) + + +class AudioWindowBuffer: + """Bounded per-source accumulator that splits on format discontinuity.""" + + def __init__(self, *, window_seconds: float, maximum_sources: int) -> None: + self._window_seconds = window_seconds + self._maximum_sources = maximum_sources + self._windows: dict[tuple[int, int], AudioWindow] = {} + + def push( + self, + envelope: pocketstation.SignalEnvelope, + ) -> tuple[AudioWindow, ...]: + payload = envelope.payload + if not isinstance(payload, pocketstation.SignalAudioPayload): + raise TypeError("transcription accepts only PCM audio signals") + lineage = envelope.lineage + if lineage is None: + raise ValueError("transcription requires source-aware audio lineage") + + key = (payload.source_id, payload.stream_id) + window = self._windows.get(key) + incompatible = window is not None and ( + window.sample_rate_hz != payload.sample_rate_hz + or window.channel_count != payload.channel_count + or window.discontinuity_epoch != lineage.discontinuity_epoch + ) + completed: list[AudioWindow] = [] + if incompatible and window is not None: + if window.samples: + completed.append(window) + del self._windows[key] + window = None + if window is None: + if len(self._windows) >= self._maximum_sources: + raise RuntimeError("maximum concurrent transcription sources exceeded") + window = AudioWindow( + sample_rate_hz=payload.sample_rate_hz, + channel_count=payload.channel_count, + source_id=payload.source_id, + stream_id=payload.stream_id, + sequence_start=payload.sequence_number, + sequence_end=payload.sequence_number, + discontinuity_epoch=lineage.discontinuity_epoch, + ) + self._windows[key] = window + + samples = array("f") + samples.frombytes(payload.samples_f32le) + if sys.byteorder != "little": + samples.byteswap() + if len(samples) != payload.sample_count: + raise ValueError("audio payload size does not match sample_count") + window.samples.extend(samples) + window.sequence_end = payload.sequence_number + + target_samples = int( + window.sample_rate_hz * window.channel_count * self._window_seconds + ) + while len(window.samples) >= target_samples: + completed.append( + AudioWindow( + sample_rate_hz=window.sample_rate_hz, + channel_count=window.channel_count, + source_id=window.source_id, + stream_id=window.stream_id, + sequence_start=window.sequence_start, + sequence_end=window.sequence_end, + discontinuity_epoch=window.discontinuity_epoch, + samples=array("f", window.samples[:target_samples]), + ) + ) + del window.samples[:target_samples] + window.sequence_start = payload.sequence_number + return tuple(completed) + + def flush(self) -> tuple[AudioWindow, ...]: + completed = tuple(window for window in self._windows.values() if window.samples) + self._windows.clear() + return completed + + def clear(self) -> None: + self._windows.clear() + + +def mono_16khz(window: AudioWindow) -> array[float]: + return resample( + downmix(window.samples, window.channel_count), + window.sample_rate_hz, + ) + + +def downmix(samples: array[float], channels: int) -> array[float]: + if channels == 1: + return array("f", samples) + return array( + "f", + ( + sum(samples[index : index + channels]) / channels + for index in range(0, len(samples), channels) + ), + ) + + +def resample( + samples: array[float], source_rate_hz: int, target_rate_hz: int = 16_000 +) -> array[float]: + if source_rate_hz == target_rate_hz: + return samples + output_count = round(len(samples) * target_rate_hz / source_rate_hz) + if not samples or output_count == 0: + return array("f") + if len(samples) == 1: + return array("f", [samples[0]] * output_count) + scale = source_rate_hz / target_rate_hz + output = array("f") + for output_index in range(output_count): + position = min(output_index * scale, len(samples) - 1) + lower = int(position) + upper = min(lower + 1, len(samples) - 1) + fraction = position - lower + output.append(samples[lower] + (samples[upper] - samples[lower]) * fraction) + return output + + +__all__ = ["AudioWindow", "AudioWindowBuffer", "mono_16khz"] diff --git a/examples/transcription/faster_whisper.py b/examples/transcription/faster_whisper.py new file mode 100644 index 0000000..991d764 --- /dev/null +++ b/examples/transcription/faster_whisper.py @@ -0,0 +1,272 @@ +"""Source-aware local transcription through the faster-whisper Python API.""" + +from __future__ import annotations + +import asyncio +import importlib +import json +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Any, Protocol, cast + +import pocketstation +import pocketstation.aio as pks_aio + +from examples.transcription.audio_windows import ( + AudioWindow, + AudioWindowBuffer, + mono_16khz, +) +from examples.transcription.transcript import TRANSCRIPT_SIGNAL + + +class WhisperSegment(Protocol): + start: float + end: float + text: str + + +class WhisperInfo(Protocol): + language: str + language_probability: float + + +class WhisperModel(Protocol): + def transcribe( + self, + audio: object, + *, + beam_size: int, + language: str | None, + vad_filter: bool, + ) -> tuple[Iterable[WhisperSegment], WhisperInfo]: ... + + +@dataclass(frozen=True, slots=True) +class FasterWhisperConfiguration: + """Finite model and buffering policy for one local transcription Operator.""" + + model: str = "base" + device: str = "auto" + compute_type: str = "default" + allow_model_download: bool = True + language: str | None = None + beam_size: int = 5 + vad_filter: bool = True + window_seconds: float = 5.0 + queue_capacity_signals: int = 512 + maximum_sources: int = 8 + maximum_output_bytes: int = 1_048_576 + create_timeout_s: float = 120.0 + inference_timeout_s: float = 120.0 + + def __post_init__(self) -> None: + for name, value in ( + ("model", self.model), + ("device", self.device), + ("compute_type", self.compute_type), + ): + if not value.strip(): + raise ValueError(f"{name} must not be empty") + if self.language is not None and ( + not self.language or not self.language.isascii() + ): + raise ValueError("language must be None or non-empty ASCII") + if not 1 <= self.beam_size <= 32: + raise ValueError("beam_size must be between 1 and 32") + if not 0.1 <= self.window_seconds <= 30: + raise ValueError("window_seconds must be between 0.1 and 30") + if not 8 <= self.queue_capacity_signals <= 4_096: + raise ValueError("queue_capacity_signals must be between 8 and 4096") + if not 1 <= self.maximum_sources <= 64: + raise ValueError("maximum_sources must be between 1 and 64") + if not 1_024 <= self.maximum_output_bytes <= 16_777_216: + raise ValueError("maximum_output_bytes must be between 1024 and 16777216") + if not 1 <= self.create_timeout_s <= 600: + raise ValueError("create_timeout_s must be between 1 and 600") + if not 1 <= self.inference_timeout_s <= 600: + raise ValueError("inference_timeout_s must be between 1 and 600") + + +ModelFactory = Callable[[FasterWhisperConfiguration], WhisperModel] +AudioConverter = Callable[[AudioWindow], object] + + +class _FasterWhisperNode(pks_aio.OperatorNode): + def __init__( + self, + configuration: FasterWhisperConfiguration, + model: WhisperModel, + audio_converter: AudioConverter, + ) -> None: + self._configuration = configuration + self._model = model + self._audio_converter = audio_converter + self._windows = AudioWindowBuffer( + window_seconds=configuration.window_seconds, + maximum_sources=configuration.maximum_sources, + ) + self._cancelled = False + + async def process( + self, + input_port: str, + envelope: pocketstation.SignalEnvelope, + ) -> tuple[pocketstation.OperatorEmission, ...]: + if input_port != "audio": + raise ValueError(f"unexpected input port: {input_port}") + if self._cancelled: + raise asyncio.CancelledError + emissions = [] + for window in self._windows.push(envelope): + emissions.append(await self._transcribe(window)) + return tuple(emissions) + + async def flush(self) -> tuple[pocketstation.OperatorEmission, ...]: + if self._cancelled: + self._windows.clear() + return () + emissions = [] + for window in self._windows.flush(): + emissions.append(await self._transcribe(window)) + return tuple(emissions) + + async def cancel(self) -> None: + self._cancelled = True + self._windows.clear() + + async def close(self) -> None: + await self.cancel() + + async def _transcribe( + self, + window: AudioWindow, + ) -> pocketstation.OperatorEmission: + result = await asyncio.wait_for( + asyncio.to_thread(self._transcribe_sync, window), + timeout=self._configuration.inference_timeout_s, + ) + encoded = json.dumps(result, separators=(",", ":"), sort_keys=True) + if len(encoded.encode()) > self._configuration.maximum_output_bytes: + raise RuntimeError("transcript envelope exceeds maximum_output_bytes") + return pocketstation.OperatorEmission.text(encoded, signal=TRANSCRIPT_SIGNAL) + + def _transcribe_sync(self, window: AudioWindow) -> dict[str, object]: + samples = self._audio_converter(window) + segments, info = self._model.transcribe( + samples, + beam_size=self._configuration.beam_size, + language=self._configuration.language, + vad_filter=self._configuration.vad_filter, + ) + completed = tuple(segments) + return { + "channel_count": window.channel_count, + "discontinuity_epoch": window.discontinuity_epoch, + "duration_ms": window.duration_ms, + "language": info.language, + "language_probability": info.language_probability, + "sample_rate_hz": window.sample_rate_hz, + "segments": [ + { + "end_s": segment.end, + "start_s": segment.start, + "text": segment.text.strip(), + } + for segment in completed + ], + "sequence_end": window.sequence_end, + "sequence_start": window.sequence_start, + "source_id": window.source_id, + "stream_id": window.stream_id, + "text": " ".join(segment.text.strip() for segment in completed).strip(), + } + + +class FasterWhisper: + """Example-owned faster-whisper provider registered as one async Operator.""" + + def __init__( + self, + configuration: FasterWhisperConfiguration | None = None, + *, + model_factory: ModelFactory | None = None, + _audio_converter: AudioConverter | None = None, + ) -> None: + self.configuration = configuration or FasterWhisperConfiguration() + self._model_factory = model_factory or _load_model + self._audio_converter = _audio_converter or _numpy_audio + self.manifest = pocketstation.OperatorManifest( + "community.faster-whisper.stt.v1", + inputs=( + pocketstation.PortSpec.input("audio", pocketstation.SignalSpec.audio()), + ), + outputs=(pocketstation.PortSpec.output("transcript", TRANSCRIPT_SIGNAL),), + queue_capacity_signals=self.configuration.queue_capacity_signals, + process_timeout_ms=round( + (self.configuration.inference_timeout_s + 1) * 1_000 + ), + network_allowed=self.configuration.allow_model_download, + filesystem_allowed=True, + terminal_roles=("transcript.final",), + ) + + def provider(self) -> pks_aio.OperatorProvider: + async def create(_configuration: Mapping[str, str]) -> _FasterWhisperNode: + model = await asyncio.to_thread(self._model_factory, self.configuration) + return _FasterWhisperNode( + self.configuration, + model, + self._audio_converter, + ) + + return pks_aio.OperatorProvider.with_node( + self.manifest, + create, + deadlines=pks_aio.OperatorDeadlines( + create_s=self.configuration.create_timeout_s, + prepare_s=5, + process_s=self.configuration.inference_timeout_s + 0.5, + close_s=5, + ), + ) + + def attach( + self, + session: pks_aio.Session, + stream: pocketstation.Stem + | pocketstation.SourceOutput + | pocketstation.DerivedStream, + ) -> pocketstation.BusSubscription: + """Attach transcription to any Session-owned PCM stream in two lines.""" + operator = session.register_operator(self.provider()).declare() + stream.connect(operator.input("audio")) + return session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + + +def _load_model(configuration: FasterWhisperConfiguration) -> WhisperModel: + try: + module = importlib.import_module("faster_whisper") + except ModuleNotFoundError as error: + raise RuntimeError( + "install PocketStation with the transcription extra: " + "pip install 'pocketstation[transcription]'" + ) from error + model: Any = module.WhisperModel( + configuration.model, + device=configuration.device, + compute_type=configuration.compute_type, + local_files_only=not configuration.allow_model_download, + ) + return cast(WhisperModel, model) + + +def _numpy_audio(window: AudioWindow) -> object: + numpy = importlib.import_module("numpy") + return numpy.asarray(mono_16khz(window), dtype="float32") + + +__all__ = ["TRANSCRIPT_SIGNAL", "FasterWhisper", "FasterWhisperConfiguration"] diff --git a/examples/transcription/run.py b/examples/transcription/run.py index 941a09c..0009835 100644 --- a/examples/transcription/run.py +++ b/examples/transcription/run.py @@ -5,15 +5,14 @@ import argparse import asyncio import json -import wave -from array import array from pathlib import Path import pocketstation import pocketstation.aio as pks_aio +from examples.transcription.transcript import TRANSCRIPT_SIGNAL +from examples.transcription.wav_input import feed_live, read_pcm16_wav from examples.transcription.whisper_cpp import ( - TRANSCRIPT_SIGNAL, WhisperCpp, WhisperCppConfiguration, ) @@ -47,34 +46,23 @@ async def transcribe_wav( record_to: Path, ) -> dict[str, object]: """Transcribe one real WAV through Session audio input and recording.""" - with wave.open(str(wav), "rb") as source: - if source.getsampwidth() != 2: - raise ValueError("input WAV must contain 16-bit PCM") - sample_rate_hz = source.getframerate() - channels = source.getnchannels() - source_frames = source.getnframes() - pcm = array("h") - pcm.frombytes(source.readframes(source_frames)) - samples = array("f", (value / 32_768 for value in pcm)) - frame_samples_per_channel = max(1, sample_rate_hz // 50) - frame_values = frame_samples_per_channel * channels - duration_s = source_frames / sample_rate_hz + source = read_pcm16_wav(wav) session = pks_aio.Session( recording_root=record_to, - sample_rate_hz=sample_rate_hz, - channels=channels, + sample_rate_hz=source.sample_rate_hz, + channels=source.channels, ) audio = session.audio_input( "application-owned-speech", capacity_frames=32, - frame_samples_per_channel=frame_samples_per_channel, + frame_samples_per_channel=source.frame_samples_per_channel, ) whisper = WhisperCpp( WhisperCppConfiguration( executable=whisper_cli, model=model, - window_seconds=min(30, max(0.1, duration_s)), + window_seconds=min(30, max(0.1, source.duration_s)), ) ) operator = session.register_operator(whisper.provider()).declare() @@ -86,15 +74,7 @@ async def transcribe_wav( running = await session.start() try: - for offset in range(0, len(samples), frame_values): - frame = samples[offset : offset + frame_values] - if len(frame) < frame_values: - frame.extend([0.0] * (frame_values - len(frame))) - await audio.write(frame, timeout_s=2) - # A file has no capture clock. Yield finite pacing so this proof - # exercises normal live ingestion instead of artificial burst loss. - await asyncio.sleep(frame_samples_per_channel / sample_rate_hz / 10) - await audio.close() + await feed_live(audio, source) result = await asyncio.wait_for( anext(running.signals(subscription).__aiter__()), timeout=whisper.configuration.process_timeout_s, diff --git a/examples/transcription/run_faster_whisper.py b/examples/transcription/run_faster_whisper.py new file mode 100644 index 0000000..087aa29 --- /dev/null +++ b/examples/transcription/run_faster_whisper.py @@ -0,0 +1,97 @@ +"""Run source-aware faster-whisper transcription from an installed SDK.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path + +import pocketstation +import pocketstation.aio as pks_aio + +from examples.transcription.faster_whisper import ( + FasterWhisper, + FasterWhisperConfiguration, +) +from examples.transcription.wav_input import feed_live, read_pcm16_wav + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--model", default="base") + parser.add_argument("--device", default="auto") + parser.add_argument("--compute-type", default="default") + parser.add_argument("--language") + parser.add_argument("--wav", type=Path, required=True) + parser.add_argument("--record-to", type=Path, required=True) + return parser.parse_args() + + +async def main() -> None: + arguments = _arguments() + result = await transcribe_wav( + model=arguments.model, + device=arguments.device, + compute_type=arguments.compute_type, + language=arguments.language, + wav=arguments.wav, + record_to=arguments.record_to, + ) + print(json.dumps(result, indent=2)) + + +async def transcribe_wav( + *, + model: str, + device: str, + compute_type: str, + language: str | None, + wav: Path, + record_to: Path, +) -> dict[str, object]: + """Transcribe one WAV through the public Session and Python model API.""" + source = read_pcm16_wav(wav) + session = pks_aio.Session( + recording_root=record_to, + sample_rate_hz=source.sample_rate_hz, + channels=source.channels, + ) + audio = session.audio_input( + "application-owned-speech", + capacity_frames=32, + frame_samples_per_channel=source.frame_samples_per_channel, + ) + transcriber = FasterWhisper( + FasterWhisperConfiguration( + model=model, + device=device, + compute_type=compute_type, + language=language, + window_seconds=min(30, max(0.1, source.duration_s)), + ) + ) + transcripts = transcriber.attach(session, audio.output) + audio.output.record("application-owned-speech") + + running = await session.start() + try: + await feed_live(audio, source) + envelope = await asyncio.wait_for( + anext(running.signals(transcripts).__aiter__()), + timeout=transcriber.configuration.inference_timeout_s, + ) + if not isinstance(envelope, pocketstation.SignalEnvelope): + raise RuntimeError("transcription ended without a transcript") + transcript = json.loads(str(envelope.payload)) + finally: + outcome = await running.stop() + if not outcome.success: + raise RuntimeError(outcome) + if not isinstance(transcript, dict): + raise RuntimeError("transcription result must be a JSON object") + return transcript + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/transcription/transcript.py b/examples/transcription/transcript.py new file mode 100644 index 0000000..7b3097b --- /dev/null +++ b/examples/transcription/transcript.py @@ -0,0 +1,11 @@ +"""Typed transcript signal shared by transcription providers.""" + +import pocketstation + +TRANSCRIPT_SIGNAL = pocketstation.SignalSpec.text( + pocketstation.TextFormat.JSON, + role="transcript.final", + schema="io.pocketstation.transcript.batch.v1", +) + +__all__ = ["TRANSCRIPT_SIGNAL"] diff --git a/examples/transcription/wav_input.py b/examples/transcription/wav_input.py new file mode 100644 index 0000000..11698d4 --- /dev/null +++ b/examples/transcription/wav_input.py @@ -0,0 +1,66 @@ +"""Finite PCM WAV input for executable transcription examples.""" + +from __future__ import annotations + +import asyncio +import wave +from array import array +from dataclasses import dataclass +from pathlib import Path + +import pocketstation.aio as pks_aio + + +@dataclass(frozen=True, slots=True) +class WavInput: + sample_rate_hz: int + channels: int + source_frames: int + samples: array[float] + + @property + def duration_s(self) -> float: + return self.source_frames / self.sample_rate_hz + + @property + def frame_samples_per_channel(self) -> int: + return max(1, self.sample_rate_hz // 50) + + +def read_pcm16_wav(path: Path) -> WavInput: + with wave.open(str(path), "rb") as source: + if source.getsampwidth() != 2: + raise ValueError("input WAV must contain 16-bit PCM") + sample_rate_hz = source.getframerate() + channels = source.getnchannels() + source_frames = source.getnframes() + pcm = array("h") + pcm.frombytes(source.readframes(source_frames)) + return WavInput( + sample_rate_hz=sample_rate_hz, + channels=channels, + source_frames=source_frames, + samples=array("f", (value / 32_768 for value in pcm)), + ) + + +async def feed_live( + audio: pks_aio.AudioInput, + source: WavInput, + *, + timeout_s: float = 2.0, + pacing_ratio: float = 0.1, +) -> None: + frame_values = source.frame_samples_per_channel * source.channels + for offset in range(0, len(source.samples), frame_values): + frame = source.samples[offset : offset + frame_values] + if len(frame) < frame_values: + frame.extend([0.0] * (frame_values - len(frame))) + await audio.write(frame, timeout_s=timeout_s) + await asyncio.sleep( + source.frame_samples_per_channel / source.sample_rate_hz * pacing_ratio + ) + await audio.close() + + +__all__ = ["WavInput", "feed_live", "read_pcm16_wav"] diff --git a/examples/transcription/whisper_cpp.py b/examples/transcription/whisper_cpp.py index f80ee7a..17c1121 100644 --- a/examples/transcription/whisper_cpp.py +++ b/examples/transcription/whisper_cpp.py @@ -8,18 +8,19 @@ import wave from array import array from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory import pocketstation import pocketstation.aio as pks_aio -TRANSCRIPT_SIGNAL = pocketstation.SignalSpec.text( - pocketstation.TextFormat.JSON, - role="transcript.final", - schema="io.pocketstation.transcript.batch.v1", +from examples.transcription.audio_windows import ( + AudioWindow, + AudioWindowBuffer, + mono_16khz, ) +from examples.transcription.transcript import TRANSCRIPT_SIGNAL @dataclass(frozen=True, slots=True) @@ -64,22 +65,13 @@ def __post_init__(self) -> None: raise ValueError("maximum_error_bytes must be between 1024 and 1048576") -@dataclass(slots=True) -class _AudioWindow: - sample_rate_hz: int - channel_count: int - source_id: int - stream_id: int - sequence_start: int - sequence_end: int - discontinuity_epoch: int - samples: array[float] = field(default_factory=lambda: array("f")) - - class _WhisperNode(pks_aio.OperatorNode): def __init__(self, configuration: WhisperCppConfiguration) -> None: self._configuration = configuration - self._windows: dict[tuple[int, int], _AudioWindow] = {} + self._windows = AudioWindowBuffer( + window_seconds=configuration.window_seconds, + maximum_sources=configuration.maximum_sources, + ) self._children: set[asyncio.subprocess.Process] = set() self._cancelled = False @@ -90,79 +82,19 @@ async def process( ) -> tuple[pocketstation.OperatorEmission, ...]: if input_port != "audio": raise ValueError(f"unexpected input port: {input_port}") - payload = envelope.payload - if not isinstance(payload, pocketstation.SignalAudioPayload): - raise TypeError("WhisperCpp accepts only PCM audio signals") - lineage = envelope.lineage - if lineage is None: - raise ValueError("WhisperCpp requires source-aware audio lineage") if self._cancelled: raise asyncio.CancelledError - - key = (payload.source_id, payload.stream_id) - window = self._windows.get(key) - incompatible = window is not None and ( - window.sample_rate_hz != payload.sample_rate_hz - or window.channel_count != payload.channel_count - or window.discontinuity_epoch != lineage.discontinuity_epoch + return tuple( + [await self._transcribe(window) for window in self._windows.push(envelope)] ) - emissions: list[pocketstation.OperatorEmission] = [] - if incompatible and window is not None: - if window.samples: - emissions.append(await self._transcribe(window)) - del self._windows[key] - window = None - if window is None: - if len(self._windows) >= self._configuration.maximum_sources: - raise RuntimeError("maximum concurrent transcription sources exceeded") - window = _AudioWindow( - sample_rate_hz=payload.sample_rate_hz, - channel_count=payload.channel_count, - source_id=payload.source_id, - stream_id=payload.stream_id, - sequence_start=payload.sequence_number, - sequence_end=payload.sequence_number, - discontinuity_epoch=lineage.discontinuity_epoch, - ) - self._windows[key] = window - - samples = array("f") - samples.frombytes(payload.samples_f32le) - if sys.byteorder != "little": - samples.byteswap() - if len(samples) != payload.sample_count: - raise ValueError("audio payload size does not match sample_count") - window.samples.extend(samples) - window.sequence_end = payload.sequence_number - - target_samples = int( - window.sample_rate_hz - * window.channel_count - * self._configuration.window_seconds - ) - if len(window.samples) >= target_samples: - batch = _AudioWindow( - sample_rate_hz=window.sample_rate_hz, - channel_count=window.channel_count, - source_id=window.source_id, - stream_id=window.stream_id, - sequence_start=window.sequence_start, - sequence_end=window.sequence_end, - discontinuity_epoch=window.discontinuity_epoch, - samples=array("f", window.samples[:target_samples]), - ) - del window.samples[:target_samples] - window.sequence_start = payload.sequence_number - emissions.append(await self._transcribe(batch)) - return tuple(emissions) async def flush(self) -> tuple[pocketstation.OperatorEmission, ...]: - emissions: list[pocketstation.OperatorEmission] = [] - for window in tuple(self._windows.values()): - if window.samples and not self._cancelled: - emissions.append(await self._transcribe(window)) - self._windows.clear() - return tuple(emissions) + if self._cancelled: + self._windows.clear() + return () + return tuple( + [await self._transcribe(window) for window in self._windows.flush()] + ) async def cancel(self) -> None: self._cancelled = True @@ -175,7 +107,7 @@ async def cancel(self) -> None: async def close(self) -> None: await self.cancel() - async def _transcribe(self, window: _AudioWindow) -> pocketstation.OperatorEmission: + async def _transcribe(self, window: AudioWindow) -> pocketstation.OperatorEmission: if self._cancelled: raise asyncio.CancelledError with TemporaryDirectory(prefix="pocketstation-whisper-") as directory: @@ -317,9 +249,8 @@ async def create(_configuration: Mapping[str, str]) -> _WhisperNode: ) -def _write_whisper_wav(path: Path, window: _AudioWindow) -> None: - mono = _downmix(window.samples, window.channel_count) - resampled = _resample(mono, window.sample_rate_hz, 16_000) +def _write_whisper_wav(path: Path, window: AudioWindow) -> None: + resampled = mono_16khz(window) pcm = array( "h", (round(max(-1.0, min(1.0, value)) * 32_767) for value in resampled) ) @@ -332,39 +263,6 @@ def _write_whisper_wav(path: Path, window: _AudioWindow) -> None: output.writeframes(pcm.tobytes()) -def _downmix(samples: array[float], channels: int) -> array[float]: - if channels == 1: - return array("f", samples) - return array( - "f", - ( - sum(samples[index : index + channels]) / channels - for index in range(0, len(samples), channels) - ), - ) - - -def _resample( - samples: array[float], source_rate_hz: int, target_rate_hz: int -) -> array[float]: - if source_rate_hz == target_rate_hz: - return samples - output_count = round(len(samples) * target_rate_hz / source_rate_hz) - if not samples or output_count == 0: - return array("f") - if len(samples) == 1: - return array("f", [samples[0]] * output_count) - scale = source_rate_hz / target_rate_hz - output = array("f") - for output_index in range(output_count): - position = min(output_index * scale, len(samples) - 1) - lower = int(position) - upper = min(lower + 1, len(samples) - 1) - fraction = position - lower - output.append(samples[lower] + (samples[upper] - samples[lower]) * fraction) - return output - - def _read_bounded(path: Path, maximum_bytes: int) -> bytes: size = path.stat().st_size if size > maximum_bytes: diff --git a/pyproject.toml b/pyproject.toml index 0a2d645..cac1fde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ ] [project.optional-dependencies] +transcription = [ + "faster-whisper>=1.2.1,<2.0", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index 1c07bf2..7ce3dd6 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -6,6 +6,7 @@ from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource from .capture import Capture, capture from .connector import ( + AudioConnectorHandler, Connector, ConnectorBatchOutcome, ConnectorCapability, @@ -256,6 +257,7 @@ "STREAM_EOF", "AudioBatch", "AudioCaps", + "AudioConnectorHandler", "AudioFrame", "AudioInput", "AudioInputBufferError", diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index 7fa6f1d..f7d52f0 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -3,6 +3,7 @@ from .audio_input import AudioInput, PcmSource from .capture import Capture, capture from .connector import ( + AudioConnectorHandler, Connector, ConnectorDeadlines, ConnectorDriver, @@ -60,6 +61,7 @@ from .streams import AudioStream, SignalStream __all__ = [ + "AudioConnectorHandler", "AudioInput", "AudioStream", "Capture", diff --git a/python/pocketstation/aio/connector.py b/python/pocketstation/aio/connector.py index 6d04680..15eb113 100644 --- a/python/pocketstation/aio/connector.py +++ b/python/pocketstation/aio/connector.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable +from .._native import AudioFrame from ..connector import ( Connector as SyncConnector, ) @@ -99,6 +100,10 @@ async def prepare( [ConnectorItem, ConnectorContext], Coroutine[Any, Any, ConnectorDeliveryOutcome | None], ] +AudioConnectorHandler: TypeAlias = Callable[ + [AudioFrame, ConnectorContext], + Coroutine[Any, Any, ConnectorDeliveryOutcome | None], +] _Result = TypeVar("_Result") @@ -404,6 +409,40 @@ async def prepare( return cls.with_driver(manifest, prepare, deadlines=deadlines) + @classmethod + def from_audio_handler( + cls, + operator_id: str, + handler: AudioConnectorHandler, + *, + package_version: str, + port_name: str = "audio", + deadlines: ConnectorDeadlines | None = None, + ) -> Connector: + """Create the common async PCM Connector without a manual manifest.""" + + async def deliver( + item: ConnectorItem, + context: ConnectorContext, + ) -> ConnectorDeliveryOutcome | None: + if item.audio is None: + raise ConnectorError( + "audio Connector received a non-audio item", + code="connector.delivery.signal_mismatch", + stage=ConnectorErrorStage.DELIVERY, + ) + return await handler(item.audio, context) + + return cls.from_handler( + ConnectorManifest.audio( + operator_id, + package_version=package_version, + port_name=port_name, + ), + deliver, + deadlines=deadlines, + ) + @classmethod def with_worker( cls, @@ -520,6 +559,7 @@ def _wait_for_provider( __all__ = [ + "AudioConnectorHandler", "Connector", "ConnectorDeadlines", "ConnectorDriver", diff --git a/python/pocketstation/connector.py b/python/pocketstation/connector.py index 55f87ee..149eda3 100644 --- a/python/pocketstation/connector.py +++ b/python/pocketstation/connector.py @@ -645,6 +645,9 @@ def prepare( ConnectorHandler: TypeAlias = Callable[ [ConnectorItem, ConnectorContext], ConnectorDeliveryOutcome | None ] +AudioConnectorHandler: TypeAlias = Callable[ + [AudioFrame, ConnectorContext], ConnectorDeliveryOutcome | None +] ConnectorPreparationGroup: TypeAlias = Callable[ [int, Mapping[str, ConnectorConfigurationValue]], str | None ] @@ -920,6 +923,38 @@ def from_handler( """Create the common stateless Connector directly from a handler.""" return cls(manifest, lambda _inputs: _HandlerDriver(handler)) + @classmethod + def from_audio_handler( + cls, + operator_id: str, + handler: AudioConnectorHandler, + *, + package_version: str, + port_name: str = "audio", + ) -> Connector: + """Create the common PCM Connector without hand-writing a manifest.""" + + def deliver( + item: ConnectorItem, + context: ConnectorContext, + ) -> ConnectorDeliveryOutcome | None: + if item.audio is None: + raise ConnectorError( + "audio Connector received a non-audio item", + code="connector.delivery.signal_mismatch", + stage=ConnectorErrorStage.DELIVERY, + ) + return handler(item.audio, context) + + return cls.from_handler( + ConnectorManifest.audio( + operator_id, + package_version=package_version, + port_name=port_name, + ), + deliver, + ) + class RegisteredConnector: """One reusable Connector implementation bound to one Session draft.""" @@ -1021,6 +1056,7 @@ def _default_edge(manifest: ConnectorManifest) -> EdgeContract: __all__ = [ + "AudioConnectorHandler", "Connector", "ConnectorBatchOutcome", "ConnectorCapability", diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 6a72022..330b375 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -147,6 +147,36 @@ async def receive(item, context): assert (await running.stop()).success +@pytest.mark.asyncio +async def test_async_audio_connector_convenience_runs_on_owning_loop() -> None: + delivered = asyncio.Event() + received = [] + owning_thread = threading.get_ident() + + async def publish(frame, context): + assert threading.get_ident() == owning_thread + received.append(frame) + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + connector = AsyncConnector.from_audio_handler( + "io.pocketstation.test.aio-audio-handler.v1", + publish, + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("remote-call", frame_samples_per_channel=4) + audio.output.send(session.register_connector(connector).declare()) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + await asyncio.wait_for(delivered.wait(), 1.0) + assert (await running.stop()).success + assert connector.manifest.inputs[0].signal.is_audio + assert received[0].source_id == audio.source_id + assert received[0].stream_id == audio.stream_id + + @pytest.mark.asyncio async def test_async_connector_delivery_deadline_is_finite_and_structured() -> None: started = asyncio.Event() diff --git a/tests/test_audio_transport_example.py b/tests/test_audio_transport_example.py new file mode 100644 index 0000000..eba9323 --- /dev/null +++ b/tests/test_audio_transport_example.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +from array import array + +import pocketstation +import pocketstation.aio as pks_aio +import pytest + +from examples.integrations import IncomingAudio, attach_audio_sender, ingest_audio + + +@pytest.mark.asyncio +async def test_call_audio_template_uses_core_source_and_connector() -> None: + delivered = asyncio.Event() + received = [] + + async def send(frame, context): + received.append(frame) + delivered.set() + return pocketstation.ConnectorDeliveryOutcome.DELIVERED + + async def incoming(): + yield IncomingAudio(array("f", [0.1, 0.2, 0.3, 0.4])) + + session = pks_aio.Session(sample_rate_hz=16_000) + caller = session.audio_input( + "caller", + sample_rate_hz=16_000, + frame_samples_per_channel=4, + ) + registered = attach_audio_sender( + session, + caller.output, + send, + connector_id="io.pocketstation.test.call.v1", + package_version="1.0.0", + ) + running = await session.start() + await ingest_audio(caller, incoming()) + await asyncio.wait_for(delivered.wait(), 1.0) + assert (await running.stop()).success + assert received[0].source_id == caller.source_id + assert received[0].stream_id == caller.stream_id + [observation] = await registered.observations() + assert observation.frames_delivered_total == 1 diff --git a/tests/test_connector.py b/tests/test_connector.py index e49bdcc..e65c0f2 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -234,6 +234,33 @@ def fail(item, context): assert endpoint.message == "provider.timeout: provider request timed out" +def test_audio_connector_convenience_keeps_native_lineage_and_lifecycle() -> None: + received = [] + delivered = Event() + + def publish(frame, context): + received.append(frame) + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + connector = Connector.from_audio_handler( + "io.pocketstation.test.audio-handler.v1", + publish, + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("remote-call", frame_samples_per_channel=4) + audio.output.send(session.register_connector(connector).declare()) + + running = session.start() + audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + assert delivered.wait(1.0) + assert running.stop().success + assert connector.manifest.inputs[0].signal.is_audio + assert received[0].source_id == audio.source_id + assert received[0].stream_id == audio.stream_id + + def test_connector_observations_preserve_service_state_and_delivery_counters() -> None: delivered = Event() diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 06fafa4..cf5f4ef 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -14,6 +14,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: assert set(pocketstation.__all__) == { "AudioBatch", "AudioCaps", + "AudioConnectorHandler", "AudioFrame", "AudioInput", "AudioInputBufferError", diff --git a/tests/test_transcription_example.py b/tests/test_transcription_example.py index 00ff95c..1a59303 100644 --- a/tests/test_transcription_example.py +++ b/tests/test_transcription_example.py @@ -4,11 +4,19 @@ import json import os import sys +from array import array +from dataclasses import dataclass from pathlib import Path +import pocketstation.aio as pks_aio import pytest -from examples.transcription import WhisperCpp, WhisperCppConfiguration +from examples.transcription import ( + FasterWhisper, + FasterWhisperConfiguration, + WhisperCpp, + WhisperCppConfiguration, +) def test_whisper_example_declares_a_bounded_source_aware_operator( @@ -35,6 +43,78 @@ def test_whisper_example_declares_a_bounded_source_aware_operator( assert whisper.manifest.filesystem_allowed +def test_faster_whisper_can_forbid_model_downloads() -> None: + transcription = FasterWhisper( + FasterWhisperConfiguration( + model="/opt/models/whisper-base-ct2", + allow_model_download=False, + ), + model_factory=lambda _configuration: _Model(), + ) + + assert not transcription.manifest.network_allowed + assert transcription.manifest.filesystem_allowed + + +@dataclass(frozen=True) +class _Segment: + start: float = 0.0 + end: float = 0.1 + text: str = " pocket station" + + +@dataclass(frozen=True) +class _Info: + language: str = "en" + language_probability: float = 0.99 + + +class _Model: + def transcribe(self, audio, *, beam_size, language, vad_filter): + assert len(audio) == 4_800 + assert beam_size == 1 + assert language == "en" + assert vad_filter + return iter((_Segment(),)), _Info() + + +@pytest.mark.asyncio +async def test_faster_whisper_is_the_concise_source_aware_python_path() -> None: + transcription = FasterWhisper( + FasterWhisperConfiguration( + model="tiny.en", + language="en", + beam_size=1, + window_seconds=0.1, + ), + model_factory=lambda _configuration: _Model(), + _audio_converter=lambda window: list(window.samples), + ) + session = pks_aio.Session() + audio = session.audio_input( + "remote-call", + frame_samples_per_channel=480, + ) + transcripts = transcription.attach(session, audio.output) + + running = await session.start() + try: + for _ in range(10): + await audio.write(array("f", [0.0] * 480)) + await asyncio.sleep(0.001) + envelope = await asyncio.wait_for( + anext(running.signals(transcripts).__aiter__()), + timeout=5.0, + ) + finally: + stop = await running.stop() + transcript = json.loads(str(envelope.payload)) + assert transcript["source_id"] == audio.source_id + assert transcript["stream_id"] == audio.stream_id + assert transcript["text"] == "pocket station" + assert stop.success + + @pytest.mark.asyncio async def test_real_whisper_process_preserves_source_identity(tmp_path: Path) -> None: executable_value = os.environ.get("POCKETSTATION_WHISPER_CLI") diff --git a/uv.lock b/uv.lock index ef22131..1597cd6 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version < '3.12'", ] [[package]] @@ -83,6 +84,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "av" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, + { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -92,6 +119,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -101,6 +140,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -110,6 +229,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -138,6 +281,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "huggingface-hub" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/222a91937ebee7f62c0ca8f5ee0afd97577caf24c0abb927d1f5c7e9f6d2/huggingface_hub-1.28.0.tar.gz", hash = "sha256:46a2e950c09234de54093d587d1675382f0d08dbd600d9fb599b5932f5b2c6cb", size = 959609, upload-time = "2026-08-18T12:27:15.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0e/eafef18f1a75e125e68395db21131db0cf868a128ecd2fce69b4df6c584b/huggingface_hub-1.28.0-py3-none-any.whl", hash = "sha256:58a8bacb03072edfc38067065e9dc24bbb34805410fcd36a1632de0b329660bb", size = 793202, upload-time = "2026-08-18T12:27:12.719Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -341,6 +504,203 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload-time = "2026-08-17T22:53:33.429Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload-time = "2026-08-17T22:53:36.47Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload-time = "2026-08-17T22:53:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload-time = "2026-08-17T22:53:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload-time = "2026-08-17T22:53:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, + { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a3/718e1b83096a1bc7b0fc8014c23d4cf795559fe666961cfac4fc038a4871/onnxruntime-1.29.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e74b278af1d949876f5d91d1268fd6c680e79f2bac194967394eaba9fdf69e7e", size = 21431104, upload-time = "2026-08-17T22:54:20.118Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/c75e78ddc1fe69b6ebaef7fe88ac83f29bfe10955e3a0d2436d93473c91c/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:939e5d65f332e6d399774b2bd0d3559fd8fa629c1e77833db29d968d2384f23d", size = 20818488, upload-time = "2026-08-17T22:54:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/54/9f197c578d3d3d7bea16971e233e5483981228eec73748585cf7b5933403/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c0c37b92f67ed68dd36221ce0403e1d9bd4f7efce724439978a2597848530e5", size = 23136994, upload-time = "2026-08-17T22:54:26.321Z" }, + { url = "https://files.pythonhosted.org/packages/24/53/4616a55d2495679cfd0195f968feb3d74fe30e26467d168ee243ac97c089/onnxruntime-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a3129ae56e70d2618ff773920166916310370a7e3cacb60b9e0e8910092725f", size = 14350643, upload-time = "2026-08-17T22:54:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/c338cb5500a522c7e671a3bb1276f4562404fbecce8a0e274565aa968484/onnxruntime-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:e417ef8628dcce310d2d53023e750ea298ec14d4341ae6dc3a572bfd9bc7fa97", size = 14124294, upload-time = "2026-08-17T22:54:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e7/61064289a9a1301b25c1f0f574fe98aba31c2d388db3c1dbec664f78621f/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449", size = 20826174, upload-time = "2026-08-17T22:54:34.283Z" }, + { url = "https://files.pythonhosted.org/packages/60/21/d0c04b561b46e9bff89b5f500fb7415b8ca0669f7902204f76ab06bb0c7e/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ea91cef3b971506e51ae9c37c16d027774ec64994a524ec1bdfb027d68a9832", size = 23138547, upload-time = "2026-08-17T22:54:37.491Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -383,16 +743,35 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, ] +transcription = [ + { name = "faster-whisper" }, +] [package.metadata] requires-dist = [ + { name = "faster-whisper", marker = "extra == 'transcription'", specifier = ">=1.2.1,<2.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, ] -provides-extras = ["dev"] +provides-extras = ["transcription", "dev"] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] [[package]] name = "pygments" @@ -432,6 +811,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.16.3" @@ -457,6 +891,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 396b1522ae392c0c1c48f1f6e145e363f3a0b137 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 18:07:34 -0400 Subject: [PATCH 09/49] fix: bound Python control-plane requests --- python/pocketstation/aio/control.py | 8 ++- python/pocketstation/control.py | 73 +++++++++++++++++++++---- tests/test_control.py | 85 ++++++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/python/pocketstation/aio/control.py b/python/pocketstation/aio/control.py index 2a7abd7..3ca448c 100644 --- a/python/pocketstation/aio/control.py +++ b/python/pocketstation/aio/control.py @@ -19,9 +19,11 @@ SessionSnapshot, SubscriberCredentials, _normalize_base_url, + _resolve_timeout, _session_credentials, _session_snapshot, _subscriber_credentials, + _validate_timeout, ) @@ -32,11 +34,11 @@ def __init__( self, control_plane_url: str, *, - timeout_seconds: float | None = 10.0, + timeout_seconds: float = 10.0, http_client: httpx.AsyncClient | None = None, ) -> None: self.control_plane_url = _normalize_base_url(control_plane_url) - self._timeout_seconds = timeout_seconds + self._timeout_seconds = _validate_timeout(timeout_seconds) self._owns_http_client = http_client is None self._http_client = http_client or httpx.AsyncClient(timeout=timeout_seconds) self._closed = False @@ -156,7 +158,7 @@ async def _request( exposed = authorization.expose_secret() headers["Authorization"] = f"Bearer {exposed}" redacted_values = (exposed,) - timeout = self._timeout_seconds if timeout_seconds is None else timeout_seconds + timeout = _resolve_timeout(self._timeout_seconds, timeout_seconds) try: async with self._http_client.stream( method, diff --git a/python/pocketstation/control.py b/python/pocketstation/control.py index 646d10f..965d82f 100644 --- a/python/pocketstation/control.py +++ b/python/pocketstation/control.py @@ -14,6 +14,10 @@ _MAX_ERROR_BODY_BYTES = 4_096 _MAX_JSON_BODY_BYTES = 65_536 +_MAX_SESSION_ID_BYTES = 128 +_MAX_SECRET_BYTES = 4_096 +_MAX_ICE_SERVERS = 32 +_MAX_ICE_URLS = 16 class ControlPlaneError(PocketStationError): @@ -34,9 +38,13 @@ class SessionId(str): """Validated Session identifier safe for one URL path segment.""" def __new__(cls, value: str) -> SessionId: - if not value or not all( - character.isascii() and (character.isalnum() or character in "-_") - for character in value + if ( + not value + or len(value.encode("utf-8")) > _MAX_SESSION_ID_BYTES + or not all( + character.isascii() and (character.isalnum() or character in "-_") + for character in value + ) ): raise ValueError( "Session ID must contain only ASCII letters, digits, '-' or '_'" @@ -52,6 +60,10 @@ class SecretToken: def __init__(self, value: str) -> None: if not value: raise ValueError("credential token must not be empty") + if len(value.encode("utf-8")) > _MAX_SECRET_BYTES: + raise ValueError( + f"credential token must not exceed {_MAX_SECRET_BYTES} bytes" + ) self._value = value def expose_secret(self) -> str: @@ -65,7 +77,7 @@ def __repr__(self) -> str: class IceServer: urls: tuple[str, ...] username: str | None = None - credential: str | None = None + credential: SecretToken | None = None @dataclass(frozen=True, slots=True) @@ -99,11 +111,11 @@ def __init__( self, control_plane_url: str, *, - timeout_seconds: float | None = 10.0, + timeout_seconds: float = 10.0, http_client: httpx.Client | None = None, ) -> None: self.control_plane_url = _normalize_base_url(control_plane_url) - self._timeout_seconds = timeout_seconds + self._timeout_seconds = _validate_timeout(timeout_seconds) self._owns_http_client = http_client is None self._http_client = http_client or httpx.Client(timeout=timeout_seconds) self._closed = False @@ -223,7 +235,7 @@ def _request( exposed = authorization.expose_secret() headers["Authorization"] = f"Bearer {exposed}" redacted_values = (exposed,) - timeout = self._timeout_seconds if timeout_seconds is None else timeout_seconds + timeout = _resolve_timeout(self._timeout_seconds, timeout_seconds) try: with self._http_client.stream( method, @@ -270,9 +282,25 @@ def _normalize_base_url(value: str) -> str: parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError("control_plane_url must be an absolute http or https URL") + if parsed.username is not None or parsed.password is not None: + raise ValueError("control_plane_url must not contain credentials") return value.split("?", 1)[0].split("#", 1)[0].rstrip("/") + "/" +def _validate_timeout(value: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError("timeout_seconds must be a number") + if not 0 < value <= 300: + raise ValueError("timeout_seconds must be greater than 0 and at most 300") + return float(value) + + +def _resolve_timeout(default: float, override: float | None) -> float: + """Resolve a per-request override without permitting unbounded I/O.""" + + return default if override is None else _validate_timeout(override) + + def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: body = bytearray() for chunk in chunks: @@ -290,7 +318,10 @@ def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: def _required(payload: dict[str, Any], key: str, expected_type: type[Any]) -> Any: value = payload.get(key) - if not isinstance(value, expected_type): + valid = isinstance(value, expected_type) + if expected_type is int and isinstance(value, bool): + valid = False + if not valid: raise ControlPlaneError( f"control-plane response field {key!r} has the wrong type", "control.response_decode", @@ -305,6 +336,11 @@ def _ice_servers(payload: dict[str, Any]) -> tuple[IceServer, ...]: "control-plane response field 'ice_servers' has the wrong type", "control.response_decode", ) + if len(raw_servers) > _MAX_ICE_SERVERS: + raise ControlPlaneError( + f"control-plane returned more than {_MAX_ICE_SERVERS} ICE servers", + "control.response_too_large", + ) servers: list[IceServer] = [] for raw_server in raw_servers: if not isinstance(raw_server, dict): @@ -318,6 +354,11 @@ def _ice_servers(payload: dict[str, Any]) -> tuple[IceServer, ...]: "control-plane ICE server URLs must be strings", "control.response_decode", ) + if len(urls) > _MAX_ICE_URLS: + raise ControlPlaneError( + f"ICE server returned more than {_MAX_ICE_URLS} URLs", + "control.response_too_large", + ) username = raw_server.get("username") credential = raw_server.get("credential") if username is not None and not isinstance(username, str): @@ -328,7 +369,13 @@ def _ice_servers(payload: dict[str, Any]) -> tuple[IceServer, ...]: raise ControlPlaneError( "ICE credential must be a string", "control.response_decode" ) - servers.append(IceServer(tuple(urls), username, credential)) + servers.append( + IceServer( + tuple(urls), + username, + None if credential is None else SecretToken(credential), + ) + ) return tuple(servers) @@ -344,10 +391,16 @@ def _session_credentials(payload: dict[str, Any]) -> SessionCredentials: def _session_snapshot(payload: dict[str, Any]) -> SessionSnapshot: + subscription_count = _required(payload, "subscription_count", int) + if subscription_count < 0: + raise ControlPlaneError( + "control-plane subscription_count must not be negative", + "control.response_decode", + ) return SessionSnapshot( session_id=SessionId(_required(payload, "session_id", str)), source_active=_required(payload, "source_active", bool), - subscription_count=_required(payload, "subscription_count", int), + subscription_count=subscription_count, codec=_required(payload, "codec", str), ) diff --git a/tests/test_control.py b/tests/test_control.py index f1270e6..e4c5e37 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -4,7 +4,6 @@ import httpx import pytest - from pocketstation import ( ControlClient, ControlPlaneError, @@ -72,6 +71,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert credentials.source_token.expose_secret() == "source-secret" assert "source-secret" not in repr(credentials.source_token) assert credentials.ice_servers[0].urls == ("turn:turn.example:3478",) + assert credentials.ice_servers[0].credential is not None + assert credentials.ice_servers[0].credential.expose_secret() == "turn-secret" + assert "turn-secret" not in repr(credentials) assert snapshot.source_active is True assert snapshot.subscription_count == 2 assert subscriber.subscriber_token.expose_secret() == "next-subscriber-secret" @@ -168,3 +170,84 @@ def test_control_client_redacts_authorization_from_http_error() -> None: def test_session_id_rejects_unsafe_path_values(value: str) -> None: with pytest.raises(ValueError): SessionId(value) + + +def test_control_decoder_rejects_boolean_or_negative_subscription_counts() -> None: + for invalid in (True, -1): + transport = httpx.MockTransport( + lambda _request, value=invalid: httpx.Response( + 200, + json={ + "session_id": "session_123", + "source_active": True, + "subscription_count": value, + "codec": "opus", + }, + ) + ) + with httpx.Client(transport=transport) as http_client: + client = ControlClient("https://control.example", http_client=http_client) + with pytest.raises(ControlPlaneError) as raised: + client.session("session_123") + assert raised.value.code == "control.response_decode" + + +@pytest.mark.parametrize( + "url", + ["https://user:password@control.example", "ftp://control.example"], +) +def test_control_origin_rejects_embedded_credentials_and_non_http(url: str) -> None: + with pytest.raises(ValueError): + ControlClient(url) + + +@pytest.mark.parametrize("timeout", [None, True, 0, -1, 301]) +def test_control_client_rejects_invalid_timeouts(timeout) -> None: + with pytest.raises((TypeError, ValueError)): + ControlClient("https://control.example", timeout_seconds=timeout) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [None, True, 0, -1, 301]) +async def test_async_control_client_rejects_invalid_timeouts(timeout) -> None: + with pytest.raises((TypeError, ValueError)): + AsyncControlClient("https://control.example", timeout_seconds=timeout) + + +def test_per_request_none_inherits_the_finite_client_timeout() -> None: + observed: list[float | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + observed.append(request.extensions["timeout"]["read"]) + return httpx.Response(201, json=CREATE_RESPONSE) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = ControlClient( + "https://control.example", + timeout_seconds=7.0, + http_client=http_client, + ) + client.create_session(timeout_seconds=None) + + assert observed == [7.0] + + +@pytest.mark.asyncio +async def test_async_per_request_none_inherits_the_finite_client_timeout() -> None: + observed: list[float | None] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + observed.append(request.extensions["timeout"]["read"]) + return httpx.Response(201, json=CREATE_RESPONSE) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler) + ) as http_client: + client = AsyncControlClient( + "https://control.example", + timeout_seconds=7.0, + http_client=http_client, + ) + await client.create_session(timeout_seconds=None) + + assert observed == [7.0] From 5e637aee4f81f76646e1a35adeca32ee8be109b4 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 18:14:27 -0400 Subject: [PATCH 10/49] fix: bound Python relay requests --- python/pocketstation/aio/relay.py | 10 +++++----- python/pocketstation/relay.py | 28 +++++++++++++++++----------- tests/test_aio_relay.py | 11 ++++++++++- tests/test_relay.py | 10 +++++++++- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py index 71aaf0a..d3cec9e 100644 --- a/python/pocketstation/aio/relay.py +++ b/python/pocketstation/aio/relay.py @@ -25,7 +25,7 @@ _bounded_request_timeout, _normalize_relay_url, _receiver_invitation, - _validate_optional_timeout, + _validate_request_timeout, _validate_wait, ) from .control import ControlClient @@ -46,7 +46,7 @@ def __init__( relay_http: httpx.AsyncClient, owns_control: bool, owns_relay_http: bool, - request_timeout_seconds: float | None, + request_timeout_seconds: float, ) -> None: self.relay_url = _normalize_relay_url(relay_url) self.credentials = credentials @@ -66,11 +66,11 @@ async def create( *, control_plane_url: str, relay_url: str, - request_timeout_seconds: float | None = 10.0, + request_timeout_seconds: float = 10.0, control_client: ControlClient | None = None, relay_http_client: httpx.AsyncClient | None = None, ) -> RelaySession: - _validate_optional_timeout(request_timeout_seconds, "request_timeout_seconds") + request_timeout_seconds = _validate_request_timeout(request_timeout_seconds) normalized_relay_url = _normalize_relay_url(relay_url) owns_control = control_client is None owns_relay_http = relay_http_client is None @@ -284,7 +284,7 @@ async def _relay_json_request( path: str, expected_status: int, authorization: SecretToken, - timeout_seconds: float | None, + timeout_seconds: float, ) -> dict[str, Any]: exposed = authorization.expose_secret() try: diff --git a/python/pocketstation/relay.py b/python/pocketstation/relay.py index 9e3c1ce..84c53a9 100644 --- a/python/pocketstation/relay.py +++ b/python/pocketstation/relay.py @@ -21,6 +21,7 @@ SessionSnapshot, ) from .errors import PocketStationError, _native_call +from .identity import RouteId if TYPE_CHECKING: from .session import Session @@ -41,7 +42,7 @@ class RelayRoute: """One native Session route publishing a named AudioBus.""" bus_id: str - route_id: int + route_id: RouteId @dataclass(frozen=True, slots=True) @@ -105,7 +106,7 @@ def __init__( relay_http: httpx.Client, owns_control: bool, owns_relay_http: bool, - request_timeout_seconds: float | None, + request_timeout_seconds: float, ) -> None: self.relay_url = _normalize_relay_url(relay_url) self.credentials = credentials @@ -125,11 +126,11 @@ def create( *, control_plane_url: str, relay_url: str, - request_timeout_seconds: float | None = 10.0, + request_timeout_seconds: float = 10.0, control_client: ControlClient | None = None, relay_http_client: httpx.Client | None = None, ) -> RelaySession: - _validate_optional_timeout(request_timeout_seconds, "request_timeout_seconds") + request_timeout_seconds = _validate_request_timeout(request_timeout_seconds) normalized_relay_url = _normalize_relay_url(relay_url) owns_control = control_client is None owns_relay_http = relay_http_client is None @@ -341,14 +342,21 @@ def _normalize_relay_url(value: str) -> str: parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError("relay_url must be an absolute http or https origin") + if parsed.username is not None or parsed.password is not None: + raise ValueError("relay_url must not contain credentials") if parsed.path not in {"", "/"}: raise ValueError("relay_url must not include a path") return value.split("?", 1)[0].split("#", 1)[0].rstrip("/") -def _validate_optional_timeout(value: float | None, name: str) -> None: - if value is not None and (isinstance(value, bool) or value <= 0): - raise ValueError(f"{name} must be positive or None") +def _validate_request_timeout(value: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError("request_timeout_seconds must be a number") + if not 0 < value <= 300: + raise ValueError( + "request_timeout_seconds must be greater than 0 and at most 300" + ) + return float(value) def _validate_wait(timeout_seconds: float, poll_interval_seconds: float) -> None: @@ -362,10 +370,8 @@ def _validate_wait(timeout_seconds: float, poll_interval_seconds: float) -> None def _bounded_request_timeout( remaining_seconds: float, - configured_seconds: float | None, + configured_seconds: float, ) -> float: - if configured_seconds is None: - return remaining_seconds return min(remaining_seconds, configured_seconds) @@ -377,7 +383,7 @@ def _relay_json_request( path: str, expected_status: int, authorization: SecretToken, - timeout_seconds: float | None, + timeout_seconds: float, ) -> dict[str, Any]: exposed = authorization.expose_secret() try: diff --git a/tests/test_aio_relay.py b/tests/test_aio_relay.py index 3a1a4da..6aab4cc 100644 --- a/tests/test_aio_relay.py +++ b/tests/test_aio_relay.py @@ -4,7 +4,6 @@ import httpx import pytest - from pocketstation import Source from pocketstation.aio import ControlClient, RelaySession, Session @@ -18,6 +17,16 @@ } +@pytest.mark.asyncio +async def test_async_relay_session_rejects_unbounded_request_timeout() -> None: + with pytest.raises((TypeError, ValueError)): + await RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + request_timeout_seconds=None, + ) + + @pytest.mark.asyncio async def test_async_relay_composes_native_routes_and_real_readiness() -> None: control_requests: list[httpx.Request] = [] diff --git a/tests/test_relay.py b/tests/test_relay.py index f2ab7a2..5384fc8 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -6,7 +6,6 @@ import httpx import pytest - from pocketstation import ( ControlClient, RelayError, @@ -26,6 +25,15 @@ } +def test_relay_session_rejects_unbounded_request_timeout() -> None: + with pytest.raises((TypeError, ValueError)): + RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + request_timeout_seconds=None, + ) + + def test_relay_composes_two_native_buses_with_authoritative_readiness() -> None: control_requests: list[httpx.Request] = [] relay_requests: list[httpx.Request] = [] From c92e5257e33598406aa10036dc26bb2d02bd2e2c Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 18:17:04 -0400 Subject: [PATCH 11/49] fix: package Python runtime identities --- python/pocketstation/identity.py | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 python/pocketstation/identity.py diff --git a/python/pocketstation/identity.py b/python/pocketstation/identity.py new file mode 100644 index 0000000..5e775e4 --- /dev/null +++ b/python/pocketstation/identity.py @@ -0,0 +1,40 @@ +"""Zero-overhead nominal identities used by the native Session runtime.""" + +from typing import Literal, NewType, TypeAlias + +# ``SessionId`` already names the opaque Relay control-plane identifier in the +# public API. RuntimeSessionId deliberately distinguishes the numeric Core +# execution identity instead of letting unrelated identifiers type-check. +RuntimeSessionId = NewType("RuntimeSessionId", int) +StreamId = NewType("StreamId", int) +SourceId = NewType("SourceId", int) +SourceInstanceId = NewType("SourceInstanceId", int) +StemId = NewType("StemId", int) +ClockDomainId = NewType("ClockDomainId", int) +ClockDomainKind: TypeAlias = Literal[ + "unspecified", "process-monotonic", "provider-defined" +] +ClockDomainOrigin: TypeAlias = Literal[ + "unspecified", "process-start", "provider-defined" +] +EndpointId = NewType("EndpointId", int) +ConnectorId = NewType("ConnectorId", int) +RouteId = NewType("RouteId", int) +OperatorInstanceId = NewType("OperatorInstanceId", int) +SidecarId = NewType("SidecarId", int) + +__all__ = [ + "ClockDomainId", + "ClockDomainKind", + "ClockDomainOrigin", + "ConnectorId", + "EndpointId", + "OperatorInstanceId", + "RouteId", + "RuntimeSessionId", + "SidecarId", + "SourceId", + "SourceInstanceId", + "StemId", + "StreamId", +] From 18936d89f7cb0914818a10778eaf3963a77890f6 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 18:56:50 -0400 Subject: [PATCH 12/49] feat: complete bounded Python provider authoring --- README.md | 12 +- examples/transcription/audio_windows.py | 2 +- examples/transcription/faster_whisper.py | 4 +- examples/transcription/whisper_cpp.py | 2 +- native/Cargo.lock | 2 - native/Cargo.toml | 3 + native/src/connector/driver.rs | 42 ++- native/src/connector/worker.rs | 22 +- native/src/errors.rs | 31 ++- native/src/graph.rs | 6 + native/src/observations.rs | 178 ++++++++++++- native/src/session.rs | 87 +++++- native/src/signals.rs | 8 + native/src/sources.rs | 251 +++++++++++++++++- native/src/streams.rs | 128 +++++++-- pyproject.toml | 2 + python/pocketstation/__init__.py | 86 +++++- python/pocketstation/_native.pyi | 187 +++++++++---- python/pocketstation/aio/__init__.py | 69 ++++- python/pocketstation/aio/audio_input.py | 8 +- python/pocketstation/aio/connector.py | 39 +++ .../pocketstation/aio/operator_authoring.py | 13 +- python/pocketstation/aio/session.py | 67 ++++- python/pocketstation/aio/source_authoring.py | 5 +- python/pocketstation/aio/streams.py | 48 +++- python/pocketstation/audio_input.py | 58 +++- python/pocketstation/compatibility.py | 30 +++ python/pocketstation/connector.py | 35 ++- python/pocketstation/errors.py | 167 ++++++++++++ python/pocketstation/graph.py | 198 +++++++++----- python/pocketstation/observations.py | 181 ++++++++++++- python/pocketstation/operator_authoring.py | 20 +- python/pocketstation/session.py | 64 ++++- python/pocketstation/signal.py | 71 +++-- python/pocketstation/source_authoring.py | 25 +- python/pocketstation/sources.py | 194 +++++++++++++- python/pocketstation/streams.py | 69 ++++- tests/installed_consumer.py | 238 ++++++++++++++++- tests/qualification/typing_contract.py | 38 +++ tests/run_installed_stream_conformance.py | 38 ++- tests/test_aio_observations.py | 3 +- tests/test_aio_session.py | 39 ++- tests/test_aio_streams.py | 27 +- tests/test_compatibility.py | 28 ++ tests/test_connector.py | 76 +++++- tests/test_control.py | 4 +- tests/test_errors.py | 56 ++++ tests/test_graph.py | 23 ++ tests/test_lifecycle.py | 11 + tests/test_observations.py | 3 +- tests/test_operator_authoring.py | 48 ++++ tests/test_permissions.py | 30 ++- tests/test_public_api.py | 62 ++++- tests/test_realtime_boundary.py | 3 + tests/test_recording.py | 12 +- tests/test_session.py | 43 ++- tests/test_sidecar.py | 7 +- tests/test_signal_streams.py | 4 + tests/test_source_authoring.py | 28 ++ tests/test_sources.py | 87 +++++- tests/test_streams.py | 28 +- 61 files changed, 2995 insertions(+), 355 deletions(-) create mode 100644 python/pocketstation/compatibility.py create mode 100644 tests/qualification/typing_contract.py create mode 100644 tests/test_compatibility.py create mode 100644 tests/test_errors.py diff --git a/README.md b/README.md index 860e12c..dfbfabf 100644 --- a/README.md +++ b/README.md @@ -217,13 +217,21 @@ publisher = pocketstation.Connector.from_audio_handler( package_version="1.0.0", ) session = pocketstation.Session() -endpoint = session.register_connector(publisher).declare() +audio = session.audio_input("agent-output") +audio.output.send(session.destination(publisher)) ``` `pocketstation.aio.Connector.from_audio_handler(...)` accepts a coroutine and enforces finite delivery deadlines. The complete manifest, typed configuration, driver, grouped worker, and observation APIs remain available -for reusable provider packages. +for reusable provider packages. When one implementation needs several +independently configured Endpoints, retain the explicit form: + +```python +registered = session.register_connector(publisher) +primary = registered.declare(primary_configuration) +backup = registered.declare(backup_configuration) +``` Stateful providers implement `ConnectorDriver` and register with `Connector.with_driver(...)`. Their factory receives every resolved input diff --git a/examples/transcription/audio_windows.py b/examples/transcription/audio_windows.py index 4130e5e..21e539a 100644 --- a/examples/transcription/audio_windows.py +++ b/examples/transcription/audio_windows.py @@ -37,7 +37,7 @@ def __init__(self, *, window_seconds: float, maximum_sources: int) -> None: def push( self, - envelope: pocketstation.SignalEnvelope, + envelope: pocketstation.SignalEnvelope[object], ) -> tuple[AudioWindow, ...]: payload = envelope.payload if not isinstance(payload, pocketstation.SignalAudioPayload): diff --git a/examples/transcription/faster_whisper.py b/examples/transcription/faster_whisper.py index 991d764..915aada 100644 --- a/examples/transcription/faster_whisper.py +++ b/examples/transcription/faster_whisper.py @@ -111,7 +111,7 @@ def __init__( async def process( self, input_port: str, - envelope: pocketstation.SignalEnvelope, + envelope: pocketstation.SignalEnvelope[object], ) -> tuple[pocketstation.OperatorEmission, ...]: if input_port != "audio": raise ValueError(f"unexpected input port: {input_port}") @@ -237,7 +237,7 @@ def attach( stream: pocketstation.Stem | pocketstation.SourceOutput | pocketstation.DerivedStream, - ) -> pocketstation.BusSubscription: + ) -> pocketstation.BusSubscription[str]: """Attach transcription to any Session-owned PCM stream in two lines.""" operator = session.register_operator(self.provider()).declare() stream.connect(operator.input("audio")) diff --git a/examples/transcription/whisper_cpp.py b/examples/transcription/whisper_cpp.py index 17c1121..3d6d0a2 100644 --- a/examples/transcription/whisper_cpp.py +++ b/examples/transcription/whisper_cpp.py @@ -78,7 +78,7 @@ def __init__(self, configuration: WhisperCppConfiguration) -> None: async def process( self, input_port: str, - envelope: pocketstation.SignalEnvelope, + envelope: pocketstation.SignalEnvelope[object], ) -> tuple[pocketstation.OperatorEmission, ...]: if input_port != "audio": raise ValueError(f"unexpected input port: {input_port}") diff --git a/native/Cargo.lock b/native/Cargo.lock index 2744d11..6046b22 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1414,8 +1414,6 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52b62ad6d1236ca1d3a55c9ca95eb3de17364ad2da648dcec74a49c8924ccc1" dependencies = [ "alsa", "cc", diff --git a/native/Cargo.toml b/native/Cargo.toml index dd907b7..0a4ba4f 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -23,3 +23,6 @@ pyo3 = { version = "0.27", features = ["abi3-py311"] } [dev-dependencies] pocketstation = { version = "=1.1.1", features = ["conformance-fixtures"] } tempfile = "3" + +[patch.crates-io] +pocketstation = { path = "../../pocketstation" } diff --git a/native/src/connector/driver.rs b/native/src/connector/driver.rs index 3d1fb2c..721f1d4 100644 --- a/native/src/connector/driver.rs +++ b/native/src/connector/driver.rs @@ -374,7 +374,12 @@ impl PythonRegisteredConnector { ) -> PyResult>> { self.registered .observations() - .map_err(|error| PyRuntimeError::new_err(error.to_string()))? + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "connector.observations_unavailable", + error.to_string(), + )) + })? .into_iter() .map(|value| python_runtime_observations(py, value)) .collect() @@ -387,11 +392,21 @@ impl PythonRegisteredConnector { ) -> PyResult>> { self.registered .observation(endpoint.handle) - .map_err(|error| PyValueError::new_err(error.to_string()))? + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.observation_lookup_failed", + error.to_string(), + )) + })? .map(|handle| { handle .snapshot() - .map_err(|error| PyRuntimeError::new_err(error.to_string())) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "connector.observation_unavailable", + error.to_string(), + )) + }) .and_then(|value| python_connector_observations(py, value)) }) .transpose() @@ -407,11 +422,21 @@ pub(crate) fn register_connector( manifest.value.clone(), Arc::new(PythonDriverFactory { factory }), ) - .map_err(|error| PyValueError::new_err(error.to_string()))?; + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.invalid_contract", + error.to_string(), + )) + })?; session .register_connector(connector) .map(|registered| PythonRegisteredConnector { registered }) - .map_err(|error| PyValueError::new_err(error.to_string())) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.registration_failed", + error.to_string(), + )) + }) } pub(crate) fn declare_connector( @@ -424,7 +449,12 @@ pub(crate) fn declare_connector( .registered .declare(session, configuration.value.clone(), edge.value) .map(|handle| PythonEndpoint { handle }) - .map_err(|error| PyValueError::new_err(error.to_string())) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.declaration_failed", + error.to_string(), + )) + }) } fn python_input_descriptor( diff --git a/native/src/connector/worker.rs b/native/src/connector/worker.rs index 230b31c..095a925 100644 --- a/native/src/connector/worker.rs +++ b/native/src/connector/worker.rs @@ -23,6 +23,7 @@ use super::driver::{ use super::values::{ configuration_values, PythonConnectorConfigurationValue, PythonConnectorManifest, }; +use crate::errors::coded_reason; use crate::graph::{PythonEdgeContract, PythonMediaCaps, PythonSignalSpec}; use crate::signals::{copy_envelope, python_envelope}; use crate::streams::{owned_endpoint_audio_frame_for_route, python_audio_frame, PythonAudioFrame}; @@ -285,7 +286,7 @@ impl PythonConnectorWorker { let frame = owned_endpoint_audio_frame_for_route( frame, input.endpoint_id, - input.connector_id, + Some(input.connector_id), input.route_id, ); let audio: Py = Py::new(py, python_audio_frame(py, frame))?; @@ -548,8 +549,9 @@ pub(crate) fn register_worker_connector( maximum_batch_items: usize, ) -> PyResult { if !(1..=MAXIMUM_BATCH_ITEMS).contains(&maximum_batch_items) { - return Err(PyValueError::new_err(format!( - "maximum_batch_items must be between 1 and {MAXIMUM_BATCH_ITEMS}" + return Err(PyValueError::new_err(coded_reason( + "connector.invalid_contract", + format!("maximum_batch_items must be between 1 and {MAXIMUM_BATCH_ITEMS}"), ))); } let connector = Connector::new( @@ -560,9 +562,19 @@ pub(crate) fn register_worker_connector( maximum_batch_items, }), ) - .map_err(|error| PyValueError::new_err(error.to_string()))?; + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.invalid_contract", + error.to_string(), + )) + })?; session .register_connector(connector) .map(|registered| PythonRegisteredConnector { registered }) - .map_err(|error| PyRuntimeError::new_err(error.to_string())) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "connector.registration_failed", + error.to_string(), + )) + }) } diff --git a/native/src/errors.rs b/native/src/errors.rs index 8431755..8c1c49e 100644 --- a/native/src/errors.rs +++ b/native/src/errors.rs @@ -20,7 +20,36 @@ pub(crate) fn session_endpoint_error(error: pocketstation::SessionEndpointError) #[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. pub(crate) fn session_start_error(error: SessionStartError) -> PyErr { - PyRuntimeError::new_err(coded_reason(error.code().as_str(), error.to_string())) + let exception = PyRuntimeError::new_err(coded_reason(error.code().as_str(), error.to_string())); + if let Some(diagnostic) = error.compile_diagnostic() { + Python::attach(|py| { + let value = exception.value(py); + let _ = value.setattr("_pocketstation_compile_code", diagnostic.code()); + let _ = value.setattr("_pocketstation_compile_node_index", diagnostic.node_index()); + let _ = value.setattr("_pocketstation_compile_edge_index", diagnostic.edge_index()); + let _ = value.setattr( + "_pocketstation_compile_operator_id", + diagnostic.operator_id(), + ); + let _ = value.setattr( + "_pocketstation_compile_operator_instance_id", + diagnostic.operator_instance_id(), + ); + let _ = value.setattr( + "_pocketstation_compile_node_type_id", + diagnostic.node_type_id(), + ); + let _ = value.setattr( + "_pocketstation_compile_source_type_id", + diagnostic.source_type_id(), + ); + let _ = value.setattr("_pocketstation_compile_port_name", diagnostic.port_name()); + let _ = value.setattr("_pocketstation_compile_direction", diagnostic.direction()); + let _ = value.setattr("_pocketstation_compile_expected", diagnostic.expected()); + let _ = value.setattr("_pocketstation_compile_actual", diagnostic.actual()); + }); + } + exception } #[allow(clippy::needless_pass_by_value)] // Direct adapter for Result::map_err. diff --git a/native/src/graph.rs b/native/src/graph.rs index b1076e1..110933e 100644 --- a/native/src/graph.rs +++ b/native/src/graph.rs @@ -406,6 +406,12 @@ impl PythonMediaCaps { self.value.is_compatible_with(&other.value) } + fn negotiate(&self, other: &Self) -> Option { + self.value + .negotiate(&other.value) + .map(|value| Self { value }) + } + fn supports_signal(&self, signal: &PythonSignalSpec) -> bool { self.value.supports_signal(&signal.value) } diff --git a/native/src/observations.rs b/native/src/observations.rs index b853bb3..0abbbfc 100644 --- a/native/src/observations.rs +++ b/native/src/observations.rs @@ -66,6 +66,10 @@ impl PythonRecordingStemOutcome { #[pyclass(name = "RecordingOutcome", frozen)] pub(crate) struct PythonRecordingOutcome { + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + group_id: String, #[pyo3(get)] complete: bool, #[pyo3(get)] @@ -77,6 +81,10 @@ pub(crate) struct PythonRecordingOutcome { #[pyo3(get)] session_directory: String, #[pyo3(get)] + manifest_path: String, + #[pyo3(get)] + manifest_schema_version: u32, + #[pyo3(get)] error_code: Option, stems: Vec>, } @@ -141,6 +149,8 @@ pub(crate) struct PythonSessionFailure { #[pyo3(get)] component: Option, #[pyo3(get)] + component_kind: Option, + #[pyo3(get)] message: Option, #[pyo3(get)] stem_id: Option, @@ -771,6 +781,42 @@ pub(crate) struct PythonSessionTraceValidation { records_validated_total: u64, } +#[pyclass(name = "SessionTraceRecord", frozen)] +pub(crate) struct PythonSessionTraceRecord { + #[pyo3(get)] + sequence_index: u64, + #[pyo3(get)] + observed_at_ns: u64, + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + kind: String, + #[pyo3(get)] + lifecycle_state: Option, + #[pyo3(get)] + terminal_state: Option, + #[pyo3(get)] + stem_id: Option, + #[pyo3(get)] + route_id: Option, + #[pyo3(get)] + endpoint_id: Option, + #[pyo3(get)] + endpoint_stage: Option, + #[pyo3(get)] + rollback_stage: Option, + #[pyo3(get)] + finalization_stage: Option, + #[pyo3(get)] + source_failures_total: Option, + #[pyo3(get)] + endpoint_failures_total: Option, + #[pyo3(get)] + rollback_failures_total: Option, + #[pyo3(get)] + finalization_failures_total: Option, +} + #[pyclass(name = "SessionTrace", frozen)] pub(crate) struct PythonSessionTrace { trace: pocketstation::SessionTrace, @@ -800,6 +846,15 @@ impl PythonSessionTrace { self.trace.records().len() } + fn records(&self) -> Vec { + self.trace + .records() + .iter() + .copied() + .map(PythonSessionTraceRecord::from) + .collect() + } + fn validate(&self) -> PyResult { self.trace .validate() @@ -833,16 +888,21 @@ struct OwnedRecordingDiscontinuity { } pub(crate) struct OwnedRecordingOutcome { + session_id: u64, + group_id: String, pub(crate) complete: bool, state: String, completed_stems: usize, failed_stems: usize, session_directory: String, + manifest_path: String, + manifest_schema_version: u32, error_code: Option, pub(crate) stems: Vec, } pub(crate) struct OwnedStopResult { + pub(crate) lifecycle_state: &'static str, pub(crate) success: bool, pub(crate) already_stopped: bool, pub(crate) disposition: String, @@ -894,6 +954,7 @@ struct OwnedSessionFailure { error_code: Option, retryability: Option, component: Option, + component_kind: Option, message: Option, stem_id: Option, route_id: Option, @@ -1445,14 +1506,42 @@ fn owned_control_failure( stage: Option, failure: &pocketstation::SessionControlFailure, ) -> OwnedSessionFailure { - OwnedSessionFailure { + let mut owned = OwnedSessionFailure { kind: kind.to_owned(), stage, operation: Some(failure.operation().to_owned()), error_class: Some(failure.error_class().to_owned()), component: Some(format!("{:?}", failure.component())), ..OwnedSessionFailure::default() + }; + match failure.component() { + pocketstation::SessionComponentId::Source { stem_id } => { + owned.component_kind = Some("source".to_owned()); + owned.stem_id = Some(stem_id.get()); + } + pocketstation::SessionComponentId::Endpoint { + route_id, + endpoint_id, + } => { + owned.component_kind = Some("endpoint".to_owned()); + owned.route_id = Some(route_id.get()); + owned.endpoint_id = Some(endpoint_id.get()); + } + pocketstation::SessionComponentId::Operator { + operator_instance_id, + } => { + owned.component_kind = Some("operator".to_owned()); + owned.operator_instance_id = Some(operator_instance_id.value()); + } + pocketstation::SessionComponentId::Sidecar { sidecar_id } => { + owned.component_kind = Some("sidecar".to_owned()); + owned.sidecar_id = Some(sidecar_id); + } + pocketstation::SessionComponentId::Runtime => { + owned.component_kind = Some("runtime".to_owned()); + } } + owned } fn populate_source_runtime_event( @@ -1524,6 +1613,7 @@ pub(crate) fn python_session_event( error_code: failure.error_code, retryability: failure.retryability, component: failure.component, + component_kind: failure.component_kind, message: failure.message, stem_id: failure.stem_id, route_id: failure.route_id, @@ -1830,6 +1920,83 @@ impl From for PythonSessionTraceValidatio } } +impl From for PythonSessionTraceRecord { + fn from(record: pocketstation::SessionTraceRecord) -> Self { + let mut output = Self { + sequence_index: record.sequence_index, + observed_at_ns: record.observed_at_ns, + session_id: record.session_id.get(), + kind: String::new(), + lifecycle_state: None, + terminal_state: None, + stem_id: None, + route_id: None, + endpoint_id: None, + endpoint_stage: None, + rollback_stage: None, + finalization_stage: None, + source_failures_total: None, + endpoint_failures_total: None, + rollback_failures_total: None, + finalization_failures_total: None, + }; + match record.kind { + pocketstation::SessionTraceRecordKind::Lifecycle { state } => { + output.kind = "lifecycle".to_owned(); + output.lifecycle_state = Some(lifecycle_state_name(state).to_owned()); + } + pocketstation::SessionTraceRecordKind::SourceFailure { stem_id } => { + output.kind = "source-failure".to_owned(); + output.stem_id = Some(stem_id.get()); + } + pocketstation::SessionTraceRecordKind::EndpointFailure { + route_id, + endpoint_id, + stage_code, + } => { + output.kind = "endpoint-failure".to_owned(); + output.route_id = Some(route_id.get()); + output.endpoint_id = Some(endpoint_id.get()); + output.endpoint_stage = endpoint_trace_stage_name(stage_code).map(str::to_owned); + } + pocketstation::SessionTraceRecordKind::RollbackFailure { stage } => { + output.kind = "rollback-failure".to_owned(); + output.rollback_stage = Some(rollback_stage_name(stage)); + } + pocketstation::SessionTraceRecordKind::FinalizationFailure { stage } => { + output.kind = "finalization-failure".to_owned(); + output.finalization_stage = Some(finalization_stage_name(stage)); + } + pocketstation::SessionTraceRecordKind::Terminal { + state, + source_failures_total, + endpoint_failures_total, + rollback_failures_total, + finalization_failures_total, + } => { + output.kind = "terminal".to_owned(); + output.terminal_state = Some(terminal_state_name(state).to_owned()); + output.source_failures_total = Some(source_failures_total); + output.endpoint_failures_total = Some(endpoint_failures_total); + output.rollback_failures_total = Some(rollback_failures_total); + output.finalization_failures_total = Some(finalization_failures_total); + } + } + output + } +} + +const fn endpoint_trace_stage_name(stage_code: u8) -> Option<&'static str> { + match stage_code { + 1 => Some("prepare"), + 2 => Some("cancel-preparation"), + 3 => Some("start"), + 4 => Some("request-stop"), + 5 => Some("join-finalize"), + _ => None, + } +} + fn session_trace_validation_error(error: pocketstation::SessionTraceValidationError) -> PyErr { let code = match &error { pocketstation::SessionTraceValidationError::Io(_) => "trace.io", @@ -2043,11 +2210,15 @@ pub(crate) fn owned_recording_outcome( }) .collect(); Some(OwnedRecordingOutcome { + session_id: outcome.session_id.get(), + group_id: outcome.group_id.as_str().to_owned(), complete: outcome.state == pocketstation::SessionRecordingState::Complete, state: format!("{:?}", outcome.state).to_lowercase(), completed_stems: outcome.completed_stems, failed_stems: outcome.failed_stems, session_directory: outcome.session_dir.display().to_string(), + manifest_path: outcome.manifest_path.display().to_string(), + manifest_schema_version: outcome.manifest_schema_version, error_code: pocketstation::session_recording_outcome_error_code(outcome) .map(|code| code.as_str().to_owned()), stems, @@ -2101,11 +2272,15 @@ pub(crate) fn python_recording_outcome( Py::new( py, PythonRecordingOutcome { + session_id: outcome.session_id, + group_id: outcome.group_id, complete: outcome.complete, state: outcome.state, completed_stems: outcome.completed_stems, failed_stems: outcome.failed_stems, session_directory: outcome.session_directory, + manifest_path: outcome.manifest_path, + manifest_schema_version: outcome.manifest_schema_version, error_code: outcome.error_code, stems, }, @@ -2132,6 +2307,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; module.add_class::()?; Ok(()) } diff --git a/native/src/session.rs b/native/src/session.rs index a4d6567..90ea988 100644 --- a/native/src/session.rs +++ b/native/src/session.rs @@ -60,6 +60,9 @@ pub(crate) enum SessionCommand { timeout: Duration, response: SyncSender>, String>>, }, + LifecycleState { + response: SyncSender<&'static str>, + }, PollEvent { response: SyncSender, String>>, }, @@ -648,6 +651,7 @@ pub(crate) struct PythonRunningSession { worker: Mutex>, signal_receipts: SignalReceipts, session_id: u64, + terminal_state: Mutex>, } #[pymethods] @@ -657,6 +661,46 @@ impl PythonRunningSession { self.session_id } + #[getter] + fn lifecycle_state(&self, py: Python<'_>) -> PyResult<&'static str> { + let guard = self + .worker + .lock() + .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))?; + let (response, receiver) = sync_channel(1); + let has_worker = guard.is_some(); + let requested = match guard.as_ref() { + Some(worker) => worker + .commands + .try_send(SessionCommand::LifecycleState { response }) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "session.lifecycle_unavailable", + format!("native Session lifecycle query is unavailable: {error}"), + )) + }) + .map(|()| true)?, + None => false, + }; + drop(guard); + if requested { + return py + .detach(move || receiver.recv_timeout(Duration::from_millis(100))) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "session.lifecycle_unavailable", + format!("native Session did not return lifecycle state: {error}"), + )) + }); + } + debug_assert!(!has_worker); + Ok(self + .terminal_state + .lock() + .map_err(|_| PyRuntimeError::new_err("terminal Session state is unavailable"))? + .unwrap_or("stopping")) + } + fn poll_audio(&self, py: Python<'_>) -> PyResult> { let commands = self.commands()?; let owned = py.detach(|| request_audio_batch(&commands))?; @@ -828,7 +872,14 @@ impl PythonRunningSession { .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))? .take() .ok_or_else(|| PyRuntimeError::new_err("Session has stopped"))?; - let owned = py.detach(|| stop_worker(worker))?; + let owned = match py.detach(|| stop_worker(worker)) { + Ok(owned) => owned, + Err(error) => { + self.cache_terminal_state("failed")?; + return Err(error); + } + }; + self.cache_terminal_state(owned.lifecycle_state)?; let recording = owned .recording .map(|recording| python_recording_outcome(py, recording)) @@ -886,7 +937,14 @@ impl PythonRunningSession { .map_err(|_| PyRuntimeError::new_err("running Session state is unavailable"))? .take() .ok_or_else(|| PyRuntimeError::new_err("Session has stopped"))?; - let owned = py.detach(|| cancel_worker(worker))?; + let owned = match py.detach(|| cancel_worker(worker)) { + Ok(owned) => owned, + Err(error) => { + self.cache_terminal_state("failed")?; + return Err(error); + } + }; + self.cache_terminal_state(owned.lifecycle_state)?; let recording = owned .recording .map(|recording| python_recording_outcome(py, recording)) @@ -939,6 +997,15 @@ impl PythonRunningSession { } impl PythonRunningSession { + fn cache_terminal_state(&self, state: &'static str) -> PyResult<()> { + *self + .terminal_state + .lock() + .map_err(|_| PyRuntimeError::new_err("terminal Session state is unavailable"))? = + Some(state); + Ok(()) + } + fn request_sidecar_read( &self, py: Python<'_>, @@ -1000,6 +1067,7 @@ impl PythonRunningSession { })), signal_receipts, session_id, + terminal_state: Mutex::new(None), }) } } @@ -1033,6 +1101,9 @@ fn session_worker( SessionCommand::WaitAudio { timeout, response } => { let _ = response.send(copy_audio_batch_until(&running, timeout)); } + SessionCommand::LifecycleState { response } => { + let _ = response.send(core_lifecycle_state_name(running.state())); + } SessionCommand::PollEvent { response } => { let _ = response.send(copy_event(&running)); } @@ -1083,6 +1154,7 @@ fn session_worker( pocketstation::SessionStopDisposition::AlreadyStopped ); let _ = response.send(OwnedStopResult { + lifecycle_state: core_lifecycle_state_name(running.state()), success: stop.is_success(), already_stopped, disposition: if already_stopped { @@ -1124,6 +1196,7 @@ fn session_worker( pocketstation::SessionCancelDisposition::AlreadyStopped ); let _ = response.send(OwnedStopResult { + lifecycle_state: core_lifecycle_state_name(running.state()), success: cancel.is_success(), already_stopped, disposition: if already_stopped { @@ -1166,6 +1239,16 @@ fn session_worker( let _ = running.stop(); } +const fn core_lifecycle_state_name(state: pocketstation::SessionLifecycleState) -> &'static str { + match state { + pocketstation::SessionLifecycleState::Starting => "starting", + pocketstation::SessionLifecycleState::Running => "running", + pocketstation::SessionLifecycleState::Stopping => "stopping", + pocketstation::SessionLifecycleState::Stopped => "stopped", + pocketstation::SessionLifecycleState::Failed => "failed", + } +} + pub(crate) fn stop_worker(mut worker: SessionWorker) -> PyResult { let (response, receiver) = sync_channel(1); worker diff --git a/native/src/signals.rs b/native/src/signals.rs index 2d76992..cce73c7 100644 --- a/native/src/signals.rs +++ b/native/src/signals.rs @@ -566,6 +566,14 @@ pub(crate) struct PythonSignalLineage { policy_epoch: u64, } +#[pymethods] +impl PythonSignalLineage { + #[getter] + fn clock(&self) -> crate::streams::PythonClockDomainDescriptor { + crate::streams::clock_domain_descriptor(pocketstation::ClockDomainId::new(self.clock_id)) + } +} + #[pyclass(name = "_SignalDerivation", frozen)] pub(crate) struct PythonSignalDerivation { upstream_lineage: Py, diff --git a/native/src/sources.rs b/native/src/sources.rs index 1c339a4..27f2e19 100644 --- a/native/src/sources.rs +++ b/native/src/sources.rs @@ -1,10 +1,13 @@ use pocketstation::{ - ApplicationSelector, CaptureSource, DeviceId, DeviceSelector, PermissionObservation, Platform, + ApplicationPolicyObservation, ApplicationSelector, CaptureAuthorizationSnapshot, + CaptureOpenOutcome, CapturePermissionLifecycle, CaptureScope, CaptureSessionGrant, + CaptureSource, DeviceId, DeviceSelector, PermissionEpoch, PermissionObservation, Platform, ProcessId, ProcessTreeScope, SelectorPersistenceScope, Source, SourceIdentityStrength, SourceKind, SourceQuery, SourceState, StableSourceId, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; +use std::sync::Mutex; use crate::errors::{coded_reason, parse_platform, validate_nonempty, validate_process_id}; @@ -24,6 +27,7 @@ pub(crate) enum SourceDeclaration { }, MicrophoneDefault, MicrophoneId(String), + SystemMix, } impl SourceDeclaration { @@ -58,6 +62,7 @@ impl SourceDeclaration { Self::MicrophoneId(device_id) => { Source::microphone(DeviceSelector::id(DeviceId::new(device_id.clone()))) } + Self::SystemMix => Source::system_mix(), } } } @@ -69,6 +74,7 @@ pub(crate) struct PythonSource { #[pyclass(name = "DiscoveredSource", frozen)] pub(crate) struct PythonDiscoveredSource { + source: CaptureSource, #[pyo3(get)] platform: String, #[pyo3(get)] @@ -99,6 +105,134 @@ pub(crate) struct PythonDiscoveredSource { process_tree_scope: Option, } +#[pyclass(name = "CaptureAuthorizationSnapshot", frozen)] +pub(crate) struct PythonCaptureAuthorizationSnapshot { + #[pyo3(get)] + capability: String, + #[pyo3(get)] + os_permission: String, + #[pyo3(get)] + application_policy: String, + #[pyo3(get)] + session_grant: String, + #[pyo3(get)] + capture_scope: String, + #[pyo3(get)] + scope_stable_id: Option, + #[pyo3(get)] + identity_strength: String, + #[pyo3(get)] + permission_epoch: u64, + #[pyo3(get)] + observed_at_ns: u64, + #[pyo3(get)] + open_outcome: String, +} + +#[pymethods] +impl PythonDiscoveredSource { + #[pyo3(signature = ( + os_permission="not-observable", + application_policy="not-observable", + session_grant="not-evaluated", + permission_epoch=1 + ))] + fn authorization_before_open( + &self, + os_permission: &str, + application_policy: &str, + session_grant: &str, + permission_epoch: u64, + ) -> PyResult { + if permission_epoch == 0 { + return Err(PyValueError::new_err(coded_reason( + "capture.invalid_permission_epoch", + "permission epoch must be greater than zero", + ))); + } + let snapshot = CaptureAuthorizationSnapshot::from_open_observations( + &self.source, + parse_session_grant(session_grant)?, + PermissionEpoch(permission_epoch), + parse_permission_observation(os_permission)?, + parse_application_policy(application_policy)?, + CaptureOpenOutcome::NotAttempted, + ); + Ok(PythonCaptureAuthorizationSnapshot::from(snapshot)) + } +} + +#[pyclass(name = "CapturePermissionTransition", frozen)] +pub(crate) struct PythonCapturePermissionTransition { + #[pyo3(get)] + kind: String, + #[pyo3(get)] + previous: String, + #[pyo3(get)] + current: String, + #[pyo3(get)] + permission_epoch: u64, +} + +#[pyclass(name = "CapturePermissionLifecycle")] +pub(crate) struct PythonCapturePermissionLifecycle { + lifecycle: Mutex, +} + +#[pymethods] +impl PythonCapturePermissionLifecycle { + #[new] + fn new(current: &str) -> PyResult { + Ok(Self { + lifecycle: Mutex::new(CapturePermissionLifecycle::new( + parse_permission_observation(current)?, + )), + }) + } + + #[getter] + fn current(&self) -> PyResult<&'static str> { + let lifecycle = self + .lifecycle + .lock() + .map_err(|_| PyValueError::new_err("capture permission state is unavailable"))?; + Ok(permission_observation_name(lifecycle.current())) + } + + #[getter] + fn permission_epoch(&self) -> PyResult { + let lifecycle = self + .lifecycle + .lock() + .map_err(|_| PyValueError::new_err("capture permission state is unavailable"))?; + Ok(lifecycle.permission_epoch().0) + } + + fn observe(&self, current: &str) -> PyResult> { + let mut lifecycle = self + .lifecycle + .lock() + .map_err(|_| PyValueError::new_err("capture permission state is unavailable"))?; + Ok(lifecycle + .observe(parse_permission_observation(current)?) + .map(|transition| PythonCapturePermissionTransition { + kind: match transition.kind { + pocketstation::SourceLifecycleEventKind::PermissionChanged => { + "permission-changed" + } + pocketstation::SourceLifecycleEventKind::PermissionRevoked => { + "permission-revoked" + } + _ => "unrecognized-permission-transition", + } + .to_owned(), + previous: permission_observation_name(transition.previous).to_owned(), + current: permission_observation_name(transition.current).to_owned(), + permission_epoch: transition.permission_epoch.0, + })) + } +} + #[pymethods] impl PythonSource { #[staticmethod] @@ -177,6 +311,13 @@ impl PythonSource { declaration: SourceDeclaration::MicrophoneId(device_id), }) } + + #[staticmethod] + const fn system_mix() -> Self { + Self { + declaration: SourceDeclaration::SystemMix, + } + } } fn platform_name(platform: Platform) -> &'static str { @@ -251,6 +392,47 @@ pub(crate) fn permission_observation_name(observation: PermissionObservation) -> } } +fn parse_permission_observation(value: &str) -> PyResult { + match value { + "allowed" => Ok(PermissionObservation::Allowed), + "denied" => Ok(PermissionObservation::Denied), + "restricted" => Ok(PermissionObservation::Restricted), + "not-determined" => Ok(PermissionObservation::NotDetermined), + "revoked" => Ok(PermissionObservation::Revoked), + "not-observable" => Ok(PermissionObservation::NotObservable), + "not-applicable" => Ok(PermissionObservation::NotApplicable), + _ => Err(PyValueError::new_err(coded_reason( + "capture.invalid_permission_observation", + "permission observation is not recognized", + ))), + } +} + +fn parse_application_policy(value: &str) -> PyResult { + match value { + "allowed" => Ok(ApplicationPolicyObservation::Allowed), + "denied" => Ok(ApplicationPolicyObservation::Denied), + "not-observable" => Ok(ApplicationPolicyObservation::NotObservable), + "not-applicable" => Ok(ApplicationPolicyObservation::NotApplicable), + _ => Err(PyValueError::new_err(coded_reason( + "capture.invalid_application_policy", + "application policy observation is not recognized", + ))), + } +} + +fn parse_session_grant(value: &str) -> PyResult { + match value { + "granted-by-explicit-selection" => Ok(CaptureSessionGrant::GrantedByExplicitSelection), + "denied" => Ok(CaptureSessionGrant::Denied), + "not-evaluated" => Ok(CaptureSessionGrant::NotEvaluated), + _ => Err(PyValueError::new_err(coded_reason( + "capture.invalid_session_grant", + "capture Session grant is not recognized", + ))), + } +} + pub(crate) fn stable_source_parts( stable_id: &StableSourceId, ) -> (&'static str, &'static str, &str) { @@ -275,6 +457,7 @@ fn discovered_source(source: CaptureSource) -> PythonDiscoveredSource { .map(process_tree_scope_name) .map(str::to_owned); PythonDiscoveredSource { + source: source.clone(), platform, kind, stable_key: source.stable_id.stable_key, @@ -292,6 +475,54 @@ fn discovered_source(source: CaptureSource) -> PythonDiscoveredSource { } } +impl From for PythonCaptureAuthorizationSnapshot { + fn from(snapshot: CaptureAuthorizationSnapshot) -> Self { + let (capture_scope, scope_stable_id) = match snapshot.capture_scope { + CaptureScope::ExactApplication { stable_id } => ("exact-application", Some(stable_id)), + CaptureScope::ExactInputDevice { stable_id } => ("exact-input-device", Some(stable_id)), + CaptureScope::ExactOutputDevice { stable_id } => { + ("exact-output-device", Some(stable_id)) + } + CaptureScope::SystemMix => ("system-mix", None), + }; + Self { + capability: match snapshot.capability { + pocketstation::CaptureCapabilityState::Available => "available", + pocketstation::CaptureCapabilityState::Unavailable => "unavailable", + pocketstation::CaptureCapabilityState::Unsupported => "unsupported", + } + .to_owned(), + os_permission: permission_observation_name(snapshot.os_permission).to_owned(), + application_policy: match snapshot.application_policy { + ApplicationPolicyObservation::Allowed => "allowed", + ApplicationPolicyObservation::Denied => "denied", + ApplicationPolicyObservation::NotObservable => "not-observable", + ApplicationPolicyObservation::NotApplicable => "not-applicable", + } + .to_owned(), + session_grant: match snapshot.session_grant { + CaptureSessionGrant::GrantedByExplicitSelection => "granted-by-explicit-selection", + CaptureSessionGrant::Denied => "denied", + CaptureSessionGrant::NotEvaluated => "not-evaluated", + } + .to_owned(), + capture_scope: capture_scope.to_owned(), + scope_stable_id, + identity_strength: identity_strength_name(snapshot.identity_strength).to_owned(), + permission_epoch: snapshot.permission_epoch.0, + observed_at_ns: snapshot.observed_at_ns, + open_outcome: match snapshot.open_outcome { + CaptureOpenOutcome::NotAttempted => "not-attempted", + CaptureOpenOutcome::Succeeded => "succeeded", + CaptureOpenOutcome::PermissionDenied => "permission-denied", + CaptureOpenOutcome::SourceUnavailable => "source-unavailable", + CaptureOpenOutcome::BackendFailed => "backend-failed", + } + .to_owned(), + } + } +} + fn parse_source_kind(value: &str) -> PyResult { match value { "application" => Ok(SourceKind::Application), @@ -361,6 +592,9 @@ fn python_microphone_permission_observation() -> &'static str { pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; module.add_function(wrap_pyfunction!(python_discover_sources, module)?)?; module.add_function(wrap_pyfunction!( python_application_capture_available, @@ -432,6 +666,21 @@ mod tests { } } + #[test] + fn authorization_snapshot_preserves_exact_pre_open_evidence() { + let source = discovered_source(fixture_source()); + let snapshot = source + .authorization_before_open("allowed", "allowed", "granted-by-explicit-selection", 7) + .expect("authorization snapshot"); + assert_eq!(snapshot.capability, "available"); + assert_eq!(snapshot.os_permission, "allowed"); + assert_eq!(snapshot.application_policy, "allowed"); + assert_eq!(snapshot.capture_scope, "exact-application"); + assert_eq!(snapshot.scope_stable_id.as_deref(), Some("pw-app:42")); + assert_eq!(snapshot.permission_epoch, 7); + assert_eq!(snapshot.open_outcome, "not-attempted"); + } + #[test] fn query_parser_rejects_missing_or_surplus_values() { assert!(parse_source_query("application", None).is_err()); diff --git a/native/src/streams.rs b/native/src/streams.rs index 0f5d159..cc49693 100644 --- a/native/src/streams.rs +++ b/native/src/streams.rs @@ -1,6 +1,5 @@ use std::sync::mpsc::{sync_channel, SyncSender}; -use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use pocketstation::PolledAudioPollError; use pyo3::exceptions::{PyIndexError, PyRuntimeError}; @@ -9,6 +8,51 @@ use pyo3::types::{PyBytes, PyMemoryView}; use crate::session::SessionCommand; +#[derive(Clone, Copy)] +#[pyclass(name = "ClockDomainDescriptor", frozen)] +pub(crate) struct PythonClockDomainDescriptor { + #[pyo3(get)] + id: u32, + #[pyo3(get)] + kind: &'static str, + #[pyo3(get)] + origin: &'static str, + #[pyo3(get)] + tick_rate_hz: Option, +} + +#[pymethods] +impl PythonClockDomainDescriptor { + fn __eq__(&self, other: &Self) -> bool { + self.id == other.id + && self.kind == other.kind + && self.origin == other.origin + && self.tick_rate_hz == other.tick_rate_hz + } +} + +pub(crate) fn clock_domain_descriptor( + id: pocketstation::ClockDomainId, +) -> PythonClockDomainDescriptor { + let descriptor = pocketstation::timing::describe_clock_domain(id); + let kind = match descriptor.kind() { + pocketstation::timing::ClockDomainKind::Unspecified => "unspecified", + pocketstation::timing::ClockDomainKind::ProcessMonotonic => "process-monotonic", + pocketstation::timing::ClockDomainKind::ProviderDefined => "provider-defined", + }; + let origin = match descriptor.origin() { + pocketstation::timing::ClockDomainOrigin::Unspecified => "unspecified", + pocketstation::timing::ClockDomainOrigin::ProcessStart => "process-start", + pocketstation::timing::ClockDomainOrigin::ProviderDefined => "provider-defined", + }; + PythonClockDomainDescriptor { + id: descriptor.id().get(), + kind, + origin, + tick_rate_hz: descriptor.tick_rate_hz(), + } +} + #[pyclass(name = "AudioFrame", frozen)] pub(crate) struct PythonAudioFrame { samples_f32le: Py, @@ -27,8 +71,7 @@ pub(crate) struct PythonAudioFrame { stem_id: u64, #[pyo3(get)] clock_id: u32, - #[pyo3(get)] - sequence_num: u64, + sequence_number: u64, #[pyo3(get)] timestamp_start_ns: u64, #[pyo3(get)] @@ -42,9 +85,17 @@ pub(crate) struct PythonAudioFrame { #[pyo3(get)] endpoint_id: u64, #[pyo3(get)] - connector_id: u64, + connector_id: Option, #[pyo3(get)] route_id: u64, + #[pyo3(get)] + route_enqueued_at_ns: u64, + #[pyo3(get)] + route_received_at_ns: u64, + #[pyo3(get)] + endpoint_enqueued_at_ns: Option, + #[pyo3(get)] + polled_at_ns: Option, } #[pymethods] @@ -54,7 +105,7 @@ impl PythonAudioFrame { "AudioFrame(stem_id={}, source_id={}, sequence_number={}, timestamp_start_ns={}, sample_count={}, sample_rate_hz={}, channel_count={}, discontinuity_epoch={})", self.stem_id, self.source_id, - self.sequence_num, + self.sequence_number, self.timestamp_start_ns, self.sample_count, self.sample_rate_hz, @@ -88,7 +139,12 @@ impl PythonAudioFrame { #[getter] const fn sequence_number(&self) -> u64 { - self.sequence_num + self.sequence_number + } + + #[getter] + fn clock(&self) -> PythonClockDomainDescriptor { + clock_domain_descriptor(pocketstation::ClockDomainId::new(self.clock_id)) } } @@ -130,15 +186,19 @@ pub(crate) struct OwnedAudioFrame { pub(crate) source_id: u64, pub(crate) stem_id: u64, pub(crate) clock_id: u32, - pub(crate) sequence_num: u64, + pub(crate) sequence_number: u64, pub(crate) timestamp_start_ns: u64, pub(crate) duration_ns: u64, pub(crate) source_generation: u32, pub(crate) discontinuity_epoch: u64, pub(crate) permission_epoch: u64, pub(crate) endpoint_id: u64, - pub(crate) connector_id: u64, + pub(crate) connector_id: Option, pub(crate) route_id: u64, + pub(crate) route_enqueued_at_ns: u64, + pub(crate) route_received_at_ns: u64, + pub(crate) endpoint_enqueued_at_ns: Option, + pub(crate) polled_at_ns: Option, } pub(crate) fn request_audio_batch( @@ -176,6 +236,12 @@ pub(crate) fn copy_audio_batch( Err(PolledAudioPollError::Empty) => return Ok(None), Err(error) => return Err(error.to_string()), }; + copy_polled_audio_batch(batch).map(Some) +} + +fn copy_polled_audio_batch( + batch: pocketstation::PolledAudioBatchLease, +) -> Result, String> { let mut frames = Vec::with_capacity(batch.len()); for index in 0..batch.len() { let frame = batch @@ -192,31 +258,34 @@ pub(crate) fn copy_audio_batch( source_id: lineage.source_id().get(), stem_id: lineage.stem_id().get(), clock_id: lineage.clock_id().get(), - sequence_num: lineage.sequence_number(), + sequence_number: lineage.sequence_number(), timestamp_start_ns: lineage.timestamp_start_ns(), duration_ns: lineage.duration_ns(), source_generation: lineage.source_generation(), discontinuity_epoch: lineage.discontinuity_epoch(), permission_epoch: lineage.permission_epoch(), endpoint_id: frame.endpoint_id().get(), - connector_id: frame.connector_id().get(), + connector_id: Some(frame.connector_id().get()), route_id: frame.route_id().get(), + route_enqueued_at_ns: frame.route_enqueued_at_ns(), + route_received_at_ns: frame.route_received_at_ns(), + endpoint_enqueued_at_ns: Some(frame.endpoint_enqueued_at_ns()), + polled_at_ns: Some(frame.polled_at_ns()), }); } - Ok(Some(frames)) + Ok(frames) } pub(crate) fn copy_audio_batch_until( running: &pocketstation::RunningSession, timeout: Duration, ) -> Result>, String> { - let deadline = Instant::now() + timeout; - loop { - match copy_audio_batch(running)? { - Some(batch) => return Ok(Some(batch)), - None if Instant::now() < deadline => thread::sleep(Duration::from_millis(1)), - None => return Ok(None), - } + match running + .wait_audio(timeout) + .map_err(|error| error.to_string())? + { + Some(batch) => copy_polled_audio_batch(batch).map(Some), + None => Ok(None), } } @@ -241,9 +310,7 @@ pub(crate) fn owned_endpoint_audio_frame( owned_endpoint_audio_frame_for_route( frame, input.endpoint_id().get(), - input - .connector_id() - .map_or(0, pocketstation::ConnectorId::get), + input.connector_id().map(pocketstation::ConnectorId::get), input.route_id().get(), ) } @@ -251,9 +318,11 @@ pub(crate) fn owned_endpoint_audio_frame( pub(crate) fn owned_endpoint_audio_frame_for_route( frame: pocketstation::EndpointAudioFrame, endpoint_id: u64, - connector_id: u64, + connector_id: Option, route_id: u64, ) -> OwnedAudioFrame { + let route_enqueued_at_ns = frame.route_enqueued_at_ns(); + let route_received_at_ns = frame.route_received_at_ns(); let lineage = frame.lineage(); OwnedAudioFrame { samples_f32le: f32_samples_to_le_bytes(frame.samples()), @@ -265,7 +334,7 @@ pub(crate) fn owned_endpoint_audio_frame_for_route( source_id: lineage.source_id().get(), stem_id: lineage.stem_id().get(), clock_id: lineage.clock_id().get(), - sequence_num: lineage.sequence_number(), + sequence_number: lineage.sequence_number(), timestamp_start_ns: lineage.timestamp_start_ns(), duration_ns: lineage.duration_ns(), source_generation: lineage.source_generation(), @@ -274,6 +343,10 @@ pub(crate) fn owned_endpoint_audio_frame_for_route( endpoint_id, connector_id, route_id, + route_enqueued_at_ns, + route_received_at_ns, + endpoint_enqueued_at_ns: None, + polled_at_ns: None, } } @@ -288,7 +361,7 @@ pub(crate) fn python_audio_frame(py: Python<'_>, frame: OwnedAudioFrame) -> Pyth source_id: frame.source_id, stem_id: frame.stem_id, clock_id: frame.clock_id, - sequence_num: frame.sequence_num, + sequence_number: frame.sequence_number, timestamp_start_ns: frame.timestamp_start_ns, duration_ns: frame.duration_ns, source_generation: frame.source_generation, @@ -297,6 +370,10 @@ pub(crate) fn python_audio_frame(py: Python<'_>, frame: OwnedAudioFrame) -> Pyth endpoint_id: frame.endpoint_id, connector_id: frame.connector_id, route_id: frame.route_id, + route_enqueued_at_ns: frame.route_enqueued_at_ns, + route_received_at_ns: frame.route_received_at_ns, + endpoint_enqueued_at_ns: frame.endpoint_enqueued_at_ns, + polled_at_ns: frame.polled_at_ns, } } @@ -309,6 +386,7 @@ fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { } pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; module.add_class::()?; module.add_class::()?; Ok(()) diff --git a/pyproject.toml b/pyproject.toml index cac1fde..56f794b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,3 +44,5 @@ select = ["B", "E", "F", "I", "RUF", "UP"] manifest-path = "native/Cargo.toml" python-source = "python" module-name = "pocketstation._native" +include = [{ path = "examples/**/*", format = "sdist" }] +exclude = ["**/__pycache__/**", "**/*.pyc", "**/*.pyo"] diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index 7ce3dd6..44ed966 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -5,6 +5,7 @@ from . import aio as aio from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource from .capture import Capture, capture +from .compatibility import RUNTIME_COMPATIBILITY, RuntimeCompatibility from .connector import ( AudioConnectorHandler, Connector, @@ -61,12 +62,22 @@ AudioInputClosedError, AudioInputError, AudioInputFullError, + CaptureError, + ConnectorRuntimeError, ExtensionError, + GraphError, + OperatorError, PocketStationError, + SessionCompileDiagnostic, + SessionDeclarationError, + SessionError, + SessionRuntimeError, + SessionStartError, SidecarBackpressureError, SidecarError, SidecarProtocolError, SidecarTimeoutError, + SourceError, StreamError, StreamInUseError, StreamModeError, @@ -115,6 +126,21 @@ Stem, TextFormat, ) +from .identity import ( + ClockDomainId, + ClockDomainKind, + ClockDomainOrigin, + ConnectorId, + EndpointId, + OperatorInstanceId, + RouteId, + RuntimeSessionId, + SidecarId, + SourceId, + SourceInstanceId, + StemId, + StreamId, +) from .observations import ( AudioReentryMetrics, DerivedRouteMetrics, @@ -138,6 +164,8 @@ RouteLatencyBoundary, RouteLatencyUnit, RouteObservationInterval, + SessionComponent, + SessionComponentKind, SessionEventType, SessionFailure, SessionFailureKind, @@ -147,7 +175,9 @@ SessionTerminalState, SessionTrace, SessionTraceConfiguration, + SessionTraceRecord, SessionTraceRecorderOutcome, + SessionTraceRecordType, SessionTraceValidation, SourceMetrics, TerminationDisposition, @@ -229,6 +259,15 @@ source, ) from .sources import ( + ApplicationPolicyObservation, + CaptureAuthorizationSnapshot, + CaptureCapabilityState, + CaptureOpenOutcome, + CapturePermissionLifecycle, + CapturePermissionTransition, + CapturePermissionTransitionKind, + CaptureScopeKind, + CaptureSessionGrant, DiscoveredSource, PermissionObservation, Platform, @@ -250,12 +289,20 @@ discover_sources, microphone_permission_observation, ) -from .streams import AudioStream, SignalStream +from .streams import ( + AudioBatchReadResult, + AudioStream, + ClockDomainDescriptor, + SignalStream, +) __version__ = "0.1.0" __all__ = [ + "RUNTIME_COMPATIBILITY", "STREAM_EOF", + "ApplicationPolicyObservation", "AudioBatch", + "AudioBatchReadResult", "AudioCaps", "AudioConnectorHandler", "AudioFrame", @@ -273,8 +320,21 @@ "BinaryFormat", "BusSubscription", "Capture", + "CaptureAuthorizationSnapshot", + "CaptureCapabilityState", + "CaptureError", + "CaptureOpenOutcome", + "CapturePermissionLifecycle", + "CapturePermissionTransition", + "CapturePermissionTransitionKind", + "CaptureScopeKind", + "CaptureSessionGrant", "ChannelLayout", "ClockDomain", + "ClockDomainDescriptor", + "ClockDomainId", + "ClockDomainKind", + "ClockDomainOrigin", "Codec", "Connector", "ConnectorBatchOutcome", @@ -298,6 +358,7 @@ "ConnectorFactory", "ConnectorHandler", "ConnectorHealth", + "ConnectorId", "ConnectorInputDescriptor", "ConnectorItem", "ConnectorManifest", @@ -306,6 +367,7 @@ "ConnectorRecovery", "ConnectorRequirement", "ConnectorRetryability", + "ConnectorRuntimeError", "ConnectorRuntimeObservations", "ConnectorServiceStatus", "ConnectorShutdownMode", @@ -327,6 +389,7 @@ "EndpointDescriptor", "EndpointFailureRetryability", "EndpointFailureStage", + "EndpointId", "EndpointMetrics", "EndpointObservationStage", "EventFormat", @@ -339,6 +402,7 @@ "ExtensionPort", "ExtensionPortDirection", "ExternalSourceMetrics", + "GraphError", "IceServer", "LatencyHistogram", "LossPolicy", @@ -351,11 +415,13 @@ "OperatorConfigValidator", "OperatorConfiguration", "OperatorEmission", + "OperatorError", "OperatorFactory", "OperatorHandler", "OperatorInput", "OperatorInputMetrics", "OperatorInstance", + "OperatorInstanceId", "OperatorManifest", "OperatorMetrics", "OperatorNode", @@ -389,16 +455,24 @@ "RelayRoute", "RelaySession", "RelayTimeoutError", + "RouteId", "RouteLatencyBoundary", "RouteLatencyUnit", "RouteMetrics", "RouteObservationInterval", "RunningSession", + "RuntimeCompatibility", + "RuntimeSessionId", "SampleFormat", "SecretToken", "SelectorPersistenceScope", "Session", + "SessionCompileDiagnostic", + "SessionComponent", + "SessionComponentKind", "SessionCredentials", + "SessionDeclarationError", + "SessionError", "SessionEvent", "SessionEventType", "SessionFailure", @@ -408,10 +482,14 @@ "SessionLifecycleState", "SessionMetrics", "SessionRollbackStage", + "SessionRuntimeError", "SessionSnapshot", + "SessionStartError", "SessionTerminalState", "SessionTrace", "SessionTraceConfiguration", + "SessionTraceRecord", + "SessionTraceRecordType", "SessionTraceRecorderOutcome", "SessionTraceValidation", "SidecarBackpressureError", @@ -419,6 +497,7 @@ "SidecarDeadlines", "SidecarError", "SidecarHandle", + "SidecarId", "SidecarMessage", "SidecarMessageKind", "SidecarProcessSpec", @@ -446,10 +525,13 @@ "SourceConfiguration", "SourceDriver", "SourceEmission", + "SourceError", "SourceFactory", "SourceFailureClass", + "SourceId", "SourceIdentityStrength", "SourceInstance", + "SourceInstanceId", "SourceIterableFactory", "SourceKind", "SourceManifest", @@ -466,8 +548,10 @@ "SourceState", "StableSourceId", "Stem", + "StemId", "StopResult", "StreamError", + "StreamId", "StreamInUseError", "StreamModeError", "SubscriberCredentials", diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index 83b25e8..7e9c5cc 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -3,6 +3,21 @@ from collections.abc import Iterator from pathlib import Path +from .identity import ( + ClockDomainId, + ClockDomainKind, + ClockDomainOrigin, + ConnectorId, + EndpointId, + OperatorInstanceId, + RouteId, + RuntimeSessionId, + SourceId, + SourceInstanceId, + StemId, + StreamId, +) + class _ExtensionAbiVersion: struct_size_bytes: int abi_major: int @@ -53,12 +68,14 @@ class Source: def microphone_default() -> Source: ... @staticmethod def microphone_id(device_id: str) -> Source: ... + @staticmethod + def system_mix() -> Source: ... class DiscoveredSource: platform: str kind: str stable_key: str - source_id: int + source_id: SourceId name: str process_id: int | None application_id: str | None @@ -69,6 +86,37 @@ class DiscoveredSource: identity_strength: str selector_persistence_scope: str | None process_tree_scope: str | None + def authorization_before_open( + self, + os_permission: str = "not-observable", + application_policy: str = "not-observable", + session_grant: str = "not-evaluated", + permission_epoch: int = 1, + ) -> CaptureAuthorizationSnapshot: ... + +class CaptureAuthorizationSnapshot: + capability: str + os_permission: str + application_policy: str + session_grant: str + capture_scope: str + scope_stable_id: str | None + identity_strength: str + permission_epoch: int + observed_at_ns: int + open_outcome: str + +class CapturePermissionTransition: + kind: str + previous: str + current: str + permission_epoch: int + +class CapturePermissionLifecycle: + def __init__(self, current: str) -> None: ... + current: str + permission_epoch: int + def observe(self, current: str) -> CapturePermissionTransition | None: ... def discover_sources( query_kind: str = "any", @@ -110,6 +158,7 @@ class _MediaCaps: frame_samples: int | None channel_layout: str | None def is_compatible_with(self, other: _MediaCaps) -> bool: ... + def negotiate(self, other: _MediaCaps) -> _MediaCaps | None: ... def supports_signal(self, signal: _SignalSpec) -> bool: ... class _PortSpec: @@ -152,8 +201,8 @@ class _EdgeContract: class BusSubscription: id: int - session_id: int - route_id: int + session_id: RuntimeSessionId + route_id: RouteId signal: _SignalSpec edge: _EdgeContract @@ -164,10 +213,11 @@ class _SignalTiming: duration_ns: int | None class _SignalLineage: - session_id: int - stream_id: int - source_id: int - clock_id: int + session_id: RuntimeSessionId + stream_id: StreamId + source_id: SourceId + clock_id: ClockDomainId + clock: ClockDomainDescriptor sequence_number: int source_generation: int discontinuity_epoch: int @@ -179,7 +229,7 @@ class _SignalDerivation: operator_id: str operator_revision: int operator_generation: int - connector_id: int | None + connector_id: ConnectorId | None class _SignalAudioPayload: samples: memoryview @@ -187,8 +237,8 @@ class _SignalAudioPayload: sample_count: int sample_rate_hz: int channel_count: int - stream_id: int - source_id: int + stream_id: StreamId + source_id: SourceId sequence_number: int timestamp_ns: int sample_format: str @@ -380,9 +430,9 @@ class _RegisteredConnector: def observation(self, endpoint: Endpoint) -> _ConnectorObservations | None: ... class Endpoint: - id: int - session_id: int - connector_id: int | None + id: EndpointId + session_id: RuntimeSessionId + connector_id: ConnectorId | None class RelayPublisher: ... @@ -390,17 +440,17 @@ class OperatorInput: port_name: str class OperatorInstance: - session_id: int - instance_id: int + session_id: RuntimeSessionId + instance_id: OperatorInstanceId def input(self, port_name: str) -> OperatorInput: ... def output(self, port_name: str) -> DerivedStream: ... class Stem: @property - def id(self) -> int: ... - def send(self, endpoint: Endpoint) -> int: ... - def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... - def connect(self, input: OperatorInput) -> int: ... + def id(self) -> StemId: ... + def send(self, endpoint: Endpoint) -> RouteId: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> RouteId: ... + def connect(self, input: OperatorInput) -> RouteId: ... def through( self, operator_id: str, @@ -409,15 +459,15 @@ class Stem: output_port: str | None = None, ) -> DerivedStream: ... def record(self, stem_name: str) -> Endpoint: ... - def publish(self, publisher: RelayPublisher, bus_id: str) -> int: ... - session_id: int + def publish(self, publisher: RelayPublisher, bus_id: str) -> RouteId: ... + session_id: RuntimeSessionId class DerivedStream: - session_id: int - operator_instance_id: int + session_id: RuntimeSessionId + operator_instance_id: OperatorInstanceId output_port: str | None def output(self, port_name: str) -> DerivedStream: ... - def connect(self, input: OperatorInput) -> int: ... + def connect(self, input: OperatorInput) -> RouteId: ... def through( self, operator_id: str, @@ -425,23 +475,23 @@ class DerivedStream: input_port: str | None = None, output_port: str | None = None, ) -> DerivedStream: ... - def send(self, endpoint: Endpoint) -> int: ... - def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... + def send(self, endpoint: Endpoint) -> RouteId: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> RouteId: ... def reenter_audio(self) -> Stem: ... class SourceInstance: - session_id: int - instance_id: int - source_id: int + session_id: RuntimeSessionId + instance_id: SourceInstanceId + source_id: SourceId def output(self, port_name: str) -> SourceOutput: ... class SourceOutput: - session_id: int - source_instance_id: int - source_id: int - stream_id: int + session_id: RuntimeSessionId + source_instance_id: SourceInstanceId + source_id: SourceId + stream_id: StreamId output_port: str - def connect(self, input: OperatorInput) -> int: ... + def connect(self, input: OperatorInput) -> RouteId: ... def through( self, operator_id: str, @@ -449,29 +499,39 @@ class SourceOutput: input_port: str | None = None, output_port: str | None = None, ) -> DerivedStream: ... - def send(self, endpoint: Endpoint) -> int: ... - def send_to(self, endpoint: Endpoint, input_port: str | None) -> int: ... + def send(self, endpoint: Endpoint) -> RouteId: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> RouteId: ... def record(self, stem_name: str) -> Endpoint: ... - def publish(self, publisher: RelayPublisher, bus_id: str) -> int: ... + def publish(self, publisher: RelayPublisher, bus_id: str) -> RouteId: ... + +class ClockDomainDescriptor: + id: ClockDomainId + kind: ClockDomainKind + origin: ClockDomainOrigin + tick_rate_hz: int | None class AudioFrame: sample_rate_hz: int channel_count: int - session_id: int - stream_id: int - source_id: int - stem_id: int - clock_id: int - sequence_num: int + session_id: RuntimeSessionId + stream_id: StreamId + source_id: SourceId + stem_id: StemId + clock_id: ClockDomainId + clock: ClockDomainDescriptor sequence_number: int timestamp_start_ns: int duration_ns: int source_generation: int discontinuity_epoch: int permission_epoch: int - endpoint_id: int - connector_id: int - route_id: int + endpoint_id: EndpointId + connector_id: ConnectorId | None + route_id: RouteId + route_enqueued_at_ns: int + route_received_at_ns: int + endpoint_enqueued_at_ns: int | None + polled_at_ns: int | None @property def samples(self) -> memoryview: ... @property @@ -511,11 +571,15 @@ class RecordingStemOutcome: def discontinuities(self) -> list[RecordingDiscontinuity]: ... class RecordingOutcome: + session_id: RuntimeSessionId + group_id: str complete: bool state: str completed_stems: int failed_stems: int session_directory: str + manifest_path: str + manifest_schema_version: int error_code: str | None def stems(self) -> list[RecordingStemOutcome]: ... @@ -580,6 +644,7 @@ class _SessionFailure: error_code: str | None retryability: str | None component: str | None + component_kind: str | None message: str | None stem_id: int | None route_id: int | None @@ -856,12 +921,31 @@ class _SessionTraceValidation: finalization_failures_total: int records_validated_total: int +class SessionTraceRecord: + sequence_index: int + observed_at_ns: int + session_id: int + kind: str + lifecycle_state: str | None + terminal_state: str | None + stem_id: int | None + route_id: int | None + endpoint_id: int | None + endpoint_stage: str | None + rollback_stage: str | None + finalization_stage: str | None + source_failures_total: int | None + endpoint_failures_total: int | None + rollback_failures_total: int | None + finalization_failures_total: int | None + class SessionTrace: @staticmethod def read(path: Path) -> SessionTrace: ... session_id: int outcome: SessionTraceRecorderOutcome records_total: int + def records(self) -> list[SessionTraceRecord]: ... def validate(self) -> _SessionTraceValidation: ... class _SidecarProcessSpec: @@ -950,8 +1034,8 @@ class _AudioInputObservations: closed: bool class _AudioInput: - source_id: int - stream_id: int + source_id: SourceId + stream_id: StreamId output: SourceOutput def try_write( self, @@ -1006,12 +1090,12 @@ class _SourceEmission: class _SourceOutputIdentity: output_port: str - stream_id: int + stream_id: StreamId class _SourcePrepareContext: source_type_id: str - session_id: int | None - source_id: int | None + session_id: RuntimeSessionId | None + source_id: SourceId | None outputs: list[_SourceOutputIdentity] class _SourceCancellation: @@ -1165,6 +1249,7 @@ class Session: class RunningSession: session_id: int + lifecycle_state: str def poll_audio(self) -> AudioBatch | None: ... def wait_audio(self, timeout_ms: int = 100) -> AudioBatch | None: ... def poll_event(self) -> SessionEvent | None: ... diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index f7d52f0..53d839c 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -5,12 +5,39 @@ from .connector import ( AudioConnectorHandler, Connector, + ConnectorBatchOutcome, + ConnectorCapability, + ConnectorConfigurationConstraint, + ConnectorConfigurationField, + ConnectorConfigurationInput, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorContext, ConnectorDeadlines, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, ConnectorDriver, ConnectorDriverBuilder, ConnectorDriverFactory, + ConnectorError, + ConnectorErrorSnapshot, + ConnectorErrorStage, ConnectorFactory, ConnectorHandler, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorObservations, + ConnectorPreparationGroup, + ConnectorRecovery, + ConnectorRequirement, + ConnectorRetryability, + ConnectorRuntimeObservations, + ConnectorServiceStatus, + ConnectorShutdownMode, ConnectorWorker, ConnectorWorkerBuilder, RegisteredConnector, @@ -28,12 +55,15 @@ ) from .observations import EventStream from .operator_authoring import ( + OperatorConfigValidator, OperatorDeadlines, + OperatorEmission, OperatorFactory, OperatorHandler, OperatorManifest, OperatorNode, OperatorNodeBuilder, + OperatorPrepareContext, OperatorProvider, RegisteredOperator, operator, @@ -44,12 +74,15 @@ from .source_authoring import ( RegisteredSource, SourceCancellation, + SourceConfigValidator, SourceDeadlines, SourceDriver, SourceDriverBuilder, + SourceEmission, SourceFactory, SourceIterableFactory, SourceManifest, + SourcePrepareContext, SourceProvider, source, ) @@ -58,20 +91,48 @@ discover_sources, microphone_permission_observation, ) -from .streams import AudioStream, SignalStream +from .streams import AudioBatchReadResult, AudioStream, SignalStream __all__ = [ + "AudioBatchReadResult", "AudioConnectorHandler", "AudioInput", "AudioStream", "Capture", "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", "ConnectorDeadlines", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", "ConnectorDriver", "ConnectorDriverBuilder", "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", "ConnectorFactory", "ConnectorHandler", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", "ConnectorWorker", "ConnectorWorkerBuilder", "ControlClient", @@ -83,12 +144,15 @@ "ExtensionPortDirection", "NativeExtensionLibrary", "NativeExtensionRegistration", + "OperatorConfigValidator", "OperatorDeadlines", + "OperatorEmission", "OperatorFactory", "OperatorHandler", "OperatorManifest", "OperatorNode", "OperatorNodeBuilder", + "OperatorPrepareContext", "OperatorProvider", "PcmSource", "RegisteredConnector", @@ -101,12 +165,15 @@ "SidecarStream", "SignalStream", "SourceCancellation", + "SourceConfigValidator", "SourceDeadlines", "SourceDriver", "SourceDriverBuilder", + "SourceEmission", "SourceFactory", "SourceIterableFactory", "SourceManifest", + "SourcePrepareContext", "SourceProvider", "application_capture_available", "capture", diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py index d5929f1..d8cf802 100644 --- a/python/pocketstation/aio/audio_input.py +++ b/python/pocketstation/aio/audio_input.py @@ -68,9 +68,12 @@ async def write( timeout_s: float = 1.0, ) -> None: """Wait finitely for one native buffer without growing a Python queue.""" + if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)): + raise TypeError("timeout_s must be a number") if not 0 <= timeout_s <= 60: raise ValueError("timeout_s must be between 0 and 60") - deadline = monotonic() + timeout_s + deadline = monotonic() + float(timeout_s) + wait_s = 0.000_25 while True: try: await self.try_write(samples, discontinuity=discontinuity) @@ -79,7 +82,8 @@ async def write( remaining = deadline - monotonic() if remaining <= 0: raise - await asyncio.sleep(min(0.001, remaining)) + await asyncio.sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) __all__ = ["AudioInput", "PcmSource"] diff --git a/python/pocketstation/aio/connector.py b/python/pocketstation/aio/connector.py index 15eb113..78e8cc9 100644 --- a/python/pocketstation/aio/connector.py +++ b/python/pocketstation/aio/connector.py @@ -16,19 +16,31 @@ ) from ..connector import ( ConnectorBatchOutcome, + ConnectorCapability, + ConnectorConfigurationConstraint, + ConnectorConfigurationField, ConnectorConfigurationInput, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, ConnectorConfigurationValue, + ConnectorConfigurationValueKind, ConnectorContext, ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, ConnectorError, + ConnectorErrorSnapshot, ConnectorErrorStage, + ConnectorHealth, ConnectorInputDescriptor, ConnectorItem, ConnectorManifest, ConnectorObservations, ConnectorPreparationGroup, + ConnectorRecovery, + ConnectorRequirement, ConnectorRetryability, ConnectorRuntimeObservations, + ConnectorServiceStatus, ConnectorShutdownMode, ) from ..connector import ( @@ -561,12 +573,39 @@ def _wait_for_provider( __all__ = [ "AudioConnectorHandler", "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", "ConnectorDeadlines", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", "ConnectorDriver", "ConnectorDriverBuilder", "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", "ConnectorFactory", "ConnectorHandler", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", "ConnectorWorker", "ConnectorWorkerBuilder", "RegisteredConnector", diff --git a/python/pocketstation/aio/operator_authoring.py b/python/pocketstation/aio/operator_authoring.py index 99efc2e..2511d85 100644 --- a/python/pocketstation/aio/operator_authoring.py +++ b/python/pocketstation/aio/operator_authoring.py @@ -49,7 +49,7 @@ async def prepare(self, context: OperatorPrepareContext) -> None: """Observe compiled port and edge contracts before processing.""" async def process( - self, input_port: str, envelope: SignalEnvelope + self, input_port: str, envelope: SignalEnvelope[object] ) -> Sequence[OperatorEmission]: raise NotImplementedError @@ -65,8 +65,6 @@ async def close(self) -> None: @runtime_checkable class OperatorFactory(Protocol): - def validate_config(self, configuration: Mapping[str, str]) -> None: ... - async def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... @@ -74,7 +72,7 @@ async def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... [Mapping[str, str]], Coroutine[Any, Any, OperatorNode] ] OperatorHandler: TypeAlias = Callable[ - [str, SignalEnvelope], Coroutine[Any, Any, Sequence[OperatorEmission]] + [str, SignalEnvelope[object]], Coroutine[Any, Any, Sequence[OperatorEmission]] ] @@ -85,7 +83,7 @@ def __init__(self, handler: OperatorHandler) -> None: self._handler = handler async def process( - self, input_port: str, envelope: SignalEnvelope + self, input_port: str, envelope: SignalEnvelope[object] ) -> Sequence[OperatorEmission]: return await self._handler(input_port, envelope) @@ -130,7 +128,7 @@ def prepare(self, context: OperatorPrepareContext) -> None: ) def process( - self, input_port: str, envelope: SignalEnvelope + self, input_port: str, envelope: SignalEnvelope[object] ) -> Sequence[OperatorEmission]: return _wait_for_operator( self._loop, @@ -289,12 +287,15 @@ def _wait_for_operator( __all__ = [ + "OperatorConfigValidator", "OperatorDeadlines", + "OperatorEmission", "OperatorFactory", "OperatorHandler", "OperatorManifest", "OperatorNode", "OperatorNodeBuilder", + "OperatorPrepareContext", "OperatorProvider", "RegisteredOperator", "operator", diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index 457f24a..8993c22 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -6,7 +6,7 @@ from collections.abc import AsyncIterator, Callable from pathlib import Path from types import TracebackType -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, TypeVar, cast from .._native import ( AudioBatch, @@ -18,19 +18,24 @@ from .._native import ( Session as _NativeSession, ) +from .._native import _RegisteredConnector as _NativeRegisteredConnector from ..audio_input import AudioInputConfig from ..audio_input import PcmSource as SyncPcmSource from ..connector import Connector as SyncConnector +from ..connector import ConnectorConfigurationInput from ..connector import RegisteredConnector as SyncRegisteredConnector from ..errors import PocketStationError, _native_call, _normalize_native_error from ..extensions import NativeExtensionLibrary from ..graph import ( + EdgeContract, Endpoint, Stem, _GraphSessionDeclarations, ) +from ..identity import RuntimeSessionId from ..observations import ( SessionEvent, + SessionLifecycleState, SessionMetrics, SessionTraceConfiguration, StopResult, @@ -65,6 +70,7 @@ from .relay import RelaySession _Result = TypeVar("_Result") +_PayloadT = TypeVar("_PayloadT") _BACKGROUND_TASKS: set[asyncio.Task[None]] = set() @@ -85,16 +91,24 @@ def __init__(self, native: _NativeRunningSession) -> None: wait_event=self._wait_event_native, is_closed=lambda: self.is_stopped, ) - self._signals: dict[int, SignalStream] = {} + self._signals: dict[int, SignalStream[object]] = {} self._sidecars: dict[int, SidecarConnection] = {} @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property def is_stopped(self) -> bool: - return self._stop_result is not None + return self.state in { + SessionLifecycleState.STOPPED, + SessionLifecycleState.FAILED, + } + + @property + def state(self) -> SessionLifecycleState: + """Return the native binding owner's authoritative lifecycle state.""" + return SessionLifecycleState(self._native.lifecycle_state) @property def stop_result(self) -> StopResult | None: @@ -110,7 +124,9 @@ def events(self) -> EventStream: """The exclusive async lifecycle and failure event stream.""" return self._events - def signals(self, subscription: BusSubscription) -> SignalStream: + def signals( + self, subscription: BusSubscription[_PayloadT] + ) -> SignalStream[_PayloadT]: """Return the one exclusive asyncio stream for a subscription.""" stream = self._signals.get(subscription.id) if stream is None: @@ -130,7 +146,7 @@ def signals(self, subscription: BusSubscription) -> SignalStream: ), ) self._signals[subscription.id] = stream - return stream + return cast(SignalStream[_PayloadT], stream) def sidecar(self, handle: SidecarHandle) -> SidecarConnection: """Return the Session-owned asyncio connection for one child.""" @@ -274,6 +290,14 @@ def __init__( ) self._sample_rate_hz = sample_rate_hz self._channels = channels + self._connector_registrations: dict[ + int, + tuple[ + Connector | SyncConnector, + SyncConnector, + _NativeRegisteredConnector, + ], + ] = {} @classmethod def _from_native(cls, native: _NativeSession) -> Session: @@ -282,11 +306,12 @@ def _from_native(cls, native: _NativeSession) -> Session: session._native = native session._sample_rate_hz = 48_000 session._channels = 1 + session._connector_registrations = {} return session @property - def id(self) -> int: - return self._native.id + def id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.id) def capture(self, source: Source) -> Stem: """Declare one independent source-aware stem.""" @@ -339,6 +364,12 @@ def register_connector( self, connector: Connector | SyncConnector ) -> RegisteredConnector: """Register an asyncio or synchronous in-process Connector.""" + identity = id(connector) + cached = self._connector_registrations.get(identity) + if cached is not None and cached[0] is connector: + return RegisteredConnector( + SyncRegisteredConnector(self, cached[1], cached[2]) + ) bound = ( connector._bind(asyncio.get_running_loop()) if isinstance(connector, Connector) @@ -359,8 +390,19 @@ def register_connector( maximum_batch_items, ) ) + self._connector_registrations[identity] = (connector, bound, native) return RegisteredConnector(SyncRegisteredConnector(self, bound, native)) + def destination( + self, + connector: Connector | SyncConnector, + configuration: ConnectorConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + """Declare one Connector destination using an idempotent registration.""" + return self.register_connector(connector).declare(configuration, edge=edge) + def register_source( self, source: SourceProvider | SyncSourceProvider ) -> RegisteredSource: @@ -465,4 +507,9 @@ async def _native_async(operation: Callable[[], _Result]) -> _Result: raise _normalize_native_error(error) from error -__all__ = ["Connector", "RegisteredConnector", "RunningSession", "Session"] +__all__ = [ + "Connector", + "RegisteredConnector", + "RunningSession", + "Session", +] diff --git a/python/pocketstation/aio/source_authoring.py b/python/pocketstation/aio/source_authoring.py index 1acdc3f..6070b31 100644 --- a/python/pocketstation/aio/source_authoring.py +++ b/python/pocketstation/aio/source_authoring.py @@ -73,8 +73,6 @@ async def close(self) -> None: @runtime_checkable class SourceFactory(Protocol): - def validate_config(self, configuration: Mapping[str, str]) -> None: ... - async def create(self, configuration: Mapping[str, str]) -> SourceDriver: ... @@ -288,12 +286,15 @@ def _wait_for_source( __all__ = [ "RegisteredSource", "SourceCancellation", + "SourceConfigValidator", "SourceDeadlines", "SourceDriver", "SourceDriverBuilder", + "SourceEmission", "SourceFactory", "SourceIterableFactory", "SourceManifest", + "SourcePrepareContext", "SourceProvider", "source", ] diff --git a/python/pocketstation/aio/streams.py b/python/pocketstation/aio/streams.py index f0c9843..e34754d 100644 --- a/python/pocketstation/aio/streams.py +++ b/python/pocketstation/aio/streams.py @@ -4,6 +4,7 @@ from collections import deque from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Generic, TypeVar, cast from .._native import AudioBatch, AudioFrame, _SignalRead, _SignalSubscriptionMetrics from ..errors import StreamError @@ -16,11 +17,14 @@ ) from ..streams import ( _DEFAULT_ITERATION_TIMEOUT_SECONDS, + AudioBatchReadResult, _iteration_timeout_milliseconds, _ReaderState, _timeout_milliseconds, ) +_PayloadT = TypeVar("_PayloadT") + class AudioStream: """Frame-first asyncio view with one explicit reader and no Python queue.""" @@ -63,6 +67,17 @@ async def poll_batch(self) -> AudioBatch | None: finally: self._state.release(token) + async def poll(self) -> AudioBatchReadResult: + """Read immediately with distinct batch, empty, and closed outcomes.""" + token = self._state.claim("batches") + try: + if self.is_closed: + return STREAM_EOF + batch = await self._poll_batch() + return STREAM_EOF if batch is None and self.is_closed else batch + finally: + self._state.release(token) + async def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: """Advanced bounded batch read using the exclusive batch mode.""" timeout_ms = _timeout_milliseconds(timeout_s) @@ -72,6 +87,18 @@ async def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: finally: self._state.release(token) + async def read_result(self, *, timeout_s: float = 1.0) -> AudioBatchReadResult: + """Wait finitely with distinct batch, timeout, and closed outcomes.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("batches") + try: + if self.is_closed: + return STREAM_EOF + batch = await self._wait_batch(timeout_ms) + return STREAM_EOF if batch is None and self.is_closed else batch + finally: + self._state.release(token) + def __aiter__(self) -> AsyncIterator[AudioFrame]: return self.frames() @@ -129,7 +156,7 @@ async def _read_frame(self, timeout_ms: int) -> AudioFrame | None: return self._pending_frames.popleft() -class SignalStream: +class SignalStream(Generic[_PayloadT]): """Cancellation-safe asyncio view of one native ``BusSubscription``.""" def __init__( @@ -155,7 +182,7 @@ def reader_mode(self) -> str | None: def is_closed(self) -> bool: return self._closed - async def poll(self) -> SignalReadResult: + async def poll(self) -> SignalReadResult[_PayloadT]: token = self._state.claim("signal_read") try: return ( @@ -164,7 +191,7 @@ async def poll(self) -> SignalReadResult: finally: self._state.release(token) - async def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: + async def read(self, *, timeout_s: float = 1.0) -> SignalReadResult[_PayloadT]: timeout_ms = _timeout_milliseconds(timeout_s) token = self._state.claim("signal_read") try: @@ -176,17 +203,17 @@ async def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: finally: self._state.release(token) - def __aiter__(self) -> AsyncIterator[SignalEnvelope]: + def __aiter__(self) -> AsyncIterator[SignalEnvelope[_PayloadT]]: return self.iter_signals() def iter_signals( self, *, wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, - ) -> AsyncIterator[SignalEnvelope]: + ) -> AsyncIterator[SignalEnvelope[_PayloadT]]: timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) - async def iterate() -> AsyncIterator[SignalEnvelope]: + async def iterate() -> AsyncIterator[SignalEnvelope[_PayloadT]]: token = self._state.claim("signals") try: while not self._closed: @@ -210,14 +237,17 @@ async def metrics(self) -> SignalSubscriptionMetrics: """Snapshot capacity, payload-byte bounds, depth, delivery, and drops.""" return SignalSubscriptionMetrics._from_native(await self._signal_metrics()) - def _decode(self, result: _SignalRead) -> SignalReadResult: + def _decode(self, result: _SignalRead) -> SignalReadResult[_PayloadT]: if result.status == "item": if result.envelope is None: raise StreamError( "native signal read omitted its envelope", "stream.invalid_read", ) - return SignalEnvelope._from_native(result.envelope) + return cast( + SignalEnvelope[_PayloadT], + SignalEnvelope._from_native(result.envelope), + ) if result.status == "empty": return None if result.status == "closed": @@ -235,4 +265,4 @@ def _decode(self, result: _SignalRead) -> SignalReadResult: ) -__all__ = ["AudioStream", "SignalStream"] +__all__ = ["AudioBatchReadResult", "AudioStream", "SignalStream"] diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index 5f40854..2478520 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -3,11 +3,13 @@ from __future__ import annotations from dataclasses import dataclass +from time import monotonic, sleep from ._native import _AudioInput as _NativeAudioInput from ._native import _AudioInputObservations as _NativeAudioInputObservations -from .errors import _native_call +from .errors import AudioInputFullError, _native_call from .graph import SourceOutput +from .identity import SourceId, StreamId @dataclass(frozen=True, slots=True) @@ -68,12 +70,12 @@ def config(self) -> AudioInputConfig: return self._config @property - def source_id(self) -> int: - return self._native.source_id + def source_id(self) -> SourceId: + return SourceId(self._native.source_id) @property - def stream_id(self) -> int: - return self._native.stream_id + def stream_id(self) -> StreamId: + return StreamId(self._native.stream_id) @property def output(self) -> SourceOutput: @@ -98,9 +100,49 @@ def observations(self) -> AudioInputObservations: class AudioInput(PcmSource): """Intent-first input for audio already owned by the embedding application.""" - def write(self, samples: object, *, discontinuity: bool = False) -> None: - """Submit one complete frame without blocking or growing the queue.""" - self.try_write(samples, discontinuity=discontinuity) + def write( + self, + samples: object, + *, + discontinuity: bool = False, + timeout_s: float = 1.0, + ) -> None: + """Wait finitely for one preallocated native buffer. + + This convenience method never grows a Python queue. Advanced callers + that need an immediate ``Full`` outcome should use :meth:`try_write`. + """ + _write_with_timeout( + self, + samples, + discontinuity=discontinuity, + timeout_s=timeout_s, + ) + + +def _write_with_timeout( + source: PcmSource, + samples: object, + *, + discontinuity: bool, + timeout_s: float, +) -> None: + if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)): + raise TypeError("timeout_s must be a number") + if not 0 <= timeout_s <= 60: + raise ValueError("timeout_s must be between 0 and 60") + deadline = monotonic() + float(timeout_s) + wait_s = 0.000_25 + while True: + try: + source.try_write(samples, discontinuity=discontinuity) + return + except AudioInputFullError: + remaining = deadline - monotonic() + if remaining <= 0: + raise + sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) __all__ = [ diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py new file mode 100644 index 0000000..b6d9d3c --- /dev/null +++ b/python/pocketstation/compatibility.py @@ -0,0 +1,30 @@ +"""Machine-readable compatibility facts for the installed SDK build.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RuntimeCompatibility: + """Exact native components and interpreter contract embedded by the wheel.""" + + sdk_version: str + core_version: str + relay_connector_version: str + python_requires: str + python_abi: str + free_threaded_cpython: bool + + +RUNTIME_COMPATIBILITY = RuntimeCompatibility( + sdk_version="0.1.0", + core_version="1.1.1", + relay_connector_version="0.1.1", + python_requires=">=3.11", + python_abi="abi3-py311", + free_threaded_cpython=False, +) + + +__all__ = ["RUNTIME_COMPATIBILITY", "RuntimeCompatibility"] diff --git a/python/pocketstation/connector.py b/python/pocketstation/connector.py index 149eda3..8c3b0d9 100644 --- a/python/pocketstation/connector.py +++ b/python/pocketstation/connector.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field from enum import StrEnum from typing import Protocol, TypeAlias, cast, runtime_checkable @@ -115,8 +115,8 @@ def __init__( ) -> None: super().__init__(message, code) self.message = message - self.stage = stage.value - self.retryability = retryability.value + self.stage = stage + self.retryability = retryability @dataclass(frozen=True, slots=True) @@ -384,6 +384,13 @@ class ConnectorConfigurationSchema: ) def __post_init__(self) -> None: + duplicate = _first_duplicate(entry.name for entry in self.fields) + if duplicate is not None: + raise ConnectorError( + f"duplicate Connector configuration field {duplicate!r}", + code="connector.configuration.duplicate_field", + stage=ConnectorErrorStage.CONFIGURATION, + ) native = _native_call( lambda: _NativeConnectorConfigurationSchema( self.revision, [entry._native for entry in self.fields] @@ -394,7 +401,14 @@ def __post_init__(self) -> None: def configuration( self, values: ConnectorConfigurationInput = () ) -> _NativeConnectorConfiguration: - entries = values.items() if isinstance(values, Mapping) else values + entries = tuple(values.items() if isinstance(values, Mapping) else values) + duplicate = _first_duplicate(name for name, _value in entries) + if duplicate is not None: + raise ConnectorError( + f"duplicate Connector configuration value {duplicate!r}", + code="connector.configuration.duplicate_value", + stage=ConnectorErrorStage.CONFIGURATION, + ) by_name = {entry.name: entry for entry in self.fields} native_entries: list[tuple[str, _NativeConnectorConfigurationValue]] = [] for name, value in entries: @@ -530,7 +544,7 @@ def signal_wire_id(self) -> str: return self._native.signal_wire_id @property - def signal(self) -> SignalSpec: + def signal(self) -> SignalSpec[object]: return SignalSpec._from_native(self._native.signal) @property @@ -567,7 +581,7 @@ def audio(self) -> AudioFrame | None: return self._native.audio @property - def signal(self) -> SignalEnvelope | None: + def signal(self) -> SignalEnvelope[object] | None: native = self._native.signal return None if native is None else SignalEnvelope._from_native(native) @@ -1049,6 +1063,15 @@ def _coerce_configuration_value( ) +def _first_duplicate(values: Iterable[str]) -> str | None: + seen: set[str] = set() + for value in values: + if value in seen: + return value + seen.add(value) + return None + + def _default_edge(manifest: ConnectorManifest) -> EdgeContract: if len(manifest.inputs) == 1 and manifest.inputs[0].signal.is_audio: return EdgeContract.realtime_audio() diff --git a/python/pocketstation/errors.py b/python/pocketstation/errors.py index a27ff46..7a7d9b8 100644 --- a/python/pocketstation/errors.py +++ b/python/pocketstation/errors.py @@ -4,6 +4,7 @@ import re from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar _Result = TypeVar("_Result") @@ -18,6 +19,69 @@ def __init__(self, message: str, code: str = "error") -> None: self.code = code +class SessionError(PocketStationError): + """Base failure from Session declaration, startup, or runtime ownership.""" + + +class SessionDeclarationError(SessionError, ValueError): + """The Session draft, selector, route, or declaration is invalid.""" + + +@dataclass(frozen=True, slots=True) +class SessionCompileDiagnostic: + """Machine-readable location and contract facts for a compile failure.""" + + code: str + node_index: int | None = None + edge_index: int | None = None + operator_id: str | None = None + operator_instance_id: int | None = None + node_type_id: str | None = None + source_type_id: str | None = None + port_name: str | None = None + direction: str | None = None + expected: str | None = None + actual: str | None = None + + +class SessionStartError(SessionError): + """Transactional Session startup failed before delivery became active.""" + + def __init__( + self, + message: str, + code: str = "error", + *, + diagnostic: SessionCompileDiagnostic | None = None, + ) -> None: + super().__init__(message, code) + self.diagnostic = diagnostic + + +class SessionRuntimeError(SessionError): + """The running Session or its native owner became unavailable.""" + + +class CaptureError(SessionStartError): + """Capture authorization, availability, or backend startup failed.""" + + +class GraphError(PocketStationError, ValueError): + """A graph, signal, port, edge, or media contract is invalid.""" + + +class SourceError(PocketStationError): + """An externally authored Source contract or lifecycle failed.""" + + +class OperatorError(PocketStationError): + """An externally authored Operator contract or lifecycle failed.""" + + +class ConnectorRuntimeError(PocketStationError): + """Connector registration, declaration, or runtime ownership failed.""" + + class StreamError(PocketStationError): """Base failure for managed consumption of one native endpoint.""" @@ -129,21 +193,124 @@ def _normalize_native_error(error: Exception) -> PocketStationError: return AudioInputBufferError(detail, code) if code.startswith("audio_input."): return AudioInputError(detail, code) + if code.startswith("capture."): + return CaptureError(detail, code) + if code.startswith("graph."): + return GraphError(detail, code) + if code.startswith("source."): + return SourceError(detail, code) + if code.startswith("operator."): + return OperatorError(detail, code) + if code.startswith("connector."): + return ConnectorRuntimeError(detail, code) + if code.startswith("session.start_") or code in { + "session.host_setup_failed", + "session.unsupported_platform", + "session.declaration_invalid", + "session.compile_failed", + "session.runtime_prepare_failed", + "session.invalid_start_options", + "session.unsupported_source_topology", + "session.missing_endpoint_declaration", + "session.endpoint_prepare_failed", + "session.endpoint_start_failed", + "session.runtime_start_failed", + "session.missing_audio_receipt", + "session.missing_recording_configuration", + "session.missing_event_receiver", + "session.trace_recorder_setup_failed", + }: + return SessionStartError( + detail, + code, + diagnostic=_native_compile_diagnostic(error), + ) + if code.startswith("session."): + declaration_codes = { + "session.no_sources", + "session.no_routes", + "session.no_source_outputs", + "session.invalid_selector", + "session.invalid_endpoint", + "session.invalid_operator", + "session.invalid_route", + "session.foreign_endpoint", + "session.draft_frozen", + "session.id_exhausted", + "session.unsupported_version", + "session.unknown_endpoint", + "session.unknown_stem", + "session.unknown_source", + "session.unknown_operator_instance", + "session.operator_has_no_destination", + } + if code in declaration_codes: + return SessionDeclarationError(detail, code) + return SessionRuntimeError(detail, code) return PocketStationError(detail, code) +def _native_optional_string(error: Exception, name: str) -> str | None: + value = getattr(error, name, None) + return value if isinstance(value, str) else None + + +def _native_optional_integer(error: Exception, name: str) -> int | None: + value = getattr(error, name, None) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _native_compile_diagnostic( + error: Exception, +) -> SessionCompileDiagnostic | None: + code = _native_optional_string(error, "_pocketstation_compile_code") + if code is None: + return None + return SessionCompileDiagnostic( + code=code, + node_index=_native_optional_integer(error, "_pocketstation_compile_node_index"), + edge_index=_native_optional_integer(error, "_pocketstation_compile_edge_index"), + operator_id=_native_optional_string( + error, "_pocketstation_compile_operator_id" + ), + operator_instance_id=_native_optional_integer( + error, "_pocketstation_compile_operator_instance_id" + ), + node_type_id=_native_optional_string( + error, "_pocketstation_compile_node_type_id" + ), + source_type_id=_native_optional_string( + error, "_pocketstation_compile_source_type_id" + ), + port_name=_native_optional_string(error, "_pocketstation_compile_port_name"), + direction=_native_optional_string(error, "_pocketstation_compile_direction"), + expected=_native_optional_string(error, "_pocketstation_compile_expected"), + actual=_native_optional_string(error, "_pocketstation_compile_actual"), + ) + + __all__ = [ "AudioInputBufferError", "AudioInputCancelledError", "AudioInputClosedError", "AudioInputError", "AudioInputFullError", + "CaptureError", + "ConnectorRuntimeError", "ExtensionError", + "GraphError", + "OperatorError", "PocketStationError", + "SessionCompileDiagnostic", + "SessionDeclarationError", + "SessionError", + "SessionRuntimeError", + "SessionStartError", "SidecarBackpressureError", "SidecarError", "SidecarProtocolError", "SidecarTimeoutError", + "SourceError", "StreamError", "StreamInUseError", "StreamModeError", diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index a55bdf0..04abcaa 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -5,7 +5,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from enum import StrEnum -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar, cast from ._native import DerivedStream as _NativeDerivedStream from ._native import Endpoint as _NativeEndpoint @@ -21,10 +21,24 @@ from ._native import _PortSpec as _NativePortSpec from ._native import _SignalSpec as _NativeSignalSpec from .errors import _native_call +from .identity import ( + ConnectorId, + EndpointId, + OperatorInstanceId, + RouteId, + RuntimeSessionId, + SourceId, + SourceInstanceId, + StemId, + StreamId, +) if TYPE_CHECKING: from .relay import RelayPublisher, RelayRoute - from .signal import BusSubscription + from .signal import BusSubscription, SignalAudioPayload + +_PayloadT = TypeVar("_PayloadT") +_PayloadT_co = TypeVar("_PayloadT_co", covariant=True) class SignalKind(StrEnum): @@ -72,7 +86,7 @@ class BinaryFormat(StrEnum): @dataclass(frozen=True, slots=True) -class SignalSpec: +class SignalSpec(Generic[_PayloadT_co]): """Stable language-neutral signal identity, role, and schema contract.""" kind: SignalKind @@ -95,7 +109,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "_native", native) @classmethod - def _from_native(cls, native: _NativeSignalSpec) -> SignalSpec: + def _from_native(cls, native: _NativeSignalSpec) -> SignalSpec[object]: kind = SignalKind(native.kind) format_value: SignalFormat | None = None if native.format is not None: @@ -120,12 +134,19 @@ def _from_native(cls, native: _NativeSignalSpec) -> SignalSpec: ) @classmethod - def any(cls, *, role: str | None = None, schema: str | None = None) -> SignalSpec: + def any( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec[object]: return cls(SignalKind.ANY, role=role, schema=schema) @classmethod - def audio(cls, *, role: str | None = None, schema: str | None = None) -> SignalSpec: - return cls(SignalKind.PCM_AUDIO, role=role, schema=schema) + def audio( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec[SignalAudioPayload]: + return cast( + "SignalSpec[SignalAudioPayload]", + cls(SignalKind.PCM_AUDIO, role=role, schema=schema), + ) @classmethod def encoded_audio( @@ -134,8 +155,11 @@ def encoded_audio( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec: - return cls(SignalKind.ENCODED_AUDIO, codec, role=role, schema=schema) + ) -> SignalSpec[bytes]: + return cast( + SignalSpec[bytes], + cls(SignalKind.ENCODED_AUDIO, codec, role=role, schema=schema), + ) @classmethod def text( @@ -144,8 +168,10 @@ def text( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec: - return cls(SignalKind.TEXT, format, role=role, schema=schema) + ) -> SignalSpec[str]: + return cast( + SignalSpec[str], cls(SignalKind.TEXT, format, role=role, schema=schema) + ) @classmethod def event( @@ -154,19 +180,19 @@ def event( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec: + ) -> SignalSpec[object]: return cls(SignalKind.EVENT, format, role=role, schema=schema) @classmethod def metrics( cls, *, role: str | None = None, schema: str | None = None - ) -> SignalSpec: + ) -> SignalSpec[object]: return cls(SignalKind.METRICS, role=role, schema=schema) @classmethod def control( cls, *, role: str | None = None, schema: str | None = None - ) -> SignalSpec: + ) -> SignalSpec[object]: return cls(SignalKind.CONTROL, role=role, schema=schema) @classmethod @@ -176,8 +202,11 @@ def binary( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec: - return cls(SignalKind.BINARY, format, role=role, schema=schema) + ) -> SignalSpec[bytes]: + return cast( + SignalSpec[bytes], + cls(SignalKind.BINARY, format, role=role, schema=schema), + ) @classmethod def custom( @@ -186,7 +215,7 @@ def custom( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec: + ) -> SignalSpec[object]: return cls( SignalKind.CUSTOM, custom_id=signal_id, @@ -202,7 +231,7 @@ def wire_id(self) -> str: def is_audio(self) -> bool: return self._native.is_audio - def is_compatible_with(self, other: SignalSpec) -> bool: + def is_compatible_with(self, other: SignalSpec[object]) -> bool: return self._native.is_compatible_with(other._native) @@ -222,6 +251,14 @@ class ChannelLayout(StrEnum): STEREO = "stereo" ANY = "any" + @property + def channel_count(self) -> int | None: + if self is ChannelLayout.MONO: + return 1 + if self is ChannelLayout.STEREO: + return 2 + return None + class SampleFormat(StrEnum): F32_INTERLEAVED = "f32-interleaved" @@ -299,7 +336,7 @@ def any(cls) -> MediaCaps: return cls(MediaKind.ANY) @classmethod - def for_signal(cls, signal: SignalSpec) -> MediaCaps: + def for_signal(cls, signal: SignalSpec[object]) -> MediaCaps: """Select the canonical wildcard media contract for a signal.""" if signal.kind is SignalKind.PCM_AUDIO: return cls.audio() @@ -325,7 +362,12 @@ def for_signal(cls, signal: SignalSpec) -> MediaCaps: def is_compatible_with(self, other: MediaCaps) -> bool: return self._native.is_compatible_with(other._native) - def supports_signal(self, signal: SignalSpec) -> bool: + def negotiate(self, other: MediaCaps) -> MediaCaps | None: + """Return Core's narrow compatible media contract, if one exists.""" + native = self._native.negotiate(other._native) + return None if native is None else type(self)._from_native(native) + + def supports_signal(self, signal: SignalSpec[object]) -> bool: return self._native.supports_signal(signal._native) @@ -345,7 +387,7 @@ class PortSpec: name: str direction: PortDirection - signal: SignalSpec + signal: SignalSpec[object] media: MediaCaps multiplicity: Multiplicity = Multiplicity.ONE required: bool = True @@ -368,7 +410,7 @@ def __post_init__(self) -> None: def input( cls, name: str, - signal: SignalSpec, + signal: SignalSpec[object], *, media: MediaCaps | None = None, multiplicity: Multiplicity = Multiplicity.ONE, @@ -388,7 +430,7 @@ def input( def output( cls, name: str, - signal: SignalSpec, + signal: SignalSpec[object], *, media: MediaCaps | None = None, multiplicity: Multiplicity = Multiplicity.ONE, @@ -412,6 +454,10 @@ class ClockDomain(StrEnum): INHERITED = "inherited" WALLCLOCK = "wallclock" + @property + def is_realtime(self) -> bool: + return self in {ClockDomain.CAPTURE, ClockDomain.PLAYBACK} + class BackpressurePolicy(StrEnum): DROP_NEWEST = "drop-newest" @@ -443,6 +489,14 @@ class EdgeObservabilityLevel(StrEnum): COUNTERS = "counters" FULL = "full" + @property + def rank(self) -> int: + return { + EdgeObservabilityLevel.OFF: 0, + EdgeObservabilityLevel.COUNTERS: 1, + EdgeObservabilityLevel.FULL: 2, + }[self] + @dataclass(frozen=True, slots=True) class EdgeContract: @@ -524,7 +578,12 @@ def with_max_payload_bytes(self, maximum_bytes: int) -> EdgeContract: def _configuration_items(values: ConfigurationInput) -> tuple[tuple[str, str], ...]: - entries = values.items() if isinstance(values, Mapping) else values + entries = tuple(values.items() if isinstance(values, Mapping) else values) + seen: set[str] = set() + for key, _value in entries: + if key in seen: + raise ValueError(f"duplicate configuration key {key!r}") + seen.add(key) return tuple(sorted(entries)) @@ -609,16 +668,17 @@ def __init__(self, native: _NativeEndpoint) -> None: self._native = native @property - def id(self) -> int: - return self._native.id + def id(self) -> EndpointId: + return EndpointId(self._native.id) @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property - def connector_id(self) -> int | None: - return self._native.connector_id + def connector_id(self) -> ConnectorId | None: + value = self._native.connector_id + return None if value is None else ConnectorId(value) class OperatorInput: @@ -643,12 +703,12 @@ def __init__(self, native: _NativeOperatorInstance) -> None: self._native = native @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property - def instance_id(self) -> int: - return self._native.instance_id + def instance_id(self) -> OperatorInstanceId: + return OperatorInstanceId(self._native.instance_id) def input(self, port_name: str) -> OperatorInput: return _native_call(lambda: OperatorInput(self._native.input(port_name))) @@ -662,13 +722,15 @@ class _RoutableStream: _native: _NativeStem | _NativeDerivedStream | _NativeSourceOutput - def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> int: + def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> RouteId: if input_port is None: - return _native_call(lambda: self._native.send(endpoint._native)) - return _native_call(lambda: self._native.send_to(endpoint._native, input_port)) + return RouteId(_native_call(lambda: self._native.send(endpoint._native))) + return RouteId( + _native_call(lambda: self._native.send_to(endpoint._native, input_port)) + ) - def connect(self, input: OperatorInput) -> int: - return _native_call(lambda: self._native.connect(input._native)) + def connect(self, input: OperatorInput) -> RouteId: + return RouteId(_native_call(lambda: self._native.connect(input._native))) def through( self, @@ -699,12 +761,12 @@ def __init__(self, native: _NativeStem) -> None: self._native = native @property - def id(self) -> int: - return self._native.id + def id(self) -> StemId: + return StemId(self._native.id) @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) def record(self, stem_name: str) -> Endpoint: return _native_call(lambda: Endpoint(self._native.record(stem_name))) @@ -716,7 +778,7 @@ def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: if not isinstance(publisher, RelayPublisher): raise TypeError("publisher must be a RelayPublisher") route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) - return RelayRoute(bus_id=bus_id, route_id=route_id) + return RelayRoute(bus_id=bus_id, route_id=RouteId(route_id)) class DerivedStream(_RoutableStream): @@ -729,12 +791,12 @@ def __init__(self, native: _NativeDerivedStream) -> None: self._native = native @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property - def operator_instance_id(self) -> int: - return self._native.operator_instance_id + def operator_instance_id(self) -> OperatorInstanceId: + return OperatorInstanceId(self._native.operator_instance_id) @property def output_port(self) -> str | None: @@ -757,16 +819,16 @@ def __init__(self, native: _NativeSourceInstance) -> None: self._native = native @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property - def instance_id(self) -> int: - return self._native.instance_id + def instance_id(self) -> SourceInstanceId: + return SourceInstanceId(self._native.instance_id) @property - def source_id(self) -> int: - return self._native.source_id + def source_id(self) -> SourceId: + return SourceId(self._native.source_id) def output(self, port_name: str) -> SourceOutput: return _native_call(lambda: SourceOutput(self._native.output(port_name))) @@ -782,20 +844,20 @@ def __init__(self, native: _NativeSourceOutput) -> None: self._native = native @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property - def source_instance_id(self) -> int: - return self._native.source_instance_id + def source_instance_id(self) -> SourceInstanceId: + return SourceInstanceId(self._native.source_instance_id) @property - def source_id(self) -> int: - return self._native.source_id + def source_id(self) -> SourceId: + return SourceId(self._native.source_id) @property - def stream_id(self) -> int: - return self._native.stream_id + def stream_id(self) -> StreamId: + return StreamId(self._native.stream_id) @property def output_port(self) -> str: @@ -811,7 +873,7 @@ def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: if not isinstance(publisher, RelayPublisher): raise TypeError("publisher must be a RelayPublisher") route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) - return RelayRoute(bus_id=bus_id, route_id=route_id) + return RelayRoute(bus_id=bus_id, route_id=RouteId(route_id)) class _GraphSessionDeclarations: @@ -820,9 +882,9 @@ class _GraphSessionDeclarations: _native: _NativeSession @property - def id(self) -> int: + def id(self) -> RuntimeSessionId: """Stable identity allocated by the canonical Rust Session.""" - return self._native.id + return RuntimeSessionId(self._native.id) def source( self, @@ -871,9 +933,9 @@ def subscribe( self, stream: DerivedStream | SourceOutput, *, - signal: SignalSpec, + signal: SignalSpec[_PayloadT], edge: EdgeContract | None = None, - ) -> BusSubscription: + ) -> BusSubscription[_PayloadT]: """Declare one bounded, exclusive typed-signal subscription. The subscription is a real endpoint in the canonical Rust Session. @@ -929,7 +991,7 @@ def _media_from_native(native: _NativeMediaCaps) -> MediaCaps: return MediaCaps(kind) -def _media_for_signal(signal: SignalSpec) -> MediaCaps: +def _media_for_signal(signal: SignalSpec[object]) -> MediaCaps: if signal.kind is SignalKind.PCM_AUDIO: return MediaCaps.audio() if signal.kind is SignalKind.ENCODED_AUDIO: diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py index 03bfa38..5c8ae19 100644 --- a/python/pocketstation/observations.py +++ b/python/pocketstation/observations.py @@ -16,6 +16,7 @@ from ._native import SessionEvent as _NativeSessionEvent from ._native import SessionMetrics as _NativeSessionMetrics from ._native import SessionTrace as _NativeSessionTrace +from ._native import SessionTraceRecord as _NativeSessionTraceRecord from ._native import SessionTraceRecorderOutcome as _NativeTraceRecorderOutcome from ._native import StopResult as _NativeStopResult from ._native import _AudioReentryMetrics as _NativeAudioReentryMetrics @@ -30,6 +31,13 @@ from ._native import _SessionTraceValidation as _NativeTraceValidation from ._native import _TypedEdgeMetrics as _NativeTypedEdgeMetrics from .errors import PocketStationError, _native_call +from .identity import ( + EndpointId, + OperatorInstanceId, + RouteId, + SidecarId, + StemId, +) from .sidecar import SidecarSnapshot from .sources import SourceRuntimeEvent from .streams import ( @@ -71,6 +79,14 @@ class SessionFailureKind(StrEnum): FINALIZATION = "finalization" +class SessionComponentKind(StrEnum): + SOURCE = "source" + ENDPOINT = "endpoint" + OPERATOR = "operator" + SIDECAR = "sidecar" + RUNTIME = "runtime" + + class EndpointFailureStage(StrEnum): PREPARE = "prepare" CANCEL_PREPARATION = "cancel-preparation" @@ -142,6 +158,18 @@ class RouteLatencyUnit(StrEnum): FailureStage = EndpointFailureStage | SessionRollbackStage | SessionFinalizationStage +@dataclass(frozen=True, slots=True) +class SessionComponent: + """Stable typed owner of a Session rollback or finalization failure.""" + + kind: SessionComponentKind + stem_id: StemId | None = None + route_id: RouteId | None = None + endpoint_id: EndpointId | None = None + operator_instance_id: OperatorInstanceId | None = None + sidecar_id: SidecarId | None = None + + def _failure_stage(kind: SessionFailureKind, value: str | None) -> FailureStage | None: if value is None: return None @@ -164,19 +192,47 @@ class SessionFailure: error_class: str | None error_code: str | None retryability: EndpointFailureRetryability | None - component: str | None + component: SessionComponent | None + component_diagnostic: str | None message: str | None - stem_id: int | None - route_id: int | None - endpoint_id: int | None - operator_instance_id: int | None - sidecar_id: int | None + stem_id: StemId | None + route_id: RouteId | None + endpoint_id: EndpointId | None + operator_instance_id: OperatorInstanceId | None + sidecar_id: SidecarId | None source: SourceRuntimeEvent | None @classmethod def _from_native(cls, failure: _NativeSessionFailure) -> SessionFailure: kind = SessionFailureKind(failure.kind) retryability = getattr(failure, "retryability", None) + component_kind = getattr(failure, "component_kind", None) + component = ( + None + if component_kind is None + else SessionComponent( + kind=SessionComponentKind(component_kind), + stem_id=None if failure.stem_id is None else StemId(failure.stem_id), + route_id=( + None if failure.route_id is None else RouteId(failure.route_id) + ), + endpoint_id=( + None + if failure.endpoint_id is None + else EndpointId(failure.endpoint_id) + ), + operator_instance_id=( + None + if failure.operator_instance_id is None + else OperatorInstanceId(failure.operator_instance_id) + ), + sidecar_id=( + None + if failure.sidecar_id is None + else SidecarId(failure.sidecar_id) + ), + ) + ) return cls( kind=kind, stage=_failure_stage(kind, failure.stage), @@ -188,13 +244,22 @@ def _from_native(cls, failure: _NativeSessionFailure) -> SessionFailure: if retryability is None else EndpointFailureRetryability(retryability) ), - component=failure.component, + component=component, + component_diagnostic=failure.component, message=failure.message, - stem_id=failure.stem_id, - route_id=failure.route_id, - endpoint_id=failure.endpoint_id, - operator_instance_id=failure.operator_instance_id, - sidecar_id=failure.sidecar_id, + stem_id=None if failure.stem_id is None else StemId(failure.stem_id), + route_id=None if failure.route_id is None else RouteId(failure.route_id), + endpoint_id=( + None if failure.endpoint_id is None else EndpointId(failure.endpoint_id) + ), + operator_instance_id=( + None + if failure.operator_instance_id is None + else OperatorInstanceId(failure.operator_instance_id) + ), + sidecar_id=( + None if failure.sidecar_id is None else SidecarId(failure.sidecar_id) + ), source=SourceRuntimeEvent._from_native(cast(_NativeSessionEvent, failure)), ) @@ -796,10 +861,14 @@ def _from_native(cls, value: _NativeRecordingStemOutcome) -> RecordingStemOutcom @dataclass(frozen=True, slots=True) class RecordingOutcome: + session_id: int + group_id: str state: RecordingState completed_stems: int failed_stems: int session_directory: Path + manifest_path: Path + manifest_schema_version: int error_code: str | None stems: tuple[RecordingStemOutcome, ...] @@ -810,10 +879,14 @@ def complete(self) -> bool: @classmethod def _from_native(cls, value: _NativeRecordingOutcome) -> RecordingOutcome: result = cls( + session_id=value.session_id, + group_id=value.group_id, state=RecordingState(value.state), completed_stems=value.completed_stems, failed_stems=value.failed_stems, session_directory=Path(value.session_directory), + manifest_path=Path(value.manifest_path), + manifest_schema_version=value.manifest_schema_version, error_code=value.error_code, stems=tuple( RecordingStemOutcome._from_native(stem) for stem in value.stems() @@ -913,6 +986,80 @@ def _from_native(cls, value: _NativeTraceValidation) -> SessionTraceValidation: ) +class SessionTraceRecordType(StrEnum): + LIFECYCLE = "lifecycle" + SOURCE_FAILURE = "source-failure" + ENDPOINT_FAILURE = "endpoint-failure" + ROLLBACK_FAILURE = "rollback-failure" + FINALIZATION_FAILURE = "finalization-failure" + TERMINAL = "terminal" + + +@dataclass(frozen=True, slots=True) +class SessionTraceRecord: + """One typed record from a finite native Session trace.""" + + sequence_index: int + observed_at_ns: int + session_id: int + kind: SessionTraceRecordType + lifecycle_state: SessionLifecycleState | None + terminal_state: SessionTerminalState | None + stem_id: StemId | None + route_id: RouteId | None + endpoint_id: EndpointId | None + endpoint_stage: EndpointFailureStage | None + rollback_stage: SessionRollbackStage | None + finalization_stage: SessionFinalizationStage | None + source_failures_total: int | None + endpoint_failures_total: int | None + rollback_failures_total: int | None + finalization_failures_total: int | None + + @classmethod + def _from_native(cls, value: _NativeSessionTraceRecord) -> SessionTraceRecord: + return cls( + sequence_index=value.sequence_index, + observed_at_ns=value.observed_at_ns, + session_id=value.session_id, + kind=SessionTraceRecordType(value.kind), + lifecycle_state=( + None + if value.lifecycle_state is None + else SessionLifecycleState(value.lifecycle_state) + ), + terminal_state=( + None + if value.terminal_state is None + else SessionTerminalState(value.terminal_state) + ), + stem_id=None if value.stem_id is None else StemId(value.stem_id), + route_id=None if value.route_id is None else RouteId(value.route_id), + endpoint_id=( + None if value.endpoint_id is None else EndpointId(value.endpoint_id) + ), + endpoint_stage=( + None + if value.endpoint_stage is None + else EndpointFailureStage(value.endpoint_stage) + ), + rollback_stage=( + None + if value.rollback_stage is None + else SessionRollbackStage(value.rollback_stage) + ), + finalization_stage=( + None + if value.finalization_stage is None + else SessionFinalizationStage(value.finalization_stage) + ), + source_failures_total=value.source_failures_total, + endpoint_failures_total=value.endpoint_failures_total, + rollback_failures_total=value.rollback_failures_total, + finalization_failures_total=value.finalization_failures_total, + ) + + class SessionTrace: """Validated reader for one finite native Session trace artifact.""" @@ -935,6 +1082,12 @@ def records_total(self) -> int: def outcome(self) -> SessionTraceRecorderOutcome: return SessionTraceRecorderOutcome._from_native(self._native.outcome) + @property + def records(self) -> tuple[SessionTraceRecord, ...]: + return tuple( + SessionTraceRecord._from_native(record) for record in self._native.records() + ) + def validate(self) -> SessionTraceValidation: return SessionTraceValidation._from_native(_native_call(self._native.validate)) @@ -1091,6 +1244,8 @@ def iterate() -> Iterator[SessionEvent]: "RouteLatencyUnit", "RouteMetrics", "RouteObservationInterval", + "SessionComponent", + "SessionComponentKind", "SessionEvent", "SessionEventType", "SessionFailure", @@ -1102,6 +1257,8 @@ def iterate() -> Iterator[SessionEvent]: "SessionTerminalState", "SessionTrace", "SessionTraceConfiguration", + "SessionTraceRecord", + "SessionTraceRecordType", "SessionTraceRecorderOutcome", "SessionTraceValidation", "SourceMetrics", diff --git a/python/pocketstation/operator_authoring.py b/python/pocketstation/operator_authoring.py index 35d78f8..07bf7c8 100644 --- a/python/pocketstation/operator_authoring.py +++ b/python/pocketstation/operator_authoring.py @@ -73,7 +73,7 @@ class OperatorPortContext: port_name: str direction: PortDirection capacity_signals: int - signal: SignalSpec + signal: SignalSpec[object] media: MediaCaps edge: EdgeContract @@ -120,13 +120,13 @@ def __init__(self, native: _NativeOperatorEmission) -> None: self._native = native @classmethod - def text(cls, payload: str, *, signal: SignalSpec) -> OperatorEmission: + def text(cls, payload: str, *, signal: SignalSpec[str]) -> OperatorEmission: return cls( _native_call(lambda: _NativeOperatorEmission.text(payload, signal._native)) ) @classmethod - def bytes(cls, payload: bytes, *, signal: SignalSpec) -> OperatorEmission: + def bytes(cls, payload: bytes, *, signal: SignalSpec[bytes]) -> OperatorEmission: return cls( _native_call(lambda: _NativeOperatorEmission.bytes(payload, signal._native)) ) @@ -139,7 +139,7 @@ def prepare(self, context: OperatorPrepareContext) -> None: """Observe compiled port and edge contracts before processing.""" def process( - self, input_port: str, envelope: SignalEnvelope + self, input_port: str, envelope: SignalEnvelope[object] ) -> Sequence[OperatorEmission]: raise NotImplementedError @@ -155,12 +155,12 @@ def close(self) -> None: @runtime_checkable class OperatorFactory(Protocol): - def validate_config(self, configuration: Mapping[str, str]) -> None: ... - def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... -OperatorHandler: TypeAlias = Callable[[str, SignalEnvelope], Sequence[OperatorEmission]] +OperatorHandler: TypeAlias = Callable[ + [str, SignalEnvelope[object]], Sequence[OperatorEmission] +] OperatorConfigValidator: TypeAlias = Callable[[Mapping[str, str]], None] @@ -171,7 +171,7 @@ def __init__(self, handler: OperatorHandler) -> None: self._handler = handler def process( - self, input_port: str, envelope: SignalEnvelope + self, input_port: str, envelope: SignalEnvelope[object] ) -> Sequence[OperatorEmission]: return self._handler(input_port, envelope) @@ -253,7 +253,9 @@ def __init__(self, factory: OperatorFactory) -> None: self._factory = factory def validate_config(self, configuration: Mapping[str, str]) -> None: - self._factory.validate_config(configuration) + validator = getattr(self._factory, "validate_config", None) + if validator is not None: + validator(configuration) def create(self, configuration: Mapping[str, str]) -> _NativeNodeAdapter: node = self._factory.create(configuration) diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index 2155f40..566ffc8 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from pathlib import Path from types import TracebackType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar, cast from ._native import ( AudioBatch, @@ -17,21 +17,29 @@ from ._native import ( Session as _NativeSession, ) +from ._native import _RegisteredConnector as _NativeRegisteredConnector from .audio_input import AudioInput, AudioInputConfig, PcmSource -from .connector import Connector, RegisteredConnector +from .connector import ( + Connector, + ConnectorConfigurationInput, + RegisteredConnector, +) from .errors import PocketStationError, _native_call from .extensions import NativeExtensionLibrary from .graph import ( + EdgeContract, Endpoint, Stem, _GraphSessionDeclarations, ) +from .identity import RuntimeSessionId from .observations import ( EventStream, RecordingOutcome, RecordingStemOutcome, RouteMetrics, SessionEvent, + SessionLifecycleState, SessionMetrics, SessionTraceConfiguration, StopResult, @@ -52,6 +60,8 @@ if TYPE_CHECKING: from .relay import RelayPublisher, RelaySession +_PayloadT = TypeVar("_PayloadT") + class RunningSession: """Running native Session with bounded synchronous batch delivery.""" @@ -69,16 +79,24 @@ def __init__(self, native: _NativeRunningSession) -> None: wait_event=self._wait_event_native, is_closed=lambda: self.is_stopped, ) - self._signals: dict[int, SignalStream] = {} + self._signals: dict[int, SignalStream[object]] = {} self._sidecars: dict[int, SidecarConnection] = {} @property - def session_id(self) -> int: - return self._native.session_id + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) @property def is_stopped(self) -> bool: - return self._stop_result is not None + return self.state in { + SessionLifecycleState.STOPPED, + SessionLifecycleState.FAILED, + } + + @property + def state(self) -> SessionLifecycleState: + """Return the native binding owner's authoritative lifecycle state.""" + return SessionLifecycleState(self._native.lifecycle_state) @property def stop_result(self) -> StopResult | None: @@ -94,7 +112,9 @@ def events(self) -> EventStream: """The exclusive typed lifecycle and failure event stream.""" return self._events - def signals(self, subscription: BusSubscription) -> SignalStream: + def signals( + self, subscription: BusSubscription[_PayloadT] + ) -> SignalStream[_PayloadT]: """Return the one exclusive stream for a declared subscription.""" stream = self._signals.get(subscription.id) if stream is None: @@ -114,7 +134,7 @@ def signals(self, subscription: BusSubscription) -> SignalStream: ), ) self._signals[subscription.id] = stream - return stream + return cast(SignalStream[_PayloadT], stream) def sidecar(self, handle: SidecarHandle) -> SidecarConnection: """Return the Session-owned bounded connection for one child.""" @@ -252,6 +272,9 @@ def __init__( ) self._sample_rate_hz = sample_rate_hz self._channels = channels + self._connector_registrations: dict[ + int, tuple[Connector, _NativeRegisteredConnector] + ] = {} @classmethod def _from_native(cls, native: _NativeSession) -> Session: @@ -260,11 +283,12 @@ def _from_native(cls, native: _NativeSession) -> Session: session._native = native session._sample_rate_hz = 48_000 session._channels = 1 + session._connector_registrations = {} return session @property - def id(self) -> int: - return self._native.id + def id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.id) def capture(self, source: Source) -> Stem: """Declare one independent source-aware stem.""" @@ -317,6 +341,10 @@ def polled_audio(self) -> Endpoint: def register_connector(self, connector: Connector) -> RegisteredConnector: """Register one in-process Python Connector implementation.""" + identity = id(connector) + cached = self._connector_registrations.get(identity) + if cached is not None and cached[0] is connector: + return RegisteredConnector(self, connector, cached[1]) maximum_batch_items = connector.maximum_batch_items if maximum_batch_items is None: native = _native_call( @@ -332,8 +360,24 @@ def register_connector(self, connector: Connector) -> RegisteredConnector: maximum_batch_items, ) ) + self._connector_registrations[identity] = (connector, native) return RegisteredConnector(self, connector, native) + def destination( + self, + connector: Connector, + configuration: ConnectorConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + """Declare one Connector destination using an idempotent registration. + + This is the intent-first form for the common one-destination case. + :meth:`register_connector` remains available when one implementation + must declare several independently configured Endpoints. + """ + return self.register_connector(connector).declare(configuration, edge=edge) + def register_source(self, source: SourceProvider) -> RegisteredSource: """Register one Python-authored typed Source implementation.""" native = _native_call( diff --git a/python/pocketstation/signal.py b/python/pocketstation/signal.py index 2989c9e..f2c860d 100644 --- a/python/pocketstation/signal.py +++ b/python/pocketstation/signal.py @@ -3,9 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypeAlias +from typing import Generic, TypeAlias, TypeVar, cast from ._native import BusSubscription as _NativeBusSubscription +from ._native import ClockDomainDescriptor from ._native import _SignalAudioPayload as _NativeSignalAudioPayload from ._native import _SignalDerivation as _NativeSignalDerivation from ._native import _SignalEnvelope as _NativeSignalEnvelope @@ -13,6 +14,16 @@ from ._native import _SignalSubscriptionMetrics as _NativeSignalSubscriptionMetrics from ._native import _SignalTiming as _NativeSignalTiming from .graph import EdgeContract, SignalSpec +from .identity import ( + ClockDomainId, + ConnectorId, + RuntimeSessionId, + SourceId, + StreamId, +) + +_PayloadT = TypeVar("_PayloadT") +_PayloadT_co = TypeVar("_PayloadT_co", covariant=True) @dataclass(frozen=True, slots=True) @@ -38,10 +49,11 @@ def _from_native(cls, value: _NativeSignalTiming) -> SignalTiming: class SignalLineage: """Source and stream identity that survives graph and language boundaries.""" - session_id: int - stream_id: int - source_id: int - clock_id: int + session_id: RuntimeSessionId + stream_id: StreamId + source_id: SourceId + clock_id: ClockDomainId + clock: ClockDomainDescriptor sequence_number: int source_generation: int discontinuity_epoch: int @@ -50,10 +62,11 @@ class SignalLineage: @classmethod def _from_native(cls, value: _NativeSignalLineage) -> SignalLineage: return cls( - session_id=value.session_id, - stream_id=value.stream_id, - source_id=value.source_id, - clock_id=value.clock_id, + session_id=RuntimeSessionId(value.session_id), + stream_id=StreamId(value.stream_id), + source_id=SourceId(value.source_id), + clock_id=ClockDomainId(value.clock_id), + clock=value.clock, sequence_number=value.sequence_number, source_generation=value.source_generation, discontinuity_epoch=value.discontinuity_epoch, @@ -70,7 +83,7 @@ class SignalDerivation: operator_id: str operator_revision: int operator_generation: int - connector_id: int | None + connector_id: ConnectorId | None @classmethod def _from_native(cls, value: _NativeSignalDerivation) -> SignalDerivation: @@ -80,7 +93,9 @@ def _from_native(cls, value: _NativeSignalDerivation) -> SignalDerivation: operator_id=value.operator_id, operator_revision=value.operator_revision, operator_generation=value.operator_generation, - connector_id=value.connector_id, + connector_id=( + None if value.connector_id is None else ConnectorId(value.connector_id) + ), ) @@ -92,8 +107,8 @@ class SignalAudioPayload: sample_count: int sample_rate_hz: int channel_count: int - stream_id: int - source_id: int + stream_id: StreamId + source_id: SourceId sequence_number: int timestamp_ns: int @@ -104,8 +119,8 @@ def _from_native(cls, value: _NativeSignalAudioPayload) -> SignalAudioPayload: sample_count=value.sample_count, sample_rate_hz=value.sample_rate_hz, channel_count=value.channel_count, - stream_id=value.stream_id, - source_id=value.source_id, + stream_id=StreamId(value.stream_id), + source_id=SourceId(value.source_id), sequence_number=value.sequence_number, timestamp_ns=value.timestamp_ns, ) @@ -154,17 +169,19 @@ def _from_native( @dataclass(frozen=True, slots=True) -class SignalEnvelope: +class SignalEnvelope(Generic[_PayloadT_co]): """One owned payload with its exact signal, timing, lineage, and derivation.""" - signal: SignalSpec + signal: SignalSpec[_PayloadT_co] timing: SignalTiming lineage: SignalLineage | None derivation: SignalDerivation | None - payload: SignalPayload + payload: _PayloadT_co @classmethod - def _from_native(cls, value: _NativeSignalEnvelope) -> SignalEnvelope: + def _from_native( + cls, value: _NativeSignalEnvelope + ) -> SignalEnvelope[SignalPayload]: if value.payload_kind == "audio": if value.audio is None: raise RuntimeError("native audio signal omitted its payload") @@ -181,8 +198,10 @@ def _from_native(cls, value: _NativeSignalEnvelope) -> SignalEnvelope: raise RuntimeError( f"native signal has unknown payload kind {value.payload_kind!r}" ) - return cls( - signal=SignalSpec._from_native(value.signal), + return SignalEnvelope[SignalPayload]( + signal=cast( + SignalSpec[SignalPayload], SignalSpec._from_native(value.signal) + ), timing=SignalTiming._from_native(value.timing), lineage=( None @@ -198,7 +217,7 @@ def _from_native(cls, value: _NativeSignalEnvelope) -> SignalEnvelope: ) -class BusSubscription: +class BusSubscription(Generic[_PayloadT_co]): """Session-scoped receipt for one bounded typed ``AudioBus`` route.""" __slots__ = ("_native",) @@ -219,8 +238,10 @@ def route_id(self) -> int: return self._native.route_id @property - def signal(self) -> SignalSpec: - return SignalSpec._from_native(self._native.signal) + def signal(self) -> SignalSpec[_PayloadT_co]: + return cast( + SignalSpec[_PayloadT_co], SignalSpec._from_native(self._native.signal) + ) @property def edge(self) -> EdgeContract: @@ -237,7 +258,7 @@ def __repr__(self) -> str: STREAM_EOF = EndOfStream() -SignalReadResult: TypeAlias = SignalEnvelope | EndOfStream | None +SignalReadResult: TypeAlias = SignalEnvelope[_PayloadT] | EndOfStream | None __all__ = [ diff --git a/python/pocketstation/source_authoring.py b/python/pocketstation/source_authoring.py index d32562f..83d1e01 100644 --- a/python/pocketstation/source_authoring.py +++ b/python/pocketstation/source_authoring.py @@ -15,6 +15,7 @@ from ._native import _SourcePrepareContext as _NativeSourcePrepareContext from .errors import _native_call from .graph import PortSpec, SignalSpec, SourceConfiguration, SourceInstance +from .identity import RuntimeSessionId, SourceId, StreamId class _SessionOwner(Protocol): @@ -52,11 +53,11 @@ class SourceOutputIdentity: """Session-owned identity assigned to one prepared Source output.""" output_port: str - stream_id: int + stream_id: StreamId @classmethod def _from_native(cls, value: _NativeSourceOutputIdentity) -> SourceOutputIdentity: - return cls(value.output_port, value.stream_id) + return cls(value.output_port, StreamId(value.stream_id)) @dataclass(frozen=True, slots=True) @@ -64,16 +65,18 @@ class SourcePrepareContext: """Immutable Session identity supplied before the Source starts.""" source_type_id: str - session_id: int | None - source_id: int | None + session_id: RuntimeSessionId | None + source_id: SourceId | None outputs: tuple[SourceOutputIdentity, ...] @classmethod def _from_native(cls, value: _NativeSourcePrepareContext) -> SourcePrepareContext: return cls( source_type_id=value.source_type_id, - session_id=value.session_id, - source_id=value.source_id, + session_id=( + None if value.session_id is None else RuntimeSessionId(value.session_id) + ), + source_id=None if value.source_id is None else SourceId(value.source_id), outputs=tuple( SourceOutputIdentity._from_native(item) for item in value.outputs ), @@ -107,7 +110,7 @@ def text( output_port: str, payload: str, *, - signal: SignalSpec, + signal: SignalSpec[str], source_timestamp_ns: int | None = None, observed_timestamp_ns: int | None = None, duration_ns: int | None = None, @@ -141,7 +144,7 @@ def bytes( output_port: str, payload: bytes, *, - signal: SignalSpec, + signal: SignalSpec[bytes], source_timestamp_ns: int | None = None, observed_timestamp_ns: int | None = None, duration_ns: int | None = None, @@ -187,8 +190,6 @@ def close(self) -> None: class SourceFactory(Protocol): """Reusable factory retained by one canonical Session.""" - def validate_config(self, configuration: Mapping[str, str]) -> None: ... - def create(self, configuration: Mapping[str, str]) -> SourceDriver: ... @@ -279,7 +280,9 @@ def __init__(self, factory: SourceFactory) -> None: self._factory = factory def validate_config(self, configuration: Mapping[str, str]) -> None: - self._factory.validate_config(configuration) + validator = getattr(self._factory, "validate_config", None) + if validator is not None: + validator(configuration) def create(self, configuration: Mapping[str, str]) -> _NativeDriverAdapter: driver = self._factory.create(configuration) diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index 5945697..5707215 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -5,6 +5,9 @@ from dataclasses import dataclass, field from enum import StrEnum +from ._native import CaptureAuthorizationSnapshot as _NativeCaptureAuthorizationSnapshot +from ._native import CapturePermissionLifecycle as _NativeCapturePermissionLifecycle +from ._native import CapturePermissionTransition as _NativeCapturePermissionTransition from ._native import DiscoveredSource as _NativeDiscoveredSource from ._native import SessionEvent as _NativeSessionEvent from ._native import Source as _NativeSource @@ -16,6 +19,7 @@ microphone_permission_observation as _native_microphone_permission_observation, ) from .errors import PocketStationError, _native_call +from .identity import SourceId class Platform(StrEnum): @@ -78,6 +82,133 @@ class PermissionObservation(StrEnum): NOT_APPLICABLE = "not-applicable" +class CapturePermissionTransitionKind(StrEnum): + CHANGED = "permission-changed" + REVOKED = "permission-revoked" + + +class CaptureCapabilityState(StrEnum): + AVAILABLE = "available" + UNAVAILABLE = "unavailable" + UNSUPPORTED = "unsupported" + + +class ApplicationPolicyObservation(StrEnum): + ALLOWED = "allowed" + DENIED = "denied" + NOT_OBSERVABLE = "not-observable" + NOT_APPLICABLE = "not-applicable" + + +class CaptureSessionGrant(StrEnum): + GRANTED_BY_EXPLICIT_SELECTION = "granted-by-explicit-selection" + DENIED = "denied" + NOT_EVALUATED = "not-evaluated" + + +class CaptureScopeKind(StrEnum): + EXACT_APPLICATION = "exact-application" + EXACT_INPUT_DEVICE = "exact-input-device" + EXACT_OUTPUT_DEVICE = "exact-output-device" + SYSTEM_MIX = "system-mix" + + +class CaptureOpenOutcome(StrEnum): + NOT_ATTEMPTED = "not-attempted" + SUCCEEDED = "succeeded" + PERMISSION_DENIED = "permission-denied" + SOURCE_UNAVAILABLE = "source-unavailable" + BACKEND_FAILED = "backend-failed" + + +@dataclass(frozen=True, slots=True) +class CaptureAuthorizationSnapshot: + """Point-in-time authorization evidence for one exact discovered source.""" + + capability: CaptureCapabilityState + os_permission: PermissionObservation + application_policy: ApplicationPolicyObservation + session_grant: CaptureSessionGrant + capture_scope: CaptureScopeKind + scope_stable_id: str | None + identity_strength: SourceIdentityStrength + permission_epoch: int + observed_at_ns: int + open_outcome: CaptureOpenOutcome + + @classmethod + def _from_native( + cls, snapshot: _NativeCaptureAuthorizationSnapshot + ) -> CaptureAuthorizationSnapshot: + return cls( + capability=CaptureCapabilityState(snapshot.capability), + os_permission=PermissionObservation(snapshot.os_permission), + application_policy=ApplicationPolicyObservation( + snapshot.application_policy + ), + session_grant=CaptureSessionGrant(snapshot.session_grant), + capture_scope=CaptureScopeKind(snapshot.capture_scope), + scope_stable_id=snapshot.scope_stable_id, + identity_strength=SourceIdentityStrength(snapshot.identity_strength), + permission_epoch=snapshot.permission_epoch, + observed_at_ns=snapshot.observed_at_ns, + open_outcome=CaptureOpenOutcome(snapshot.open_outcome), + ) + + +@dataclass(frozen=True, slots=True) +class CapturePermissionTransition: + """One authoritative host-supplied permission-state transition.""" + + kind: CapturePermissionTransitionKind + previous: PermissionObservation + current: PermissionObservation + permission_epoch: int + + @classmethod + def _from_native( + cls, transition: _NativeCapturePermissionTransition + ) -> CapturePermissionTransition: + return cls( + kind=CapturePermissionTransitionKind(transition.kind), + previous=PermissionObservation(transition.previous), + current=PermissionObservation(transition.current), + permission_epoch=transition.permission_epoch, + ) + + +class CapturePermissionLifecycle: + """Canonical control-plane permission epoch owner. + + The host supplies authoritative platform observations. Equal observations + produce no transition; PocketStation never converts generic backend errors + into permission state. + """ + + def __init__(self, current: PermissionObservation) -> None: + self._native = _native_call( + lambda: _NativeCapturePermissionLifecycle(current.value) + ) + + @property + def current(self) -> PermissionObservation: + return PermissionObservation(self._native.current) + + @property + def permission_epoch(self) -> int: + return self._native.permission_epoch + + def observe( + self, current: PermissionObservation + ) -> CapturePermissionTransition | None: + transition = _native_call(lambda: self._native.observe(current.value)) + return ( + None + if transition is None + else CapturePermissionTransition._from_native(transition) + ) + + class SourceSelectorKind(StrEnum): APPLICATION_NAME = "application-name" APPLICATION_BUNDLE_ID = "application-bundle-id" @@ -86,6 +217,7 @@ class SourceSelectorKind(StrEnum): APPLICATION_PROCESS_INSTANCE = "application-process-instance" MICROPHONE_DEFAULT = "microphone-default" MICROPHONE_ID = "microphone-id" + SYSTEM_MIX = "system-mix" class SourceRuntimeEventKind(StrEnum): @@ -110,7 +242,7 @@ class StableSourceId: platform: Platform kind: SourceKind stable_key: str - source_id: int | None + source_id: SourceId | None @dataclass(frozen=True, slots=True) @@ -128,6 +260,9 @@ class DiscoveredSource: identity_strength: SourceIdentityStrength selector_persistence_scope: SelectorPersistenceScope | None process_tree_scope: ProcessTreeScope | None + _native: _NativeDiscoveredSource | None = field( + default=None, repr=False, compare=False + ) @classmethod def _from_native(cls, source: _NativeDiscoveredSource) -> DiscoveredSource: @@ -136,7 +271,7 @@ def _from_native(cls, source: _NativeDiscoveredSource) -> DiscoveredSource: platform=Platform(source.platform), kind=SourceKind(source.kind), stable_key=source.stable_key, - source_id=source.source_id, + source_id=SourceId(source.source_id), ), name=source.name, process_id=source.process_id, @@ -156,7 +291,34 @@ def _from_native(cls, source: _NativeDiscoveredSource) -> DiscoveredSource: if source.process_tree_scope is None else ProcessTreeScope(source.process_tree_scope) ), + _native=source, + ) + + def authorization_before_open( + self, + *, + os_permission: PermissionObservation = PermissionObservation.NOT_OBSERVABLE, + application_policy: ApplicationPolicyObservation = ( + ApplicationPolicyObservation.NOT_OBSERVABLE + ), + session_grant: CaptureSessionGrant = CaptureSessionGrant.NOT_EVALUATED, + permission_epoch: int = 1, + ) -> CaptureAuthorizationSnapshot: + """Create truthful pre-open evidence without inferring backend success.""" + native = self._native + if native is None: + raise ValueError( + "authorization evidence requires a native discovery result" + ) + snapshot = _native_call( + lambda: native.authorization_before_open( + os_permission.value, + application_policy.value, + session_grant.value, + permission_epoch, + ) ) + return CaptureAuthorizationSnapshot._from_native(snapshot) @dataclass(frozen=True, slots=True) @@ -312,14 +474,21 @@ def microphone_id(cls, device_id: str) -> Source: device_id, ) + @classmethod + def system_mix(cls) -> Source: + """Capture the host system output mix through native loopback.""" + return cls( + _native_call(_NativeSource.system_mix), + SourceKind.SYSTEM_MIX, + SourceSelectorKind.SYSTEM_MIX, + ) + @classmethod def from_discovered(cls, source: DiscoveredSource) -> Source: """Build the strongest supported Session declaration from discovery. - System mix and output devices can be discovered as host capabilities, - but the frozen public ``Session`` does not expose them as built-in - ``Source`` variants. This method rejects them instead of fabricating a - lowering path. + Output devices remain discovery-only. System mix is a built-in Session + source and retains the platform-owned loopback capability decision. """ stable_id = source.stable_id if stable_id.kind is SourceKind.APPLICATION: @@ -335,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_mix() raise PocketStationError( "discovered " f"{stable_id.kind.value!r} is not a frozen built-in Session Source", @@ -378,7 +549,7 @@ def _from_native(cls, event: _NativeSessionEvent) -> SourceRuntimeEvent | None: platform=Platform(event.source_platform), kind=SourceKind(event.source_kind), stable_key=event.source_stable_key, - source_id=event.source_source_id, + source_id=SourceId(event.source_source_id), ), generation=event.source_generation, recovery_requirement=( @@ -429,6 +600,15 @@ def _platform_value(platform: Platform | str) -> str: __all__ = [ + "ApplicationPolicyObservation", + "CaptureAuthorizationSnapshot", + "CaptureCapabilityState", + "CaptureOpenOutcome", + "CapturePermissionLifecycle", + "CapturePermissionTransition", + "CapturePermissionTransitionKind", + "CaptureScopeKind", + "CaptureSessionGrant", "DiscoveredSource", "PermissionObservation", "Platform", diff --git a/python/pocketstation/streams.py b/python/pocketstation/streams.py index 68e8ae9..577b2e0 100644 --- a/python/pocketstation/streams.py +++ b/python/pocketstation/streams.py @@ -5,9 +5,15 @@ from collections import deque from collections.abc import Callable, Iterator from threading import Lock -from typing import Literal - -from ._native import AudioBatch, AudioFrame, _SignalRead, _SignalSubscriptionMetrics +from typing import Generic, Literal, TypeAlias, TypeVar, cast + +from ._native import ( + AudioBatch, + AudioFrame, + ClockDomainDescriptor, + _SignalRead, + _SignalSubscriptionMetrics, +) from .errors import StreamError, StreamInUseError, StreamModeError from .signal import ( STREAM_EOF, @@ -31,6 +37,9 @@ _MAXIMUM_TIMEOUT_SECONDS = 1.0 _DEFAULT_ITERATION_TIMEOUT_SECONDS = 0.1 +AudioBatchReadResult: TypeAlias = AudioBatch | EndOfStream | None +_PayloadT = TypeVar("_PayloadT") + class _ReaderState: """Fail-fast one-mode/one-reader ownership shared by sync and asyncio.""" @@ -126,6 +135,22 @@ def poll_batch(self) -> AudioBatch | None: finally: self._state.release(token) + def poll(self) -> AudioBatchReadResult: + """Read immediately with distinct batch, empty, and closed outcomes. + + ``None`` means the bounded native receipt is currently empty; + ``STREAM_EOF`` means the owning Session has stopped. Native faults are + raised as typed PocketStation exceptions. + """ + token = self._state.claim("batches") + try: + if self.is_closed: + return STREAM_EOF + batch = self._poll_batch() + return STREAM_EOF if batch is None and self.is_closed else batch + finally: + self._state.release(token) + def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: """Advanced bounded batch read using the exclusive batch mode.""" timeout_ms = _timeout_milliseconds(timeout_s) @@ -135,6 +160,18 @@ def read_batch(self, *, timeout_s: float = 1.0) -> AudioBatch | None: finally: self._state.release(token) + def read_result(self, *, timeout_s: float = 1.0) -> AudioBatchReadResult: + """Wait finitely with distinct batch, timeout, and closed outcomes.""" + timeout_ms = _timeout_milliseconds(timeout_s) + token = self._state.claim("batches") + try: + if self.is_closed: + return STREAM_EOF + batch = self._wait_batch(timeout_ms) + return STREAM_EOF if batch is None and self.is_closed else batch + finally: + self._state.release(token) + def __iter__(self) -> Iterator[AudioFrame]: return self.frames() @@ -192,7 +229,7 @@ def _read_frame(self, timeout_ms: int) -> AudioFrame | None: return self._pending_frames.popleft() -class SignalStream: +class SignalStream(Generic[_PayloadT]): """Exclusive Pythonic view of one native bounded ``BusSubscription``. ``None`` means a bounded read timed out, while ``STREAM_EOF`` means the @@ -223,7 +260,7 @@ def reader_mode(self) -> str | None: def is_closed(self) -> bool: return self._closed - def poll(self) -> SignalReadResult: + def poll(self) -> SignalReadResult[_PayloadT]: """Read immediately: envelope, ``None`` for empty, or ``STREAM_EOF``.""" token = self._state.claim("signal_read") try: @@ -231,7 +268,7 @@ def poll(self) -> SignalReadResult: finally: self._state.release(token) - def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: + def read(self, *, timeout_s: float = 1.0) -> SignalReadResult[_PayloadT]: """Perform one native bounded wait with explicit timeout and EOF states.""" timeout_ms = _timeout_milliseconds(timeout_s) token = self._state.claim("signal_read") @@ -244,18 +281,18 @@ def read(self, *, timeout_s: float = 1.0) -> SignalReadResult: finally: self._state.release(token) - def __iter__(self) -> Iterator[SignalEnvelope]: + def __iter__(self) -> Iterator[SignalEnvelope[_PayloadT]]: return self.iter_signals() def iter_signals( self, *, wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, - ) -> Iterator[SignalEnvelope]: + ) -> Iterator[SignalEnvelope[_PayloadT]]: """Yield immutable envelopes until native EOF or explicit close.""" timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) - def iterate() -> Iterator[SignalEnvelope]: + def iterate() -> Iterator[SignalEnvelope[_PayloadT]]: token = self._state.claim("signals") try: while not self._closed: @@ -280,14 +317,17 @@ def metrics(self) -> SignalSubscriptionMetrics: """Snapshot capacity, payload-byte bounds, depth, delivery, and drops.""" return SignalSubscriptionMetrics._from_native(self._signal_metrics()) - def _decode(self, result: _SignalRead) -> SignalReadResult: + def _decode(self, result: _SignalRead) -> SignalReadResult[_PayloadT]: if result.status == "item": if result.envelope is None: raise StreamError( "native signal read omitted its envelope", "stream.invalid_read", ) - return SignalEnvelope._from_native(result.envelope) + return cast( + SignalEnvelope[_PayloadT], + SignalEnvelope._from_native(result.envelope), + ) if result.status == "empty": return None if result.status == "closed": @@ -305,4 +345,9 @@ def _decode(self, result: _SignalRead) -> SignalReadResult: ) -__all__ = ["AudioStream", "SignalStream"] +__all__ = [ + "AudioBatchReadResult", + "AudioStream", + "ClockDomainDescriptor", + "SignalStream", +] diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index c823b0f..2a0a178 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -11,19 +11,134 @@ import pocketstation -def main() -> None: - delivered = Event() +class InstalledSource(pocketstation.SourceDriver): + def __init__( + self, + signal: pocketstation.SignalSpec[str], + closed: Event, + ) -> None: + self._signal = signal + self._closed = closed + self._sent = False + + def next( + self, cancellation: pocketstation.SourceCancellation + ) -> pocketstation.SourceEmission | None: + if cancellation.cancelled or self._sent: + return None + self._sent = True + return pocketstation.SourceEmission.text( + "events", "installed", signal=self._signal, terminal=True + ) + + def close(self) -> None: + self._closed.set() + + +class InstalledSourceFactory: + def __init__(self, driver: InstalledSource) -> None: + self._driver = driver + + def create(self, _configuration: object) -> InstalledSource: + return self._driver + + +class InstalledOperator(pocketstation.OperatorNode): + def __init__(self, signal: pocketstation.SignalSpec[str], closed: Event) -> None: + self._signal = signal + self._closed = closed + + def process( + self, + _input_port: str, + envelope: pocketstation.SignalEnvelope[object], + ) -> tuple[pocketstation.OperatorEmission, ...]: + return ( + pocketstation.OperatorEmission.text( + str(envelope.payload).upper(), signal=self._signal + ), + ) + + def close(self) -> None: + self._closed.set() + + +class InstalledOperatorFactory: + def __init__(self, node: InstalledOperator) -> None: + self._node = node + + def create(self, _configuration: object) -> InstalledOperator: + return self._node - def receive( + +class InstalledConnector(pocketstation.ConnectorDriver): + def __init__(self, delivered: Event, stopped: Event) -> None: + self._delivered = delivered + self._stopped = stopped + self.shutdown_mode: pocketstation.ConnectorShutdownMode | None = None + + def deliver( + self, item: pocketstation.ConnectorItem, _context: pocketstation.ConnectorContext, ) -> pocketstation.ConnectorDeliveryOutcome: if item.audio is None: raise RuntimeError("installed Connector received no audio") - delivered.set() + self._delivered.set() return pocketstation.ConnectorDeliveryOutcome.DELIVERED + def shutdown( + self, + mode: pocketstation.ConnectorShutdownMode, + _context: pocketstation.ConnectorContext, + ) -> None: + self.shutdown_mode = mode + self._stopped.set() + + +class InstalledConnectorFactory: + def __init__(self, driver: InstalledConnector) -> None: + self._driver = driver + + def prepare( + self, + _inputs: object, + ) -> InstalledConnector: + return self._driver + + +def _exercise_complete_provider_path() -> dict[str, object]: + delivered = Event() + source_closed = Event() + operator_closed = Event() + connector_stopped = Event() + request_signal = pocketstation.SignalSpec.text(role="request") + response_signal = pocketstation.SignalSpec.text(role="response.final") + session = pocketstation.Session() + source_driver = InstalledSource(request_signal, source_closed) + source_provider = pocketstation.SourceProvider.with_driver( + pocketstation.SourceManifest( + "io.pocketstation.source.installed-consumer.v1", + outputs=(pocketstation.PortSpec.output("events", request_signal),), + ), + InstalledSourceFactory(source_driver), + ) + operator_node = InstalledOperator(response_signal, operator_closed) + operator_provider = pocketstation.OperatorProvider.with_node( + pocketstation.OperatorManifest( + "io.pocketstation.test.installed-operator.v1", + inputs=(pocketstation.PortSpec.input("input", request_signal),), + outputs=(pocketstation.PortSpec.output("output", response_signal),), + terminal_roles=("response.final",), + ), + InstalledOperatorFactory(operator_node), + ) + source = session.register_source(source_provider).declare() + operator = session.register_operator(operator_provider).declare() + source.output("events").connect(operator.input("input")) + subscription = session.subscribe(operator.output("output"), signal=response_signal) + audio = session.audio_input( "installed-consumer", capacity_frames=2, @@ -33,13 +148,17 @@ def receive( "io.pocketstation.test.installed-consumer.v1", package_version="1.0.0", ) - endpoint = session.register_connector( - pocketstation.Connector.from_handler(manifest, receive) - ).declare() + connector_driver = InstalledConnector(delivered, connector_stopped) + endpoint = session.destination( + pocketstation.Connector.with_driver( + manifest, InstalledConnectorFactory(connector_driver) + ) + ) audio.output.send(endpoint) audio.output.send(session.polled_audio()) running = session.start() + transformed = running.signals(subscription).read(timeout_s=1.0) audio.write(array("f", [0.25, -0.25, 0.5, -0.5])) frame = running.audio.read(timeout_s=1.0) if frame is None: @@ -51,6 +170,108 @@ def receive( raise RuntimeError("installed consumer Session did not stop successfully") if frame.source_id != audio.source_id or frame.stream_id != audio.stream_id: raise RuntimeError("installed consumer lost source or stream identity") + if not isinstance(transformed, pocketstation.SignalEnvelope): + raise RuntimeError("installed Source and Operator produced no signal") + if transformed.payload != "INSTALLED": + raise RuntimeError("installed Source and Operator did not execute") + if transformed.derivation is None: + raise RuntimeError("installed Operator output lost derivation") + if not source_closed.wait(1.0): + raise RuntimeError("installed Source was not closed exactly") + if not operator_closed.wait(1.0): + raise RuntimeError("installed Operator was not closed exactly") + if not connector_stopped.wait(1.0): + raise RuntimeError("installed Connector was not stopped exactly") + if connector_driver.shutdown_mode is not pocketstation.ConnectorShutdownMode.DRAIN: + raise RuntimeError("installed Connector did not receive drain shutdown") + return { + "source_id": frame.source_id, + "stream_id": frame.stream_id, + "transformed": transformed.payload, + } + + +def _exercise_saturation() -> None: + session = pocketstation.Session() + audio = session.audio_input( + "installed-saturation", + capacity_frames=1, + frame_samples_per_channel=4, + ) + samples = array("f", [0.0, 0.0, 0.0, 0.0]) + audio.try_write(samples) + try: + audio.try_write(samples) + except pocketstation.AudioInputFullError: + return + raise RuntimeError("installed AudioInput did not expose finite saturation") + + +def _exercise_abort() -> None: + delivered = Event() + stopped = Event() + driver = InstalledConnector(delivered, stopped) + session = pocketstation.Session() + audio = session.audio_input("installed-abort", frame_samples_per_channel=4) + manifest = pocketstation.ConnectorManifest.audio( + "io.pocketstation.test.installed-abort.v1", + package_version="1.0.0", + ) + endpoint = session.destination( + pocketstation.Connector.with_driver(manifest, InstalledConnectorFactory(driver)) + ) + audio.output.send(endpoint) + running = session.start() + result = running.cancel() + if not result.success or not stopped.wait(1.0): + raise RuntimeError("installed Connector abort did not finalize") + if driver.shutdown_mode is not pocketstation.ConnectorShutdownMode.ABORT: + raise RuntimeError("installed Connector did not receive abort shutdown") + + +def _exercise_structured_failure() -> None: + attempted = Event() + + def fail( + _item: pocketstation.ConnectorItem, + _context: pocketstation.ConnectorContext, + ) -> pocketstation.ConnectorDeliveryOutcome: + attempted.set() + raise pocketstation.ConnectorError( + "provider request timed out", + code="provider.timeout", + stage=pocketstation.ConnectorErrorStage.DELIVERY, + retryability=pocketstation.ConnectorRetryability.RETRYABLE, + ) + + session = pocketstation.Session() + audio = session.audio_input("installed-failure", frame_samples_per_channel=4) + manifest = pocketstation.ConnectorManifest.audio( + "io.pocketstation.test.installed-failure.v1", + package_version="1.0.0", + ) + endpoint = session.destination(pocketstation.Connector.from_handler(manifest, fail)) + audio.output.send(endpoint) + running = session.start() + audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) + if not attempted.wait(1.0): + raise RuntimeError("installed failing Connector did not execute") + result = running.stop() + if result.success or result.terminal_event is None: + raise RuntimeError("installed Connector failure was not terminal") + if not any( + failure.error_code == "provider.timeout" + and failure.retryability is pocketstation.EndpointFailureRetryability.RETRYABLE + for failure in result.terminal_event.failures + ): + raise RuntimeError("installed Connector failure lost structured fields") + + +def main() -> None: + provider = _exercise_complete_provider_path() + _exercise_saturation() + _exercise_abort() + _exercise_structured_failure() package_path = Path(pocketstation.__file__).resolve() environment_root = Path(sys.prefix).resolve() if not package_path.is_relative_to(environment_root): @@ -60,8 +281,7 @@ def receive( { "package_path": str(package_path), "python": sys.version.split()[0], - "source_id": frame.source_id, - "stream_id": frame.stream_id, + **provider, "success": True, }, sort_keys=True, diff --git a/tests/qualification/typing_contract.py b/tests/qualification/typing_contract.py new file mode 100644 index 0000000..cda0c54 --- /dev/null +++ b/tests/qualification/typing_contract.py @@ -0,0 +1,38 @@ +"""Static-only checks for signal payload and runtime identity preservation.""" + +from typing import assert_type + +import pocketstation + + +def verify_signal_types( + session: pocketstation.Session, + source: pocketstation.SourceOutput, +) -> None: + audio_spec = pocketstation.SignalSpec.audio() + text_spec = pocketstation.SignalSpec.text() + assert_type(audio_spec, pocketstation.SignalSpec[pocketstation.SignalAudioPayload]) + assert_type(text_spec, pocketstation.SignalSpec[str]) + + audio_subscription = session.subscribe(source, signal=audio_spec) + text_subscription = session.subscribe(source, signal=text_spec) + assert_type( + audio_subscription, + pocketstation.BusSubscription[pocketstation.SignalAudioPayload], + ) + assert_type(text_subscription, pocketstation.BusSubscription[str]) + + +def verify_runtime_identities( + running: pocketstation.RunningSession, + frame: pocketstation.AudioFrame, +) -> None: + assert_type(running.session_id, pocketstation.RuntimeSessionId) + assert_type(frame.session_id, pocketstation.RuntimeSessionId) + assert_type(frame.stream_id, pocketstation.StreamId) + assert_type(frame.source_id, pocketstation.SourceId) + assert_type(frame.stem_id, pocketstation.StemId) + assert_type(frame.clock_id, pocketstation.ClockDomainId) + assert_type(frame.endpoint_id, pocketstation.EndpointId) + assert_type(frame.connector_id, pocketstation.ConnectorId | None) + assert_type(frame.route_id, pocketstation.RouteId) diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index b419958..8de9941 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -11,14 +11,22 @@ from pathlib import Path REPOSITORY = Path(__file__).resolve().parents[1] -TESTS = ( - REPOSITORY / "tests" / "test_streams.py", - REPOSITORY / "tests" / "test_aio_streams.py", - REPOSITORY / "tests" / "test_connector.py", - REPOSITORY / "tests" / "test_source_authoring.py", - REPOSITORY / "tests" / "test_operator_authoring.py", - REPOSITORY / "tests" / "test_aio_session.py", - REPOSITORY / "tests" / "test_transcription_example.py", +TEST_CASES = ( + "tests/test_streams.py::test_read_and_batch_modes_use_canonical_native_session", + "tests/test_streams.py::test_audio_batch_result_distinguishes_empty_timeout_and_closed", + "tests/test_aio_streams.py::test_async_read_and_batch_modes_use_canonical_native_session", + "tests/test_aio_streams.py::test_async_audio_batch_result_distinguishes_states", + "tests/test_sources.py::test_application_owned_pcm_uses_the_canonical_source_and_recording_path", + "tests/test_aio_session.py::test_application_owned_pcm_has_an_async_writer", + "tests/test_source_authoring.py::test_iterable_source_runs_in_core_and_receives_session_lineage", + "tests/test_source_authoring.py::test_async_iterable_source_runs_on_the_owning_event_loop", + "tests/test_operator_authoring.py::test_python_operator_processes_source_signal_with_derivation", + "tests/test_operator_authoring.py::test_async_operator_runs_on_owning_loop", + "tests/test_connector.py::test_connector_worker_receives_finite_native_owned_batches", + "tests/test_aio_session.py::test_async_connector_worker_receives_finite_native_batches", + "tests/test_audio_bridge.py::test_given_pcm_iterable_when_bridge_runs_then_core_drains_one_connector", + "tests/test_aio_audio_bridge.py::test_given_async_pcm_when_bridge_runs_then_core_drains_connector", + "tests/test_source_aware_transcription_example.py::test_two_source_lanes_keep_identity_through_one_model_operator", ) @@ -95,19 +103,7 @@ def main() -> int: "pytest", "-q", "--import-mode=importlib", - *(os.fspath(test) for test in TESTS), - "-k", - ( - "canonical_native_session or " - "connector_worker_receives_finite_native_owned_batches or " - "async_connector_worker_receives_finite_native_batches or " - "iterable_source_runs_in_core or " - "async_iterable_source_runs_on_the_owning_event_loop or " - "python_operator_processes_source_signal_with_derivation or " - "async_operator_runs_on_owning_loop or " - "whisper_example_declares_a_bounded_source_aware_operator or " - "real_whisper_process_preserves_source_identity" - ), + *(os.fspath(REPOSITORY / test_case) for test_case in TEST_CASES), "-rs", ], cwd=root, diff --git a/tests/test_aio_observations.py b/tests/test_aio_observations.py index e37e170..c8fbcd5 100644 --- a/tests/test_aio_observations.py +++ b/tests/test_aio_observations.py @@ -6,7 +6,6 @@ from types import SimpleNamespace import pytest - from pocketstation import StreamInUseError, StreamModeError, _native from pocketstation.aio import EventStream, RunningSession from pocketstation.aio.session import _native_async @@ -86,6 +85,8 @@ async def poll_event(): @pytest.mark.asyncio async def test_async_running_session_exposes_event_stream() -> None: class NativeRunning: + lifecycle_state = "running" + def poll_audio(self): return None diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 330b375..0dbf184 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -8,7 +8,12 @@ from array import array import pytest -from pocketstation import Connector, ConnectorDeliveryOutcome, ConnectorManifest +from pocketstation import ( + Connector, + ConnectorDeliveryOutcome, + ConnectorManifest, + SessionLifecycleState, +) from pocketstation.aio import ( Connector as AsyncConnector, ) @@ -56,9 +61,12 @@ async def test_application_owned_pcm_has_an_async_writer() -> None: audio.output.send(session.polled_audio()) running = await session.start() + assert running.state is SessionLifecycleState.RUNNING await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) frame = await running.audio.read(timeout_s=1.0) await running.stop() + assert running.state is SessionLifecycleState.STOPPED + assert running.is_stopped assert frame is not None assert frame.source_id == audio.source_id @@ -79,6 +87,10 @@ async def test_async_audio_write_wait_is_finite_and_adds_no_python_queue() -> No with pytest.raises(AudioInputFullError): await audio.write(samples, timeout_s=0.01) + with pytest.raises(TypeError): + await audio.write(samples, timeout_s=True) + with pytest.raises(ValueError): + await audio.write(samples, timeout_s=61) observations = await audio.observations() assert observations.capacity_frames == 1 @@ -112,6 +124,29 @@ def receive(item, context): assert (await running.stop()).success +@pytest.mark.asyncio +async def test_async_session_destination_reuses_one_connector_registration() -> None: + async def receive(_item, _context): + return ConnectorDeliveryOutcome.DELIVERED + + provider = AsyncConnector.from_handler( + ConnectorManifest.audio( + "io.pocketstation.test.aio-destination.v1", + package_version="1.0.0", + ), + receive, + ) + session = Session() + + first = session.destination(provider) + registration = session.register_connector(provider) + second = registration.declare() + + assert first.session_id == session.id + assert second.session_id == session.id + assert session.register_connector(provider).session_id == session.id + + @pytest.mark.asyncio async def test_async_connector_runs_on_owning_loop_with_observations() -> None: delivered = asyncio.Event() @@ -166,7 +201,7 @@ async def publish(frame, context): ) session = Session() audio = session.audio_input("remote-call", frame_samples_per_channel=4) - audio.output.send(session.register_connector(connector).declare()) + audio.output.send(session.destination(connector)) running = await session.start() await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) diff --git a/tests/test_aio_streams.py b/tests/test_aio_streams.py index 04e1f1d..9e2a8a4 100644 --- a/tests/test_aio_streams.py +++ b/tests/test_aio_streams.py @@ -7,8 +7,7 @@ from time import monotonic import pytest - -from pocketstation import StreamInUseError, StreamModeError, _native +from pocketstation import STREAM_EOF, StreamInUseError, StreamModeError, _native from pocketstation.aio import AudioStream, RunningSession from pocketstation.aio.session import _native_async @@ -66,6 +65,7 @@ async def test_async_running_session_exposes_the_same_exclusive_stream() -> None class NativeRunning: def __init__(self) -> None: self.batches = [["a"]] + self.lifecycle_state = "running" def poll_audio(self): return None @@ -179,6 +179,29 @@ async def test_async_iteration_rejects_a_busy_poll_timeout() -> None: await anext(stream.frames(wait_timeout_s=0.0)) +@pytest.mark.asyncio +async def test_async_audio_batch_result_distinguishes_states() -> None: + state = {"closed": False} + + async def empty() -> None: + return None + + async def wait_empty(_timeout_ms: int) -> None: + return None + + stream = AudioStream( + poll_batch=empty, + wait_batch=wait_empty, + is_closed=lambda: state["closed"], + ) + + assert await stream.poll() is None + assert await stream.read_result(timeout_s=0.001) is None + state["closed"] = True + assert await stream.poll() is STREAM_EOF + assert await stream.read_result(timeout_s=0.001) is STREAM_EOF + + @pytest.mark.asyncio async def test_async_frame_stream_preserves_two_stems_from_canonical_native_session( tmp_path, diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..b439448 --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,28 @@ +"""Installed SDK compatibility metadata must match its build inputs.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pocketstation + +REPOSITORY = Path(__file__).resolve().parents[1] + + +def test_runtime_compatibility_matches_python_and_native_manifests() -> None: + project = tomllib.loads((REPOSITORY / "pyproject.toml").read_text()) + native = tomllib.loads((REPOSITORY / "native" / "Cargo.toml").read_text()) + compatibility = pocketstation.RUNTIME_COMPATIBILITY + + assert compatibility.sdk_version == project["project"]["version"] + assert compatibility.core_version == native["dependencies"]["pocketstation"].lstrip( + "=" + ) + assert compatibility.relay_connector_version == native["dependencies"][ + "pocketstation-relay" + ].lstrip("=") + assert compatibility.python_requires == project["project"]["requires-python"] + assert compatibility.python_abi == "abi3-py311" + assert not compatibility.free_threaded_cpython + assert pocketstation.__version__ == compatibility.sdk_version diff --git a/tests/test_connector.py b/tests/test_connector.py index e65c0f2..72495d2 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -101,6 +101,7 @@ def prepare( assert item.audio is not None assert item.audio.source_id == audio.source_id assert item.audio.stream_id == audio.stream_id + assert item.audio.connector_id == endpoint.connector_id assert item.audio.sequence_number == 0 assert item.audio.discontinuity_epoch == 1 assert list(item.audio.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) @@ -156,6 +157,31 @@ def prepare(inputs): schema.configuration({"token": "not-explicitly-secret"}) assert mismatch.value.code == "connector.configuration.type_mismatch" + with pytest.raises(ConnectorError) as duplicate_value: + schema.configuration( + [ + ("token", ConnectorConfigurationValue.secret("first")), + ("token", ConnectorConfigurationValue.secret("second")), + ] + ) + assert duplicate_value.value.code == "connector.configuration.duplicate_value" + + with pytest.raises(ConnectorError) as duplicate_field: + ConnectorConfigurationSchema(fields=(schema.fields[0], schema.fields[0])) + assert duplicate_field.value.code == "connector.configuration.duplicate_field" + + +def test_connector_error_keeps_typed_stage_and_retryability() -> None: + error = ConnectorError( + "retry later", + code="provider.unavailable", + stage=ConnectorErrorStage.DELIVERY, + retryability=ConnectorRetryability.RETRYABLE, + ) + + assert error.stage is ConnectorErrorStage.DELIVERY + assert error.retryability is ConnectorRetryability.RETRYABLE + def test_connector_decorator_builds_an_in_process_provider() -> None: delivered = Event() @@ -173,13 +199,57 @@ def provider(item, context): assert isinstance(provider, Connector) session = Session() audio = session.audio_input("generated", frame_samples_per_channel=4) - audio.output.send(session.register_connector(provider).declare()) + audio.output.send(session.destination(provider)) running = session.start() audio.write(array("f", [0.0, 0.0, 0.0, 0.0])) assert delivered.wait(1.0) assert running.cancel().success +def test_session_destination_reuses_one_connector_registration() -> None: + manifest = ConnectorManifest.audio( + "io.pocketstation.test.destination.v1", + package_version="1.0.0", + ) + provider = Connector.from_handler( + manifest, + lambda _item, _context: ConnectorDeliveryOutcome.DELIVERED, + ) + session = Session() + + first = session.destination(provider) + registration = session.register_connector(provider) + second = registration.declare() + + assert first.session_id == session.id + assert second.session_id == session.id + assert session.register_connector(provider).session_id == session.id + + +def test_session_destination_does_not_merge_different_connector_implementations() -> ( + None +): + manifest = ConnectorManifest.audio( + "io.pocketstation.test.destination-collision.v1", + package_version="1.0.0", + ) + first = Connector.from_handler( + manifest, + lambda _item, _context: ConnectorDeliveryOutcome.DELIVERED, + ) + second = Connector.from_handler( + manifest, + lambda _item, _context: ConnectorDeliveryOutcome.DROPPED, + ) + session = Session() + session.destination(first) + + with pytest.raises(PocketStationError) as failure: + session.destination(second) + + assert failure.value.code == "connector.registration_failed" + + def test_connector_manifest_rejects_output_ports() -> None: from pocketstation import MediaCaps, PortDirection, PortSpec, SignalSpec @@ -259,6 +329,10 @@ def publish(frame, context): assert connector.manifest.inputs[0].signal.is_audio assert received[0].source_id == audio.source_id assert received[0].stream_id == audio.stream_id + assert received[0].route_enqueued_at_ns > 0 + assert received[0].route_received_at_ns >= received[0].route_enqueued_at_ns + assert received[0].endpoint_enqueued_at_ns is None + assert received[0].polled_at_ns is None def test_connector_observations_preserve_service_state_and_delivery_counters() -> None: diff --git a/tests/test_control.py b/tests/test_control.py index e4c5e37..6abd569 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -240,9 +240,7 @@ async def handler(request: httpx.Request) -> httpx.Response: observed.append(request.extensions["timeout"]["read"]) return httpx.Response(201, json=CREATE_RESPONSE) - async with httpx.AsyncClient( - transport=httpx.MockTransport(handler) - ) as http_client: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: client = AsyncControlClient( "https://control.example", timeout_seconds=7.0, diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..edc36a7 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest +from pocketstation.errors import ( + CaptureError, + ConnectorRuntimeError, + GraphError, + OperatorError, + SessionCompileDiagnostic, + SessionDeclarationError, + SessionRuntimeError, + SessionStartError, + SourceError, + _normalize_native_error, +) + + +@pytest.mark.parametrize( + ("encoded", "expected"), + [ + ("[session.invalid_route] wrong owner", SessionDeclarationError), + ("[session.endpoint_start_failed] refused", SessionStartError), + ("[session.missing_metrics_snapshot] unavailable", SessionRuntimeError), + ("[capture.permission_denied] denied", CaptureError), + ("[graph.invalid_contract] incompatible", GraphError), + ("[source.invalid_contract] invalid", SourceError), + ("[operator.registration_failed] duplicate", OperatorError), + ("[connector.registration_failed] duplicate", ConnectorRuntimeError), + ], +) +def test_native_codes_map_to_stable_failure_families( + encoded: str, + expected: type[Exception], +) -> None: + error = _normalize_native_error(RuntimeError(encoded)) + + assert isinstance(error, expected) + assert error.code == encoded[1 : encoded.index("]")] + + +def test_native_compile_diagnostic_is_projected_without_message_parsing() -> None: + native_error = RuntimeError("[session.compile_failed] graph rejected") + native_error._pocketstation_compile_code = "compile.graph.media_mismatch" # type: ignore[attr-defined] + native_error._pocketstation_compile_edge_index = 7 # type: ignore[attr-defined] + native_error._pocketstation_compile_expected = "audio/f32/mono" # type: ignore[attr-defined] + native_error._pocketstation_compile_actual = "audio/f32/stereo" # type: ignore[attr-defined] + + error = _normalize_native_error(native_error) + + assert isinstance(error, SessionStartError) + assert error.diagnostic == SessionCompileDiagnostic( + code="compile.graph.media_mismatch", + edge_index=7, + expected="audio/f32/mono", + actual="audio/f32/stereo", + ) diff --git a/tests/test_graph.py b/tests/test_graph.py index cbc7e7a..a4fed58 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -26,6 +26,7 @@ PortDirection, PortSpec, Session, + SessionStartError, SignalSpec, Source, SourceConfiguration, @@ -117,7 +118,13 @@ def test_media_caps_and_port_specs_are_rust_validated() -> None: assert wildcard.is_compatible_with(exact) assert not exact.is_compatible_with(stereo) + assert MediaCaps.any().negotiate(exact) == exact + assert wildcard.negotiate(exact) == exact + assert exact.negotiate(MediaCaps.text()) is None assert exact.supports_signal(SignalSpec.audio()) + assert ChannelLayout.MONO.channel_count == 1 + assert ChannelLayout.STEREO.channel_count == 2 + assert ChannelLayout.ANY.channel_count is None port = PortSpec( "audio-in", PortDirection.INPUT, @@ -163,6 +170,9 @@ def test_edge_presets_and_modifiers_preserve_bounded_contracts() -> None: assert realtime.copy_policy is CopyPolicy.SHARE_READ_ONLY assert realtime.observability is EdgeObservabilityLevel.COUNTERS assert realtime.max_payload_bytes is None + assert realtime.clock.is_realtime + assert not ClockDomain.INHERITED.is_realtime + assert EdgeObservabilityLevel.FULL.rank > realtime.observability.rank bounded = EdgeContract.bounded_async() assert bounded.clock is ClockDomain.INHERITED @@ -197,6 +207,15 @@ def test_configuration_values_are_immutable_snapshots() -> None: assert operator.with_value("model", "large").values == (("model", "large"),) +@pytest.mark.parametrize( + "configuration_type", + (OperatorConfiguration, SourceConfiguration, EndpointConfiguration), +) +def test_configuration_rejects_duplicate_keys(configuration_type) -> None: + with pytest.raises(ValueError, match="duplicate configuration key 'mode'"): + configuration_type((("mode", "first"), ("mode", "second"))) + + def test_graph_declarations_lower_immediately_to_one_rust_session(tmp_path) -> None: session = Session(recording_root=tmp_path) application = session.capture(Source.application("PocketStation Fixture")) @@ -277,6 +296,10 @@ def test_unknown_operator_is_rejected_by_the_canonical_compiler() -> None: session.start() assert failure.value.code == "session.compile_failed" assert "operator org.example.missing.v1 is not registered" in str(failure.value) + assert isinstance(failure.value, SessionStartError) + assert failure.value.diagnostic is not None + assert failure.value.diagnostic.code == "compile.unknown_async_operator" + assert failure.value.diagnostic.operator_id == "org.example.missing.v1" def test_sync_and_async_sessions_share_the_same_graph_declaration_surface() -> None: diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index ee6e601..7751435 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -8,11 +8,13 @@ from pocketstation import ( EndpointFailureStage, Session, + SessionComponentKind, SessionEvent, SessionFailureKind, SessionTerminalState, SessionTrace, SessionTraceConfiguration, + SessionTraceRecordType, Source, TerminationDisposition, _native, @@ -81,6 +83,11 @@ def test_trace_round_trip_preserves_terminal_lifecycle_and_hash(tmp_path) -> Non assert validation.terminal_state is SessionTerminalState.STOPPED assert validation.source_failures_total == 0 assert validation.endpoint_failures_total == 0 + assert len(trace.records) == trace.records_total + assert trace.records[0].sequence_index == 0 + assert trace.records[0].kind is SessionTraceRecordType.LIFECYCLE + assert trace.records[-1].kind is SessionTraceRecordType.TERMINAL + assert trace.records[-1].terminal_state is SessionTerminalState.STOPPED def test_trace_configuration_rejects_unbounded_or_zero_capacity(tmp_path) -> None: @@ -100,6 +107,7 @@ def failure(kind, stage, **identifiers): error_code="fixture.endpoint" if kind == "endpoint" else None, retryability="retryable" if kind == "endpoint" else None, component="Runtime" if kind == "finalization" else None, + component_kind="runtime" if kind == "finalization" else None, message="endpoint failed" if kind == "endpoint" else None, stem_id=identifiers.get("stem_id"), route_id=identifiers.get("route_id"), @@ -146,3 +154,6 @@ def failure(kind, stage, **identifiers): assert event.failures[0].error_code == "fixture.endpoint" assert event.failures[0].retryability.value == "retryable" assert event.failures[1].kind is SessionFailureKind.FINALIZATION + assert event.failures[1].component is not None + assert event.failures[1].component.kind is SessionComponentKind.RUNTIME + assert event.failures[1].component_diagnostic == "Runtime" diff --git a/tests/test_observations.py b/tests/test_observations.py index 76c631c..3da8959 100644 --- a/tests/test_observations.py +++ b/tests/test_observations.py @@ -6,7 +6,6 @@ from types import SimpleNamespace import pytest - from pocketstation import ( EventStream, RunningSession, @@ -82,6 +81,8 @@ def wait_event(_timeout_ms): def test_running_session_exposes_events_without_public_poll_loop() -> None: class NativeRunning: + lifecycle_state = "running" + def poll_audio(self): return None diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py index e9085e2..3aefde1 100644 --- a/tests/test_operator_authoring.py +++ b/tests/test_operator_authoring.py @@ -118,6 +118,54 @@ def create(self, _configuration) -> Uppercase: assert node.closed.wait(1.0) +def test_operator_factory_does_not_require_a_noop_validator() -> None: + input_signal = SignalSpec.text(role="request") + output_signal = SignalSpec.text(role="result") + source = SourceProvider.from_iterable( + SourceManifest( + "io.pocketstation.source.no-validator-operator-input.v1", + outputs=(PortSpec.output("events", input_signal),), + ), + lambda _configuration: ( + SourceEmission.text("events", "hello", signal=input_signal), + ), + ) + + class Uppercase(OperatorNode): + def process(self, _input_port, envelope): + return ( + OperatorEmission.text( + str(envelope.payload).upper(), signal=output_signal + ), + ) + + class Factory: + def create(self, _configuration) -> Uppercase: + return Uppercase() + + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.no-validator-test.v1", + inputs=(PortSpec.input("input", input_signal),), + outputs=(PortSpec.output("output", output_signal),), + ), + Factory(), + ) + session = Session() + source_instance = session.register_source(source).declare() + operator_instance = session.register_operator(provider).declare() + source_instance.output("events").connect(operator_instance.input("input")) + subscription = session.subscribe( + operator_instance.output("output"), signal=output_signal + ) + + with session.start() as running: + value = running.signals(subscription).read(timeout_s=1.0) + + assert isinstance(value, SignalEnvelope) + assert value.payload == "HELLO" + + @pytest.mark.asyncio async def test_async_operator_runs_on_owning_loop() -> None: input_signal = SignalSpec.text(role="async.request") diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 05d666b..6a425b3 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -2,10 +2,13 @@ import sys -import pytest - import pocketstation -from pocketstation import PermissionObservation +import pytest +from pocketstation import ( + CapturePermissionLifecycle, + CapturePermissionTransitionKind, + PermissionObservation, +) def test_permission_observation_is_typed_and_has_no_prompt_api() -> None: @@ -29,6 +32,27 @@ def test_permission_states_do_not_collapse_to_a_boolean() -> None: assert not issubclass(PermissionObservation, bool) +def test_permission_lifecycle_preserves_transitions_and_epochs() -> None: + lifecycle = CapturePermissionLifecycle(PermissionObservation.ALLOWED) + + assert lifecycle.current is PermissionObservation.ALLOWED + assert lifecycle.permission_epoch == 1 + assert lifecycle.observe(PermissionObservation.ALLOWED) is None + + revoked = lifecycle.observe(PermissionObservation.REVOKED) + assert revoked is not None + assert revoked.kind is CapturePermissionTransitionKind.REVOKED + assert revoked.previous is PermissionObservation.ALLOWED + assert revoked.current is PermissionObservation.REVOKED + assert revoked.permission_epoch == 2 + assert lifecycle.permission_epoch == 2 + + changed = lifecycle.observe(PermissionObservation.NOT_DETERMINED) + assert changed is not None + assert changed.kind is CapturePermissionTransitionKind.CHANGED + assert changed.permission_epoch == 3 + + def test_linux_truth_is_not_reinterpreted_as_allowed_or_denied() -> None: if sys.platform != "linux": pytest.skip("Linux-specific platform contract") diff --git a/tests/test_public_api.py b/tests/test_public_api.py index cf5f4ef..425de48 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -13,6 +13,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: assert set(pocketstation.__all__) == { "AudioBatch", + "AudioBatchReadResult", "AudioCaps", "AudioConnectorHandler", "AudioFrame", @@ -26,12 +27,26 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "AudioInputObservations", "AudioReentryMetrics", "AudioStream", + "ApplicationPolicyObservation", "BackpressurePolicy", "BinaryFormat", "BusSubscription", "Capture", + "CaptureAuthorizationSnapshot", + "CaptureCapabilityState", + "CaptureError", + "CaptureOpenOutcome", + "CapturePermissionLifecycle", + "CapturePermissionTransition", + "CapturePermissionTransitionKind", + "CaptureScopeKind", + "CaptureSessionGrant", "ChannelLayout", "ClockDomain", + "ClockDomainDescriptor", + "ClockDomainId", + "ClockDomainKind", + "ClockDomainOrigin", "Codec", "Connector", "ConnectorBatchOutcome", @@ -55,6 +70,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "ConnectorErrorStage", "ConnectorFactory", "ConnectorHealth", + "ConnectorId", "ConnectorInputDescriptor", "ConnectorItem", "ConnectorManifest", @@ -62,6 +78,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "ConnectorPreparationGroup", "ConnectorRecovery", "ConnectorRetryability", + "ConnectorRuntimeError", "ConnectorRequirement", "ConnectorRuntimeObservations", "ConnectorServiceStatus", @@ -79,6 +96,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "EdgeMetrics", "EdgeObservabilityLevel", "Endpoint", + "EndpointId", "EndpointConfiguration", "EndpointDescriptor", "EndpointFailureStage", @@ -90,6 +108,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "EventStream", "EventQueueMetrics", "ExternalSourceMetrics", + "GraphError", "ExtensionAbiVersion", "ExtensionDescriptor", "ExtensionError", @@ -105,6 +124,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "NativeExtensionLibrary", "NativeExtensionRegistration", "Operator", + "OperatorError", "OperatorConfigValidator", "OperatorConfiguration", "OperatorEmission", @@ -112,6 +132,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "OperatorHandler", "OperatorInput", "OperatorInputMetrics", + "OperatorInstanceId", "OperatorInstance", "OperatorMetrics", "OperatorManifest", @@ -132,6 +153,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "PublisherActivation", "ReceiverActivation", "ReceiverInvitation", + "RUNTIME_COMPATIBILITY", "RecordingOutcome", "RecordingDiscontinuity", "RecordingDiscontinuityKind", @@ -146,16 +168,24 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "RelayRoute", "RelaySession", "RelayTimeoutError", + "RouteId", "RouteLatencyBoundary", "RouteLatencyUnit", "RouteMetrics", "RouteObservationInterval", + "RuntimeCompatibility", + "RuntimeSessionId", "RunningSession", "SampleFormat", "SecretToken", "SelectorPersistenceScope", "Session", + "SessionCompileDiagnostic", + "SessionComponent", + "SessionComponentKind", "SessionCredentials", + "SessionDeclarationError", + "SessionError", "SessionEvent", "SessionEventType", "SessionFailure", @@ -164,11 +194,15 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "SessionId", "SessionLifecycleState", "SessionMetrics", + "SessionRuntimeError", "SessionRollbackStage", "SessionSnapshot", + "SessionStartError", "SessionTerminalState", "SessionTrace", "SessionTraceConfiguration", + "SessionTraceRecord", + "SessionTraceRecordType", "SessionTraceRecorderOutcome", "SessionTraceValidation", "SidecarBackpressureError", @@ -182,6 +216,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "SidecarProtocolError", "SidecarProtocolLimits", "SidecarReadResult", + "SidecarId", "SidecarSnapshot", "SidecarState", "SidecarStream", @@ -204,8 +239,11 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "SourceConfiguration", "SourceDriver", "SourceEmission", + "SourceError", "SourceFactory", "SourceFailureClass", + "SourceId", + "SourceInstanceId", "SourceIdentityStrength", "SourceInstance", "SourceIterableFactory", @@ -224,8 +262,10 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "SourceState", "StableSourceId", "Stem", + "StemId", "StopResult", "StreamError", + "StreamId", "StreamInUseError", "StreamModeError", "SubscriberCredentials", @@ -243,6 +283,26 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: } +def test_async_namespace_exposes_its_complete_authoring_contract() -> None: + required = { + "ConnectorManifest", + "ConnectorConfigurationSchema", + "ConnectorContext", + "ConnectorDeliveryOutcome", + "ConnectorError", + "ConnectorObservations", + "OperatorEmission", + "OperatorManifest", + "OperatorPrepareContext", + "SourceEmission", + "SourceManifest", + "SourcePrepareContext", + } + + assert required <= set(pocketstation.aio.__all__) + assert all(hasattr(pocketstation.aio, name) for name in required) + + def test_private_native_runtime_and_stub_export_the_same_classes() -> None: stub = ast.parse((ROOT / "python" / "pocketstation" / "_native.pyi").read_text()) stub_classes = {node.name for node in stub.body if isinstance(node, ast.ClassDef)} @@ -272,7 +332,7 @@ def test_relay_members_already_exported_by_native_are_typed() -> None: for declaration in ( "class RelayPublisher", "class RelayPublishOutcome", - "def publish(self, publisher: RelayPublisher, bus_id: str) -> int", + "def publish(self, publisher: RelayPublisher, bus_id: str) -> RouteId", "def relay_outcomes(self) -> list[RelayPublishOutcome]", "def relay(", ): diff --git a/tests/test_realtime_boundary.py b/tests/test_realtime_boundary.py index 1ff23a8..8203cd4 100644 --- a/tests/test_realtime_boundary.py +++ b/tests/test_realtime_boundary.py @@ -6,6 +6,7 @@ from time import monotonic import pocketstation as pks +import pytest from pocketstation._native import Session as NativeSession ROOT = Path(__file__).parents[1] @@ -14,6 +15,8 @@ def session_with_hung_sidecar(tmp_path: Path) -> pks.RunningSession: + if not hasattr(NativeSession, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") session = pks.Session._from_native(NativeSession.conformance(tmp_path)) audio = session.polled_audio() session.capture(pks.Source.application("PocketStation Python Fixture")).send(audio) diff --git a/tests/test_recording.py b/tests/test_recording.py index 08891c3..4f020d9 100644 --- a/tests/test_recording.py +++ b/tests/test_recording.py @@ -6,7 +6,6 @@ from types import SimpleNamespace import pytest - from pocketstation import ( RecordingDiscontinuityKind, RecordingOutcome, @@ -44,6 +43,13 @@ def test_application_and_microphone_record_as_independent_stems(tmp_path) -> Non assert stop.success assert stop.recording is not None assert stop.recording.complete + assert stop.recording.session_id == running.session_id + assert stop.recording.group_id == "session.multistem.default.v1" + assert stop.recording.manifest_path == ( + stop.recording.session_directory / "manifest.json" + ) + assert stop.recording.manifest_path.is_file() + assert stop.recording.manifest_schema_version == 1 outcomes = {stem.stem_name: stem for stem in stop.recording.stems} assert set(outcomes) == {"application", "microphone"} assert all(stem.frames_written_total > 0 for stem in outcomes.values()) @@ -77,11 +83,15 @@ def test_incomplete_recording_preserves_stable_code_and_gap_detail(tmp_path) -> ) outcome = RecordingOutcome._from_native( SimpleNamespace( + session_id=7, + group_id="session.multistem.default.v1", state="incomplete", complete=False, completed_stems=0, failed_stems=1, session_directory=str(tmp_path), + manifest_path=str(tmp_path / "manifest.json"), + manifest_schema_version=1, error_code="recording.incomplete", stems=lambda: [stem], ) diff --git a/tests/test_session.py b/tests/test_session.py index e38c2c5..a7a95c3 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,9 +2,16 @@ from __future__ import annotations -import pytest +from array import array -from pocketstation import PocketStationError, Session, Source +import pytest +from pocketstation import ( + PocketStationError, + Session, + SessionLifecycleState, + Source, + _native, +) def test_given_app_and_mic_when_routed_then_native_session_owns_routes(tmp_path): @@ -39,6 +46,7 @@ def test_given_selector_family_when_declared_then_each_shape_is_available(): "bundle:com.spotify.client", ) assert Source.microphone_id("device-42") + assert Source.system_mix() def test_given_invalid_process_or_platform_when_declared_then_rejected(): @@ -57,3 +65,34 @@ def test_given_session_after_start_attempt_when_reused_then_rejected(): with pytest.raises(PocketStationError, match="already started") as failure: session.capture(Source.microphone_default()) assert failure.value.code == "session.draft_frozen" + + +def test_running_session_projects_native_lifecycle_state() -> None: + session = Session() + audio = session.audio_input("owned", frame_samples_per_channel=4) + audio.output.send(session.polled_audio()) + + running = session.start() + assert running.state is SessionLifecycleState.RUNNING + assert not running.is_stopped + audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + assert running.stop().success + assert running.state is SessionLifecycleState.STOPPED + assert running.is_stopped + + +def test_system_mix_runs_through_the_canonical_capture_backend(tmp_path) -> None: + if not hasattr(_native.Session, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") + + session = Session._from_native(_native.Session.conformance(tmp_path, None, 256)) + system_mix = session.capture(Source.system_mix()) + system_mix.send(session.polled_audio()) + + running = session.start() + frame = running.audio.read(timeout_s=1.0) + stop = running.stop() + + assert frame is not None + assert frame.stem_id == system_mix.id + assert stop.success diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index c50e4a3..e258d71 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -5,16 +5,17 @@ from pathlib import Path from time import monotonic -import pytest - import pocketstation as pks import pocketstation.aio as aio +import pytest from pocketstation._native import Session as NativeSession CHILD = Path(__file__).with_name("_pkss_child.py") def session_with_product_sources(tmp_path: Path) -> pks.Session: + if not hasattr(NativeSession, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") session = pks.Session._from_native(NativeSession.conformance(tmp_path)) audio = session.polled_audio() session.capture(pks.Source.application("PocketStation Python Fixture")).send(audio) @@ -162,6 +163,8 @@ def test_sidecar_handle_is_session_scoped(tmp_path: Path) -> None: def test_asyncio_sidecar_uses_same_native_owner(tmp_path: Path) -> None: async def scenario() -> None: + if not hasattr(NativeSession, "conformance"): + pytest.skip("native extension was not built with conformance-fixtures") session = aio.Session._from_native(NativeSession.conformance(tmp_path)) audio = session.polled_audio() session.capture(pks.Source.application("PocketStation Python Fixture")).send( diff --git a/tests/test_signal_streams.py b/tests/test_signal_streams.py index 3132fe7..8972de7 100644 --- a/tests/test_signal_streams.py +++ b/tests/test_signal_streams.py @@ -97,6 +97,10 @@ def test_real_session_delivers_audio_text_and_bytes_with_complete_provenance( assert audio.signal == SignalSpec.audio() assert audio.lineage is not None + assert audio.lineage.clock.id == audio.lineage.clock_id + assert audio.lineage.clock.kind == "process-monotonic" + assert audio.lineage.clock.origin == "process-start" + assert audio.lineage.clock.tick_rate_hz == 1_000_000_000 assert audio.derivation is not None assert audio.derivation.upstream_lineage == audio.lineage assert audio_payload.source_id == audio.lineage.source_id diff --git a/tests/test_source_authoring.py b/tests/test_source_authoring.py index fd16c1e..8980541 100644 --- a/tests/test_source_authoring.py +++ b/tests/test_source_authoring.py @@ -114,6 +114,14 @@ def create(self, configuration) -> RecordingDriver: return self.driver +class FactoryWithoutValidator: + def __init__(self, driver: RecordingDriver) -> None: + self.driver = driver + + def create(self, _configuration) -> RecordingDriver: + return self.driver + + def test_driver_source_preparation_validation_and_exact_close() -> None: signal = SignalSpec.text() driver = RecordingDriver(signal) @@ -138,6 +146,26 @@ def test_driver_source_preparation_validation_and_exact_close() -> None: assert driver.closed.wait(1.0) +def test_source_factory_does_not_require_a_noop_validator() -> None: + signal = SignalSpec.text() + driver = RecordingDriver(signal) + session = Session() + instance = session.register_source( + SourceProvider.with_driver( + text_manifest("io.pocketstation.source.no-validator-test.v1"), + FactoryWithoutValidator(driver), + ) + ).declare() + subscription = session.subscribe(instance.output("events"), signal=signal) + + with session.start() as running: + assert isinstance( + running.signals(subscription).read(timeout_s=1.0), SignalEnvelope + ) + + assert driver.closed.wait(1.0) + + def test_source_manifest_rejects_pcm_and_points_to_audio_input() -> None: with pytest.raises(Exception, match=r"Session\.audio_input"): SourceManifest( diff --git a/tests/test_sources.py b/tests/test_sources.py index 6f81a21..989e83e 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,15 +1,22 @@ from __future__ import annotations from array import array -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, replace +from types import SimpleNamespace import pytest from pocketstation import ( + ApplicationPolicyObservation, AudioInputBufferError, AudioInputClosedError, AudioInputConfig, AudioInputFullError, + CaptureCapabilityState, + CaptureOpenOutcome, + CaptureScopeKind, + CaptureSessionGrant, DiscoveredSource, + PermissionObservation, Platform, PocketStationError, ProcessTreeScope, @@ -56,6 +63,9 @@ def test_source_declarations_are_immutable_and_descriptive() -> None: assert application.selector_value == "PocketStation Fixture" assert microphone.kind is SourceKind.INPUT_DEVICE assert microphone.selector_kind is SourceSelectorKind.MICROPHONE_DEFAULT + system_mix = Source.system_mix() + assert system_mix.kind is SourceKind.SYSTEM_MIX + assert system_mix.selector_kind is SourceSelectorKind.SYSTEM_MIX with pytest.raises(FrozenInstanceError): application.selector_value = "changed" @@ -74,12 +84,49 @@ def test_discovered_input_device_lowers_to_microphone_id() -> None: assert selected.selector_value == "device-42" -@pytest.mark.parametrize("kind", [SourceKind.SYSTEM_MIX, SourceKind.OUTPUT_DEVICE]) -def test_discovery_does_not_fabricate_unsupported_builtin_session_sources( - kind: SourceKind, -) -> None: +def test_discovered_system_mix_lowers_to_builtin_session_source() -> None: + selected = Source.from_discovered(_discovered(SourceKind.SYSTEM_MIX)) + + assert selected.kind is SourceKind.SYSTEM_MIX + assert selected.selector_kind is SourceSelectorKind.SYSTEM_MIX + + +def test_discovered_source_projects_typed_pre_open_authorization_evidence() -> None: + native_snapshot = SimpleNamespace( + capability="available", + os_permission="allowed", + application_policy="allowed", + session_grant="granted-by-explicit-selection", + capture_scope="exact-application", + scope_stable_id="fixture:application", + identity_strength="platform-stable-id", + permission_epoch=4, + observed_at_ns=10, + open_outcome="not-attempted", + ) + discovered = replace( + _discovered(SourceKind.APPLICATION), + _native=SimpleNamespace( + authorization_before_open=lambda *_args: native_snapshot + ), + ) + + snapshot = discovered.authorization_before_open( + os_permission=PermissionObservation.ALLOWED, + application_policy=ApplicationPolicyObservation.ALLOWED, + session_grant=CaptureSessionGrant.GRANTED_BY_EXPLICIT_SELECTION, + permission_epoch=4, + ) + + assert snapshot.capability is CaptureCapabilityState.AVAILABLE + assert snapshot.capture_scope is CaptureScopeKind.EXACT_APPLICATION + assert snapshot.open_outcome is CaptureOpenOutcome.NOT_ATTEMPTED + assert snapshot.permission_epoch == 4 + + +def test_discovery_does_not_fabricate_output_device_session_source() -> None: with pytest.raises(PocketStationError) as failure: - Source.from_discovered(_discovered(kind)) + Source.from_discovered(_discovered(SourceKind.OUTPUT_DEVICE)) assert failure.value.code == "source.unsupported_session_kind" @@ -113,6 +160,7 @@ def test_application_owned_pcm_uses_the_canonical_source_and_recording_path( assert frame.source_id == audio.source_id assert frame.stream_id == audio.stream_id assert frame.sequence_number == 0 + assert not hasattr(frame, "sequence_num") assert frame.discontinuity_epoch == 1 assert list(frame.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) assert audio.observations().accepted_total == 1 @@ -150,3 +198,30 @@ def test_audio_input_reports_invalid_full_and_closed_without_blocking() -> None: assert observations.full_total == 1 assert observations.invalid_total == 1 assert observations.closed + + +def test_audio_input_write_waits_finitely_without_hiding_nonblocking_try_write() -> ( + None +): + session = Session() + audio = session.audio_input( + "generated", + capacity_frames=1, + frame_samples_per_channel=4, + ) + samples = array("f", [0.0, 0.0, 0.0, 0.0]) + audio.try_write(samples) + + with pytest.raises(AudioInputFullError) as full: + audio.write(samples, timeout_s=0.005) + + assert full.value.code == "audio_input.full" + assert audio.observations().full_total > 0 + + +@pytest.mark.parametrize("timeout", [True, -0.1, 60.1]) +def test_audio_input_write_rejects_invalid_timeouts(timeout: object) -> None: + audio = Session().audio_input("generated", frame_samples_per_channel=4) + + with pytest.raises((TypeError, ValueError)): + audio.write(array("f", [0.0] * 4), timeout_s=timeout) # type: ignore[arg-type] diff --git a/tests/test_streams.py b/tests/test_streams.py index 3c6cca9..a582d01 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -5,8 +5,8 @@ import threading import pytest - from pocketstation import ( + STREAM_EOF, AudioStream, RunningSession, StreamInUseError, @@ -69,6 +69,7 @@ def test_running_session_exposes_the_same_exclusive_stream() -> None: class NativeRunning: def __init__(self) -> None: self.batches = [["a"]] + self.lifecycle_state = "running" def poll_audio(self): return None @@ -149,6 +150,21 @@ def test_iteration_rejects_a_busy_poll_timeout() -> None: next(stream.frames(wait_timeout_s=0.0)) +def test_audio_batch_result_distinguishes_empty_timeout_and_closed() -> None: + state = {"closed": False} + stream = AudioStream( + poll_batch=lambda: None, + wait_batch=lambda _timeout_ms: None, + is_closed=lambda: state["closed"], + ) + + assert stream.poll() is None + assert stream.read_result(timeout_s=0.001) is None + state["closed"] = True + assert stream.poll() is STREAM_EOF + assert stream.read_result(timeout_s=0.001) is STREAM_EOF + + def test_frame_stream_preserves_two_stems_from_canonical_native_session( tmp_path, ) -> None: @@ -166,6 +182,16 @@ def test_frame_stream_preserves_two_stems_from_canonical_native_session( assert first.sequence_number >= 0 assert first.timestamp_start_ns >= 0 assert first.discontinuity_epoch >= 0 + assert first.clock.id == first.clock_id + assert first.clock.kind == "process-monotonic" + assert first.clock.origin == "process-start" + assert first.clock.tick_rate_hz == 1_000_000_000 + assert first.route_enqueued_at_ns > 0 + assert first.route_received_at_ns >= first.route_enqueued_at_ns + assert first.endpoint_enqueued_at_ns is not None + assert first.endpoint_enqueued_at_ns >= first.route_received_at_ns + assert first.polled_at_ns is not None + assert first.polled_at_ns >= first.endpoint_enqueued_at_ns assert first.samples.readonly with pytest.raises(StreamInUseError): From fb3bb4831aa35d6d9a18b1db5f65ea9bb713758a Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 20:05:17 -0400 Subject: [PATCH 13/49] feat: expose generic Python endpoint authoring --- README.md | 19 + native/src/endpoint_authoring.rs | 767 ++++++++++++++++++ native/src/lib.rs | 2 + native/src/session.rs | 20 + python/pocketstation/__init__.py | 36 + python/pocketstation/_native.pyi | 60 ++ python/pocketstation/aio/__init__.py | 38 + .../pocketstation/aio/endpoint_authoring.py | 290 +++++++ python/pocketstation/aio/session.py | 36 + python/pocketstation/endpoint_authoring.py | 476 +++++++++++ python/pocketstation/session.py | 25 + tests/installed_consumer.py | 67 +- tests/test_aio_session.py | 74 ++ tests/test_endpoint_authoring.py | 221 +++++ tests/test_public_api.py | 21 +- 15 files changed, 2149 insertions(+), 3 deletions(-) create mode 100644 native/src/endpoint_authoring.rs create mode 100644 python/pocketstation/aio/endpoint_authoring.py create mode 100644 python/pocketstation/endpoint_authoring.py create mode 100644 tests/test_endpoint_authoring.py diff --git a/README.md b/README.md index dfbfabf..bd71f90 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,25 @@ by default and requires explicit provider access. Provider exceptions can use `ConnectorError` to preserve a stable error code, stage, and retryability in the final Session outcome. +### Generic Endpoints are an advanced escape hatch + +Use a `Connector` for an outbound provider integration. Use `EndpointProvider` +only when an integration needs Core's lower-level Endpoint SPI without the +Connector service model: + +```python +provider = pocketstation.EndpointProvider(manifest, prepare) +endpoint = session.register_endpoint(provider).declare(configuration) +audio.output.send(endpoint) +``` + +Core still owns graph compilation, bounded input receivers, transactional +prepare/start rollback, the closed start gate, drain versus abort, join, and +terminal outcomes. The Python implementation owns only its off-realtime worker +and provider resources. `pocketstation.aio.EndpointProvider` projects the same +contract onto one owning event loop with finite prepare, start, and shutdown +deadlines. Neither API creates a Python Session or media engine. + The capability matrix distinguishes declaration-level `REAL` rows from component-only `PARTIAL` rows and completely `ABSENT` projections. A row marked `REAL` is evidence-scoped; it does not upgrade the SDK, a platform, or a diff --git a/native/src/endpoint_authoring.rs b/native/src/endpoint_authoring.rs new file mode 100644 index 0000000..031204c --- /dev/null +++ b/native/src/endpoint_authoring.rs @@ -0,0 +1,767 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use pocketstation::graph::NodeConfig; +use pocketstation::{ + ConfigError, EndpointCancellationOutcome, EndpointDriverFactory, EndpointDriverFinalization, + EndpointDriverObservations, EndpointFailure, EndpointFailureRetryability, EndpointFailureStage, + EndpointInputOrigin, EndpointPortInput, EndpointPreparationGroup, EndpointReceiver, + EndpointShutdownMode, EndpointStartGate, ExecutionPartition, NodeDefinition, NodeDescriptor, + NodeTypeId, OperatorId, PreparedEndpointDriver, RunningEndpointDriver, SafetyContract, Session, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::errors::{coded_reason, session_endpoint_error}; +use crate::graph::{ + PythonEdgeContract, PythonEndpoint, PythonMediaCaps, PythonPortSpec, PythonSignalSpec, +}; +use crate::signals::{copy_envelope, python_envelope, PythonSignalEnvelope}; +use crate::streams::{owned_endpoint_audio_frame_for_route, python_audio_frame, PythonAudioFrame}; + +const MAXIMUM_ENDPOINT_ERROR_MESSAGE_BYTES: usize = 4_096; + +#[pyclass(name = "_EndpointManifest", frozen)] +#[derive(Clone)] +pub(crate) struct PythonEndpointManifest { + operator_id: OperatorId, + descriptor: NodeDescriptor, +} + +#[pymethods] +impl PythonEndpointManifest { + #[new] + #[pyo3(signature = (operator_id, node_type_id, inputs))] + fn new( + py: Python<'_>, + operator_id: String, + node_type_id: String, + inputs: Vec>, + ) -> PyResult { + validate_contract_id("operator ID", &operator_id)?; + validate_contract_id("node type ID", &node_type_id)?; + if inputs.is_empty() { + return Err(invalid_endpoint( + "Endpoint manifest needs at least one input", + )); + } + let inputs = inputs + .into_iter() + .map(|input| input.borrow(py).value.clone()) + .collect::>(); + if inputs + .iter() + .any(|input| input.direction() != pocketstation::PortDirection::Input) + { + return Err(invalid_endpoint( + "Endpoint manifest ports must all be inputs", + )); + } + let descriptor = NodeDescriptor::new( + NodeTypeId::from(node_type_id.as_str()), + "Python Endpoint", + inputs, + Vec::new(), + ExecutionPartition::External, + SafetyContract::ExternalService, + true, + ) + .map_err(|error| invalid_endpoint(error.to_string()))?; + Ok(Self { + operator_id: OperatorId::new(operator_id), + descriptor, + }) + } + + #[getter] + fn operator_id(&self) -> &str { + self.operator_id.as_str() + } + + #[getter] + fn node_type_id(&self) -> &str { + self.descriptor.type_id().as_str() + } +} + +struct PythonEndpointDefinition { + manifest: PythonEndpointManifest, + factory: Py, +} + +impl NodeDefinition for PythonEndpointDefinition { + fn descriptor(&self) -> NodeDescriptor { + self.manifest.descriptor.clone() + } + + fn validate_config(&self, configuration: &NodeConfig) -> Result<(), ConfigError> { + Python::attach(|py| { + let values = + node_configuration(py, configuration).map_err(|error| ConfigError::Invalid { + key: "".to_owned(), + reason: error.to_string(), + })?; + self.factory + .bind(py) + .call_method1("validate_configuration", (values,)) + .map(|_| ()) + .map_err(|error| ConfigError::Invalid { + key: "".to_owned(), + reason: bounded_message(error.to_string()), + }) + }) + } +} + +struct PythonEndpointFactory { + factory: Py, +} + +impl EndpointDriverFactory for PythonEndpointFactory { + fn preparation_group( + &self, + route_id: pocketstation::RouteId, + configuration: &NodeConfig, + ) -> Result { + Python::attach(|py| { + let values = node_configuration(py, configuration) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Prepare))?; + let result = self + .factory + .bind(py) + .call_method1("preparation_group", (route_id.get(), values)) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Prepare))?; + if result.is_none() { + Ok(EndpointPreparationGroup::Route(route_id)) + } else { + let group = result + .extract::() + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Prepare))?; + if group.trim().is_empty() { + return Err(EndpointFailure::new( + EndpointFailureStage::Prepare, + "Endpoint preparation group cannot be empty", + ) + .with_external_details( + "endpoint.invalid_preparation_group", + EndpointFailureRetryability::ReconfigurationRequired, + )); + } + Ok(EndpointPreparationGroup::Shared( + pocketstation::EndpointGroupId::new(group), + )) + } + }) + } + + fn prepare( + &self, + inputs: Vec, + ) -> Result, EndpointFailure> { + Python::attach(|py| { + let inputs = inputs + .into_iter() + .map(|input| python_port_input(py, input)) + .collect::>>() + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Prepare))?; + let prepared = self + .factory + .bind(py) + .call_method1("prepare", (inputs,)) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Prepare))? + .unbind(); + Ok(Box::new(PythonPreparedEndpoint { + prepared, + completed: false, + }) as Box) + }) + } +} + +struct PythonPreparedEndpoint { + prepared: Py, + completed: bool, +} + +impl Drop for PythonPreparedEndpoint { + fn drop(&mut self) { + if !self.completed { + Python::attach(|py| { + let _ = self.prepared.bind(py).call_method0("cancel_preparation"); + }); + } + } +} + +impl PreparedEndpointDriver for PythonPreparedEndpoint { + fn start( + mut self: Box, + start_gate: Arc, + ) -> Result, EndpointFailure> { + Python::attach(|py| { + let gate = Py::new(py, PythonEndpointStartGate { gate: start_gate }) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Start))?; + let running = self + .prepared + .bind(py) + .call_method1("start", (gate,)) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::Start))? + .unbind(); + self.completed = true; + Ok(Box::new(PythonRunningEndpoint { + running, + finalized: false, + }) as Box) + }) + } + + fn cancel_preparation(mut self: Box) -> EndpointCancellationOutcome { + let result = Python::attach(|py| { + self.prepared + .bind(py) + .call_method0("cancel_preparation") + .map(|_| ()) + .map_err(|error| { + endpoint_failure(py, error, EndpointFailureStage::CancelPreparation) + }) + }); + self.completed = true; + EndpointCancellationOutcome { + observations: EndpointDriverObservations::default(), + result, + } + } +} + +struct PythonRunningEndpoint { + running: Py, + finalized: bool, +} + +impl Drop for PythonRunningEndpoint { + fn drop(&mut self) { + if !self.finalized { + Python::attach(|py| { + let value = self.running.bind(py); + let _ = value.call_method1("request_shutdown", ("abort",)); + let _ = value.call_method0("join_and_finalize"); + }); + } + } +} + +impl RunningEndpointDriver for PythonRunningEndpoint { + fn observations(&self) -> EndpointDriverObservations { + Python::attach(|py| python_observations(py, self.running.bind(py)).unwrap_or_default()) + } + + fn request_stop(&mut self) -> Result<(), EndpointFailure> { + self.request_shutdown(EndpointShutdownMode::Drain) + } + + fn request_shutdown(&mut self, mode: EndpointShutdownMode) -> Result<(), EndpointFailure> { + Python::attach(|py| { + self.running + .bind(py) + .call_method1("request_shutdown", (shutdown_mode_name(mode),)) + .map(|_| ()) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::RequestStop)) + }) + } + + fn join_and_finalize(mut self: Box) -> EndpointDriverFinalization { + let result = Python::attach(|py| { + let observations = self + .running + .bind(py) + .call_method0("join_and_finalize") + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::JoinFinalize))?; + python_observations(py, &observations) + .map_err(|error| endpoint_failure(py, error, EndpointFailureStage::JoinFinalize)) + }); + self.finalized = true; + match result { + Ok(observations) => EndpointDriverFinalization { + observations, + result: Ok(()), + }, + Err(error) => EndpointDriverFinalization { + observations: EndpointDriverObservations::default(), + result: Err(error), + }, + } + } +} + +#[pyclass(name = "EndpointStartGate", frozen)] +struct PythonEndpointStartGate { + gate: Arc, +} + +#[pymethods] +impl PythonEndpointStartGate { + #[getter] + fn is_open(&self) -> bool { + self.gate.is_open() + } +} + +#[pyclass(name = "EndpointPrepareContext", frozen)] +struct PythonEndpointPrepareContext { + #[pyo3(get)] + session_id: u64, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + connector_id: Option, + #[pyo3(get)] + route_id: u64, + #[pyo3(get)] + origin_kind: &'static str, + #[pyo3(get)] + source_id: Option, + #[pyo3(get)] + stream_id: Option, + #[pyo3(get)] + stem_id: Option, + #[pyo3(get)] + session_timeline_origin_ns: u64, + configuration: HashMap, +} + +#[pymethods] +impl PythonEndpointPrepareContext { + #[getter] + fn configuration(&self) -> HashMap { + self.configuration.clone() + } +} + +#[pyclass(name = "EndpointReceiver")] +struct PythonEndpointReceiver { + receiver: Mutex, + endpoint_id: u64, + connector_id: Option, + route_id: u64, +} + +#[pymethods] +impl PythonEndpointReceiver { + fn try_recv(&self, py: Python<'_>) -> PyResult> { + let mut receiver = self + .receiver + .lock() + .map_err(|_| invalid_endpoint("Endpoint receiver is unavailable"))?; + match &mut *receiver { + EndpointReceiver::Audio { receiver, .. } => receiver + .try_recv() + .map(|frame| { + let frame = owned_endpoint_audio_frame_for_route( + frame, + self.endpoint_id, + self.connector_id, + self.route_id, + ); + Py::new(py, python_audio_frame(py, frame)).map(|audio| PythonEndpointItem { + kind: "audio", + audio: Some(audio), + signal: None, + }) + }) + .transpose(), + EndpointReceiver::Signal(receiver) => receiver + .try_recv() + .map(|signal| { + Py::new(py, python_envelope(py, copy_envelope(&signal))?).map(|signal| { + PythonEndpointItem { + kind: "signal", + audio: None, + signal: Some(signal), + } + }) + }) + .transpose(), + } + } + + fn is_abandoned(&self) -> bool { + let Ok(receiver) = self.receiver.lock() else { + return true; + }; + match &*receiver { + EndpointReceiver::Audio { receiver, .. } => receiver.is_abandoned(), + EndpointReceiver::Signal(receiver) => receiver.is_abandoned(), + } + } + + fn mark_discontinuity(&self) { + let Ok(receiver) = self.receiver.lock() else { + return; + }; + if let EndpointReceiver::Audio { receiver, .. } = &*receiver { + receiver.mark_discontinuity(); + } + } + + fn mark_worker_failure(&self) { + let Ok(receiver) = self.receiver.lock() else { + return; + }; + if let EndpointReceiver::Audio { receiver, .. } = &*receiver { + receiver.mark_worker_failure(); + } + } +} + +#[pyclass(name = "EndpointItem", frozen)] +struct PythonEndpointItem { + #[pyo3(get)] + kind: &'static str, + audio: Option>, + signal: Option>, +} + +#[pymethods] +impl PythonEndpointItem { + #[getter] + fn audio(&self, py: Python<'_>) -> Option> { + self.audio.as_ref().map(|value| value.clone_ref(py)) + } + + #[getter] + fn signal(&self, py: Python<'_>) -> Option> { + self.signal.as_ref().map(|value| value.clone_ref(py)) + } +} + +#[pyclass(name = "EndpointPortInput", frozen)] +struct PythonEndpointPortInput { + #[pyo3(get)] + port_name: String, + signal: Py, + media: Py, + edge: Py, + context: Py, + receiver: Py, +} + +#[pymethods] +impl PythonEndpointPortInput { + #[getter] + fn signal(&self, py: Python<'_>) -> Py { + self.signal.clone_ref(py) + } + + #[getter] + fn media(&self, py: Python<'_>) -> Py { + self.media.clone_ref(py) + } + + #[getter] + fn edge(&self, py: Python<'_>) -> Py { + self.edge.clone_ref(py) + } + + #[getter] + fn context(&self, py: Python<'_>) -> Py { + self.context.clone_ref(py) + } + + #[getter] + fn receiver(&self, py: Python<'_>) -> Py { + self.receiver.clone_ref(py) + } +} + +#[pyclass(name = "_RegisteredEndpoint", frozen)] +pub(crate) struct PythonRegisteredEndpoint { + session_id: pocketstation::SessionId, + operator_id: OperatorId, + node_type_id: NodeTypeId, +} + +#[pymethods] +impl PythonRegisteredEndpoint { + #[getter] + fn session_id(&self) -> u64 { + self.session_id.get() + } + + #[getter] + fn operator_id(&self) -> &str { + self.operator_id.as_str() + } + + #[getter] + fn node_type_id(&self) -> &str { + self.node_type_id.as_str() + } +} + +pub(crate) fn register_endpoint( + session: &Session, + manifest: &PythonEndpointManifest, + factory: Py, +) -> PyResult { + Python::attach(|py| { + session.register_endpoint( + manifest.operator_id.clone(), + Arc::new(PythonEndpointDefinition { + manifest: manifest.clone(), + factory: factory.clone_ref(py), + }), + Arc::new(PythonEndpointFactory { factory }), + ) + }) + .map_err(session_endpoint_error)?; + Ok(PythonRegisteredEndpoint { + session_id: session.id(), + operator_id: manifest.operator_id.clone(), + node_type_id: manifest.descriptor.type_id().clone(), + }) +} + +pub(crate) fn declare_endpoint( + session: &Session, + registered: &PythonRegisteredEndpoint, + configuration: HashMap, + edge: &PythonEdgeContract, +) -> PyResult { + if registered.session_id != session.id() { + return Err(PyValueError::new_err(coded_reason( + "endpoint.wrong_session", + "registered Endpoint belongs to a different Session", + ))); + } + let configuration = configuration.into_iter().fold( + pocketstation::EndpointConfiguration::new(), + |configuration, (key, value)| configuration.with(key, value), + ); + session + .endpoint( + pocketstation::EndpointDescriptor::new( + registered.node_type_id.clone(), + registered.operator_id.clone(), + ) + .with_configuration(configuration) + .with_input_edge(edge.value), + ) + .map(|handle| PythonEndpoint { handle }) + .map_err(crate::errors::session_error) +} + +fn python_port_input( + py: Python<'_>, + input: EndpointPortInput, +) -> PyResult> { + let port_name = input.port_name().to_owned(); + let signal = Py::new( + py, + PythonSignalSpec { + value: input.signal_spec().clone(), + }, + )?; + let media = Py::new( + py, + PythonMediaCaps { + value: *input.media(), + }, + )?; + let edge = Py::new( + py, + PythonEdgeContract { + value: *input.edge_contract(), + }, + )?; + let prepare = input.context(); + let route = prepare.route_context(); + let (origin_kind, source_id, stream_id, stem_id) = match route.origin() { + EndpointInputOrigin::Stem(stem_id) => ("stem", None, None, Some(stem_id.get())), + EndpointInputOrigin::Signal => ("signal", None, None, None), + EndpointInputOrigin::Source { + source_id, + stream_id, + audio_stem_id, + } => ( + "source", + Some(source_id.get()), + Some(stream_id.get()), + audio_stem_id.map(pocketstation::StemId::get), + ), + }; + let endpoint_id = prepare.endpoint_id().get(); + let connector_id = prepare.connector_id().map(pocketstation::ConnectorId::get); + let route_id = route.route_id().get(); + let context = Py::new( + py, + PythonEndpointPrepareContext { + session_id: prepare.session_id().get(), + endpoint_id, + connector_id, + route_id, + origin_kind, + source_id, + stream_id, + stem_id, + session_timeline_origin_ns: prepare.session_timeline_origin().monotonic_timestamp_ns(), + configuration: prepare + .node_configuration() + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(), + }, + )?; + let (receiver, _) = input.into_parts(); + let receiver = Py::new( + py, + PythonEndpointReceiver { + receiver: Mutex::new(receiver), + endpoint_id, + connector_id, + route_id, + }, + )?; + Py::new( + py, + PythonEndpointPortInput { + port_name, + signal, + media, + edge, + context, + receiver, + }, + ) +} + +fn node_configuration<'py>( + py: Python<'py>, + configuration: &NodeConfig, +) -> PyResult> { + let values = PyDict::new(py); + for (key, value) in configuration.iter() { + values.set_item(key, value)?; + } + Ok(values) +} + +fn python_observations( + _py: Python<'_>, + value: &Bound<'_, PyAny>, +) -> PyResult { + let value = if value.hasattr("observations")? { + let observations = value.getattr("observations")?; + if observations.is_callable() { + observations.call0()? + } else { + observations + } + } else { + value.clone() + }; + Ok(EndpointDriverObservations { + frames_received_total: observation_value(&value, "frames_received_total")?, + frames_delivered_total: observation_value(&value, "frames_delivered_total")?, + frames_dropped_total: observation_value(&value, "frames_dropped_total")?, + discontinuities_total: observation_value(&value, "discontinuities_total")?, + failures_total: observation_value(&value, "failures_total")?, + }) +} + +fn observation_value(value: &Bound<'_, PyAny>, name: &str) -> PyResult { + value.getattr(name)?.extract() +} + +fn endpoint_failure( + py: Python<'_>, + error: PyErr, + default_stage: EndpointFailureStage, +) -> EndpointFailure { + let value = error.value(py); + let stage = value + .getattr("stage") + .and_then(|value| value.extract::()) + .ok() + .and_then(|value| parse_stage(&value)) + .unwrap_or(default_stage); + let retryability = value + .getattr("retryability") + .and_then(|value| value.extract::()) + .ok() + .and_then(|value| parse_retryability(&value)) + .unwrap_or(EndpointFailureRetryability::Never); + let code = value + .getattr("code") + .and_then(|value| value.extract::()) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "python.endpoint_exception".to_owned()); + let message = value + .getattr("message") + .and_then(|value| value.extract::()) + .unwrap_or_else(|_| error.to_string()); + EndpointFailure::new(stage, bounded_message(message)).with_external_details(code, retryability) +} + +fn parse_stage(value: &str) -> Option { + match value { + "prepare" => Some(EndpointFailureStage::Prepare), + "cancel-preparation" => Some(EndpointFailureStage::CancelPreparation), + "start" => Some(EndpointFailureStage::Start), + "request-stop" => Some(EndpointFailureStage::RequestStop), + "join-finalize" => Some(EndpointFailureStage::JoinFinalize), + _ => None, + } +} + +fn parse_retryability(value: &str) -> Option { + match value { + "never" => Some(EndpointFailureRetryability::Never), + "retryable" => Some(EndpointFailureRetryability::Retryable), + "retry-after-reconfiguration" | "reconfiguration-required" => { + Some(EndpointFailureRetryability::ReconfigurationRequired) + } + _ => None, + } +} + +fn shutdown_mode_name(mode: EndpointShutdownMode) -> &'static str { + match mode { + EndpointShutdownMode::Drain => "drain", + EndpointShutdownMode::Abort => "abort", + } +} + +fn bounded_message(mut value: String) -> String { + if value.len() > MAXIMUM_ENDPOINT_ERROR_MESSAGE_BYTES { + let mut boundary = MAXIMUM_ENDPOINT_ERROR_MESSAGE_BYTES; + while boundary > 0 && !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); + } + value +} + +fn validate_contract_id(label: &str, value: &str) -> PyResult<()> { + if value.trim().is_empty() || value.trim() != value { + return Err(invalid_endpoint(format!("{label} is invalid"))); + } + Ok(()) +} + +fn invalid_endpoint(reason: impl Into) -> PyErr { + PyValueError::new_err(coded_reason("endpoint.invalid_contract", reason.into())) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/lib.rs b/native/src/lib.rs index 2b2b156..d5142db 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -2,6 +2,7 @@ pub(crate) mod audio_input; pub(crate) mod connector; +pub(crate) mod endpoint_authoring; pub(crate) mod errors; pub(crate) mod extensions; pub(crate) mod graph; @@ -21,6 +22,7 @@ use pyo3::prelude::*; fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { audio_input::register(module)?; connector::register(module)?; + endpoint_authoring::register(module)?; extensions::register(module)?; operator_authoring::register(module)?; source_authoring::register(module)?; diff --git a/native/src/session.rs b/native/src/session.rs index 90ea988..398d089 100644 --- a/native/src/session.rs +++ b/native/src/session.rs @@ -14,6 +14,9 @@ use crate::connector::{ declare_connector, register_connector, register_worker_connector, PythonConnectorConfiguration, PythonConnectorManifest, PythonRegisteredConnector, }; +use crate::endpoint_authoring::{ + declare_endpoint, register_endpoint, PythonEndpointManifest, PythonRegisteredEndpoint, +}; use crate::errors::{ coded_reason, native_extension_error, session_error, session_start_error, validate_nonempty, }; @@ -381,6 +384,14 @@ impl PythonSession { }) } + fn register_endpoint_provider( + &self, + manifest: &PythonEndpointManifest, + factory: Py, + ) -> PyResult { + self.with_session(|session| register_endpoint(session, manifest, factory)) + } + fn register_source_provider( &self, manifest: &PythonSourceManifest, @@ -406,6 +417,15 @@ impl PythonSession { self.with_session(|session| declare_connector(registered, session, configuration, edge)) } + fn declare_registered_endpoint( + &self, + registered: &PythonRegisteredEndpoint, + configuration: HashMap, + edge: &PythonEdgeContract, + ) -> PyResult { + self.with_session(|session| declare_endpoint(session, registered, configuration, edge)) + } + fn register_sidecar(&self, spec: &PythonSidecarProcessSpec) -> PyResult { self.with_session(|session| { session.register_sidecar(spec.to_core()).map_err(|error| { diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index 44ed966..47f32d7 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -56,6 +56,25 @@ SessionSnapshot, SubscriberCredentials, ) +from .endpoint_authoring import ( + EndpointConfigurationInput, + EndpointDriverBuilder, + EndpointDriverError, + EndpointDriverFactory, + EndpointDriverObservations, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointPreparationGroup, + EndpointPrepareContext, + EndpointProvider, + EndpointReceiver, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RegisteredEndpoint, + RunningEndpointDriver, +) from .errors import ( AudioInputBufferError, AudioInputCancelledError, @@ -386,12 +405,26 @@ "EndOfStream", "Endpoint", "EndpointConfiguration", + "EndpointConfigurationInput", "EndpointDescriptor", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", "EndpointFailureRetryability", "EndpointFailureStage", "EndpointId", + "EndpointItem", + "EndpointManifest", "EndpointMetrics", "EndpointObservationStage", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", "EventFormat", "EventQueueMetrics", "EventStream", @@ -436,6 +469,7 @@ "PolledAudioMetrics", "PortDirection", "PortSpec", + "PreparedEndpointDriver", "ProcessInstanceSelector", "ProcessTreeScope", "PublisherActivation", @@ -447,6 +481,7 @@ "RecordingState", "RecordingStemOutcome", "RegisteredConnector", + "RegisteredEndpoint", "RegisteredOperator", "RegisteredSource", "RelayError", @@ -460,6 +495,7 @@ "RouteLatencyUnit", "RouteMetrics", "RouteObservationInterval", + "RunningEndpointDriver", "RunningSession", "RuntimeCompatibility", "RuntimeSessionId", diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index 7e9c5cc..d8a95b5 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -1128,6 +1128,55 @@ class _OperatorEmission: @staticmethod def bytes(payload: bytes, signal: _SignalSpec) -> _OperatorEmission: ... +class _EndpointManifest: + def __init__( + self, + operator_id: str, + node_type_id: str, + inputs: list[_PortSpec], + ) -> None: ... + operator_id: str + node_type_id: str + +class EndpointStartGate: + is_open: bool + +class EndpointPrepareContext: + session_id: int + endpoint_id: int + connector_id: int | None + route_id: int + origin_kind: str + source_id: int | None + stream_id: int | None + stem_id: int | None + session_timeline_origin_ns: int + configuration: dict[str, str] + +class EndpointItem: + kind: str + audio: AudioFrame | None + signal: _SignalEnvelope | None + +class EndpointReceiver: + def try_recv(self) -> EndpointItem | None: ... + def is_abandoned(self) -> bool: ... + def mark_discontinuity(self) -> None: ... + def mark_worker_failure(self) -> None: ... + +class EndpointPortInput: + port_name: str + signal: _SignalSpec + media: _MediaCaps + edge: _EdgeContract + context: EndpointPrepareContext + receiver: EndpointReceiver + +class _RegisteredEndpoint: + session_id: int + operator_id: str + node_type_id: str + class _OperatorPortContext: edge_id: int | None port_name: str @@ -1203,6 +1252,11 @@ class Session: factory: object, maximum_batch_items: int, ) -> _RegisteredConnector: ... + def register_endpoint_provider( + self, + manifest: _EndpointManifest, + factory: object, + ) -> _RegisteredEndpoint: ... def register_source_provider( self, manifest: _SourceManifest, @@ -1219,6 +1273,12 @@ class Session: configuration: _ConnectorConfiguration, edge: _EdgeContract, ) -> Endpoint: ... + def declare_registered_endpoint( + self, + registered: _RegisteredEndpoint, + configuration: dict[str, str], + edge: _EdgeContract, + ) -> Endpoint: ... def load_native_extension_library( self, path: Path, diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index 53d839c..9e97348 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -44,6 +44,26 @@ connector, ) from .control import ControlClient +from .endpoint_authoring import ( + EndpointConfigurationInput, + EndpointDeadlines, + EndpointDriverBuilder, + EndpointDriverError, + EndpointDriverFactory, + EndpointDriverObservations, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointPreparationGroup, + EndpointPrepareContext, + EndpointProvider, + EndpointReceiver, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RegisteredEndpoint, + RunningEndpointDriver, +) from .extensions import ( ExtensionAbiVersion, ExtensionDescriptor, @@ -136,6 +156,21 @@ "ConnectorWorker", "ConnectorWorkerBuilder", "ControlClient", + "EndpointConfigurationInput", + "EndpointDeadlines", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", + "EndpointItem", + "EndpointManifest", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", "EventStream", "ExtensionAbiVersion", "ExtensionDescriptor", @@ -155,10 +190,13 @@ "OperatorPrepareContext", "OperatorProvider", "PcmSource", + "PreparedEndpointDriver", "RegisteredConnector", + "RegisteredEndpoint", "RegisteredOperator", "RegisteredSource", "RelaySession", + "RunningEndpointDriver", "RunningSession", "Session", "SidecarConnection", diff --git a/python/pocketstation/aio/endpoint_authoring.py b/python/pocketstation/aio/endpoint_authoring.py new file mode 100644 index 0000000..b00512e --- /dev/null +++ b/python/pocketstation/aio/endpoint_authoring.py @@ -0,0 +1,290 @@ +"""Bounded asyncio projection of Core's advanced Endpoint lifecycle.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine, Mapping, Sequence +from concurrent.futures import CancelledError as FutureCancelledError +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from dataclasses import dataclass +from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable + +from ..endpoint_authoring import ( + EndpointConfigurationInput, + EndpointDriverError, + EndpointDriverObservations, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointPreparationGroup, + EndpointPrepareContext, + EndpointReceiver, + EndpointShutdownMode, + EndpointStartGate, +) +from ..endpoint_authoring import EndpointProvider as SyncEndpointProvider +from ..endpoint_authoring import PreparedEndpointDriver as SyncPreparedEndpointDriver +from ..endpoint_authoring import RegisteredEndpoint as SyncRegisteredEndpoint +from ..endpoint_authoring import RunningEndpointDriver as SyncRunningEndpointDriver +from ..graph import EdgeContract, Endpoint +from ..observations import EndpointFailureStage + +_Result = TypeVar("_Result") + + +@dataclass(frozen=True, slots=True) +class EndpointDeadlines: + """Finite waits while Core awaits asyncio Endpoint lifecycle work.""" + + prepare_s: float = 5.0 + start_s: float = 5.0 + shutdown_s: float = 5.0 + + def __post_init__(self) -> None: + for name, value in ( + ("prepare_s", self.prepare_s), + ("start_s", self.start_s), + ("shutdown_s", self.shutdown_s), + ): + if not 0 < value <= 300: + raise ValueError(f"{name} must be greater than 0 and at most 300") + + +class PreparedEndpointDriver: + """Async prepared resources held behind Core's closed start gate.""" + + async def start(self, gate: EndpointStartGate) -> RunningEndpointDriver: + raise NotImplementedError + + async def cancel_preparation(self) -> None: + """Release prepared resources during transactional rollback.""" + + +class RunningEndpointDriver: + """Async Endpoint resources owned until Core joins finalization.""" + + async def observations(self) -> EndpointDriverObservations: + return EndpointDriverObservations() + + async def request_shutdown(self, mode: EndpointShutdownMode) -> None: + """Request finite drain or immediate abort.""" + + async def join_and_finalize(self) -> EndpointDriverObservations: + return await self.observations() + + +@runtime_checkable +class EndpointDriverFactory(Protocol): + async def prepare( + self, inputs: Sequence[EndpointPortInput] + ) -> PreparedEndpointDriver: ... + + +EndpointDriverBuilder: TypeAlias = Callable[ + [Sequence[EndpointPortInput]], Coroutine[Any, Any, PreparedEndpointDriver] +] +EndpointConfigurationValidator: TypeAlias = Callable[[Mapping[str, str]], None] + + +class _RunningAdapter(SyncRunningEndpointDriver): + __slots__ = ("_deadlines", "_loop", "_running") + + def __init__( + self, + running: RunningEndpointDriver, + loop: asyncio.AbstractEventLoop, + deadlines: EndpointDeadlines, + ) -> None: + self._running = running + self._loop = loop + self._deadlines = deadlines + + def observations(self) -> EndpointDriverObservations: + return _wait_for_provider( + self._loop, + self._running.observations(), + timeout_s=self._deadlines.shutdown_s, + stage=EndpointFailureStage.JOIN_FINALIZE, + ) + + def request_shutdown(self, mode: EndpointShutdownMode) -> None: + _wait_for_provider( + self._loop, + self._running.request_shutdown(mode), + timeout_s=self._deadlines.shutdown_s, + stage=EndpointFailureStage.REQUEST_STOP, + ) + + def join_and_finalize(self) -> EndpointDriverObservations: + return _wait_for_provider( + self._loop, + self._running.join_and_finalize(), + timeout_s=self._deadlines.shutdown_s, + stage=EndpointFailureStage.JOIN_FINALIZE, + ) + + +class _PreparedAdapter(SyncPreparedEndpointDriver): + __slots__ = ("_deadlines", "_loop", "_prepared") + + def __init__( + self, + prepared: PreparedEndpointDriver, + loop: asyncio.AbstractEventLoop, + deadlines: EndpointDeadlines, + ) -> None: + self._prepared = prepared + self._loop = loop + self._deadlines = deadlines + + def start(self, gate: EndpointStartGate) -> SyncRunningEndpointDriver: + running = _wait_for_provider( + self._loop, + self._prepared.start(gate), + timeout_s=self._deadlines.start_s, + stage=EndpointFailureStage.START, + ) + return _RunningAdapter(running, self._loop, self._deadlines) + + def cancel_preparation(self) -> None: + _wait_for_provider( + self._loop, + self._prepared.cancel_preparation(), + timeout_s=self._deadlines.shutdown_s, + stage=EndpointFailureStage.CANCEL_PREPARATION, + ) + + +class _FactoryAdapter: + __slots__ = ("_deadlines", "_factory", "_loop") + + def __init__( + self, + factory: EndpointDriverFactory | EndpointDriverBuilder, + loop: asyncio.AbstractEventLoop, + deadlines: EndpointDeadlines, + ) -> None: + self._factory = factory + self._loop = loop + self._deadlines = deadlines + + def prepare(self, inputs: Sequence[EndpointPortInput]) -> _PreparedAdapter: + prepare = getattr(self._factory, "prepare", None) + awaitable = ( + self._factory(inputs) # type: ignore[operator] + if prepare is None + else prepare(inputs) + ) + prepared = _wait_for_provider( + self._loop, + awaitable, + timeout_s=self._deadlines.prepare_s, + stage=EndpointFailureStage.PREPARE, + ) + return _PreparedAdapter(prepared, self._loop, self._deadlines) + + +@dataclass(frozen=True, slots=True) +class EndpointProvider: + """Reusable asyncio implementation of Core's advanced Endpoint SPI.""" + + manifest: EndpointManifest + factory: EndpointDriverFactory | EndpointDriverBuilder + deadlines: EndpointDeadlines = EndpointDeadlines() + validate_configuration: EndpointConfigurationValidator | None = None + preparation_group: EndpointPreparationGroup | None = None + + def _bind(self, loop: asyncio.AbstractEventLoop) -> SyncEndpointProvider: + if not loop.is_running(): + raise RuntimeError("async Endpoint requires a running event loop") + return SyncEndpointProvider( + self.manifest, + _FactoryAdapter(self.factory, loop, self.deadlines), + validate_configuration=self.validate_configuration, + preparation_group=self.preparation_group, + ) + + +class RegisteredEndpoint: + """Async Session view of one Core-registered advanced Endpoint.""" + + __slots__ = ("_registered",) + + def __init__(self, registered: SyncRegisteredEndpoint) -> None: + self._registered = registered + + @property + def session_id(self) -> int: + return self._registered.session_id + + def declare( + self, + configuration: EndpointConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + return self._registered.declare(configuration, edge=edge) + + +def _wait_for_provider( + loop: asyncio.AbstractEventLoop, + awaitable: Coroutine[Any, Any, _Result], + *, + timeout_s: float, + stage: EndpointFailureStage, +) -> _Result: + try: + future: Future[_Result] = asyncio.run_coroutine_threadsafe(awaitable, loop) + except RuntimeError as error: + awaitable.close() + raise EndpointDriverError( + "asyncio Endpoint event loop is not available", + code="python.async.loop_unavailable", + stage=stage, + ) from error + try: + return future.result(timeout_s) + except FutureTimeoutError as error: + future.cancel() + raise EndpointDriverError( + f"asyncio Endpoint operation exceeded {timeout_s:g} seconds", + code="python.async.timeout", + stage=stage, + ) from error + except FutureCancelledError as error: + raise EndpointDriverError( + "asyncio Endpoint operation was cancelled", + code="python.async.cancelled", + stage=stage, + ) from error + except EndpointDriverError: + raise + except Exception as error: + raise EndpointDriverError( + str(error), + code="python.async.endpoint_exception", + stage=stage, + ) from error + + +__all__ = [ + "EndpointConfigurationInput", + "EndpointDeadlines", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", + "EndpointItem", + "EndpointManifest", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", + "PreparedEndpointDriver", + "RegisteredEndpoint", + "RunningEndpointDriver", +] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index 8993c22..dfef890 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -19,11 +19,14 @@ Session as _NativeSession, ) from .._native import _RegisteredConnector as _NativeRegisteredConnector +from .._native import _RegisteredEndpoint as _NativeRegisteredEndpoint from ..audio_input import AudioInputConfig from ..audio_input import PcmSource as SyncPcmSource from ..connector import Connector as SyncConnector from ..connector import ConnectorConfigurationInput from ..connector import RegisteredConnector as SyncRegisteredConnector +from ..endpoint_authoring import EndpointProvider as SyncEndpointProvider +from ..endpoint_authoring import RegisteredEndpoint as SyncRegisteredEndpoint from ..errors import PocketStationError, _native_call, _normalize_native_error from ..extensions import NativeExtensionLibrary from ..graph import ( @@ -59,6 +62,7 @@ from ..sources import Source from .audio_input import AudioInput, PcmSource from .connector import Connector, RegisteredConnector +from .endpoint_authoring import EndpointProvider, RegisteredEndpoint from .observations import EventStream from .operator_authoring import OperatorProvider from .sidecar import SidecarConnection @@ -298,6 +302,14 @@ def __init__( _NativeRegisteredConnector, ], ] = {} + self._endpoint_registrations: dict[ + int, + tuple[ + EndpointProvider | SyncEndpointProvider, + SyncEndpointProvider, + _NativeRegisteredEndpoint, + ], + ] = {} @classmethod def _from_native(cls, native: _NativeSession) -> Session: @@ -307,6 +319,7 @@ def _from_native(cls, native: _NativeSession) -> Session: session._sample_rate_hz = 48_000 session._channels = 1 session._connector_registrations = {} + session._endpoint_registrations = {} return session @property @@ -403,6 +416,29 @@ def destination( """Declare one Connector destination using an idempotent registration.""" return self.register_connector(connector).declare(configuration, edge=edge) + def register_endpoint( + self, endpoint: EndpointProvider | SyncEndpointProvider + ) -> RegisteredEndpoint: + """Register one asyncio or synchronous advanced Endpoint.""" + identity = id(endpoint) + cached = self._endpoint_registrations.get(identity) + if cached is not None and cached[0] is endpoint: + return RegisteredEndpoint( + SyncRegisteredEndpoint(self, cached[1], cached[2]) + ) + bound = ( + endpoint._bind(asyncio.get_running_loop()) + if isinstance(endpoint, EndpointProvider) + else endpoint + ) + native = _native_call( + lambda: self._native.register_endpoint_provider( + bound.manifest._native, bound._native_factory + ) + ) + self._endpoint_registrations[identity] = (endpoint, bound, native) + return RegisteredEndpoint(SyncRegisteredEndpoint(self, bound, native)) + def register_source( self, source: SourceProvider | SyncSourceProvider ) -> RegisteredSource: diff --git a/python/pocketstation/endpoint_authoring.py b/python/pocketstation/endpoint_authoring.py new file mode 100644 index 0000000..cf6bac9 --- /dev/null +++ b/python/pocketstation/endpoint_authoring.py @@ -0,0 +1,476 @@ +"""Advanced Python projection of Core's generic Endpoint lifecycle.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Protocol, TypeAlias, runtime_checkable + +from ._native import ( + AudioFrame, +) +from ._native import ( + EndpointItem as _NativeEndpointItem, +) +from ._native import ( + EndpointPortInput as _NativeEndpointPortInput, +) +from ._native import ( + EndpointPrepareContext as _NativeEndpointPrepareContext, +) +from ._native import ( + EndpointReceiver as _NativeEndpointReceiver, +) +from ._native import ( + EndpointStartGate as _NativeEndpointStartGate, +) +from ._native import ( + Session as _NativeSession, +) +from ._native import ( + _EndpointManifest as _NativeEndpointManifest, +) +from ._native import ( + _RegisteredEndpoint as _NativeRegisteredEndpoint, +) +from .errors import PocketStationError, _native_call +from .graph import EdgeContract, Endpoint, MediaCaps, PortSpec, SignalSpec +from .observations import EndpointFailureRetryability, EndpointFailureStage +from .signal import SignalEnvelope + +EndpointConfigurationInput: TypeAlias = Mapping[str, str] | Iterable[tuple[str, str]] + + +class EndpointShutdownMode(StrEnum): + DRAIN = "drain" + ABORT = "abort" + + +class EndpointDriverError(PocketStationError): + """Structured failure raised by a Python-authored generic Endpoint.""" + + def __init__( + self, + message: str, + *, + code: str = "python.endpoint_failure", + stage: EndpointFailureStage = EndpointFailureStage.PREPARE, + retryability: EndpointFailureRetryability = EndpointFailureRetryability.NEVER, + ) -> None: + super().__init__(message, code) + self.message = message + self.stage = stage + self.retryability = retryability + + +@dataclass(frozen=True, slots=True) +class EndpointDriverObservations: + frames_received_total: int = 0 + frames_delivered_total: int = 0 + frames_dropped_total: int = 0 + discontinuities_total: int = 0 + failures_total: int = 0 + + def __post_init__(self) -> None: + if ( + min( + self.frames_received_total, + self.frames_delivered_total, + self.frames_dropped_total, + self.discontinuities_total, + self.failures_total, + ) + < 0 + ): + raise ValueError("Endpoint observation counters cannot be negative") + + +@dataclass(frozen=True, slots=True) +class EndpointManifest: + """Compiler-visible identity and input ports for one generic Endpoint.""" + + operator_id: str + inputs: tuple[PortSpec, ...] + node_type_id: str | None = None + _native: _NativeEndpointManifest = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + native = _native_call( + lambda: _NativeEndpointManifest( + self.operator_id, + self.node_type_id or self.operator_id, + [port._native for port in self.inputs], + ) + ) + object.__setattr__(self, "_native", native) + + @classmethod + def audio( + cls, + operator_id: str, + *, + port_name: str = "audio", + node_type_id: str | None = None, + ) -> EndpointManifest: + from .graph import MediaCaps, Multiplicity, SignalSpec + + return cls( + operator_id=operator_id, + node_type_id=node_type_id, + inputs=( + PortSpec.input( + port_name, + SignalSpec.audio(), + media=MediaCaps.audio(), + multiplicity=Multiplicity.MANY, + ), + ), + ) + + +class EndpointStartGate: + """Read-only Core start barrier supplied to a prepared Endpoint.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpointStartGate) -> None: + self._native = native + + @property + def is_open(self) -> bool: + return self._native.is_open + + +class EndpointPrepareContext: + """Session-owned route identity and configuration for one Endpoint input.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpointPrepareContext) -> None: + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + @property + def endpoint_id(self) -> int: + return self._native.endpoint_id + + @property + def connector_id(self) -> int | None: + return self._native.connector_id + + @property + def route_id(self) -> int: + return self._native.route_id + + @property + def origin_kind(self) -> str: + return self._native.origin_kind + + @property + def source_id(self) -> int | None: + return self._native.source_id + + @property + def stream_id(self) -> int | None: + return self._native.stream_id + + @property + def stem_id(self) -> int | None: + return self._native.stem_id + + @property + def session_timeline_origin_ns(self) -> int: + return self._native.session_timeline_origin_ns + + @property + def configuration(self) -> Mapping[str, str]: + return self._native.configuration + + +class EndpointItem: + """One owned audio frame or typed signal read from a bounded Core edge.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpointItem) -> None: + self._native = native + + @property + def kind(self) -> str: + return self._native.kind + + @property + def audio(self) -> AudioFrame | None: + return self._native.audio + + @property + def signal(self) -> SignalEnvelope[object] | None: + value = self._native.signal + return None if value is None else SignalEnvelope._from_native(value) + + +class EndpointReceiver: + """Exclusive bounded input receiver; consume only from an off-realtime worker.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpointReceiver) -> None: + self._native = native + + def try_recv(self) -> EndpointItem | None: + value = _native_call(self._native.try_recv) + return None if value is None else EndpointItem(value) + + @property + def is_abandoned(self) -> bool: + return _native_call(self._native.is_abandoned) + + def mark_discontinuity(self) -> None: + _native_call(self._native.mark_discontinuity) + + def mark_worker_failure(self) -> None: + _native_call(self._native.mark_worker_failure) + + +class EndpointPortInput: + """One compiled input port, receiver, route identity, and edge contract.""" + + __slots__ = ("_native",) + + def __init__(self, native: _NativeEndpointPortInput) -> None: + self._native = native + + @property + def port_name(self) -> str: + return self._native.port_name + + @property + def signal(self) -> SignalSpec[object]: + return SignalSpec._from_native(self._native.signal) + + @property + def media(self) -> MediaCaps: + return MediaCaps._from_native(self._native.media) + + @property + def edge(self) -> EdgeContract: + return EdgeContract(self._native.edge) + + @property + def context(self) -> EndpointPrepareContext: + return EndpointPrepareContext(self._native.context) + + @property + def receiver(self) -> EndpointReceiver: + return EndpointReceiver(self._native.receiver) + + +class PreparedEndpointDriver: + """Prepared resources held behind Core's closed start gate.""" + + def start(self, gate: EndpointStartGate) -> RunningEndpointDriver: + raise NotImplementedError + + def cancel_preparation(self) -> None: + """Release prepared resources during transactional rollback.""" + + +class RunningEndpointDriver: + """Active generic Endpoint resources owned until joined finalization.""" + + def observations(self) -> EndpointDriverObservations: + return EndpointDriverObservations() + + def request_shutdown(self, mode: EndpointShutdownMode) -> None: + """Request finite drain or immediate abort.""" + + def join_and_finalize(self) -> EndpointDriverObservations: + return self.observations() + + +@runtime_checkable +class EndpointDriverFactory(Protocol): + def prepare( + self, inputs: Sequence[EndpointPortInput] + ) -> PreparedEndpointDriver: ... + + +EndpointDriverBuilder: TypeAlias = Callable[ + [Sequence[EndpointPortInput]], PreparedEndpointDriver +] +EndpointConfigurationValidator: TypeAlias = Callable[[Mapping[str, str]], None] +EndpointPreparationGroup: TypeAlias = Callable[[int, Mapping[str, str]], str | None] + + +class _PreparedAdapter: + __slots__ = ("_prepared",) + + def __init__(self, prepared: PreparedEndpointDriver) -> None: + self._prepared = prepared + + def start(self, gate: _NativeEndpointStartGate) -> _RunningAdapter: + return _RunningAdapter(self._prepared.start(EndpointStartGate(gate))) + + def cancel_preparation(self) -> None: + self._prepared.cancel_preparation() + + +class _RunningAdapter: + __slots__ = ("_running",) + + def __init__(self, running: RunningEndpointDriver) -> None: + self._running = running + + def observations(self) -> EndpointDriverObservations: + return self._running.observations() + + def request_shutdown(self, mode: str) -> None: + self._running.request_shutdown(EndpointShutdownMode(mode)) + + def join_and_finalize(self) -> EndpointDriverObservations: + return self._running.join_and_finalize() + + +class _FactoryAdapter: + __slots__ = ("_factory", "_group", "_validator") + + def __init__( + self, + factory: EndpointDriverFactory | EndpointDriverBuilder, + validator: EndpointConfigurationValidator | None, + group: EndpointPreparationGroup | None, + ) -> None: + self._factory = factory + self._validator = validator + self._group = group + + def validate_configuration(self, configuration: Mapping[str, str]) -> None: + if self._validator is not None: + self._validator(configuration) + + def preparation_group( + self, route_id: int, configuration: Mapping[str, str] + ) -> str | None: + if self._group is None: + return None + return self._group(route_id, configuration) + + def prepare( + self, native_inputs: Sequence[_NativeEndpointPortInput] + ) -> _PreparedAdapter: + inputs = tuple(EndpointPortInput(value) for value in native_inputs) + prepare = getattr(self._factory, "prepare", None) + prepared = ( + self._factory(inputs) # type: ignore[operator] + if prepare is None + else prepare(inputs) + ) + return _PreparedAdapter(prepared) + + +@dataclass(frozen=True, slots=True) +class EndpointProvider: + """Reusable low-level Endpoint implementation registered into one Session.""" + + manifest: EndpointManifest + factory: EndpointDriverFactory | EndpointDriverBuilder + validate_configuration: EndpointConfigurationValidator | None = field( + default=None, repr=False, compare=False + ) + preparation_group: EndpointPreparationGroup | None = field( + default=None, repr=False, compare=False + ) + _native_factory: _FactoryAdapter = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "_native_factory", + _FactoryAdapter( + self.factory, + self.validate_configuration, + self.preparation_group, + ), + ) + + +class RegisteredEndpoint: + """One generic Endpoint implementation bound to a Session draft.""" + + __slots__ = ("_native", "_provider", "_session") + + def __init__( + self, + session: _SessionDraft, + provider: EndpointProvider, + native: _NativeRegisteredEndpoint, + ) -> None: + self._session = session + self._provider = provider + self._native = native + + @property + def session_id(self) -> int: + return self._native.session_id + + def declare( + self, + configuration: EndpointConfigurationInput = (), + *, + edge: EdgeContract | None = None, + ) -> Endpoint: + values = _configuration(configuration) + selected_edge = edge or _default_edge(self._provider.manifest) + native = _native_call( + lambda: self._session._native.declare_registered_endpoint( + self._native, values, selected_edge._native + ) + ) + return Endpoint(native) + + +class _SessionDraft(Protocol): + _native: _NativeSession + + +def _configuration(values: EndpointConfigurationInput) -> dict[str, str]: + entries = tuple(values.items() if isinstance(values, Mapping) else values) + result: dict[str, str] = {} + for key, value in entries: + if not key or key.strip() != key: + raise ValueError("Endpoint configuration keys must be non-empty and exact") + if key in result: + raise ValueError(f"duplicate Endpoint configuration key {key!r}") + result[key] = value + return result + + +def _default_edge(manifest: EndpointManifest) -> EdgeContract: + if len(manifest.inputs) == 1 and manifest.inputs[0].signal.is_audio: + return EdgeContract.realtime_audio() + return EdgeContract.bounded_async() + + +__all__ = [ + "EndpointConfigurationInput", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", + "EndpointItem", + "EndpointManifest", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", + "PreparedEndpointDriver", + "RegisteredEndpoint", + "RunningEndpointDriver", +] diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index 566ffc8..fa7714e 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -18,12 +18,14 @@ Session as _NativeSession, ) from ._native import _RegisteredConnector as _NativeRegisteredConnector +from ._native import _RegisteredEndpoint as _NativeRegisteredEndpoint from .audio_input import AudioInput, AudioInputConfig, PcmSource from .connector import ( Connector, ConnectorConfigurationInput, RegisteredConnector, ) +from .endpoint_authoring import EndpointProvider, RegisteredEndpoint from .errors import PocketStationError, _native_call from .extensions import NativeExtensionLibrary from .graph import ( @@ -275,6 +277,9 @@ def __init__( self._connector_registrations: dict[ int, tuple[Connector, _NativeRegisteredConnector] ] = {} + self._endpoint_registrations: dict[ + int, tuple[EndpointProvider, _NativeRegisteredEndpoint] + ] = {} @classmethod def _from_native(cls, native: _NativeSession) -> Session: @@ -284,6 +289,7 @@ def _from_native(cls, native: _NativeSession) -> Session: session._sample_rate_hz = 48_000 session._channels = 1 session._connector_registrations = {} + session._endpoint_registrations = {} return session @property @@ -378,6 +384,25 @@ def destination( """ return self.register_connector(connector).declare(configuration, edge=edge) + def register_endpoint(self, endpoint: EndpointProvider) -> RegisteredEndpoint: + """Register one advanced Python implementation of Core's Endpoint SPI. + + Most outbound integrations should use :meth:`destination` with a + :class:`Connector`. This lower-level API exists for Endpoint behavior + that is not a provider transport specialization. + """ + identity = id(endpoint) + cached = self._endpoint_registrations.get(identity) + if cached is not None and cached[0] is endpoint: + return RegisteredEndpoint(self, endpoint, cached[1]) + native = _native_call( + lambda: self._native.register_endpoint_provider( + endpoint.manifest._native, endpoint._native_factory + ) + ) + self._endpoint_registrations[identity] = (endpoint, native) + return RegisteredEndpoint(self, endpoint, native) + def register_source(self, source: SourceProvider) -> RegisteredSource: """Register one Python-authored typed Source implementation.""" native = _native_call( diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index 2a0a178..24d73d0 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -6,7 +6,8 @@ import sys from array import array from pathlib import Path -from threading import Event +from threading import Event, Thread +from time import sleep import pocketstation @@ -107,11 +108,64 @@ def prepare( return self._driver +class InstalledEndpoint(pocketstation.RunningEndpointDriver): + def __init__( + self, + input: pocketstation.EndpointPortInput, + gate: pocketstation.EndpointStartGate, + delivered: Event, + ) -> None: + self._input = input + self._gate = gate + self._delivered = delivered + self._stop = Event() + self._thread = Thread(target=self._run, name="installed-endpoint") + self._thread.start() + + def _run(self) -> None: + while not self._gate.is_open and not self._stop.is_set(): + sleep(0.001) + while not self._stop.is_set(): + item = self._input.receiver.try_recv() + if item is not None and item.audio is not None: + self._delivered.set() + else: + sleep(0.001) + + def request_shutdown(self, mode: pocketstation.EndpointShutdownMode) -> None: + self._stop.set() + + def join_and_finalize(self) -> pocketstation.EndpointDriverObservations: + self._thread.join(1.0) + if self._thread.is_alive(): + raise RuntimeError("installed Endpoint worker did not terminate") + return pocketstation.EndpointDriverObservations( + frames_received_total=int(self._delivered.is_set()), + frames_delivered_total=int(self._delivered.is_set()), + ) + + +class InstalledPreparedEndpoint(pocketstation.PreparedEndpointDriver): + def __init__( + self, + input: pocketstation.EndpointPortInput, + delivered: Event, + ) -> None: + self._input = input + self._delivered = delivered + + def start( + self, gate: pocketstation.EndpointStartGate + ) -> pocketstation.RunningEndpointDriver: + return InstalledEndpoint(self._input, gate, self._delivered) + + def _exercise_complete_provider_path() -> dict[str, object]: delivered = Event() source_closed = Event() operator_closed = Event() connector_stopped = Event() + endpoint_delivered = Event() request_signal = pocketstation.SignalSpec.text(role="request") response_signal = pocketstation.SignalSpec.text(role="response.final") @@ -155,6 +209,15 @@ def _exercise_complete_provider_path() -> dict[str, object]: ) ) audio.output.send(endpoint) + generic_endpoint = session.register_endpoint( + pocketstation.EndpointProvider( + pocketstation.EndpointManifest.audio( + "io.pocketstation.test.installed-endpoint.v1" + ), + lambda inputs: InstalledPreparedEndpoint(inputs[0], endpoint_delivered), + ) + ).declare() + audio.output.send(generic_endpoint) audio.output.send(session.polled_audio()) running = session.start() @@ -165,6 +228,8 @@ def _exercise_complete_provider_path() -> dict[str, object]: raise RuntimeError("installed consumer timed out waiting for audio") if not delivered.wait(1.0): raise RuntimeError("installed Connector did not receive audio") + if not endpoint_delivered.wait(1.0): + raise RuntimeError("installed generic Endpoint did not receive audio") stop = running.stop() if not stop.success: raise RuntimeError("installed consumer Session did not stop successfully") diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 0dbf184..541059f 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -20,6 +20,14 @@ from pocketstation.aio import ( ConnectorDeadlines, ConnectorWorker, + EndpointDriverObservations, + EndpointManifest, + EndpointPortInput, + EndpointProvider, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RunningEndpointDriver, Session, ) from pocketstation.errors import AudioInputFullError @@ -98,6 +106,72 @@ async def test_async_audio_write_wait_is_finite_and_adds_no_python_queue() -> No assert observations.full_total > 0 +@pytest.mark.asyncio +async def test_async_endpoint_runs_on_owning_loop_over_core_receivers() -> None: + delivered = asyncio.Event() + owning_thread = threading.get_ident() + + class Running(RunningEndpointDriver): + def __init__(self, input: EndpointPortInput, gate: EndpointStartGate) -> None: + self.input = input + self.gate = gate + self.stop = asyncio.Event() + self.received = 0 + self.task = asyncio.create_task(self._run()) + + async def _run(self) -> None: + assert threading.get_ident() == owning_thread + while not self.gate.is_open and not self.stop.is_set(): + await asyncio.sleep(0.001) + while not self.stop.is_set(): + item = self.input.receiver.try_recv() + if item is None: + await asyncio.sleep(0.001) + continue + assert item.audio is not None + self.received += 1 + delivered.set() + + async def observations(self) -> EndpointDriverObservations: + return EndpointDriverObservations( + frames_received_total=self.received, + frames_delivered_total=self.received, + ) + + async def request_shutdown(self, mode: EndpointShutdownMode) -> None: + assert mode is EndpointShutdownMode.DRAIN + self.stop.set() + + async def join_and_finalize(self) -> EndpointDriverObservations: + await self.task + return await self.observations() + + class Prepared(PreparedEndpointDriver): + def __init__(self, input: EndpointPortInput) -> None: + self.input = input + + async def start(self, gate: EndpointStartGate) -> RunningEndpointDriver: + return Running(self.input, gate) + + async def prepare(inputs) -> PreparedEndpointDriver: + return Prepared(inputs[0]) + + session = Session() + audio = session.audio_input("playback", frame_samples_per_channel=4) + endpoint = session.register_endpoint( + EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.aio-endpoint.v1"), + prepare, + ) + ).declare() + audio.output.send(endpoint) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + await asyncio.wait_for(delivered.wait(), 1.0) + assert (await running.stop()).success + + @pytest.mark.asyncio async def test_async_session_registers_the_same_core_connector_contract() -> None: delivered = threading.Event() diff --git a/tests/test_endpoint_authoring.py b/tests/test_endpoint_authoring.py new file mode 100644 index 0000000..767492e --- /dev/null +++ b/tests/test_endpoint_authoring.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from array import array +from collections.abc import Sequence +from threading import Event, Thread +from time import monotonic, sleep + +import pytest +from pocketstation import ( + EndpointDriverError, + EndpointDriverObservations, + EndpointFailureRetryability, + EndpointFailureStage, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointProvider, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RunningEndpointDriver, + Session, +) + + +class CollectingEndpoint(RunningEndpointDriver): + def __init__(self, input: EndpointPortInput, gate: EndpointStartGate) -> None: + self.input = input + self.gate = gate + self.started = Event() + self.delivered = Event() + self.stop = Event() + self.shutdown_mode: EndpointShutdownMode | None = None + self.items: list[EndpointItem] = [] + self.thread = Thread(target=self._run, name="test-python-endpoint") + self.thread.start() + + def _run(self) -> None: + deadline = monotonic() + 1.0 + while not self.gate.is_open and not self.stop.is_set(): + assert monotonic() < deadline + sleep(0.001) + self.started.set() + while not self.stop.is_set(): + item = self.input.receiver.try_recv() + if item is None: + sleep(0.001) + continue + self.items.append(item) + self.delivered.set() + + def observations(self) -> EndpointDriverObservations: + count = len(self.items) + return EndpointDriverObservations( + frames_received_total=count, + frames_delivered_total=count, + ) + + def request_shutdown(self, mode: EndpointShutdownMode) -> None: + self.shutdown_mode = mode + self.stop.set() + + def join_and_finalize(self) -> EndpointDriverObservations: + self.thread.join(1.0) + assert not self.thread.is_alive() + return self.observations() + + +class PreparedCollector(PreparedEndpointDriver): + def __init__(self, input: EndpointPortInput) -> None: + self.input = input + self.running: CollectingEndpoint | None = None + self.cancelled = False + + def start(self, gate: EndpointStartGate) -> RunningEndpointDriver: + self.running = CollectingEndpoint(self.input, gate) + return self.running + + def cancel_preparation(self) -> None: + self.cancelled = True + + +def test_generic_endpoint_uses_core_lifecycle_and_preserves_lineage() -> None: + prepared: list[PreparedCollector] = [] + + def prepare(inputs: Sequence[EndpointPortInput]) -> PreparedCollector: + assert len(inputs) == 1 + value = PreparedCollector(inputs[0]) + prepared.append(value) + return value + + provider = EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.endpoint.collect.v1"), + prepare, + ) + session = Session() + audio = session.audio_input("agent-output", frame_samples_per_channel=4) + endpoint = session.register_endpoint(provider).declare({"mode": "test"}) + route_id = audio.output.send(endpoint) + + running_session = session.start() + assert prepared[0].running is not None + endpoint_driver = prepared[0].running + assert endpoint_driver.started.wait(1.0) + audio.write(array("f", [0.25, -0.25, 0.5, -0.5]), discontinuity=True) + assert endpoint_driver.delivered.wait(1.0) + result = running_session.stop() + + assert result.success + assert endpoint_driver.shutdown_mode is EndpointShutdownMode.DRAIN + assert prepared[0].input.context.endpoint_id == endpoint.id + assert prepared[0].input.context.connector_id is None + assert prepared[0].input.context.route_id == route_id + assert prepared[0].input.context.source_id == audio.source_id + assert prepared[0].input.context.stream_id == audio.stream_id + assert prepared[0].input.context.configuration == {"mode": "test"} + assert len(endpoint_driver.items) == 1 + item = endpoint_driver.items[0] + assert item.kind == "audio" + assert item.signal is None + assert item.audio is not None + assert item.audio.source_id == audio.source_id + assert item.audio.stream_id == audio.stream_id + assert item.audio.sequence_number == 0 + assert item.audio.discontinuity_epoch == 1 + + +def test_endpoint_registration_is_idempotent_and_session_scoped() -> None: + provider = EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.endpoint.identity.v1"), + lambda inputs: PreparedCollector(inputs[0]), + ) + session = Session() + first = session.register_endpoint(provider) + second = session.register_endpoint(provider) + + assert first.session_id == session.id + assert second.session_id == session.id + with pytest.raises(Exception, match="different Session"): + other = Session() + first._session = other + first.declare() + + +def test_endpoint_failure_preserves_structure_in_terminal_outcome() -> None: + class FailingRunning(RunningEndpointDriver): + def request_shutdown(self, mode: EndpointShutdownMode) -> None: + raise EndpointDriverError( + "provider did not drain", + code="provider.drain_timeout", + stage=EndpointFailureStage.REQUEST_STOP, + retryability=EndpointFailureRetryability.RETRYABLE, + ) + + class FailingPrepared(PreparedEndpointDriver): + def start(self, gate: EndpointStartGate) -> RunningEndpointDriver: + return FailingRunning() + + session = Session() + audio = session.audio_input("agent-output", frame_samples_per_channel=4) + endpoint = session.register_endpoint( + EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.endpoint.failure.v1"), + lambda _inputs: FailingPrepared(), + ) + ).declare() + audio.output.send(endpoint) + running = session.start() + result = running.stop() + + assert not result.success + assert result.terminal_event is not None + failure = next( + value + for value in result.terminal_event.failures + if value.error_code == "provider.drain_timeout" + ) + assert failure.stage is EndpointFailureStage.REQUEST_STOP + assert failure.retryability is EndpointFailureRetryability.RETRYABLE + + +def test_endpoint_prepare_failure_rolls_back_prepared_peer() -> None: + prepared: list[PreparedCollector] = [] + + def prepare_first(inputs: Sequence[EndpointPortInput]) -> PreparedCollector: + value = PreparedCollector(inputs[0]) + prepared.append(value) + return value + + def fail_prepare( + _inputs: Sequence[EndpointPortInput], + ) -> PreparedEndpointDriver: + raise EndpointDriverError( + "provider configuration is unavailable", + code="provider.prepare_unavailable", + stage=EndpointFailureStage.PREPARE, + retryability=EndpointFailureRetryability.RETRYABLE, + ) + + session = Session() + audio = session.audio_input("agent-output", frame_samples_per_channel=4) + first = session.register_endpoint( + EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.endpoint.rollback.v1"), + prepare_first, + ) + ).declare() + second = session.register_endpoint( + EndpointProvider( + EndpointManifest.audio("io.pocketstation.test.endpoint.reject.v1"), + fail_prepare, + ) + ).declare() + audio.output.send(first) + audio.output.send(second) + + with pytest.raises(Exception, match="provider configuration is unavailable"): + session.start() + + assert len(prepared) == 1 + assert prepared[0].cancelled diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 425de48..b4b1e2f 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -96,13 +96,27 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "EdgeMetrics", "EdgeObservabilityLevel", "Endpoint", - "EndpointId", "EndpointConfiguration", + "EndpointConfigurationInput", "EndpointDescriptor", - "EndpointFailureStage", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", "EndpointFailureRetryability", + "EndpointFailureStage", + "EndpointId", + "EndpointItem", + "EndpointManifest", "EndpointMetrics", "EndpointObservationStage", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", "EndOfStream", "EventFormat", "EventStream", @@ -150,6 +164,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "PolledAudioMetrics", "ProcessInstanceSelector", "ProcessTreeScope", + "PreparedEndpointDriver", "PublisherActivation", "ReceiverActivation", "ReceiverInvitation", @@ -160,6 +175,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "RecordingState", "RecordingStemOutcome", "RegisteredConnector", + "RegisteredEndpoint", "RegisteredOperator", "RegisteredSource", "RelayError", @@ -175,6 +191,7 @@ def test_root_exports_are_an_intentional_stable_snapshot() -> None: "RouteObservationInterval", "RuntimeCompatibility", "RuntimeSessionId", + "RunningEndpointDriver", "RunningSession", "SampleFormat", "SecretToken", From 5ff52b5fc1198093e28e6ed5774b68ef0e5870d8 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 20:46:25 -0400 Subject: [PATCH 14/49] feat: emit bounded PCM from Python operators --- README.md | 21 ++ native/src/operator_authoring/driver.rs | 171 ++++++++++++- native/src/operator_authoring/values.rs | 27 +- python/pocketstation/_native.pyi | 2 + python/pocketstation/operator_authoring.py | 14 +- tests/installed_consumer.py | 53 ++++ tests/run_installed_stream_conformance.py | 8 +- tests/test_operator_authoring.py | 272 +++++++++++++++++++++ 8 files changed, 556 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bd71f90..4c9b2f1 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,27 @@ def uppercase(_port, envelope): return (pocketstation.OperatorEmission.text(envelope.payload.upper(), signal=result),) ``` +An Operator with an exact PCM output contract can emit one contiguous float32 +frame directly. Python runs on the bounded async-worker partition; the binding +snapshots the frame and Core moves it through its preallocated generated-audio +pool and normal Session routing: + +```python +def synthesize(_port, envelope): + pcm = model.render(envelope.payload) + return (pocketstation.OperatorEmission.audio(pcm, signal=generated_audio),) + +speaker = operator.output("audio").reenter_audio() +speaker.send(session.destination(publisher)) +speaker.record("agent-output") +``` + +The Operator manifest must declare an exact sample rate, frame size, and mono +or stereo layout. A wrong frame size, exhausted native pool, or non-contiguous +buffer fails explicitly. Streaming TTS or audio produced independently of an +Operator input should continue to use `Session.audio_input()`; that Source path +has its own finite writer and discontinuity contract. + `pocketstation.aio.source` and `pocketstation.aio.operator` accept async iterables and coroutine handlers with explicit finite deadlines. The same native Session remains authoritative for registration, compilation, diff --git a/native/src/operator_authoring/driver.rs b/native/src/operator_authoring/driver.rs index 553fae8..3e73b3c 100644 --- a/native/src/operator_authoring/driver.rs +++ b/native/src/operator_authoring/driver.rs @@ -2,15 +2,16 @@ use std::sync::Arc; use pocketstation::graph::NodeConfig; use pocketstation::{ - AsyncNode, AsyncNodeFuture, AsyncOperatorFactory, AsyncOperatorPrepareContext, ConfigError, - NodeError, OperatorId, PortDirection, PortPrepareContext, SignalDerivation, SignalEnvelope, - SignalLineage, SignalTiming, + AsyncNode, AsyncNodeFuture, AsyncOperatorFactory, AsyncOperatorPrepareContext, AudioBufferPool, + AudioFrame, ChannelLayout, ConfigError, MediaCaps, NodeError, OperatorId, PortDirection, + PortPrepareContext, SampleFormat, SampleSpec, SignalDerivation, SignalEnvelope, SignalLineage, + SignalPayload, SignalTiming, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDict; -use super::values::{PythonOperatorEmission, PythonOperatorManifest}; +use super::values::{PythonOperatorEmission, PythonOperatorManifest, PythonOperatorPayload}; use crate::errors::coded_reason; use crate::graph::{PythonEdgeContract, PythonMediaCaps, PythonSignalSpec}; use crate::signals::{copy_envelope, python_envelope}; @@ -20,10 +21,12 @@ pub(crate) fn register_operator( manifest: &PythonOperatorManifest, factory: Py, ) -> PyResult<()> { + let audio_output = audio_output_spec(&manifest.value)?; session .register_operator(Arc::new(PythonOperatorFactory { manifest: manifest.value.clone(), factory, + audio_output, })) .map_err(|error| { PyValueError::new_err(coded_reason( @@ -96,6 +99,7 @@ impl PythonOperatorPrepareContext { struct PythonOperatorFactory { manifest: pocketstation::AsyncOperatorManifest, factory: Py, + audio_output: Option, } impl AsyncOperatorFactory for PythonOperatorFactory { @@ -130,6 +134,7 @@ impl AsyncOperatorFactory for PythonOperatorFactory { revision: self.manifest.revision(), generation: self.manifest.generation(), last_input: None, + audio_output: self.audio_output.map(OperatorAudioOutput::new), }) as Box) }) } @@ -141,6 +146,72 @@ struct PythonOperatorNode { revision: u32, generation: u32, last_input: Option<(SignalLineage, SignalTiming)>, + audio_output: Option, +} + +// AudioBufferPool's public constructor uses a 64-bit ownership mask. Keep the +// binding-side request finite even when a provider declares a larger signal +// queue; Core remains the pool implementation and runtime authority. +const AUDIO_OUTPUT_POOL_MAX_SLOTS: usize = u64::BITS as usize; + +#[derive(Clone, Copy)] +struct OperatorAudioOutputSpec { + sample_spec: SampleSpec, + frame_samples_per_channel: usize, + pool_slots: usize, +} + +struct OperatorAudioOutput { + pool: Arc, + sample_spec: SampleSpec, + samples_per_frame: usize, +} + +impl OperatorAudioOutput { + fn new(spec: OperatorAudioOutputSpec) -> Self { + let samples_per_frame = spec + .frame_samples_per_channel + .saturating_mul(usize::from(spec.sample_spec.channels)); + Self { + pool: AudioBufferPool::new(spec.pool_slots, samples_per_frame), + sample_spec: spec.sample_spec, + samples_per_frame, + } + } + + fn frame( + &self, + samples: &[f32], + lineage: SignalLineage, + timing: SignalTiming, + ) -> Result { + if samples.len() != self.samples_per_frame { + return Err(NodeError::Process(format!( + "operator audio emission has {} samples; expected {}", + samples.len(), + self.samples_per_frame + ))); + } + let mut buffer = self.pool.acquire().ok_or_else(|| { + NodeError::Process("operator audio emission buffer pool is full".to_owned()) + })?; + buffer + .try_copy_from_slice(samples) + .map_err(|error| NodeError::Process(error.to_string()))?; + let timestamp_ns = timing + .session_timestamp_ns() + .or(timing.source_timestamp_ns()) + .unwrap_or(timing.observed_timestamp_ns()); + AudioFrame::try_new( + lineage.stream_id(), + lineage.source_id(), + lineage.sequence_number(), + timestamp_ns, + self.sample_spec, + buffer, + ) + .map_err(|error| NodeError::Process(error.to_string())) + } } impl AsyncNode for PythonOperatorNode { @@ -252,8 +323,22 @@ impl PythonOperatorNode { None, ) .map_err(|error| NodeError::Process(error.to_string()))?; + let payload = match emission.payload { + PythonOperatorPayload::Audio(samples) => { + let audio_output = self.audio_output.as_ref().ok_or_else(|| { + NodeError::Process( + "operator audio emission requires one concrete PCM output" + .to_owned(), + ) + })?; + SignalPayload::Audio(audio_output.frame(&samples, lineage, timing)?) + } + payload => payload.into_non_audio_core().ok_or_else(|| { + NodeError::Process("operator emitted an unsupported payload".to_owned()) + })?, + }; Ok(SignalEnvelope::untracked( - emission.payload.into_core(), + payload, emission.signal, timing.observed_timestamp_ns(), ) @@ -264,6 +349,82 @@ impl PythonOperatorNode { } } +fn audio_output_spec( + manifest: &pocketstation::AsyncOperatorManifest, +) -> PyResult> { + let Some(MediaCaps::Audio(caps)) = manifest.output_ports().next().map(|port| port.media()) + else { + return Ok(None); + }; + let sample_rate_hz = caps.sample_rate_hz.ok_or_else(|| { + PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM output requires an exact sample rate", + )) + })?; + if sample_rate_hz == 0 { + return Err(PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM output sample rate must be non-zero", + ))); + } + let frame_samples_per_channel = caps.frame_samples.ok_or_else(|| { + PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM output requires an exact frame sample count", + )) + })?; + if frame_samples_per_channel == 0 { + return Err(PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM output frame sample count must be non-zero", + ))); + } + let channels = match caps.channel_layout { + ChannelLayout::Mono => 1, + ChannelLayout::Stereo => 2, + ChannelLayout::Any => { + return Err(PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM output requires a concrete channel layout", + ))) + } + }; + let samples_per_frame = frame_samples_per_channel + .checked_mul(usize::from(channels)) + .ok_or_else(|| { + PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM frame size exceeds the platform limit", + )) + })?; + let payload_bytes = samples_per_frame + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM payload size exceeds the platform limit", + )) + })?; + if manifest + .output_edge() + .max_payload_bytes() + .is_some_and(|maximum| payload_bytes > maximum) + { + return Err(PyValueError::new_err(coded_reason( + "operator.invalid_contract", + "Python Operator PCM frame exceeds its output edge payload bound", + ))); + } + Ok(Some(OperatorAudioOutputSpec { + sample_spec: SampleSpec::new(sample_rate_hz, channels, SampleFormat::F32Interleaved), + frame_samples_per_channel, + pool_slots: manifest + .queue_capacity_frames() + .min(AUDIO_OUTPUT_POOL_MAX_SLOTS), + })) +} + fn python_prepare_context( py: Python<'_>, context: &AsyncOperatorPrepareContext, diff --git a/native/src/operator_authoring/values.rs b/native/src/operator_authoring/values.rs index 3f6853a..f646001 100644 --- a/native/src/operator_authoring/values.rs +++ b/native/src/operator_authoring/values.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; + use pocketstation::{ AsyncOperatorManifest, BackpressurePolicy, CopyPolicy, EdgeContract, ExecutionPartition, MediaCaps, NodeDescriptor, NodeTypeId, OperatorCancellationPolicy, OperatorDeadlinePolicy, OperatorFailurePolicy, OperatorId, OperatorOutputRolePolicy, OperatorPermissionPolicy, PortDirection, SafetyContract, SemanticRole, SignalPayload, SignalSpec, }; +use pyo3::buffer::PyBuffer; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -152,15 +155,17 @@ fn common_media(ports: &[pocketstation::PortSpec], kind: &str) -> PyResult), Text(String), Bytes(Vec), } impl PythonOperatorPayload { - pub(super) fn into_core(self) -> SignalPayload { + pub(super) fn into_non_audio_core(self) -> Option { match self { - Self::Text(value) => SignalPayload::Text(value), - Self::Bytes(value) => SignalPayload::Bytes(value), + Self::Audio(_) => None, + Self::Text(value) => Some(SignalPayload::Text(value)), + Self::Bytes(value) => Some(SignalPayload::Bytes(value)), } } } @@ -174,6 +179,18 @@ pub(crate) struct PythonOperatorEmission { #[pymethods] impl PythonOperatorEmission { + #[staticmethod] + fn audio(py: Python<'_>, samples: PyBuffer, signal: &PythonSignalSpec) -> PyResult { + let samples = samples.as_slice(py).ok_or_else(|| { + invalid_operator("audio samples must be a C-contiguous float32 buffer") + })?; + let owned = samples + .iter() + .map(|sample| sample.get()) + .collect::>(); + Self::new(PythonOperatorPayload::Audio(Arc::from(owned)), signal) + } + #[staticmethod] fn text(payload: String, signal: &PythonSignalSpec) -> PyResult { Self::new(PythonOperatorPayload::Text(payload), signal) @@ -188,6 +205,10 @@ impl PythonOperatorEmission { impl PythonOperatorEmission { fn new(payload: PythonOperatorPayload, signal: &PythonSignalSpec) -> PyResult { let supported = match &payload { + PythonOperatorPayload::Audio(_) => matches!( + signal.value.class(), + pocketstation::SignalClass::Any | pocketstation::SignalClass::PcmAudio + ), PythonOperatorPayload::Text(_) => { SignalPayload::Text(String::new()).supports(&signal.value) } diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index d8a95b5..921202a 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -1123,6 +1123,8 @@ class _OperatorManifest: operator_id: str class _OperatorEmission: + @staticmethod + def audio(payload: object, signal: _SignalSpec) -> _OperatorEmission: ... @staticmethod def text(payload: str, signal: _SignalSpec) -> _OperatorEmission: ... @staticmethod diff --git a/python/pocketstation/operator_authoring.py b/python/pocketstation/operator_authoring.py index 07bf7c8..9d414df 100644 --- a/python/pocketstation/operator_authoring.py +++ b/python/pocketstation/operator_authoring.py @@ -22,7 +22,7 @@ PortSpec, SignalSpec, ) -from .signal import SignalEnvelope +from .signal import SignalAudioPayload, SignalEnvelope class _SessionOwner(Protocol): @@ -119,6 +119,18 @@ class OperatorEmission: def __init__(self, native: _NativeOperatorEmission) -> None: self._native = native + @classmethod + def audio( + cls, + samples: object, + *, + signal: SignalSpec[SignalAudioPayload], + ) -> OperatorEmission: + """Copy one exact float32 PCM frame into a bounded Core-owned pool.""" + return cls( + _native_call(lambda: _NativeOperatorEmission.audio(samples, signal._native)) + ) + @classmethod def text(cls, payload: str, *, signal: SignalSpec[str]) -> OperatorEmission: return cls( diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index 24d73d0..fbaec62 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -332,11 +332,64 @@ def fail( raise RuntimeError("installed Connector failure lost structured fields") +def _exercise_operator_pcm_reentry() -> None: + signal = pocketstation.SignalSpec.audio(role="audio.generated") + media = pocketstation.MediaCaps.audio( + pocketstation.AudioCaps( + sample_rate_hz=48_000, + frame_samples=4, + channel_layout=pocketstation.ChannelLayout.MONO, + ) + ) + emitted = array("f", [0.25, -0.25, 0.5, -0.5]) + closed = Event() + + class InstalledPcmOperator(pocketstation.OperatorNode): + def process( + self, + _input_port: str, + _envelope: pocketstation.SignalEnvelope[object], + ) -> tuple[pocketstation.OperatorEmission, ...]: + return (pocketstation.OperatorEmission.audio(emitted, signal=signal),) + + def close(self) -> None: + closed.set() + + class InstalledPcmFactory: + def create(self, _configuration: object) -> InstalledPcmOperator: + return InstalledPcmOperator() + + provider = pocketstation.OperatorProvider.with_node( + pocketstation.OperatorManifest( + "io.pocketstation.operator.installed-pcm.v1", + inputs=(pocketstation.PortSpec.input("input", signal, media=media),), + outputs=(pocketstation.PortSpec.output("output", signal, media=media),), + queue_capacity_signals=2, + ), + InstalledPcmFactory(), + ) + session = pocketstation.Session() + source = session.audio_input("installed-pcm", frame_samples_per_channel=4) + operator = session.register_operator(provider).declare() + source.output.connect(operator.input("input")) + operator.output("output").reenter_audio().send(session.polled_audio()) + + running = session.start() + source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + frame = running.audio.read(timeout_s=1.0) + result = running.stop() + if frame is None or list(frame.samples.cast("f")) != list(emitted): + raise RuntimeError("installed Python Operator PCM did not reenter Core") + if not result.success or not closed.wait(1.0): + raise RuntimeError("installed Python Operator PCM did not finalize") + + def main() -> None: provider = _exercise_complete_provider_path() _exercise_saturation() _exercise_abort() _exercise_structured_failure() + _exercise_operator_pcm_reentry() package_path = Path(pocketstation.__file__).resolve() environment_root = Path(sys.prefix).resolve() if not package_path.is_relative_to(environment_root): diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index 8de9941..c6048cc 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -21,12 +21,14 @@ "tests/test_source_authoring.py::test_iterable_source_runs_in_core_and_receives_session_lineage", "tests/test_source_authoring.py::test_async_iterable_source_runs_on_the_owning_event_loop", "tests/test_operator_authoring.py::test_python_operator_processes_source_signal_with_derivation", + "tests/test_operator_authoring.py::test_python_operator_emits_pcm_into_core_reentry_and_recording", + "tests/test_operator_authoring.py::test_python_operator_rejects_wrong_pcm_frame_size", "tests/test_operator_authoring.py::test_async_operator_runs_on_owning_loop", + "tests/test_operator_authoring.py::test_async_operator_pcm_uses_the_same_core_reentry", "tests/test_connector.py::test_connector_worker_receives_finite_native_owned_batches", "tests/test_aio_session.py::test_async_connector_worker_receives_finite_native_batches", - "tests/test_audio_bridge.py::test_given_pcm_iterable_when_bridge_runs_then_core_drains_one_connector", - "tests/test_aio_audio_bridge.py::test_given_async_pcm_when_bridge_runs_then_core_drains_connector", - "tests/test_source_aware_transcription_example.py::test_two_source_lanes_keep_identity_through_one_model_operator", + "tests/test_audio_transport_example.py::test_call_audio_template_uses_core_source_and_connector", + "tests/test_transcription_example.py::test_faster_whisper_is_the_concise_source_aware_python_path", ) diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py index 3aefde1..897bcfb 100644 --- a/tests/test_operator_authoring.py +++ b/tests/test_operator_authoring.py @@ -1,16 +1,22 @@ from __future__ import annotations +from array import array from threading import Event import pocketstation.aio as pks_aio import pytest from pocketstation import ( + AudioCaps, + ChannelLayout, + Connector, + ConnectorDeliveryOutcome, MediaCaps, OperatorEmission, OperatorManifest, OperatorNode, OperatorPrepareContext, OperatorProvider, + PocketStationError, PortDirection, PortSpec, Session, @@ -22,6 +28,54 @@ ) +def _pcm_media(*, frame_samples: int = 4) -> MediaCaps: + return MediaCaps.audio( + AudioCaps( + sample_rate_hz=48_000, + frame_samples=frame_samples, + channel_layout=ChannelLayout.MONO, + ) + ) + + +def _pcm_operator( + *, + samples: array[float], + frame_samples: int = 4, +) -> tuple[OperatorProvider, Event]: + input_signal = SignalSpec.audio(role="audio.input") + output_signal = SignalSpec.audio(role="audio.generated") + closed = Event() + + class GeneratePcm(OperatorNode): + def process(self, _input_port, _envelope): + return (OperatorEmission.audio(samples, signal=output_signal),) + + def close(self) -> None: + closed.set() + + class Factory: + def create(self, _configuration) -> GeneratePcm: + return GeneratePcm() + + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.python-pcm-test.v1", + inputs=(PortSpec.input("input", input_signal, media=_pcm_media()),), + outputs=( + PortSpec.output( + "output", + output_signal, + media=_pcm_media(frame_samples=frame_samples), + ), + ), + queue_capacity_signals=2, + ), + Factory(), + ) + return provider, closed + + def test_python_operator_processes_source_signal_with_derivation() -> None: input_signal = SignalSpec.text(role="request") output_signal = SignalSpec.text(role="result.final") @@ -227,3 +281,221 @@ async def uppercase(input_port, envelope): assert stop.success assert isinstance(value, SignalEnvelope) assert value.payload == "HELLO" + + +def test_python_operator_emits_pcm_into_core_reentry_and_recording(tmp_path) -> None: + provider, closed = _pcm_operator(samples=array("f", [0.25, -0.25, 0.5, -0.5])) + delivered = Event() + connector_frames = [] + + def deliver(frame, _context): + connector_frames.append(frame) + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + connector = Connector.from_audio_handler( + "io.pocketstation.connector.python-pcm-test.v1", + deliver, + package_version="1.0.0", + ) + session = Session(recording_root=tmp_path) + source = session.audio_input("operator-input", frame_samples_per_channel=4) + operator = session.register_operator(provider).declare() + source.output.connect(operator.input("input")) + generated = operator.output("output").reenter_audio() + generated.send(session.polled_audio()) + generated.send(session.destination(connector)) + generated.record("generated") + + running = session.start() + source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + frame = running.audio.read(timeout_s=1.0) + assert delivered.wait(1.0) + stop = running.stop() + + assert frame is not None + assert list(frame.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) + assert frame.sample_rate_hz == 48_000 + assert frame.channel_count == 1 + assert frame.sequence_number == 0 + assert frame.source_id != source.source_id + assert frame.stem_id == generated.id + assert len(connector_frames) == 1 + assert connector_frames[0].source_id == frame.source_id + assert connector_frames[0].stem_id == frame.stem_id + assert connector_frames[0].sequence_number == frame.sequence_number + assert stop.success + assert stop.recording is not None + assert stop.recording.complete + assert [stem.stem_name for stem in stop.recording.stems] == ["generated"] + assert closed.wait(1.0) + + +def test_python_operator_rejects_wrong_pcm_frame_size() -> None: + provider, closed = _pcm_operator(samples=array("f", [0.0, 0.0, 0.0])) + session = Session() + source = session.audio_input("operator-input", frame_samples_per_channel=4) + operator = session.register_operator(provider).declare() + source.output.connect(operator.input("input")) + operator.output("output").reenter_audio().send(session.polled_audio()) + + running = session.start() + source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + assert running.audio.read(timeout_s=0.2) is None + stop = running.stop() + + assert not stop.success + assert stop.terminal_event is not None + assert any( + "expected 4" + in " ".join( + value + for value in ( + failure.message, + failure.component_diagnostic, + failure.error_class, + ) + if value is not None + ) + for failure in stop.terminal_event.failures + ) + assert closed.wait(1.0) + + +def test_pcm_operator_requires_an_exact_output_contract() -> None: + signal = SignalSpec.audio(role="audio.generated") + + class GeneratePcm(OperatorNode): + def process(self, _input_port, _envelope): + return (OperatorEmission.audio(array("f", [0.0]), signal=signal),) + + class Factory: + def create(self, _configuration) -> GeneratePcm: + return GeneratePcm() + + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.non-concrete-python-pcm-test.v1", + inputs=(PortSpec.input("input", signal),), + outputs=(PortSpec.output("output", signal),), + ), + Factory(), + ) + + with pytest.raises(PocketStationError) as failure: + Session().register_operator(provider) + assert failure.value.code == "operator.invalid_contract" + assert "exact sample rate" in str(failure.value) + + +def test_pcm_operator_rejects_a_frame_beyond_the_edge_payload_bound() -> None: + signal = SignalSpec.audio(role="audio.generated") + + class GeneratePcm(OperatorNode): + def process(self, _input_port, _envelope): + return () + + class Factory: + def create(self, _configuration) -> GeneratePcm: + return GeneratePcm() + + oversized = _pcm_media(frame_samples=262_145) + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.oversized-python-pcm-test.v1", + inputs=(PortSpec.input("input", signal, media=_pcm_media()),), + outputs=(PortSpec.output("output", signal, media=oversized),), + ), + Factory(), + ) + + with pytest.raises(PocketStationError) as failure: + Session().register_operator(provider) + assert failure.value.code == "operator.invalid_contract" + assert "payload bound" in str(failure.value) + + +def test_pcm_emission_rejects_non_contiguous_input() -> None: + signal = SignalSpec.audio(role="audio.generated") + samples = memoryview(array("f", [0.0, 0.1, 0.2, 0.3]))[::2] + + with pytest.raises(PocketStationError) as failure: + OperatorEmission.audio(samples, signal=signal) + assert failure.value.code == "operator.invalid_contract" + assert "C-contiguous float32" in str(failure.value) + + +def test_pcm_operator_fails_explicitly_when_its_native_pool_is_saturated() -> None: + signal = SignalSpec.audio(role="audio.generated") + samples = array("f", [0.0, 0.0, 0.0, 0.0]) + + class EmitBeyondCapacity(OperatorNode): + def process(self, _input_port, _envelope): + return ( + OperatorEmission.audio(samples, signal=signal), + OperatorEmission.audio(samples, signal=signal), + ) + + class Factory: + def create(self, _configuration) -> EmitBeyondCapacity: + return EmitBeyondCapacity() + + provider = OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.saturated-python-pcm-test.v1", + inputs=(PortSpec.input("input", signal, media=_pcm_media()),), + outputs=(PortSpec.output("output", signal, media=_pcm_media()),), + queue_capacity_signals=1, + ), + Factory(), + ) + session = Session() + source = session.audio_input("operator-input", frame_samples_per_channel=4) + operator = session.register_operator(provider).declare() + source.output.connect(operator.input("input")) + operator.output("output").reenter_audio().send(session.polled_audio()) + + running = session.start() + source.write(samples) + assert running.audio.read(timeout_s=0.2) is None + stop = running.stop() + + assert not stop.success + assert stop.terminal_event is not None + assert any( + "buffer pool is full" + in " ".join( + value + for value in ( + failure.message, + failure.component_diagnostic, + failure.error_class, + ) + if value is not None + ) + for failure in stop.terminal_event.failures + ) + + +@pytest.mark.asyncio +async def test_async_operator_pcm_uses_the_same_core_reentry(tmp_path) -> None: + provider, closed = _pcm_operator(samples=array("f", [0.1, 0.2, 0.3, 0.4])) + session = pks_aio.Session(recording_root=tmp_path) + source = session.audio_input("operator-input", frame_samples_per_channel=4) + operator = session.register_operator(provider).declare() + source.output.connect(operator.input("input")) + generated = operator.output("output").reenter_audio() + generated.send(session.polled_audio()) + generated.record("generated") + + running = await session.start() + await source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + frame = await running.audio.read(timeout_s=1.0) + stop = await running.stop() + + assert frame is not None + assert list(frame.samples.cast("f")) == pytest.approx([0.1, 0.2, 0.3, 0.4]) + assert stop.success + assert stop.recording is not None + assert stop.recording.complete + assert closed.wait(1.0) From 4e5fe07956e620c747f2329c4f2f8b99d717f923 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 20 Aug 2026 21:11:33 -0400 Subject: [PATCH 15/49] feat: route streams directly to connectors --- README.md | 12 ++-- python/pocketstation/aio/session.py | 11 ++- python/pocketstation/audio_input.py | 12 +++- python/pocketstation/graph.py | 97 +++++++++++++++++++++----- python/pocketstation/session.py | 11 ++- tests/installed_consumer.py | 3 +- tests/qualification/typing_contract.py | 4 ++ tests/test_aio_session.py | 27 +++++++ tests/test_connector.py | 20 ++++++ tests/test_operator_authoring.py | 2 +- 10 files changed, 165 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 4c9b2f1..91f591a 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ > the exhaustive capability matrix, Pythonic stream slice, package ownership, > typed source lifecycle, Rust-backed graph declarations, bounded typed signal > streams, process sidecars, compiled native extensions, complete observations, -> application-owned PCM ingress, and independently installable wheel/sdist -> artifacts. The real Relay/browser path, source-aware transcription notebook, +> application-owned PCM ingress, and an independently installable macOS wheel. +> The rebuildable sdist remains blocked on releasing the post-1.1.1 Core APIs +> currently consumed through a local development patch. The real Relay/browser +> path and source-aware transcription proof, > and physical macOS application-plus-microphone path are proven. Linux and > Windows artifact qualification and final OSS readiness remain gated. @@ -114,8 +116,8 @@ The SDK is not complete: - capture authorization snapshots and permission-transition ownership are not attached to the canonical running Session; the SDK preserves discovery and the authoritative seven-state platform observation without inventing either; -- isolated macOS wheel and independently rebuilt sdist consumers exist; Linux, - Windows, and broader real-device matrices remain release gates; +- an isolated macOS wheel consumer exists; a rebuildable sdist, Linux and + Windows wheels, and broader real-device matrices remain release gates; - the real browser proof is same-host. It does not establish WAN, TURN, or multi-region operation. @@ -239,7 +241,7 @@ publisher = pocketstation.Connector.from_audio_handler( ) session = pocketstation.Session() audio = session.audio_input("agent-output") -audio.output.send(session.destination(publisher)) +audio.output.send_to(publisher) ``` `pocketstation.aio.Connector.from_audio_handler(...)` accepts a coroutine and diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index dfef890..e42cc4c 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -328,7 +328,12 @@ def id(self) -> RuntimeSessionId: def capture(self, source: Source) -> Stem: """Declare one independent source-aware stem.""" - return _native_call(lambda: Stem(self._native.capture(source._native))) + return _native_call( + lambda: Stem( + self._native.capture(source._native), + self._destination_for_stream, + ) + ) def audio_input( self, @@ -356,7 +361,7 @@ def audio_input( config.frame_samples_per_channel, ) ) - return AudioInput(SyncPcmSource(native, config)) + return AudioInput(SyncPcmSource(native, config, self._destination_for_stream)) def pcm_source(self, config: AudioInputConfig) -> PcmSource: native = _native_call( @@ -367,7 +372,7 @@ def pcm_source(self, config: AudioInputConfig) -> PcmSource: config.frame_samples_per_channel, ) ) - return PcmSource(SyncPcmSource(native, config)) + return PcmSource(SyncPcmSource(native, config, self._destination_for_stream)) def polled_audio(self) -> Endpoint: """Declare the bounded managed-language polling endpoint.""" diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index 2478520..0780827 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from time import monotonic, sleep from ._native import _AudioInput as _NativeAudioInput from ._native import _AudioInputObservations as _NativeAudioInputObservations from .errors import AudioInputFullError, _native_call -from .graph import SourceOutput +from .graph import Endpoint, SourceOutput from .identity import SourceId, StreamId @@ -60,10 +61,15 @@ def _from_native( class PcmSource: """Advanced explicit ownership of one Session source output and PCM writer.""" - def __init__(self, native: _NativeAudioInput, config: AudioInputConfig) -> None: + def __init__( + self, + native: _NativeAudioInput, + config: AudioInputConfig, + destination: Callable[[object], Endpoint], + ) -> None: self._native = native self._config = config - self._output = SourceOutput(native.output) + self._output = SourceOutput(native.output, destination) @property def config(self) -> AudioInputConfig: diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index 04abcaa..d2060af 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field from enum import StrEnum -from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar, cast from ._native import DerivedStream as _NativeDerivedStream from ._native import Endpoint as _NativeEndpoint @@ -34,9 +34,17 @@ ) if TYPE_CHECKING: + from .aio.connector import Connector as AsyncConnector + from .connector import Connector as SyncConnector from .relay import RelayPublisher, RelayRoute from .signal import BusSubscription, SignalAudioPayload + _ConnectorTarget: TypeAlias = SyncConnector | AsyncConnector +else: + _ConnectorTarget: TypeAlias = object + +_DestinationResolver: TypeAlias = Callable[[object], "Endpoint"] + _PayloadT = TypeVar("_PayloadT") _PayloadT_co = TypeVar("_PayloadT_co", covariant=True) @@ -697,10 +705,15 @@ def port_name(self) -> str: class OperatorInstance: """Session-scoped operator instance with explicit named ports.""" - __slots__ = ("_native",) + __slots__ = ("_destination", "_native") - def __init__(self, native: _NativeOperatorInstance) -> None: + def __init__( + self, + native: _NativeOperatorInstance, + destination: _DestinationResolver, + ) -> None: self._native = native + self._destination = destination @property def session_id(self) -> RuntimeSessionId: @@ -714,13 +727,16 @@ def input(self, port_name: str) -> OperatorInput: return _native_call(lambda: OperatorInput(self._native.input(port_name))) def output(self, port_name: str) -> DerivedStream: - return _native_call(lambda: DerivedStream(self._native.output(port_name))) + return _native_call( + lambda: DerivedStream(self._native.output(port_name), self._destination) + ) class _RoutableStream: - __slots__ = () + __slots__ = ("_destination",) _native: _NativeStem | _NativeDerivedStream | _NativeSourceOutput + _destination: _DestinationResolver def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> RouteId: if input_port is None: @@ -729,6 +745,24 @@ def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> RouteId: _native_call(lambda: self._native.send_to(endpoint._native, input_port)) ) + def send_to( + self, + connector: _ConnectorTarget, + *, + input_port: str | None = None, + ) -> RouteId: + """Route to one Connector using this stream's owning Session. + + This is the concise one-destination form. Use + ``Session.register_connector(...).declare(...)`` when one Connector + implementation needs multiple configurations or explicit edge + contracts. + """ + return self.send( + self._destination(connector), + input_port=input_port, + ) + def connect(self, input: OperatorInput) -> RouteId: return RouteId(_native_call(lambda: self._native.connect(input._native))) @@ -746,7 +780,8 @@ def through( operator.configuration._as_dict(), input_port, output_port, - ) + ), + self._destination, ) ) @@ -757,8 +792,9 @@ class Stem(_RoutableStream): __slots__ = ("_native",) _native: _NativeStem - def __init__(self, native: _NativeStem) -> None: + def __init__(self, native: _NativeStem, destination: _DestinationResolver) -> None: self._native = native + self._destination = destination @property def id(self) -> StemId: @@ -787,8 +823,13 @@ class DerivedStream(_RoutableStream): __slots__ = ("_native",) _native: _NativeDerivedStream - def __init__(self, native: _NativeDerivedStream) -> None: + def __init__( + self, + native: _NativeDerivedStream, + destination: _DestinationResolver, + ) -> None: self._native = native + self._destination = destination @property def session_id(self) -> RuntimeSessionId: @@ -803,20 +844,29 @@ def output_port(self) -> str | None: return self._native.output_port def output(self, port_name: str) -> DerivedStream: - return _native_call(lambda: type(self)(self._native.output(port_name))) + return _native_call( + lambda: type(self)(self._native.output(port_name), self._destination) + ) def reenter_audio(self) -> Stem: """Declare canonical generated-PCM reentry; no Python callback runs.""" - return _native_call(lambda: Stem(self._native.reenter_audio())) + return _native_call( + lambda: Stem(self._native.reenter_audio(), self._destination) + ) class SourceInstance: """Open registered source declaration scoped to one Session.""" - __slots__ = ("_native",) + __slots__ = ("_destination", "_native") - def __init__(self, native: _NativeSourceInstance) -> None: + def __init__( + self, + native: _NativeSourceInstance, + destination: _DestinationResolver, + ) -> None: self._native = native + self._destination = destination @property def session_id(self) -> RuntimeSessionId: @@ -831,7 +881,9 @@ def source_id(self) -> SourceId: return SourceId(self._native.source_id) def output(self, port_name: str) -> SourceOutput: - return _native_call(lambda: SourceOutput(self._native.output(port_name))) + return _native_call( + lambda: SourceOutput(self._native.output(port_name), self._destination) + ) class SourceOutput(_RoutableStream): @@ -840,8 +892,13 @@ class SourceOutput(_RoutableStream): __slots__ = ("_native",) _native: _NativeSourceOutput - def __init__(self, native: _NativeSourceOutput) -> None: + def __init__( + self, + native: _NativeSourceOutput, + destination: _DestinationResolver, + ) -> None: self._native = native + self._destination = destination @property def session_id(self) -> RuntimeSessionId: @@ -881,6 +938,10 @@ class _GraphSessionDeclarations: _native: _NativeSession + def _destination_for_stream(self, connector: object) -> Endpoint: + """Call the concrete sync or asyncio Session declaration method.""" + return cast(Endpoint, cast(Any, self).destination(connector)) + @property def id(self) -> RuntimeSessionId: """Stable identity allocated by the canonical Rust Session.""" @@ -895,7 +956,8 @@ def source( values = SourceConfiguration() if configuration is None else configuration return _native_call( lambda: SourceInstance( - self._native.source(source_type_id, values._as_dict()) + self._native.source(source_type_id, values._as_dict()), + self._destination_for_stream, ) ) @@ -906,7 +968,8 @@ def operator(self, operator: Operator) -> OperatorInstance: self._native.operator( operator.operator_id, operator.configuration._as_dict(), - ) + ), + self._destination_for_stream, ) ) diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index fa7714e..719abed 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -298,7 +298,12 @@ def id(self) -> RuntimeSessionId: def capture(self, source: Source) -> Stem: """Declare one independent source-aware stem.""" - return _native_call(lambda: Stem(self._native.capture(source._native))) + return _native_call( + lambda: Stem( + self._native.capture(source._native), + self._destination_for_stream, + ) + ) def audio_input( self, @@ -327,7 +332,7 @@ def audio_input( config.frame_samples_per_channel, ) ) - return AudioInput(native, config) + return AudioInput(native, config, self._destination_for_stream) def pcm_source(self, config: AudioInputConfig) -> PcmSource: """Open the advanced explicit source-output and writer ownership API.""" @@ -339,7 +344,7 @@ def pcm_source(self, config: AudioInputConfig) -> PcmSource: config.frame_samples_per_channel, ) ) - return PcmSource(native, config) + return PcmSource(native, config, self._destination_for_stream) def polled_audio(self) -> Endpoint: """Declare the bounded managed-language polling endpoint.""" diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index fbaec62..dcf1951 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -282,10 +282,9 @@ def _exercise_abort() -> None: "io.pocketstation.test.installed-abort.v1", package_version="1.0.0", ) - endpoint = session.destination( + audio.output.send_to( pocketstation.Connector.with_driver(manifest, InstalledConnectorFactory(driver)) ) - audio.output.send(endpoint) running = session.start() result = running.cancel() if not result.success or not stopped.wait(1.0): diff --git a/tests/qualification/typing_contract.py b/tests/qualification/typing_contract.py index cda0c54..2c1a98e 100644 --- a/tests/qualification/typing_contract.py +++ b/tests/qualification/typing_contract.py @@ -8,6 +8,8 @@ def verify_signal_types( session: pocketstation.Session, source: pocketstation.SourceOutput, + connector: pocketstation.Connector, + async_connector: pocketstation.aio.Connector, ) -> None: audio_spec = pocketstation.SignalSpec.audio() text_spec = pocketstation.SignalSpec.text() @@ -21,6 +23,8 @@ def verify_signal_types( pocketstation.BusSubscription[pocketstation.SignalAudioPayload], ) assert_type(text_subscription, pocketstation.BusSubscription[str]) + assert_type(source.send_to(connector), pocketstation.RouteId) + assert_type(source.send_to(async_connector), pocketstation.RouteId) def verify_runtime_identities( diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 541059f..9becb64 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -221,6 +221,33 @@ async def receive(_item, _context): assert session.register_connector(provider).session_id == session.id +@pytest.mark.asyncio +async def test_async_stream_send_to_uses_the_owning_session() -> None: + delivered = asyncio.Event() + + async def receive(_item, _context): + delivered.set() + return ConnectorDeliveryOutcome.DELIVERED + + connector = AsyncConnector.from_handler( + ConnectorManifest.audio( + "io.pocketstation.test.aio-stream-destination.v1", + package_version="1.0.0", + ), + receive, + ) + session = Session() + audio = session.audio_input("agent-output", frame_samples_per_channel=4) + route_id = audio.output.send_to(connector) + + running = await session.start() + await audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + + await asyncio.wait_for(delivered.wait(), 1.0) + assert int(route_id) > 0 + assert (await running.stop()).success + + @pytest.mark.asyncio async def test_async_connector_runs_on_owning_loop_with_observations() -> None: delivered = asyncio.Event() diff --git a/tests/test_connector.py b/tests/test_connector.py index 72495d2..cca0e61 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -335,6 +335,26 @@ def publish(frame, context): assert received[0].polled_at_ns is None +def test_stream_send_to_declares_the_connector_on_its_owning_session() -> None: + delivered = Event() + + connector = Connector.from_audio_handler( + "io.pocketstation.test.stream-destination.v1", + lambda _frame, _context: delivered.set() or ConnectorDeliveryOutcome.DELIVERED, + package_version="1.0.0", + ) + session = Session() + audio = session.audio_input("agent-output", frame_samples_per_channel=4) + route_id = audio.output.send_to(connector) + + running = session.start() + audio.write(array("f", [0.1, 0.2, 0.3, 0.4])) + + assert delivered.wait(1.0) + assert int(route_id) > 0 + assert running.stop().success + + def test_connector_observations_preserve_service_state_and_delivery_counters() -> None: delivered = Event() diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py index 897bcfb..82cc189 100644 --- a/tests/test_operator_authoring.py +++ b/tests/test_operator_authoring.py @@ -304,7 +304,7 @@ def deliver(frame, _context): source.output.connect(operator.input("input")) generated = operator.output("output").reenter_audio() generated.send(session.polled_audio()) - generated.send(session.destination(connector)) + generated.send_to(connector) generated.record("generated") running = session.start() From e37134dd8824c724294f0f4cc23c86a12e37d1d6 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 21 Aug 2026 02:16:04 -0400 Subject: [PATCH 16/49] feat: qualify bounded Python audio ingress --- python/pocketstation/aio/audio_input.py | 19 ++-- python/pocketstation/audio_input.py | 17 ++- tests/run_installed_stream_conformance.py | 6 ++ tests/run_relay_e2e_publisher.py | 114 +++++++++++++++++--- tests/test_aio_session.py | 44 ++++++++ tests/test_audio_input.py | 124 ++++++++++++++++++++++ 6 files changed, 299 insertions(+), 25 deletions(-) create mode 100644 tests/test_audio_input.py diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py index d8cf802..2408a42 100644 --- a/python/pocketstation/aio/audio_input.py +++ b/python/pocketstation/aio/audio_input.py @@ -44,17 +44,22 @@ async def try_write( *, discontinuity: bool = False, ) -> None: - await asyncio.to_thread( - self._source.try_write, - samples, - discontinuity=discontinuity, - ) + """Attempt one immediate write into Core's finite preallocated pool. + + The native operation never waits for capacity. It either accepts the + frame or reports ``Full``, ``Closed``, ``Cancelled``, or an invalid + buffer, so dispatching every write through the thread pool would add + scheduling overhead without making the operation more asynchronous. + """ + self._source.try_write(samples, discontinuity=discontinuity) async def close(self) -> None: - await asyncio.to_thread(self._source.close) + """Close the native input immediately after its accepted frames drain.""" + self._source.close() async def observations(self) -> AudioInputObservations: - return await asyncio.to_thread(self._source.observations) + """Read one immediate point-in-time snapshot from Core.""" + return self._source.observations() class AudioInput(PcmSource): diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index 0780827..1205895 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -8,7 +8,7 @@ from ._native import _AudioInput as _NativeAudioInput from ._native import _AudioInputObservations as _NativeAudioInputObservations -from .errors import AudioInputFullError, _native_call +from .errors import AudioInputBufferError, AudioInputFullError, _native_call from .graph import Endpoint, SourceOutput from .identity import SourceId, StreamId @@ -89,9 +89,18 @@ def output(self) -> SourceOutput: def try_write(self, samples: object, *, discontinuity: bool = False) -> None: """Copy one C-contiguous float32 frame into a preallocated Core buffer.""" - _native_call( - lambda: self._native.try_write(samples, discontinuity=discontinuity) - ) + try: + _native_call( + lambda: self._native.try_write(samples, discontinuity=discontinuity) + ) + except BufferError as error: + # PyO3 rejects an incompatible buffer format before entering the + # native method, so it cannot attach PocketStation's coded error. + # Keep that binding detail out of the public SDK contract. + raise AudioInputBufferError( + "samples must be a C-contiguous float32 buffer", + "audio_input.invalid_buffer", + ) from error def close(self) -> None: """Close after accepted frames drain; subsequent writes fail explicitly.""" diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index c6048cc..af2a7dd 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -17,7 +17,13 @@ "tests/test_aio_streams.py::test_async_read_and_batch_modes_use_canonical_native_session", "tests/test_aio_streams.py::test_async_audio_batch_result_distinguishes_states", "tests/test_sources.py::test_application_owned_pcm_uses_the_canonical_source_and_recording_path", + "tests/test_audio_input.py::test_audio_input_rejects_wrong_or_noncontiguous_buffers", + "tests/test_audio_input.py::test_pcm_source_exposes_typed_identity_and_exact_finite_capacity", + "tests/test_audio_input.py::test_audio_input_close_and_session_cancellation_have_distinct_outcomes", + "tests/test_audio_input.py::test_audio_input_recovers_its_exact_preallocated_slot_after_delivery", "tests/test_aio_session.py::test_application_owned_pcm_has_an_async_writer", + "tests/test_aio_session.py::test_async_audio_input_immediate_operations_do_not_use_thread_pool", + "tests/test_aio_session.py::test_cancelled_async_audio_write_leaves_no_background_write", "tests/test_source_authoring.py::test_iterable_source_runs_in_core_and_receives_session_lineage", "tests/test_source_authoring.py::test_async_iterable_source_runs_on_the_owning_event_loop", "tests/test_operator_authoring.py::test_python_operator_processes_source_signal_with_derivation", diff --git a/tests/run_relay_e2e_publisher.py b/tests/run_relay_e2e_publisher.py index fc7cf53..d00a538 100644 --- a/tests/run_relay_e2e_publisher.py +++ b/tests/run_relay_e2e_publisher.py @@ -4,7 +4,10 @@ import argparse import json +import math +import os import sys +from array import array from pathlib import Path from time import sleep from typing import Any @@ -16,6 +19,32 @@ def emit(message_type: str, **fields: Any) -> None: print(json.dumps({"type": message_type, **fields}, sort_keys=True), flush=True) +def _tone(frequency_hz: float, *, sample_count: int = 480) -> array[float]: + return array( + "f", + ( + 0.2 * math.sin(2.0 * math.pi * frequency_hz * index / 48_000) + for index in range(sample_count) + ), + ) + + +def _write_application_inputs( + application: pks.AudioInput, + microphone: pks.AudioInput, + *, + active_seconds: float, +) -> None: + """Feed two finite Core inputs at their declared 10 ms cadence.""" + application_frame = _tone(440.0) + microphone_frame = _tone(660.0) + frame_count = max(1, math.ceil(active_seconds / 0.01)) + for _ in range(frame_count): + application.write(application_frame) + microphone.write(microphone_frame) + sleep(0.01) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--control-plane-url", required=True) @@ -28,9 +57,13 @@ def main() -> int: arguments = parser.parse_args() if arguments.active_seconds <= 0: parser.error("--active-seconds must be positive") + use_application_audio_inputs = ( + os.environ.get("PKS_E2E_APPLICATION_AUDIO_INPUT", "") == "1" + ) if ( arguments.application_name is None and arguments.application_process_id is None + and not use_application_audio_inputs and not hasattr(pks._native.Session, "conformance") ): emit("failure", code="relay.conformance_fixture_unavailable") @@ -43,7 +76,27 @@ def main() -> int: control_plane_url=arguments.control_plane_url, relay_url=arguments.relay_url, ) - if ( + application_audio: pks.AudioInput | None = None + microphone_audio: pks.AudioInput | None = None + application: pks.Stem | pks.SourceOutput + microphone: pks.Stem | pks.SourceOutput + if use_application_audio_inputs: + if ( + arguments.application_name is not None + or arguments.application_process_id is not None + ): + raise RuntimeError( + "application-owned PCM fixture cannot be combined with " + "physical capture" + ) + session = pks.Session(recording_root=arguments.recording_root) + application_audio = session.audio_input("application") + microphone_audio = session.audio_input("microphone") + application = application_audio.output + microphone = microphone_audio.output + source_mode = "conformance-fixture" + input_mode = "application-audio-input" + elif ( arguments.application_name is None and arguments.application_process_id is None ): @@ -52,12 +105,14 @@ def main() -> int: ) application_source = pks.Source.application("PocketStation Python Fixture") source_mode = "conformance-fixture" + input_mode = "capture-source" elif arguments.application_process_id is not None: session = pks.Session(recording_root=arguments.recording_root) application_source = pks.Source.application_process_id( arguments.application_process_id ) source_mode = "physical" + input_mode = "capture-source" else: assert arguments.application_name is not None matches = tuple( @@ -74,8 +129,10 @@ def main() -> int: session = pks.Session(recording_root=arguments.recording_root) application_source = pks.Source.from_discovered(matches[0]) source_mode = "physical" - application = session.capture(application_source) - microphone = session.capture(pks.Source.microphone_default()) + input_mode = "capture-source" + if not use_application_audio_inputs: + application = session.capture(application_source) + microphone = session.capture(pks.Source.microphone_default()) publisher = session.relay(remote) routes = ( application.publish(publisher, "application"), @@ -85,6 +142,25 @@ def main() -> int: microphone.record("microphone") running = session.start() + if application_audio is not None and microphone_audio is not None: + # Relay declares publication readiness only after every named bus + # has produced RTP. Prime a finite 100 ms per bus before waiting + # for the invitation; one 10 ms PCM frame is not enough to form + # the configured Opus packet. The remaining feed starts after the + # browser is attached so its delivery is observable. + application_frame = _tone(440.0) + microphone_frame = _tone(660.0) + for index in range(10): + discontinuity = index == 0 + application_audio.write( + application_frame, + discontinuity=discontinuity, + ) + microphone_audio.write( + microphone_frame, + discontinuity=discontinuity, + ) + sleep(0.01) invitation = remote.wait_for_publisher_and_invitation( timeout_seconds=15.0, poll_interval_seconds=0.05, @@ -97,6 +173,7 @@ def main() -> int: buses=[route.bus_id for route in routes], route_ids=[route.route_id for route in routes], source_mode=source_mode, + input_mode=input_mode, ) receiver = remote.wait_for_receiver( @@ -109,11 +186,19 @@ def main() -> int: source_active=receiver.snapshot.source_active, subscription_count=receiver.snapshot.subscription_count, ) - sleep(arguments.active_seconds) + if application_audio is not None and microphone_audio is not None: + _write_application_inputs( + application_audio, + microphone_audio, + active_seconds=arguments.active_seconds, + ) + else: + sleep(arguments.active_seconds) stop = running.stop() running = None recording = stop.recording + relay_outcome_values = stop.relay_outcomes relay_outcomes = [ { "bus_id": outcome.bus_id, @@ -125,8 +210,9 @@ def main() -> int: "failures_total": outcome.failures_total, "error": outcome.error, } - for outcome in stop.relay_outcomes + for outcome in relay_outcome_values ] + recording_stem_values = () if recording is None else recording.stems recording_stems = ( [] if recording is None @@ -138,7 +224,7 @@ def main() -> int: "discontinuities_total": stem.discontinuities_total, "error": stem.error, } - for stem in recording.stems + for stem in recording_stem_values ] ) expected_buses = {"application", "microphone"} @@ -146,15 +232,15 @@ def main() -> int: stop.success and recording is not None and recording.complete - and {stem["stem_name"] for stem in recording_stems} == expected_buses - and all(stem["frames_written_total"] > 0 for stem in recording_stems) - and {outcome["bus_id"] for outcome in relay_outcomes} == expected_buses + and {stem.stem_name for stem in recording_stem_values} == expected_buses + and all(stem.frames_written_total > 0 for stem in recording_stem_values) + and {outcome.bus_id for outcome in relay_outcome_values} == expected_buses and all( - outcome["frames_received_total"] > 0 - and outcome["rtp_packets_sent_total"] > 0 - and outcome["failures_total"] == 0 - and outcome["error"] is None - for outcome in relay_outcomes + outcome.frames_received_total > 0 + and outcome.rtp_packets_sent_total > 0 + and outcome.failures_total == 0 + and outcome.error is None + for outcome in relay_outcome_values ) ) remote.close() diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index 9becb64..fb9b638 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -106,6 +106,50 @@ async def test_async_audio_write_wait_is_finite_and_adds_no_python_queue() -> No assert observations.full_total > 0 +@pytest.mark.asyncio +async def test_async_audio_input_immediate_operations_do_not_use_thread_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = Session() + audio = session.audio_input( + "playback", + capacity_frames=2, + frame_samples_per_channel=4, + ) + + async def unexpected_to_thread(*args, **kwargs): + raise AssertionError("immediate audio input operation used asyncio.to_thread") + + monkeypatch.setattr(asyncio, "to_thread", unexpected_to_thread) + await audio.try_write(array("f", [0.1, 0.2, 0.3, 0.4])) + assert (await audio.observations()).accepted_total == 1 + await audio.close() + assert (await audio.observations()).closed + + +@pytest.mark.asyncio +async def test_cancelled_async_audio_write_leaves_no_background_write() -> None: + session = Session() + audio = session.audio_input( + "playback", + capacity_frames=1, + frame_samples_per_channel=4, + ) + samples = array("f", [0.1, 0.2, 0.3, 0.4]) + await audio.try_write(samples) + + pending = asyncio.create_task(audio.write(samples, timeout_s=1.0)) + await asyncio.sleep(0.01) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + await asyncio.sleep(0.01) + observations = await audio.observations() + assert observations.accepted_total == 1 + assert observations.full_total > 0 + + @pytest.mark.asyncio async def test_async_endpoint_runs_on_owning_loop_over_core_receivers() -> None: delivered = asyncio.Event() diff --git a/tests/test_audio_input.py b/tests/test_audio_input.py new file mode 100644 index 0000000..94a7f97 --- /dev/null +++ b/tests/test_audio_input.py @@ -0,0 +1,124 @@ +"""Application-owned PCM ingress contracts.""" + +from __future__ import annotations + +from array import array + +import pytest +from pocketstation import ( + AudioInputBufferError, + AudioInputCancelledError, + AudioInputClosedError, + AudioInputConfig, + AudioInputFullError, + Session, + SourceId, + StreamId, +) + + +@pytest.mark.parametrize("name", ["", " ", "\t"]) +def test_audio_input_rejects_empty_names(name: str) -> None: + with pytest.raises(ValueError, match="name must not be empty"): + AudioInputConfig(name=name) + + +@pytest.mark.parametrize( + "samples", + [ + array("d", [0.0] * 4), + array("f", [0.0] * 2), + memoryview(array("f", [0.0] * 8))[::2], + ], +) +def test_audio_input_rejects_wrong_or_noncontiguous_buffers(samples: object) -> None: + source = Session().audio_input("owned", frame_samples_per_channel=4) + + with pytest.raises(AudioInputBufferError) as failure: + source.try_write(samples) + + assert failure.value.code == "audio_input.invalid_buffer" + + +def test_pcm_source_exposes_typed_identity_and_exact_finite_capacity() -> None: + session = Session() + source = session.pcm_source( + AudioInputConfig( + name="generated", + capacity_frames=1, + frame_samples_per_channel=4, + ) + ) + samples = array("f", [0.0] * 4) + + assert isinstance(source.source_id, int) + assert isinstance(source.stream_id, int) + assert SourceId(source.source_id) == source.source_id + assert StreamId(source.stream_id) == source.stream_id + + source.try_write(samples) + with pytest.raises(AudioInputFullError) as full: + source.try_write(samples) + assert full.value.code == "audio_input.full" + + observations = source.observations() + assert observations.capacity_frames == 1 + # The queue has one slot and Core owns one additional in-flight buffer so + # the producer never allocates while a queued frame is being consumed. + assert observations.buffer_slots == observations.capacity_frames + 1 + assert observations.available_buffers == ( + observations.buffer_slots - observations.capacity_frames + ) + assert observations.accepted_total == 1 + assert observations.full_total == 1 + + +def test_audio_input_close_and_session_cancellation_have_distinct_outcomes() -> None: + samples = array("f", [0.0] * 4) + + closed = Session().audio_input("closed", frame_samples_per_channel=4) + closed.close() + with pytest.raises(AudioInputClosedError) as closed_failure: + closed.try_write(samples) + assert closed_failure.value.code == "audio_input.closed" + assert closed.observations().closed + + session = Session() + cancelled = session.audio_input("cancelled", frame_samples_per_channel=4) + cancelled.output.send(session.polled_audio()) + running = session.start() + assert running.cancel().success + + with pytest.raises(AudioInputCancelledError) as cancelled_failure: + cancelled.try_write(samples) + assert cancelled_failure.value.code == "audio_input.cancelled" + assert cancelled.observations().cancelled + + +def test_audio_input_recovers_its_exact_preallocated_slot_after_delivery() -> None: + session = Session() + audio = session.audio_input( + "owned", + capacity_frames=1, + frame_samples_per_channel=4, + ) + audio.output.send(session.polled_audio()) + running = session.start() + samples = array("f", [0.25, -0.25, 0.5, -0.5]) + + audio.try_write(samples, discontinuity=True) + frame = running.audio.read(timeout_s=1.0) + + assert frame is not None + assert frame.source_id == audio.source_id + assert frame.stream_id == audio.stream_id + assert frame.discontinuity_epoch == 1 + observations = audio.observations() + assert observations.available_buffers == observations.buffer_slots + + audio.try_write(samples) + second = running.audio.read(timeout_s=1.0) + assert second is not None + assert second.sequence_number == frame.sequence_number + 1 + assert second.discontinuity_epoch == frame.discontinuity_epoch + assert running.stop().success From c1aa2321a5351bba54e5dd90e9e9296880867cf4 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 21 Aug 2026 21:24:41 -0400 Subject: [PATCH 17/49] feat: ship the installed source-aware demo --- README.md | 679 ++++-------------- docs/adr/PY-004-backpressure-policy.md | 3 + docs/adr/PY-005-relay-listener-slice.md | 4 + docs/adr/PY-006-clock-sync-src.md | 3 + docs/adr/PY-007-capability-negotiation.md | 3 + .../PY-008-workspace-release-sequencing.md | 3 + docs/adr/PY-009-pion-writertp-profile.md | 3 + docs/adr/PY-010-jitter-buffer.md | 3 + docs/adr/PY-011-spsc-ring.md | 3 + docs/adr/PY-012-opus-frame-duration.md | 3 + .../PY-013-internal-format-channel-layout.md | 3 + docs/architecture/PocketStation-v2.3.md | 13 +- docs/architecture/pocketstation-v3.0.md | 7 +- examples/README.md | 55 ++ examples/__init__.py | 1 - examples/debug_voice_ai.py | 5 + examples/integrations/README.md | 33 - examples/integrations/__init__.py | 5 - examples/integrations/audio_transport.py | 61 -- examples/notebooks/__init__.py | 1 - examples/notebooks/execute.py | 51 -- .../source_aware_transcription.ipynb | 77 -- examples/physical_capture.py | 98 --- examples/transcription/README.md | 69 -- examples/transcription/__init__.py | 13 - examples/transcription/faster_whisper.py | 272 ------- examples/transcription/run.py | 95 --- examples/transcription/run_faster_whisper.py | 97 --- examples/transcription/transcript.py | 11 - examples/transcription/whisper_cpp.py | 270 ------- native/src/streams.rs | 2 +- pyproject.toml | 7 +- python/pocketstation/__init__.py | 588 +-------------- python/pocketstation/_api.py | 611 ++++++++++++++++ python/pocketstation/aio/__init__.py | 211 +----- python/pocketstation/aio/_api.py | 223 ++++++ python/pocketstation/aio/audio_input.py | 2 +- python/pocketstation/aio/capture.py | 50 +- python/pocketstation/aio/connector.py | 2 +- python/pocketstation/aio/control.py | 41 +- python/pocketstation/aio/relay.py | 118 +-- python/pocketstation/aio/session.py | 6 +- python/pocketstation/aio/sources.py | 2 +- python/pocketstation/capture.py | 50 +- python/pocketstation/connector.py | 2 +- python/pocketstation/control.py | 192 ++++- python/pocketstation/graph.py | 14 +- python/pocketstation/observations.py | 2 +- python/pocketstation/relay.py | 144 +--- python/pocketstation/session.py | 6 +- python/pocketstation/signal.py | 2 +- python/pocketstation/source_authoring.py | 6 +- python/pocketstation/sources.py | 22 +- python/pocketstation/streams.py | 4 +- python/pocketstation_examples/__init__.py | 7 + .../pocketstation_examples}/audio_windows.py | 88 ++- python/pocketstation_examples/demo.py | 50 ++ .../pocketstation_examples/faster_whisper.py | 445 ++++++++++++ python/pocketstation_examples/transcript.py | 11 + tests/installed_consumer.py | 2 +- tests/qualification/runtime_resources.py | 16 +- tests/qualification/typing_contract.py | 67 +- tests/run_artifact_consumer.py | 30 + tests/run_installed_stream_conformance.py | 1 - ...un_installed_transcription_cancellation.py | 235 ++++++ tests/run_relay_e2e_publisher.py | 299 +++++++- tests/test_aio_observations.py | 5 +- tests/test_aio_relay.py | 73 +- tests/test_aio_session.py | 6 +- tests/test_aio_streams.py | 5 +- tests/test_audio_input.py | 2 +- tests/test_audio_transport_example.py | 46 -- tests/test_batch_transcription.py | 163 +++++ tests/test_connector.py | 6 +- tests/test_control.py | 95 ++- tests/test_discovery.py | 5 +- tests/test_endpoint_authoring.py | 2 +- tests/test_extensions.py | 4 +- tests/test_generated_audio.py | 4 +- tests/test_graph.py | 2 +- tests/test_lifecycle.py | 4 +- tests/test_metrics.py | 5 +- tests/test_observations.py | 4 +- tests/test_operator_authoring.py | 4 +- tests/test_package_structure.py | 12 +- tests/test_permissions.py | 9 +- tests/test_public_api.py | 307 +------- tests/test_realtime_boundary.py | 2 +- tests/test_recording.py | 4 +- tests/test_relay.py | 120 ++-- tests/test_session.py | 4 +- tests/test_sidecar.py | 2 +- tests/test_signal_streams.py | 4 +- tests/test_source_authoring.py | 4 +- tests/test_source_lifecycle.py | 5 +- tests/test_sources.py | 2 +- tests/test_station.py | 4 +- tests/test_stream_state_machine.py | 10 +- tests/test_streams.py | 4 +- tests/test_transcription_example.py | 68 +- tests/test_types.py | 3 +- tests/transcription/__init__.py | 1 + tests/transcription/run_source_aware.py | 264 +++++++ .../transcription/wav_input.py | 6 +- 104 files changed, 3353 insertions(+), 3419 deletions(-) create mode 100644 examples/README.md delete mode 100644 examples/__init__.py create mode 100644 examples/debug_voice_ai.py delete mode 100644 examples/integrations/README.md delete mode 100644 examples/integrations/__init__.py delete mode 100644 examples/integrations/audio_transport.py delete mode 100644 examples/notebooks/__init__.py delete mode 100644 examples/notebooks/execute.py delete mode 100644 examples/notebooks/source_aware_transcription.ipynb delete mode 100644 examples/physical_capture.py delete mode 100644 examples/transcription/README.md delete mode 100644 examples/transcription/__init__.py delete mode 100644 examples/transcription/faster_whisper.py delete mode 100644 examples/transcription/run.py delete mode 100644 examples/transcription/run_faster_whisper.py delete mode 100644 examples/transcription/transcript.py delete mode 100644 examples/transcription/whisper_cpp.py create mode 100644 python/pocketstation/_api.py create mode 100644 python/pocketstation/aio/_api.py create mode 100644 python/pocketstation_examples/__init__.py rename {examples/transcription => python/pocketstation_examples}/audio_windows.py (57%) create mode 100644 python/pocketstation_examples/demo.py create mode 100644 python/pocketstation_examples/faster_whisper.py create mode 100644 python/pocketstation_examples/transcript.py create mode 100644 tests/run_installed_transcription_cancellation.py delete mode 100644 tests/test_audio_transport_example.py create mode 100644 tests/test_batch_transcription.py create mode 100644 tests/transcription/__init__.py create mode 100644 tests/transcription/run_source_aware.py rename {examples => tests}/transcription/wav_input.py (92%) diff --git a/README.md b/README.md index 91f591a..5fc2a0a 100644 --- a/README.md +++ b/README.md @@ -1,599 +1,202 @@ -# PocketStation for Python - -> **Development status: PARTIAL.** This is not yet the complete Python SDK and -> does not have full Rust capability parity. The binding program has accepted -> the exhaustive capability matrix, Pythonic stream slice, package ownership, -> typed source lifecycle, Rust-backed graph declarations, bounded typed signal -> streams, process sidecars, compiled native extensions, complete observations, -> application-owned PCM ingress, and an independently installable macOS wheel. -> The rebuildable sdist remains blocked on releasing the post-1.1.1 Core APIs -> currently consumed through a local development patch. The real Relay/browser -> path and source-aware transcription proof, -> and physical macOS application-plus-microphone path are proven. Linux and -> Windows artifact qualification and final OSS readiness remain gated. - -Capture one application and one microphone as independent, source-aware live -audio stems. Consume both from a bounded Python endpoint while the native Rust -runtime records each stem separately and preserves lineage, timing, drops, and -discontinuities. The same Session model extends to devices and explicit network -endpoints without changing the identity contract. +# PocketStation Python SDK -```python -import pocketstation - -with pocketstation.capture( - application="PocketStation Demo", - microphone=True, - record_to="recordings", -) as live: - for frame in live.audio: - print(frame) -``` - -The concise recipe and the explicit API use the same native `Session`. Python -does not reimplement capture, routing, timing, backpressure, or recording. - -## Current implementation truth - -The accepted stream, structure, source-lifecycle, graph, typed-signal, -extension, and sidecar slices are `SAFE-TO-MERGE`; the SDK as a whole remains -`PARTIAL`. Their component and -canonical-Session evidence is not release evidence: - -- native application and microphone declarations; -- independent stem and source identity on every delivered frame; -- one bounded managed-language polling boundary; -- frame-first sync and async streams that lazily flatten one native batch, - reject mixed reader modes, and fail immediately on concurrent readers; -- typed sync and async lifecycle/failure event streams backed by bounded native - waits rather than user-written polling loops; -- immutable sync and async source discovery over the canonical Rust provider, - including stable source identity, selector persistence, process-tree scope, - and exact native state; -- seven-state microphone permission observation with no implicit prompt and no - boolean collapse of `NotObservable`; -- typed source-unavailable/backend-failure events carrying source identity, - generation, failure detail, and the explicit recovery requirement; -- canonical Rust-backed `SignalSpec`, media, port, edge, operator, source, and - endpoint declarations with open stable identifiers and named ports; -- bounded application-owned float32 PCM input with preallocated Core buffers, - explicit full/closed/cancelled/invalid outcomes, source/stream identity, - discontinuity propagation, sync/async writers, and normal Session fan-out; -- operator chaining and concrete generated-audio reentry through the Rust - compiler/runtime, preserving lineage and recording without a Python hot-path - callback; -- real bounded `BusSubscription` endpoints for PCM, text, and bytes with - Pythonic sync/async read and iteration, immutable payloads, complete generic - timing/lineage/derivation, and distinct timeout, EOF, fault, and close states; -- immutable native extension descriptors and trusted absolute-path compiled - libraries validated by the linked ABI 1.2 authority, transactionally imported - into the canonical native `Session`, and retained for its full lifetime; -- typed process-sidecar specs, messages, sync/async streams, bounded queue - saturation, deadlines, cancellation, graceful close, forced kill, wait, reap, - and live/final observations, all owned by the same native `Session`; -- in-process Python Connector authoring over Core's bounded off-realtime - Connector worker, with typed configuration, redacted secrets, full input - contracts, structured failures, readiness/health/recovery control, and - drain/abort shutdown; -- sync and asyncio Source authoring over Core's blocking Source worker, with - typed output contracts, Session-owned lineage, finite async deadlines, - cancellation, and exact provider cleanup; -- sync and asyncio Operator authoring over Core's bounded async Operator - runtime, with compiled port/edge preparation, typed derived outputs, - finite async deadlines, cancellation, and derivation metadata; -- native blocking waits release the interpreter, and executable tests prove - Python remains responsive while a hung child is terminated and reaped; -- immutable Session snapshots covering event and audio queues, source ingress, - routes, operator inputs/workers, external sources, derived routes, and - generated-audio reentry with capacities, bytes, depths, peaks, loss causes, - discontinuities, and named nanosecond latency boundaries; -- bounded native trace configuration, final recorder accounting, offline read, - rolling hash, and deterministic lifecycle/terminal validation; -- deterministic, idempotent Python shutdown; -- distinct stop/cancel dispositions with the native terminal event retained so - source, endpoint, rollback, and finalization fault categories are not lost; -- complete/incomplete per-stem recording outcomes with stable error codes, - queue/write/drop counters, and typed gap detail; -- synchronous and `asyncio` ownership models; -- typed Session lifecycle client for the current control-plane HTTP API. -- shared native Relay publication of independent application and microphone - buses, opaque receiver invitation after publication readiness, real browser - receipt, and complete two-stem recording; -- source-aware faster-whisper integration over the normal Python model API, - plus a real hard-isolated whisper.cpp proof and output-free executable - notebook over the same public async Operator contract; -- current-host sync/async boundary and slow-consumer qualification with exact - units, bounded queue peaks, explicit drops, and post-shutdown descriptor, - thread, native-buffer, and Python-allocation observations. - -The SDK is not complete: - -- Core's normal public API does not name raw trace-record values or the typed - component/stage enums behind rollback and finalization failures. Python - therefore exposes validated trace summary/terminal truth and stable failure - stages, but does not claim a stable raw-record or fully typed control-failure - owner projection; -- capture authorization snapshots and permission-transition ownership are not - attached to the canonical running Session; the SDK preserves discovery and - the authoritative seven-state platform observation without inventing either; -- an isolated macOS wheel consumer exists; a rebuildable sdist, Linux and - Windows wheels, and broader real-device matrices remain release gates; -- the real browser proof is same-host. It does not establish WAN, TURN, or - multi-region operation. - -The ordinary API is frame iteration over the native bounded endpoint; explicit -reads and native batch iteration remain advanced modes. The accepted stream -gate proves that no second Python queue exists and reader modes cannot be mixed -or consumed concurrently. Python is never invoked from an audio callback or -realtime partition. - -## Compiled native extensions - -Load a trusted C or Rust dynamic library before starting the Session. This is a -raw native-code trust boundary, not package authentication: PocketStation does -not verify its publisher, signature, or checksum and does not sandbox it. The -path must be absolute; Core canonicalizes it, validates the ABI and complete -descriptor set, imports every registration transactionally, and retains the -library until the Session is destroyed. - -```python -from pathlib import Path - -import pocketstation +PocketStation lets you inspect both sides of a live desktop voice application. +It keeps the application's output separate from the physical microphone while +one native Session transcribes, publishes, and records both sides. -session = pocketstation.Session() -library = session.load_native_extension_library( - Path("extensions/libacme_processor.dylib").resolve() -) +The Python SDK uses the PocketStation Rust engine. Python owns application and +model logic; it does not reimplement capture, routing, timing, recording, or +Relay media transport. -source = session.source("acme.source") -operator = session.operator(pocketstation.Operator("acme.operator")) -source.output("out").connect(operator.input("in")) -``` +> **Status: PARTIAL.** The installed macOS wheel has completed the Lab workflow +> described below. The package is not published to PyPI. Linux and Windows +> wheels, WAN/TURN evidence, and a standalone source distribution remain +> release gates. -The receipt exposes the canonical path and immutable source, operator, and -endpoint registrations. Loading is a synchronous pre-start declaration in -both `pocketstation.Session` and `pocketstation.aio.Session`; it does not run -Python in foreign callbacks or admit PCM callbacks onto realtime partitions. -Process sidecars remain available when crash isolation or a separately managed -process is wanted. They are not required for ordinary Python Connector -authoring. +## Debug a live voice application -## Python Sources and Operators +The demo requires: -Python can define typed non-PCM Sources and Operators without implementing a -second Session runtime. These providers execute only on Core-owned blocking or -async worker partitions; application-owned PCM continues to use the dedicated -bounded `Session.audio_input()` path. +- macOS with Screen Recording and Microphone permission; +- Python 3.11 or newer; +- a PocketStation development wheel built for your Python and macOS target; +- internet access on the first run to download the default faster-whisper + model; +- access to the configured PocketStation control plane and Relay. -```python -import pocketstation +Install the wheel with its transcription dependency, then run one command: -text = pocketstation.SignalSpec.text(role="request") -source_manifest = pocketstation.SourceManifest( - "io.example.source.requests.v1", - outputs=(pocketstation.PortSpec.output("events", text),), -) - -@pocketstation.source(source_manifest) -def requests(configuration): - yield pocketstation.SourceEmission.text( - "events", configuration["text"], signal=text - ) -``` - -Operators receive immutable envelopes and emit values whose lineage and -derivation are attached by Core: - -```python -result = pocketstation.SignalSpec.text(role="result.final") -operator_manifest = pocketstation.OperatorManifest( - "io.example.operator.uppercase.v1", - inputs=(pocketstation.PortSpec.input("input", text),), - outputs=(pocketstation.PortSpec.output("output", result),), -) - -@pocketstation.operator(operator_manifest) -def uppercase(_port, envelope): - return (pocketstation.OperatorEmission.text(envelope.payload.upper(), signal=result),) -``` - -An Operator with an exact PCM output contract can emit one contiguous float32 -frame directly. Python runs on the bounded async-worker partition; the binding -snapshots the frame and Core moves it through its preallocated generated-audio -pool and normal Session routing: - -```python -def synthesize(_port, envelope): - pcm = model.render(envelope.payload) - return (pocketstation.OperatorEmission.audio(pcm, signal=generated_audio),) - -speaker = operator.output("audio").reenter_audio() -speaker.send(session.destination(publisher)) -speaker.record("agent-output") +```bash +python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' +pocketstation-demo ``` -The Operator manifest must declare an exact sample rate, frame size, and mono -or stereo layout. A wrong frame size, exhausted native pool, or non-contiguous -buffer fails explicitly. Streaming TTS or audio produced independently of an -Operator input should continue to use `Session.audio_input()`; that Source path -has its own finite writer and discontinuity contract. - -`pocketstation.aio.source` and `pocketstation.aio.operator` accept async -iterables and coroutine handlers with explicit finite deadlines. The same -native Session remains authoritative for registration, compilation, -backpressure, cancellation, and terminal outcomes. +The command asks which desktop application to inspect. It then starts one +Session and: -## Python Connectors +- opens the browser invitation after Relay confirms the publisher; +- prints each transcript with its original source identity; +- writes the application and microphone to separate recording stems. -The concise path declares an audio contract and handles frames directly. Core -still owns bounded receiver polling, route accounting, readiness, failure -containment, and shutdown; Python executes only on the Connector's -off-realtime worker. - -```python -import pocketstation - -publisher = pocketstation.Connector.from_audio_handler( - "io.example.connector.stdout.v1", - lambda frame, context: print(frame.sequence_number), - package_version="1.0.0", -) -session = pocketstation.Session() -audio = session.audio_input("agent-output") -audio.output.send_to(publisher) -``` +The Session runs this path concurrently: -`pocketstation.aio.Connector.from_audio_handler(...)` accepts a coroutine and -enforces finite delivery deadlines. The complete manifest, typed -configuration, driver, grouped worker, and observation APIs remain available -for reusable provider packages. When one implementation needs several -independently configured Endpoints, retain the explicit form: - -```python -registered = session.register_connector(publisher) -primary = registered.declare(primary_configuration) -backup = registered.declare(backup_configuration) +```text +voice application ─┐ +physical microphone┼─ faster-whisper transcripts + ├─ two named Relay/browser buses + └─ two aligned recording stems ``` -Stateful providers implement `ConnectorDriver` and register with -`Connector.with_driver(...)`. Their factory receives every resolved input -descriptor, including `SignalSpec`, `MediaCaps`, `EdgeContract`, route identity, -and typed configuration. `ConnectorConfigurationValue.secret(...)` is redacted -by default and requires explicit provider access. Provider exceptions can use -`ConnectorError` to preserve a stable error code, stage, and retryability in the -final Session outcome. - -### Generic Endpoints are an advanced escape hatch - -Use a `Connector` for an outbound provider integration. Use `EndpointProvider` -only when an integration needs Core's lower-level Endpoint SPI without the -Connector service model: +Press `Ctrl-C` to stop. PocketStation cancels pending model work, closes the +RelaySession, and finalizes the recording. -```python -provider = pocketstation.EndpointProvider(manifest, prepare) -endpoint = session.register_endpoint(provider).declare(configuration) -audio.output.send(endpoint) -``` - -Core still owns graph compilation, bounded input receivers, transactional -prepare/start rollback, the closed start gate, drain versus abort, join, and -terminal outcomes. The Python implementation owns only its off-realtime worker -and provider resources. `pocketstation.aio.EndpointProvider` projects the same -contract onto one owning event loop with finite prepare, start, and shutdown -deadlines. Neither API creates a Python Session or media engine. +The installed command is implemented in one program under 50 lines: +[`python/pocketstation_examples/demo.py`](python/pocketstation_examples/demo.py). +The example package imports `faster_whisper.WhisperModel` when it starts. Its +adapter is example-owned and is not part of the `pocketstation` SDK namespace. -The capability matrix distinguishes declaration-level `REAL` rows from -component-only `PARTIAL` rows and completely `ABSENT` projections. A row marked -`REAL` is evidence-scoped; it does not upgrade the SDK, a platform, or a -deployment to production readiness. +This demo does not claim speaker diarization, conversational-agent behavior, +WAN/TURN qualification, or a zero-copy Rust-to-Python model boundary. -## Application-owned audio +## Capture application and microphone audio -When an application already owns PCM, feed it directly into the Session instead -of recapturing the application through the operating system: +Use `capture()` when you want frames in Python as well as separate recordings: ```python -from array import array - -from pocketstation import Session - -session = Session() -playback = session.audio_input("playback", frame_samples_per_channel=480) -playback.output.send(session.polled_audio()) -playback.output.record("playback") +import pocketstation -with session.start() as running: - playback.write(array("f", [0.0] * 480)) - frame = running.audio.read(timeout_s=1.0) +with pocketstation.capture( + application="Zoom", + microphone=True, + record_to="recordings", +) as live: + for frame in live.audio: + print(frame.source_id, frame.stem_id) ``` -`write()` accepts one C-contiguous float32 frame and never grows the native -queue. Advanced integrations can use `Session.pcm_source(AudioInputConfig(...))` -to retain explicit source-output and writer ownership. The asyncio API exposes -the same contract without executing Python on realtime partitions. +The iterator reads a bounded native endpoint. A slow Python consumer produces +observable pressure and discontinuities; it does not create an unbounded Python +audio queue. -## Explicit Session API +## Send application-owned audio into a Session -Use the explicit surface when route identifiers and lifecycle control matter: +Use `audio_input()` when your application already owns PCM, such as generated +speech or audio received from a call provider: ```python -from itertools import islice - -from pocketstation import Session, Source - -session = Session(recording_root="recordings") -application = session.capture(Source.application("PocketStation Demo")) -microphone = session.capture(Source.microphone_default()) -audio = session.polled_audio() - -application_route = application.send(audio) -microphone_route = microphone.send(audio) -application.record("application") -microphone.record("microphone") - -with session.start() as running: - for frame in islice(running.audio, 500): - print(frame) - - metrics = running.metrics() - print(metrics.polled_audio.queue_capacity_frames) - print(metrics.polled_audio.queue_full_drops_total) - for route in metrics.routes: - print(route.route_id, route.edge.frames_dropped_total) - -stop = running.stop_result -assert stop is not None -recording = stop.recording -terminal = stop.terminal_event -``` - -Enable a finite diagnostic trace at Session declaration time and validate it -offline after shutdown: +session = pocketstation.Session(recording_root="recordings") +agent = session.audio_input("agent-output") +agent.output.record("agent") -```python -from pocketstation import Session, SessionTrace, SessionTraceConfiguration - -session = Session(trace=SessionTraceConfiguration("session.trace", 256)) -# declare sources and routes, start, then stop the Session -trace = SessionTrace.read("session.trace") -validation = trace.validate() -print(validation.terminal_state, trace.outcome.rolling_hash) +with session.start(): + agent.write(samples) ``` -Snapshots and outcomes are frozen, slotted Python values copied from the -canonical native owner. PocketStation does not start an exporter or a Python -telemetry thread; a future OpenTelemetry adapter must remain optional and -outside the realtime runtime. - -Application selectors also support bundle ID, process ID, stable source ID, and -an exact process instance. Microphones support the default device or a stable -device ID. +The input uses finite preallocated Core buffers. Writes report full, closed, +cancelled, and invalid-buffer outcomes explicitly. -## Typed graph declarations - -The expert surface remains Pythonic without creating a Python graph engine. -Each declaration immediately becomes an opaque handle in the same Rust -`Session`; the Rust compiler remains authoritative for registration, ports, -media, exclusivity, and route errors. - -```python -from pocketstation import Operator, Session, Source - -session = Session(recording_root="recordings") -microphone = session.capture(Source.microphone_default()) -transcribed = microphone.through( - Operator("org.example.transcriber.v1"), - input_port="audio-in", - output_port="transcript", -) -transcribed.send( - session.connector("org.example.transcript-sink.v1"), - input_port="events", -) -``` - -`SignalSpec`, `MediaCaps`, `PortSpec`, and `EdgeContract` project the canonical -Rust value contracts. Operator and connector IDs remain open strings—there is -no closed model/provider enum. Generated PCM uses -`derived.reenter_audio()` and returns a normal source-aware `Stem`; concrete -media and exclusive consumption are checked before runtime start. - -## Typed signal subscriptions - -Operators and external sources can expose non-audio signals without creating a -second Python graph or queue. A subscription is a real bounded endpoint in the -same Rust `Session`: - -```python -from pocketstation import Operator, Session, SignalSpec, Source - -session = Session() -microphone = session.capture(Source.microphone_default()) -transcript = microphone.through( - Operator("org.example.transcriber.v1"), - input_port="audio-in", - output_port="transcript", -) -subscription = session.subscribe(transcript, signal=SignalSpec.text()) - -with session.start() as running: - for envelope in running.signals(subscription): - print(envelope.payload, envelope.lineage, envelope.derivation) -``` +## Choose the right extension point -`read()` returns `None` only when its bounded wait expires and `STREAM_EOF` -after permanent endpoint closure. Faults raise `StreamError`. A stream fixes its -reader mode on first use and rejects concurrent readers. The default edge is a -finite bounded-async contract with media inferred from the exact `SignalSpec`. +PocketStation uses four open boundaries: -## Discovery and permission truth +| Boundary | Use it when | +|---|---| +| `Source` | Media or signals enter the Session. | +| `Operator` | Work transforms media or emits typed signals. | +| `Connector` | Media or signals leave for an external system. | +| `Endpoint` | You need the lower-level outbound execution contract. | -Discovery is a point-in-time immutable snapshot from the Rust source provider; -Python does not maintain a second registry or reinterpret platform state: +Import advanced contracts from their owning module so application code shows +which boundary it uses: ```python -import pocketstation - -for source in pocketstation.discover_sources( - pocketstation.SourceQuery.kind(pocketstation.SourceKind.APPLICATION) -): - print(source.stable_id, source.state, source.identity_strength) - -permission = pocketstation.microphone_permission_observation() -if permission is pocketstation.PermissionObservation.NOT_DETERMINED: - print("The host application must request permission explicitly.") +from pocketstation.connector import Connector, ConnectorManifest +from pocketstation.operator_authoring import OperatorProvider +from pocketstation.source_authoring import SourceProvider ``` -Observation never prompts. macOS and eligible Windows application contexts can -provide authoritative states; Linux and other backends return -`NOT_OBSERVABLE` unless the native backend can establish authority. That value -does not mean allowed or denied. Source disappearance is delivered through -`running.events` with the stable identity, source generation, failure detail, -and an explicit `EXPLICIT_REDISCOVERY_AND_NEW_SESSION` recovery requirement. +The package root contains only the common Session, capture, audio-input, and +error contracts. Advanced imports name the boundary they use; there is no +second flat compatibility API. -Each `AudioFrame.samples` is a read-only `memoryview` over owned little-endian -`f32` PCM bytes. The view adds no further copy, but transferring a realtime -frame into Python ownership does copy it out of the native bounded batch. Use -`numpy.frombuffer(frame.samples, dtype=" **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-005-relay-listener-slice.md b/docs/adr/PY-005-relay-listener-slice.md index 227155a..2d89c94 100644 --- a/docs/adr/PY-005-relay-listener-slice.md +++ b/docs/adr/PY-005-relay-listener-slice.md @@ -1,5 +1,9 @@ # PY-005-relay-listener-slice — Relay Listener Slice Model +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. The current receiver term is `BusSubscription`; see +> [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-006-clock-sync-src.md b/docs/adr/PY-006-clock-sync-src.md index 9d3fa71..6549e79 100644 --- a/docs/adr/PY-006-clock-sync-src.md +++ b/docs/adr/PY-006-clock-sync-src.md @@ -1,5 +1,8 @@ # PY-006-clock-sync-src — Clock Sync / Async Sample Rate Conversion +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-007-capability-negotiation.md b/docs/adr/PY-007-capability-negotiation.md index 705092a..bb1b8d4 100644 --- a/docs/adr/PY-007-capability-negotiation.md +++ b/docs/adr/PY-007-capability-negotiation.md @@ -1,5 +1,8 @@ # PY-007-capability-negotiation — Capability Negotiation +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-008-workspace-release-sequencing.md b/docs/adr/PY-008-workspace-release-sequencing.md index 3999e8e..ecec867 100644 --- a/docs/adr/PY-008-workspace-release-sequencing.md +++ b/docs/adr/PY-008-workspace-release-sequencing.md @@ -1,5 +1,8 @@ # PY-008-workspace-release-sequencing — Workspace Release Sequencing +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-009-pion-writertp-profile.md b/docs/adr/PY-009-pion-writertp-profile.md index 9cf235d..1ec3acf 100644 --- a/docs/adr/PY-009-pion-writertp-profile.md +++ b/docs/adr/PY-009-pion-writertp-profile.md @@ -1,5 +1,8 @@ # PY-009-pion-writertp-profile — Pion WriteRTP Allocation Profile +> **Historical record.** This Relay implementation note does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-010-jitter-buffer.md b/docs/adr/PY-010-jitter-buffer.md index 3c934ca..2122c0d 100644 --- a/docs/adr/PY-010-jitter-buffer.md +++ b/docs/adr/PY-010-jitter-buffer.md @@ -1,5 +1,8 @@ # PY-010-jitter-buffer — Jitter Buffer Algorithm +> **Historical record.** This Relay implementation note does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-011-spsc-ring.md b/docs/adr/PY-011-spsc-ring.md index fd6cf78..aa8270e 100644 --- a/docs/adr/PY-011-spsc-ring.md +++ b/docs/adr/PY-011-spsc-ring.md @@ -1,5 +1,8 @@ # PY-011-spsc-ring — SPSC Ring Buffer Choice +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-012-opus-frame-duration.md b/docs/adr/PY-012-opus-frame-duration.md index eadd11e..bae0969 100644 --- a/docs/adr/PY-012-opus-frame-duration.md +++ b/docs/adr/PY-012-opus-frame-duration.md @@ -1,5 +1,8 @@ # PY-012-opus-frame-duration — Opus Frame Duration +> **Historical record.** This Relay codec note does not define the current +> SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## Status Accepted for v2.3 scaffold. Reversal requires Phase 0/1 measurement data. diff --git a/docs/adr/PY-013-internal-format-channel-layout.md b/docs/adr/PY-013-internal-format-channel-layout.md index 98d717c..2c60376 100644 --- a/docs/adr/PY-013-internal-format-channel-layout.md +++ b/docs/adr/PY-013-internal-format-channel-layout.md @@ -1,5 +1,8 @@ # PY-013-internal-format-channel-layout — Internal Sample Format and Channel Layout +> **Historical record.** This v2.3 scaffold decision does not define the +> current SDK. See [Python SDK design](../PYTHON_SDK_DESIGN.md). + ## 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 e3cb58a..2baf12b 100644 --- a/docs/architecture/PocketStation-v2.3.md +++ b/docs/architecture/PocketStation-v2.3.md @@ -1,12 +1,15 @@ # PocketStation Architecture -**Canonical document:** [`pocketstation-io/docs`](https://github.com/pocketstation-io/docs/blob/main/content/architecture/pocketstation-v2.3.md) +> **Historical pointer.** This page does not define the current Python SDK. +> Start with the repository [README](../../README.md). + +**Archived document:** [`pocketstation-io/docs`](https://github.com/pocketstation-io/docs/blob/main/content/architecture/pocketstation-v2.3.md) This repo previously contained a copy of the architecture document. The copy has been -replaced with this pointer to eliminate sync drift between the canonical source and 17 +replaced with this pointer to eliminate sync drift between the archived source and 17 downstream copies. **Last synced copy removed:** 2026-06-09 (v2.3) -**Reason:** Wave 5 — single source of truth enforcement per FAANG-tier repo standards -**Tracking issue:** any architecture change must update `pocketstation-io/docs` only; -sub-repos reference the canonical via this pointer. +**Reason:** the architecture copy had no repository-local ownership. +**Maintenance:** preserve this pointer; current SDK documentation belongs in +this repository's README and design reference. diff --git a/docs/architecture/pocketstation-v3.0.md b/docs/architecture/pocketstation-v3.0.md index 4672cbc..d1040ec 100644 --- a/docs/architecture/pocketstation-v3.0.md +++ b/docs/architecture/pocketstation-v3.0.md @@ -1,6 +1,11 @@ # PocketStation ## Program Document v3.0 +> **Historical architecture record.** This document preserves an earlier +> program design. It does not describe the current Python SDK API or supported +> workflow. Start with the repository [README](../../README.md), then use the +> [SDK design contract](../PYTHON_SDK_DESIGN.md) for current ownership. + **Date:** 2026-06-26 **Status:** Green-light version. AudioGraph is the product center. v2.3 core algorithm, platform specs, and engineering ADRs are fully preserved underneath the new graph abstraction. No further structural rewrites planned. **Supersedes:** v2.3 (Universal Audio Fabric / mobile-first SDK + relay positioning) @@ -2272,4 +2277,4 @@ When a model node fails or exceeds latency budget: does the graph pause, reroute *Document version 3.0 — green-light version. AudioGraph is the product center. v2.3 core algorithm, hot-path rules, platform specs, and FFI boundary contracts (DOCS-001 through DOCS-013) are fully preserved. New open questions: ADR-014 through ADR-022. Next revision trigger: Phase 0 exit criteria met and first crates.io publish at Phase 1 exit.* -*Kill criteria reviewed: 2026-06-26.* \ No newline at end of file +*Kill criteria reviewed: 2026-06-26.* diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..db1972e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,55 @@ +# Debug both sides of a live voice application + +Use this demo when you need to see what a desktop voice application produced +and what the person said into the microphone without mixing the two sides +together. It transcribes both sides, sends them to a browser, and records each +side as a separate stem. + +## Prerequisites + +- macOS Screen Recording and Microphone permission; +- Python 3.11 or newer; +- an installed PocketStation wheel with the `transcription` extra; +- network access for the first model download and the configured Relay services. + +## Run + +```bash +pocketstation-demo +``` + +Enter an application display name, process ID, or bundle identifier when +prompted. The command uses PocketStation's small, rate-limited demonstration +deployment by default. It can return `HTTP 429` when the shared capacity is in +use. Set `POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` to use a +deployment you operate. + +The checkout runner calls the same installed entry point: +[`debug_voice_ai.py`](debug_voice_ai.py). The complete installed command is a +program under 50 lines: [`demo.py`](../python/pocketstation_examples/demo.py). +Model buffering and provider code stay in the example-owned +`pocketstation_examples` package, outside the `pocketstation` SDK namespace. + +## Expected result + +After Relay confirms publication, the command opens a browser invitation. It +then prints transcript events produced by +`faster_whisper.WhisperModel`, including the source identity for each result. + +```text +voice application ─┐ +physical microphone┼─ independent local transcripts + ├─ two browser buses + └─ two recording stems +``` + +Press `Ctrl-C` to stop. The Session cancels pending model work, deletes the +remote RelaySession, and finalizes both recordings under `recordings/`. + +## Evidence boundary + +The Lab gate installs the built wheel and uses faster-whisper inference, +the Rust Relay connector, the Go Relay service, Chromium, and finalized +recording artifacts. Its network path is same-host and remains +`LOOPBACK-ONLY`; it does not prove WAN or TURN behavior. The model runs on a +bounded off-realtime worker. The Rust-to-Python audio boundary is not zero-copy. diff --git a/examples/__init__.py b/examples/__init__.py deleted file mode 100644 index ca3b96f..0000000 --- a/examples/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Executable PocketStation examples; provider code does not enter the SDK package.""" diff --git a/examples/debug_voice_ai.py b/examples/debug_voice_ai.py new file mode 100644 index 0000000..29941e4 --- /dev/null +++ b/examples/debug_voice_ai.py @@ -0,0 +1,5 @@ +"""Run the installed voice-AI debugging demo.""" + +from pocketstation_examples import main + +main() diff --git a/examples/integrations/README.md b/examples/integrations/README.md deleted file mode 100644 index 33785d9..0000000 --- a/examples/integrations/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Call and agent audio - -PocketStation does not need a second media engine or a provider enum to work -with LiveKit, Daily, Pipecat, Vapi, SIP, or a custom WebSocket. An adapter maps -the provider's decoded PCM into `AudioInput` and maps a Session stream into an -async audio Connector. - -```python -import pocketstation.aio as pks_aio - -session = pks_aio.Session(sample_rate_hz=16_000) -caller = session.audio_input("caller", sample_rate_hz=16_000) -publisher = attach_audio_sender( - session, - agent_audio, - call.send_pcm, - connector_id="io.acme.call.v1", - package_version="1.0.0", -) -await ingest_audio(caller, call.incoming_pcm()) -``` - -The provider adapter still owns authentication, codec conversion, track or -participant selection, resampling into the Session's one concrete sample -contract, reconnect policy, and remote metadata transport. Core -owns the source/stream/stem identity, bounded queues, discontinuities, routing, -recording, Operator execution, Connector lifecycle, and terminal outcome. - -`call.send_pcm` must accept PocketStation's `AudioFrame` and convert it to the -provider's required frame type. `call.incoming_pcm()` yields `IncomingAudio` -with C-contiguous float32 samples and marks provider reconnects or packet gaps -as discontinuities. No Python function runs on a capture callback or realtime -partition. diff --git a/examples/integrations/__init__.py b/examples/integrations/__init__.py deleted file mode 100644 index 3af3633..0000000 --- a/examples/integrations/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Provider-neutral integration examples over the public Python SDK.""" - -from .audio_transport import IncomingAudio, attach_audio_sender, ingest_audio - -__all__ = ["IncomingAudio", "attach_audio_sender", "ingest_audio"] diff --git a/examples/integrations/audio_transport.py b/examples/integrations/audio_transport.py deleted file mode 100644 index e6df2b3..0000000 --- a/examples/integrations/audio_transport.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Bridge call, agent, or transport PCM without a provider-specific engine.""" - -from __future__ import annotations - -from collections.abc import AsyncIterable -from dataclasses import dataclass - -import pocketstation -import pocketstation.aio as pks_aio - - -@dataclass(frozen=True, slots=True) -class IncomingAudio: - """One provider-decoded float32 PCM frame and its continuity boundary.""" - - samples: object - discontinuity: bool = False - - -def attach_audio_sender( - session: pks_aio.Session, - stream: pocketstation.Stem - | pocketstation.SourceOutput - | pocketstation.DerivedStream, - sender: pks_aio.AudioConnectorHandler, - *, - connector_id: str, - package_version: str, - delivery_timeout_s: float = 5.0, -) -> pks_aio.RegisteredConnector: - """Route one Session stream into any coroutine-based audio transport.""" - connector = pks_aio.Connector.from_audio_handler( - connector_id, - sender, - package_version=package_version, - deadlines=pks_aio.ConnectorDeadlines(delivery_s=delivery_timeout_s), - ) - registered = session.register_connector(connector) - stream.send(registered.declare()) - return registered - - -async def ingest_audio( - target: pks_aio.AudioInput, - frames: AsyncIterable[IncomingAudio], - *, - write_timeout_s: float = 1.0, -) -> None: - """Feed provider-owned PCM into one bounded source and close it exactly once.""" - try: - async for frame in frames: - await target.write( - frame.samples, - discontinuity=frame.discontinuity, - timeout_s=write_timeout_s, - ) - finally: - await target.close() - - -__all__ = ["IncomingAudio", "attach_audio_sender", "ingest_audio"] diff --git a/examples/notebooks/__init__.py b/examples/notebooks/__init__.py deleted file mode 100644 index 7dfe806..0000000 --- a/examples/notebooks/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Executable notebook proof helpers.""" diff --git a/examples/notebooks/execute.py b/examples/notebooks/execute.py deleted file mode 100644 index a68afd3..0000000 --- a/examples/notebooks/execute.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Dependency-free executor for PocketStation notebooks without IPython magic.""" - -from __future__ import annotations - -import argparse -import ast -import asyncio -import inspect -import json -from collections.abc import Awaitable -from pathlib import Path -from typing import Any - - -def execute(path: Path) -> None: - notebook = json.loads(path.read_text()) - if notebook.get("nbformat") != 4 or not isinstance(notebook.get("cells"), list): - raise ValueError("expected a version 4 notebook with a cells array") - namespace: dict[str, object] = {"__name__": "__notebook__"} - for index, cell in enumerate(notebook["cells"]): - if cell.get("cell_type") != "code": - continue - source = cell.get("source") - if not isinstance(source, list) or not all( - isinstance(line, str) for line in source - ): - raise ValueError(f"code cell {index} has invalid source") - code = compile( - "".join(source), - f"{path}#cell-{index}", - "exec", - flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, - ) - result = eval(code, namespace) - if inspect.isawaitable(result): - asyncio.run(_await_result(result)) - - -async def _await_result(result: Awaitable[Any]) -> Any: - return await result - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("notebook", type=Path) - arguments = parser.parse_args() - execute(arguments.notebook) - - -if __name__ == "__main__": - main() diff --git a/examples/notebooks/source_aware_transcription.ipynb b/examples/notebooks/source_aware_transcription.ipynb deleted file mode 100644 index 9de1ddb..0000000 --- a/examples/notebooks/source_aware_transcription.ipynb +++ /dev/null @@ -1,77 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", - "metadata": {}, - "source": [ - "# Source-aware transcription with PocketStation\n", - "\n", - "This notebook sends application-owned PCM through the real bounded Session, records it as an independent stem, and transcribes it through the normal faster-whisper Python API. Set a real WAV path below; the model may be a downloaded model name or local path. No output is fabricated or embedded in this notebook." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "acae54e37e7d407bbb7b55eff062a284", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "import os\n", - "from pathlib import Path\n", - "\n", - "from examples.transcription.run_faster_whisper import transcribe_wav" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9a63283cbaf04dbcab1f6479b197f3a8", - "metadata": {}, - "outputs": [], - "source": [ - "model = os.environ.get(\"POCKETSTATION_WHISPER_MODEL\", \"base\")\n", - "device = os.environ.get(\"POCKETSTATION_WHISPER_DEVICE\", \"auto\")\n", - "compute_type = os.environ.get(\"POCKETSTATION_WHISPER_COMPUTE_TYPE\", \"default\")\n", - "wav = Path(os.environ[\"POCKETSTATION_WHISPER_WAV\"])\n", - "record_to = Path(\n", - " os.environ.get(\"POCKETSTATION_NOTEBOOK_RECORDINGS\", \"recordings/notebook\")\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8dd0d8092fe74a7c96281538738b07e2", - "metadata": {}, - "outputs": [], - "source": [ - "transcript = await transcribe_wav(\n", - " model=model,\n", - " device=device,\n", - " compute_type=compute_type,\n", - " language=None,\n", - " wav=wav,\n", - " record_to=record_to,\n", - ")\n", - "assert transcript[\"source_id\"] > 0\n", - "assert transcript[\"stream_id\"] > 0\n", - "print(json.dumps(transcript, indent=2))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/physical_capture.py b/examples/physical_capture.py deleted file mode 100644 index ce1b593..0000000 --- a/examples/physical_capture.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Capture one playing macOS application and the default microphone.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from time import monotonic - -import pocketstation - - -def _application_source(name: str) -> pocketstation.DiscoveredSource: - matches = tuple( - source - for source in pocketstation.discover_sources() - if source.name == name - and source.stable_id.kind is pocketstation.SourceKind.APPLICATION - ) - if len(matches) != 1: - raise RuntimeError( - f"expected one application named {name!r}, found {len(matches)}" - ) - return matches[0] - - -def capture_physical_sources( - *, - application_name: str, - recording_root: Path, - duration_s: float = 5.0, -) -> dict[str, object]: - """Run the physical app+microphone path and return bounded observations.""" - if not 0.1 <= duration_s <= 300: - raise ValueError("duration_s must be between 0.1 and 300") - application_source = _application_source(application_name) - session = pocketstation.Session(recording_root=recording_root) - application = session.capture( - pocketstation.Source.from_discovered(application_source) - ) - microphone = session.capture(pocketstation.Source.microphone_default()) - endpoint = session.polled_audio() - application.send(endpoint) - microphone.send(endpoint) - application.record("application") - microphone.record("microphone") - - running = session.start() - frames_by_stem: dict[int, int] = {} - deadline = monotonic() + duration_s - try: - while monotonic() < deadline: - frame = running.audio.read(timeout_s=0.1) - if frame is not None: - frames_by_stem[frame.stem_id] = frames_by_stem.get(frame.stem_id, 0) + 1 - finally: - outcome = running.stop() - - expected_stems = {application.id, microphone.id} - recording = outcome.recording - success = ( - outcome.success - and set(frames_by_stem) == expected_stems - and recording is not None - and recording.complete - and {stem.stem_name for stem in recording.stems} - == {"application", "microphone"} - and all(stem.frames_written_total > 0 for stem in recording.stems) - ) - result: dict[str, object] = { - "application": application_source.name, - "application_process_id": application_source.process_id, - "frames_by_stem": frames_by_stem, - "microphone_permission": str(pocketstation.microphone_permission_observation()), - "recording_complete": recording is not None and recording.complete, - "success": success, - } - if not success: - raise RuntimeError(json.dumps(result, sort_keys=True)) - return result - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--application", required=True) - parser.add_argument("--record-to", type=Path, required=True) - parser.add_argument("--duration", type=float, default=5) - arguments = parser.parse_args() - result = capture_physical_sources( - application_name=arguments.application, - recording_root=arguments.record_to, - duration_s=arguments.duration, - ) - print(json.dumps(result, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - main() diff --git a/examples/transcription/README.md b/examples/transcription/README.md deleted file mode 100644 index 530873b..0000000 --- a/examples/transcription/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Source-aware transcription - -The primary Python path uses `faster-whisper`, the local Whisper backend also -used by Pipecat's standard Python Whisper service. PocketStation hosts it as a -bounded async Operator and emits typed JSON transcript signals containing -source, stream, sequence, and discontinuity identity. - -```sh -pip install 'pocketstation[transcription]' -``` - -```python -transcriber = FasterWhisper(FasterWhisperConfiguration(model="base")) -transcripts = transcriber.attach(session, microphone) -``` - -Named models may be downloaded through the provider library. Deployments that -require an offline trust boundary can point at a provisioned model directory -and disable network access explicitly: - -```python -configuration = FasterWhisperConfiguration( - model="/opt/models/whisper-base-ct2", - allow_model_download=False, -) -``` - -Executable WAV proof: - -```sh -python -m examples.transcription.run_faster_whisper \ - --model base \ - --wav /path/to/speech.wav \ - --record-to recordings -``` - -The same two-line attachment accepts a captured `Stem`, application-owned -`SourceOutput`, or generated `DerivedStream`. Model loading and inference run -outside capture and realtime partitions. Core owns the finite Operator input -queue; `FasterWhisperConfiguration` bounds window duration, source count, -output size, and operation deadlines. An asyncio deadline bounds the Session -operation, but it cannot preempt an already-running CTranslate2 call in a -Python worker thread. Use the subprocess alternative below when forced provider -termination is required. - -## Hard-isolated whisper.cpp alternative - -The subprocess example remains useful when killing and reaping the provider at -a hard deadline matters more than the normal Python model API: - -```sh -python -m examples.transcription.run \ - --whisper-cli "$(command -v whisper-cli)" \ - --model /path/to/ggml-tiny.en.bin \ - --wav /path/to/speech.wav \ - --record-to recordings -``` - -The subprocess defaults to CPU inference. Provider processes have finite startup, -execution, output, and shutdown limits. Each Session route retains its own -bounded queue, so a slow transcription branch does not become the Relay or -recording queue. - -The notebook uses the same function and can be executed without storing output: - -```sh -python -m examples.notebooks.execute \ - examples/notebooks/source_aware_transcription.ipynb -``` diff --git a/examples/transcription/__init__.py b/examples/transcription/__init__.py deleted file mode 100644 index 04e79f1..0000000 --- a/examples/transcription/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Source-aware transcription examples built on the public PocketStation SDK.""" - -from .faster_whisper import FasterWhisper, FasterWhisperConfiguration -from .transcript import TRANSCRIPT_SIGNAL -from .whisper_cpp import WhisperCpp, WhisperCppConfiguration - -__all__ = [ - "TRANSCRIPT_SIGNAL", - "FasterWhisper", - "FasterWhisperConfiguration", - "WhisperCpp", - "WhisperCppConfiguration", -] diff --git a/examples/transcription/faster_whisper.py b/examples/transcription/faster_whisper.py deleted file mode 100644 index 915aada..0000000 --- a/examples/transcription/faster_whisper.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Source-aware local transcription through the faster-whisper Python API.""" - -from __future__ import annotations - -import asyncio -import importlib -import json -from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass -from typing import Any, Protocol, cast - -import pocketstation -import pocketstation.aio as pks_aio - -from examples.transcription.audio_windows import ( - AudioWindow, - AudioWindowBuffer, - mono_16khz, -) -from examples.transcription.transcript import TRANSCRIPT_SIGNAL - - -class WhisperSegment(Protocol): - start: float - end: float - text: str - - -class WhisperInfo(Protocol): - language: str - language_probability: float - - -class WhisperModel(Protocol): - def transcribe( - self, - audio: object, - *, - beam_size: int, - language: str | None, - vad_filter: bool, - ) -> tuple[Iterable[WhisperSegment], WhisperInfo]: ... - - -@dataclass(frozen=True, slots=True) -class FasterWhisperConfiguration: - """Finite model and buffering policy for one local transcription Operator.""" - - model: str = "base" - device: str = "auto" - compute_type: str = "default" - allow_model_download: bool = True - language: str | None = None - beam_size: int = 5 - vad_filter: bool = True - window_seconds: float = 5.0 - queue_capacity_signals: int = 512 - maximum_sources: int = 8 - maximum_output_bytes: int = 1_048_576 - create_timeout_s: float = 120.0 - inference_timeout_s: float = 120.0 - - def __post_init__(self) -> None: - for name, value in ( - ("model", self.model), - ("device", self.device), - ("compute_type", self.compute_type), - ): - if not value.strip(): - raise ValueError(f"{name} must not be empty") - if self.language is not None and ( - not self.language or not self.language.isascii() - ): - raise ValueError("language must be None or non-empty ASCII") - if not 1 <= self.beam_size <= 32: - raise ValueError("beam_size must be between 1 and 32") - if not 0.1 <= self.window_seconds <= 30: - raise ValueError("window_seconds must be between 0.1 and 30") - if not 8 <= self.queue_capacity_signals <= 4_096: - raise ValueError("queue_capacity_signals must be between 8 and 4096") - if not 1 <= self.maximum_sources <= 64: - raise ValueError("maximum_sources must be between 1 and 64") - if not 1_024 <= self.maximum_output_bytes <= 16_777_216: - raise ValueError("maximum_output_bytes must be between 1024 and 16777216") - if not 1 <= self.create_timeout_s <= 600: - raise ValueError("create_timeout_s must be between 1 and 600") - if not 1 <= self.inference_timeout_s <= 600: - raise ValueError("inference_timeout_s must be between 1 and 600") - - -ModelFactory = Callable[[FasterWhisperConfiguration], WhisperModel] -AudioConverter = Callable[[AudioWindow], object] - - -class _FasterWhisperNode(pks_aio.OperatorNode): - def __init__( - self, - configuration: FasterWhisperConfiguration, - model: WhisperModel, - audio_converter: AudioConverter, - ) -> None: - self._configuration = configuration - self._model = model - self._audio_converter = audio_converter - self._windows = AudioWindowBuffer( - window_seconds=configuration.window_seconds, - maximum_sources=configuration.maximum_sources, - ) - self._cancelled = False - - async def process( - self, - input_port: str, - envelope: pocketstation.SignalEnvelope[object], - ) -> tuple[pocketstation.OperatorEmission, ...]: - if input_port != "audio": - raise ValueError(f"unexpected input port: {input_port}") - if self._cancelled: - raise asyncio.CancelledError - emissions = [] - for window in self._windows.push(envelope): - emissions.append(await self._transcribe(window)) - return tuple(emissions) - - async def flush(self) -> tuple[pocketstation.OperatorEmission, ...]: - if self._cancelled: - self._windows.clear() - return () - emissions = [] - for window in self._windows.flush(): - emissions.append(await self._transcribe(window)) - return tuple(emissions) - - async def cancel(self) -> None: - self._cancelled = True - self._windows.clear() - - async def close(self) -> None: - await self.cancel() - - async def _transcribe( - self, - window: AudioWindow, - ) -> pocketstation.OperatorEmission: - result = await asyncio.wait_for( - asyncio.to_thread(self._transcribe_sync, window), - timeout=self._configuration.inference_timeout_s, - ) - encoded = json.dumps(result, separators=(",", ":"), sort_keys=True) - if len(encoded.encode()) > self._configuration.maximum_output_bytes: - raise RuntimeError("transcript envelope exceeds maximum_output_bytes") - return pocketstation.OperatorEmission.text(encoded, signal=TRANSCRIPT_SIGNAL) - - def _transcribe_sync(self, window: AudioWindow) -> dict[str, object]: - samples = self._audio_converter(window) - segments, info = self._model.transcribe( - samples, - beam_size=self._configuration.beam_size, - language=self._configuration.language, - vad_filter=self._configuration.vad_filter, - ) - completed = tuple(segments) - return { - "channel_count": window.channel_count, - "discontinuity_epoch": window.discontinuity_epoch, - "duration_ms": window.duration_ms, - "language": info.language, - "language_probability": info.language_probability, - "sample_rate_hz": window.sample_rate_hz, - "segments": [ - { - "end_s": segment.end, - "start_s": segment.start, - "text": segment.text.strip(), - } - for segment in completed - ], - "sequence_end": window.sequence_end, - "sequence_start": window.sequence_start, - "source_id": window.source_id, - "stream_id": window.stream_id, - "text": " ".join(segment.text.strip() for segment in completed).strip(), - } - - -class FasterWhisper: - """Example-owned faster-whisper provider registered as one async Operator.""" - - def __init__( - self, - configuration: FasterWhisperConfiguration | None = None, - *, - model_factory: ModelFactory | None = None, - _audio_converter: AudioConverter | None = None, - ) -> None: - self.configuration = configuration or FasterWhisperConfiguration() - self._model_factory = model_factory or _load_model - self._audio_converter = _audio_converter or _numpy_audio - self.manifest = pocketstation.OperatorManifest( - "community.faster-whisper.stt.v1", - inputs=( - pocketstation.PortSpec.input("audio", pocketstation.SignalSpec.audio()), - ), - outputs=(pocketstation.PortSpec.output("transcript", TRANSCRIPT_SIGNAL),), - queue_capacity_signals=self.configuration.queue_capacity_signals, - process_timeout_ms=round( - (self.configuration.inference_timeout_s + 1) * 1_000 - ), - network_allowed=self.configuration.allow_model_download, - filesystem_allowed=True, - terminal_roles=("transcript.final",), - ) - - def provider(self) -> pks_aio.OperatorProvider: - async def create(_configuration: Mapping[str, str]) -> _FasterWhisperNode: - model = await asyncio.to_thread(self._model_factory, self.configuration) - return _FasterWhisperNode( - self.configuration, - model, - self._audio_converter, - ) - - return pks_aio.OperatorProvider.with_node( - self.manifest, - create, - deadlines=pks_aio.OperatorDeadlines( - create_s=self.configuration.create_timeout_s, - prepare_s=5, - process_s=self.configuration.inference_timeout_s + 0.5, - close_s=5, - ), - ) - - def attach( - self, - session: pks_aio.Session, - stream: pocketstation.Stem - | pocketstation.SourceOutput - | pocketstation.DerivedStream, - ) -> pocketstation.BusSubscription[str]: - """Attach transcription to any Session-owned PCM stream in two lines.""" - operator = session.register_operator(self.provider()).declare() - stream.connect(operator.input("audio")) - return session.subscribe( - operator.output("transcript"), - signal=TRANSCRIPT_SIGNAL, - ) - - -def _load_model(configuration: FasterWhisperConfiguration) -> WhisperModel: - try: - module = importlib.import_module("faster_whisper") - except ModuleNotFoundError as error: - raise RuntimeError( - "install PocketStation with the transcription extra: " - "pip install 'pocketstation[transcription]'" - ) from error - model: Any = module.WhisperModel( - configuration.model, - device=configuration.device, - compute_type=configuration.compute_type, - local_files_only=not configuration.allow_model_download, - ) - return cast(WhisperModel, model) - - -def _numpy_audio(window: AudioWindow) -> object: - numpy = importlib.import_module("numpy") - return numpy.asarray(mono_16khz(window), dtype="float32") - - -__all__ = ["TRANSCRIPT_SIGNAL", "FasterWhisper", "FasterWhisperConfiguration"] diff --git a/examples/transcription/run.py b/examples/transcription/run.py deleted file mode 100644 index 0009835..0000000 --- a/examples/transcription/run.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Run real source-aware whisper.cpp transcription from an installed SDK.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from pathlib import Path - -import pocketstation -import pocketstation.aio as pks_aio - -from examples.transcription.transcript import TRANSCRIPT_SIGNAL -from examples.transcription.wav_input import feed_live, read_pcm16_wav -from examples.transcription.whisper_cpp import ( - WhisperCpp, - WhisperCppConfiguration, -) - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--whisper-cli", type=Path, required=True) - parser.add_argument("--model", type=Path, required=True) - parser.add_argument("--wav", type=Path, required=True) - parser.add_argument("--record-to", type=Path, required=True) - return parser.parse_args() - - -async def main() -> None: - arguments = _arguments() - result = await transcribe_wav( - whisper_cli=arguments.whisper_cli, - model=arguments.model, - wav=arguments.wav, - record_to=arguments.record_to, - ) - print(json.dumps(result, indent=2)) - - -async def transcribe_wav( - *, - whisper_cli: Path, - model: Path, - wav: Path, - record_to: Path, -) -> dict[str, object]: - """Transcribe one real WAV through Session audio input and recording.""" - source = read_pcm16_wav(wav) - - session = pks_aio.Session( - recording_root=record_to, - sample_rate_hz=source.sample_rate_hz, - channels=source.channels, - ) - audio = session.audio_input( - "application-owned-speech", - capacity_frames=32, - frame_samples_per_channel=source.frame_samples_per_channel, - ) - whisper = WhisperCpp( - WhisperCppConfiguration( - executable=whisper_cli, - model=model, - window_seconds=min(30, max(0.1, source.duration_s)), - ) - ) - operator = session.register_operator(whisper.provider()).declare() - audio.output.connect(operator.input("audio")) - audio.output.record("application-owned-speech") - subscription = session.subscribe( - operator.output("transcript"), signal=TRANSCRIPT_SIGNAL - ) - - running = await session.start() - try: - await feed_live(audio, source) - result = await asyncio.wait_for( - anext(running.signals(subscription).__aiter__()), - timeout=whisper.configuration.process_timeout_s, - ) - if not isinstance(result, pocketstation.SignalEnvelope): - raise RuntimeError("transcription ended without a transcript") - transcript = json.loads(str(result.payload)) - finally: - outcome = await running.stop() - if not outcome.success: - raise RuntimeError(outcome) - if not isinstance(transcript, dict): - raise RuntimeError("transcription result must be a JSON object") - return transcript - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/transcription/run_faster_whisper.py b/examples/transcription/run_faster_whisper.py deleted file mode 100644 index 087aa29..0000000 --- a/examples/transcription/run_faster_whisper.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Run source-aware faster-whisper transcription from an installed SDK.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from pathlib import Path - -import pocketstation -import pocketstation.aio as pks_aio - -from examples.transcription.faster_whisper import ( - FasterWhisper, - FasterWhisperConfiguration, -) -from examples.transcription.wav_input import feed_live, read_pcm16_wav - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--model", default="base") - parser.add_argument("--device", default="auto") - parser.add_argument("--compute-type", default="default") - parser.add_argument("--language") - parser.add_argument("--wav", type=Path, required=True) - parser.add_argument("--record-to", type=Path, required=True) - return parser.parse_args() - - -async def main() -> None: - arguments = _arguments() - result = await transcribe_wav( - model=arguments.model, - device=arguments.device, - compute_type=arguments.compute_type, - language=arguments.language, - wav=arguments.wav, - record_to=arguments.record_to, - ) - print(json.dumps(result, indent=2)) - - -async def transcribe_wav( - *, - model: str, - device: str, - compute_type: str, - language: str | None, - wav: Path, - record_to: Path, -) -> dict[str, object]: - """Transcribe one WAV through the public Session and Python model API.""" - source = read_pcm16_wav(wav) - session = pks_aio.Session( - recording_root=record_to, - sample_rate_hz=source.sample_rate_hz, - channels=source.channels, - ) - audio = session.audio_input( - "application-owned-speech", - capacity_frames=32, - frame_samples_per_channel=source.frame_samples_per_channel, - ) - transcriber = FasterWhisper( - FasterWhisperConfiguration( - model=model, - device=device, - compute_type=compute_type, - language=language, - window_seconds=min(30, max(0.1, source.duration_s)), - ) - ) - transcripts = transcriber.attach(session, audio.output) - audio.output.record("application-owned-speech") - - running = await session.start() - try: - await feed_live(audio, source) - envelope = await asyncio.wait_for( - anext(running.signals(transcripts).__aiter__()), - timeout=transcriber.configuration.inference_timeout_s, - ) - if not isinstance(envelope, pocketstation.SignalEnvelope): - raise RuntimeError("transcription ended without a transcript") - transcript = json.loads(str(envelope.payload)) - finally: - outcome = await running.stop() - if not outcome.success: - raise RuntimeError(outcome) - if not isinstance(transcript, dict): - raise RuntimeError("transcription result must be a JSON object") - return transcript - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/transcription/transcript.py b/examples/transcription/transcript.py deleted file mode 100644 index 7b3097b..0000000 --- a/examples/transcription/transcript.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Typed transcript signal shared by transcription providers.""" - -import pocketstation - -TRANSCRIPT_SIGNAL = pocketstation.SignalSpec.text( - pocketstation.TextFormat.JSON, - role="transcript.final", - schema="io.pocketstation.transcript.batch.v1", -) - -__all__ = ["TRANSCRIPT_SIGNAL"] diff --git a/examples/transcription/whisper_cpp.py b/examples/transcription/whisper_cpp.py deleted file mode 100644 index 3d6d0a2..0000000 --- a/examples/transcription/whisper_cpp.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Bounded source-aware speech transcription using a local whisper.cpp process.""" - -from __future__ import annotations - -import asyncio -import json -import sys -import wave -from array import array -from collections.abc import Mapping -from dataclasses import dataclass -from pathlib import Path -from tempfile import TemporaryDirectory - -import pocketstation -import pocketstation.aio as pks_aio - -from examples.transcription.audio_windows import ( - AudioWindow, - AudioWindowBuffer, - mono_16khz, -) -from examples.transcription.transcript import TRANSCRIPT_SIGNAL - - -@dataclass(frozen=True, slots=True) -class WhisperCppConfiguration: - """Finite process, buffering, and output limits for whisper.cpp.""" - - executable: Path - model: Path - language: str = "en" - window_seconds: float = 5.0 - process_timeout_s: float = 90.0 - shutdown_timeout_s: float = 2.0 - threads: int = 4 - queue_capacity_signals: int = 512 - maximum_sources: int = 8 - maximum_output_bytes: int = 1_048_576 - maximum_error_bytes: int = 65_536 - use_gpu: bool = False - - def __post_init__(self) -> None: - if not self.executable.is_file(): - raise ValueError(f"whisper executable does not exist: {self.executable}") - if not self.model.is_file(): - raise ValueError(f"whisper model does not exist: {self.model}") - if not self.language or not self.language.isascii(): - raise ValueError("language must be non-empty ASCII") - if not 0.1 <= self.window_seconds <= 30: - raise ValueError("window_seconds must be between 0.1 and 30") - if not 1 <= self.process_timeout_s <= 300: - raise ValueError("process_timeout_s must be between 1 and 300") - if not 0.1 <= self.shutdown_timeout_s <= 10: - raise ValueError("shutdown_timeout_s must be between 0.1 and 10") - if not 1 <= self.threads <= 64: - raise ValueError("threads must be between 1 and 64") - if not 8 <= self.queue_capacity_signals <= 4_096: - raise ValueError("queue_capacity_signals must be between 8 and 4096") - if not 1 <= self.maximum_sources <= 64: - raise ValueError("maximum_sources must be between 1 and 64") - if not 1_024 <= self.maximum_output_bytes <= 16_777_216: - raise ValueError("maximum_output_bytes must be between 1024 and 16777216") - if not 1_024 <= self.maximum_error_bytes <= 1_048_576: - raise ValueError("maximum_error_bytes must be between 1024 and 1048576") - - -class _WhisperNode(pks_aio.OperatorNode): - def __init__(self, configuration: WhisperCppConfiguration) -> None: - self._configuration = configuration - self._windows = AudioWindowBuffer( - window_seconds=configuration.window_seconds, - maximum_sources=configuration.maximum_sources, - ) - self._children: set[asyncio.subprocess.Process] = set() - self._cancelled = False - - async def process( - self, - input_port: str, - envelope: pocketstation.SignalEnvelope[object], - ) -> tuple[pocketstation.OperatorEmission, ...]: - if input_port != "audio": - raise ValueError(f"unexpected input port: {input_port}") - if self._cancelled: - raise asyncio.CancelledError - return tuple( - [await self._transcribe(window) for window in self._windows.push(envelope)] - ) - - async def flush(self) -> tuple[pocketstation.OperatorEmission, ...]: - if self._cancelled: - self._windows.clear() - return () - return tuple( - [await self._transcribe(window) for window in self._windows.flush()] - ) - - async def cancel(self) -> None: - self._cancelled = True - await asyncio.gather( - *(self._stop_child(child) for child in tuple(self._children)), - return_exceptions=True, - ) - self._windows.clear() - - async def close(self) -> None: - await self.cancel() - - async def _transcribe(self, window: AudioWindow) -> pocketstation.OperatorEmission: - if self._cancelled: - raise asyncio.CancelledError - with TemporaryDirectory(prefix="pocketstation-whisper-") as directory: - root = Path(directory) - wav_path = root / "input.wav" - output_prefix = root / "transcript" - stdout_path = root / "stdout.log" - stderr_path = root / "stderr.log" - await asyncio.to_thread(_write_whisper_wav, wav_path, window) - arguments = [ - str(self._configuration.executable), - "-m", - str(self._configuration.model), - "-f", - str(wav_path), - "-oj", - "-of", - str(output_prefix), - "-np", - "-nt", - "-l", - self._configuration.language, - "-t", - str(self._configuration.threads), - ] - if not self._configuration.use_gpu: - arguments.insert(1, "-ng") - with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr: - child = await asyncio.create_subprocess_exec( - *arguments, - stdin=asyncio.subprocess.DEVNULL, - stdout=stdout, - stderr=stderr, - ) - self._children.add(child) - try: - await asyncio.wait_for( - child.wait(), timeout=self._configuration.process_timeout_s - ) - except (asyncio.CancelledError, TimeoutError): - await self._stop_child(child) - raise - finally: - self._children.discard(child) - - if child.returncode != 0: - provider_error = await asyncio.to_thread( - _read_bounded, - stderr_path, - self._configuration.maximum_error_bytes, - ) - raise RuntimeError( - f"whisper-cli exited with status {child.returncode}: " - f"{provider_error.decode('utf-8', errors='replace').strip()}" - ) - result_bytes = await asyncio.to_thread( - _read_bounded, - output_prefix.with_suffix(".json"), - self._configuration.maximum_output_bytes, - ) - - provider_result = json.loads(result_bytes) - segments = provider_result.get("transcription", ()) - text = " ".join( - str(segment.get("text", "")).strip() - for segment in segments - if isinstance(segment, dict) - ).strip() - output = json.dumps( - { - "channel_count": window.channel_count, - "discontinuity_epoch": window.discontinuity_epoch, - "duration_ms": round( - len(window.samples) - * 1_000 - / (window.sample_rate_hz * window.channel_count) - ), - "language": provider_result.get("result", {}).get( - "language", self._configuration.language - ), - "sample_rate_hz": window.sample_rate_hz, - "sequence_end": window.sequence_end, - "sequence_start": window.sequence_start, - "source_id": window.source_id, - "stream_id": window.stream_id, - "text": text, - }, - separators=(",", ":"), - sort_keys=True, - ) - if len(output.encode()) > self._configuration.maximum_output_bytes: - raise RuntimeError("transcript envelope exceeds maximum_output_bytes") - return pocketstation.OperatorEmission.text(output, signal=TRANSCRIPT_SIGNAL) - - async def _stop_child(self, child: asyncio.subprocess.Process) -> None: - if child.returncode is not None: - return - child.terminate() - try: - await asyncio.wait_for( - child.wait(), timeout=self._configuration.shutdown_timeout_s - ) - except TimeoutError: - child.kill() - await child.wait() - - -class WhisperCpp: - """Example-owned provider that registers as one bounded async Operator.""" - - def __init__(self, configuration: WhisperCppConfiguration) -> None: - self.configuration = configuration - timeout_ms = round((configuration.process_timeout_s + 1) * 1_000) - self.manifest = pocketstation.OperatorManifest( - "community.whisper.cpp.stt.v1", - inputs=( - pocketstation.PortSpec.input("audio", pocketstation.SignalSpec.audio()), - ), - outputs=(pocketstation.PortSpec.output("transcript", TRANSCRIPT_SIGNAL),), - queue_capacity_signals=configuration.queue_capacity_signals, - process_timeout_ms=timeout_ms, - filesystem_allowed=True, - terminal_roles=("transcript.final",), - ) - - def provider(self) -> pks_aio.OperatorProvider: - async def create(_configuration: Mapping[str, str]) -> _WhisperNode: - return _WhisperNode(self.configuration) - - return pks_aio.OperatorProvider.with_node( - self.manifest, - create, - deadlines=pks_aio.OperatorDeadlines( - create_s=5, - prepare_s=5, - process_s=self.configuration.process_timeout_s + 0.5, - close_s=self.configuration.shutdown_timeout_s + 0.5, - ), - ) - - -def _write_whisper_wav(path: Path, window: AudioWindow) -> None: - resampled = mono_16khz(window) - pcm = array( - "h", (round(max(-1.0, min(1.0, value)) * 32_767) for value in resampled) - ) - if sys.byteorder != "little": - pcm.byteswap() - with wave.open(str(path), "wb") as output: - output.setnchannels(1) - output.setsampwidth(2) - output.setframerate(16_000) - output.writeframes(pcm.tobytes()) - - -def _read_bounded(path: Path, maximum_bytes: int) -> bytes: - size = path.stat().st_size - if size > maximum_bytes: - raise RuntimeError(f"provider output exceeds {maximum_bytes} bytes") - return path.read_bytes() diff --git a/native/src/streams.rs b/native/src/streams.rs index cc49693..1ec5190 100644 --- a/native/src/streams.rs +++ b/native/src/streams.rs @@ -114,7 +114,7 @@ impl PythonAudioFrame { ) } - /// Read-only zero-copy Python view over owned little-endian f32 PCM bytes. + /// Read-only view over the frame's Python-owned PCM copy. #[getter] fn samples<'py>(&self, py: Python<'py>) -> PyResult> { PyMemoryView::from(self.samples_f32le.bind(py).as_any()) diff --git a/pyproject.toml b/pyproject.toml index 56f794b..4d92035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "httpx>=0.27", ] +[project.scripts] +pocketstation-demo = "pocketstation_examples:main" + [project.optional-dependencies] transcription = [ "faster-whisper>=1.2.1,<2.0", @@ -29,7 +32,7 @@ asyncio_mode = "auto" pythonpath = ["."] [tool.mypy] -packages = ["pocketstation"] +packages = ["pocketstation", "pocketstation_examples"] mypy_path = "python" python_version = "3.11" strict = true @@ -43,6 +46,6 @@ select = ["B", "E", "F", "I", "RUF", "UP"] [tool.maturin] manifest-path = "native/Cargo.toml" python-source = "python" +python-packages = ["pocketstation", "pocketstation_examples"] module-name = "pocketstation._native" -include = [{ path = "examples/**/*", format = "sdist" }] exclude = ["**/__pycache__/**", "**/*.pyc", "**/*.pyo"] diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py index 47f32d7..699fb05 100644 --- a/python/pocketstation/__init__.py +++ b/python/pocketstation/__init__.py @@ -1,605 +1,37 @@ -"""PocketStation: source-aware live audio Sessions for Python.""" +"""PocketStation's concise Python entry point. + +Advanced graph, authoring, Relay, extension, and diagnostic contracts live in +their named modules. They are not duplicated at the package root. +""" from __future__ import annotations from . import aio as aio -from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource +from .audio_input import AudioInput, AudioInputConfig, PcmSource from .capture import Capture, capture from .compatibility import RUNTIME_COMPATIBILITY, RuntimeCompatibility -from .connector import ( - AudioConnectorHandler, - Connector, - ConnectorBatchOutcome, - ConnectorCapability, - ConnectorConfigurationConstraint, - ConnectorConfigurationField, - ConnectorConfigurationInput, - ConnectorConfigurationRequirement, - ConnectorConfigurationSchema, - ConnectorConfigurationValue, - ConnectorConfigurationValueKind, - ConnectorContext, - ConnectorDeliveryOutcome, - ConnectorDeliveryReadiness, - ConnectorDriver, - ConnectorDriverBuilder, - ConnectorDriverFactory, - ConnectorError, - ConnectorErrorSnapshot, - ConnectorErrorStage, - ConnectorFactory, - ConnectorHandler, - ConnectorHealth, - ConnectorInputDescriptor, - ConnectorItem, - ConnectorManifest, - ConnectorObservations, - ConnectorPreparationGroup, - ConnectorRecovery, - ConnectorRequirement, - ConnectorRetryability, - ConnectorRuntimeObservations, - ConnectorServiceStatus, - ConnectorShutdownMode, - ConnectorWorker, - ConnectorWorkerBuilder, - RegisteredConnector, - connector, -) -from .control import ( - ControlClient, - ControlPlaneError, - IceServer, - SecretToken, - SessionCredentials, - SessionId, - SessionSnapshot, - SubscriberCredentials, -) -from .endpoint_authoring import ( - EndpointConfigurationInput, - EndpointDriverBuilder, - EndpointDriverError, - EndpointDriverFactory, - EndpointDriverObservations, - EndpointItem, - EndpointManifest, - EndpointPortInput, - EndpointPreparationGroup, - EndpointPrepareContext, - EndpointProvider, - EndpointReceiver, - EndpointShutdownMode, - EndpointStartGate, - PreparedEndpointDriver, - RegisteredEndpoint, - RunningEndpointDriver, -) -from .errors import ( - AudioInputBufferError, - AudioInputCancelledError, - AudioInputClosedError, - AudioInputError, - AudioInputFullError, - CaptureError, - ConnectorRuntimeError, - ExtensionError, - GraphError, - OperatorError, - PocketStationError, - SessionCompileDiagnostic, - SessionDeclarationError, - SessionError, - SessionRuntimeError, - SessionStartError, - SidecarBackpressureError, - SidecarError, - SidecarProtocolError, - SidecarTimeoutError, - SourceError, - StreamError, - StreamInUseError, - StreamModeError, -) -from .extensions import ( - ExtensionAbiVersion, - ExtensionDescriptor, - ExtensionKind, - ExtensionPort, - ExtensionPortDirection, - NativeExtensionLibrary, - NativeExtensionRegistration, -) -from .graph import ( - AudioCaps, - BackpressurePolicy, - BinaryFormat, - ChannelLayout, - ClockDomain, - Codec, - CopyPolicy, - DeliverySemantics, - DerivedStream, - EdgeContract, - EdgeObservabilityLevel, - Endpoint, - EndpointConfiguration, - EndpointDescriptor, - EventFormat, - LossPolicy, - MediaCaps, - MediaKind, - Multiplicity, - Operator, - OperatorConfiguration, - OperatorInput, - OperatorInstance, - PortDirection, - PortSpec, - SampleFormat, - SignalKind, - SignalSpec, - SourceConfiguration, - SourceInstance, - SourceOutput, - Stem, - TextFormat, -) -from .identity import ( - ClockDomainId, - ClockDomainKind, - ClockDomainOrigin, - ConnectorId, - EndpointId, - OperatorInstanceId, - RouteId, - RuntimeSessionId, - SidecarId, - SourceId, - SourceInstanceId, - StemId, - StreamId, -) -from .observations import ( - AudioReentryMetrics, - DerivedRouteMetrics, - EdgeMetrics, - EndpointFailureRetryability, - EndpointFailureStage, - EndpointMetrics, - EndpointObservationStage, - EventQueueMetrics, - EventStream, - ExternalSourceMetrics, - LatencyHistogram, - OperatorInputMetrics, - OperatorMetrics, - OperatorWorkerMetrics, - PolledAudioMetrics, - RecordingDiscontinuity, - RecordingDiscontinuityKind, - RecordingState, - RelayPublishOutcome, - RouteLatencyBoundary, - RouteLatencyUnit, - RouteObservationInterval, - SessionComponent, - SessionComponentKind, - SessionEventType, - SessionFailure, - SessionFailureKind, - SessionFinalizationStage, - SessionLifecycleState, - SessionRollbackStage, - SessionTerminalState, - SessionTrace, - SessionTraceConfiguration, - SessionTraceRecord, - SessionTraceRecorderOutcome, - SessionTraceRecordType, - SessionTraceValidation, - SourceMetrics, - TerminationDisposition, - TypedEdgeMetrics, -) -from .operator_authoring import ( - OperatorConfigValidator, - OperatorEmission, - OperatorFactory, - OperatorHandler, - OperatorManifest, - OperatorNode, - OperatorPortContext, - OperatorPrepareContext, - OperatorProvider, - RegisteredOperator, - operator, -) -from .relay import ( - PublisherActivation, - ReceiverActivation, - ReceiverInvitation, - RelayError, - RelayPublisher, - RelayRoute, - RelaySession, - RelayTimeoutError, -) -from .session import ( - AudioBatch, - AudioFrame, - RecordingOutcome, - RecordingStemOutcome, - RouteMetrics, - RunningSession, - Session, - SessionEvent, - SessionMetrics, - StopResult, -) -from .sidecar import ( - SidecarConnection, - SidecarDeadlines, - SidecarHandle, - SidecarMessage, - SidecarMessageKind, - SidecarProcessSpec, - SidecarProtocolLimits, - SidecarReadResult, - SidecarSnapshot, - SidecarState, - SidecarStream, -) -from .signal import ( - STREAM_EOF, - BusSubscription, - EndOfStream, - SignalAudioPayload, - SignalDerivation, - SignalEnvelope, - SignalLineage, - SignalPayload, - SignalReadResult, - SignalSubscriptionMetrics, - SignalTiming, -) -from .source_authoring import ( - RegisteredSource, - SourceCancellation, - SourceConfigValidator, - SourceDriver, - SourceEmission, - SourceFactory, - SourceIterableFactory, - SourceManifest, - SourceOutputIdentity, - SourcePrepareContext, - SourceProvider, - source, -) -from .sources import ( - ApplicationPolicyObservation, - CaptureAuthorizationSnapshot, - CaptureCapabilityState, - CaptureOpenOutcome, - CapturePermissionLifecycle, - CapturePermissionTransition, - CapturePermissionTransitionKind, - CaptureScopeKind, - CaptureSessionGrant, - DiscoveredSource, - PermissionObservation, - Platform, - ProcessInstanceSelector, - ProcessTreeScope, - SelectorPersistenceScope, - Source, - SourceFailureClass, - SourceIdentityStrength, - SourceKind, - SourceQuery, - SourceRecoveryRequirement, - SourceRuntimeEvent, - SourceRuntimeEventKind, - SourceSelectorKind, - SourceState, - StableSourceId, - application_capture_available, - discover_sources, - microphone_permission_observation, -) -from .streams import ( - AudioBatchReadResult, - AudioStream, - ClockDomainDescriptor, - SignalStream, -) +from .errors import CaptureError, PocketStationError, SessionError +from .session import RecordingOutcome, RunningSession, Session, StopResult +from .sources import Source, discover_sources __version__ = "0.1.0" + __all__ = [ "RUNTIME_COMPATIBILITY", - "STREAM_EOF", - "ApplicationPolicyObservation", - "AudioBatch", - "AudioBatchReadResult", - "AudioCaps", - "AudioConnectorHandler", - "AudioFrame", "AudioInput", - "AudioInputBufferError", - "AudioInputCancelledError", - "AudioInputClosedError", "AudioInputConfig", - "AudioInputError", - "AudioInputFullError", - "AudioInputObservations", - "AudioReentryMetrics", - "AudioStream", - "BackpressurePolicy", - "BinaryFormat", - "BusSubscription", "Capture", - "CaptureAuthorizationSnapshot", - "CaptureCapabilityState", "CaptureError", - "CaptureOpenOutcome", - "CapturePermissionLifecycle", - "CapturePermissionTransition", - "CapturePermissionTransitionKind", - "CaptureScopeKind", - "CaptureSessionGrant", - "ChannelLayout", - "ClockDomain", - "ClockDomainDescriptor", - "ClockDomainId", - "ClockDomainKind", - "ClockDomainOrigin", - "Codec", - "Connector", - "ConnectorBatchOutcome", - "ConnectorCapability", - "ConnectorConfigurationConstraint", - "ConnectorConfigurationField", - "ConnectorConfigurationInput", - "ConnectorConfigurationRequirement", - "ConnectorConfigurationSchema", - "ConnectorConfigurationValue", - "ConnectorConfigurationValueKind", - "ConnectorContext", - "ConnectorDeliveryOutcome", - "ConnectorDeliveryReadiness", - "ConnectorDriver", - "ConnectorDriverBuilder", - "ConnectorDriverFactory", - "ConnectorError", - "ConnectorErrorSnapshot", - "ConnectorErrorStage", - "ConnectorFactory", - "ConnectorHandler", - "ConnectorHealth", - "ConnectorId", - "ConnectorInputDescriptor", - "ConnectorItem", - "ConnectorManifest", - "ConnectorObservations", - "ConnectorPreparationGroup", - "ConnectorRecovery", - "ConnectorRequirement", - "ConnectorRetryability", - "ConnectorRuntimeError", - "ConnectorRuntimeObservations", - "ConnectorServiceStatus", - "ConnectorShutdownMode", - "ConnectorWorker", - "ConnectorWorkerBuilder", - "ControlClient", - "ControlPlaneError", - "CopyPolicy", - "DeliverySemantics", - "DerivedRouteMetrics", - "DerivedStream", - "DiscoveredSource", - "EdgeContract", - "EdgeMetrics", - "EdgeObservabilityLevel", - "EndOfStream", - "Endpoint", - "EndpointConfiguration", - "EndpointConfigurationInput", - "EndpointDescriptor", - "EndpointDriverBuilder", - "EndpointDriverError", - "EndpointDriverFactory", - "EndpointDriverObservations", - "EndpointFailureRetryability", - "EndpointFailureStage", - "EndpointId", - "EndpointItem", - "EndpointManifest", - "EndpointMetrics", - "EndpointObservationStage", - "EndpointPortInput", - "EndpointPreparationGroup", - "EndpointPrepareContext", - "EndpointProvider", - "EndpointReceiver", - "EndpointShutdownMode", - "EndpointStartGate", - "EventFormat", - "EventQueueMetrics", - "EventStream", - "ExtensionAbiVersion", - "ExtensionDescriptor", - "ExtensionError", - "ExtensionKind", - "ExtensionPort", - "ExtensionPortDirection", - "ExternalSourceMetrics", - "GraphError", - "IceServer", - "LatencyHistogram", - "LossPolicy", - "MediaCaps", - "MediaKind", - "Multiplicity", - "NativeExtensionLibrary", - "NativeExtensionRegistration", - "Operator", - "OperatorConfigValidator", - "OperatorConfiguration", - "OperatorEmission", - "OperatorError", - "OperatorFactory", - "OperatorHandler", - "OperatorInput", - "OperatorInputMetrics", - "OperatorInstance", - "OperatorInstanceId", - "OperatorManifest", - "OperatorMetrics", - "OperatorNode", - "OperatorPortContext", - "OperatorPrepareContext", - "OperatorProvider", - "OperatorWorkerMetrics", "PcmSource", - "PermissionObservation", - "Platform", "PocketStationError", - "PolledAudioMetrics", - "PortDirection", - "PortSpec", - "PreparedEndpointDriver", - "ProcessInstanceSelector", - "ProcessTreeScope", - "PublisherActivation", - "ReceiverActivation", - "ReceiverInvitation", - "RecordingDiscontinuity", - "RecordingDiscontinuityKind", "RecordingOutcome", - "RecordingState", - "RecordingStemOutcome", - "RegisteredConnector", - "RegisteredEndpoint", - "RegisteredOperator", - "RegisteredSource", - "RelayError", - "RelayPublishOutcome", - "RelayPublisher", - "RelayRoute", - "RelaySession", - "RelayTimeoutError", - "RouteId", - "RouteLatencyBoundary", - "RouteLatencyUnit", - "RouteMetrics", - "RouteObservationInterval", - "RunningEndpointDriver", "RunningSession", "RuntimeCompatibility", - "RuntimeSessionId", - "SampleFormat", - "SecretToken", - "SelectorPersistenceScope", "Session", - "SessionCompileDiagnostic", - "SessionComponent", - "SessionComponentKind", - "SessionCredentials", - "SessionDeclarationError", "SessionError", - "SessionEvent", - "SessionEventType", - "SessionFailure", - "SessionFailureKind", - "SessionFinalizationStage", - "SessionId", - "SessionLifecycleState", - "SessionMetrics", - "SessionRollbackStage", - "SessionRuntimeError", - "SessionSnapshot", - "SessionStartError", - "SessionTerminalState", - "SessionTrace", - "SessionTraceConfiguration", - "SessionTraceRecord", - "SessionTraceRecordType", - "SessionTraceRecorderOutcome", - "SessionTraceValidation", - "SidecarBackpressureError", - "SidecarConnection", - "SidecarDeadlines", - "SidecarError", - "SidecarHandle", - "SidecarId", - "SidecarMessage", - "SidecarMessageKind", - "SidecarProcessSpec", - "SidecarProtocolError", - "SidecarProtocolLimits", - "SidecarReadResult", - "SidecarSnapshot", - "SidecarState", - "SidecarStream", - "SidecarTimeoutError", - "SignalAudioPayload", - "SignalDerivation", - "SignalEnvelope", - "SignalKind", - "SignalLineage", - "SignalPayload", - "SignalReadResult", - "SignalSpec", - "SignalStream", - "SignalSubscriptionMetrics", - "SignalTiming", "Source", - "SourceCancellation", - "SourceConfigValidator", - "SourceConfiguration", - "SourceDriver", - "SourceEmission", - "SourceError", - "SourceFactory", - "SourceFailureClass", - "SourceId", - "SourceIdentityStrength", - "SourceInstance", - "SourceInstanceId", - "SourceIterableFactory", - "SourceKind", - "SourceManifest", - "SourceMetrics", - "SourceOutput", - "SourceOutputIdentity", - "SourcePrepareContext", - "SourceProvider", - "SourceQuery", - "SourceRecoveryRequirement", - "SourceRuntimeEvent", - "SourceRuntimeEventKind", - "SourceSelectorKind", - "SourceState", - "StableSourceId", - "Stem", - "StemId", "StopResult", - "StreamError", - "StreamId", - "StreamInUseError", - "StreamModeError", - "SubscriberCredentials", - "TerminationDisposition", - "TextFormat", - "TypedEdgeMetrics", "aio", - "application_capture_available", "capture", - "connector", "discover_sources", - "microphone_permission_observation", - "operator", - "source", ] diff --git a/python/pocketstation/_api.py b/python/pocketstation/_api.py new file mode 100644 index 0000000..24b6050 --- /dev/null +++ b/python/pocketstation/_api.py @@ -0,0 +1,611 @@ +"""Compatibility resolver for the pre-1.0 flat Python API.""" + +from __future__ import annotations + +from . import aio as aio +from .audio_input import AudioInput, AudioInputConfig, AudioInputObservations, PcmSource +from .capture import Capture, capture +from .compatibility import RUNTIME_COMPATIBILITY, RuntimeCompatibility +from .connector import ( + AudioConnectorHandler, + Connector, + ConnectorBatchOutcome, + ConnectorCapability, + ConnectorConfigurationConstraint, + ConnectorConfigurationField, + ConnectorConfigurationInput, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorContext, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, + ConnectorDriver, + ConnectorDriverBuilder, + ConnectorDriverFactory, + ConnectorError, + ConnectorErrorSnapshot, + ConnectorErrorStage, + ConnectorFactory, + ConnectorHandler, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorObservations, + ConnectorPreparationGroup, + ConnectorRecovery, + ConnectorRequirement, + ConnectorRetryability, + ConnectorRuntimeObservations, + ConnectorServiceStatus, + ConnectorShutdownMode, + ConnectorWorker, + ConnectorWorkerBuilder, + RegisteredConnector, + connector, +) +from .control import ( + BusState, + ControlClient, + ControlPlaneError, + IceServer, + Invitation, + SecretToken, + SessionCredentials, + SessionId, + SessionSnapshot, + SubscriberCredentials, + SubscriptionState, +) +from .endpoint_authoring import ( + EndpointConfigurationInput, + EndpointDriverBuilder, + EndpointDriverError, + EndpointDriverFactory, + EndpointDriverObservations, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointPreparationGroup, + EndpointPrepareContext, + EndpointProvider, + EndpointReceiver, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RegisteredEndpoint, + RunningEndpointDriver, +) +from .errors import ( + AudioInputBufferError, + AudioInputCancelledError, + AudioInputClosedError, + AudioInputError, + AudioInputFullError, + CaptureError, + ConnectorRuntimeError, + ExtensionError, + GraphError, + OperatorError, + PocketStationError, + SessionCompileDiagnostic, + SessionDeclarationError, + SessionError, + SessionRuntimeError, + SessionStartError, + SidecarBackpressureError, + SidecarError, + SidecarProtocolError, + SidecarTimeoutError, + SourceError, + StreamError, + StreamInUseError, + StreamModeError, +) +from .extensions import ( + ExtensionAbiVersion, + ExtensionDescriptor, + ExtensionKind, + ExtensionPort, + ExtensionPortDirection, + NativeExtensionLibrary, + NativeExtensionRegistration, +) +from .graph import ( + AudioCaps, + BackpressurePolicy, + BinaryFormat, + ChannelLayout, + ClockDomain, + Codec, + CopyPolicy, + DeliverySemantics, + DerivedStream, + EdgeContract, + EdgeObservabilityLevel, + Endpoint, + EndpointConfiguration, + EndpointDescriptor, + EventFormat, + LossPolicy, + MediaCaps, + MediaKind, + Multiplicity, + Operator, + OperatorConfiguration, + OperatorInput, + OperatorInstance, + PortDirection, + PortSpec, + SampleFormat, + SignalKind, + SignalSpec, + SourceConfiguration, + SourceInstance, + SourceOutput, + Stem, + TextFormat, +) +from .identity import ( + ClockDomainId, + ClockDomainKind, + ClockDomainOrigin, + ConnectorId, + EndpointId, + OperatorInstanceId, + RouteId, + RuntimeSessionId, + SidecarId, + SourceId, + SourceInstanceId, + StemId, + StreamId, +) +from .observations import ( + AudioReentryMetrics, + DerivedRouteMetrics, + EdgeMetrics, + EndpointFailureRetryability, + EndpointFailureStage, + EndpointMetrics, + EndpointObservationStage, + EventQueueMetrics, + EventStream, + ExternalSourceMetrics, + LatencyHistogram, + OperatorInputMetrics, + OperatorMetrics, + OperatorWorkerMetrics, + PolledAudioMetrics, + RecordingDiscontinuity, + RecordingDiscontinuityKind, + RecordingState, + RelayPublishOutcome, + RouteLatencyBoundary, + RouteLatencyUnit, + RouteObservationInterval, + SessionComponent, + SessionComponentKind, + SessionEventType, + SessionFailure, + SessionFailureKind, + SessionFinalizationStage, + SessionLifecycleState, + SessionRollbackStage, + SessionTerminalState, + SessionTrace, + SessionTraceConfiguration, + SessionTraceRecord, + SessionTraceRecorderOutcome, + SessionTraceRecordType, + SessionTraceValidation, + SourceMetrics, + TerminationDisposition, + TypedEdgeMetrics, +) +from .operator_authoring import ( + OperatorConfigValidator, + OperatorEmission, + OperatorFactory, + OperatorHandler, + OperatorManifest, + OperatorNode, + OperatorPortContext, + OperatorPrepareContext, + OperatorProvider, + RegisteredOperator, + operator, +) +from .relay import ( + PublisherActivation, + ReceiverActivation, + ReceiverInvitation, + RelayError, + RelayPublisher, + RelayRoute, + RelaySession, + RelayTimeoutError, +) +from .session import ( + AudioBatch, + AudioFrame, + RecordingOutcome, + RecordingStemOutcome, + RouteMetrics, + RunningSession, + Session, + SessionEvent, + SessionMetrics, + StopResult, +) +from .sidecar import ( + SidecarConnection, + SidecarDeadlines, + SidecarHandle, + SidecarMessage, + SidecarMessageKind, + SidecarProcessSpec, + SidecarProtocolLimits, + SidecarReadResult, + SidecarSnapshot, + SidecarState, + SidecarStream, +) +from .signal import ( + STREAM_EOF, + BusSubscription, + EndOfStream, + SignalAudioPayload, + SignalDerivation, + SignalEnvelope, + SignalLineage, + SignalPayload, + SignalReadResult, + SignalSubscriptionMetrics, + SignalTiming, +) +from .source_authoring import ( + RegisteredSource, + SourceCancellation, + SourceConfigValidator, + SourceDriver, + SourceEmission, + SourceFactory, + SourceIterableFactory, + SourceManifest, + SourceOutputIdentity, + SourcePrepareContext, + SourceProvider, + source, +) +from .sources import ( + ApplicationPolicyObservation, + CaptureAuthorizationSnapshot, + CaptureCapabilityState, + CaptureOpenOutcome, + CapturePermissionLifecycle, + CapturePermissionTransition, + CapturePermissionTransitionKind, + CaptureScopeKind, + CaptureSessionGrant, + DiscoveredSource, + PermissionObservation, + Platform, + ProcessInstanceSelector, + ProcessTreeScope, + SelectorPersistenceScope, + Source, + SourceFailureClass, + SourceIdentityStrength, + SourceKind, + SourceQuery, + SourceRecoveryRequirement, + SourceRuntimeEvent, + SourceRuntimeEventKind, + SourceSelectorKind, + SourceState, + StableSourceId, + application_capture_available, + discover_sources, + microphone_permission_observation, +) +from .streams import ( + AudioBatchReadResult, + AudioStream, + ClockDomainDescriptor, + SignalStream, +) + +__version__ = "0.1.0" +__all__ = [ + "RUNTIME_COMPATIBILITY", + "STREAM_EOF", + "ApplicationPolicyObservation", + "AudioBatch", + "AudioBatchReadResult", + "AudioCaps", + "AudioConnectorHandler", + "AudioFrame", + "AudioInput", + "AudioInputBufferError", + "AudioInputCancelledError", + "AudioInputClosedError", + "AudioInputConfig", + "AudioInputError", + "AudioInputFullError", + "AudioInputObservations", + "AudioReentryMetrics", + "AudioStream", + "BackpressurePolicy", + "BinaryFormat", + "BusState", + "BusSubscription", + "Capture", + "CaptureAuthorizationSnapshot", + "CaptureCapabilityState", + "CaptureError", + "CaptureOpenOutcome", + "CapturePermissionLifecycle", + "CapturePermissionTransition", + "CapturePermissionTransitionKind", + "CaptureScopeKind", + "CaptureSessionGrant", + "ChannelLayout", + "ClockDomain", + "ClockDomainDescriptor", + "ClockDomainId", + "ClockDomainKind", + "ClockDomainOrigin", + "Codec", + "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorHealth", + "ConnectorId", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeError", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", + "ConnectorWorker", + "ConnectorWorkerBuilder", + "ControlClient", + "ControlPlaneError", + "CopyPolicy", + "DeliverySemantics", + "DerivedRouteMetrics", + "DerivedStream", + "DiscoveredSource", + "EdgeContract", + "EdgeMetrics", + "EdgeObservabilityLevel", + "EndOfStream", + "Endpoint", + "EndpointConfiguration", + "EndpointConfigurationInput", + "EndpointDescriptor", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", + "EndpointFailureRetryability", + "EndpointFailureStage", + "EndpointId", + "EndpointItem", + "EndpointManifest", + "EndpointMetrics", + "EndpointObservationStage", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", + "EventFormat", + "EventQueueMetrics", + "EventStream", + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionError", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "ExternalSourceMetrics", + "GraphError", + "IceServer", + "Invitation", + "LatencyHistogram", + "LossPolicy", + "MediaCaps", + "MediaKind", + "Multiplicity", + "NativeExtensionLibrary", + "NativeExtensionRegistration", + "Operator", + "OperatorConfigValidator", + "OperatorConfiguration", + "OperatorEmission", + "OperatorError", + "OperatorFactory", + "OperatorHandler", + "OperatorInput", + "OperatorInputMetrics", + "OperatorInstance", + "OperatorInstanceId", + "OperatorManifest", + "OperatorMetrics", + "OperatorNode", + "OperatorPortContext", + "OperatorPrepareContext", + "OperatorProvider", + "OperatorWorkerMetrics", + "PcmSource", + "PermissionObservation", + "Platform", + "PocketStationError", + "PolledAudioMetrics", + "PortDirection", + "PortSpec", + "PreparedEndpointDriver", + "ProcessInstanceSelector", + "ProcessTreeScope", + "PublisherActivation", + "ReceiverActivation", + "ReceiverInvitation", + "RecordingDiscontinuity", + "RecordingDiscontinuityKind", + "RecordingOutcome", + "RecordingState", + "RecordingStemOutcome", + "RegisteredConnector", + "RegisteredEndpoint", + "RegisteredOperator", + "RegisteredSource", + "RelayError", + "RelayPublishOutcome", + "RelayPublisher", + "RelayRoute", + "RelaySession", + "RelayTimeoutError", + "RouteId", + "RouteLatencyBoundary", + "RouteLatencyUnit", + "RouteMetrics", + "RouteObservationInterval", + "RunningEndpointDriver", + "RunningSession", + "RuntimeCompatibility", + "RuntimeSessionId", + "SampleFormat", + "SecretToken", + "SelectorPersistenceScope", + "Session", + "SessionCompileDiagnostic", + "SessionComponent", + "SessionComponentKind", + "SessionCredentials", + "SessionDeclarationError", + "SessionError", + "SessionEvent", + "SessionEventType", + "SessionFailure", + "SessionFailureKind", + "SessionFinalizationStage", + "SessionId", + "SessionLifecycleState", + "SessionMetrics", + "SessionRollbackStage", + "SessionRuntimeError", + "SessionSnapshot", + "SessionStartError", + "SessionTerminalState", + "SessionTrace", + "SessionTraceConfiguration", + "SessionTraceRecord", + "SessionTraceRecordType", + "SessionTraceRecorderOutcome", + "SessionTraceValidation", + "SidecarBackpressureError", + "SidecarConnection", + "SidecarDeadlines", + "SidecarError", + "SidecarHandle", + "SidecarId", + "SidecarMessage", + "SidecarMessageKind", + "SidecarProcessSpec", + "SidecarProtocolError", + "SidecarProtocolLimits", + "SidecarReadResult", + "SidecarSnapshot", + "SidecarState", + "SidecarStream", + "SidecarTimeoutError", + "SignalAudioPayload", + "SignalDerivation", + "SignalEnvelope", + "SignalKind", + "SignalLineage", + "SignalPayload", + "SignalReadResult", + "SignalSpec", + "SignalStream", + "SignalSubscriptionMetrics", + "SignalTiming", + "Source", + "SourceCancellation", + "SourceConfigValidator", + "SourceConfiguration", + "SourceDriver", + "SourceEmission", + "SourceError", + "SourceFactory", + "SourceFailureClass", + "SourceId", + "SourceIdentityStrength", + "SourceInstance", + "SourceInstanceId", + "SourceIterableFactory", + "SourceKind", + "SourceManifest", + "SourceMetrics", + "SourceOutput", + "SourceOutputIdentity", + "SourcePrepareContext", + "SourceProvider", + "SourceQuery", + "SourceRecoveryRequirement", + "SourceRuntimeEvent", + "SourceRuntimeEventKind", + "SourceSelectorKind", + "SourceState", + "StableSourceId", + "Stem", + "StemId", + "StopResult", + "StreamError", + "StreamId", + "StreamInUseError", + "StreamModeError", + "SubscriberCredentials", + "SubscriptionState", + "TerminationDisposition", + "TextFormat", + "TypedEdgeMetrics", + "aio", + "application_capture_available", + "capture", + "connector", + "discover_sources", + "microphone_permission_observation", + "operator", + "source", +] diff --git a/python/pocketstation/aio/__init__.py b/python/pocketstation/aio/__init__.py index 9e97348..7d2cf62 100644 --- a/python/pocketstation/aio/__init__.py +++ b/python/pocketstation/aio/__init__.py @@ -1,223 +1,20 @@ -"""Asyncio PocketStation SDK surface.""" +"""PocketStation's concise asyncio entry point.""" + +from __future__ import annotations from .audio_input import AudioInput, PcmSource from .capture import Capture, capture -from .connector import ( - AudioConnectorHandler, - Connector, - ConnectorBatchOutcome, - ConnectorCapability, - ConnectorConfigurationConstraint, - ConnectorConfigurationField, - ConnectorConfigurationInput, - ConnectorConfigurationRequirement, - ConnectorConfigurationSchema, - ConnectorConfigurationValue, - ConnectorConfigurationValueKind, - ConnectorContext, - ConnectorDeadlines, - ConnectorDeliveryOutcome, - ConnectorDeliveryReadiness, - ConnectorDriver, - ConnectorDriverBuilder, - ConnectorDriverFactory, - ConnectorError, - ConnectorErrorSnapshot, - ConnectorErrorStage, - ConnectorFactory, - ConnectorHandler, - ConnectorHealth, - ConnectorInputDescriptor, - ConnectorItem, - ConnectorManifest, - ConnectorObservations, - ConnectorPreparationGroup, - ConnectorRecovery, - ConnectorRequirement, - ConnectorRetryability, - ConnectorRuntimeObservations, - ConnectorServiceStatus, - ConnectorShutdownMode, - ConnectorWorker, - ConnectorWorkerBuilder, - RegisteredConnector, - connector, -) -from .control import ControlClient -from .endpoint_authoring import ( - EndpointConfigurationInput, - EndpointDeadlines, - EndpointDriverBuilder, - EndpointDriverError, - EndpointDriverFactory, - EndpointDriverObservations, - EndpointItem, - EndpointManifest, - EndpointPortInput, - EndpointPreparationGroup, - EndpointPrepareContext, - EndpointProvider, - EndpointReceiver, - EndpointShutdownMode, - EndpointStartGate, - PreparedEndpointDriver, - RegisteredEndpoint, - RunningEndpointDriver, -) -from .extensions import ( - ExtensionAbiVersion, - ExtensionDescriptor, - ExtensionKind, - ExtensionPort, - ExtensionPortDirection, - NativeExtensionLibrary, - NativeExtensionRegistration, -) -from .observations import EventStream -from .operator_authoring import ( - OperatorConfigValidator, - OperatorDeadlines, - OperatorEmission, - OperatorFactory, - OperatorHandler, - OperatorManifest, - OperatorNode, - OperatorNodeBuilder, - OperatorPrepareContext, - OperatorProvider, - RegisteredOperator, - operator, -) from .relay import RelaySession from .session import RunningSession, Session -from .sidecar import SidecarConnection, SidecarStream -from .source_authoring import ( - RegisteredSource, - SourceCancellation, - SourceConfigValidator, - SourceDeadlines, - SourceDriver, - SourceDriverBuilder, - SourceEmission, - SourceFactory, - SourceIterableFactory, - SourceManifest, - SourcePrepareContext, - SourceProvider, - source, -) -from .sources import ( - application_capture_available, - discover_sources, - microphone_permission_observation, -) -from .streams import AudioBatchReadResult, AudioStream, SignalStream +from .sources import discover_sources __all__ = [ - "AudioBatchReadResult", - "AudioConnectorHandler", "AudioInput", - "AudioStream", "Capture", - "Connector", - "ConnectorBatchOutcome", - "ConnectorCapability", - "ConnectorConfigurationConstraint", - "ConnectorConfigurationField", - "ConnectorConfigurationInput", - "ConnectorConfigurationRequirement", - "ConnectorConfigurationSchema", - "ConnectorConfigurationValue", - "ConnectorConfigurationValueKind", - "ConnectorContext", - "ConnectorDeadlines", - "ConnectorDeliveryOutcome", - "ConnectorDeliveryReadiness", - "ConnectorDriver", - "ConnectorDriverBuilder", - "ConnectorDriverFactory", - "ConnectorError", - "ConnectorErrorSnapshot", - "ConnectorErrorStage", - "ConnectorFactory", - "ConnectorHandler", - "ConnectorHealth", - "ConnectorInputDescriptor", - "ConnectorItem", - "ConnectorManifest", - "ConnectorObservations", - "ConnectorPreparationGroup", - "ConnectorRecovery", - "ConnectorRequirement", - "ConnectorRetryability", - "ConnectorRuntimeObservations", - "ConnectorServiceStatus", - "ConnectorShutdownMode", - "ConnectorWorker", - "ConnectorWorkerBuilder", - "ControlClient", - "EndpointConfigurationInput", - "EndpointDeadlines", - "EndpointDriverBuilder", - "EndpointDriverError", - "EndpointDriverFactory", - "EndpointDriverObservations", - "EndpointItem", - "EndpointManifest", - "EndpointPortInput", - "EndpointPreparationGroup", - "EndpointPrepareContext", - "EndpointProvider", - "EndpointReceiver", - "EndpointShutdownMode", - "EndpointStartGate", - "EventStream", - "ExtensionAbiVersion", - "ExtensionDescriptor", - "ExtensionKind", - "ExtensionPort", - "ExtensionPortDirection", - "NativeExtensionLibrary", - "NativeExtensionRegistration", - "OperatorConfigValidator", - "OperatorDeadlines", - "OperatorEmission", - "OperatorFactory", - "OperatorHandler", - "OperatorManifest", - "OperatorNode", - "OperatorNodeBuilder", - "OperatorPrepareContext", - "OperatorProvider", "PcmSource", - "PreparedEndpointDriver", - "RegisteredConnector", - "RegisteredEndpoint", - "RegisteredOperator", - "RegisteredSource", "RelaySession", - "RunningEndpointDriver", "RunningSession", "Session", - "SidecarConnection", - "SidecarStream", - "SignalStream", - "SourceCancellation", - "SourceConfigValidator", - "SourceDeadlines", - "SourceDriver", - "SourceDriverBuilder", - "SourceEmission", - "SourceFactory", - "SourceIterableFactory", - "SourceManifest", - "SourcePrepareContext", - "SourceProvider", - "application_capture_available", "capture", - "connector", "discover_sources", - "microphone_permission_observation", - "operator", - "source", ] diff --git a/python/pocketstation/aio/_api.py b/python/pocketstation/aio/_api.py new file mode 100644 index 0000000..ec663b0 --- /dev/null +++ b/python/pocketstation/aio/_api.py @@ -0,0 +1,223 @@ +"""Compatibility resolver for the pre-1.0 flat asyncio API.""" + +from .audio_input import AudioInput, PcmSource +from .capture import Capture, capture +from .connector import ( + AudioConnectorHandler, + Connector, + ConnectorBatchOutcome, + ConnectorCapability, + ConnectorConfigurationConstraint, + ConnectorConfigurationField, + ConnectorConfigurationInput, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorContext, + ConnectorDeadlines, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, + ConnectorDriver, + ConnectorDriverBuilder, + ConnectorDriverFactory, + ConnectorError, + ConnectorErrorSnapshot, + ConnectorErrorStage, + ConnectorFactory, + ConnectorHandler, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorObservations, + ConnectorPreparationGroup, + ConnectorRecovery, + ConnectorRequirement, + ConnectorRetryability, + ConnectorRuntimeObservations, + ConnectorServiceStatus, + ConnectorShutdownMode, + ConnectorWorker, + ConnectorWorkerBuilder, + RegisteredConnector, + connector, +) +from .control import ControlClient +from .endpoint_authoring import ( + EndpointConfigurationInput, + EndpointDeadlines, + EndpointDriverBuilder, + EndpointDriverError, + EndpointDriverFactory, + EndpointDriverObservations, + EndpointItem, + EndpointManifest, + EndpointPortInput, + EndpointPreparationGroup, + EndpointPrepareContext, + EndpointProvider, + EndpointReceiver, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RegisteredEndpoint, + RunningEndpointDriver, +) +from .extensions import ( + ExtensionAbiVersion, + ExtensionDescriptor, + ExtensionKind, + ExtensionPort, + ExtensionPortDirection, + NativeExtensionLibrary, + NativeExtensionRegistration, +) +from .observations import EventStream +from .operator_authoring import ( + OperatorConfigValidator, + OperatorDeadlines, + OperatorEmission, + OperatorFactory, + OperatorHandler, + OperatorManifest, + OperatorNode, + OperatorNodeBuilder, + OperatorPrepareContext, + OperatorProvider, + RegisteredOperator, + operator, +) +from .relay import RelaySession +from .session import RunningSession, Session +from .sidecar import SidecarConnection, SidecarStream +from .source_authoring import ( + RegisteredSource, + SourceCancellation, + SourceConfigValidator, + SourceDeadlines, + SourceDriver, + SourceDriverBuilder, + SourceEmission, + SourceFactory, + SourceIterableFactory, + SourceManifest, + SourcePrepareContext, + SourceProvider, + source, +) +from .sources import ( + application_capture_available, + discover_sources, + microphone_permission_observation, +) +from .streams import AudioBatchReadResult, AudioStream, SignalStream + +__all__ = [ + "AudioBatchReadResult", + "AudioConnectorHandler", + "AudioInput", + "AudioStream", + "Capture", + "Connector", + "ConnectorBatchOutcome", + "ConnectorCapability", + "ConnectorConfigurationConstraint", + "ConnectorConfigurationField", + "ConnectorConfigurationInput", + "ConnectorConfigurationRequirement", + "ConnectorConfigurationSchema", + "ConnectorConfigurationValue", + "ConnectorConfigurationValueKind", + "ConnectorContext", + "ConnectorDeadlines", + "ConnectorDeliveryOutcome", + "ConnectorDeliveryReadiness", + "ConnectorDriver", + "ConnectorDriverBuilder", + "ConnectorDriverFactory", + "ConnectorError", + "ConnectorErrorSnapshot", + "ConnectorErrorStage", + "ConnectorFactory", + "ConnectorHandler", + "ConnectorHealth", + "ConnectorInputDescriptor", + "ConnectorItem", + "ConnectorManifest", + "ConnectorObservations", + "ConnectorPreparationGroup", + "ConnectorRecovery", + "ConnectorRequirement", + "ConnectorRetryability", + "ConnectorRuntimeObservations", + "ConnectorServiceStatus", + "ConnectorShutdownMode", + "ConnectorWorker", + "ConnectorWorkerBuilder", + "ControlClient", + "EndpointConfigurationInput", + "EndpointDeadlines", + "EndpointDriverBuilder", + "EndpointDriverError", + "EndpointDriverFactory", + "EndpointDriverObservations", + "EndpointItem", + "EndpointManifest", + "EndpointPortInput", + "EndpointPreparationGroup", + "EndpointPrepareContext", + "EndpointProvider", + "EndpointReceiver", + "EndpointShutdownMode", + "EndpointStartGate", + "EventStream", + "ExtensionAbiVersion", + "ExtensionDescriptor", + "ExtensionKind", + "ExtensionPort", + "ExtensionPortDirection", + "NativeExtensionLibrary", + "NativeExtensionRegistration", + "OperatorConfigValidator", + "OperatorDeadlines", + "OperatorEmission", + "OperatorFactory", + "OperatorHandler", + "OperatorManifest", + "OperatorNode", + "OperatorNodeBuilder", + "OperatorPrepareContext", + "OperatorProvider", + "PcmSource", + "PreparedEndpointDriver", + "RegisteredConnector", + "RegisteredEndpoint", + "RegisteredOperator", + "RegisteredSource", + "RelaySession", + "RunningEndpointDriver", + "RunningSession", + "Session", + "SidecarConnection", + "SidecarStream", + "SignalStream", + "SourceCancellation", + "SourceConfigValidator", + "SourceDeadlines", + "SourceDriver", + "SourceDriverBuilder", + "SourceEmission", + "SourceFactory", + "SourceIterableFactory", + "SourceManifest", + "SourcePrepareContext", + "SourceProvider", + "application_capture_available", + "capture", + "connector", + "discover_sources", + "microphone_permission_observation", + "operator", + "source", +] diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py index 2408a42..6dd3917 100644 --- a/python/pocketstation/aio/audio_input.py +++ b/python/pocketstation/aio/audio_input.py @@ -63,7 +63,7 @@ async def observations(self) -> AudioInputObservations: class AudioInput(PcmSource): - """Intent-first asyncio input over the canonical bounded native source.""" + """Write application-owned PCM to a bounded native Source with asyncio.""" async def write( self, diff --git a/python/pocketstation/aio/capture.py b/python/pocketstation/aio/capture.py index 91b2019..f471a80 100644 --- a/python/pocketstation/aio/capture.py +++ b/python/pocketstation/aio/capture.py @@ -5,6 +5,7 @@ from collections.abc import AsyncIterator from pathlib import Path from types import TracebackType +from typing import TypeVar from .._native import AudioBatch from ..graph import Stem @@ -15,10 +16,13 @@ SessionTraceConfiguration, StopResult, ) -from ..sources import Source +from ..signal import BusSubscription +from ..sources import Source, _capture_application from .observations import EventStream from .session import RunningSession, Session -from .streams import AudioStream +from .streams import AudioStream, SignalStream + +_PayloadT = TypeVar("_PayloadT") class Capture: @@ -27,13 +31,18 @@ class Capture: def __init__( self, *, - application: str, + application: str | int, microphone: bool | str = True, record_to: str | Path | None = None, + stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> None: - if not application.strip(): + if isinstance(application, str) and not application.strip(): raise ValueError("application must not be empty") + if not isinstance(application, (str, int)) or isinstance(application, bool): + raise TypeError("application must be a display name or process ID") + if isinstance(application, int) and application <= 0: + raise ValueError("application process ID must be positive") if not isinstance(microphone, (bool, str)): raise TypeError("microphone must be True, False, or a device ID") if isinstance(microphone, str) and not microphone.strip(): @@ -42,6 +51,7 @@ def __init__( self._application_name = application self._microphone = microphone self._record_to = None if record_to is None else Path(record_to) + self._stream_audio = stream_audio self._trace = trace self._running: RunningSession | None = None self._entered = False @@ -53,18 +63,21 @@ def _declare(self) -> None: if self._trace is None else Session(recording_root=self._record_to, trace=self._trace) ) - application = session.capture(Source.application(self._application_name)) + application = session.capture(_capture_application(self._application_name)) microphone: Stem | None = None if self._microphone is True: microphone = session.capture(Source.microphone_default()) elif isinstance(self._microphone, str): microphone = session.capture(Source.microphone_id(self._microphone)) - audio = session.polled_audio() - self.application_route_id = application.send(audio) - self.microphone_route_id = ( - None if microphone is None else microphone.send(audio) - ) + self.application_route_id = None + self.microphone_route_id = None + if self._stream_audio: + audio = session.polled_audio() + self.application_route_id = application.send(audio) + self.microphone_route_id = ( + None if microphone is None else microphone.send(audio) + ) if self._record_to is not None: application.record("application") if microphone is not None: @@ -74,6 +87,13 @@ def _declare(self) -> None: self.application_stem = application self.microphone_stem = microphone + @property + def stems(self) -> tuple[Stem, ...]: + """The independently routable application and optional microphone stems.""" + if self.microphone_stem is None: + return (self.application_stem,) + return (self.application_stem, self.microphone_stem) + @property def is_running(self) -> bool: return self._running is not None and not self._running.is_stopped @@ -97,6 +117,12 @@ def events(self) -> EventStream: """Async lifecycle and failure events from the native Session.""" return self._require_running().events + def signals( + self, subscription: BusSubscription[_PayloadT] + ) -> SignalStream[_PayloadT]: + """Read one declared typed-signal branch from this running capture.""" + return self._require_running().signals(subscription) + async def start(self) -> Capture: if self._running is not None: raise RuntimeError("Capture has already started") @@ -160,9 +186,10 @@ def _require_running(self) -> RunningSession: def capture( *, - application: str, + application: str | int, microphone: bool | str = True, record_to: str | Path | None = None, + stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> Capture: """Declare a concise app+mic recipe backed by one native Rust Session.""" @@ -170,6 +197,7 @@ def capture( application=application, microphone=microphone, record_to=record_to, + stream_audio=stream_audio, trace=trace, ) diff --git a/python/pocketstation/aio/connector.py b/python/pocketstation/aio/connector.py index 78e8cc9..f7de346 100644 --- a/python/pocketstation/aio/connector.py +++ b/python/pocketstation/aio/connector.py @@ -1,4 +1,4 @@ -"""Bounded asyncio Connector authoring over the canonical Core worker.""" +"""Author bounded Python Connectors with asyncio lifecycle methods.""" from __future__ import annotations diff --git a/python/pocketstation/aio/control.py b/python/pocketstation/aio/control.py index 3ca448c..55e368a 100644 --- a/python/pocketstation/aio/control.py +++ b/python/pocketstation/aio/control.py @@ -13,11 +13,15 @@ _MAX_ERROR_BODY_BYTES, _MAX_JSON_BODY_BYTES, ControlPlaneError, + Invitation, SecretToken, SessionCredentials, SessionId, SessionSnapshot, SubscriberCredentials, + _bus_id, + _bus_ids, + _invitation, _normalize_base_url, _resolve_timeout, _session_credentials, @@ -46,19 +50,23 @@ def __init__( async def create_session( self, *, + required_buses: tuple[str, ...] = ("application", "microphone"), timeout_seconds: float | None = None, ) -> SessionCredentials: + required_buses = _bus_ids(required_buses, "required_buses") payload = await self._json_request( "POST", "v1/sessions", expected_status=201, timeout_seconds=timeout_seconds, + json_body={"required_buses": list(required_buses)}, ) return _session_credentials(payload) async def session( self, session_id: str | SessionId, + source_token: SecretToken, *, timeout_seconds: float | None = None, ) -> SessionSnapshot: @@ -68,24 +76,50 @@ async def session( f"v1/sessions/{quote(identifier, safe='')}", expected_status=200, timeout_seconds=timeout_seconds, + authorization=source_token, ) return _session_snapshot(payload) async def issue_subscriber_credentials( self, session_id: str | SessionId, + source_token: SecretToken, *, + bus_id: str = "mix", timeout_seconds: float | None = None, ) -> SubscriberCredentials: identifier = SessionId(str(session_id)) + bus_id = _bus_id(bus_id, "bus_id") payload = await self._json_request( "POST", f"v1/sessions/{quote(identifier, safe='')}/subscribe", expected_status=200, timeout_seconds=timeout_seconds, + authorization=source_token, + json_body={"bus_id": bus_id}, ) return _subscriber_credentials(payload) + async def create_invitation( + self, + session_id: str | SessionId, + source_token: SecretToken, + *, + bus_id: str = "mix", + timeout_seconds: float | None = None, + ) -> Invitation: + identifier = SessionId(str(session_id)) + bus_id = _bus_id(bus_id, "bus_id") + payload = await self._json_request( + "POST", + f"v1/sessions/{quote(identifier, safe='')}/invitations", + expected_status=201, + timeout_seconds=timeout_seconds, + authorization=source_token, + json_body={"bus_id": bus_id}, + ) + return _invitation(payload, identifier) + async def delete_session( self, session_id: str | SessionId, @@ -130,14 +164,17 @@ async def _json_request( *, expected_status: int, timeout_seconds: float | None, + authorization: SecretToken | None = None, + json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: return await self._request( method, path, expected_status=expected_status, timeout_seconds=timeout_seconds, - authorization=None, + authorization=authorization, expect_json=True, + json_body=json_body, ) async def _request( @@ -149,6 +186,7 @@ async def _request( timeout_seconds: float | None, authorization: SecretToken | None, expect_json: bool, + json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: if self._closed: raise RuntimeError("ControlClient has closed") @@ -164,6 +202,7 @@ async def _request( method, urljoin(self.control_plane_url, path), headers=headers, + json=json_body, timeout=timeout, ) as response: if response.status_code != expected_status: diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py index d3cec9e..39a5c33 100644 --- a/python/pocketstation/aio/relay.py +++ b/python/pocketstation/aio/relay.py @@ -1,21 +1,16 @@ -"""Asyncio control composition for the real PocketStation relay services.""" +"""Create and operate PocketStation RelaySessions with asyncio.""" from __future__ import annotations import asyncio -import json -from collections.abc import AsyncIterator, Callable +from collections.abc import Callable from time import monotonic from types import TracebackType -from typing import TYPE_CHECKING, Any -from urllib.parse import quote, urljoin +from typing import TYPE_CHECKING -import httpx - -from ..control import SecretToken, SessionCredentials, SessionId, SessionSnapshot +from ..control import SessionCredentials, SessionId, SessionSnapshot from ..errors import _native_call from ..relay import ( - _MAX_RELAY_RESPONSE_BYTES, PublisherActivation, ReceiverActivation, ReceiverInvitation, @@ -43,17 +38,13 @@ def __init__( relay_url: str, credentials: SessionCredentials, control: ControlClient, - relay_http: httpx.AsyncClient, owns_control: bool, - owns_relay_http: bool, request_timeout_seconds: float, ) -> None: self.relay_url = _normalize_relay_url(relay_url) self.credentials = credentials self._control = control - self._relay_http = relay_http self._owns_control = owns_control - self._owns_relay_http = owns_relay_http self._request_timeout_seconds = request_timeout_seconds self._publisher_activation: PublisherActivation | None = None self._invitation: ReceiverInvitation | None = None @@ -67,27 +58,22 @@ async def create( control_plane_url: str, relay_url: str, request_timeout_seconds: float = 10.0, + required_buses: tuple[str, ...] = ("application", "microphone"), control_client: ControlClient | None = None, - relay_http_client: httpx.AsyncClient | None = None, ) -> RelaySession: request_timeout_seconds = _validate_request_timeout(request_timeout_seconds) normalized_relay_url = _normalize_relay_url(relay_url) owns_control = control_client is None - owns_relay_http = relay_http_client is None control = control_client or ControlClient( control_plane_url, timeout_seconds=request_timeout_seconds, ) - relay_http = relay_http_client or httpx.AsyncClient( - timeout=request_timeout_seconds, - ) try: credentials = await control.create_session( + required_buses=required_buses, timeout_seconds=request_timeout_seconds, ) except BaseException: - if owns_relay_http: - await relay_http.aclose() if owns_control: await control.aclose() raise @@ -95,9 +81,7 @@ async def create( relay_url=normalized_relay_url, credentials=credentials, control=control, - relay_http=relay_http, owns_control=owns_control, - owns_relay_http=owns_relay_http, request_timeout_seconds=request_timeout_seconds, ) @@ -141,7 +125,7 @@ async def wait_for_publisher( ) -> PublisherActivation: self._require_open() snapshot = await self._wait_for_snapshot( - lambda value: value.source_active, + lambda value: value.ready, timeout_seconds=timeout_seconds, poll_interval_seconds=poll_interval_seconds, timeout_code="relay.publisher_timeout", @@ -151,23 +135,22 @@ async def wait_for_publisher( self._publisher_activation = activation return activation - async def create_receiver_invitation(self) -> ReceiverInvitation: + async def create_receiver_invitation( + self, *, bus_id: str = "mix" + ) -> ReceiverInvitation: self._require_open() if self._publisher_activation is None: raise RelayError( "wait_for_publisher() must succeed before creating an invitation", "relay.publisher_not_active", ) - payload = await _relay_json_request( - self._relay_http, - relay_url=self.relay_url, - method="POST", - path=(f"v1/sessions/{quote(str(self.session_id), safe='')}/invitations"), - expected_status=201, - authorization=self.credentials.source_token, + created = await self._control.create_invitation( + self.session_id, + self.credentials.source_token, + bus_id=bus_id, timeout_seconds=self._request_timeout_seconds, ) - invitation = _receiver_invitation(payload, self.session_id) + invitation = _receiver_invitation(created, self.session_id) self._invitation = invitation return invitation @@ -197,7 +180,7 @@ async def wait_for_receiver( "relay.invitation_missing", ) snapshot = await self._wait_for_snapshot( - lambda value: value.source_active and value.subscription_count > 0, + lambda value: value.ready and value.subscription_count > 0, timeout_seconds=timeout_seconds, poll_interval_seconds=poll_interval_seconds, timeout_code="relay.receiver_timeout", @@ -219,8 +202,6 @@ async def aclose(self, *, delete_remote_session: bool = True) -> None: timeout_seconds=self._request_timeout_seconds, ) finally: - if self._owns_relay_http: - await self._relay_http.aclose() if self._owns_control: await self._control.aclose() @@ -260,6 +241,7 @@ async def _wait_for_snapshot( raise RelayTimeoutError(timeout_message, timeout_code) snapshot = await self._control.session( self.session_id, + self.credentials.source_token, timeout_seconds=_bounded_request_timeout( remaining, self._request_timeout_seconds, @@ -276,72 +258,6 @@ def _require_open(self) -> None: raise RelayError("RelaySession has closed", "relay.closed") -async def _relay_json_request( - client: httpx.AsyncClient, - *, - relay_url: str, - method: str, - path: str, - expected_status: int, - authorization: SecretToken, - timeout_seconds: float, -) -> dict[str, Any]: - exposed = authorization.expose_secret() - try: - async with client.stream( - method, - urljoin(relay_url + "/", path), - headers={"Authorization": f"Bearer {exposed}"}, - timeout=timeout_seconds, - ) as response: - body = await _read_bounded( - response.aiter_bytes(), - _MAX_RELAY_RESPONSE_BYTES, - ) - if response.status_code != expected_status: - detail = body.decode("utf-8", errors="replace").replace( - exposed, - "[redacted]", - ) - raise RelayError( - f"relay returned HTTP {response.status_code}: {detail}", - "relay.http_status", - ) - except RelayError: - raise - except httpx.HTTPError as error: - message = str(error).replace(exposed, "[redacted]") - raise RelayError(f"relay request failed: {message}", "relay.request") from error - try: - payload = json.loads(body) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise RelayError( - f"relay response could not be decoded: {error}", - "relay.response_decode", - ) from error - if not isinstance(payload, dict): - raise RelayError( - "relay response must be a JSON object", - "relay.response_decode", - ) - return payload - - -async def _read_bounded(chunks: AsyncIterator[bytes], limit_bytes: int) -> bytes: - body = bytearray() - async for chunk in chunks: - remaining = limit_bytes + 1 - len(body) - if remaining <= 0: - break - body.extend(chunk[:remaining]) - if len(body) > limit_bytes: - raise RelayError( - f"relay response exceeds {limit_bytes} bytes", - "relay.response_too_large", - ) - return bytes(body) - - __all__ = [ "PublisherActivation", "ReceiverActivation", diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index e42cc4c..56c1d0a 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -1,4 +1,4 @@ -"""Asyncio ownership of the canonical native PocketStation Session.""" +"""Build and operate a native PocketStation Session with asyncio.""" from __future__ import annotations @@ -274,7 +274,7 @@ async def _wait_event_native(self, timeout_ms: int) -> SessionEvent | None: class Session(_GraphSessionDeclarations): - """Explicit asyncio façade over the canonical Rust Session.""" + """Build and operate one Rust Session from asyncio code.""" def __init__( self, @@ -313,7 +313,7 @@ def __init__( @classmethod def _from_native(cls, native: _NativeSession) -> Session: - """Construct an internal façade around a canonical conformance Session.""" + """Construct an internal façade around a conformance Session.""" session = cls.__new__(cls) session._native = native session._sample_rate_hz = 48_000 diff --git a/python/pocketstation/aio/sources.py b/python/pocketstation/aio/sources.py index d9a02cb..ff5fc57 100644 --- a/python/pocketstation/aio/sources.py +++ b/python/pocketstation/aio/sources.py @@ -21,7 +21,7 @@ async def discover_sources( query: SourceQuery | None = None, ) -> tuple[DiscoveredSource, ...]: - """Run canonical native source discovery off the asyncio event loop.""" + """Run native source discovery without blocking the asyncio event loop.""" return await asyncio.to_thread(_discover_sources, query) diff --git a/python/pocketstation/capture.py b/python/pocketstation/capture.py index 9bc38c9..c8c00f5 100644 --- a/python/pocketstation/capture.py +++ b/python/pocketstation/capture.py @@ -5,6 +5,7 @@ from collections.abc import Iterator from pathlib import Path from types import TracebackType +from typing import TypeVar from ._native import AudioBatch from .graph import Stem @@ -17,8 +18,11 @@ StopResult, ) from .session import RunningSession, Session -from .sources import Source -from .streams import AudioStream +from .signal import BusSubscription +from .sources import Source, _capture_application +from .streams import AudioStream, SignalStream + +_PayloadT = TypeVar("_PayloadT") class Capture: @@ -27,13 +31,18 @@ class Capture: def __init__( self, *, - application: str, + application: str | int, microphone: bool | str = True, record_to: str | Path | None = None, + stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> None: - if not application.strip(): + if isinstance(application, str) and not application.strip(): raise ValueError("application must not be empty") + if not isinstance(application, (str, int)) or isinstance(application, bool): + raise TypeError("application must be a display name or process ID") + if isinstance(application, int) and application <= 0: + raise ValueError("application process ID must be positive") if not isinstance(microphone, (bool, str)): raise TypeError("microphone must be True, False, or a device ID") if isinstance(microphone, str) and not microphone.strip(): @@ -42,6 +51,7 @@ def __init__( self._application_name = application self._microphone = microphone self._record_to = None if record_to is None else Path(record_to) + self._stream_audio = stream_audio self._trace = trace self._running: RunningSession | None = None self._entered = False @@ -53,18 +63,21 @@ def _declare(self) -> None: if self._trace is None else Session(recording_root=self._record_to, trace=self._trace) ) - application = session.capture(Source.application(self._application_name)) + application = session.capture(_capture_application(self._application_name)) microphone: Stem | None = None if self._microphone is True: microphone = session.capture(Source.microphone_default()) elif isinstance(self._microphone, str): microphone = session.capture(Source.microphone_id(self._microphone)) - audio = session.polled_audio() - self.application_route_id = application.send(audio) - self.microphone_route_id = ( - None if microphone is None else microphone.send(audio) - ) + self.application_route_id = None + self.microphone_route_id = None + if self._stream_audio: + audio = session.polled_audio() + self.application_route_id = application.send(audio) + self.microphone_route_id = ( + None if microphone is None else microphone.send(audio) + ) if self._record_to is not None: application.record("application") if microphone is not None: @@ -74,6 +87,13 @@ def _declare(self) -> None: self.application_stem = application self.microphone_stem = microphone + @property + def stems(self) -> tuple[Stem, ...]: + """The independently routable application and optional microphone stems.""" + if self.microphone_stem is None: + return (self.application_stem,) + return (self.application_stem, self.microphone_stem) + @property def is_running(self) -> bool: return self._running is not None and not self._running.is_stopped @@ -97,6 +117,12 @@ def events(self) -> EventStream: """Lifecycle and failure events from the running native Session.""" return self._require_running().events + def signals( + self, subscription: BusSubscription[_PayloadT] + ) -> SignalStream[_PayloadT]: + """Read one declared typed-signal branch from this running capture.""" + return self._require_running().signals(subscription) + def start(self) -> Capture: if self._running is not None: raise RuntimeError("Capture has already started") @@ -156,9 +182,10 @@ def _require_running(self) -> RunningSession: def capture( *, - application: str, + application: str | int, microphone: bool | str = True, record_to: str | Path | None = None, + stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> Capture: """Declare a concise app+mic recipe backed by one native Rust Session.""" @@ -166,6 +193,7 @@ def capture( application=application, microphone=microphone, record_to=record_to, + stream_audio=stream_audio, trace=trace, ) diff --git a/python/pocketstation/connector.py b/python/pocketstation/connector.py index 8c3b0d9..19fe3a9 100644 --- a/python/pocketstation/connector.py +++ b/python/pocketstation/connector.py @@ -1,4 +1,4 @@ -"""In-process Python Connector authoring over the canonical Core worker.""" +"""Author in-process Python Connectors on the Core worker lifecycle.""" from __future__ import annotations diff --git a/python/pocketstation/control.py b/python/pocketstation/control.py index 965d82f..766d0d3 100644 --- a/python/pocketstation/control.py +++ b/python/pocketstation/control.py @@ -5,7 +5,7 @@ import json from dataclasses import dataclass from types import TracebackType -from typing import Any +from typing import Any, cast from urllib.parse import quote, urljoin, urlparse import httpx @@ -83,24 +83,53 @@ class IceServer: @dataclass(frozen=True, slots=True) class SessionCredentials: session_id: SessionId + required_buses: tuple[str, ...] source_token: SecretToken - subscriber_token: SecretToken whip_url: str | None = None whep_url: str | None = None ice_servers: tuple[IceServer, ...] = () +@dataclass(frozen=True, slots=True) +class BusState: + bus_id: str + role: str + source_active: bool + source_generation: int + + +@dataclass(frozen=True, slots=True) +class SubscriptionState: + subscriber_id: str + bus_id: str + + @dataclass(frozen=True, slots=True) class SessionSnapshot: session_id: SessionId - source_active: bool + state_revision: int + relay_epoch: str | None + relay_revision: int + required_buses: tuple[str, ...] + buses: tuple[BusState, ...] + subscriptions: tuple[SubscriptionState, ...] + ready: bool subscription_count: int codec: str +@dataclass(frozen=True, slots=True) +class Invitation: + session_id: SessionId + join_code: str + join_url: str + expires_at: str + + @dataclass(frozen=True, slots=True) class SubscriberCredentials: session_id: SessionId + bus_id: str subscriber_token: SecretToken @@ -123,19 +152,23 @@ def __init__( def create_session( self, *, + required_buses: tuple[str, ...] = ("application", "microphone"), timeout_seconds: float | None = None, ) -> SessionCredentials: + required_buses = _bus_ids(required_buses, "required_buses") payload = self._json_request( "POST", "v1/sessions", expected_status=201, timeout_seconds=timeout_seconds, + json_body={"required_buses": list(required_buses)}, ) return _session_credentials(payload) def session( self, session_id: str | SessionId, + source_token: SecretToken, *, timeout_seconds: float | None = None, ) -> SessionSnapshot: @@ -145,24 +178,50 @@ def session( f"v1/sessions/{quote(identifier, safe='')}", expected_status=200, timeout_seconds=timeout_seconds, + authorization=source_token, ) return _session_snapshot(payload) def issue_subscriber_credentials( self, session_id: str | SessionId, + source_token: SecretToken, *, + bus_id: str = "mix", timeout_seconds: float | None = None, ) -> SubscriberCredentials: identifier = SessionId(str(session_id)) + bus_id = _bus_id(bus_id, "bus_id") payload = self._json_request( "POST", f"v1/sessions/{quote(identifier, safe='')}/subscribe", expected_status=200, timeout_seconds=timeout_seconds, + authorization=source_token, + json_body={"bus_id": bus_id}, ) return _subscriber_credentials(payload) + def create_invitation( + self, + session_id: str | SessionId, + source_token: SecretToken, + *, + bus_id: str = "mix", + timeout_seconds: float | None = None, + ) -> Invitation: + identifier = SessionId(str(session_id)) + bus_id = _bus_id(bus_id, "bus_id") + payload = self._json_request( + "POST", + f"v1/sessions/{quote(identifier, safe='')}/invitations", + expected_status=201, + timeout_seconds=timeout_seconds, + authorization=source_token, + json_body={"bus_id": bus_id}, + ) + return _invitation(payload, identifier) + def delete_session( self, session_id: str | SessionId, @@ -207,14 +266,17 @@ def _json_request( *, expected_status: int, timeout_seconds: float | None, + authorization: SecretToken | None = None, + json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: return self._request( method, path, expected_status=expected_status, timeout_seconds=timeout_seconds, - authorization=None, + authorization=authorization, expect_json=True, + json_body=json_body, ) def _request( @@ -226,6 +288,7 @@ def _request( timeout_seconds: float | None, authorization: SecretToken | None, expect_json: bool, + json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: if self._closed: raise RuntimeError("ControlClient has closed") @@ -241,6 +304,7 @@ def _request( method, urljoin(self.control_plane_url, path), headers=headers, + json=json_body, timeout=timeout, ) as response: if response.status_code != expected_status: @@ -382,8 +446,11 @@ def _ice_servers(payload: dict[str, Any]) -> tuple[IceServer, ...]: def _session_credentials(payload: dict[str, Any]) -> SessionCredentials: return SessionCredentials( session_id=SessionId(_required(payload, "session_id", str)), + required_buses=_decoded_bus_ids( + tuple(_required(payload, "required_buses", list)), + "required_buses", + ), source_token=SecretToken(_required(payload, "source_token", str)), - subscriber_token=SecretToken(_required(payload, "subscriber_token", str)), whip_url=_optional_string(payload, "whip_url"), whep_url=_optional_string(payload, "whep_url"), ice_servers=_ice_servers(payload), @@ -391,6 +458,8 @@ def _session_credentials(payload: dict[str, Any]) -> SessionCredentials: def _session_snapshot(payload: dict[str, Any]) -> SessionSnapshot: + state_revision = _nonnegative_integer(payload, "state_revision", minimum=1) + relay_revision = _nonnegative_integer(payload, "relay_revision") subscription_count = _required(payload, "subscription_count", int) if subscription_count < 0: raise ControlPlaneError( @@ -399,7 +468,16 @@ def _session_snapshot(payload: dict[str, Any]) -> SessionSnapshot: ) return SessionSnapshot( session_id=SessionId(_required(payload, "session_id", str)), - source_active=_required(payload, "source_active", bool), + state_revision=state_revision, + relay_epoch=_optional_string(payload, "relay_epoch"), + relay_revision=relay_revision, + required_buses=_decoded_bus_ids( + tuple(_required(payload, "required_buses", list)), + "required_buses", + ), + buses=_bus_states(payload), + subscriptions=_subscription_states(payload), + ready=_required(payload, "ready", bool), subscription_count=subscription_count, codec=_required(payload, "codec", str), ) @@ -408,10 +486,109 @@ def _session_snapshot(payload: dict[str, Any]) -> SessionSnapshot: def _subscriber_credentials(payload: dict[str, Any]) -> SubscriberCredentials: return SubscriberCredentials( session_id=SessionId(_required(payload, "session_id", str)), + bus_id=_bus_id(_required(payload, "bus_id", str), "bus_id"), subscriber_token=SecretToken(_required(payload, "subscriber_token", str)), ) +def _invitation(payload: dict[str, Any], session_id: SessionId) -> Invitation: + return Invitation( + session_id=session_id, + join_code=_required(payload, "join_code", str), + join_url=_required(payload, "join_url", str), + expires_at=_required(payload, "expires_at", str), + ) + + +def _nonnegative_integer(payload: dict[str, Any], key: str, *, minimum: int = 0) -> int: + value = cast(int, _required(payload, key, int)) + if value < minimum: + raise ControlPlaneError( + f"control-plane response field {key!r} must be at least {minimum}", + "control.response_decode", + ) + return value + + +def _identifier(value: str, field: str, maximum: int) -> str: + if ( + not value + or len(value) > maximum + or not all( + character.isascii() and (character.isalnum() or character in "._-") + for character in value + ) + ): + raise ValueError( + f"{field} must contain 1 to {maximum} ASCII letters, digits, " + "'.', '_' or '-'" + ) + return value + + +def _bus_id(value: str, field: str) -> str: + return _identifier(value, field, 64) + + +def _bus_ids(values: tuple[Any, ...], field: str) -> tuple[str, ...]: + if not 1 <= len(values) <= 16 or not all( + isinstance(value, str) for value in values + ): + raise ValueError(f"{field} must contain between 1 and 16 bus IDs") + result = tuple(_bus_id(value, field) for value in values) + if len(set(result)) != len(result): + raise ValueError(f"{field} must not contain duplicate bus IDs") + return result + + +def _decoded_bus_ids(values: tuple[Any, ...], field: str) -> tuple[str, ...]: + try: + return _bus_ids(values, field) + except ValueError as error: + raise ControlPlaneError(str(error), "control.response_decode") from error + + +def _required_identifier(payload: dict[str, Any], key: str, *, maximum: int) -> str: + try: + return _identifier(_required(payload, key, str), key, maximum) + except ValueError as error: + raise ControlPlaneError(str(error), "control.response_decode") from error + + +def _bus_states(payload: dict[str, Any]) -> tuple[BusState, ...]: + raw = _required(payload, "buses", list) + if len(raw) > 16 or not all(isinstance(value, dict) for value in raw): + raise ControlPlaneError( + "control-plane buses must contain at most 16 objects", + "control.response_decode", + ) + return tuple( + BusState( + bus_id=_required_identifier(value, "bus_id", maximum=64), + role=_required_identifier(value, "role", maximum=64), + source_active=_required(value, "source_active", bool), + source_generation=_nonnegative_integer(value, "source_generation"), + ) + for value in raw + ) + + +def _subscription_states(payload: dict[str, Any]) -> tuple[SubscriptionState, ...]: + raw = _required(payload, "subscriptions", list) + if len(raw) > 1_024 or not all(isinstance(value, dict) for value in raw): + raise ControlPlaneError( + "control-plane subscriptions must contain at most 1024 objects", + "control.response_decode", + ) + return tuple( + SubscriptionState( + subscriber_id=_required_identifier(value, "subscriber_id", maximum=128), + bus_id=_required_identifier(value, "bus_id", maximum=64), + ) + for value in raw + ) + + def _optional_string(payload: dict[str, Any], key: str) -> str | None: value = payload.get(key) if value is not None and not isinstance(value, str): @@ -423,12 +600,15 @@ def _optional_string(payload: dict[str, Any], key: str) -> str | None: __all__ = [ + "BusState", "ControlClient", "ControlPlaneError", "IceServer", + "Invitation", "SecretToken", "SessionCredentials", "SessionId", "SessionSnapshot", "SubscriberCredentials", + "SubscriptionState", ] diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index d2060af..a6366da 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -1,4 +1,4 @@ -"""Pythonic graph declarations lowered by the canonical Rust ``Session``.""" +"""Declare Python graph routes for the Rust ``Session`` to compile.""" from __future__ import annotations @@ -345,7 +345,7 @@ def any(cls) -> MediaCaps: @classmethod def for_signal(cls, signal: SignalSpec[object]) -> MediaCaps: - """Select the canonical wildcard media contract for a signal.""" + """Select the wildcard media contract for a signal.""" if signal.kind is SignalKind.PCM_AUDIO: return cls.audio() if signal.kind is SignalKind.ENCODED_AUDIO: @@ -508,7 +508,7 @@ def rank(self) -> int: @dataclass(frozen=True, slots=True) class EdgeContract: - """Canonical bounded edge preset plus the exact public Rust modifiers.""" + """Configure a bounded edge with the public Rust policy modifiers.""" _native: _NativeEdgeContract = field(repr=False, compare=False) @@ -849,7 +849,7 @@ def output(self, port_name: str) -> DerivedStream: ) def reenter_audio(self) -> Stem: - """Declare canonical generated-PCM reentry; no Python callback runs.""" + """Return generated PCM through Core without a Python audio callback.""" return _native_call( lambda: Stem(self._native.reenter_audio(), self._destination) ) @@ -944,7 +944,7 @@ def _destination_for_stream(self, connector: object) -> Endpoint: @property def id(self) -> RuntimeSessionId: - """Stable identity allocated by the canonical Rust Session.""" + """Return the stable identity allocated by the Rust Session.""" return RuntimeSessionId(self._native.id) def source( @@ -974,7 +974,7 @@ def operator(self, operator: Operator) -> OperatorInstance: ) def endpoint(self, descriptor: EndpointDescriptor) -> Endpoint: - """Declare one open endpoint descriptor on the canonical draft.""" + """Declare one open Endpoint descriptor on the Session draft.""" return _native_call(lambda: Endpoint(self._native.endpoint(descriptor._native))) def connector( @@ -1001,7 +1001,7 @@ def subscribe( ) -> BusSubscription[_PayloadT]: """Declare one bounded, exclusive typed-signal subscription. - The subscription is a real endpoint in the canonical Rust Session. + The subscription is an Endpoint in the Rust Session. Python owns no additional queue, router, or background pump. """ from .signal import BusSubscription diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py index 5c8ae19..45a9e90 100644 --- a/python/pocketstation/observations.py +++ b/python/pocketstation/observations.py @@ -1,4 +1,4 @@ -"""Typed, immutable observations from the canonical native Session.""" +"""Inspect typed, immutable observations from the native Session.""" from __future__ import annotations diff --git a/python/pocketstation/relay.py b/python/pocketstation/relay.py index 84c53a9..ad2a672 100644 --- a/python/pocketstation/relay.py +++ b/python/pocketstation/relay.py @@ -1,33 +1,30 @@ -"""Explicit control and declaration composition for the real relay services.""" +"""Create RelaySessions and compose their publication declarations.""" from __future__ import annotations -import json from collections.abc import Callable from dataclasses import dataclass from time import monotonic, sleep from types import TracebackType -from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, quote, urljoin, urlparse - -import httpx +from typing import TYPE_CHECKING +from urllib.parse import parse_qs, urlparse from ._native import RelayPublisher as _NativeRelayPublisher from .control import ( ControlClient, - SecretToken, SessionCredentials, SessionId, SessionSnapshot, ) +from .control import ( + Invitation as ControlInvitation, +) from .errors import PocketStationError, _native_call from .identity import RouteId if TYPE_CHECKING: from .session import Session -_MAX_RELAY_RESPONSE_BYTES = 16_384 - class RelayError(PocketStationError): """A relay declaration, HTTP, activation, or lifecycle failure.""" @@ -61,7 +58,7 @@ class ReceiverActivation: @dataclass(frozen=True, slots=True) class ReceiverInvitation: - """Opaque relay-issued browser invitation containing no subscriber token.""" + """Opaque control-plane invitation containing no subscriber capability.""" session_id: SessionId join_code: str @@ -94,7 +91,7 @@ class RelaySession: The object creates no relay, control-plane, browser, signaling, or media process. Callers provide already-running service origins. Audio remains in - the canonical Rust Session and shared ``pocketstation-relay`` crate. + the Rust Session and shared ``pocketstation-relay`` crate. """ def __init__( @@ -103,17 +100,13 @@ def __init__( relay_url: str, credentials: SessionCredentials, control: ControlClient, - relay_http: httpx.Client, owns_control: bool, - owns_relay_http: bool, request_timeout_seconds: float, ) -> None: self.relay_url = _normalize_relay_url(relay_url) self.credentials = credentials self._control = control - self._relay_http = relay_http self._owns_control = owns_control - self._owns_relay_http = owns_relay_http self._request_timeout_seconds = request_timeout_seconds self._publisher_activation: PublisherActivation | None = None self._invitation: ReceiverInvitation | None = None @@ -127,27 +120,22 @@ def create( control_plane_url: str, relay_url: str, request_timeout_seconds: float = 10.0, + required_buses: tuple[str, ...] = ("application", "microphone"), control_client: ControlClient | None = None, - relay_http_client: httpx.Client | None = None, ) -> RelaySession: request_timeout_seconds = _validate_request_timeout(request_timeout_seconds) normalized_relay_url = _normalize_relay_url(relay_url) owns_control = control_client is None - owns_relay_http = relay_http_client is None control = control_client or ControlClient( control_plane_url, timeout_seconds=request_timeout_seconds, ) - relay_http = relay_http_client or httpx.Client( - timeout=request_timeout_seconds, - ) try: credentials = control.create_session( + required_buses=required_buses, timeout_seconds=request_timeout_seconds, ) except Exception: - if owns_relay_http: - relay_http.close() if owns_control: control.close() raise @@ -155,9 +143,7 @@ def create( relay_url=normalized_relay_url, credentials=credentials, control=control, - relay_http=relay_http, owns_control=owns_control, - owns_relay_http=owns_relay_http, request_timeout_seconds=request_timeout_seconds, ) @@ -202,7 +188,7 @@ def wait_for_publisher( """Wait for the relay's source-active callback, within one deadline.""" self._require_open() snapshot = self._wait_for_snapshot( - lambda value: value.source_active, + lambda value: value.ready, timeout_seconds=timeout_seconds, poll_interval_seconds=poll_interval_seconds, timeout_code="relay.publisher_timeout", @@ -212,24 +198,21 @@ def wait_for_publisher( self._publisher_activation = activation return activation - def create_receiver_invitation(self) -> ReceiverInvitation: - """Ask the relay for an opaque invitation after publisher activation.""" + def create_receiver_invitation(self, *, bus_id: str = "mix") -> ReceiverInvitation: + """Create a scoped invitation after every required bus is attached.""" self._require_open() if self._publisher_activation is None: raise RelayError( "wait_for_publisher() must succeed before creating an invitation", "relay.publisher_not_active", ) - payload = _relay_json_request( - self._relay_http, - relay_url=self.relay_url, - method="POST", - path=(f"v1/sessions/{quote(str(self.session_id), safe='')}/invitations"), - expected_status=201, - authorization=self.credentials.source_token, + created = self._control.create_invitation( + self.session_id, + self.credentials.source_token, + bus_id=bus_id, timeout_seconds=self._request_timeout_seconds, ) - invitation = _receiver_invitation(payload, self.session_id) + invitation = _receiver_invitation(created, self.session_id) self._invitation = invitation return invitation @@ -260,7 +243,7 @@ def wait_for_receiver( "relay.invitation_missing", ) snapshot = self._wait_for_snapshot( - lambda value: value.source_active and value.subscription_count > 0, + lambda value: value.ready and value.subscription_count > 0, timeout_seconds=timeout_seconds, poll_interval_seconds=poll_interval_seconds, timeout_code="relay.receiver_timeout", @@ -282,8 +265,6 @@ def close(self, *, delete_remote_session: bool = True) -> None: timeout_seconds=self._request_timeout_seconds, ) finally: - if self._owns_relay_http: - self._relay_http.close() if self._owns_control: self._control.close() @@ -327,6 +308,7 @@ def _wait_for_snapshot( ) snapshot = self._control.session( self.session_id, + self.credentials.source_token, timeout_seconds=request_timeout, ) if predicate(snapshot): @@ -375,81 +357,17 @@ def _bounded_request_timeout( return min(remaining_seconds, configured_seconds) -def _relay_json_request( - client: httpx.Client, - *, - relay_url: str, - method: str, - path: str, - expected_status: int, - authorization: SecretToken, - timeout_seconds: float, -) -> dict[str, Any]: - exposed = authorization.expose_secret() - try: - with client.stream( - method, - urljoin(relay_url + "/", path), - headers={"Authorization": f"Bearer {exposed}"}, - timeout=timeout_seconds, - ) as response: - body = _read_bounded(response.iter_bytes(), _MAX_RELAY_RESPONSE_BYTES) - if response.status_code != expected_status: - detail = body.decode("utf-8", errors="replace").replace( - exposed, - "[redacted]", - ) - raise RelayError( - f"relay returned HTTP {response.status_code}: {detail}", - "relay.http_status", - ) - except RelayError: - raise - except httpx.HTTPError as error: - message = str(error).replace(exposed, "[redacted]") - raise RelayError(f"relay request failed: {message}", "relay.request") from error - try: - payload = json.loads(body) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise RelayError( - f"relay response could not be decoded: {error}", - "relay.response_decode", - ) from error - if not isinstance(payload, dict): - raise RelayError( - "relay response must be a JSON object", - "relay.response_decode", - ) - return payload - - -def _read_bounded(chunks: Any, limit_bytes: int) -> bytes: - body = bytearray() - for chunk in chunks: - remaining = limit_bytes + 1 - len(body) - if remaining <= 0: - break - body.extend(chunk[:remaining]) - if len(body) > limit_bytes: - raise RelayError( - f"relay response exceeds {limit_bytes} bytes", - "relay.response_too_large", - ) - return bytes(body) - - def _receiver_invitation( - payload: dict[str, Any], + created: ControlInvitation, expected_session_id: SessionId, ) -> ReceiverInvitation: - session_id = SessionId(_required_string(payload, "session_id")) - if session_id != expected_session_id: + if created.session_id != expected_session_id: raise RelayError( - "relay invitation belongs to a different Session", + "control-plane invitation belongs to a different Session", "relay.response_identity", ) - join_code = _required_string(payload, "join_code") - invitation_url = _required_string(payload, "join_url") + join_code = created.join_code + invitation_url = created.join_url parsed = urlparse(invitation_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise RelayError( @@ -479,17 +397,7 @@ def _receiver_invitation( "relay invitation URL exposes the Session identifier", "relay.unsafe_invitation", ) - return ReceiverInvitation(session_id, join_code, invitation_url) - - -def _required_string(payload: dict[str, Any], key: str) -> str: - value = payload.get(key) - if not isinstance(value, str) or not value: - raise RelayError( - f"relay response field {key!r} must be a non-empty string", - "relay.response_decode", - ) - return value + return ReceiverInvitation(created.session_id, join_code, invitation_url) __all__ = [ diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index 719abed..b73e8a7 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -1,4 +1,4 @@ -"""Synchronous Python ownership of the canonical native PocketStation Session.""" +"""Build and operate a native PocketStation Session synchronously.""" from __future__ import annotations @@ -254,7 +254,7 @@ def _wait_event_native(self, timeout_ms: int) -> SessionEvent | None: class Session(_GraphSessionDeclarations): - """Explicit synchronous façade over the canonical Rust Session.""" + """Build and operate one Rust Session from synchronous Python code.""" def __init__( self, @@ -283,7 +283,7 @@ def __init__( @classmethod def _from_native(cls, native: _NativeSession) -> Session: - """Construct an internal façade around a canonical conformance Session.""" + """Construct an internal façade around a conformance Session.""" session = cls.__new__(cls) session._native = native session._sample_rate_hz = 48_000 diff --git a/python/pocketstation/signal.py b/python/pocketstation/signal.py index f2c860d..c6a1398 100644 --- a/python/pocketstation/signal.py +++ b/python/pocketstation/signal.py @@ -1,4 +1,4 @@ -"""Immutable typed signals delivered by canonical Rust Session endpoints.""" +"""Read immutable typed signals delivered by Rust Session endpoints.""" from __future__ import annotations diff --git a/python/pocketstation/source_authoring.py b/python/pocketstation/source_authoring.py index 83d1e01..d46ea16 100644 --- a/python/pocketstation/source_authoring.py +++ b/python/pocketstation/source_authoring.py @@ -1,4 +1,4 @@ -"""Python-authored typed Sources over the canonical Core source lifecycle.""" +"""Author typed Python Sources on the Core source lifecycle.""" from __future__ import annotations @@ -188,7 +188,7 @@ def close(self) -> None: @runtime_checkable class SourceFactory(Protocol): - """Reusable factory retained by one canonical Session.""" + """Reusable factory retained by one Session.""" def create(self, configuration: Mapping[str, str]) -> SourceDriver: ... @@ -292,7 +292,7 @@ def create(self, configuration: Mapping[str, str]) -> _NativeDriverAdapter: class RegisteredSource: - """One Source implementation registered into one canonical Session.""" + """Register one Python Source implementation in a Session.""" __slots__ = ("_native", "_provider", "_session") diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index 5707215..868e627 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -247,7 +247,7 @@ class StableSourceId: @dataclass(frozen=True, slots=True) class DiscoveredSource: - """Immutable point-in-time result from the canonical Rust discovery query.""" + """Immutable point-in-time result from native source discovery.""" stable_id: StableSourceId name: str @@ -323,7 +323,7 @@ def authorization_before_open( @dataclass(frozen=True, slots=True) class SourceQuery: - """Typed query executed by the canonical Rust source provider.""" + """Describe a typed query for the native source provider to execute.""" _query_kind: str = "any" _value: str | None = None @@ -360,7 +360,7 @@ class ProcessInstanceSelector: @dataclass(frozen=True, slots=True) class Source: - """Immutable declaration lowered by the canonical Rust ``Session``.""" + """Immutable Source declaration compiled by the Rust ``Session``.""" _native: _NativeSource = field(repr=False) kind: SourceKind @@ -513,6 +513,22 @@ def from_discovered(cls, source: DiscoveredSource) -> Source: ) +def _capture_application(application: str | int) -> Source: + """Resolve the concise capture façade's name-or-process selector.""" + if isinstance(application, int): + return Source.application_process_id(application) + if application.isascii() and application.isdecimal(): + return Source.application_process_id(int(application)) + if application.startswith("app:"): + process_id = application.removeprefix("app:") + if not process_id.isascii() or not process_id.isdecimal(): + raise ValueError("app: application selector must contain a process ID") + return Source.application_process_id(int(process_id)) + if application.startswith("bundle:"): + return Source.application_bundle_id(application.removeprefix("bundle:")) + return Source.application(application) + + @dataclass(frozen=True, slots=True) class SourceRuntimeEvent: """Typed native source disappearance or backend-failure observation.""" diff --git a/python/pocketstation/streams.py b/python/pocketstation/streams.py index 577b2e0..eef522b 100644 --- a/python/pocketstation/streams.py +++ b/python/pocketstation/streams.py @@ -234,7 +234,7 @@ class SignalStream(Generic[_PayloadT]): ``None`` means a bounded read timed out, while ``STREAM_EOF`` means the endpoint is permanently closed. Iteration handles both states naturally - and owns no queue or worker beyond the canonical Rust edge. + and owns no queue or worker beyond the Rust edge. """ def __init__( @@ -346,7 +346,9 @@ def _decode(self, result: _SignalRead) -> SignalReadResult[_PayloadT]: __all__ = [ + "AudioBatch", "AudioBatchReadResult", + "AudioFrame", "AudioStream", "ClockDomainDescriptor", "SignalStream", diff --git a/python/pocketstation_examples/__init__.py b/python/pocketstation_examples/__init__.py new file mode 100644 index 0000000..b3ed6c0 --- /dev/null +++ b/python/pocketstation_examples/__init__.py @@ -0,0 +1,7 @@ +"""Example-owned provider integrations over the installed PocketStation SDK.""" + +from .demo import main +from .faster_whisper import FasterWhisper, FasterWhisperConfiguration +from .transcript import TRANSCRIPT_SIGNAL + +__all__ = ["TRANSCRIPT_SIGNAL", "FasterWhisper", "FasterWhisperConfiguration", "main"] diff --git a/examples/transcription/audio_windows.py b/python/pocketstation_examples/audio_windows.py similarity index 57% rename from examples/transcription/audio_windows.py rename to python/pocketstation_examples/audio_windows.py index 21e539a..156dcab 100644 --- a/examples/transcription/audio_windows.py +++ b/python/pocketstation_examples/audio_windows.py @@ -1,4 +1,4 @@ -"""Finite source-aware PCM windows shared by transcription examples.""" +"""Finite source-aware PCM windows for the example transcription provider.""" from __future__ import annotations @@ -6,18 +6,29 @@ from array import array from dataclasses import dataclass, field -import pocketstation +from pocketstation.signal import SignalAudioPayload, SignalEnvelope @dataclass(slots=True) class AudioWindow: sample_rate_hz: int channel_count: int + session_id: int source_id: int stream_id: int + clock_id: int + source_generation: int + policy_epoch: int sequence_start: int sequence_end: int discontinuity_epoch: int + timestamp_start_ns: int + timestamp_end_ns: int + source_timestamp_start_ns: int | None + source_timestamp_end_ns: int | None + session_timestamp_start_ns: int | None + session_timestamp_end_ns: int | None + discontinuity_reasons: tuple[str, ...] = () samples: array[float] = field(default_factory=lambda: array("f")) @property @@ -37,10 +48,10 @@ def __init__(self, *, window_seconds: float, maximum_sources: int) -> None: def push( self, - envelope: pocketstation.SignalEnvelope[object], + envelope: SignalEnvelope[object], ) -> tuple[AudioWindow, ...]: payload = envelope.payload - if not isinstance(payload, pocketstation.SignalAudioPayload): + if not isinstance(payload, SignalAudioPayload): raise TypeError("transcription accepts only PCM audio signals") lineage = envelope.lineage if lineage is None: @@ -48,13 +59,24 @@ def push( key = (payload.source_id, payload.stream_id) window = self._windows.get(key) - incompatible = window is not None and ( - window.sample_rate_hz != payload.sample_rate_hz - or window.channel_count != payload.channel_count - or window.discontinuity_epoch != lineage.discontinuity_epoch - ) + reasons: list[str] = [] + if window is not None: + if window.sample_rate_hz != payload.sample_rate_hz: + reasons.append("sample-rate-change") + if window.channel_count != payload.channel_count: + reasons.append("channel-count-change") + if window.clock_id != lineage.clock_id: + reasons.append("clock-change") + if window.source_generation != lineage.source_generation: + reasons.append("source-generation-change") + if window.policy_epoch != lineage.policy_epoch: + reasons.append("policy-epoch-change") + if window.discontinuity_epoch != lineage.discontinuity_epoch: + reasons.append("discontinuity-epoch-change") + if payload.sequence_number != window.sequence_end + 1: + reasons.append("sequence-gap") completed: list[AudioWindow] = [] - if incompatible and window is not None: + if reasons and window is not None: if window.samples: completed.append(window) del self._windows[key] @@ -65,11 +87,22 @@ def push( window = AudioWindow( sample_rate_hz=payload.sample_rate_hz, channel_count=payload.channel_count, + session_id=lineage.session_id, source_id=payload.source_id, stream_id=payload.stream_id, + clock_id=lineage.clock_id, + source_generation=lineage.source_generation, + policy_epoch=lineage.policy_epoch, sequence_start=payload.sequence_number, sequence_end=payload.sequence_number, discontinuity_epoch=lineage.discontinuity_epoch, + timestamp_start_ns=payload.timestamp_ns, + timestamp_end_ns=payload.timestamp_ns, + source_timestamp_start_ns=envelope.timing.source_timestamp_ns, + source_timestamp_end_ns=envelope.timing.source_timestamp_ns, + session_timestamp_start_ns=envelope.timing.session_timestamp_ns, + session_timestamp_end_ns=envelope.timing.session_timestamp_ns, + discontinuity_reasons=tuple(reasons), ) self._windows[key] = window @@ -81,25 +114,30 @@ def push( raise ValueError("audio payload size does not match sample_count") window.samples.extend(samples) window.sequence_end = payload.sequence_number + duration_ns = envelope.timing.duration_ns or round( + payload.sample_count + / (payload.sample_rate_hz * payload.channel_count) + * 1_000_000_000 + ) + window.timestamp_end_ns = payload.timestamp_ns + duration_ns + if envelope.timing.source_timestamp_ns is not None: + window.source_timestamp_end_ns = ( + envelope.timing.source_timestamp_ns + duration_ns + ) + if envelope.timing.session_timestamp_ns is not None: + window.session_timestamp_end_ns = ( + envelope.timing.session_timestamp_ns + duration_ns + ) target_samples = int( window.sample_rate_hz * window.channel_count * self._window_seconds ) - while len(window.samples) >= target_samples: - completed.append( - AudioWindow( - sample_rate_hz=window.sample_rate_hz, - channel_count=window.channel_count, - source_id=window.source_id, - stream_id=window.stream_id, - sequence_start=window.sequence_start, - sequence_end=window.sequence_end, - discontinuity_epoch=window.discontinuity_epoch, - samples=array("f", window.samples[:target_samples]), - ) - ) - del window.samples[:target_samples] - window.sequence_start = payload.sequence_number + # Keep complete input frames together. A window may exceed the target + # by at most one declared input frame, which preserves exact sequence + # and timing ranges instead of assigning one frame to two windows. + if len(window.samples) >= target_samples: + completed.append(window) + del self._windows[key] return tuple(completed) def flush(self) -> tuple[AudioWindow, ...]: diff --git a/python/pocketstation_examples/demo.py b/python/pocketstation_examples/demo.py new file mode 100644 index 0000000..d1009ea --- /dev/null +++ b/python/pocketstation_examples/demo.py @@ -0,0 +1,50 @@ +"""Run the installed application-and-microphone product demo.""" + +import asyncio +import json +import os +import webbrowser + +import pocketstation.aio as pks + +from .faster_whisper import FasterWhisper + +DEMO_CONTROL_PLANE_URL = "https://pocketstation-api.fly.dev" +DEMO_RELAY_URL = "https://pocketstation-relay.fly.dev" + + +async def run_demo() -> None: + """Inspect both sides of a live voice application without mixing them.""" + application = input("Desktop application name, PID, or bundle ID: ") + control_plane_url = os.getenv("POCKETSTATION_CONTROL_URL", DEMO_CONTROL_PLANE_URL) + relay_url = os.getenv("POCKETSTATION_RELAY_URL", DEMO_RELAY_URL) + if control_plane_url == DEMO_CONTROL_PLANE_URL: + print("Using the limited shared demo; HTTP 429 means it is busy.") + remote = await pks.RelaySession.create( + control_plane_url=control_plane_url, + relay_url=relay_url, + ) + live = pks.capture( + application=application, record_to="recordings", stream_audio=False + ) + publisher = remote.publisher(live.session) + for bus, stem in zip(("application", "microphone"), live.stems, strict=True): + stem.publish(publisher, bus) + transcripts = FasterWhisper().attach_many(live.session, live.stems) + async with remote, live: + invitation = await remote.wait_for_publisher_and_invitation(timeout_seconds=30) + print(f"Listen live: {invitation.join_url}", flush=True) + webbrowser.open(invitation.join_url) + await remote.wait_for_receiver(timeout_seconds=30) + async for event in live.signals(transcripts): + transcript = json.loads(event.payload) + print(f"source {transcript['source_id']}: {transcript['text']}", flush=True) + + +def main() -> None: + """Run the installed demo until capture ends or the user interrupts it.""" + asyncio.run(run_demo()) + + +if __name__ == "__main__": + main() diff --git a/python/pocketstation_examples/faster_whisper.py b/python/pocketstation_examples/faster_whisper.py new file mode 100644 index 0000000..2b34254 --- /dev/null +++ b/python/pocketstation_examples/faster_whisper.py @@ -0,0 +1,445 @@ +"""Transcribe source-aware example audio with faster-whisper.""" + +from __future__ import annotations + +import asyncio +import importlib +import json +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from time import monotonic_ns +from typing import Any, Protocol, cast + +from pocketstation.aio.operator_authoring import ( + OperatorDeadlines as AsyncOperatorDeadlines, +) +from pocketstation.aio.operator_authoring import OperatorNode as AsyncOperatorNode +from pocketstation.aio.operator_authoring import ( + OperatorProvider as AsyncOperatorProvider, +) +from pocketstation.aio.session import Session as AsyncSession +from pocketstation.graph import ( + DerivedStream, + Multiplicity, + PortSpec, + SignalSpec, + SourceOutput, + Stem, +) +from pocketstation.operator_authoring import ( + OperatorEmission, + OperatorManifest, + OperatorNode, + OperatorProvider, +) +from pocketstation.signal import BusSubscription, SignalEnvelope + +from .audio_windows import ( + AudioWindow, + AudioWindowBuffer, + mono_16khz, +) +from .transcript import TRANSCRIPT_SIGNAL + + +class WhisperSegment(Protocol): + start: float + end: float + text: str + + +class WhisperInfo(Protocol): + language: str + language_probability: float + + +class WhisperModel(Protocol): + def transcribe( + self, + audio: object, + *, + beam_size: int, + language: str | None, + vad_filter: bool, + ) -> tuple[Iterable[WhisperSegment], WhisperInfo]: ... + + +@dataclass(frozen=True, slots=True) +class FasterWhisperConfiguration: + """Finite model and buffering policy for one local transcription Operator.""" + + model: str = "base" + model_revision: str | None = None + device: str = "auto" + compute_type: str = "default" + cpu_threads: int = 4 + num_workers: int = 1 + allow_model_download: bool = True + language: str | None = None + beam_size: int = 5 + vad_filter: bool = True + window_seconds: float = 5.0 + queue_capacity_signals: int = 512 + maximum_sources: int = 8 + maximum_output_bytes: int = 1_048_576 + create_timeout_s: float = 120.0 + inference_timeout_s: float = 120.0 + + def __post_init__(self) -> None: + for name, value in ( + ("model", self.model), + ("device", self.device), + ("compute_type", self.compute_type), + ): + if not value.strip(): + raise ValueError(f"{name} must not be empty") + if self.language is not None and ( + not self.language or not self.language.isascii() + ): + raise ValueError("language must be None or non-empty ASCII") + if self.model_revision is not None and ( + not self.model_revision.strip() or not self.model_revision.isascii() + ): + raise ValueError("model_revision must be None or non-empty ASCII") + if not 1 <= self.cpu_threads <= 64: + raise ValueError("cpu_threads must be between 1 and 64") + if not 1 <= self.num_workers <= 16: + raise ValueError("num_workers must be between 1 and 16") + if not 1 <= self.beam_size <= 32: + raise ValueError("beam_size must be between 1 and 32") + if not 0.1 <= self.window_seconds <= 30: + raise ValueError("window_seconds must be between 0.1 and 30") + if not 8 <= self.queue_capacity_signals <= 4_096: + raise ValueError("queue_capacity_signals must be between 8 and 4096") + if not 1 <= self.maximum_sources <= 64: + raise ValueError("maximum_sources must be between 1 and 64") + if not 1_024 <= self.maximum_output_bytes <= 16_777_216: + raise ValueError("maximum_output_bytes must be between 1024 and 16777216") + if not 1 <= self.create_timeout_s <= 600: + raise ValueError("create_timeout_s must be between 1 and 600") + if not 1 <= self.inference_timeout_s <= 600: + raise ValueError("inference_timeout_s must be between 1 and 600") + + +ModelFactory = Callable[[FasterWhisperConfiguration], WhisperModel] +AudioConverter = Callable[[AudioWindow], object] + + +class _FasterWhisperNode(AsyncOperatorNode): + def __init__( + self, + configuration: FasterWhisperConfiguration, + model: WhisperModel, + audio_converter: AudioConverter, + ) -> None: + self._configuration = configuration + self._model = model + self._audio_converter = audio_converter + self._windows = AudioWindowBuffer( + window_seconds=configuration.window_seconds, + maximum_sources=configuration.maximum_sources, + ) + self._cancelled = False + + async def process( + self, + input_port: str, + envelope: SignalEnvelope[object], + ) -> tuple[OperatorEmission, ...]: + if input_port != "audio": + raise ValueError(f"unexpected input port: {input_port}") + if self._cancelled: + raise asyncio.CancelledError + emissions = [] + for window in self._windows.push(envelope): + emissions.append(await self._transcribe(window)) + return tuple(emissions) + + async def flush(self) -> tuple[OperatorEmission, ...]: + if self._cancelled: + self._windows.clear() + return () + emissions = [] + for window in self._windows.flush(): + emissions.append(await self._transcribe(window)) + return tuple(emissions) + + async def cancel(self) -> None: + self._cancelled = True + self._windows.clear() + + async def close(self) -> None: + await self.cancel() + + async def _transcribe( + self, + window: AudioWindow, + ) -> OperatorEmission: + return await asyncio.wait_for( + asyncio.to_thread( + _transcribe_window, + self._configuration, + self._model, + self._audio_converter, + window, + ), + timeout=self._configuration.inference_timeout_s, + ) + + +class _SyncFasterWhisperNode(OperatorNode): + """Blocking model call hosted by Core's off-realtime Operator worker.""" + + def __init__( + self, + configuration: FasterWhisperConfiguration, + model: WhisperModel, + audio_converter: AudioConverter, + ) -> None: + self._configuration = configuration + self._model = model + self._audio_converter = audio_converter + self._windows = AudioWindowBuffer( + window_seconds=configuration.window_seconds, + maximum_sources=configuration.maximum_sources, + ) + self._cancelled = False + + def process( + self, + input_port: str, + envelope: SignalEnvelope[object], + ) -> tuple[OperatorEmission, ...]: + if input_port != "audio": + raise ValueError(f"unexpected input port: {input_port}") + if self._cancelled: + raise RuntimeError("transcription Operator is cancelled") + return tuple( + _transcribe_window( + self._configuration, + self._model, + self._audio_converter, + window, + ) + for window in self._windows.push(envelope) + ) + + def flush(self) -> tuple[OperatorEmission, ...]: + if self._cancelled: + self._windows.clear() + return () + return tuple( + _transcribe_window( + self._configuration, + self._model, + self._audio_converter, + window, + ) + for window in self._windows.flush() + ) + + def cancel(self) -> None: + self._cancelled = True + self._windows.clear() + + def close(self) -> None: + self.cancel() + + +def _transcribe_window( + configuration: FasterWhisperConfiguration, + model: WhisperModel, + audio_converter: AudioConverter, + window: AudioWindow, +) -> OperatorEmission: + inference_started_ns = monotonic_ns() + samples = audio_converter(window) + segments, info = model.transcribe( + samples, + beam_size=configuration.beam_size, + language=configuration.language, + vad_filter=configuration.vad_filter, + ) + completed = tuple(segments) + result = { + "channel_count": window.channel_count, + "clock_id": window.clock_id, + "discontinuity_epoch": window.discontinuity_epoch, + "discontinuity_reasons": list(window.discontinuity_reasons), + "duration_ms": window.duration_ms, + "inference_duration_ns": monotonic_ns() - inference_started_ns, + "language": info.language, + "language_probability": info.language_probability, + "policy_epoch": window.policy_epoch, + "sample_rate_hz": window.sample_rate_hz, + "session_id": window.session_id, + "session_timestamp_end_ns": window.session_timestamp_end_ns, + "session_timestamp_start_ns": window.session_timestamp_start_ns, + "segments": [ + { + "end_s": segment.end, + "start_s": segment.start, + "text": segment.text.strip(), + } + for segment in completed + ], + "sequence_end": window.sequence_end, + "sequence_start": window.sequence_start, + "source_id": window.source_id, + "source_generation": window.source_generation, + "source_timestamp_end_ns": window.source_timestamp_end_ns, + "source_timestamp_start_ns": window.source_timestamp_start_ns, + "stream_id": window.stream_id, + "text": " ".join(segment.text.strip() for segment in completed).strip(), + "timestamp_end_ns": window.timestamp_end_ns, + "timestamp_start_ns": window.timestamp_start_ns, + } + encoded = json.dumps(result, separators=(",", ":"), sort_keys=True) + if len(encoded.encode()) > configuration.maximum_output_bytes: + raise RuntimeError("transcript envelope exceeds maximum_output_bytes") + return OperatorEmission.text(encoded, signal=TRANSCRIPT_SIGNAL) + + +class _SyncFasterWhisperFactory: + def __init__( + self, + configuration: FasterWhisperConfiguration, + model_factory: ModelFactory, + audio_converter: AudioConverter, + ) -> None: + self._configuration = configuration + self._model_factory = model_factory + self._audio_converter = audio_converter + + def create(self, _configuration: Mapping[str, str]) -> _SyncFasterWhisperNode: + return _SyncFasterWhisperNode( + self._configuration, + self._model_factory(self._configuration), + self._audio_converter, + ) + + +class FasterWhisper: + """Optional faster-whisper integration over Core's Operator runtime.""" + + def __init__( + self, + configuration: FasterWhisperConfiguration | None = None, + *, + model_factory: ModelFactory | None = None, + _audio_converter: AudioConverter | None = None, + ) -> None: + self.configuration = configuration or FasterWhisperConfiguration() + self._model_factory = model_factory or _load_model + self._audio_converter = _audio_converter or _numpy_audio + self.manifest = OperatorManifest( + "community.faster-whisper.stt.v1", + inputs=( + PortSpec.input( + "audio", + SignalSpec.audio(), + multiplicity=Multiplicity.MANY, + ), + ), + outputs=(PortSpec.output("transcript", TRANSCRIPT_SIGNAL),), + queue_capacity_signals=self.configuration.queue_capacity_signals, + process_timeout_ms=round( + (self.configuration.inference_timeout_s + 1) * 1_000 + ), + network_allowed=self.configuration.allow_model_download, + filesystem_allowed=True, + terminal_roles=("transcript.final",), + ) + + def sync_provider(self) -> OperatorProvider: + """Use Core's off-realtime worker directly from sync or asyncio Sessions.""" + return OperatorProvider.with_node( + self.manifest, + _SyncFasterWhisperFactory( + self.configuration, + self._model_factory, + self._audio_converter, + ), + ) + + def provider(self) -> AsyncOperatorProvider: + """Use an asyncio node while keeping native inference off the event loop.""" + + async def create(_configuration: Mapping[str, str]) -> _FasterWhisperNode: + model = await asyncio.to_thread(self._model_factory, self.configuration) + return _FasterWhisperNode( + self.configuration, + model, + self._audio_converter, + ) + + return AsyncOperatorProvider.with_node( + self.manifest, + create, + deadlines=AsyncOperatorDeadlines( + create_s=self.configuration.create_timeout_s, + prepare_s=5, + process_s=self.configuration.inference_timeout_s + 0.5, + close_s=5, + ), + ) + + def attach( + self, + session: AsyncSession, + stream: Stem | SourceOutput | DerivedStream, + ) -> BusSubscription[str]: + """Attach transcription to any Session-owned PCM stream in two lines.""" + operator = session.register_operator(self.sync_provider()).declare() + stream.connect(operator.input("audio")) + return session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + + def attach_many( + self, + session: AsyncSession, + streams: Iterable[Stem | SourceOutput | DerivedStream], + ) -> BusSubscription[str]: + """Share one model across finite source-aware Session inputs.""" + operator = session.register_operator(self.sync_provider()).declare() + input_port = operator.input("audio") + attached = 0 + for stream in streams: + stream.connect(input_port) + attached += 1 + if attached == 0: + raise ValueError("transcription requires at least one input stream") + return session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + + +def _load_model(configuration: FasterWhisperConfiguration) -> WhisperModel: + try: + module = importlib.import_module("faster_whisper") + except ModuleNotFoundError as error: + raise RuntimeError( + "install PocketStation with the transcription extra: " + "pip install 'pocketstation[transcription]'" + ) from error + model: Any = module.WhisperModel( + configuration.model, + device=configuration.device, + compute_type=configuration.compute_type, + cpu_threads=configuration.cpu_threads, + num_workers=configuration.num_workers, + local_files_only=not configuration.allow_model_download, + revision=configuration.model_revision, + ) + return cast(WhisperModel, model) + + +def _numpy_audio(window: AudioWindow) -> object: + numpy = importlib.import_module("numpy") + return numpy.asarray(mono_16khz(window), dtype="float32") + + +__all__ = ["TRANSCRIPT_SIGNAL", "FasterWhisper", "FasterWhisperConfiguration"] diff --git a/python/pocketstation_examples/transcript.py b/python/pocketstation_examples/transcript.py new file mode 100644 index 0000000..0640afc --- /dev/null +++ b/python/pocketstation_examples/transcript.py @@ -0,0 +1,11 @@ +"""Typed transcript signal shared by the example transcription provider.""" + +from pocketstation.graph import SignalSpec, TextFormat + +TRANSCRIPT_SIGNAL = SignalSpec.text( + TextFormat.JSON, + role="transcript.final", + schema="io.pocketstation.transcript.batch.v1", +) + +__all__ = ["TRANSCRIPT_SIGNAL"] diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index dcf1951..00b50d6 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -9,7 +9,7 @@ from threading import Event, Thread from time import sleep -import pocketstation +import pocketstation._api as pocketstation class InstalledSource(pocketstation.SourceDriver): diff --git a/tests/qualification/runtime_resources.py b/tests/qualification/runtime_resources.py index a17c9ae..c029552 100644 --- a/tests/qualification/runtime_resources.py +++ b/tests/qualification/runtime_resources.py @@ -1,4 +1,9 @@ -"""Measure Python boundary cost and prove bounded slow-consumer behavior.""" +"""Measure Python boundary cost and prove bounded slow-consumer behavior. + +The copy counters describe the audited native implementation: Core samples are +copied into an owned Rust byte vector, then into Python-owned bytes. The +returned memoryview adds no third PCM copy. +""" from __future__ import annotations @@ -28,6 +33,9 @@ class BoundaryResult: mode: str frames_total: int samples_per_frame: int + audited_native_to_owned_copies_per_frame: int + audited_owned_to_python_copies_per_frame: int + audited_total_pcm_copies_per_frame: int wall_time_ns: int process_cpu_time_ns: int frame_latency_p50_ns: int @@ -153,6 +161,9 @@ def qualify_sync(frames_total: int, samples_per_frame: int) -> BoundaryResult: mode="sync", frames_total=frames_total, samples_per_frame=samples_per_frame, + audited_native_to_owned_copies_per_frame=1, + audited_owned_to_python_copies_per_frame=1, + audited_total_pcm_copies_per_frame=2, wall_time_ns=wall_time_ns, process_cpu_time_ns=process_cpu_time_ns, frame_latency_p50_ns=_percentile(latencies_ns, 50), @@ -224,6 +235,9 @@ async def qualify_async( mode="asyncio", frames_total=frames_total, samples_per_frame=samples_per_frame, + audited_native_to_owned_copies_per_frame=1, + audited_owned_to_python_copies_per_frame=1, + audited_total_pcm_copies_per_frame=2, wall_time_ns=wall_time_ns, process_cpu_time_ns=process_cpu_time_ns, frame_latency_p50_ns=_percentile(latencies_ns, 50), diff --git a/tests/qualification/typing_contract.py b/tests/qualification/typing_contract.py index 2c1a98e..81e1123 100644 --- a/tests/qualification/typing_contract.py +++ b/tests/qualification/typing_contract.py @@ -2,41 +2,50 @@ from typing import assert_type -import pocketstation +from pocketstation.aio.connector import Connector as AsyncConnector +from pocketstation.connector import Connector +from pocketstation.graph import SignalSpec, SourceOutput +from pocketstation.identity import ( + ClockDomainId, + ConnectorId, + EndpointId, + RouteId, + RuntimeSessionId, + SourceId, + StemId, + StreamId, +) +from pocketstation.session import RunningSession, Session +from pocketstation.signal import BusSubscription, SignalAudioPayload +from pocketstation.streams import AudioFrame def verify_signal_types( - session: pocketstation.Session, - source: pocketstation.SourceOutput, - connector: pocketstation.Connector, - async_connector: pocketstation.aio.Connector, + session: Session, + source: SourceOutput, + connector: Connector, + async_connector: AsyncConnector, ) -> None: - audio_spec = pocketstation.SignalSpec.audio() - text_spec = pocketstation.SignalSpec.text() - assert_type(audio_spec, pocketstation.SignalSpec[pocketstation.SignalAudioPayload]) - assert_type(text_spec, pocketstation.SignalSpec[str]) + audio_spec = SignalSpec.audio() + text_spec = SignalSpec.text() + assert_type(audio_spec, SignalSpec[SignalAudioPayload]) + assert_type(text_spec, SignalSpec[str]) audio_subscription = session.subscribe(source, signal=audio_spec) text_subscription = session.subscribe(source, signal=text_spec) - assert_type( - audio_subscription, - pocketstation.BusSubscription[pocketstation.SignalAudioPayload], - ) - assert_type(text_subscription, pocketstation.BusSubscription[str]) - assert_type(source.send_to(connector), pocketstation.RouteId) - assert_type(source.send_to(async_connector), pocketstation.RouteId) + assert_type(audio_subscription, BusSubscription[SignalAudioPayload]) + assert_type(text_subscription, BusSubscription[str]) + assert_type(source.send_to(connector), RouteId) + assert_type(source.send_to(async_connector), RouteId) -def verify_runtime_identities( - running: pocketstation.RunningSession, - frame: pocketstation.AudioFrame, -) -> None: - assert_type(running.session_id, pocketstation.RuntimeSessionId) - assert_type(frame.session_id, pocketstation.RuntimeSessionId) - assert_type(frame.stream_id, pocketstation.StreamId) - assert_type(frame.source_id, pocketstation.SourceId) - assert_type(frame.stem_id, pocketstation.StemId) - assert_type(frame.clock_id, pocketstation.ClockDomainId) - assert_type(frame.endpoint_id, pocketstation.EndpointId) - assert_type(frame.connector_id, pocketstation.ConnectorId | None) - assert_type(frame.route_id, pocketstation.RouteId) +def verify_runtime_identities(running: RunningSession, frame: AudioFrame) -> None: + assert_type(running.session_id, RuntimeSessionId) + assert_type(frame.session_id, RuntimeSessionId) + assert_type(frame.stream_id, StreamId) + assert_type(frame.source_id, SourceId) + assert_type(frame.stem_id, StemId) + assert_type(frame.clock_id, ClockDomainId) + assert_type(frame.endpoint_id, EndpointId) + assert_type(frame.connector_id, ConnectorId | None) + assert_type(frame.route_id, RouteId) diff --git a/tests/run_artifact_consumer.py b/tests/run_artifact_consumer.py index 1accb48..b0b99a3 100644 --- a/tests/run_artifact_consumer.py +++ b/tests/run_artifact_consumer.py @@ -69,6 +69,36 @@ def main() -> int: check=True, timeout=60, ) + demo = ( + environment / "Scripts" / "pocketstation-demo.exe" + if os.name == "nt" + else environment / "bin" / "pocketstation-demo" + ) + if not demo.is_file(): + raise SystemExit( + "installed artifact contains no pocketstation-demo command" + ) + qualification = root / "runtime_resources.py" + report = root / "runtime-qualification.json" + shutil.copyfile( + REPOSITORY / "tests" / "qualification" / "runtime_resources.py", + qualification, + ) + subprocess.run( + [ + os.fspath(interpreter), + os.fspath(qualification), + "--frames", + "500", + "--output", + os.fspath(report), + ], + cwd=root, + env=process_environment, + check=True, + timeout=180, + ) + print(report.read_text()) return 0 diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py index af2a7dd..e5a2ce8 100644 --- a/tests/run_installed_stream_conformance.py +++ b/tests/run_installed_stream_conformance.py @@ -33,7 +33,6 @@ "tests/test_operator_authoring.py::test_async_operator_pcm_uses_the_same_core_reentry", "tests/test_connector.py::test_connector_worker_receives_finite_native_owned_batches", "tests/test_aio_session.py::test_async_connector_worker_receives_finite_native_batches", - "tests/test_audio_transport_example.py::test_call_audio_template_uses_core_source_and_connector", "tests/test_transcription_example.py::test_faster_whisper_is_the_concise_source_aware_python_path", ) diff --git a/tests/run_installed_transcription_cancellation.py b/tests/run_installed_transcription_cancellation.py new file mode 100644 index 0000000..4e391b8 --- /dev/null +++ b/tests/run_installed_transcription_cancellation.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Prove bounded Session cancellation while real transcription is in flight.""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import json +import sys +from array import array +from collections.abc import Iterator +from pathlib import Path +from threading import Event, Lock +from time import monotonic_ns +from typing import Any, cast + +import pocketstation._api as pocketstation +import pocketstation.aio as pks_aio + +# Resolve PocketStation from the isolated wheel before qualification support. +SDK_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SDK_ROOT)) + +from pocketstation_examples import ( # noqa: E402 + FasterWhisper, + FasterWhisperConfiguration, +) +from pocketstation_examples.faster_whisper import ( # noqa: E402 + WhisperInfo, + WhisperSegment, +) + +from tests.transcription.wav_input import read_pcm16_wav # noqa: E402 + + +class _ObservedRealModel: + """Expose an exact cancellation boundary around one real model iterator.""" + + def __init__(self, model: Any, entered: Event, release: Event) -> None: + self._model = model + self._entered = entered + self._release = release + self._lock = Lock() + self._transcript = "" + self.completed = Event() + + @property + def transcript(self) -> str: + with self._lock: + return self._transcript + + def transcribe( + self, + audio: object, + *, + beam_size: int, + language: str | None, + vad_filter: bool, + ) -> tuple[Iterator[WhisperSegment], WhisperInfo]: + segments, info = self._model.transcribe( + audio, + beam_size=beam_size, + language=language, + vad_filter=vad_filter, + ) + + def observed_segments() -> Iterator[WhisperSegment]: + self._entered.set() + if not self._release.wait(timeout=10): + raise TimeoutError("cancellation test did not release real inference") + completed = tuple(segments) + with self._lock: + self._transcript = " ".join( + str(segment.text).strip() for segment in completed + ).strip() + self.completed.set() + yield from completed + + return observed_segments(), cast(WhisperInfo, info) + + +def _model_factory( + configuration: FasterWhisperConfiguration, + entered: Event, + release: Event, +) -> _ObservedRealModel: + faster_whisper = importlib.import_module("faster_whisper") + model: Any = faster_whisper.WhisperModel( + configuration.model, + device=configuration.device, + compute_type=configuration.compute_type, + cpu_threads=configuration.cpu_threads, + num_workers=configuration.num_workers, + local_files_only=not configuration.allow_model_download, + revision=configuration.model_revision, + ) + return _ObservedRealModel(model, entered, release) + + +async def _run(arguments: argparse.Namespace) -> dict[str, object]: + package_path = Path(pocketstation.__file__).resolve() + if "site-packages" not in package_path.parts: + raise RuntimeError( + f"PocketStation is not loaded from an installed wheel: {package_path}" + ) + + source = read_pcm16_wav(arguments.wav) + if source.sample_rate_hz != 48_000 or source.channels != 1: + raise ValueError("cancellation fixture must be 48 kHz mono PCM") + + entered = Event() + release = Event() + observed_model: _ObservedRealModel | None = None + configuration = FasterWhisperConfiguration( + model=str(arguments.model), + device="cpu", + compute_type="int8", + cpu_threads=4, + num_workers=1, + allow_model_download=False, + language="en", + beam_size=1, + window_seconds=2, + queue_capacity_signals=512, + maximum_sources=1, + inference_timeout_s=60, + ) + + def create_model(config: FasterWhisperConfiguration) -> _ObservedRealModel: + nonlocal observed_model + observed_model = _model_factory(config, entered, release) + return observed_model + + session = pks_aio.Session(recording_root=arguments.recording) + audio = session.audio_input( + "application", + sample_rate_hz=source.sample_rate_hz, + channels=source.channels, + capacity_frames=63, + frame_samples_per_channel=source.frame_samples_per_channel, + ) + transcriber = FasterWhisper(configuration, model_factory=create_model) + transcriber.attach(session, audio.output) + audio.output.record("application") + + running = await session.start() + cancel_task: asyncio.Task[pocketstation.StopResult] | None = None + try: + frame_values = source.frame_samples_per_channel * source.channels + required_values = round(configuration.window_seconds * source.sample_rate_hz) + samples = source.samples[:required_values] + for offset in range(0, len(samples), frame_values): + frame = array("f", samples[offset : offset + frame_values]) + if len(frame) < frame_values: + frame.extend([0.0] * (frame_values - len(frame))) + await audio.write(frame, timeout_s=2) + await asyncio.sleep(0.001) + + inference_entered = await asyncio.to_thread(entered.wait, 15) + if not inference_entered: + raise TimeoutError("real faster-whisper inference did not begin") + + cancel_started_ns = monotonic_ns() + cancel_task = asyncio.create_task(running.cancel()) + await asyncio.sleep(0.05) + cancellation_waited_for_inflight_inference = not cancel_task.done() + release.set() + outcome = await asyncio.wait_for(cancel_task, timeout=65) + cancel_completed_ns = monotonic_ns() + finally: + release.set() + if cancel_task is not None and not cancel_task.done(): + await cancel_task + if not running.is_stopped: + await running.cancel() + + if observed_model is None or not observed_model.completed.is_set(): + raise RuntimeError("the exact real model call did not reach a bounded boundary") + if not observed_model.transcript: + raise RuntimeError("the real model did not produce transcript text") + if outcome.disposition is not pocketstation.TerminationDisposition.CANCELLED: + raise RuntimeError( + f"unexpected cancellation disposition: {outcome.disposition}" + ) + if not cancellation_waited_for_inflight_inference: + raise RuntimeError("cancellation did not overlap the in-flight model call") + if outcome.recording is None: + raise RuntimeError("Session cancellation did not finalize recording") + observations = await audio.observations() + if not observations.cancelled: + raise RuntimeError( + "Core did not mark the application-owned PCM source cancelled" + ) + + return { + "schema_version": 1, + "classification": "LOOPBACK-ONLY", + "installed_package": str(package_path), + "real_inference": True, + "real_transcript": observed_model.transcript, + "inference_in_flight_when_cancel_requested": True, + "native_inference_preempted": False, + "cancellation_duration_ns": cancel_completed_ns - cancel_started_ns, + "termination_disposition": outcome.disposition.value, + "session_success": outcome.success, + "operator_finalization_failures_total": ( + outcome.operator_finalization_failures_total + ), + "runtime_failures_total": outcome.runtime_failures_total, + "source_cancelled": observations.cancelled, + "recording_state": outcome.recording.state.value, + "recording_complete": outcome.recording.complete, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--wav", type=Path, required=True) + parser.add_argument("--recording", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args() + result = asyncio.run(_run(arguments)) + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_relay_e2e_publisher.py b/tests/run_relay_e2e_publisher.py index d00a538..754374e 100644 --- a/tests/run_relay_e2e_publisher.py +++ b/tests/run_relay_e2e_publisher.py @@ -7,12 +7,21 @@ import math import os import sys +import threading from array import array +from collections.abc import Iterator from pathlib import Path from time import sleep -from typing import Any +from typing import TYPE_CHECKING, Any, cast -import pocketstation as pks +import pocketstation._api as pks + +if TYPE_CHECKING: + from tests.transcription.wav_input import WavInput + +SDK_ROOT = Path(__file__).resolve().parents[1] +if str(SDK_ROOT) not in sys.path: + sys.path.insert(0, str(SDK_ROOT)) def emit(message_type: str, **fields: Any) -> None: @@ -45,7 +54,75 @@ def _write_application_inputs( sleep(0.01) +def _wav_frames(source: WavInput) -> Iterator[array[float]]: + frame_values = source.frame_samples_per_channel * source.channels + for offset in range(0, len(source.samples), frame_values): + frame = source.samples[offset : offset + frame_values] + if len(frame) < frame_values: + frame.extend([0.0] * (frame_values - len(frame))) + yield frame + + +def _write_fixture_frames( + application: pks.AudioInput, + microphone: pks.AudioInput, + application_frames: Iterator[array[float]], + microphone_frames: Iterator[array[float]], + *, + frame_duration_s: float, + maximum_frames: int | None = None, +) -> int: + written = 0 + application_open = True + microphone_open = True + while application_open or microphone_open: + application_frame = next(application_frames, None) + microphone_frame = next(microphone_frames, None) + application_open = application_frame is not None + microphone_open = microphone_frame is not None + if not application_open and not microphone_open: + break + if application_frame is not None: + application.write(application_frame, discontinuity=written == 0) + if microphone_frame is not None: + microphone.write(microphone_frame, discontinuity=written == 0) + written += 1 + sleep(frame_duration_s) + if maximum_frames is not None and written >= maximum_frames: + break + return written + + +def _collect_transcripts( + stream: pks.SignalStream[str], + received: list[dict[str, object]], + failures: list[BaseException], +) -> None: + try: + while True: + envelope = stream.read(timeout_s=1.0) + if envelope is None: + continue + if isinstance(envelope, pks.EndOfStream): + return + value = json.loads(str(envelope.payload)) + if not isinstance(value, dict): + raise RuntimeError("transcript payload must be a JSON object") + if value.get("text"): + received.append(value) + except BaseException as error: + failures.append(error) + + def main() -> int: + from pocketstation_examples import ( + TRANSCRIPT_SIGNAL, + FasterWhisper, + FasterWhisperConfiguration, + ) + + from tests.transcription.wav_input import read_pcm16_wav + parser = argparse.ArgumentParser() parser.add_argument("--control-plane-url", required=True) parser.add_argument("--relay-url", required=True) @@ -57,8 +134,26 @@ def main() -> int: arguments = parser.parse_args() if arguments.active_seconds <= 0: parser.error("--active-seconds must be positive") + transcription_model = os.environ.get("PKS_E2E_TRANSCRIPTION_MODEL") + transcription_application_wav = os.environ.get( + "PKS_E2E_TRANSCRIPTION_APPLICATION_WAV" + ) + transcription_microphone_wav = os.environ.get( + "PKS_E2E_TRANSCRIPTION_MICROPHONE_WAV" + ) + transcription_values = ( + transcription_model, + transcription_application_wav, + transcription_microphone_wav, + ) + if any(transcription_values) and not all(transcription_values): + parser.error( + "transcription E2E requires model, application WAV, and microphone WAV" + ) + use_transcription = transcription_model is not None use_application_audio_inputs = ( os.environ.get("PKS_E2E_APPLICATION_AUDIO_INPUT", "") == "1" + or use_transcription ) if ( arguments.application_name is None @@ -78,6 +173,10 @@ def main() -> int: ) application_audio: pks.AudioInput | None = None microphone_audio: pks.AudioInput | None = None + application_fixture: WavInput | None = None + microphone_fixture: WavInput | None = None + application_frames: Iterator[array[float]] | None = None + microphone_frames: Iterator[array[float]] | None = None application: pks.Stem | pks.SourceOutput microphone: pks.Stem | pks.SourceOutput if use_application_audio_inputs: @@ -89,9 +188,52 @@ def main() -> int: "application-owned PCM fixture cannot be combined with " "physical capture" ) - session = pks.Session(recording_root=arguments.recording_root) - application_audio = session.audio_input("application") - microphone_audio = session.audio_input("microphone") + if use_transcription: + assert transcription_application_wav is not None + assert transcription_microphone_wav is not None + application_fixture = read_pcm16_wav( + Path(transcription_application_wav) + ) + microphone_fixture = read_pcm16_wav(Path(transcription_microphone_wav)) + application_contract = ( + application_fixture.sample_rate_hz, + application_fixture.channels, + application_fixture.frame_samples_per_channel, + ) + microphone_contract = ( + microphone_fixture.sample_rate_hz, + microphone_fixture.channels, + microphone_fixture.frame_samples_per_channel, + ) + if application_contract != microphone_contract: + raise RuntimeError( + "transcription fixtures must share one Session media contract" + ) + session = pks.Session( + recording_root=arguments.recording_root, + sample_rate_hz=application_fixture.sample_rate_hz, + channels=application_fixture.channels, + ) + application_audio = session.audio_input( + "application", + capacity_frames=32, + frame_samples_per_channel=( + application_fixture.frame_samples_per_channel + ), + ) + microphone_audio = session.audio_input( + "microphone", + capacity_frames=32, + frame_samples_per_channel=( + microphone_fixture.frame_samples_per_channel + ), + ) + application_frames = _wav_frames(application_fixture) + microphone_frames = _wav_frames(microphone_fixture) + else: + session = pks.Session(recording_root=arguments.recording_root) + application_audio = session.audio_input("application") + microphone_audio = session.audio_input("microphone") application = application_audio.output microphone = microphone_audio.output source_mode = "conformance-fixture" @@ -141,26 +283,88 @@ def main() -> int: application.record("application") microphone.record("microphone") + transcript_subscription: pks.BusSubscription[str] | None = None + if use_transcription: + assert transcription_model is not None + transcription_window_seconds = float( + os.environ.get("PKS_E2E_TRANSCRIPTION_WINDOW_SECONDS", "2") + ) + transcription_queue_capacity = int( + os.environ.get("PKS_E2E_TRANSCRIPTION_QUEUE_CAPACITY", "1024") + ) + transcriber = FasterWhisper( + FasterWhisperConfiguration( + model=transcription_model, + device="cpu", + compute_type="int8", + cpu_threads=4, + allow_model_download=False, + beam_size=1, + window_seconds=transcription_window_seconds, + queue_capacity_signals=transcription_queue_capacity, + maximum_sources=2, + create_timeout_s=60, + inference_timeout_s=60, + ) + ) + operator = session.register_operator(transcriber.sync_provider()).declare() + operator_input = operator.input("audio") + application.connect(operator_input) + microphone.connect(operator_input) + transcript_subscription = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + running = session.start() + transcript_values: list[dict[str, object]] = [] + transcript_failures: list[BaseException] = [] + transcript_thread: threading.Thread | None = None + if transcript_subscription is not None: + transcript_thread = threading.Thread( + target=_collect_transcripts, + args=( + running.signals(transcript_subscription), + transcript_values, + transcript_failures, + ), + name="pocketstation-transcript-consumer", + daemon=False, + ) + transcript_thread.start() if application_audio is not None and microphone_audio is not None: # Relay declares publication readiness only after every named bus # has produced RTP. Prime a finite 100 ms per bus before waiting # for the invitation; one 10 ms PCM frame is not enough to form # the configured Opus packet. The remaining feed starts after the # browser is attached so its delivery is observable. - application_frame = _tone(440.0) - microphone_frame = _tone(660.0) - for index in range(10): - discontinuity = index == 0 - application_audio.write( - application_frame, - discontinuity=discontinuity, - ) - microphone_audio.write( - microphone_frame, - discontinuity=discontinuity, + if application_frames is not None and microphone_frames is not None: + assert application_fixture is not None + _write_fixture_frames( + application_audio, + microphone_audio, + application_frames, + microphone_frames, + frame_duration_s=( + application_fixture.frame_samples_per_channel + / application_fixture.sample_rate_hz + ), + maximum_frames=10, ) - sleep(0.01) + else: + application_frame = _tone(440.0) + microphone_frame = _tone(660.0) + for index in range(10): + discontinuity = index == 0 + application_audio.write( + application_frame, + discontinuity=discontinuity, + ) + microphone_audio.write( + microphone_frame, + discontinuity=discontinuity, + ) + sleep(0.01) invitation = remote.wait_for_publisher_and_invitation( timeout_seconds=15.0, poll_interval_seconds=0.05, @@ -183,20 +387,43 @@ def main() -> int: emit( "receiver-active", session_id=str(remote.session_id), - source_active=receiver.snapshot.source_active, + source_active=receiver.snapshot.ready, subscription_count=receiver.snapshot.subscription_count, ) if application_audio is not None and microphone_audio is not None: - _write_application_inputs( - application_audio, - microphone_audio, - active_seconds=arguments.active_seconds, - ) + if application_frames is not None and microphone_frames is not None: + assert application_fixture is not None + _write_fixture_frames( + application_audio, + microphone_audio, + application_frames, + microphone_frames, + frame_duration_s=( + application_fixture.frame_samples_per_channel + / application_fixture.sample_rate_hz + ), + ) + application_audio.close() + microphone_audio.close() + else: + _write_application_inputs( + application_audio, + microphone_audio, + active_seconds=arguments.active_seconds, + ) else: sleep(arguments.active_seconds) stop = running.stop() running = None + if transcript_thread is not None: + transcript_thread.join(timeout=5) + if transcript_thread.is_alive(): + raise RuntimeError("transcript consumer did not stop") + if transcript_failures: + raise RuntimeError( + f"transcript consumer failed: {transcript_failures[0]}" + ) recording = stop.recording relay_outcome_values = stop.relay_outcomes relay_outcomes = [ @@ -227,6 +454,19 @@ def main() -> int: for stem in recording_stem_values ] ) + transcript_source_ids = { + cast(int, value["source_id"]) + for value in transcript_values + if isinstance(value.get("source_id"), int) and value.get("text") + } + expected_transcript_source_ids = ( + set() + if application_audio is None or microphone_audio is None + else { + int(application_audio.source_id), + int(microphone_audio.source_id), + } + ) expected_buses = {"application", "microphone"} success = ( stop.success @@ -242,7 +482,19 @@ def main() -> int: and outcome.error is None for outcome in relay_outcome_values ) + and ( + not use_transcription + or transcript_source_ids == expected_transcript_source_ids + ) ) + if use_transcription: + emit( + "transcription", + sources=sorted(transcript_source_ids), + expected_sources=sorted(expected_transcript_source_ids), + windows_total=len(transcript_values), + transcripts=transcript_values, + ) remote.close() remote = None emit( @@ -258,6 +510,7 @@ def main() -> int: "failure", error_type=type(error).__name__, code=getattr(error, "code", "relay.e2e_failure"), + message=str(error), ) return 1 finally: diff --git a/tests/test_aio_observations.py b/tests/test_aio_observations.py index c8fbcd5..ac0a4f6 100644 --- a/tests/test_aio_observations.py +++ b/tests/test_aio_observations.py @@ -5,9 +5,10 @@ import asyncio from types import SimpleNamespace +import pocketstation._native as _native import pytest -from pocketstation import StreamInUseError, StreamModeError, _native -from pocketstation.aio import EventStream, RunningSession +from pocketstation._api import StreamInUseError, StreamModeError +from pocketstation.aio._api import EventStream, RunningSession from pocketstation.aio.session import _native_async diff --git a/tests/test_aio_relay.py b/tests/test_aio_relay.py index 6aab4cc..1cdcac8 100644 --- a/tests/test_aio_relay.py +++ b/tests/test_aio_relay.py @@ -4,13 +4,13 @@ import httpx import pytest -from pocketstation import Source -from pocketstation.aio import ControlClient, RelaySession, Session +from pocketstation._api import Source +from pocketstation.aio._api import ControlClient, RelaySession, Session CREATE_RESPONSE = { "session_id": "session_123", + "required_buses": ["application", "microphone"], "source_token": "source-secret", - "subscriber_token": "subscriber-secret", "whip_url": "https://relay.example/v1/sessions/session_123/whip", "whep_url": "https://relay.example/v1/sessions/session_123/whep", "ice_servers": [], @@ -30,35 +30,34 @@ async def test_async_relay_session_rejects_unbounded_request_timeout() -> None: @pytest.mark.asyncio async def test_async_relay_composes_native_routes_and_real_readiness() -> None: control_requests: list[httpx.Request] = [] - relay_requests: list[httpx.Request] = [] - snapshots = iter([_snapshot(True, 0), _snapshot(True, 1)]) + snapshots = iter( + [ + _snapshot(ready=True, subscription_count=0), + _snapshot(ready=True, subscription_count=1), + ] + ) async def control_handler(request: httpx.Request) -> httpx.Response: control_requests.append(request) - if request.method == "POST": + if request.method == "POST" and request.url.path == "/v1/sessions": return httpx.Response(201, json=CREATE_RESPONSE) if request.method == "GET": return httpx.Response(200, json=next(snapshots)) - return httpx.Response(204) - - async def relay_handler(request: httpx.Request) -> httpx.Response: - relay_requests.append(request) assert request.headers["authorization"] == "Bearer source-secret" - return httpx.Response( - 201, - json={ - "session_id": "session_123", - "join_code": "opaque-code", - "join_url": "https://receiver.example/?join=opaque-code", - }, - ) + if request.method == "POST" and request.url.path.endswith("/invitations"): + return httpx.Response( + 201, + json={ + "join_code": "opaque-code", + "join_url": "https://receiver.example/?join=opaque-code", + "expires_at": "2026-08-21T18:00:00Z", + }, + ) + return httpx.Response(204) - async with ( - httpx.AsyncClient( - transport=httpx.MockTransport(control_handler) - ) as control_http, - httpx.AsyncClient(transport=httpx.MockTransport(relay_handler)) as relay_http, - ): + async with httpx.AsyncClient( + transport=httpx.MockTransport(control_handler) + ) as control_http: control = ControlClient( "https://control.example", http_client=control_http, @@ -67,7 +66,6 @@ async def relay_handler(request: httpx.Request) -> httpx.Response: control_plane_url="https://control.example", relay_url="https://relay.example/", control_client=control, - relay_http_client=relay_http, ) session = Session() @@ -97,16 +95,35 @@ async def relay_handler(request: httpx.Request) -> httpx.Response: assert [(request.method, request.url.path) for request in control_requests] == [ ("POST", "/v1/sessions"), ("GET", "/v1/sessions/session_123"), + ("POST", "/v1/sessions/session_123/invitations"), ("GET", "/v1/sessions/session_123"), ("DELETE", "/v1/sessions/session_123"), ] - assert len(relay_requests) == 1 -def _snapshot(source_active: bool, subscription_count: int) -> dict[str, object]: +def _snapshot(*, ready: bool, subscription_count: int) -> dict[str, object]: return { "session_id": "session_123", - "source_active": source_active, + "state_revision": 2, + "relay_epoch": "relay-epoch-1", + "relay_revision": 2, + "required_buses": ["application", "microphone"], + "buses": [ + { + "bus_id": bus_id, + "role": "voice", + "source_active": ready, + "source_generation": 1 if ready else 0, + } + for bus_id in ("application", "microphone") + ], + "subscriptions": ( + [{"subscriber_id": "receiver_1", "bus_id": "mix"}] + if subscription_count + else [] + ), + "ready": ready, + "source_active": ready, "subscription_count": subscription_count, "codec": "opus", } diff --git a/tests/test_aio_session.py b/tests/test_aio_session.py index fb9b638..dca4179 100644 --- a/tests/test_aio_session.py +++ b/tests/test_aio_session.py @@ -8,16 +8,16 @@ from array import array import pytest -from pocketstation import ( +from pocketstation._api import ( Connector, ConnectorDeliveryOutcome, ConnectorManifest, SessionLifecycleState, ) -from pocketstation.aio import ( +from pocketstation.aio._api import ( Connector as AsyncConnector, ) -from pocketstation.aio import ( +from pocketstation.aio._api import ( ConnectorDeadlines, ConnectorWorker, EndpointDriverObservations, diff --git a/tests/test_aio_streams.py b/tests/test_aio_streams.py index 9e2a8a4..dd376a6 100644 --- a/tests/test_aio_streams.py +++ b/tests/test_aio_streams.py @@ -6,9 +6,10 @@ import threading from time import monotonic +import pocketstation._native as _native import pytest -from pocketstation import STREAM_EOF, StreamInUseError, StreamModeError, _native -from pocketstation.aio import AudioStream, RunningSession +from pocketstation._api import STREAM_EOF, StreamInUseError, StreamModeError +from pocketstation.aio._api import AudioStream, RunningSession from pocketstation.aio.session import _native_async diff --git a/tests/test_audio_input.py b/tests/test_audio_input.py index 94a7f97..78f9f99 100644 --- a/tests/test_audio_input.py +++ b/tests/test_audio_input.py @@ -5,7 +5,7 @@ from array import array import pytest -from pocketstation import ( +from pocketstation._api import ( AudioInputBufferError, AudioInputCancelledError, AudioInputClosedError, diff --git a/tests/test_audio_transport_example.py b/tests/test_audio_transport_example.py deleted file mode 100644 index eba9323..0000000 --- a/tests/test_audio_transport_example.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import asyncio -from array import array - -import pocketstation -import pocketstation.aio as pks_aio -import pytest - -from examples.integrations import IncomingAudio, attach_audio_sender, ingest_audio - - -@pytest.mark.asyncio -async def test_call_audio_template_uses_core_source_and_connector() -> None: - delivered = asyncio.Event() - received = [] - - async def send(frame, context): - received.append(frame) - delivered.set() - return pocketstation.ConnectorDeliveryOutcome.DELIVERED - - async def incoming(): - yield IncomingAudio(array("f", [0.1, 0.2, 0.3, 0.4])) - - session = pks_aio.Session(sample_rate_hz=16_000) - caller = session.audio_input( - "caller", - sample_rate_hz=16_000, - frame_samples_per_channel=4, - ) - registered = attach_audio_sender( - session, - caller.output, - send, - connector_id="io.pocketstation.test.call.v1", - package_version="1.0.0", - ) - running = await session.start() - await ingest_audio(caller, incoming()) - await asyncio.wait_for(delivered.wait(), 1.0) - assert (await running.stop()).success - assert received[0].source_id == caller.source_id - assert received[0].stream_id == caller.stream_id - [observation] = await registered.observations() - assert observation.frames_delivered_total == 1 diff --git a/tests/test_batch_transcription.py b/tests/test_batch_transcription.py new file mode 100644 index 0000000..5b57a1d --- /dev/null +++ b/tests/test_batch_transcription.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import asyncio +import json +import os +from array import array +from dataclasses import dataclass +from pathlib import Path +from time import monotonic + +import pocketstation._api as pocketstation +import pocketstation.aio as pks_aio +import pytest +from pocketstation_examples import FasterWhisper, FasterWhisperConfiguration + +from tests.transcription.run_source_aware import transcribe_sources +from tests.transcription.wav_input import read_pcm16_wav + + +@dataclass(frozen=True) +class _Segment: + start: float + end: float + text: str + + +@dataclass(frozen=True) +class _Info: + language: str = "en" + language_probability: float = 1.0 + + +class _SourceModel: + calls: int = 0 + + def transcribe(self, audio, *, beam_size, language, vad_filter): + self.calls += 1 + text = "application source" if sum(audio) > 0 else "microphone source" + return iter((_Segment(0.0, 0.1, text),)), _Info() + + +@pytest.mark.asyncio +async def test_one_bounded_model_preserves_two_source_identities( + tmp_path: Path, +) -> None: + model = _SourceModel() + model_creations = 0 + + def create_model(_configuration: FasterWhisperConfiguration) -> _SourceModel: + nonlocal model_creations + model_creations += 1 + return model + + transcriber = FasterWhisper( + FasterWhisperConfiguration( + model="test-model", + allow_model_download=False, + language="en", + beam_size=1, + window_seconds=0.1, + maximum_sources=2, + ), + model_factory=create_model, + _audio_converter=lambda window: list(window.samples), + ) + assert ( + transcriber.manifest.inputs[0].multiplicity is pocketstation.Multiplicity.MANY + ) + + session = pks_aio.Session(recording_root=tmp_path) + application = session.audio_input( + "application", capacity_frames=16, frame_samples_per_channel=480 + ) + microphone = session.audio_input( + "microphone", capacity_frames=16, frame_samples_per_channel=480 + ) + subscription = transcriber.attach_many( + session, + (application.output, microphone.output), + ) + application.output.record("application") + microphone.output.record("microphone") + + running = await session.start() + try: + for _ in range(10): + await application.write(array("f", [0.1] * 480)) + await microphone.write(array("f", [-0.1] * 480)) + await asyncio.sleep(0.01) + stream = running.signals(subscription) + received: dict[int, dict[str, object]] = {} + deadline = monotonic() + 5 + while len(received) < 2: + if monotonic() >= deadline: + raise TimeoutError("transcription did not emit both source identities") + envelope = await stream.read(timeout_s=1) + if envelope is None: + continue + if isinstance(envelope, pocketstation.EndOfStream): + metrics = await running.metrics() + terminal = await running.stop() + raise RuntimeError( + "transcription ended before both sources; " + f"model_calls={model.calls}, sources={metrics.external_sources!r}, " + f"operators={metrics.operators!r}, terminal={terminal!r}" + ) + assert isinstance(envelope, pocketstation.SignalEnvelope) + value = json.loads(str(envelope.payload)) + received[int(value["source_id"])] = value + await application.close() + await microphone.close() + finally: + outcome = await running.stop() + + assert model_creations == 1 + assert model.calls == 2 + assert set(received) == {application.source_id, microphone.source_id} + assert received[application.source_id]["text"] == "application source" + assert received[microphone.source_id]["text"] == "microphone source" + assert all(value["sequence_start"] == 0 for value in received.values()) + assert all(value["sequence_end"] == 9 for value in received.values()) + assert all(value["clock_id"] == 1 for value in received.values()) + assert outcome.success + assert outcome.recording is not None and outcome.recording.complete + + +@pytest.mark.asyncio +async def test_real_faster_whisper_transcribes_two_upstream_fixtures( + tmp_path: Path, +) -> None: + model = os.environ.get("PKS_REAL_TRANSCRIPTION_MODEL") + application_wav = os.environ.get("PKS_REAL_TRANSCRIPTION_APPLICATION_WAV") + microphone_wav = os.environ.get("PKS_REAL_TRANSCRIPTION_MICROPHONE_WAV") + if not model or not application_wav or not microphone_wav: + pytest.skip("real model and source fixtures are supplied by the Lab gate") + + result = await transcribe_sources( + application=read_pcm16_wav(Path(application_wav)), + microphone=read_pcm16_wav(Path(microphone_wav)), + record_to=tmp_path, + model=model, + model_revision=None, + device="cpu", + compute_type="int8", + cpu_threads=4, + window_seconds=2, + timeout_s=60, + allow_model_download=False, + ) + + assert result["recording_complete"] is True + assert set(result["recording_stems"]) == {"application", "microphone"} + assert result["application_source_id"] != result["microphone_source_id"] + transcripts = result["transcripts"] + assert isinstance(transcripts, dict) + application = transcripts["application"] + microphone = transcripts["microphone"] + assert application["source_id"] == result["application_source_id"] + assert microphone["source_id"] == result["microphone_source_id"] + assert "country" in str(application["text"]).lower() + assert str(microphone["text"]).strip() + assert application["inference_duration_ns"] > 0 + assert microphone["inference_duration_ns"] > 0 diff --git a/tests/test_connector.py b/tests/test_connector.py index cca0e61..da6617f 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -5,7 +5,7 @@ from time import monotonic import pytest -from pocketstation import ( +from pocketstation._api import ( Connector, ConnectorConfigurationField, ConnectorConfigurationRequirement, @@ -27,8 +27,8 @@ ConnectorWorker, PocketStationError, Session, - connector, ) +from pocketstation.connector import connector class CollectingDriver(ConnectorDriver): @@ -251,7 +251,7 @@ def test_session_destination_does_not_merge_different_connector_implementations( def test_connector_manifest_rejects_output_ports() -> None: - from pocketstation import MediaCaps, PortDirection, PortSpec, SignalSpec + from pocketstation.graph import MediaCaps, PortDirection, PortSpec, SignalSpec with pytest.raises(Exception, match="input"): ConnectorManifest( diff --git a/tests/test_control.py b/tests/test_control.py index 6abd569..560a8fa 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -4,18 +4,18 @@ import httpx import pytest -from pocketstation import ( +from pocketstation._api import ( ControlClient, ControlPlaneError, SecretToken, SessionId, ) -from pocketstation.aio import ControlClient as AsyncControlClient +from pocketstation.aio._api import ControlClient as AsyncControlClient CREATE_RESPONSE = { "session_id": "session_123", + "required_buses": ["application", "microphone"], "source_token": "source-secret", - "subscriber_token": "subscriber-secret", "whip_url": "https://relay.example/v1/sessions/session_123/whip", "whep_url": "https://relay.example/v1/sessions/session_123/whep", "ice_servers": [ @@ -28,6 +28,35 @@ } +def _snapshot(*, ready: bool, subscription_count: int) -> dict[str, object]: + buses = [ + { + "bus_id": bus_id, + "role": "voice", + "source_active": ready, + "source_generation": 1 if ready else 0, + } + for bus_id in ("application", "microphone") + ] + return { + "session_id": "session_123", + "state_revision": 3, + "relay_epoch": "relay-epoch-1" if ready else "", + "relay_revision": 2 if ready else 0, + "required_buses": ["application", "microphone"], + "buses": buses, + "subscriptions": ( + [{"subscriber_id": "receiver_1", "bus_id": "mix"}] + if subscription_count + else [] + ), + "ready": ready, + "source_active": ready, + "subscription_count": subscription_count, + "codec": "opus", + } + + def test_sync_client_maps_the_exact_session_contract_and_redacts_tokens() -> None: requests: list[httpx.Request] = [] @@ -36,21 +65,26 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path.endswith("/v1/sessions"): return httpx.Response(201, json=CREATE_RESPONSE) if request.method == "GET": + return httpx.Response(200, json=_snapshot(ready=True, subscription_count=1)) + if request.url.path.endswith("/subscribe"): return httpx.Response( 200, json={ "session_id": "session_123", - "source_active": True, - "subscription_count": 2, - "codec": "opus", + "bus_id": "mix", + "subscriber_token": "next-subscriber-secret", }, ) - if request.url.path.endswith("/subscribe"): + if request.url.path.endswith("/invitations"): return httpx.Response( - 200, + 201, json={ - "session_id": "session_123", - "subscriber_token": "next-subscriber-secret", + "join_code": "opaque-code", + "join_url": ( + "https://receiver.example/?join=opaque-code" + "&control=https%3A%2F%2Fcontrol.example" + ), + "expires_at": "2026-08-21T18:00:00Z", }, ) assert request.headers["authorization"] == "Bearer source-secret" @@ -63,8 +97,13 @@ def handler(request: httpx.Request) -> httpx.Response: http_client=http_client, ) as client: credentials = client.create_session() - snapshot = client.session(credentials.session_id) - subscriber = client.issue_subscriber_credentials(credentials.session_id) + snapshot = client.session(credentials.session_id, credentials.source_token) + subscriber = client.issue_subscriber_credentials( + credentials.session_id, credentials.source_token + ) + invitation = client.create_invitation( + credentials.session_id, credentials.source_token + ) client.delete_session(credentials.session_id, credentials.source_token) assert credentials.session_id == SessionId("session_123") @@ -74,13 +113,18 @@ def handler(request: httpx.Request) -> httpx.Response: assert credentials.ice_servers[0].credential is not None assert credentials.ice_servers[0].credential.expose_secret() == "turn-secret" assert "turn-secret" not in repr(credentials) - assert snapshot.source_active is True - assert snapshot.subscription_count == 2 + assert credentials.required_buses == ("application", "microphone") + assert snapshot.ready is True + assert snapshot.subscription_count == 1 + assert snapshot.buses[0].source_generation == 1 assert subscriber.subscriber_token.expose_secret() == "next-subscriber-secret" + assert subscriber.bus_id == "mix" + assert invitation.join_code == "opaque-code" assert [(request.method, request.url.path) for request in requests] == [ ("POST", "/base/v1/sessions"), ("GET", "/base/v1/sessions/session_123"), ("POST", "/base/v1/sessions/session_123/subscribe"), + ("POST", "/base/v1/sessions/session_123/invitations"), ("DELETE", "/base/v1/sessions/session_123"), ] @@ -95,19 +139,14 @@ async def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(201, json=CREATE_RESPONSE) if request.method == "GET": return httpx.Response( - 200, - json={ - "session_id": "session_123", - "source_active": False, - "subscription_count": 0, - "codec": "opus", - }, + 200, json=_snapshot(ready=False, subscription_count=0) ) if request.url.path.endswith("/subscribe"): return httpx.Response( 200, json={ "session_id": "session_123", + "bus_id": "mix", "subscriber_token": "next-subscriber-secret", }, ) @@ -121,16 +160,18 @@ async def handler(request: httpx.Request) -> httpx.Response: http_client=http_client, ) as client: credentials = await client.create_session() - snapshot = await client.session(credentials.session_id) + snapshot = await client.session( + credentials.session_id, credentials.source_token + ) subscriber = await client.issue_subscriber_credentials( - credentials.session_id + credentials.session_id, credentials.source_token ) await client.delete_session( credentials.session_id, credentials.source_token, ) - assert snapshot.source_active is False + assert snapshot.ready is False assert subscriber.subscriber_token.expose_secret() == "next-subscriber-secret" assert [(request.method, request.url.path) for request in requests] == [ ("POST", "/v1/sessions"), @@ -178,17 +219,15 @@ def test_control_decoder_rejects_boolean_or_negative_subscription_counts() -> No lambda _request, value=invalid: httpx.Response( 200, json={ - "session_id": "session_123", - "source_active": True, + **_snapshot(ready=True, subscription_count=0), "subscription_count": value, - "codec": "opus", }, ) ) with httpx.Client(transport=transport) as http_client: client = ControlClient("https://control.example", http_client=http_client) with pytest.raises(ControlPlaneError) as raised: - client.session("session_123") + client.session("session_123", SecretToken("source-secret")) assert raised.value.code == "control.response_decode" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d530115..b12e083 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -2,10 +2,9 @@ from dataclasses import FrozenInstanceError +import pocketstation._api as pocketstation import pytest - -import pocketstation -from pocketstation import SourceKind, SourceQuery +from pocketstation._api import SourceKind, SourceQuery def test_discovery_returns_an_immutable_typed_native_snapshot() -> None: diff --git a/tests/test_endpoint_authoring.py b/tests/test_endpoint_authoring.py index 767492e..3f79906 100644 --- a/tests/test_endpoint_authoring.py +++ b/tests/test_endpoint_authoring.py @@ -6,7 +6,7 @@ from time import monotonic, sleep import pytest -from pocketstation import ( +from pocketstation._api import ( EndpointDriverError, EndpointDriverObservations, EndpointFailureRetryability, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b49fd0c..2578c60 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -7,8 +7,8 @@ from dataclasses import FrozenInstanceError from pathlib import Path -import pocketstation as pks -import pocketstation.aio as aio +import pocketstation._api as pks +import pocketstation.aio._api as aio import pytest SOURCE_ID = "dev.pocketstation.source.fixture.v1" diff --git a/tests/test_generated_audio.py b/tests/test_generated_audio.py index 28dcf5a..23624fd 100644 --- a/tests/test_generated_audio.py +++ b/tests/test_generated_audio.py @@ -4,9 +4,9 @@ from time import monotonic +import pocketstation._native as _native import pytest - -from pocketstation import Operator, PocketStationError, Session, Source, _native +from pocketstation._api import Operator, PocketStationError, Session, Source GRAPH_OPERATOR_ID = "org.pocketstation.python.conformance.audio-pass-through.v1" NONCONCRETE_OPERATOR_ID = "org.pocketstation.python.conformance.nonconcrete-audio.v1" diff --git a/tests/test_graph.py b/tests/test_graph.py index a4fed58..095f6ae 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pocketstation import ( +from pocketstation._api import ( AudioCaps, BackpressurePolicy, BinaryFormat, diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 7751435..5ed95bc 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -4,8 +4,9 @@ from types import SimpleNamespace +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( EndpointFailureStage, Session, SessionComponentKind, @@ -17,7 +18,6 @@ SessionTraceRecordType, Source, TerminationDisposition, - _native, ) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 594e3f4..06d70bd 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -4,14 +4,13 @@ from dataclasses import FrozenInstanceError +import pocketstation._native as _native import pytest - -from pocketstation import ( +from pocketstation._api import ( EndpointObservationStage, PocketStationError, Session, Source, - _native, ) diff --git a/tests/test_observations.py b/tests/test_observations.py index 3da8959..954787a 100644 --- a/tests/test_observations.py +++ b/tests/test_observations.py @@ -5,13 +5,13 @@ import threading from types import SimpleNamespace +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( EventStream, RunningSession, StreamInUseError, StreamModeError, - _native, ) diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py index 82cc189..b49bea8 100644 --- a/tests/test_operator_authoring.py +++ b/tests/test_operator_authoring.py @@ -3,9 +3,9 @@ from array import array from threading import Event -import pocketstation.aio as pks_aio +import pocketstation.aio._api as pks_aio import pytest -from pocketstation import ( +from pocketstation._api import ( AudioCaps, ChannelLayout, Connector, diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index 308e45b..4f45436 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -4,6 +4,8 @@ import pocketstation from pocketstation import signal +from pocketstation.graph import Endpoint, SignalSpec, Stem +from pocketstation.relay import RelaySession ROOT = Path(__file__).resolve().parents[1] PACKAGE = ROOT / "python" / "pocketstation" @@ -52,11 +54,11 @@ def test_relay_modules_are_real_owners_not_empty_parity_scaffolds() -> None: def test_public_declarations_report_their_canonical_owner() -> None: assert pocketstation.Source.__module__ == "pocketstation.sources" - assert pocketstation.Endpoint.__module__ == "pocketstation.graph" - assert pocketstation.Stem.__module__ == "pocketstation.graph" - assert pocketstation.SignalSpec.__module__ == "pocketstation.graph" - assert pocketstation.RelaySession.__module__ == "pocketstation.relay" - assert signal.SignalSpec is pocketstation.SignalSpec + assert Endpoint.__module__ == "pocketstation.graph" + assert Stem.__module__ == "pocketstation.graph" + assert SignalSpec.__module__ == "pocketstation.graph" + assert RelaySession.__module__ == "pocketstation.relay" + assert signal.SignalSpec is SignalSpec def test_session_modules_do_not_redeclare_source_or_graph_types() -> None: diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 6a425b3..1213452 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -2,13 +2,16 @@ import sys -import pocketstation +import pocketstation._api as pocketstation import pytest -from pocketstation import ( +from pocketstation._api import ( CapturePermissionLifecycle, CapturePermissionTransitionKind, PermissionObservation, ) +from pocketstation.aio.sources import ( + microphone_permission_observation as async_microphone_permission_observation, +) def test_permission_observation_is_typed_and_has_no_prompt_api() -> None: @@ -65,6 +68,6 @@ def test_linux_truth_is_not_reinterpreted_as_allowed_or_denied() -> None: @pytest.mark.asyncio async def test_async_permission_observation_shares_native_policy() -> None: assert ( - await pocketstation.aio.microphone_permission_observation() + await async_microphone_permission_observation() is pocketstation.microphone_permission_observation() ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index b4b1e2f..d5e4536 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -10,314 +10,45 @@ ROOT = Path(__file__).resolve().parents[1] -def test_root_exports_are_an_intentional_stable_snapshot() -> None: +def test_root_exports_are_a_small_intentional_entry_point() -> None: assert set(pocketstation.__all__) == { - "AudioBatch", - "AudioBatchReadResult", - "AudioCaps", - "AudioConnectorHandler", - "AudioFrame", + "RUNTIME_COMPATIBILITY", "AudioInput", - "AudioInputBufferError", - "AudioInputCancelledError", - "AudioInputClosedError", "AudioInputConfig", - "AudioInputError", - "AudioInputFullError", - "AudioInputObservations", - "AudioReentryMetrics", - "AudioStream", - "ApplicationPolicyObservation", - "BackpressurePolicy", - "BinaryFormat", - "BusSubscription", "Capture", - "CaptureAuthorizationSnapshot", - "CaptureCapabilityState", "CaptureError", - "CaptureOpenOutcome", - "CapturePermissionLifecycle", - "CapturePermissionTransition", - "CapturePermissionTransitionKind", - "CaptureScopeKind", - "CaptureSessionGrant", - "ChannelLayout", - "ClockDomain", - "ClockDomainDescriptor", - "ClockDomainId", - "ClockDomainKind", - "ClockDomainOrigin", - "Codec", - "Connector", - "ConnectorBatchOutcome", - "ConnectorCapability", - "ConnectorConfigurationConstraint", - "ConnectorConfigurationField", - "ConnectorConfigurationInput", - "ConnectorConfigurationRequirement", - "ConnectorConfigurationSchema", - "ConnectorConfigurationValue", - "ConnectorConfigurationValueKind", - "ConnectorContext", - "ConnectorDeliveryOutcome", - "ConnectorDeliveryReadiness", - "ConnectorDriver", - "ConnectorDriverBuilder", - "ConnectorDriverFactory", - "ConnectorHandler", - "ConnectorError", - "ConnectorErrorSnapshot", - "ConnectorErrorStage", - "ConnectorFactory", - "ConnectorHealth", - "ConnectorId", - "ConnectorInputDescriptor", - "ConnectorItem", - "ConnectorManifest", - "ConnectorObservations", - "ConnectorPreparationGroup", - "ConnectorRecovery", - "ConnectorRetryability", - "ConnectorRuntimeError", - "ConnectorRequirement", - "ConnectorRuntimeObservations", - "ConnectorServiceStatus", - "ConnectorShutdownMode", - "ConnectorWorker", - "ConnectorWorkerBuilder", - "ControlClient", - "ControlPlaneError", - "CopyPolicy", - "DeliverySemantics", - "DerivedStream", - "DerivedRouteMetrics", - "DiscoveredSource", - "EdgeContract", - "EdgeMetrics", - "EdgeObservabilityLevel", - "Endpoint", - "EndpointConfiguration", - "EndpointConfigurationInput", - "EndpointDescriptor", - "EndpointDriverBuilder", - "EndpointDriverError", - "EndpointDriverFactory", - "EndpointDriverObservations", - "EndpointFailureRetryability", - "EndpointFailureStage", - "EndpointId", - "EndpointItem", - "EndpointManifest", - "EndpointMetrics", - "EndpointObservationStage", - "EndpointPortInput", - "EndpointPreparationGroup", - "EndpointPrepareContext", - "EndpointProvider", - "EndpointReceiver", - "EndpointShutdownMode", - "EndpointStartGate", - "EndOfStream", - "EventFormat", - "EventStream", - "EventQueueMetrics", - "ExternalSourceMetrics", - "GraphError", - "ExtensionAbiVersion", - "ExtensionDescriptor", - "ExtensionError", - "ExtensionKind", - "ExtensionPort", - "ExtensionPortDirection", - "IceServer", - "LossPolicy", - "LatencyHistogram", - "MediaCaps", - "MediaKind", - "Multiplicity", - "NativeExtensionLibrary", - "NativeExtensionRegistration", - "Operator", - "OperatorError", - "OperatorConfigValidator", - "OperatorConfiguration", - "OperatorEmission", - "OperatorFactory", - "OperatorHandler", - "OperatorInput", - "OperatorInputMetrics", - "OperatorInstanceId", - "OperatorInstance", - "OperatorMetrics", - "OperatorManifest", - "OperatorNode", - "OperatorPortContext", - "OperatorPrepareContext", - "OperatorProvider", - "OperatorWorkerMetrics", "PcmSource", "PocketStationError", - "PermissionObservation", - "Platform", - "PortDirection", - "PortSpec", - "PolledAudioMetrics", - "ProcessInstanceSelector", - "ProcessTreeScope", - "PreparedEndpointDriver", - "PublisherActivation", - "ReceiverActivation", - "ReceiverInvitation", - "RUNTIME_COMPATIBILITY", "RecordingOutcome", - "RecordingDiscontinuity", - "RecordingDiscontinuityKind", - "RecordingState", - "RecordingStemOutcome", - "RegisteredConnector", - "RegisteredEndpoint", - "RegisteredOperator", - "RegisteredSource", - "RelayError", - "RelayPublishOutcome", - "RelayPublisher", - "RelayRoute", - "RelaySession", - "RelayTimeoutError", - "RouteId", - "RouteLatencyBoundary", - "RouteLatencyUnit", - "RouteMetrics", - "RouteObservationInterval", - "RuntimeCompatibility", - "RuntimeSessionId", - "RunningEndpointDriver", "RunningSession", - "SampleFormat", - "SecretToken", - "SelectorPersistenceScope", + "RuntimeCompatibility", "Session", - "SessionCompileDiagnostic", - "SessionComponent", - "SessionComponentKind", - "SessionCredentials", - "SessionDeclarationError", "SessionError", - "SessionEvent", - "SessionEventType", - "SessionFailure", - "SessionFailureKind", - "SessionFinalizationStage", - "SessionId", - "SessionLifecycleState", - "SessionMetrics", - "SessionRuntimeError", - "SessionRollbackStage", - "SessionSnapshot", - "SessionStartError", - "SessionTerminalState", - "SessionTrace", - "SessionTraceConfiguration", - "SessionTraceRecord", - "SessionTraceRecordType", - "SessionTraceRecorderOutcome", - "SessionTraceValidation", - "SidecarBackpressureError", - "SidecarConnection", - "SidecarDeadlines", - "SidecarError", - "SidecarHandle", - "SidecarMessage", - "SidecarMessageKind", - "SidecarProcessSpec", - "SidecarProtocolError", - "SidecarProtocolLimits", - "SidecarReadResult", - "SidecarId", - "SidecarSnapshot", - "SidecarState", - "SidecarStream", - "SidecarTimeoutError", - "STREAM_EOF", - "SignalAudioPayload", - "SignalDerivation", - "SignalEnvelope", - "SignalKind", - "SignalLineage", - "SignalPayload", - "SignalReadResult", - "SignalSpec", - "SignalStream", - "SignalSubscriptionMetrics", - "SignalTiming", "Source", - "SourceCancellation", - "SourceConfigValidator", - "SourceConfiguration", - "SourceDriver", - "SourceEmission", - "SourceError", - "SourceFactory", - "SourceFailureClass", - "SourceId", - "SourceInstanceId", - "SourceIdentityStrength", - "SourceInstance", - "SourceIterableFactory", - "SourceKind", - "SourceManifest", - "SourceMetrics", - "SourceOutput", - "SourceOutputIdentity", - "SourcePrepareContext", - "SourceProvider", - "SourceQuery", - "SourceRecoveryRequirement", - "SourceRuntimeEvent", - "SourceRuntimeEventKind", - "SourceSelectorKind", - "SourceState", - "StableSourceId", - "Stem", - "StemId", "StopResult", - "StreamError", - "StreamId", - "StreamInUseError", - "StreamModeError", - "SubscriberCredentials", - "TextFormat", - "TerminationDisposition", - "TypedEdgeMetrics", "aio", - "application_capture_available", "capture", - "connector", "discover_sources", - "microphone_permission_observation", - "operator", - "source", } -def test_async_namespace_exposes_its_complete_authoring_contract() -> None: - required = { - "ConnectorManifest", - "ConnectorConfigurationSchema", - "ConnectorContext", - "ConnectorDeliveryOutcome", - "ConnectorError", - "ConnectorObservations", - "OperatorEmission", - "OperatorManifest", - "OperatorPrepareContext", - "SourceEmission", - "SourceManifest", - "SourcePrepareContext", - } +def test_advanced_contracts_are_not_duplicated_at_the_package_root() -> None: + assert not hasattr(pocketstation, "Connector") + assert not hasattr(pocketstation, "OperatorProvider") - assert required <= set(pocketstation.aio.__all__) - assert all(hasattr(pocketstation.aio, name) for name in required) + +def test_async_namespace_is_concise() -> None: + assert set(pocketstation.aio.__all__) == { + "AudioInput", + "Capture", + "PcmSource", + "RelaySession", + "RunningSession", + "Session", + "capture", + "discover_sources", + } + assert not hasattr(pocketstation.aio, "Connector") def test_private_native_runtime_and_stub_export_the_same_classes() -> None: diff --git a/tests/test_realtime_boundary.py b/tests/test_realtime_boundary.py index 8203cd4..af2b3a9 100644 --- a/tests/test_realtime_boundary.py +++ b/tests/test_realtime_boundary.py @@ -5,7 +5,7 @@ from pathlib import Path from time import monotonic -import pocketstation as pks +import pocketstation._api as pks import pytest from pocketstation._native import Session as NativeSession diff --git a/tests/test_recording.py b/tests/test_recording.py index 4f020d9..2877d56 100644 --- a/tests/test_recording.py +++ b/tests/test_recording.py @@ -5,14 +5,14 @@ from time import monotonic from types import SimpleNamespace +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( RecordingDiscontinuityKind, RecordingOutcome, RecordingState, Session, Source, - _native, ) diff --git a/tests/test_relay.py b/tests/test_relay.py index 5384fc8..476e76d 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -6,7 +6,7 @@ import httpx import pytest -from pocketstation import ( +from pocketstation._api import ( ControlClient, RelayError, RelaySession, @@ -17,8 +17,8 @@ CREATE_RESPONSE = { "session_id": "session_123", + "required_buses": ["application", "microphone"], "source_token": "source-secret", - "subscriber_token": "subscriber-secret", "whip_url": "https://relay.example/v1/sessions/session_123/whip", "whep_url": "https://relay.example/v1/sessions/session_123/whep", "ice_servers": [], @@ -36,44 +36,35 @@ def test_relay_session_rejects_unbounded_request_timeout() -> None: def test_relay_composes_two_native_buses_with_authoritative_readiness() -> None: control_requests: list[httpx.Request] = [] - relay_requests: list[httpx.Request] = [] snapshots = iter( [ - _snapshot(source_active=True, subscription_count=0), - _snapshot(source_active=True, subscription_count=1), + _snapshot(ready=True, subscription_count=0), + _snapshot(ready=True, subscription_count=1), ] ) def control_handler(request: httpx.Request) -> httpx.Response: control_requests.append(request) - if request.method == "POST": + if request.method == "POST" and request.url.path == "/v1/sessions": return httpx.Response(201, json=CREATE_RESPONSE) + assert request.headers["authorization"] == "Bearer source-secret" if request.method == "GET": return httpx.Response(200, json=next(snapshots)) - assert request.headers["authorization"] == "Bearer source-secret" + if request.method == "POST" and request.url.path.endswith("/invitations"): + return httpx.Response( + 201, + json={ + "join_code": "opaque-code", + "join_url": ( + "https://receiver.example/?join=opaque-code" + "&control=https%3A%2F%2Fcontrol.example" + ), + "expires_at": "2026-08-21T18:00:00Z", + }, + ) return httpx.Response(204) - def relay_handler(request: httpx.Request) -> httpx.Response: - relay_requests.append(request) - assert request.method == "POST" - assert request.url.path == "/v1/sessions/session_123/invitations" - assert request.headers["authorization"] == "Bearer source-secret" - return httpx.Response( - 201, - json={ - "session_id": "session_123", - "join_code": "opaque-code", - "join_url": ( - "https://receiver.example/?join=opaque-code" - "&relay=https%3A%2F%2Frelay.example" - ), - }, - ) - - with ( - httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, - httpx.Client(transport=httpx.MockTransport(relay_handler)) as relay_http, - ): + with httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http: control = ControlClient( "https://control.example", http_client=control_http, @@ -82,7 +73,6 @@ def relay_handler(request: httpx.Request) -> httpx.Response: control_plane_url="https://control.example", relay_url="https://relay.example/", control_client=control, - relay_http_client=relay_http, ) session = Session() @@ -109,12 +99,11 @@ def relay_handler(request: httpx.Request) -> httpx.Response: assert app_route.bus_id == "application" assert mic_route.bus_id == "microphone" assert app_route.route_id != mic_route.route_id - assert publisher_ready.snapshot.source_active is True + assert publisher_ready.snapshot.ready is True assert publisher_ready.snapshot.subscription_count == 0 assert receiver_ready.snapshot.subscription_count == 1 assert remote.relay_url == "https://relay.example" assert "source-secret" not in repr(remote) - assert "subscriber-secret" not in repr(remote) parsed = urlparse(invitation.join_url) assert parse_qs(parsed.query)["join"] == ["opaque-code"] @@ -127,26 +116,23 @@ def relay_handler(request: httpx.Request) -> httpx.Response: assert [(request.method, request.url.path) for request in control_requests] == [ ("POST", "/v1/sessions"), ("GET", "/v1/sessions/session_123"), + ("POST", "/v1/sessions/session_123/invitations"), ("GET", "/v1/sessions/session_123"), ("DELETE", "/v1/sessions/session_123"), ] - assert len(relay_requests) == 1 def test_relay_wait_uses_a_single_bounded_deadline() -> None: def control_handler(request: httpx.Request) -> httpx.Response: - if request.method == "POST": + if request.method == "POST" and request.url.path == "/v1/sessions": return httpx.Response(201, json=CREATE_RESPONSE) if request.method == "GET": - return httpx.Response(200, json=_snapshot(False, 0)) + return httpx.Response( + 200, json=_snapshot(ready=False, subscription_count=0) + ) return httpx.Response(204) - with ( - httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, - httpx.Client( - transport=httpx.MockTransport(lambda _request: httpx.Response(500)) - ) as relay_http, - ): + with httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http: control = ControlClient( "https://control.example", http_client=control_http, @@ -155,7 +141,6 @@ def control_handler(request: httpx.Request) -> httpx.Response: control_plane_url="https://control.example", relay_url="https://relay.example", control_client=control, - relay_http_client=relay_http, ) with pytest.raises(RelayTimeoutError) as timeout: remote.wait_for_publisher( @@ -180,27 +165,23 @@ def test_relay_rejects_unsafe_or_mismatched_invitations(join_url: str) -> None: def control_handler(request: httpx.Request) -> httpx.Response: nonlocal get_calls - if request.method == "POST": + if request.method == "POST" and request.url.path == "/v1/sessions": return httpx.Response(201, json=CREATE_RESPONSE) if request.method == "GET": get_calls += 1 - return httpx.Response(200, json=_snapshot(True, 0)) + return httpx.Response(200, json=_snapshot(ready=True, subscription_count=0)) + if request.method == "POST" and request.url.path.endswith("/invitations"): + return httpx.Response( + 201, + json={ + "join_code": "opaque-code", + "join_url": join_url, + "expires_at": "2026-08-21T18:00:00Z", + }, + ) return httpx.Response(204) - relay_transport = httpx.MockTransport( - lambda _request: httpx.Response( - 201, - json={ - "session_id": "session_123", - "join_code": "opaque-code", - "join_url": join_url, - }, - ) - ) - with ( - httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http, - httpx.Client(transport=relay_transport) as relay_http, - ): + with httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http: control = ControlClient( "https://control.example", http_client=control_http, @@ -209,7 +190,6 @@ def control_handler(request: httpx.Request) -> httpx.Response: control_plane_url="https://control.example", relay_url="https://relay.example", control_client=control, - relay_http_client=relay_http, ) remote.wait_for_publisher( timeout_seconds=0.1, @@ -243,15 +223,33 @@ def test_invalid_relay_origin_fails_before_remote_session_creation() -> None: control_plane_url="https://control.example", relay_url="https://relay.example/not-an-origin", control_client=control, - relay_http_client=http_client, ) assert requests == [] -def _snapshot(source_active: bool, subscription_count: int) -> dict[str, object]: +def _snapshot(*, ready: bool, subscription_count: int) -> dict[str, object]: return { "session_id": "session_123", - "source_active": source_active, + "state_revision": 2, + "relay_epoch": "relay-epoch-1", + "relay_revision": 2, + "required_buses": ["application", "microphone"], + "buses": [ + { + "bus_id": bus_id, + "role": "voice", + "source_active": ready, + "source_generation": 1 if ready else 0, + } + for bus_id in ("application", "microphone") + ], + "subscriptions": ( + [{"subscriber_id": "receiver_1", "bus_id": "mix"}] + if subscription_count + else [] + ), + "ready": ready, + "source_active": ready, "subscription_count": subscription_count, "codec": "opus", } diff --git a/tests/test_session.py b/tests/test_session.py index a7a95c3..29eb841 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,13 +4,13 @@ from array import array +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( PocketStationError, Session, SessionLifecycleState, Source, - _native, ) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index e258d71..a7a4b73 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -5,7 +5,7 @@ from pathlib import Path from time import monotonic -import pocketstation as pks +import pocketstation._api as pks import pocketstation.aio as aio import pytest from pocketstation._native import Session as NativeSession diff --git a/tests/test_signal_streams.py b/tests/test_signal_streams.py index 8972de7..5cdc895 100644 --- a/tests/test_signal_streams.py +++ b/tests/test_signal_streams.py @@ -5,8 +5,9 @@ from pathlib import Path from time import monotonic +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( STREAM_EOF, BackpressurePolicy, BinaryFormat, @@ -18,7 +19,6 @@ SignalSpec, Source, TextFormat, - _native, aio, ) diff --git a/tests/test_source_authoring.py b/tests/test_source_authoring.py index 8980541..0009725 100644 --- a/tests/test_source_authoring.py +++ b/tests/test_source_authoring.py @@ -2,9 +2,9 @@ from threading import Event -import pocketstation.aio as pks_aio +import pocketstation.aio._api as pks_aio import pytest -from pocketstation import ( +from pocketstation._api import ( MediaCaps, Multiplicity, PortDirection, diff --git a/tests/test_source_lifecycle.py b/tests/test_source_lifecycle.py index 1bcd8b8..786de1b 100644 --- a/tests/test_source_lifecycle.py +++ b/tests/test_source_lifecycle.py @@ -3,9 +3,9 @@ from dataclasses import FrozenInstanceError from types import SimpleNamespace +import pocketstation._native as _native import pytest - -from pocketstation import ( +from pocketstation._api import ( Platform, RunningSession, Session, @@ -15,7 +15,6 @@ SourceRecoveryRequirement, SourceRuntimeEvent, SourceRuntimeEventKind, - _native, ) from pocketstation.observations import SessionEvent diff --git a/tests/test_sources.py b/tests/test_sources.py index 989e83e..ce7785d 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -5,7 +5,7 @@ from types import SimpleNamespace import pytest -from pocketstation import ( +from pocketstation._api import ( ApplicationPolicyObservation, AudioInputBufferError, AudioInputClosedError, diff --git a/tests/test_station.py b/tests/test_station.py index 1fa001e..bad1fa1 100644 --- a/tests/test_station.py +++ b/tests/test_station.py @@ -3,12 +3,14 @@ from __future__ import annotations import pocketstation +from pocketstation.control import ControlClient def test_given_primary_exports_when_inspected_then_room_vocabulary_is_absent(): assert "PocketStation" not in pocketstation.__all__ assert "RoomCredentials" not in pocketstation.__all__ - assert "ControlClient" in pocketstation.__all__ + assert "ControlClient" not in pocketstation.__all__ + assert ControlClient.__module__ == "pocketstation.control" assert "Session" in pocketstation.__all__ diff --git a/tests/test_stream_state_machine.py b/tests/test_stream_state_machine.py index f41d077..a9679af 100644 --- a/tests/test_stream_state_machine.py +++ b/tests/test_stream_state_machine.py @@ -7,9 +7,13 @@ from dataclasses import dataclass import pytest - -from pocketstation import STREAM_EOF, StreamError, StreamInUseError, StreamModeError -from pocketstation.aio import SignalStream as AsyncSignalStream +from pocketstation._api import ( + STREAM_EOF, + StreamError, + StreamInUseError, + StreamModeError, +) +from pocketstation.aio._api import SignalStream as AsyncSignalStream from pocketstation.streams import SignalStream diff --git a/tests/test_streams.py b/tests/test_streams.py index a582d01..5c4de49 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -4,14 +4,14 @@ import threading +import pocketstation._native as _native import pytest -from pocketstation import ( +from pocketstation._api import ( STREAM_EOF, AudioStream, RunningSession, StreamInUseError, StreamModeError, - _native, ) diff --git a/tests/test_transcription_example.py b/tests/test_transcription_example.py index 1a59303..5053812 100644 --- a/tests/test_transcription_example.py +++ b/tests/test_transcription_example.py @@ -2,47 +2,17 @@ import asyncio import json -import os -import sys from array import array from dataclasses import dataclass -from pathlib import Path import pocketstation.aio as pks_aio import pytest - -from examples.transcription import ( +from pocketstation_examples import ( FasterWhisper, FasterWhisperConfiguration, - WhisperCpp, - WhisperCppConfiguration, ) -def test_whisper_example_declares_a_bounded_source_aware_operator( - tmp_path: Path, -) -> None: - executable = tmp_path / "whisper-cli" - model = tmp_path / "model.bin" - executable.touch() - model.touch() - configuration = WhisperCppConfiguration( - executable=executable, - model=model, - window_seconds=2, - process_timeout_s=10, - queue_capacity_signals=128, - ) - whisper = WhisperCpp(configuration) - - assert whisper.manifest.inputs[0].name == "audio" - assert whisper.manifest.inputs[0].signal.is_audio - assert whisper.manifest.outputs[0].name == "transcript" - assert whisper.manifest.outputs[0].signal.role == "transcript.final" - assert whisper.manifest.queue_capacity_signals == 128 - assert whisper.manifest.filesystem_allowed - - def test_faster_whisper_can_forbid_model_downloads() -> None: transcription = FasterWhisper( FasterWhisperConfiguration( @@ -113,39 +83,3 @@ async def test_faster_whisper_is_the_concise_source_aware_python_path() -> None: assert transcript["stream_id"] == audio.stream_id assert transcript["text"] == "pocket station" assert stop.success - - -@pytest.mark.asyncio -async def test_real_whisper_process_preserves_source_identity(tmp_path: Path) -> None: - executable_value = os.environ.get("POCKETSTATION_WHISPER_CLI") - model_value = os.environ.get("POCKETSTATION_WHISPER_MODEL") - wav_value = os.environ.get("POCKETSTATION_WHISPER_WAV") - if not executable_value or not model_value or not wav_value: - pytest.skip("real whisper paths were not supplied") - - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "examples.transcription.run", - "--whisper-cli", - executable_value, - "--model", - model_value, - "--wav", - wav_value, - "--record-to", - str(tmp_path / "recordings"), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) - assert process.returncode == 0, stderr.decode(errors="replace") - transcript = json.loads(stdout) - assert transcript["source_id"] > 0 - assert transcript["stream_id"] > 0 - assert transcript["sequence_start"] == 0 - assert transcript["sequence_end"] >= transcript["sequence_start"] - assert transcript["discontinuity_epoch"] == 0 - assert "pocket station" in transcript["text"].lower() - stems = list((tmp_path / "recordings").glob("session-*/stems/*.wav")) - assert len(stems) == 1 diff --git a/tests/test_types.py b/tests/test_types.py index 2fbc2d6..3d53fbb 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,8 +1,7 @@ """Stable exception contract tests.""" import pytest - -from pocketstation import PocketStationError +from pocketstation._api import PocketStationError def test_given_pocketstation_error_when_raised_then_code_set(): diff --git a/tests/transcription/__init__.py b/tests/transcription/__init__.py new file mode 100644 index 0000000..e020baf --- /dev/null +++ b/tests/transcription/__init__.py @@ -0,0 +1 @@ +"""Real transcription qualification support.""" diff --git a/tests/transcription/run_source_aware.py b/tests/transcription/run_source_aware.py new file mode 100644 index 0000000..eec29ad --- /dev/null +++ b/tests/transcription/run_source_aware.py @@ -0,0 +1,264 @@ +"""Transcribe two independent PCM sources through one installed PocketStation SDK.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +from time import monotonic +from typing import cast + +import pocketstation._api as pocketstation +import pocketstation.aio as pks_aio +from pocketstation_examples import ( + FasterWhisper, + FasterWhisperConfiguration, +) + +from tests.transcription.wav_input import WavInput, feed_live, read_pcm16_wav + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--application-wav", type=Path, required=True) + parser.add_argument("--microphone-wav", type=Path, required=True) + parser.add_argument("--record-to", type=Path, required=True) + parser.add_argument("--model", default="tiny.en") + parser.add_argument("--model-revision") + parser.add_argument("--device", default="cpu") + parser.add_argument("--compute-type", default="int8") + parser.add_argument("--cpu-threads", type=int, default=4) + parser.add_argument("--window-seconds", type=float, default=2.0) + parser.add_argument("--timeout-seconds", type=float, default=60.0) + parser.add_argument("--allow-model-download", action="store_true") + return parser.parse_args() + + +async def main() -> None: + arguments = _arguments() + result = await transcribe_sources( + application=read_pcm16_wav(arguments.application_wav), + microphone=read_pcm16_wav(arguments.microphone_wav), + record_to=arguments.record_to, + model=arguments.model, + model_revision=arguments.model_revision, + device=arguments.device, + compute_type=arguments.compute_type, + cpu_threads=arguments.cpu_threads, + window_seconds=arguments.window_seconds, + timeout_s=arguments.timeout_seconds, + allow_model_download=arguments.allow_model_download, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + + +async def transcribe_sources( + *, + application: WavInput, + microphone: WavInput, + record_to: Path, + model: str, + model_revision: str | None, + device: str, + compute_type: str, + cpu_threads: int, + window_seconds: float, + timeout_s: float, + allow_model_download: bool, +) -> dict[str, object]: + """Run real inference while preserving two independent Session sources.""" + if not 1 <= timeout_s <= 300: + raise ValueError("timeout_s must be between 1 and 300") + application_contract = ( + application.sample_rate_hz, + application.channels, + application.frame_samples_per_channel, + ) + microphone_contract = ( + microphone.sample_rate_hz, + microphone.channels, + microphone.frame_samples_per_channel, + ) + if application_contract != microphone_contract: + raise ValueError( + "application and microphone WAV inputs must use the same " + "sample rate, channel count, and frame size" + ) + session = pks_aio.Session( + recording_root=record_to, + sample_rate_hz=application.sample_rate_hz, + channels=application.channels, + ) + application_audio = session.audio_input( + "application", + sample_rate_hz=application.sample_rate_hz, + channels=application.channels, + capacity_frames=32, + frame_samples_per_channel=application.frame_samples_per_channel, + ) + microphone_audio = session.audio_input( + "microphone", + sample_rate_hz=microphone.sample_rate_hz, + channels=microphone.channels, + capacity_frames=32, + frame_samples_per_channel=microphone.frame_samples_per_channel, + ) + transcriber = FasterWhisper( + FasterWhisperConfiguration( + model=model, + model_revision=model_revision, + device=device, + compute_type=compute_type, + cpu_threads=cpu_threads, + allow_model_download=allow_model_download, + language="en" if model.endswith(".en") else None, + beam_size=1, + window_seconds=window_seconds, + queue_capacity_signals=1_024, + maximum_sources=2, + create_timeout_s=timeout_s, + inference_timeout_s=timeout_s, + ) + ) + transcripts = transcriber.attach_many( + session, + (application_audio.output, microphone_audio.output), + ) + application_audio.output.record("application") + microphone_audio.output.record("microphone") + + running = await session.start() + transcript_stream = running.signals(transcripts) + expected_sources = { + int(application_audio.source_id): "application", + int(microphone_audio.source_id): "microphone", + } + received: dict[str, dict[str, object]] = {} + collector = asyncio.create_task( + _collect_transcripts( + transcript_stream, + expected_sources=expected_sources, + received=received, + timeout_s=timeout_s, + ) + ) + try: + async with asyncio.TaskGroup() as feeds: + feeds.create_task( + feed_live( + application_audio, + application, + pacing_ratio=1, + close_when_complete=False, + ) + ) + feeds.create_task( + feed_live( + microphone_audio, + microphone, + pacing_ratio=1, + close_when_complete=False, + ) + ) + await application_audio.close() + await microphone_audio.close() + finally: + outcome = await running.stop() + await collector + if ( + not outcome.success + or outcome.recording is None + or not outcome.recording.complete + ): + raise RuntimeError(f"Session did not finalize cleanly: {outcome!r}") + + return { + "application_source_id": int(application_audio.source_id), + "microphone_source_id": int(microphone_audio.source_id), + "recording_complete": True, + "recording_stems": [stem.stem_name for stem in outcome.recording.stems], + "transcripts": received, + } + + +async def _collect_transcripts( + stream: pks_aio.SignalStream[str], + *, + expected_sources: dict[int, str], + received: dict[str, dict[str, object]], + timeout_s: float, +) -> None: + """Drain the bounded branch through Session finalization.""" + deadline = monotonic() + timeout_s + while True: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError("transcription did not finalize within its deadline") + envelope = await stream.read(timeout_s=min(1.0, remaining)) + if envelope is None: + continue + if isinstance(envelope, pocketstation.EndOfStream): + if set(received) != set(expected_sources.values()): + raise RuntimeError("transcript stream ended before both sources") + return + value = json.loads(str(envelope.payload)) + if not isinstance(value, dict): + raise RuntimeError("transcript payload must be a JSON object") + source_id = value.get("source_id") + if not isinstance(source_id, int) or source_id not in expected_sources: + raise RuntimeError("transcript lost its input source identity") + if value.get("text"): + _accumulate_transcript(received, expected_sources[source_id], value) + + +def _accumulate_transcript( + received: dict[str, dict[str, object]], + source_name: str, + window: dict[str, object], +) -> None: + summary = received.get(source_name) + if summary is None: + summary = dict(window) + summary["windows"] = [dict(window)] + summary["windows_total"] = 1 + received[source_name] = summary + return + + for identity in ("session_id", "source_id", "stream_id"): + if summary.get(identity) != window.get(identity): + raise RuntimeError(f"transcript changed {identity} within one source") + summary["text"] = " ".join( + part + for part in (str(summary.get("text", "")), str(window.get("text", ""))) + if part + ) + summary["segments"] = [ + *cast(list[object], summary.get("segments", [])), + *cast(list[object], window.get("segments", [])), + ] + for terminal_field in ( + "sequence_end", + "timestamp_end_ns", + "source_timestamp_end_ns", + "session_timestamp_end_ns", + ): + summary[terminal_field] = window.get(terminal_field) + summary["inference_duration_ns"] = cast( + int, summary.get("inference_duration_ns", 0) + ) + cast(int, window.get("inference_duration_ns", 0)) + summary["discontinuity_reasons"] = sorted( + { + *cast(list[str], summary.get("discontinuity_reasons", [])), + *cast(list[str], window.get("discontinuity_reasons", [])), + } + ) + windows = summary.get("windows") + if not isinstance(windows, list): + raise RuntimeError("transcript summary lost its bounded window history") + windows.append(dict(window)) + summary["windows_total"] = len(windows) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/transcription/wav_input.py b/tests/transcription/wav_input.py similarity index 92% rename from examples/transcription/wav_input.py rename to tests/transcription/wav_input.py index 11698d4..516e02d 100644 --- a/examples/transcription/wav_input.py +++ b/tests/transcription/wav_input.py @@ -1,4 +1,4 @@ -"""Finite PCM WAV input for executable transcription examples.""" +"""Finite PCM WAV input for transcription qualification.""" from __future__ import annotations @@ -50,6 +50,7 @@ async def feed_live( *, timeout_s: float = 2.0, pacing_ratio: float = 0.1, + close_when_complete: bool = True, ) -> None: frame_values = source.frame_samples_per_channel * source.channels for offset in range(0, len(source.samples), frame_values): @@ -60,7 +61,8 @@ async def feed_live( await asyncio.sleep( source.frame_samples_per_channel / source.sample_rate_hz * pacing_ratio ) - await audio.close() + if close_when_complete: + await audio.close() __all__ = ["WavInput", "feed_live", "read_pcm16_wav"] From fe29a8f9bd16949144bc53a16f853c8e658a8864 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 21 Aug 2026 23:28:05 -0400 Subject: [PATCH 18/49] fix: preserve Core 1.1 SDK compatibility --- native/src/observations.rs | 12 ++++++++---- native/src/sources.rs | 9 --------- python/pocketstation/_native.pyi | 2 -- python/pocketstation/sources.py | 15 ++------------- tests/test_session.py | 19 ------------------- tests/test_sources.py | 10 ---------- 6 files changed, 10 insertions(+), 57 deletions(-) diff --git a/native/src/observations.rs b/native/src/observations.rs index 0abbbfc..edcf56d 100644 --- a/native/src/observations.rs +++ b/native/src/observations.rs @@ -2210,15 +2210,19 @@ pub(crate) fn owned_recording_outcome( }) .collect(); Some(OwnedRecordingOutcome { - session_id: outcome.session_id.get(), - group_id: outcome.group_id.as_str().to_owned(), + session_id: running.session_id().get(), + group_id: pocketstation::DEFAULT_MULTISTEM_RECORDING_GROUP_ID.to_owned(), complete: outcome.state == pocketstation::SessionRecordingState::Complete, state: format!("{:?}", outcome.state).to_lowercase(), completed_stems: outcome.completed_stems, failed_stems: outcome.failed_stems, session_directory: outcome.session_dir.display().to_string(), - manifest_path: outcome.manifest_path.display().to_string(), - manifest_schema_version: outcome.manifest_schema_version, + manifest_path: outcome + .session_dir + .join(pocketstation::SESSION_RECORDING_MANIFEST_FILE_NAME) + .display() + .to_string(), + manifest_schema_version: pocketstation::SESSION_RECORDING_MANIFEST_SCHEMA_VERSION, error_code: pocketstation::session_recording_outcome_error_code(outcome) .map(|code| code.as_str().to_owned()), stems, diff --git a/native/src/sources.rs b/native/src/sources.rs index 27f2e19..d2c1fc6 100644 --- a/native/src/sources.rs +++ b/native/src/sources.rs @@ -27,7 +27,6 @@ pub(crate) enum SourceDeclaration { }, MicrophoneDefault, MicrophoneId(String), - SystemMix, } impl SourceDeclaration { @@ -62,7 +61,6 @@ impl SourceDeclaration { Self::MicrophoneId(device_id) => { Source::microphone(DeviceSelector::id(DeviceId::new(device_id.clone()))) } - Self::SystemMix => Source::system_mix(), } } } @@ -311,13 +309,6 @@ impl PythonSource { declaration: SourceDeclaration::MicrophoneId(device_id), }) } - - #[staticmethod] - const fn system_mix() -> Self { - Self { - declaration: SourceDeclaration::SystemMix, - } - } } fn platform_name(platform: Platform) -> &'static str { diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index 921202a..b10e564 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -68,8 +68,6 @@ class Source: def microphone_default() -> Source: ... @staticmethod def microphone_id(device_id: str) -> Source: ... - @staticmethod - def system_mix() -> Source: ... class DiscoveredSource: platform: str diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index 868e627..7d20933 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -474,21 +474,12 @@ def microphone_id(cls, device_id: str) -> Source: device_id, ) - @classmethod - def system_mix(cls) -> Source: - """Capture the host system output mix through native loopback.""" - return cls( - _native_call(_NativeSource.system_mix), - SourceKind.SYSTEM_MIX, - SourceSelectorKind.SYSTEM_MIX, - ) - @classmethod def from_discovered(cls, source: DiscoveredSource) -> Source: """Build the strongest supported Session declaration from discovery. - Output devices remain discovery-only. System mix is a built-in Session - source and retains the platform-owned loopback capability decision. + Output devices and system mix remain discovery-only in the stable 1.1 + Session declaration contract. """ stable_id = source.stable_id if stable_id.kind is SourceKind.APPLICATION: @@ -504,8 +495,6 @@ 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_mix() raise PocketStationError( "discovered " f"{stable_id.kind.value!r} is not a frozen built-in Session Source", diff --git a/tests/test_session.py b/tests/test_session.py index 29eb841..165d252 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,6 @@ from array import array -import pocketstation._native as _native import pytest from pocketstation._api import ( PocketStationError, @@ -46,7 +45,6 @@ def test_given_selector_family_when_declared_then_each_shape_is_available(): "bundle:com.spotify.client", ) assert Source.microphone_id("device-42") - assert Source.system_mix() def test_given_invalid_process_or_platform_when_declared_then_rejected(): @@ -79,20 +77,3 @@ def test_running_session_projects_native_lifecycle_state() -> None: assert running.stop().success assert running.state is SessionLifecycleState.STOPPED assert running.is_stopped - - -def test_system_mix_runs_through_the_canonical_capture_backend(tmp_path) -> None: - if not hasattr(_native.Session, "conformance"): - pytest.skip("native extension was not built with conformance-fixtures") - - session = Session._from_native(_native.Session.conformance(tmp_path, None, 256)) - system_mix = session.capture(Source.system_mix()) - system_mix.send(session.polled_audio()) - - running = session.start() - frame = running.audio.read(timeout_s=1.0) - stop = running.stop() - - assert frame is not None - assert frame.stem_id == system_mix.id - assert stop.success diff --git a/tests/test_sources.py b/tests/test_sources.py index ce7785d..3038de8 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -63,9 +63,6 @@ def test_source_declarations_are_immutable_and_descriptive() -> None: assert application.selector_value == "PocketStation Fixture" assert microphone.kind is SourceKind.INPUT_DEVICE assert microphone.selector_kind is SourceSelectorKind.MICROPHONE_DEFAULT - system_mix = Source.system_mix() - assert system_mix.kind is SourceKind.SYSTEM_MIX - assert system_mix.selector_kind is SourceSelectorKind.SYSTEM_MIX with pytest.raises(FrozenInstanceError): application.selector_value = "changed" @@ -84,13 +81,6 @@ def test_discovered_input_device_lowers_to_microphone_id() -> None: assert selected.selector_value == "device-42" -def test_discovered_system_mix_lowers_to_builtin_session_source() -> None: - selected = Source.from_discovered(_discovered(SourceKind.SYSTEM_MIX)) - - assert selected.kind is SourceKind.SYSTEM_MIX - assert selected.selector_kind is SourceSelectorKind.SYSTEM_MIX - - def test_discovered_source_projects_typed_pre_open_authorization_evidence() -> None: native_snapshot = SimpleNamespace( capability="available", From e47f06f1d0837061a14c5d60f1516ea6e7d9632b Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Sun, 23 Aug 2026 18:30:25 -0700 Subject: [PATCH 19/49] build: consume immutable Core 1.1.2 --- README.md | 9 ++++----- native/Cargo.lock | 4 +++- native/Cargo.toml | 7 ++----- python/pocketstation/compatibility.py | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5fc2a0a..2308045 100644 --- a/README.md +++ b/README.md @@ -156,13 +156,12 @@ does not have the same execution cost as Rust. | Linux wheel | Pending external qualification | | Windows wheel | Pending external qualification | | WAN/TURN receiver | Pending external qualification | -| Standalone sdist | Blocked by an unpublished Core correction | +| Standalone sdist | Qualified from the immutable Core 1.1.2 dependency | | PyPI release | Requires explicit release authorization | -The current `native/Cargo.toml` patches Core to a sibling checkout. That patch is -development provenance, not a distributable dependency. Wheel CI checks out the -pinned Core source commit used by the binding. Standalone sdist acceptance stays -blocked until that Core revision is released and the path patch is removed. +The native binding pins published Core `1.1.2` and the shared Relay connector +`0.1.1`. Wheel and source-distribution builds resolve those immutable registry +artifacts without a sibling repository checkout. The Rust-to-Python audio read currently copies native samples into Python-owned bytes before exposing a `memoryview`. The view avoids another Python-side copy; diff --git a/native/Cargo.lock b/native/Cargo.lock index 6046b22..5d4f615 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1413,7 +1413,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" -version = "1.1.1" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f53262a88999cacefba43c5d8cfbf6ff257a86acf2f1827bb05c237664d1febf" dependencies = [ "alsa", "cc", diff --git a/native/Cargo.toml b/native/Cargo.toml index 0a4ba4f..6479f31 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -16,13 +16,10 @@ default = [] conformance-fixtures = ["pocketstation/conformance-fixtures"] [dependencies] -pocketstation = "=1.1.1" +pocketstation = "=1.1.2" pocketstation-relay = "=0.1.1" pyo3 = { version = "0.27", features = ["abi3-py311"] } [dev-dependencies] -pocketstation = { version = "=1.1.1", features = ["conformance-fixtures"] } +pocketstation = { version = "=1.1.2", features = ["conformance-fixtures"] } tempfile = "3" - -[patch.crates-io] -pocketstation = { path = "../../pocketstation" } diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py index b6d9d3c..22344f0 100644 --- a/python/pocketstation/compatibility.py +++ b/python/pocketstation/compatibility.py @@ -19,7 +19,7 @@ class RuntimeCompatibility: RUNTIME_COMPATIBILITY = RuntimeCompatibility( sdk_version="0.1.0", - core_version="1.1.1", + core_version="1.1.2", relay_connector_version="0.1.1", python_requires=">=3.11", python_abi="abi3-py311", From 047a333750d52f614715fc5e455accf9584bb6c0 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 26 Aug 2026 10:29:00 -0700 Subject: [PATCH 20/49] feat: simplify application audio examples --- README.md | 138 ++++++++++-------- docs/README.md | 27 ++++ examples/README.md | 83 +++++------ examples/debug_voice_ai.py | 24 ++- examples/stream_any_app_audio.py | 29 ++++ python/pocketstation/aio/capture.py | 6 +- python/pocketstation/capture.py | 6 +- python/pocketstation_examples/__init__.py | 12 +- python/pocketstation_examples/demo.py | 27 ++-- .../pocketstation_examples/faster_whisper.py | 15 +- python/pocketstation_examples/relay.py | 27 ++++ python/pocketstation_examples/transcript.py | 35 ++++- tests/test_capture.py | 2 +- 13 files changed, 297 insertions(+), 134 deletions(-) create mode 100644 docs/README.md create mode 100644 examples/stream_any_app_audio.py create mode 100644 python/pocketstation_examples/relay.py diff --git a/README.md b/README.md index 2308045..b847750 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,101 @@ # PocketStation Python SDK -PocketStation lets you inspect both sides of a live desktop voice application. -It keeps the application's output separate from the physical microphone while -one native Session transcribes, publishes, and records both sides. +PocketStation lets Python applications capture one desktop application and an +optional microphone as separate live audio stems. One native Session can route +those stems to Python model code, Relay, and recording without mixing their +source identities. The Python SDK uses the PocketStation Rust engine. Python owns application and model logic; it does not reimplement capture, routing, timing, recording, or Relay media transport. -> **Status: PARTIAL.** The installed macOS wheel has completed the Lab workflow -> described below. The package is not published to PyPI. Linux and Windows -> wheels, WAN/TURN evidence, and a standalone source distribution remain -> release gates. +> **Status: preview.** The package is not published to PyPI. The macOS wheel has +> been tested with the workflow below. Linux and Windows wheels, plus WAN and +> TURN testing, are still in progress. The source distribution builds against +> PocketStation Core `1.1.2` and Relay `0.1.1`. + +## Capture a desktop application + +Install a development wheel: + +```bash +python -m pip install 'pocketstation @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' +``` + +Capture one application without opening a microphone or writing files: + +```python +import pocketstation + +with pocketstation.capture(application="Spotify") as live: + for frame in live.audio: + print(frame.source_id, frame.stem_id) +``` + +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. ## Debug a live voice application -The demo requires: +The voice-debug example requires: - macOS with Screen Recording and Microphone permission; - Python 3.11 or newer; - a PocketStation development wheel built for your Python and macOS target; - internet access on the first run to download the default faster-whisper - model; -- access to the configured PocketStation control plane and Relay. + model. -Install the wheel with its transcription dependency, then run one command: +Install the transcription extra, then run the example: ```bash python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' -pocketstation-demo +python examples/debug_voice_ai.py ``` -The command asks which desktop application to inspect. It then starts one -Session and: - -- opens the browser invitation after Relay confirms the publisher; -- prints each transcript with its original source identity; -- writes the application and microphone to separate recording stems. +The program asks which desktop voice application to inspect. It declares one +faster-whisper Operator, connects both stems to its audio input, and prints each +transcript with its original source identity. It does not start Relay or write a +recording. The Session runs this path concurrently: ```text voice application ─┐ -physical microphone┼─ faster-whisper transcripts - ├─ two named Relay/browser buses - └─ two aligned recording stems + ├─ one bounded faster-whisper Operator ─ transcripts +physical microphone┘ ``` -Press `Ctrl-C` to stop. PocketStation cancels pending model work, closes the -RelaySession, and finalizes the recording. +The complete composition is visible in +[`examples/debug_voice_ai.py`](examples/debug_voice_ai.py). The example-owned +adapter imports `faster_whisper.WhisperModel` when the Operator starts; it is +not built into the `pocketstation` SDK namespace. + +This example does not perform speaker diarization or conversational-agent +orchestration. + +## Stream any application audio to a browser -The installed command is implemented in one program under 50 lines: -[`python/pocketstation_examples/demo.py`](python/pocketstation_examples/demo.py). -The example package imports `faster_whisper.WhisperModel` when it starts. Its -adapter is example-owned and is not part of the `pocketstation` SDK namespace. +Run the Relay example when you want another person to listen in a browser: + +```bash +python examples/stream_any_app_audio.py +``` -This demo does not claim speaker diarization, conversational-agent behavior, -WAN/TURN qualification, or a zero-copy Rust-to-Python model boundary. +Choose any running application that is producing audio. The example publishes +that application as one named AudioBus, waits for Relay readiness, and prints a +single-use word code and browser URL. It does not open the microphone or record +audio. -## Capture application and microphone audio +The example uses PocketStation's small rate-limited demo service unless you set +`POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` to services you +operate. The shared URLs live in `pocketstation_examples`; application code does +not contain service credentials. -Use `capture()` when you want frames in Python as well as separate recordings: +## Read application and microphone audio + +Set the optional microphone and recording parameters when the workflow needs +both sides: ```python import pocketstation @@ -124,7 +159,7 @@ be used as native capture callbacks. Compiled native extensions remain the path for native provider code, and process sidecars remain available when crash isolation is required. -## Relay ownership +## Use Relay from Python Python creates and deletes RelaySessions through the typed HTTP control client. The shared Rust `pocketstation-relay` connector publishes media. The Go Relay @@ -144,20 +179,20 @@ Python callbacks still cross the interpreter boundary. Capture, routing, recording, and Relay transport remain native-speed; arbitrary Python model code does not have the same execution cost as Rust. -## Release qualification +## Current package status -| Gate | Current evidence | +| Area | Current status | |---|---| -| Native Rust, Python, Ruff, MyPy | Passing locally | -| Installed macOS wheel | REAL | -| Real faster-whisper inference | REAL | -| Same-host Relay and Chromium receiver | LOOPBACK-ONLY | -| Physical application and microphone | REAL-DEVICE-PROVEN on the recorded host | -| Linux wheel | Pending external qualification | -| Windows wheel | Pending external qualification | -| WAN/TURN receiver | Pending external qualification | -| Standalone sdist | Qualified from the immutable Core 1.1.2 dependency | -| PyPI release | Requires explicit release authorization | +| Native Rust, Python, Ruff, and MyPy checks | Pass locally | +| Installed macOS wheel | Tested | +| Real faster-whisper inference | Tested | +| Relay and Chromium receiver | Tested on the publisher host only | +| Physical application and microphone | Tested on the recorded macOS host | +| Linux wheel | Not yet tested externally | +| Windows wheel | Not yet tested externally | +| Receiver over WAN or TURN | Not yet tested externally | +| Standalone source distribution | Builds from the published Core 1.1.2 dependency | +| PyPI release | Not published | The native binding pins published Core `1.1.2` and the shared Relay connector `0.1.1`. Wheel and source-distribution builds resolve those immutable registry @@ -167,7 +202,7 @@ The Rust-to-Python audio read currently copies native samples into Python-owned bytes before exposing a `memoryview`. The view avoids another Python-side copy; the complete boundary is not zero-copy. -## Develop and verify +## Verify a local change ```bash uv sync --extra transcription @@ -177,20 +212,9 @@ uv run ruff format --check python tests examples uv run mypy python tests/qualification/typing_contract.py examples ``` -Run the installed product gate from the workspace root: - -```bash -bash pocketstation-lab/tests/test-w21-python-batch-transcription-artifact.sh -``` - -That gate builds and installs the wheel, runs faster-whisper inference, -publishes through the shared Relay connector, receives audio in Chromium, and -verifies the finalized multistem recording. Same-host evidence remains labeled -`LOOPBACK-ONLY`. - ## Reference -- [`examples/README.md`](examples/README.md) — product demo behavior and +- [`examples/README.md`](examples/README.md) — runnable examples and prerequisites. - `pocketstation.capture` — concise application and microphone capture. - `pocketstation.session` — Session declarations and lifecycle. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8eb21b0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# PocketStation Python documentation + +Start with the task you want to run. Open the module reference only when you +need the lower-level API. + +## Get started + +- [Capture one desktop application](../README.md#capture-a-desktop-application) +- [Debug both sides of a voice application](../README.md#debug-a-live-voice-application) +- [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 + +- [`pocketstation.session`](../python/pocketstation/session.py) owns synchronous + Session declaration and lifecycle. +- [`pocketstation.aio`](../python/pocketstation/aio/__init__.py) provides the + asyncio projection of the same native Session. +- [`pocketstation.graph`](../python/pocketstation/graph.py) declares stems, + routes, ports, and typed signals. +- [`pocketstation.source_authoring`](../python/pocketstation/source_authoring.py), + [`pocketstation.operator_authoring`](../python/pocketstation/operator_authoring.py), + and [`pocketstation.connector`](../python/pocketstation/connector.py) are the + open provider boundaries. + +For package status and current qualification limits, read the +[repository README](../README.md#current-package-status). diff --git a/examples/README.md b/examples/README.md index db1972e..5b9408e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,55 +1,52 @@ -# Debug both sides of a live voice application +# Python examples -Use this demo when you need to see what a desktop voice application produced -and what the person said into the microphone without mixing the two sides -together. It transcribes both sides, sends them to a browser, and records each -side as a separate stem. +Each example is a complete Python program. Start with the task you want to try. -## Prerequisites +## Debug both sides of a voice application -- macOS Screen Recording and Microphone permission; -- Python 3.11 or newer; -- an installed PocketStation wheel with the `transcription` extra; -- network access for the first model download and the configured Relay services. +Use this example to inspect what a desktop voice application produced and what +the person said into the microphone. PocketStation keeps both sources separate +while one faster-whisper Operator transcribes them. -## Run +```bash +python examples/debug_voice_ai.py +``` + +The example asks which running application to capture. Microphone capture is +explicit in the source, and no recording or cloud service starts. + +Install the optional model dependency before the first run: ```bash -pocketstation-demo +python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' ``` -Enter an application display name, process ID, or bundle identifier when -prompted. The command uses PocketStation's small, rate-limited demonstration -deployment by default. It can return `HTTP 429` when the shared capacity is in -use. Set `POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` to use a -deployment you operate. - -The checkout runner calls the same installed entry point: -[`debug_voice_ai.py`](debug_voice_ai.py). The complete installed command is a -program under 50 lines: [`demo.py`](../python/pocketstation_examples/demo.py). -Model buffering and provider code stay in the example-owned -`pocketstation_examples` package, outside the `pocketstation` SDK namespace. - -## Expected result - -After Relay confirms publication, the command opens a browser invitation. It -then prints transcript events produced by -`faster_whisper.WhisperModel`, including the source identity for each result. - -```text -voice application ─┐ -physical microphone┼─ independent local transcripts - ├─ two browser buses - └─ two recording stems +The first run may download the configured faster-whisper model. Model work runs +on a bounded off-realtime Operator worker, not on a capture callback. + +## Stream application audio to a browser + +Use this example to stream one selected application's audio as a named AudioBus +and open a browser invitation: + +```bash +python examples/stream_any_app_audio.py ``` -Press `Ctrl-C` to stop. The Session cancels pending model work, deletes the -remote RelaySession, and finalizes both recordings under `recordings/`. +The example prints the single-use word code and browser URL returned by the +control plane. It does not open a microphone or write a recording. The shared +Fly deployment is a small, rate-limited demonstration service and may return +`HTTP 429` when capacity is in use. It is not a hosted production service. + +To use services you operate, set `POCKETSTATION_CONTROL_URL` and +`POCKETSTATION_RELAY_URL` before running the command. No shared secret belongs +in application code. + +## Run capture, transcription, browser audio, and recording together -## Evidence boundary +The installed `pocketstation-demo` command combines independent application and +microphone capture, faster-whisper transcripts, two Relay/browser AudioBuses, +and a finalized two-stem recording. -The Lab gate installs the built wheel and uses faster-whisper inference, -the Rust Relay connector, the Go Relay service, Chromium, and finalized -recording artifacts. Its network path is same-host and remains -`LOOPBACK-ONLY`; it does not prove WAN or TURN behavior. The model runs on a -bounded off-realtime worker. The Rust-to-Python audio boundary is not zero-copy. +The current browser test runs on the same host as the publisher. WAN and TURN +behavior have not been verified yet. diff --git a/examples/debug_voice_ai.py b/examples/debug_voice_ai.py index 29941e4..c10123c 100644 --- a/examples/debug_voice_ai.py +++ b/examples/debug_voice_ai.py @@ -1,5 +1,23 @@ -"""Run the installed voice-AI debugging demo.""" +"""Transcribe both sides of a desktop voice application without mixing them.""" -from pocketstation_examples import main +import asyncio -main() +import pocketstation.aio as pks +from pocketstation_examples import FasterWhisper + + +async def main() -> None: + application = input("Desktop voice application: ") + live = pks.capture( + application=application, + microphone=True, + stream_audio=False, + ) + transcripts = FasterWhisper().transcribe(live) + + async with live: + async for transcript in transcripts: + print(f"source {transcript.source_id}: {transcript.text}") + + +asyncio.run(main()) diff --git a/examples/stream_any_app_audio.py b/examples/stream_any_app_audio.py new file mode 100644 index 0000000..448f7fc --- /dev/null +++ b/examples/stream_any_app_audio.py @@ -0,0 +1,29 @@ +"""Stream any selected desktop application's audio to a browser.""" + +import asyncio +import webbrowser + +import pocketstation.aio as pks +from pocketstation_examples import demo_relay_session + + +async def main() -> None: + application = input("Application to stream (for example, Spotify): ") + remote = await demo_relay_session(required_buses=("application",)) + live = pks.capture(application=application, stream_audio=False) + live.application_stem.publish(remote.publisher(live.session), "application") + + async with remote, live: + invitation = await remote.wait_for_publisher_and_invitation(timeout_seconds=30) + print(f"Invitation code: {invitation.join_code}") + print(f"Listen in a browser: {invitation.join_url}") + webbrowser.open(invitation.join_url) + await remote.wait_for_receiver(timeout_seconds=30) + print("Browser connected. Press Ctrl-C to stop.") + await asyncio.Event().wait() + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + pass diff --git a/python/pocketstation/aio/capture.py b/python/pocketstation/aio/capture.py index f471a80..c658b7a 100644 --- a/python/pocketstation/aio/capture.py +++ b/python/pocketstation/aio/capture.py @@ -32,7 +32,7 @@ def __init__( self, *, application: str | int, - microphone: bool | str = True, + microphone: bool | str = False, record_to: str | Path | None = None, stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, @@ -187,12 +187,12 @@ def _require_running(self) -> RunningSession: def capture( *, application: str | int, - microphone: bool | str = True, + microphone: bool | str = False, record_to: str | Path | None = None, stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> Capture: - """Declare a concise app+mic recipe backed by one native Rust Session.""" + """Capture one application and optionally add a microphone.""" return Capture( application=application, microphone=microphone, diff --git a/python/pocketstation/capture.py b/python/pocketstation/capture.py index c8c00f5..52c2c4e 100644 --- a/python/pocketstation/capture.py +++ b/python/pocketstation/capture.py @@ -32,7 +32,7 @@ def __init__( self, *, application: str | int, - microphone: bool | str = True, + microphone: bool | str = False, record_to: str | Path | None = None, stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, @@ -183,12 +183,12 @@ def _require_running(self) -> RunningSession: def capture( *, application: str | int, - microphone: bool | str = True, + microphone: bool | str = False, record_to: str | Path | None = None, stream_audio: bool = True, trace: SessionTraceConfiguration | None = None, ) -> Capture: - """Declare a concise app+mic recipe backed by one native Rust Session.""" + """Capture one application and optionally add a microphone.""" return Capture( application=application, microphone=microphone, diff --git a/python/pocketstation_examples/__init__.py b/python/pocketstation_examples/__init__.py index b3ed6c0..ba6f738 100644 --- a/python/pocketstation_examples/__init__.py +++ b/python/pocketstation_examples/__init__.py @@ -2,6 +2,14 @@ from .demo import main from .faster_whisper import FasterWhisper, FasterWhisperConfiguration -from .transcript import TRANSCRIPT_SIGNAL +from .relay import demo_relay_session +from .transcript import TRANSCRIPT_SIGNAL, Transcript -__all__ = ["TRANSCRIPT_SIGNAL", "FasterWhisper", "FasterWhisperConfiguration", "main"] +__all__ = [ + "TRANSCRIPT_SIGNAL", + "FasterWhisper", + "FasterWhisperConfiguration", + "Transcript", + "demo_relay_session", + "main", +] diff --git a/python/pocketstation_examples/demo.py b/python/pocketstation_examples/demo.py index d1009ea..cf8f75f 100644 --- a/python/pocketstation_examples/demo.py +++ b/python/pocketstation_examples/demo.py @@ -1,44 +1,35 @@ """Run the installed application-and-microphone product demo.""" import asyncio -import json -import os import webbrowser import pocketstation.aio as pks from .faster_whisper import FasterWhisper - -DEMO_CONTROL_PLANE_URL = "https://pocketstation-api.fly.dev" -DEMO_RELAY_URL = "https://pocketstation-relay.fly.dev" +from .relay import demo_relay_session async def run_demo() -> None: """Inspect both sides of a live voice application without mixing them.""" application = input("Desktop application name, PID, or bundle ID: ") - control_plane_url = os.getenv("POCKETSTATION_CONTROL_URL", DEMO_CONTROL_PLANE_URL) - relay_url = os.getenv("POCKETSTATION_RELAY_URL", DEMO_RELAY_URL) - if control_plane_url == DEMO_CONTROL_PLANE_URL: - print("Using the limited shared demo; HTTP 429 means it is busy.") - remote = await pks.RelaySession.create( - control_plane_url=control_plane_url, - relay_url=relay_url, - ) + remote = await demo_relay_session() live = pks.capture( - application=application, record_to="recordings", stream_audio=False + application=application, + microphone=True, + record_to="recordings", + stream_audio=False, ) publisher = remote.publisher(live.session) for bus, stem in zip(("application", "microphone"), live.stems, strict=True): stem.publish(publisher, bus) - transcripts = FasterWhisper().attach_many(live.session, live.stems) + transcripts = FasterWhisper().transcribe(live) async with remote, live: invitation = await remote.wait_for_publisher_and_invitation(timeout_seconds=30) print(f"Listen live: {invitation.join_url}", flush=True) webbrowser.open(invitation.join_url) await remote.wait_for_receiver(timeout_seconds=30) - async for event in live.signals(transcripts): - transcript = json.loads(event.payload) - print(f"source {transcript['source_id']}: {transcript['text']}", flush=True) + async for transcript in transcripts: + print(f"source {transcript.source_id}: {transcript.text}", flush=True) def main() -> None: diff --git a/python/pocketstation_examples/faster_whisper.py b/python/pocketstation_examples/faster_whisper.py index 2b34254..5aeac02 100644 --- a/python/pocketstation_examples/faster_whisper.py +++ b/python/pocketstation_examples/faster_whisper.py @@ -5,11 +5,12 @@ import asyncio import importlib import json -from collections.abc import Callable, Iterable, Mapping +from collections.abc import AsyncIterator, Callable, Iterable, Mapping from dataclasses import dataclass from time import monotonic_ns from typing import Any, Protocol, cast +from pocketstation.aio.capture import Capture from pocketstation.aio.operator_authoring import ( OperatorDeadlines as AsyncOperatorDeadlines, ) @@ -39,7 +40,7 @@ AudioWindowBuffer, mono_16khz, ) -from .transcript import TRANSCRIPT_SIGNAL +from .transcript import TRANSCRIPT_SIGNAL, Transcript class WhisperSegment(Protocol): @@ -416,6 +417,16 @@ def attach_many( signal=TRANSCRIPT_SIGNAL, ) + def transcribe(self, capture: Capture) -> AsyncIterator[Transcript]: + """Attach to every selected stem and return typed transcript results.""" + subscription = self.attach_many(capture.session, capture.stems) + + async def results() -> AsyncIterator[Transcript]: + async for event in capture.signals(subscription): + yield Transcript.from_json(event.payload) + + return results() + def _load_model(configuration: FasterWhisperConfiguration) -> WhisperModel: try: diff --git a/python/pocketstation_examples/relay.py b/python/pocketstation_examples/relay.py new file mode 100644 index 0000000..74564c8 --- /dev/null +++ b/python/pocketstation_examples/relay.py @@ -0,0 +1,27 @@ +"""Connect an example to the small shared PocketStation Relay service.""" + +import os +from collections.abc import Sequence + +import pocketstation.aio as pks + +_CONTROL_PLANE_URL = "https://pocketstation-api.fly.dev" +_RELAY_URL = "https://pocketstation-relay.fly.dev" + + +async def demo_relay_session( + *, required_buses: Sequence[str] = ("application", "microphone") +) -> pks.RelaySession: + """Create a RelaySession for an example or a user-operated deployment.""" + control_plane_url = os.getenv("POCKETSTATION_CONTROL_URL", _CONTROL_PLANE_URL) + relay_url = os.getenv("POCKETSTATION_RELAY_URL", _RELAY_URL) + if control_plane_url == _CONTROL_PLANE_URL: + print("Using the shared demo service. If it is busy, try again later.") + return await pks.RelaySession.create( + control_plane_url=control_plane_url, + relay_url=relay_url, + required_buses=tuple(required_buses), + ) + + +__all__ = ["demo_relay_session"] diff --git a/python/pocketstation_examples/transcript.py b/python/pocketstation_examples/transcript.py index 0640afc..6f24f01 100644 --- a/python/pocketstation_examples/transcript.py +++ b/python/pocketstation_examples/transcript.py @@ -1,4 +1,10 @@ -"""Typed transcript signal shared by the example transcription provider.""" +"""Typed transcript values emitted by the example transcription provider.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any from pocketstation.graph import SignalSpec, TextFormat @@ -8,4 +14,29 @@ schema="io.pocketstation.transcript.batch.v1", ) -__all__ = ["TRANSCRIPT_SIGNAL"] + +@dataclass(frozen=True, slots=True) +class Transcript: + """One source-aware faster-whisper result.""" + + source_id: int + text: str + language: str + timestamp_start_ns: int + timestamp_end_ns: int + discontinuity_reasons: tuple[str, ...] + + @classmethod + def from_json(cls, payload: str) -> Transcript: + value: Any = json.loads(payload) + return cls( + source_id=int(value["source_id"]), + text=str(value["text"]), + language=str(value["language"]), + timestamp_start_ns=int(value["timestamp_start_ns"]), + timestamp_end_ns=int(value["timestamp_end_ns"]), + discontinuity_reasons=tuple(value["discontinuity_reasons"]), + ) + + +__all__ = ["TRANSCRIPT_SIGNAL", "Transcript"] diff --git a/tests/test_capture.py b/tests/test_capture.py index 54f98b3..c84df91 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -113,7 +113,7 @@ def test_given_recipe_when_entered_then_one_session_owns_two_stems( def test_given_recipe_without_microphone_when_declared_then_one_stem(monkeypatch): monkeypatch.setattr(capture_module, "Session", FakeSession) - live = capture_module.capture(application="Spotify", microphone=False) + live = capture_module.capture(application="Spotify") assert len(FakeSession.latest.stems) == 1 assert live.microphone_stem is None From 8c0abca343df832111b9a152f8f46bdb900702dd Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Wed, 26 Aug 2026 11:37:08 -0700 Subject: [PATCH 21/49] docs: name transcription example honestly --- README.md | 12 ++++++------ docs/README.md | 2 +- examples/README.md | 4 ++-- .../{debug_voice_ai.py => transcribe_voice_app.py} | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) rename examples/{debug_voice_ai.py => transcribe_voice_app.py} (86%) diff --git a/README.md b/README.md index b847750..a27d27f 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ 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. -## Debug a live voice application +## Transcribe both sides of a voice application -The voice-debug example requires: +The transcription example requires: - macOS with Screen Recording and Microphone permission; - Python 3.11 or newer; @@ -50,7 +50,7 @@ Install the transcription extra, then run the example: ```bash python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' -python examples/debug_voice_ai.py +python examples/transcribe_voice_app.py ``` The program asks which desktop voice application to inspect. It declares one @@ -67,12 +67,12 @@ physical microphone┘ ``` The complete composition is visible in -[`examples/debug_voice_ai.py`](examples/debug_voice_ai.py). The example-owned +[`examples/transcribe_voice_app.py`](examples/transcribe_voice_app.py). The example-owned adapter imports `faster_whisper.WhisperModel` when the Operator starts; it is not built into the `pocketstation` SDK namespace. -This example does not perform speaker diarization or conversational-agent -orchestration. +This example does not debug turn handling, interruption, agent latency, or +browser playout. PocketStation does not receive those events in this program. ## Stream any application audio to a browser diff --git a/docs/README.md b/docs/README.md index 8eb21b0..f1eadae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ need the lower-level API. ## Get started - [Capture one desktop application](../README.md#capture-a-desktop-application) -- [Debug both sides of a voice application](../README.md#debug-a-live-voice-application) +- [Transcribe both sides of a voice application](../README.md#transcribe-both-sides-of-a-voice-application) - [Stream any application audio to a browser](../README.md#stream-any-application-audio-to-a-browser) - [Browse the runnable examples](../examples/README.md) diff --git a/examples/README.md b/examples/README.md index 5b9408e..4e44996 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,14 +2,14 @@ Each example is a complete Python program. Start with the task you want to try. -## Debug both sides of a voice application +## Transcribe both sides of a voice application Use this example to inspect what a desktop voice application produced and what the person said into the microphone. PocketStation keeps both sources separate while one faster-whisper Operator transcribes them. ```bash -python examples/debug_voice_ai.py +python examples/transcribe_voice_app.py ``` The example asks which running application to capture. Microphone capture is diff --git a/examples/debug_voice_ai.py b/examples/transcribe_voice_app.py similarity index 86% rename from examples/debug_voice_ai.py rename to examples/transcribe_voice_app.py index c10123c..dff5768 100644 --- a/examples/debug_voice_ai.py +++ b/examples/transcribe_voice_app.py @@ -1,4 +1,4 @@ -"""Transcribe both sides of a desktop voice application without mixing them.""" +"""Transcribe a desktop voice application and microphone as separate stems.""" import asyncio From 480efa9dacb4554d709f2f9e4fc34f67b54f7dc1 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 27 Aug 2026 11:09:42 -0700 Subject: [PATCH 22/49] Add bounded conversation composition --- native/src/audio_input.rs | 56 +- native/src/observations.rs | 13 + native/src/streams.rs | 6 + python/pocketstation/_native.pyi | 13 + python/pocketstation/aio/audio_input.py | 18 +- python/pocketstation/aio/conversation.py | 795 +++++++++++++++++++++++ python/pocketstation/aio/session.py | 36 + python/pocketstation/audio_input.py | 51 +- python/pocketstation/conversation.py | 298 +++++++++ python/pocketstation/graph.py | 43 +- python/pocketstation/observations.py | 4 + python/pocketstation/signal.py | 2 +- tests/conversation_support.py | 84 +++ tests/test_aio_conversation.py | 264 ++++++++ tests/test_audio_input.py | 32 + tests/test_conversation.py | 40 ++ tests/test_conversation_interruptions.py | 211 ++++++ tests/test_metrics.py | 1 + 18 files changed, 1951 insertions(+), 16 deletions(-) create mode 100644 python/pocketstation/aio/conversation.py create mode 100644 python/pocketstation/conversation.py create mode 100644 tests/conversation_support.py create mode 100644 tests/test_aio_conversation.py create mode 100644 tests/test_conversation.py create mode 100644 tests/test_conversation_interruptions.py diff --git a/native/src/audio_input.rs b/native/src/audio_input.rs index 9cc917b..a455433 100644 --- a/native/src/audio_input.rs +++ b/native/src/audio_input.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use pocketstation::{ AudioInput, AudioInputConfig, AudioInputObservations, AudioInputWriteError, - AudioInputWriteErrorKind, + AudioInputWriteErrorKind, OutputGeneration, }; use pyo3::buffer::PyBuffer; use pyo3::exceptions::{PyRuntimeError, PyValueError}; @@ -11,6 +11,28 @@ use pyo3::prelude::*; use crate::errors::coded_reason; use crate::graph::PythonSourceOutput; +#[pyclass(name = "_OutputGeneration", frozen)] +pub(crate) struct PythonOutputGeneration { + generation: OutputGeneration, +} + +#[pymethods] +impl PythonOutputGeneration { + #[getter] + fn id(&self) -> u64 { + self.generation.id().get() + } + + #[getter] + fn active(&self) -> bool { + self.generation.is_active() + } + + fn cancel(&self) { + let _ = self.generation.cancel(); + } +} + #[pyclass(name = "_AudioInputObservations", frozen)] pub(crate) struct PythonAudioInputObservations { observations: AudioInputObservations, @@ -48,6 +70,16 @@ impl PythonAudioInputObservations { self.observations.invalid_total } + #[getter] + fn discarded_output_frames_total(&self) -> u64 { + self.observations.discarded_output_frames_total + } + + #[getter] + fn inactive_output_writes_total(&self) -> u64 { + self.observations.inactive_output_writes_total + } + #[getter] fn cancelled(&self) -> bool { self.observations.cancelled @@ -106,12 +138,27 @@ impl PythonAudioInput { }) } - #[pyo3(signature = (samples, *, discontinuity=false))] + fn begin_output(&self) -> PyResult { + self.with_input(|input| { + input + .begin_output_generation() + .map(|generation| PythonOutputGeneration { generation }) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "audio_input.output_generation_limit", + error.to_string(), + )) + }) + }) + } + + #[pyo3(signature = (samples, *, discontinuity=false, generation=None))] fn try_write( &self, py: Python<'_>, samples: PyBuffer, discontinuity: bool, + generation: Option>, ) -> PyResult<()> { let source = samples.as_slice(py).ok_or_else(|| { PyValueError::new_err(coded_reason( @@ -130,6 +177,9 @@ impl PythonAudioInput { if discontinuity { buffer.mark_discontinuity(); } + if let Some(generation) = generation.as_deref() { + buffer.set_output_generation(&generation.generation); + } input.try_send(buffer).map_err(audio_input_write_error) }) } @@ -170,6 +220,7 @@ fn audio_input_write_error(error: AudioInputWriteError) -> PyErr { AudioInputWriteErrorKind::Full => "audio_input.full", AudioInputWriteErrorKind::Closed => "audio_input.closed", AudioInputWriteErrorKind::Cancelled => "audio_input.cancelled", + AudioInputWriteErrorKind::OutputGenerationInactive(_) => "audio_input.output_inactive", AudioInputWriteErrorKind::InvalidBuffer(_) => "audio_input.invalid_buffer", }; let message = error.to_string(); @@ -209,5 +260,6 @@ pub(crate) fn configuration( pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; + module.add_class::()?; Ok(()) } diff --git a/native/src/observations.rs b/native/src/observations.rs index edcf56d..194f1b4 100644 --- a/native/src/observations.rs +++ b/native/src/observations.rs @@ -563,6 +563,8 @@ pub(crate) struct PythonEdgeMetrics { worker_failures_total: u64, #[pyo3(get)] shutdown_discarded_total: u64, + #[pyo3(get)] + discarded_output_frames_total: u64, } #[pyclass(name = "SessionMetrics", frozen)] @@ -608,6 +610,8 @@ pub(crate) struct PythonSessionMetrics { #[pyo3(get)] audio_invalid_ownership_drops_total: u64, #[pyo3(get)] + audio_discarded_output_frames_total: u64, + #[pyo3(get)] audio_lease_capacity_count: u64, #[pyo3(get)] audio_outstanding_leases: u64, @@ -722,6 +726,8 @@ pub(crate) struct PythonRouteMetrics { #[pyo3(get)] shutdown_discarded_total: u64, #[pyo3(get)] + discarded_output_frames_total: u64, + #[pyo3(get)] endpoint_frames_received_total: u64, #[pyo3(get)] endpoint_frames_delivered_total: u64, @@ -995,6 +1001,7 @@ pub(crate) struct OwnedSessionMetrics { audio_frames_delivered_total: u64, audio_queue_full_drops_total: u64, audio_invalid_ownership_drops_total: u64, + audio_discarded_output_frames_total: u64, audio_lease_capacity_count: u64, audio_outstanding_leases: u64, audio_lease_exhausted_total: u64, @@ -1053,6 +1060,7 @@ pub(crate) struct OwnedRouteMetrics { source_timestamp_to_receive_max_ns: u64, worker_failures_total: u64, shutdown_discarded_total: u64, + discarded_output_frames_total: u64, pub(crate) endpoint_frames_received_total: u64, endpoint_frames_delivered_total: u64, endpoint_frames_dropped_total: u64, @@ -1208,6 +1216,7 @@ pub(crate) fn copy_metrics( source_timestamp_to_receive_max_ns: route.edge.source_timestamp_to_receive_max_ns, worker_failures_total: route.edge.worker_failures_total, shutdown_discarded_total: route.edge.shutdown_discarded_total, + discarded_output_frames_total: route.edge.discarded_output_frames_total, endpoint_frames_received_total: endpoint.frames_received_total, endpoint_frames_delivered_total: endpoint.frames_delivered_total, endpoint_frames_dropped_total: endpoint.frames_dropped_total, @@ -1246,6 +1255,7 @@ pub(crate) fn copy_metrics( audio_frames_delivered_total: audio.frames_delivered_total, audio_queue_full_drops_total: audio.queue_full_drops_total, audio_invalid_ownership_drops_total: audio.invalid_ownership_drops_total, + audio_discarded_output_frames_total: audio.discarded_output_frames_total, audio_lease_capacity_count: audio.lease_capacity_count, audio_outstanding_leases: audio.outstanding_leases, audio_lease_exhausted_total: audio.lease_exhausted_total, @@ -1698,6 +1708,7 @@ impl From for PythonEdgeMetrics { source_timestamp_to_receive_max_ns: edge.source_timestamp_to_receive_max_ns, worker_failures_total: edge.worker_failures_total, shutdown_discarded_total: edge.shutdown_discarded_total, + discarded_output_frames_total: edge.discarded_output_frames_total, } } } @@ -2109,6 +2120,7 @@ pub(crate) fn python_session_metrics( source_timestamp_to_receive_max_ns: route.source_timestamp_to_receive_max_ns, worker_failures_total: route.worker_failures_total, shutdown_discarded_total: route.shutdown_discarded_total, + discarded_output_frames_total: route.discarded_output_frames_total, endpoint_frames_received_total: route.endpoint_frames_received_total, endpoint_frames_delivered_total: route.endpoint_frames_delivered_total, endpoint_frames_dropped_total: route.endpoint_frames_dropped_total, @@ -2147,6 +2159,7 @@ pub(crate) fn python_session_metrics( audio_frames_delivered_total: metrics.audio_frames_delivered_total, audio_queue_full_drops_total: metrics.audio_queue_full_drops_total, audio_invalid_ownership_drops_total: metrics.audio_invalid_ownership_drops_total, + audio_discarded_output_frames_total: metrics.audio_discarded_output_frames_total, audio_lease_capacity_count: metrics.audio_lease_capacity_count, audio_outstanding_leases: metrics.audio_outstanding_leases, audio_lease_exhausted_total: metrics.audio_lease_exhausted_total, diff --git a/native/src/streams.rs b/native/src/streams.rs index 1ec5190..698fc0c 100644 --- a/native/src/streams.rs +++ b/native/src/streams.rs @@ -83,6 +83,8 @@ pub(crate) struct PythonAudioFrame { #[pyo3(get)] permission_epoch: u64, #[pyo3(get)] + output_generation_id: Option, + #[pyo3(get)] endpoint_id: u64, #[pyo3(get)] connector_id: Option, @@ -192,6 +194,7 @@ pub(crate) struct OwnedAudioFrame { pub(crate) source_generation: u32, pub(crate) discontinuity_epoch: u64, pub(crate) permission_epoch: u64, + pub(crate) output_generation_id: Option, pub(crate) endpoint_id: u64, pub(crate) connector_id: Option, pub(crate) route_id: u64, @@ -264,6 +267,7 @@ fn copy_polled_audio_batch( source_generation: lineage.source_generation(), discontinuity_epoch: lineage.discontinuity_epoch(), permission_epoch: lineage.permission_epoch(), + output_generation_id: frame.output_generation_id().map(|id| id.get()), endpoint_id: frame.endpoint_id().get(), connector_id: Some(frame.connector_id().get()), route_id: frame.route_id().get(), @@ -340,6 +344,7 @@ pub(crate) fn owned_endpoint_audio_frame_for_route( source_generation: lineage.source_generation(), discontinuity_epoch: lineage.discontinuity_epoch(), permission_epoch: lineage.permission_epoch(), + output_generation_id: frame.output_generation_id().map(|id| id.get()), endpoint_id, connector_id, route_id, @@ -367,6 +372,7 @@ pub(crate) fn python_audio_frame(py: Python<'_>, frame: OwnedAudioFrame) -> Pyth source_generation: frame.source_generation, discontinuity_epoch: frame.discontinuity_epoch, permission_epoch: frame.permission_epoch, + output_generation_id: frame.output_generation_id, endpoint_id: frame.endpoint_id, connector_id: frame.connector_id, route_id: frame.route_id, diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index b10e564..2d15a5b 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -523,6 +523,7 @@ class AudioFrame: source_generation: int discontinuity_epoch: int permission_epoch: int + output_generation_id: int | None endpoint_id: EndpointId connector_id: ConnectorId | None route_id: RouteId @@ -696,6 +697,7 @@ class _EdgeMetrics: source_timestamp_to_receive_max_ns: int worker_failures_total: int shutdown_discarded_total: int + discarded_output_frames_total: int class RouteMetrics: route_id: int @@ -736,6 +738,7 @@ class RouteMetrics: source_timestamp_to_receive_max_ns: int worker_failures_total: int shutdown_discarded_total: int + discarded_output_frames_total: int endpoint_frames_received_total: int endpoint_frames_delivered_total: int endpoint_frames_dropped_total: int @@ -882,6 +885,7 @@ class SessionMetrics: audio_frames_delivered_total: int audio_queue_full_drops_total: int audio_invalid_ownership_drops_total: int + audio_discarded_output_frames_total: int audio_lease_capacity_count: int audio_outstanding_leases: int audio_lease_exhausted_total: int @@ -1028,18 +1032,27 @@ class _AudioInputObservations: accepted_total: int full_total: int invalid_total: int + discarded_output_frames_total: int + inactive_output_writes_total: int cancelled: bool closed: bool +class _OutputGeneration: + id: int + active: bool + def cancel(self) -> None: ... + class _AudioInput: source_id: SourceId stream_id: StreamId output: SourceOutput + def begin_output(self) -> _OutputGeneration: ... def try_write( self, samples: object, *, discontinuity: bool = False, + generation: _OutputGeneration | None = None, ) -> None: ... def close(self) -> None: ... def observations(self) -> _AudioInputObservations: ... diff --git a/python/pocketstation/aio/audio_input.py b/python/pocketstation/aio/audio_input.py index 6dd3917..b51921d 100644 --- a/python/pocketstation/aio/audio_input.py +++ b/python/pocketstation/aio/audio_input.py @@ -8,6 +8,7 @@ from ..audio_input import ( AudioInputConfig, AudioInputObservations, + OutputGeneration, ) from ..audio_input import ( PcmSource as SyncPcmSource, @@ -38,11 +39,15 @@ def stream_id(self) -> int: def output(self) -> SourceOutput: return self._source.output + def begin_output(self) -> OutputGeneration: + return self._source.begin_output() + async def try_write( self, samples: object, *, discontinuity: bool = False, + generation: OutputGeneration | None = None, ) -> None: """Attempt one immediate write into Core's finite preallocated pool. @@ -51,7 +56,11 @@ async def try_write( buffer, so dispatching every write through the thread pool would add scheduling overhead without making the operation more asynchronous. """ - self._source.try_write(samples, discontinuity=discontinuity) + self._source.try_write( + samples, + discontinuity=discontinuity, + generation=generation, + ) async def close(self) -> None: """Close the native input immediately after its accepted frames drain.""" @@ -70,6 +79,7 @@ async def write( samples: object, *, discontinuity: bool = False, + generation: OutputGeneration | None = None, timeout_s: float = 1.0, ) -> None: """Wait finitely for one native buffer without growing a Python queue.""" @@ -81,7 +91,11 @@ async def write( wait_s = 0.000_25 while True: try: - await self.try_write(samples, discontinuity=discontinuity) + await self.try_write( + samples, + discontinuity=discontinuity, + generation=generation, + ) return except AudioInputFullError: remaining = deadline - monotonic() diff --git a/python/pocketstation/aio/conversation.py b/python/pocketstation/aio/conversation.py new file mode 100644 index 0000000..b2d58de --- /dev/null +++ b/python/pocketstation/aio/conversation.py @@ -0,0 +1,795 @@ +"""Continuous voice composition over one running native Session.""" + +from __future__ import annotations + +import asyncio +import inspect +from collections import OrderedDict, deque +from collections.abc import AsyncIterable, Awaitable, Callable +from dataclasses import dataclass +from time import monotonic_ns +from typing import TypeAlias, cast + +from ..audio_input import OutputGeneration +from ..conversation import ( + ConversationConfig, + ConversationContext, + ConversationDisposition, + ConversationEvent, + ConversationMessage, + ConversationOutcome, + ConversationResponse, + ConversationResponseChunk, + ConversationRole, + ConversationTurn, + TranscriptUpdate, +) +from ..signal import BusSubscription, EndOfStream, SignalEnvelope +from .audio_input import AudioInput + +ResponseItem: TypeAlias = str | ConversationResponse | ConversationResponseChunk +ResponseResult: TypeAlias = ( + ResponseItem + | AsyncIterable[ResponseItem] + | Awaitable[ResponseItem | AsyncIterable[ResponseItem]] +) +SynthesisResult: TypeAlias = AsyncIterable[object] | Awaitable[AsyncIterable[object]] + + +ResponseHandler: TypeAlias = Callable[ + [TranscriptUpdate, ConversationContext], ResponseResult +] +SynthesisHandler: TypeAlias = Callable[ + [ConversationResponseChunk, ConversationTurn], SynthesisResult +] +TranscriptDecoder: TypeAlias = Callable[[SignalEnvelope[str]], TranscriptUpdate | None] + + +@dataclass(frozen=True, slots=True) +class _TranscriptRecord: + revision: int + stable_prefix: str + final: bool + + +class _TranscriptState: + def __init__(self, capacity: int, maximum_characters: int) -> None: + self._capacity = capacity + self._maximum_characters = maximum_characters + self._records: OrderedDict[str, _TranscriptRecord] = OrderedDict() + + def accept(self, update: TranscriptUpdate) -> None: + if len(update.text) > self._maximum_characters: + raise ValueError("transcript exceeded maximum_transcript_characters") + previous = self._records.get(update.utterance_id) + if previous is not None: + if previous.final: + raise ValueError("a final utterance cannot receive another revision") + if update.revision <= previous.revision: + raise ValueError("transcript revisions must increase") + if not update.stable_prefix.startswith(previous.stable_prefix): + raise ValueError("stable transcript text cannot change or shrink") + self._records.move_to_end(update.utterance_id) + elif len(self._records) >= self._capacity: + oldest_id, oldest = next(iter(self._records.items())) + if not oldest.final: + raise RuntimeError("transcript_state_capacity is exhausted") + del self._records[oldest_id] + self._records[update.utterance_id] = _TranscriptRecord( + revision=update.revision, + stable_prefix=update.stable_prefix, + final=update.final, + ) + + +@dataclass(slots=True) +class _Speculation: + update: TranscriptUpdate + task: asyncio.Task[tuple[ConversationResponseChunk, ...]] + + +@dataclass(slots=True) +class _Delivery: + turn: ConversationTurn + generation: OutputGeneration + task: asyncio.Task[None] + settled: bool = False + interruption_counted: bool = False + + +class Conversation: + """Coordinate transcript, response, synthesis, and generated audio work. + + The native Session continues to own Sources, routing, recording, and + Connector delivery. This object owns only finite provider work and retained + conversation state. Partial transcripts may prepare a response, but audio + is not emitted until the transcript is final. + """ + + def __init__( + self, + *, + transcripts: BusSubscription[str], + respond: ResponseHandler, + synthesize: SynthesisHandler, + output: AudioInput, + config: ConversationConfig | None = None, + decode_transcript: TranscriptDecoder | None = None, + ) -> None: + if transcripts.session_id != int(output.output.session_id): + raise ValueError("transcripts and output must belong to the same Session") + self._transcripts = transcripts + self._respond = respond + self._synthesize = synthesize + self._output = output + self._config = ConversationConfig() if config is None else config + self._decode_transcript = ( + _default_transcript_decoder + if decode_transcript is None + else decode_transcript + ) + self._transcript_state = _TranscriptState( + self._config.transcript_state_capacity, + self._config.maximum_transcript_characters, + ) + self._history: deque[ConversationMessage] = deque( + maxlen=self._config.history_capacity + ) + self._events: deque[ConversationEvent] = deque( + maxlen=self._config.event_capacity + ) + self._stop_requested = asyncio.Event() + self._running = False + self._has_run = False + self._discontinuity_pending = False + self._turns_started = 0 + self._turns_completed = 0 + self._turns_interrupted = 0 + self._transcript_updates_received = 0 + self._speculative_responses_started = 0 + self._speculative_responses_reused = 0 + self._output_generations_cancelled = 0 + self._output_frames_written = 0 + self._outcome: ConversationOutcome | None = None + + @property + def config(self) -> ConversationConfig: + return self._config + + @property + def outcome(self) -> ConversationOutcome | None: + return self._outcome + + @property + def history(self) -> tuple[ConversationMessage, ...]: + return tuple(self._history) + + @property + def events(self) -> tuple[ConversationEvent, ...]: + return tuple(self._events) + + def stop(self) -> None: + """Request a normal stop at the next finite signal wait.""" + self._stop_requested.set() + + async def run(self, running: object) -> ConversationOutcome: + """Run until the transcript endpoint closes or :meth:`stop` is called.""" + from .session import RunningSession + + if not isinstance(running, RunningSession): + raise TypeError("running must be a pocketstation.aio.RunningSession") + if self._running: + raise RuntimeError("Conversation is already running") + if self._has_run: + raise RuntimeError("Conversation can run only once") + if int(running.session_id) != self._transcripts.session_id: + raise ValueError("running Session does not own this conversation") + + self._running = True + self._has_run = True + disposition = "completed" + failure: str | None = None + delivery: _Delivery | None = None + speculation: _Speculation | None = None + stream = running.signals(self._transcripts) + started_providers: list[object] = [] + try: + for provider in _unique_providers(self._respond, self._synthesize): + await self._provider_lifecycle(provider, "start") + started_providers.append(provider) + while not self._stop_requested.is_set(): + if ( + delivery is not None + and delivery.task.done() + and not delivery.settled + ): + await delivery.task + delivery.settled = True + result = await stream.read(timeout_s=self._config.signal_wait_timeout_s) + if isinstance(result, EndOfStream): + break + if result is None: + continue + update = self._decode_transcript(result) + if update is None: + continue + self._transcript_state.accept(update) + self._transcript_updates_received += 1 + self._event("transcript.updated", update=update) + + if delivery is not None and update.interrupts: + await self._interrupt_delivery(delivery) + delivery = None + + if not update.final: + if not update.text.strip(): + continue + if speculation is not None and ( + speculation.update.utterance_id != update.utterance_id + or speculation.update.text != update.text + ): + await self._cancel_speculation(speculation) + speculation = None + if speculation is None: + self._speculative_responses_started += 1 + self._event("response.preparing", update=update) + speculation = _Speculation( + update=update, + task=asyncio.create_task(self._prepare_response(update)), + ) + continue + + prepared: tuple[ConversationResponseChunk, ...] | None = None + if speculation is not None: + if ( + speculation.update.utterance_id == update.utterance_id + and speculation.update.text == update.text + ): + prepared = await speculation.task + self._speculative_responses_reused += 1 + self._event("response.prepared", update=update) + else: + await self._cancel_speculation(speculation) + speculation = None + + turn = self._turn(result, update) + self._turns_started += 1 + self._append_message("user", update.text, turn.id) + self._event("turn.started", turn=turn, update=update) + generation = self._output.begin_output() + self._event( + "output.started", + turn=turn, + update=update, + generation=generation, + ) + delivery = _Delivery( + turn=turn, + generation=generation, + task=asyncio.create_task( + self._deliver_response(turn, update, generation, prepared) + ), + ) + await asyncio.sleep(0) + + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None: + if self._stop_requested.is_set(): + await self._interrupt_delivery(delivery) + disposition = "stopped" + elif not delivery.settled: + await delivery.task + delivery.settled = True + elif self._stop_requested.is_set(): + disposition = "stopped" + await self._wait_output_drained(running) + except asyncio.CancelledError: + disposition = "cancelled" + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None: + await self._interrupt_delivery(delivery) + raise + except Exception as error: + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None and not delivery.task.done(): + await self._interrupt_delivery(delivery) + disposition = "failed" + failure = f"{type(error).__name__}: {error}" + self._event("conversation.failed", detail=failure) + finally: + for provider in reversed(started_providers): + try: + await self._provider_lifecycle(provider, "aclose") + except Exception as error: + disposition = "failed" + failure = f"provider close failed: {type(error).__name__}: {error}" + self._event("provider.close_failed", detail=failure) + self._outcome = ConversationOutcome( + disposition=cast(ConversationDisposition, disposition), + turns_started=self._turns_started, + turns_completed=self._turns_completed, + turns_interrupted=self._turns_interrupted, + transcript_updates_received=self._transcript_updates_received, + speculative_responses_started=self._speculative_responses_started, + speculative_responses_reused=self._speculative_responses_reused, + output_generations_cancelled=self._output_generations_cancelled, + output_frames_written=self._output_frames_written, + history=tuple(self._history), + events=tuple(self._events), + failure=failure, + ) + self._running = False + return self._outcome + + async def _prepare_response( + self, + update: TranscriptUpdate, + ) -> tuple[ConversationResponseChunk, ...]: + chunks: list[ConversationResponseChunk] = [] + characters = 0 + async for chunk in self._response_chunks(update, committed=False): + if chunk.tool_events: + raise ValueError("a speculative response cannot request tool work") + chunks.append(chunk) + characters += len(chunk.text) + self._check_response_bounds(len(chunks), characters, 0) + if not any(chunk.text for chunk in chunks): + raise ValueError("response provider produced no text") + return tuple(chunks) + + async def _deliver_response( + self, + turn: ConversationTurn, + update: TranscriptUpdate, + generation: OutputGeneration, + prepared: tuple[ConversationResponseChunk, ...] | None, + ) -> None: + response_started = monotonic_ns() + response_text: list[str] = [] + response_chunks = 0 + response_characters = 0 + tool_events = 0 + frames = 0 + synthesis_started: int | None = None + synthesis_deadline = ( + asyncio.get_running_loop().time() + self._config.synthesis_timeout_s + ) + chunks: AsyncIterable[ConversationResponseChunk] + if prepared is None: + chunks = self._response_chunks(update, committed=True) + else: + chunks = _iter_prepared(prepared) + + async for chunk in chunks: + response_chunks += 1 + response_characters += len(chunk.text) + tool_events += len(chunk.tool_events) + self._check_response_bounds( + response_chunks, + response_characters, + tool_events, + ) + response_text.append(chunk.text) + self._event( + "response.chunk", + turn=turn, + update=update, + generation=generation, + ) + for tool in chunk.tool_events: + detail = f": {tool.detail}" if tool.detail else "" + self._append_message( + "tool", + f"{tool.name}: {tool.outcome}{detail}", + turn.id, + ) + self._event( + "tool.completed", + turn=turn, + update=update, + generation=generation, + detail=f"{tool.name}:{tool.outcome}", + ) + if not chunk.text: + continue + if synthesis_started is None: + synthesis_started = monotonic_ns() + self._event( + "synthesis.started", + turn=turn, + update=update, + generation=generation, + ) + produced = self._synthesize(chunk, turn) + if inspect.isawaitable(produced): + produced = await asyncio.wait_for( + produced, + timeout=self._remaining(synthesis_deadline, "synthesis"), + ) + async for samples in _iterate_until( + produced, + synthesis_deadline, + "synthesis", + ): + if not generation.active: + raise asyncio.CancelledError + frames += 1 + if frames > self._config.maximum_output_frames_per_turn: + raise ValueError( + "synthesis exceeded maximum_output_frames_per_turn" + ) + await self._output.write( + samples, + discontinuity=self._discontinuity_pending, + generation=generation, + timeout_s=min( + self._config.output_write_timeout_s, + self._remaining(synthesis_deadline, "synthesis"), + ), + ) + self._discontinuity_pending = False + self._output_frames_written += 1 + + text = "".join(response_text) + if not text.strip(): + raise ValueError("response provider produced no text") + self._event( + "response.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=monotonic_ns() - response_started, + ) + self._event( + "synthesis.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=( + None + if synthesis_started is None + else monotonic_ns() - synthesis_started + ), + detail=f"frames={frames}", + ) + self._append_message("assistant", text, turn.id) + self._turns_completed += 1 + self._event( + "turn.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=monotonic_ns() - response_started, + ) + + async def _response_chunks( + self, + update: TranscriptUpdate, + *, + committed: bool, + ) -> AsyncIterable[ConversationResponseChunk]: + deadline = asyncio.get_running_loop().time() + self._config.response_timeout_s + produced = self._respond( + update, + ConversationContext(tuple(self._history), committed=committed), + ) + if inspect.isawaitable(produced): + produced = await asyncio.wait_for( + produced, + timeout=self._remaining(deadline, "response"), + ) + if isinstance(produced, AsyncIterable): + async for value in _iterate_until(produced, deadline, "response"): + yield _response_chunk(value) + else: + yield _response_chunk(produced) + + def _check_response_bounds( + self, + chunks: int, + characters: int, + tool_events: int, + ) -> None: + if chunks > self._config.maximum_response_chunks_per_turn: + raise ValueError("response exceeded maximum_response_chunks_per_turn") + if characters > self._config.maximum_response_characters: + raise ValueError("response exceeded maximum_response_characters") + if tool_events > self._config.maximum_tool_events_per_turn: + raise ValueError("response exceeded maximum_tool_events_per_turn") + + async def _interrupt_delivery(self, delivery: _Delivery) -> None: + if delivery.generation.active: + delivery.generation.cancel() + self._output_generations_cancelled += 1 + self._discontinuity_pending = True + self._event( + "output.cancelled", + turn=delivery.turn, + generation=delivery.generation, + ) + if not delivery.task.done(): + delivery.task.cancel() + try: + await asyncio.wait_for( + delivery.task, + timeout=self._config.cancellation_timeout_s, + ) + except asyncio.CancelledError: + pass + except TimeoutError as error: + raise TimeoutError( + "provider did not stop within cancellation_timeout_s" + ) from error + if not delivery.interruption_counted: + self._turns_interrupted += 1 + delivery.interruption_counted = True + self._event("turn.interrupted", turn=delivery.turn) + + async def _cancel_speculation(self, speculation: _Speculation) -> None: + if speculation.task.done(): + await speculation.task + return + speculation.task.cancel() + try: + await asyncio.wait_for( + speculation.task, + timeout=self._config.cancellation_timeout_s, + ) + except asyncio.CancelledError: + self._event("response.preparation_cancelled", update=speculation.update) + except TimeoutError as error: + raise TimeoutError( + "speculative response did not stop within cancellation_timeout_s" + ) from error + + async def _provider_lifecycle(self, provider: object, method_name: str) -> None: + method = getattr(provider, method_name, None) + if method is None: + return + timeout_s = ( + self._config.provider_close_timeout_s + if method_name == "aclose" + else self._config.provider_start_timeout_s + ) + if inspect.iscoroutinefunction(method): + result = await asyncio.wait_for(method(), timeout=timeout_s) + else: + result = await asyncio.wait_for( + asyncio.to_thread(method), + timeout=timeout_s, + ) + if inspect.isawaitable(result): + await asyncio.wait_for(result, timeout=timeout_s) + + async def _wait_output_drained(self, running: object) -> None: + from .session import RunningSession + + if not isinstance(running, RunningSession): + raise TypeError("running must be a pocketstation.aio.RunningSession") + deadline = ( + asyncio.get_running_loop().time() + self._config.output_drain_timeout_s + ) + route_ids, endpoint_ids = self._output.output._delivery_targets() + wait_s = 0.000_25 + while True: + observations = await self._output.observations() + metrics = await running.metrics() + routes = tuple( + route + for route in metrics.routes + if route.route_id in route_ids or route.endpoint_id in endpoint_ids + ) + if any( + route.frames_dropped_total > 0 + or route.endpoint.frames_dropped_total > 0 + or route.endpoint.failures_total > 0 + for route in routes + ): + raise RuntimeError( + "generated audio delivery failed before output drained" + ) + routes_drained = bool(routes) and all( + route.edge.queue_depth_frames == 0 + and route.edge.frames_delivered_total + + route.edge.discarded_output_frames_total + >= self._output_frames_written + for route in routes + ) + buffers_reclaimed = ( + observations.available_buffers == observations.buffer_slots + ) + no_declared_delivery = not route_ids and not endpoint_ids + if routes_drained or (no_declared_delivery and buffers_reclaimed): + self._event("output.drained") + return + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError( + "generated audio did not drain within output_drain_timeout_s" + ) + await asyncio.sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) + + def _turn( + self, + envelope: SignalEnvelope[str], + update: TranscriptUpdate, + ) -> ConversationTurn: + lineage = envelope.lineage + return ConversationTurn( + id=self._turns_started + 1, + utterance_id=update.utterance_id, + text=update.text, + source_id=( + update.source_id + if update.source_id is not None + else None + if lineage is None + else lineage.source_id + ), + stream_id=( + update.stream_id + if update.stream_id is not None + else None + if lineage is None + else lineage.stream_id + ), + source_sequence=( + update.source_sequence + if update.source_sequence is not None + else None + if lineage is None + else lineage.sequence_number + ), + source_timestamp_ns=( + update.source_timestamp_ns + if update.source_timestamp_ns is not None + else envelope.timing.source_timestamp_ns + ), + audio_start_ns=update.audio_start_ns, + audio_end_ns=update.audio_end_ns, + received_timestamp_ns=monotonic_ns(), + ) + + def _append_message(self, role: str, content: str, turn_id: int) -> None: + self._history.append( + ConversationMessage( + role=cast(ConversationRole, role), + content=content, + turn_id=turn_id, + timestamp_ns=monotonic_ns(), + ) + ) + + def _event( + self, + kind: str, + *, + turn: ConversationTurn | None = None, + update: TranscriptUpdate | None = None, + generation: OutputGeneration | None = None, + duration_ns: int | None = None, + detail: str | None = None, + ) -> None: + self._events.append( + ConversationEvent( + kind=kind, + timestamp_ns=monotonic_ns(), + turn_id=None if turn is None else turn.id, + utterance_id=( + update.utterance_id + if update is not None + else None + if turn is None + else turn.utterance_id + ), + transcript_revision=None if update is None else update.revision, + output_generation_id=None if generation is None else generation.id, + duration_ns=duration_ns, + detail=detail, + ) + ) + + @staticmethod + def _remaining(deadline: float, operation: str) -> float: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"{operation} exceeded its configured timeout") + return remaining + + +def _default_transcript_decoder( + envelope: SignalEnvelope[str], +) -> TranscriptUpdate | None: + if not isinstance(envelope.payload, str): + raise TypeError("conversation transcript signals must contain text") + text = envelope.payload.strip() + if not text: + return None + lineage = envelope.lineage + source = "unknown" if lineage is None else str(lineage.source_id) + sequence = 0 if lineage is None else lineage.sequence_number + audio_start_ns = envelope.timing.source_timestamp_ns + duration_ns = envelope.timing.duration_ns + audio_end_ns = ( + None + if audio_start_ns is None or duration_ns is None + else audio_start_ns + duration_ns + ) + return TranscriptUpdate( + utterance_id=f"{source}:{sequence}", + revision=1, + text=text, + stable_prefix=text, + final=True, + source_id=None if lineage is None else lineage.source_id, + stream_id=None if lineage is None else lineage.stream_id, + source_sequence=None if lineage is None else lineage.sequence_number, + source_timestamp_ns=envelope.timing.source_timestamp_ns, + audio_start_ns=audio_start_ns, + audio_end_ns=audio_end_ns, + ) + + +def _response_chunk(value: object) -> ConversationResponseChunk: + if isinstance(value, ConversationResponseChunk): + return value + if isinstance(value, ConversationResponse): + return ConversationResponseChunk(value.text, value.tool_events) + if isinstance(value, str): + return ConversationResponseChunk(value) + raise TypeError( + "response provider must return text, ConversationResponse, " + "ConversationResponseChunk, or an async iterable of those values" + ) + + +async def _iterate_until( + values: AsyncIterable[object], + deadline: float, + operation: str, +) -> AsyncIterable[object]: + iterator = aiter(values) + try: + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"{operation} exceeded its configured timeout") + try: + yield await asyncio.wait_for(anext(iterator), timeout=remaining) + except StopAsyncIteration: + return + finally: + close = getattr(iterator, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + + +async def _iter_prepared( + chunks: tuple[ConversationResponseChunk, ...], +) -> AsyncIterable[ConversationResponseChunk]: + for chunk in chunks: + yield chunk + + +def _unique_providers(*providers: object) -> tuple[object, ...]: + unique: list[object] = [] + identities: set[int] = set() + for provider in providers: + if id(provider) not in identities: + unique.append(provider) + identities.add(id(provider)) + return tuple(unique) + + +__all__ = [ + "Conversation", + "ResponseHandler", + "SynthesisHandler", + "TranscriptDecoder", +] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index 56c1d0a..e83a9d1 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -70,7 +70,14 @@ from .streams import AudioStream, SignalStream if TYPE_CHECKING: + from ..conversation import ConversationConfig, TranscriptUpdate from ..relay import RelayPublisher + from ..signal import SignalEnvelope + from .conversation import ( + Conversation, + ResponseHandler, + SynthesisHandler, + ) from .relay import RelaySession _Result = TypeVar("_Result") @@ -505,6 +512,35 @@ def relay(self, remote: RelaySession) -> RelayPublisher: """Declare the existing bounded Rust relay connector.""" return remote.publisher(self) + def conversation( + self, + *, + transcripts: BusSubscription[str], + respond: ResponseHandler, + synthesize: SynthesisHandler, + output: AudioInput, + config: ConversationConfig | None = None, + decode_transcript: Callable[[SignalEnvelope[str]], TranscriptUpdate | None] + | None = None, + ) -> Conversation: + """Compose an interruptible voice workflow over this Session draft. + + The transcript subscription, generated-audio input, graph, routing, and + lifecycle remain owned by the existing Rust Session. The returned + object owns only bounded turn, provider, history, and interruption + orchestration. + """ + from .conversation import Conversation + + return Conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + config=config, + decode_transcript=decode_transcript, + ) + async def start(self) -> RunningSession: """Start transactionally and propagate asyncio cancellation to Rust.""" cancellation = _SessionStartCancellation() diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index 1205895..6f16491 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -8,6 +8,7 @@ from ._native import _AudioInput as _NativeAudioInput from ._native import _AudioInputObservations as _NativeAudioInputObservations +from ._native import _OutputGeneration as _NativeOutputGeneration from .errors import AudioInputBufferError, AudioInputFullError, _native_call from .graph import Endpoint, SourceOutput from .identity import SourceId, StreamId @@ -38,6 +39,8 @@ class AudioInputObservations: accepted_total: int full_total: int invalid_total: int + discarded_output_frames_total: int + inactive_output_writes_total: int cancelled: bool closed: bool @@ -53,11 +56,32 @@ def _from_native( accepted_total=native.accepted_total, full_total=native.full_total, invalid_total=native.invalid_total, + discarded_output_frames_total=native.discarded_output_frames_total, + inactive_output_writes_total=native.inactive_output_writes_total, cancelled=native.cancelled, closed=native.closed, ) +class OutputGeneration: + """Keeps replaceable PCM attached to one output operation.""" + + def __init__(self, native: _NativeOutputGeneration) -> None: + self._native = native + + @property + def id(self) -> int: + return self._native.id + + @property + def active(self) -> bool: + return self._native.active + + def cancel(self) -> None: + """Cancel pending PCM without stopping capture or the Session.""" + _native_call(self._native.cancel) + + class PcmSource: """Advanced explicit ownership of one Session source output and PCM writer.""" @@ -87,11 +111,24 @@ def stream_id(self) -> StreamId: def output(self) -> SourceOutput: return self._output - def try_write(self, samples: object, *, discontinuity: bool = False) -> None: + def begin_output(self) -> OutputGeneration: + return OutputGeneration(_native_call(self._native.begin_output)) + + def try_write( + self, + samples: object, + *, + discontinuity: bool = False, + generation: OutputGeneration | None = None, + ) -> None: """Copy one C-contiguous float32 frame into a preallocated Core buffer.""" try: _native_call( - lambda: self._native.try_write(samples, discontinuity=discontinuity) + lambda: self._native.try_write( + samples, + discontinuity=discontinuity, + generation=None if generation is None else generation._native, + ) ) except BufferError as error: # PyO3 rejects an incompatible buffer format before entering the @@ -120,6 +157,7 @@ def write( samples: object, *, discontinuity: bool = False, + generation: OutputGeneration | None = None, timeout_s: float = 1.0, ) -> None: """Wait finitely for one preallocated native buffer. @@ -131,6 +169,7 @@ def write( self, samples, discontinuity=discontinuity, + generation=generation, timeout_s=timeout_s, ) @@ -140,6 +179,7 @@ def _write_with_timeout( samples: object, *, discontinuity: bool, + generation: OutputGeneration | None, timeout_s: float, ) -> None: if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)): @@ -150,7 +190,11 @@ def _write_with_timeout( wait_s = 0.000_25 while True: try: - source.try_write(samples, discontinuity=discontinuity) + source.try_write( + samples, + discontinuity=discontinuity, + generation=generation, + ) return except AudioInputFullError: remaining = deadline - monotonic() @@ -164,5 +208,6 @@ def _write_with_timeout( "AudioInput", "AudioInputConfig", "AudioInputObservations", + "OutputGeneration", "PcmSource", ] diff --git a/python/pocketstation/conversation.py b/python/pocketstation/conversation.py new file mode 100644 index 0000000..d53fc6a --- /dev/null +++ b/python/pocketstation/conversation.py @@ -0,0 +1,298 @@ +"""Bounded provider-neutral contracts for interruptible voice composition.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .identity import SourceId, StreamId + + +@dataclass(frozen=True, slots=True) +class ConversationConfig: + """Finite work, retention, deadline, and output limits for one conversation.""" + + history_capacity: int = 32 + event_capacity: int = 128 + transcript_state_capacity: int = 128 + maximum_transcript_characters: int = 32_768 + maximum_response_characters: int = 16_384 + maximum_response_chunks_per_turn: int = 1_024 + maximum_tool_events_per_turn: int = 32 + maximum_output_frames_per_turn: int = 3_000 + provider_start_timeout_s: float = 10.0 + provider_close_timeout_s: float = 10.0 + response_timeout_s: float = 60.0 + synthesis_timeout_s: float = 60.0 + output_write_timeout_s: float = 1.0 + output_drain_timeout_s: float = 5.0 + cancellation_timeout_s: float = 2.0 + signal_wait_timeout_s: float = 0.1 + + def __post_init__(self) -> None: + _bounded_integer("history_capacity", self.history_capacity, maximum=4_096) + _bounded_integer("event_capacity", self.event_capacity, maximum=16_384) + _bounded_integer( + "transcript_state_capacity", + self.transcript_state_capacity, + maximum=16_384, + ) + _bounded_integer( + "maximum_transcript_characters", + self.maximum_transcript_characters, + maximum=1_000_000, + ) + _bounded_integer( + "maximum_response_characters", + self.maximum_response_characters, + maximum=1_000_000, + ) + _bounded_integer( + "maximum_response_chunks_per_turn", + self.maximum_response_chunks_per_turn, + maximum=65_536, + ) + _bounded_integer( + "maximum_tool_events_per_turn", + self.maximum_tool_events_per_turn, + maximum=4_096, + ) + _bounded_integer( + "maximum_output_frames_per_turn", + self.maximum_output_frames_per_turn, + maximum=1_000_000, + ) + _bounded_seconds( + "provider_start_timeout_s", self.provider_start_timeout_s, maximum=300 + ) + _bounded_seconds( + "provider_close_timeout_s", self.provider_close_timeout_s, maximum=300 + ) + _bounded_seconds("response_timeout_s", self.response_timeout_s, maximum=900) + _bounded_seconds("synthesis_timeout_s", self.synthesis_timeout_s, maximum=900) + _bounded_seconds( + "output_write_timeout_s", self.output_write_timeout_s, maximum=60 + ) + _bounded_seconds( + "output_drain_timeout_s", self.output_drain_timeout_s, maximum=60 + ) + _bounded_seconds( + "cancellation_timeout_s", self.cancellation_timeout_s, maximum=60 + ) + _bounded_seconds("signal_wait_timeout_s", self.signal_wait_timeout_s, maximum=1) + + +@dataclass(frozen=True, slots=True) +class TranscriptUpdate: + """One bounded revision of speech recognized from a Session stem.""" + + utterance_id: str + revision: int + text: str + stable_prefix: str = "" + final: bool = False + interrupts: bool = True + source_id: SourceId | None = None + stream_id: StreamId | None = None + source_sequence: int | None = None + source_timestamp_ns: int | None = None + audio_start_ns: int | None = None + audio_end_ns: int | None = None + + def __post_init__(self) -> None: + if not self.utterance_id.strip(): + raise ValueError("utterance_id must not be empty") + if len(self.utterance_id) > 128: + raise ValueError("utterance_id must not exceed 128 characters") + if isinstance(self.revision, bool) or not isinstance(self.revision, int): + raise TypeError("revision must be an integer") + if self.revision < 1: + raise ValueError("revision must be greater than zero") + if not self.text.startswith(self.stable_prefix): + raise ValueError("stable_prefix must be a prefix of text") + if self.final and not self.text.strip(): + raise ValueError("a final transcript update must contain text") + if self.final and self.stable_prefix != self.text: + raise ValueError("a final transcript update must make all text stable") + _optional_identity("source_id", self.source_id) + _optional_identity("stream_id", self.stream_id) + _optional_sequence("source_sequence", self.source_sequence) + _optional_timestamp("source_timestamp_ns", self.source_timestamp_ns) + _optional_timestamp("audio_start_ns", self.audio_start_ns) + _optional_timestamp("audio_end_ns", self.audio_end_ns) + if ( + self.audio_start_ns is not None + and self.audio_end_ns is not None + and self.audio_end_ns < self.audio_start_ns + ): + raise ValueError("audio_end_ns must not precede audio_start_ns") + + +@dataclass(frozen=True, slots=True) +class ConversationTurn: + """One final transcript retained with its Session timing and source identity.""" + + id: int + utterance_id: str + text: str + source_id: SourceId | None + stream_id: StreamId | None + source_sequence: int | None + source_timestamp_ns: int | None + audio_start_ns: int | None + audio_end_ns: int | None + received_timestamp_ns: int + + +ConversationRole = Literal["user", "assistant", "tool"] +ConversationDisposition = Literal["completed", "stopped", "cancelled", "failed"] + + +@dataclass(frozen=True, slots=True) +class ConversationMessage: + """One retained bounded-history message.""" + + role: ConversationRole + content: str + turn_id: int + timestamp_ns: int + + +@dataclass(frozen=True, slots=True) +class ToolEvent: + """One explicit tool observation returned by a response provider.""" + + name: str + outcome: str + detail: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("tool event name must not be empty") + if not self.outcome.strip(): + raise ValueError("tool event outcome must not be empty") + + +@dataclass(frozen=True, slots=True) +class ConversationResponse: + """Text and bounded tool observations produced for one user turn.""" + + text: str + tool_events: tuple[ToolEvent, ...] = () + + def __post_init__(self) -> None: + if not self.text.strip(): + raise ValueError("conversation response text must not be empty") + + +@dataclass(frozen=True, slots=True) +class ConversationResponseChunk: + """One ordered response fragment supplied to streaming synthesis.""" + + text: str = "" + tool_events: tuple[ToolEvent, ...] = () + + def __post_init__(self) -> None: + if not self.text and not self.tool_events: + raise ValueError("a response chunk must contain text or a tool event") + + +@dataclass(frozen=True, slots=True) +class ConversationContext: + """Immutable history and commit state presented to a response provider.""" + + history: tuple[ConversationMessage, ...] + committed: bool + + +@dataclass(frozen=True, slots=True) +class ConversationEvent: + """One retained lifecycle, latency, interruption, tool, or failure event.""" + + kind: str + timestamp_ns: int + turn_id: int | None = None + utterance_id: str | None = None + transcript_revision: int | None = None + output_generation_id: int | None = None + duration_ns: int | None = None + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class ConversationOutcome: + """Terminal facts for one bounded conversation run.""" + + disposition: ConversationDisposition + turns_started: int + turns_completed: int + turns_interrupted: int + transcript_updates_received: int + speculative_responses_started: int + speculative_responses_reused: int + output_generations_cancelled: int + output_frames_written: int + history: tuple[ConversationMessage, ...] + events: tuple[ConversationEvent, ...] + failure: str | None = None + + @property + def success(self) -> bool: + return self.disposition in {"completed", "stopped"} and self.failure is None + + +def _bounded_integer(name: str, value: int, *, maximum: int) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not 1 <= value <= maximum: + raise ValueError(f"{name} must be between 1 and {maximum}") + + +def _bounded_seconds(name: str, value: float, *, maximum: float) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a number") + if not 0 < value <= maximum: + raise ValueError(f"{name} must be greater than 0 and at most {maximum}") + + +def _optional_timestamp(name: str, value: int | None) -> None: + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer or None") + if value < 0: + raise ValueError(f"{name} must not be negative") + + +def _optional_identity(name: str, value: int | None) -> None: + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer or None") + if value < 1: + raise ValueError(f"{name} must be greater than zero") + + +def _optional_sequence(name: str, value: int | None) -> None: + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer or None") + if value < 0: + raise ValueError(f"{name} must not be negative") + + +__all__ = [ + "ConversationConfig", + "ConversationContext", + "ConversationDisposition", + "ConversationEvent", + "ConversationMessage", + "ConversationOutcome", + "ConversationResponse", + "ConversationResponseChunk", + "ConversationRole", + "ConversationTurn", + "ToolEvent", + "TranscriptUpdate", +] diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index a6366da..9a30d41 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -733,17 +733,26 @@ def output(self, port_name: str) -> DerivedStream: class _RoutableStream: - __slots__ = ("_destination",) + __slots__ = ("_destination", "_endpoint_ids", "_route_ids") _native: _NativeStem | _NativeDerivedStream | _NativeSourceOutput _destination: _DestinationResolver + _endpoint_ids: set[int] + _route_ids: set[int] def send(self, endpoint: Endpoint, *, input_port: str | None = None) -> RouteId: - if input_port is None: - return RouteId(_native_call(lambda: self._native.send(endpoint._native))) - return RouteId( - _native_call(lambda: self._native.send_to(endpoint._native, input_port)) + route_id = RouteId( + _native_call( + lambda: ( + self._native.send(endpoint._native) + if input_port is None + else self._native.send_to(endpoint._native, input_port) + ) + ) ) + self._route_ids.add(int(route_id)) + self._endpoint_ids.add(int(endpoint.id)) + return route_id def send_to( self, @@ -764,7 +773,13 @@ def send_to( ) def connect(self, input: OperatorInput) -> RouteId: - return RouteId(_native_call(lambda: self._native.connect(input._native))) + route_id = RouteId(_native_call(lambda: self._native.connect(input._native))) + self._route_ids.add(int(route_id)) + return route_id + + def _delivery_targets(self) -> tuple[frozenset[int], frozenset[int]]: + """Return declarations needed for a finite delivery-completion wait.""" + return frozenset(self._route_ids), frozenset(self._endpoint_ids) def through( self, @@ -795,6 +810,8 @@ class Stem(_RoutableStream): def __init__(self, native: _NativeStem, destination: _DestinationResolver) -> None: self._native = native self._destination = destination + self._route_ids = set() + self._endpoint_ids = set() @property def id(self) -> StemId: @@ -805,7 +822,9 @@ def session_id(self) -> RuntimeSessionId: return RuntimeSessionId(self._native.session_id) def record(self, stem_name: str) -> Endpoint: - return _native_call(lambda: Endpoint(self._native.record(stem_name))) + endpoint = _native_call(lambda: Endpoint(self._native.record(stem_name))) + self._endpoint_ids.add(int(endpoint.id)) + return endpoint def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: """Publish this stem as one named bus through the Rust connector.""" @@ -814,6 +833,7 @@ def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: if not isinstance(publisher, RelayPublisher): raise TypeError("publisher must be a RelayPublisher") route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) + self._route_ids.add(route_id) return RelayRoute(bus_id=bus_id, route_id=RouteId(route_id)) @@ -830,6 +850,8 @@ def __init__( ) -> None: self._native = native self._destination = destination + self._route_ids = set() + self._endpoint_ids = set() @property def session_id(self) -> RuntimeSessionId: @@ -899,6 +921,8 @@ def __init__( ) -> None: self._native = native self._destination = destination + self._route_ids = set() + self._endpoint_ids = set() @property def session_id(self) -> RuntimeSessionId: @@ -921,7 +945,9 @@ def output_port(self) -> str: return self._native.output_port def record(self, stem_name: str) -> Endpoint: - return _native_call(lambda: Endpoint(self._native.record(stem_name))) + endpoint = _native_call(lambda: Endpoint(self._native.record(stem_name))) + self._endpoint_ids.add(int(endpoint.id)) + return endpoint def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: """Publish this source output as one named Relay AudioBus.""" @@ -930,6 +956,7 @@ def publish(self, publisher: RelayPublisher, bus_id: str) -> RelayRoute: if not isinstance(publisher, RelayPublisher): raise TypeError("publisher must be a RelayPublisher") route_id = _native_call(lambda: self._native.publish(publisher._native, bus_id)) + self._route_ids.add(route_id) return RelayRoute(bus_id=bus_id, route_id=RouteId(route_id)) diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py index 45a9e90..f99eb0a 100644 --- a/python/pocketstation/observations.py +++ b/python/pocketstation/observations.py @@ -341,6 +341,7 @@ class PolledAudioMetrics: frames_delivered_total: int queue_full_drops_total: int invalid_ownership_drops_total: int + discarded_output_frames_total: int lease_capacity_count: int outstanding_leases: int lease_exhausted_total: int @@ -385,6 +386,7 @@ class EdgeMetrics: source_timestamp_to_receive: LatencyHistogram worker_failures_total: int shutdown_discarded_total: int + discarded_output_frames_total: int @classmethod def _from_native(cls, value: _NativeEdgeMetrics) -> EdgeMetrics: @@ -430,6 +432,7 @@ def _from_native(cls, value: _NativeEdgeMetrics) -> EdgeMetrics: ), worker_failures_total=value.worker_failures_total, shutdown_discarded_total=value.shutdown_discarded_total, + discarded_output_frames_total=value.discarded_output_frames_total, ) @@ -754,6 +757,7 @@ def _from_native(cls, value: _NativeSessionMetrics) -> SessionMetrics: frames_delivered_total=value.audio_frames_delivered_total, queue_full_drops_total=value.audio_queue_full_drops_total, invalid_ownership_drops_total=value.audio_invalid_ownership_drops_total, + discarded_output_frames_total=value.audio_discarded_output_frames_total, lease_capacity_count=value.audio_lease_capacity_count, outstanding_leases=value.audio_outstanding_leases, lease_exhausted_total=value.audio_lease_exhausted_total, diff --git a/python/pocketstation/signal.py b/python/pocketstation/signal.py index c6a1398..2b38dbd 100644 --- a/python/pocketstation/signal.py +++ b/python/pocketstation/signal.py @@ -176,7 +176,7 @@ class SignalEnvelope(Generic[_PayloadT_co]): timing: SignalTiming lineage: SignalLineage | None derivation: SignalDerivation | None - payload: _PayloadT_co + payload: _PayloadT_co # type: ignore[misc] @classmethod def _from_native( diff --git a/tests/conversation_support.py b/tests/conversation_support.py new file mode 100644 index 0000000..571ab0a --- /dev/null +++ b/tests/conversation_support.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from pocketstation._api import ( + MediaCaps, + OperatorEmission, + OperatorManifest, + OperatorNode, + OperatorProvider, + PortSpec, + SignalEnvelope, + SignalSpec, + SourceEmission, + SourceManifest, + SourceProvider, +) + +TRANSCRIPT_SIGNAL = SignalSpec.text(role="transcript.final") + + +def transcript_source(*texts: str) -> SourceProvider: + def emissions() -> Iterator[SourceEmission]: + for text in texts: + yield SourceEmission.text( + "transcript", + text, + signal=TRANSCRIPT_SIGNAL, + ) + + return SourceProvider.from_iterable( + SourceManifest( + "io.pocketstation.source.conversation-test.v1", + outputs=( + PortSpec.output( + "transcript", + TRANSCRIPT_SIGNAL, + media=MediaCaps.text(), + ), + ), + ), + lambda _configuration: emissions(), + ) + + +def transcript_operator() -> OperatorProvider: + class PassTranscript(OperatorNode): + def process( + self, + _input_port: str, + envelope: SignalEnvelope[object], + ) -> tuple[OperatorEmission, ...]: + return ( + OperatorEmission.text( + str(envelope.payload), + signal=TRANSCRIPT_SIGNAL, + ), + ) + + class Factory: + def create(self, _configuration: object) -> PassTranscript: + return PassTranscript() + + return OperatorProvider.with_node( + OperatorManifest( + "io.pocketstation.operator.conversation-test.v1", + inputs=( + PortSpec.input( + "transcript", + TRANSCRIPT_SIGNAL, + media=MediaCaps.text(), + ), + ), + outputs=( + PortSpec.output( + "transcript", + TRANSCRIPT_SIGNAL, + media=MediaCaps.text(), + ), + ), + terminal_roles=("transcript.final",), + ), + Factory(), + ) diff --git a/tests/test_aio_conversation.py b/tests/test_aio_conversation.py new file mode 100644 index 0000000..7d5f4c6 --- /dev/null +++ b/tests/test_aio_conversation.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from array import array +from collections.abc import AsyncIterator +from pathlib import Path + +import pocketstation.aio as pocketstation +import pytest +from conversation_support import ( + TRANSCRIPT_SIGNAL, + transcript_operator, + transcript_source, +) +from pocketstation.conversation import ( + ConversationConfig, + ConversationContext, + ConversationResponse, + ConversationResponseChunk, + ConversationTurn, + ToolEvent, + TranscriptUpdate, +) +from pocketstation.signal import SignalEnvelope + + +@pytest.mark.asyncio +async def test_given_conversation_when_run_then_one_session_owns_bounded_audio( + tmp_path: Path, +) -> None: + session = pocketstation.Session(recording_root=tmp_path) + source = session.register_source(transcript_source("ship the answer")).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input( + "assistant", + capacity_frames=2, + frame_samples_per_channel=480, + ) + output.output.record("assistant") + provider_events: list[str] = [] + received_source_ids: list[int | None] = [] + + class Responder: + async def start(self) -> None: + provider_events.append("responder.started") + + async def __call__( + self, + turn: TranscriptUpdate, + context: ConversationContext, + ) -> ConversationResponse: + assert turn.text == "ship the answer" + assert context.history[-1].role == "user" + received_source_ids.append(turn.source_id) + return ConversationResponse( + "completed", + tool_events=(ToolEvent("lookup", "completed", "local"),), + ) + + async def aclose(self) -> None: + provider_events.append("responder.closed") + + class Synthesizer: + async def start(self) -> None: + provider_events.append("synthesizer.started") + + async def __call__( + self, + _response: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + async def frames() -> AsyncIterator[array[float]]: + yield array("f", [0.1] * 480) + yield array("f", [0.2] * 480) + + return frames() + + async def aclose(self) -> None: + provider_events.append("synthesizer.closed") + + conversation = session.conversation( + transcripts=transcripts, + respond=Responder(), + synthesize=Synthesizer(), + output=output, + ) + running = await session.start() + outcome = await conversation.run(running) + await output.close() + stopped = await running.stop() + + assert outcome.success + assert outcome.turns_started == 1 + assert outcome.turns_completed == 1 + assert outcome.output_frames_written == 2 + assert received_source_ids == [source.source_id] + assert provider_events == [ + "responder.started", + "synthesizer.started", + "synthesizer.closed", + "responder.closed", + ] + assert [message.role for message in outcome.history] == [ + "user", + "tool", + "assistant", + ] + assert {event.kind for event in outcome.events} >= { + "turn.started", + "response.completed", + "tool.completed", + "turn.completed", + } + assert stopped.success, ( + f"endpoint_finalization_failures=" + f"{stopped.endpoint_finalization_failures_total}; " + f"runtime_failures={stopped.runtime_failures_total}; " + f"source_send_rejections={stopped.source_send_rejections_total}; " + f"recording={stopped.recording!r}; " + f"terminal_event={stopped.terminal_event!r}" + ) + assert stopped.recording is not None and stopped.recording.complete + with pytest.raises(RuntimeError, match="only once"): + await conversation.run(running) + + +@pytest.mark.asyncio +async def test_given_synthesis_limit_when_exceeded_then_conversation_fails( + tmp_path: Path, +) -> None: + session = pocketstation.Session(recording_root=tmp_path) + source = session.register_source(transcript_source("too much audio")).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input("assistant", frame_samples_per_channel=480) + output.output.send(session.polled_audio()) + + async def respond( + _turn: TranscriptUpdate, + _context: ConversationContext, + ) -> str: + return "bounded response" + + async def synthesize( + _response: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + yield array("f", [0.1] * 480) + yield array("f", [0.2] * 480) + + conversation = session.conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + config=ConversationConfig(maximum_output_frames_per_turn=1), + ) + running = await session.start() + outcome = await conversation.run(running) + frame = await running.audio.read(timeout_s=1) + await output.close() + stopped = await running.stop() + + assert frame is not None + assert not outcome.success + assert outcome.disposition == "failed" + assert outcome.output_frames_written == 1 + assert outcome.failure is not None + assert "maximum_output_frames_per_turn" in outcome.failure + assert stopped.success + + +@pytest.mark.asyncio +async def test_given_revisable_transcript_when_final_then_prepared_chunks_stream( + tmp_path: Path, +) -> None: + session = pocketstation.Session(recording_root=tmp_path) + source = session.register_source( + transcript_source("partial-1", "partial-2", "final") + ).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input("assistant", frame_samples_per_channel=480) + output.output.send(session.polled_audio()) + final_seen = False + response_inputs: list[tuple[str, bool]] = [] + synthesis_chunks: list[str] = [] + + def decode(envelope: SignalEnvelope[str]) -> TranscriptUpdate: + nonlocal final_seen + lineage = envelope.lineage + updates = { + "partial-1": (1, "hello", "hello", False), + "partial-2": (2, "hello there", "hello", False), + "final": (3, "hello there", "hello there", True), + } + revision, text, stable_prefix, final = updates[envelope.payload] + final_seen = final_seen or final + return TranscriptUpdate( + utterance_id="speech-1", + revision=revision, + text=text, + stable_prefix=stable_prefix, + final=final, + source_id=None if lineage is None else lineage.source_id, + stream_id=None if lineage is None else lineage.stream_id, + source_sequence=None if lineage is None else lineage.sequence_number, + source_timestamp_ns=envelope.timing.source_timestamp_ns, + ) + + async def respond( + update: TranscriptUpdate, + context: ConversationContext, + ) -> AsyncIterator[ConversationResponseChunk]: + response_inputs.append((update.text, context.committed)) + + async def chunks() -> AsyncIterator[ConversationResponseChunk]: + yield ConversationResponseChunk("answer: ") + yield ConversationResponseChunk(update.text) + + return chunks() + + async def synthesize( + chunk: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + assert final_seen + synthesis_chunks.append(chunk.text) + yield array("f", [0.1] * 480) + + conversation = session.conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + decode_transcript=decode, + ) + running = await session.start() + outcome = await conversation.run(running) + first = await running.audio.read(timeout_s=1) + second = await running.audio.read(timeout_s=1) + await output.close() + stopped = await running.stop() + + assert outcome.success + assert outcome.transcript_updates_received == 3 + assert outcome.speculative_responses_started == 2 + assert outcome.speculative_responses_reused == 1 + assert response_inputs == [("hello", False), ("hello there", False)] + assert synthesis_chunks == ["answer: ", "hello there"] + assert first is not None and second is not None + assert stopped.success diff --git a/tests/test_audio_input.py b/tests/test_audio_input.py index 78f9f99..148796e 100644 --- a/tests/test_audio_input.py +++ b/tests/test_audio_input.py @@ -10,6 +10,7 @@ AudioInputCancelledError, AudioInputClosedError, AudioInputConfig, + AudioInputError, AudioInputFullError, Session, SourceId, @@ -122,3 +123,34 @@ def test_audio_input_recovers_its_exact_preallocated_slot_after_delivery() -> No assert second.sequence_number == frame.sequence_number + 1 assert second.discontinuity_epoch == frame.discontinuity_epoch assert running.stop().success + + +def test_given_replaced_output_when_read_then_only_active_pcm_is_returned() -> None: + session = Session() + output = session.audio_input( + "generated", + capacity_frames=4, + frame_samples_per_channel=4, + ) + output.output.send(session.polled_audio()) + running = session.start() + first = output.begin_output() + + output.try_write(array("f", [-0.5] * 4), generation=first) + output.try_write(array("f", [-0.25] * 4), generation=first) + first.cancel() + assert not first.active + with pytest.raises(AudioInputError) as inactive: + output.try_write(array("f", [-0.75] * 4), generation=first) + assert inactive.value.code == "audio_input.output_inactive" + + replacement = output.begin_output() + output.try_write(array("f", [0.5] * 4), generation=replacement) + frame = running.audio.read(timeout_s=1.0) + + assert frame is not None + assert frame.output_generation_id == replacement.id + assert memoryview(frame.samples).cast("f")[0] == pytest.approx(0.5) + assert running.audio.read(timeout_s=0.01) is None + assert output.observations().inactive_output_writes_total == 1 + assert running.stop().success diff --git a/tests/test_conversation.py b/tests/test_conversation.py new file mode 100644 index 0000000..c1f49fa --- /dev/null +++ b/tests/test_conversation.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest +from pocketstation.conversation import ( + ConversationConfig, + ConversationResponse, + ToolEvent, + TranscriptUpdate, +) + + +def test_given_unbounded_contract_when_created_then_validation_rejects_it() -> None: + with pytest.raises(ValueError, match="history_capacity"): + ConversationConfig(history_capacity=0) + with pytest.raises(ValueError, match="response_timeout_s"): + ConversationConfig(response_timeout_s=0) + with pytest.raises(ValueError, match="provider_close_timeout_s"): + ConversationConfig(provider_close_timeout_s=0) + with pytest.raises(ValueError, match="output_drain_timeout_s"): + ConversationConfig(output_drain_timeout_s=0) + with pytest.raises(ValueError, match="maximum_output_frames_per_turn"): + ConversationConfig(maximum_output_frames_per_turn=1_000_001) + with pytest.raises(ValueError, match="final transcript update"): + TranscriptUpdate("speech-1", 1, "", final=True) + with pytest.raises(ValueError, match="stable_prefix"): + TranscriptUpdate("speech-1", 1, "hello", stable_prefix="goodbye") + with pytest.raises(ValueError, match="response text"): + ConversationResponse(" ") + with pytest.raises(ValueError, match="tool event name"): + ToolEvent("", "completed") + + +def test_given_default_contract_when_created_then_all_work_is_finite() -> None: + config = ConversationConfig() + assert config.history_capacity == 32 + assert config.event_capacity == 128 + assert config.provider_close_timeout_s == 10 + assert config.response_timeout_s == 60 + assert config.synthesis_timeout_s == 60 + assert config.maximum_output_frames_per_turn == 3_000 diff --git a/tests/test_conversation_interruptions.py b/tests/test_conversation_interruptions.py new file mode 100644 index 0000000..e31889b --- /dev/null +++ b/tests/test_conversation_interruptions.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import asyncio +from array import array +from collections.abc import AsyncIterator +from pathlib import Path + +import pocketstation.aio as pocketstation +import pytest +from conversation_support import ( + TRANSCRIPT_SIGNAL, + transcript_operator, + transcript_source, +) +from pocketstation.conversation import ( + ConversationConfig, + ConversationContext, + ConversationResponseChunk, + ConversationTurn, + TranscriptUpdate, +) + + +@pytest.mark.asyncio +async def test_given_new_turn_when_response_is_active_then_provider_is_cancelled( + tmp_path: Path, +) -> None: + session = pocketstation.Session(recording_root=tmp_path) + source = session.register_source(transcript_source("obsolete", "current")).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input( + "assistant", + capacity_frames=1, + frame_samples_per_channel=480, + ) + output.output.record("assistant") + output.output.send(session.polled_audio()) + cancelled = asyncio.Event() + + async def respond( + turn: TranscriptUpdate, + _context: ConversationContext, + ) -> str: + if turn.text == "obsolete": + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + cancelled.set() + raise + return f"answer:{turn.text}" + + async def synthesize( + response: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + assert response.text == "answer:current" + yield array("f", [0.25] * 480) + + conversation = session.conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + config=ConversationConfig(cancellation_timeout_s=1), + ) + running = await session.start() + frame_task = asyncio.create_task(running.audio.read(timeout_s=1)) + outcome = await conversation.run(running) + frame = await frame_task + await output.close() + stopped = await running.stop() + + assert cancelled.is_set() + assert outcome.success + assert outcome.turns_started == 2 + assert outcome.turns_interrupted == 1 + assert outcome.turns_completed == 1 + assert outcome.output_frames_written == 1 + assert outcome.history[-1].content == "answer:current" + assert frame is not None + assert frame.discontinuity_epoch == 1 + assert stopped.success, stopped + + +@pytest.mark.asyncio +async def test_given_run_cancel_when_provider_active_then_lifecycle_closes() -> None: + session = pocketstation.Session() + source = session.register_source(transcript_source("wait for me")).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input("assistant", frame_samples_per_channel=480) + output.output.send(session.polled_audio()) + started = asyncio.Event() + cancelled = asyncio.Event() + + async def respond( + _turn: TranscriptUpdate, + _context: ConversationContext, + ) -> str: + started.set() + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + cancelled.set() + raise + raise AssertionError("active response provider was not cancelled") + + async def synthesize( + _response: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + yield array("f", [0.0] * 480) + + conversation = session.conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + config=ConversationConfig(cancellation_timeout_s=1), + ) + running = await session.start() + task = asyncio.create_task(conversation.run(running)) + await asyncio.wait_for(started.wait(), 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await output.close() + stopped = await running.stop() + + assert cancelled.is_set() + assert conversation.outcome is not None + assert conversation.outcome.disposition == "cancelled" + assert conversation.outcome.turns_interrupted == 1 + assert conversation.outcome.output_frames_written == 0 + assert stopped.success + + +@pytest.mark.asyncio +async def test_given_queued_output_when_interrupted_then_only_replacement_is_read( + tmp_path: Path, +) -> None: + session = pocketstation.Session(recording_root=tmp_path) + source = session.register_source(transcript_source("old", "new")).declare() + operator = session.register_operator(transcript_operator()).declare() + source.output("transcript").connect(operator.input("transcript")) + transcripts = session.subscribe( + operator.output("transcript"), + signal=TRANSCRIPT_SIGNAL, + ) + output = session.audio_input( + "assistant", + capacity_frames=2, + frame_samples_per_channel=480, + ) + output.output.send(session.polled_audio()) + old_frame_queued = asyncio.Event() + + async def respond( + update: TranscriptUpdate, + _context: ConversationContext, + ) -> str: + return update.text + + async def synthesize( + chunk: ConversationResponseChunk, + _turn: ConversationTurn, + ) -> AsyncIterator[array[float]]: + if chunk.text == "old": + yield array("f", [-0.5] * 480) + old_frame_queued.set() + await asyncio.sleep(30) + else: + assert old_frame_queued.is_set() + yield array("f", [0.5] * 480) + + conversation = session.conversation( + transcripts=transcripts, + respond=respond, + synthesize=synthesize, + output=output, + config=ConversationConfig(cancellation_timeout_s=1), + ) + running = await session.start() + outcome = await conversation.run(running) + frame = await running.audio.read(timeout_s=1) + metrics = await running.metrics() + await output.close() + stopped = await running.stop() + + assert outcome.success + assert outcome.output_generations_cancelled == 1 + assert outcome.turns_interrupted == 1 + assert frame is not None + assert memoryview(frame.samples).cast("f")[0] == pytest.approx(0.5) + assert frame.output_generation_id is not None + assert await running.audio.read(timeout_s=0.01) is None + discarded_output_frames_total = ( + metrics.polled_audio.discarded_output_frames_total + + sum(route.edge.discarded_output_frames_total for route in metrics.routes) + ) + assert discarded_output_frames_total >= 1 + assert stopped.success diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 06d70bd..29efca2 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -84,6 +84,7 @@ class InvalidMetrics: audio_frames_delivered_total = 0 audio_queue_full_drops_total = 0 audio_invalid_ownership_drops_total = 0 + audio_discarded_output_frames_total = 0 audio_lease_capacity_count = 0 audio_outstanding_leases = 0 audio_lease_exhausted_total = 0 From e18df07e1e185ff82c8a5bce5841beb88fa8b92b Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 27 Aug 2026 11:20:16 -0700 Subject: [PATCH 23/49] Expose Relay output cancellation counters --- native/src/relay.rs | 16 ++++++++++++++++ python/pocketstation/_native.pyi | 2 ++ python/pocketstation/observations.py | 2 ++ tests/run_relay_e2e_publisher.py | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/native/src/relay.rs b/native/src/relay.rs index 302263f..160d614 100644 --- a/native/src/relay.rs +++ b/native/src/relay.rs @@ -43,6 +43,10 @@ pub(crate) struct PythonRelayPublishOutcome { #[pyo3(get)] publisher_stale_drops_total: u64, #[pyo3(get)] + cancelled_output_frames_total: u64, + #[pyo3(get)] + cancelled_output_samples_total: u64, + #[pyo3(get)] failures_total: u64, #[pyo3(get)] error: Option, @@ -57,6 +61,8 @@ pub(crate) struct OwnedRelayPublishOutcome { pub(crate) rtp_payload_bytes_sent_total: u64, pub(crate) ingress_queue_drops_total: u64, pub(crate) publisher_stale_drops_total: u64, + pub(crate) cancelled_output_frames_total: u64, + pub(crate) cancelled_output_samples_total: u64, pub(crate) failures_total: u64, pub(crate) error: Option, } @@ -79,6 +85,8 @@ pub(crate) fn owned_relay_outcomes(relay: Option<&RelayRuntime>) -> Vec) -> Vec int: "rtp_payload_bytes_sent_total": outcome.rtp_payload_bytes_sent_total, "ingress_queue_drops_total": outcome.ingress_queue_drops_total, "publisher_stale_drops_total": outcome.publisher_stale_drops_total, + "cancelled_output_frames_total": outcome.cancelled_output_frames_total, + "cancelled_output_samples_total": ( + outcome.cancelled_output_samples_total + ), "failures_total": outcome.failures_total, "error": outcome.error, } From 88205ce3b56d75fba51d641f7ea4d7c4ddd30dc2 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 27 Aug 2026 12:16:12 -0700 Subject: [PATCH 24/49] Add direct streaming voice proof candidate --- README.md | 10 + examples/README.md | 49 ++ examples/debug_voice_ai.py | 71 ++ pyproject.toml | 3 + python/pocketstation/_api.py | 6 + python/pocketstation/aio/_api.py | 3 + python/pocketstation/aio/event_input.py | 161 ++++ python/pocketstation/aio/session.py | 19 + python/pocketstation/errors.py | 15 + python/pocketstation/graph.py | 7 +- python/pocketstation/signal.py | 2 +- .../pocketstation_examples/openai_realtime.py | 769 ++++++++++++++++++ tests/test_aio_event_input.py | 49 ++ tests/test_station.py | 1 + tests/test_types.py | 1 + uv.lock | 305 +++++-- 16 files changed, 1409 insertions(+), 62 deletions(-) create mode 100644 examples/debug_voice_ai.py create mode 100644 python/pocketstation/aio/event_input.py create mode 100644 python/pocketstation_examples/openai_realtime.py create mode 100644 tests/test_aio_event_input.py diff --git a/README.md b/README.md index a27d27f..926757f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,16 @@ 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. +## Find where a voice agent lost time + +[`examples/debug_voice_ai.py`](examples/debug_voice_ai.py) sends a physical +microphone to OpenAI Realtime without another voice framework. PocketStation +keeps the microphone, generated assistant audio, and the selected browser's +output as independent recorded stems. It also records provider lifecycle and +interruption events on the same monotonic timeline. + +See the [voice-agent debugger instructions](examples/README.md#debug-a-voice-agent-interruption-from-the-media-boundary). + ## Transcribe both sides of a voice application The transcription example requires: diff --git a/examples/README.md b/examples/README.md index 4e44996..8271a81 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,55 @@ Each example is a complete Python program. Start with the task you want to try. +## Debug a voice-agent interruption from the media boundary + +[`debug_voice_ai.py`](debug_voice_ai.py) connects PocketStation directly to +OpenAI Realtime. PocketStation owns the physical microphone, generated +assistant PCM, browser delivery, independent recording stems, bounded queues, +and sender-side output cancellation. The provider owns speech recognition and +the model response. + +The example observes three separate audio paths: + +- the microphone sent to the model; +- generated assistant audio sent to Relay; +- output captured from the browser application playing the assistant. + +It also puts speech, transcript, response, synthesis, cancellation, and failure +events on the Session timeline. This makes it possible to distinguish model +delay, local queue delay, Relay delivery, and browser output without treating +provider logs as media evidence. + +Install the optional dependencies and provide an OpenAI API key: + +```bash +python -m pip install 'pocketstation[voice-agent-debug] @ file:///absolute/path/to/pocketstation.whl' +export OPENAI_API_KEY='...' +``` + +Run the example and enter the name of the browser application that will play +the assistant: + +```bash +python examples/debug_voice_ai.py +``` + +The example creates a short-lived Relay invitation and opens it in a browser. +Speak, interrupt the assistant while it is replying, then press `Ctrl-C`. Use +headphones for this run: the example does not provide acoustic echo +cancellation. + +The final report includes source continuity, bounded queue drops, routing +delay, the speech-start event, and PocketStation's output-cancellation time. +It reports browser playout position as unavailable because the receiver does +not yet return a played-sample acknowledgement to the publisher. Therefore the +example does not claim that the model conversation was truncated to the exact +sample a person heard. + +The installed examples use the small shared demo service by default. It has +strict admission limits. Set `POCKETSTATION_CONTROL_URL` and +`POCKETSTATION_RELAY_URL` to use your own deployment. + ## Transcribe both sides of a voice application Use this example to inspect what a desktop voice application produced and what diff --git a/examples/debug_voice_ai.py b/examples/debug_voice_ai.py new file mode 100644 index 0000000..54edde5 --- /dev/null +++ b/examples/debug_voice_ai.py @@ -0,0 +1,71 @@ +"""Find where an interruptible voice agent lost audio or time.""" + +import asyncio +import os +import webbrowser + +import pocketstation.aio as pks +from pocketstation import Source +from pocketstation_examples import demo_relay_session +from pocketstation_examples.openai_realtime import OpenAIRealtimeVoice, silent_frame + + +async def main() -> None: + application_name = input("Browser application playing the agent: ") + remote = await demo_relay_session( + required_buses=("application", "microphone", "assistant") + ) + session = pks.Session(recording_root="recordings/voice-agent-debug") + application = session.capture(Source.application(application_name)) + microphone = session.capture(Source.microphone_default()) + assistant = session.audio_input("assistant") + audio = session.polled_audio() + routes = { + int(application.send(audio)): "browser-output", + int(microphone.send(audio)): "microphone", + int(assistant.output.send(audio)): "assistant-output", + } + publisher = remote.publisher(session) + application.record("application") + application.publish(publisher, "application") + microphone.record("microphone") + microphone.publish(publisher, "microphone") + assistant.output.record("assistant") + assistant.output.publish(publisher, "assistant") + events = session.event_input("openai-realtime") + event_log = session.subscribe(events.output, signal=events.signal) + voice = OpenAIRealtimeVoice( + api_key=os.environ["OPENAI_API_KEY"], + microphone_route_id=next( + route for route, name in routes.items() if name == "microphone" + ), + output=assistant, + events=events, + route_labels=routes, + ) + async with remote: + try: + await voice.connect() + async with await session.start() as running: + await voice.start(running, event_log) + for _ in range(10): + await assistant.write(silent_frame()) + await asyncio.sleep(0.01) + await remote.wait_for_publisher(timeout_seconds=30) + invitation = await remote.create_receiver_invitation(bus_id="assistant") + print(f"Invitation code: {invitation.join_code}") + print(f"Agent audio: {invitation.join_url}") + webbrowser.open(invitation.join_url) + await remote.wait_for_receiver(timeout_seconds=30) + voice.enable_input() + print("Speak, interrupt the reply, then press Ctrl-C to stop.") + await voice.wait() + finally: + await voice.aclose() + voice.print_report() + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + pass diff --git a/pyproject.toml b/pyproject.toml index 4d92035..0ef8585 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ pocketstation-demo = "pocketstation_examples:main" transcription = [ "faster-whisper>=1.2.1,<2.0", ] +voice-agent-debug = [ + "websockets>=17.0,<18", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/python/pocketstation/_api.py b/python/pocketstation/_api.py index 24b6050..dc1af6a 100644 --- a/python/pocketstation/_api.py +++ b/python/pocketstation/_api.py @@ -86,6 +86,9 @@ AudioInputFullError, CaptureError, ConnectorRuntimeError, + EventInputClosedError, + EventInputError, + EventInputFullError, ExtensionError, GraphError, OperatorError, @@ -430,6 +433,9 @@ "EndpointShutdownMode", "EndpointStartGate", "EventFormat", + "EventInputClosedError", + "EventInputError", + "EventInputFullError", "EventQueueMetrics", "EventStream", "ExtensionAbiVersion", diff --git a/python/pocketstation/aio/_api.py b/python/pocketstation/aio/_api.py index ec663b0..01a3c4d 100644 --- a/python/pocketstation/aio/_api.py +++ b/python/pocketstation/aio/_api.py @@ -64,6 +64,7 @@ RegisteredEndpoint, RunningEndpointDriver, ) +from .event_input import EventInput, EventInputObservations from .extensions import ( ExtensionAbiVersion, ExtensionDescriptor, @@ -171,6 +172,8 @@ "EndpointReceiver", "EndpointShutdownMode", "EndpointStartGate", + "EventInput", + "EventInputObservations", "EventStream", "ExtensionAbiVersion", "ExtensionDescriptor", diff --git a/python/pocketstation/aio/event_input.py b/python/pocketstation/aio/event_input.py new file mode 100644 index 0000000..73c866e --- /dev/null +++ b/python/pocketstation/aio/event_input.py @@ -0,0 +1,161 @@ +"""Bounded typed-event input for asyncio frameworks and application callbacks.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass +from time import monotonic_ns +from typing import TYPE_CHECKING, Any + +from ..errors import EventInputClosedError, EventInputFullError +from ..graph import Multiplicity, PortSpec, SignalSpec, SourceOutput +from ..source_authoring import SourceEmission, SourceManifest +from .source_authoring import SourceProvider + +if TYPE_CHECKING: + from .session import Session + +_OUTPUT_PORT = "events" + + +@dataclass(frozen=True, slots=True) +class EventInputObservations: + """Current capacity and delivery counters for one event input.""" + + capacity_events: int + depth_events: int + accepted_total: int + full_total: int + closed: bool + + +@dataclass(frozen=True, slots=True) +class _QueuedEvent: + payload: bytes + timestamp_ns: int + + +class EventInput: + """Push framework events into a normal PocketStation typed Source.""" + + def __init__( + self, + session: Session, + name: str, + *, + signal: SignalSpec[bytes], + capacity_events: int, + maximum_event_bytes: int, + ) -> None: + if not name.strip(): + raise ValueError("name must not be empty") + if not 1 <= capacity_events <= 65_536: + raise ValueError("capacity_events must be between 1 and 65536") + if not 1 <= maximum_event_bytes <= 1_048_576: + raise ValueError("maximum_event_bytes must be between 1 and 1048576") + + self.name = name + self.signal = signal + self.capacity_events = capacity_events + self.maximum_event_bytes = maximum_event_bytes + self._queue: asyncio.Queue[_QueuedEvent | None] = asyncio.Queue(capacity_events) + self._accepted_total = 0 + self._full_total = 0 + self._closed = False + + async def emissions( + _configuration: Mapping[str, str], + ) -> AsyncIterator[SourceEmission]: + while True: + queued = await self._queue.get() + try: + if queued is None: + return + yield SourceEmission.bytes( + _OUTPUT_PORT, + queued.payload, + signal=signal, + source_timestamp_ns=queued.timestamp_ns, + observed_timestamp_ns=queued.timestamp_ns, + ) + finally: + self._queue.task_done() + + provider = SourceProvider.from_async_iterable( + SourceManifest( + _source_type_id(name), + outputs=( + PortSpec.output( + _OUTPUT_PORT, + signal, + multiplicity=Multiplicity.MANY, + ), + ), + ), + emissions, + ) + instance = session.register_source(provider).declare() + self.output: SourceOutput = instance.output(_OUTPUT_PORT) + + def try_write( + self, + event: Mapping[str, Any], + *, + timestamp_ns: int | None = None, + ) -> None: + """Serialize and enqueue one JSON event without waiting.""" + if self._closed: + raise EventInputClosedError("event input is closed", "event_input.closed") + payload = json.dumps( + event, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + if len(payload) > self.maximum_event_bytes: + raise ValueError( + f"event is {len(payload)} bytes; maximum is {self.maximum_event_bytes}" + ) + queued = _QueuedEvent( + payload=payload, + timestamp_ns=monotonic_ns() if timestamp_ns is None else timestamp_ns, + ) + try: + self._queue.put_nowait(queued) + except asyncio.QueueFull as error: + self._full_total += 1 + raise EventInputFullError( + "event input is full", "event_input.full" + ) from error + self._accepted_total += 1 + + async def aclose(self) -> None: + """Stop accepting events after previously accepted events drain.""" + if self._closed: + return + self._closed = True + await self._queue.put(None) + + def observations(self) -> EventInputObservations: + return EventInputObservations( + capacity_events=self.capacity_events, + depth_events=self._queue.qsize(), + accepted_total=self._accepted_total, + full_total=self._full_total, + closed=self._closed, + ) + + +def _source_type_id(name: str) -> str: + normalized = "-".join(name.strip().lower().replace("_", "-").split()) + if not normalized or any( + character not in "abcdefghijklmnopqrstuvwxyz0123456789-" + for character in normalized + ): + raise ValueError("name must contain only letters, numbers, spaces, '_' or '-'") + return f"io.pocketstation.source.event-input.{normalized}.v1" + + +__all__ = ["EventInput", "EventInputObservations"] diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index e83a9d1..de44283 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -32,6 +32,7 @@ from ..graph import ( EdgeContract, Endpoint, + SignalSpec, Stem, _GraphSessionDeclarations, ) @@ -63,6 +64,7 @@ from .audio_input import AudioInput, PcmSource from .connector import Connector, RegisteredConnector from .endpoint_authoring import EndpointProvider, RegisteredEndpoint +from .event_input import EventInput from .observations import EventStream from .operator_authoring import OperatorProvider from .sidecar import SidecarConnection @@ -370,6 +372,23 @@ def audio_input( ) return AudioInput(SyncPcmSource(native, config, self._destination_for_stream)) + def event_input( + self, + name: str, + *, + signal: SignalSpec[bytes] | None = None, + capacity_events: int = 256, + maximum_event_bytes: int = 16_384, + ) -> EventInput: + """Open bounded JSON event ingress for an asyncio framework.""" + return EventInput( + self, + name, + signal=signal or SignalSpec.event(role=name), + capacity_events=capacity_events, + maximum_event_bytes=maximum_event_bytes, + ) + def pcm_source(self, config: AudioInputConfig) -> PcmSource: native = _native_call( lambda: self._native.pcm_source( diff --git a/python/pocketstation/errors.py b/python/pocketstation/errors.py index 7a7d9b8..136f371 100644 --- a/python/pocketstation/errors.py +++ b/python/pocketstation/errors.py @@ -149,6 +149,18 @@ class AudioInputBufferError(AudioInputError, ValueError): """The supplied object is not one exact contiguous float32 frame.""" +class EventInputError(PocketStationError): + """Base error for bounded typed-event ingress.""" + + +class EventInputFullError(EventInputError, BufferError): + """The bounded event input has no free capacity.""" + + +class EventInputClosedError(EventInputError): + """The event input no longer accepts writes.""" + + def _native_call(operation: Callable[[], _Result]) -> _Result: """Execute one synchronous native call through the shared error policy.""" try: @@ -297,6 +309,9 @@ def _native_compile_diagnostic( "AudioInputFullError", "CaptureError", "ConnectorRuntimeError", + "EventInputClosedError", + "EventInputError", + "EventInputFullError", "ExtensionError", "GraphError", "OperatorError", diff --git a/python/pocketstation/graph.py b/python/pocketstation/graph.py index 9a30d41..ea1bf85 100644 --- a/python/pocketstation/graph.py +++ b/python/pocketstation/graph.py @@ -188,8 +188,11 @@ def event( *, role: str | None = None, schema: str | None = None, - ) -> SignalSpec[object]: - return cls(SignalKind.EVENT, format, role=role, schema=schema) + ) -> SignalSpec[bytes]: + return cast( + SignalSpec[bytes], + cls(SignalKind.EVENT, format, role=role, schema=schema), + ) @classmethod def metrics( diff --git a/python/pocketstation/signal.py b/python/pocketstation/signal.py index 2b38dbd..c6a1398 100644 --- a/python/pocketstation/signal.py +++ b/python/pocketstation/signal.py @@ -176,7 +176,7 @@ class SignalEnvelope(Generic[_PayloadT_co]): timing: SignalTiming lineage: SignalLineage | None derivation: SignalDerivation | None - payload: _PayloadT_co # type: ignore[misc] + payload: _PayloadT_co @classmethod def _from_native( diff --git a/python/pocketstation_examples/openai_realtime.py b/python/pocketstation_examples/openai_realtime.py new file mode 100644 index 0000000..a58cdc2 --- /dev/null +++ b/python/pocketstation_examples/openai_realtime.py @@ -0,0 +1,769 @@ +"""Example-owned OpenAI Realtime adapter for the voice debugging demo.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import json +import sys +from array import array +from collections import deque +from collections.abc import Mapping +from dataclasses import dataclass, field +from math import sqrt +from time import monotonic_ns +from typing import Any +from urllib.parse import urlencode + +import pocketstation.aio as pks +from pocketstation.aio.event_input import EventInput +from pocketstation.audio_input import OutputGeneration +from pocketstation.errors import ( + AudioInputFullError, + EventInputFullError, +) +from pocketstation.signal import BusSubscription, SignalEnvelope +from websockets.asyncio.client import ClientConnection, connect + +_MODEL_SAMPLE_RATE_HZ = 24_000 +_SESSION_SAMPLE_RATE_HZ = 48_000 +_SESSION_FRAME_SAMPLES = 480 +_MAX_EVENT_BYTES = 262_144 +_MAX_INPUT_QUEUE_FRAMES = 64 +_MAX_OUTPUT_QUEUE_CHUNKS = 32 +_MAX_RETAINED_EVENTS = 65_536 +_MAX_RETAINED_VOICED_FRAMES = 12_000 + + +@dataclass(frozen=True, slots=True) +class RealtimeVoiceConfig: + """Finite OpenAI Realtime connection and buffering settings.""" + + model: str = "gpt-realtime-2.1" + voice: str = "marin" + instructions: str = ( + "Answer clearly and in enough detail that the user can interrupt you." + ) + connect_timeout_s: float = 10.0 + close_timeout_s: float = 5.0 + input_queue_frames: int = _MAX_INPUT_QUEUE_FRAMES + output_queue_chunks: int = _MAX_OUTPUT_QUEUE_CHUNKS + + def __post_init__(self) -> None: + if not self.model.strip() or not self.voice.strip(): + raise ValueError("model and voice must not be empty") + if not self.instructions.strip(): + raise ValueError("instructions must not be empty") + for name, value in ( + ("connect_timeout_s", self.connect_timeout_s), + ("close_timeout_s", self.close_timeout_s), + ): + if isinstance(value, bool) or not 0 < value <= 60: + raise ValueError(f"{name} must be greater than 0 and at most 60") + for name, value, maximum in ( + ("input_queue_frames", self.input_queue_frames, 4_096), + ("output_queue_chunks", self.output_queue_chunks, 1_024), + ): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not 1 <= value <= maximum: + raise ValueError(f"{name} must be between 1 and {maximum}") + + +@dataclass(frozen=True, slots=True) +class RealtimeVoiceObservations: + """Finite provider and PocketStation boundary counters.""" + + input_frames_sent: int + input_frames_dropped: int + output_chunks_received: int + output_chunks_dropped: int + output_frames_written: int + output_generations_cancelled: int + provider_errors: int + event_input_drops: int + + +@dataclass(slots=True) +class _RouteTimeline: + label: str + first_sequence: int | None = None + last_sequence: int | None = None + sequence_gaps: int = 0 + maximum_route_delay_ns: int = 0 + discontinuities: set[int] = field(default_factory=set) + voiced_frames: deque[tuple[int, int]] = field( + default_factory=lambda: deque(maxlen=_MAX_RETAINED_VOICED_FRAMES) + ) + + +@dataclass(frozen=True, slots=True) +class _OutputChunk: + response_id: str + generation: OutputGeneration + pcm16le: bytes + done: bool = False + + +@dataclass(slots=True) +class _ResponseOutput: + response_id: str + generation: OutputGeneration + item_id: str | None = None + + +class _Pcm24To48: + def __init__(self) -> None: + self._previous: float | None = None + self._pending = array("f") + + def append(self, pcm16le: bytes) -> tuple[array[float], ...]: + samples = _pcm16le(pcm16le) + for value in samples: + current = float(value) / 32_768.0 + if self._previous is not None: + self._pending.append(self._previous) + self._pending.append((self._previous + current) * 0.5) + self._previous = current + return self._take_frames() + + def finish(self) -> tuple[array[float], ...]: + if self._previous is not None: + self._pending.extend((self._previous, self._previous)) + self._previous = None + remainder = len(self._pending) % _SESSION_FRAME_SAMPLES + if remainder: + self._pending.extend([0.0] * (_SESSION_FRAME_SAMPLES - remainder)) + return self._take_frames() + + def _take_frames(self) -> tuple[array[float], ...]: + frames: list[array[float]] = [] + while len(self._pending) >= _SESSION_FRAME_SAMPLES: + frames.append(array("f", self._pending[:_SESSION_FRAME_SAMPLES])) + del self._pending[:_SESSION_FRAME_SAMPLES] + return tuple(frames) + + +class OpenAIRealtimeVoice: + """Move one PocketStation microphone stem through OpenAI Realtime. + + PocketStation owns capture, media identity, bounded fan-out, recording, + Relay publication, generated-audio ingestion, and sender-side cancellation. + This example adapter owns only the provider WebSocket and PCM conversion. + """ + + def __init__( + self, + *, + api_key: str, + microphone_route_id: int, + output: pks.AudioInput, + events: EventInput, + route_labels: Mapping[int, str], + config: RealtimeVoiceConfig | None = None, + ) -> None: + if not api_key.strip(): + raise ValueError("api_key must not be empty") + self._api_key = api_key + self._microphone_route_id = microphone_route_id + self._output = output + self._events = events + self._route_labels = dict(route_labels) + self._config = RealtimeVoiceConfig() if config is None else config + self._input_queue: asyncio.Queue[str | None] = asyncio.Queue( + self._config.input_queue_frames + ) + self._output_queue: asyncio.Queue[_OutputChunk | None] = asyncio.Queue( + self._config.output_queue_chunks + ) + self._input_enabled = asyncio.Event() + self._ready = asyncio.Event() + self._socket: ClientConnection | None = None + self._tasks: list[asyncio.Task[None]] = [] + self._response: _ResponseOutput | None = None + self._failure: BaseException | None = None + self._timelines: dict[int, _RouteTimeline] = {} + self._event_records: deque[dict[str, Any]] = deque(maxlen=_MAX_RETAINED_EVENTS) + self._input_frames_sent = 0 + self._input_frames_dropped = 0 + self._output_chunks_received = 0 + self._output_chunks_dropped = 0 + self._output_frames_written = 0 + self._output_generations_cancelled = 0 + self._provider_errors = 0 + self._event_input_drops = 0 + self._started = False + self._closed = False + + @property + def observations(self) -> RealtimeVoiceObservations: + return RealtimeVoiceObservations( + input_frames_sent=self._input_frames_sent, + input_frames_dropped=self._input_frames_dropped, + output_chunks_received=self._output_chunks_received, + output_chunks_dropped=self._output_chunks_dropped, + output_frames_written=self._output_frames_written, + output_generations_cancelled=self._output_generations_cancelled, + provider_errors=self._provider_errors, + event_input_drops=self._event_input_drops, + ) + + async def connect(self) -> None: + """Open and configure one finite provider connection.""" + if self._socket is not None: + raise RuntimeError("OpenAI Realtime connection is already open") + query = urlencode({"model": self._config.model}) + self._socket = await connect( + f"wss://api.openai.com/v1/realtime?{query}", + additional_headers={"Authorization": f"Bearer {self._api_key}"}, + compression=None, + open_timeout=self._config.connect_timeout_s, + close_timeout=self._config.close_timeout_s, + ping_interval=20, + ping_timeout=20, + max_size=_MAX_EVENT_BYTES, + max_queue=16, + write_limit=32_768, + ) + self._tasks.append( + asyncio.create_task(self._receive(), name="pks-openai-receive") + ) + await self._send( + { + "type": "session.update", + "session": { + "type": "realtime", + "instructions": self._config.instructions, + "output_modalities": ["audio"], + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": _MODEL_SAMPLE_RATE_HZ, + }, + "transcription": {"model": "gpt-live-transcribe"}, + "turn_detection": { + "type": "server_vad", + "create_response": True, + "interrupt_response": True, + }, + }, + "output": { + "format": { + "type": "audio/pcm", + "rate": _MODEL_SAMPLE_RATE_HZ, + }, + "voice": self._config.voice, + }, + }, + }, + } + ) + await asyncio.wait_for( + self._ready.wait(), timeout=self._config.connect_timeout_s + ) + if self._failure is not None: + raise RuntimeError("OpenAI Realtime setup failed") from self._failure + + async def start( + self, + running: pks.RunningSession, + event_log: BusSubscription[bytes], + ) -> None: + """Start bounded media and event workers for one running Session.""" + if self._socket is None: + raise RuntimeError("connect() must complete before start()") + if self._started: + raise RuntimeError("OpenAI Realtime media workers already started") + if int(running.session_id) != event_log.session_id: + raise ValueError("event_log and running must belong to one Session") + self._started = True + self._tasks.extend( + ( + asyncio.create_task(self._read_audio(running), name="pks-openai-media"), + asyncio.create_task(self._send_audio(), name="pks-openai-input"), + asyncio.create_task(self._write_output(), name="pks-openai-output"), + asyncio.create_task( + self._read_events(running, event_log), name="pks-openai-events" + ), + ) + ) + + def enable_input(self) -> None: + """Begin forwarding microphone frames after the receiver is ready.""" + if not self._started: + raise RuntimeError("start() must complete before enable_input()") + self._input_enabled.set() + self._event("pocketstation.input.enabled") + + async def wait(self) -> None: + """Wait until the provider connection closes or fails.""" + if not self._tasks: + raise RuntimeError("connect() must complete before wait()") + await self._tasks[0] + if self._failure is not None: + raise RuntimeError("OpenAI Realtime connection failed") from self._failure + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + if self._response is not None and self._response.generation.active: + self._response.generation.cancel() + self._output_generations_cancelled += 1 + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + if self._socket is not None: + await self._socket.close(code=1000) + await self._socket.wait_closed() + await self._events.aclose() + + def print_report(self) -> None: + """Print measured media and interruption facts, including missing facts.""" + event_times = [ + int(event["pocketstation_timestamp_ns"]) + for event in self._event_records + if "pocketstation_timestamp_ns" in event + ] + audio_times = [ + timeline.voiced_frames[0][0] + for timeline in self._timelines.values() + if timeline.voiced_frames + ] + origin = min((*event_times, *audio_times), default=0) + print("\nPocketStation voice timeline") + for route_id, timeline in sorted(self._timelines.items()): + first = timeline.voiced_frames[0][0] if timeline.voiced_frames else None + last = ( + timeline.voiced_frames[-1][0] + timeline.voiced_frames[-1][1] + if timeline.voiced_frames + else None + ) + print( + f" {timeline.label:18} route={route_id} " + f"first_voice={_relative(first, origin)} " + f"last_voice={_relative(last, origin)} " + f"sequence_gaps={timeline.sequence_gaps} " + f"discontinuities={len(timeline.discontinuities)} " + f"max_route_delay_ms={timeline.maximum_route_delay_ns / 1_000_000:.1f}" + ) + interruption = _event_time( + self._event_records, "input_audio_buffer.speech_started" + ) + cancelled = _event_time( + self._event_records, + "pocketstation.output.cancelled", + after=interruption, + ) + if interruption is not None: + print(f" user speech detected {_relative(interruption, origin)}") + if interruption is not None and cancelled is not None: + print(f" output cancelled {_relative(cancelled, origin)}") + print( + " cancellation decision " + f"{max(cancelled - interruption, 0) / 1_000_000:.1f} ms" + ) + print(f" provider boundary {self.observations}") + print( + " browser playout cutoff unavailable: the receiver has not returned " + "a played-sample acknowledgement" + ) + print( + " conversation truncation unavailable until that playout position " + "can be sent to the model provider" + ) + + async def _read_audio(self, running: pks.RunningSession) -> None: + async for frame in running.audio: + timeline = self._timelines.setdefault( + int(frame.route_id), + _RouteTimeline( + self._route_labels.get( + int(frame.route_id), f"route-{int(frame.route_id)}" + ) + ), + ) + _observe_frame(timeline, frame) + if ( + int(frame.route_id) != self._microphone_route_id + or not self._input_enabled.is_set() + ): + continue + try: + encoded = _encode_microphone_frame(frame) + self._input_queue.put_nowait(encoded) + except asyncio.QueueFull: + self._input_frames_dropped += 1 + self._event("pocketstation.provider_input.full") + + async def _send_audio(self) -> None: + while True: + encoded = await self._input_queue.get() + try: + if encoded is None: + return + await self._send( + {"type": "input_audio_buffer.append", "audio": encoded} + ) + self._input_frames_sent += 1 + finally: + self._input_queue.task_done() + + async def _receive(self) -> None: + assert self._socket is not None + try: + async for message in self._socket: + if not isinstance(message, str): + raise ValueError("OpenAI Realtime returned a binary message") + event = json.loads(message) + if not isinstance(event, dict): + raise ValueError("OpenAI Realtime event must be an object") + self._handle_event(event) + except asyncio.CancelledError: + raise + except BaseException as error: + self._failure = error + self._provider_errors += 1 + self._event( + "provider.connection.failed", + detail=f"{type(error).__name__}: {error}"[:2_048], + ) + finally: + self._ready.set() + + def _handle_event(self, event: Mapping[str, Any]) -> None: + event_type = event.get("type") + if not isinstance(event_type, str) or len(event_type) > 128: + raise ValueError("OpenAI Realtime event type is invalid") + if event_type == "session.updated": + self._event(event_type) + self._ready.set() + return + if event_type == "error": + detail = event.get("error") + self._provider_errors += 1 + self._event(event_type, detail=str(detail)[:2_048]) + self._failure = RuntimeError(f"OpenAI Realtime error: {detail}") + self._ready.set() + return + if event_type == "response.created": + response_id = _nested_string(event, "response", "id") + generation = self._output.begin_output() + self._response = _ResponseOutput(response_id, generation) + self._event( + event_type, + response_id=response_id, + output_generation_id=generation.id, + ) + return + if event_type == "response.output_item.added": + response_id = _required_string(event, "response_id") + if self._response is not None and self._response.response_id == response_id: + self._response.item_id = _nested_string(event, "item", "id") + self._event(event_type, response_id=response_id) + return + if event_type == "response.output_audio.delta": + self._queue_output_delta(event) + return + if event_type == "response.output_audio.done": + response = self._matching_response(event) + self._queue_output( + _OutputChunk(response.response_id, response.generation, b"", done=True) + ) + self._event(event_type, response_id=response.response_id) + return + if event_type == "input_audio_buffer.speech_started": + self._event( + event_type, + item_id=_optional_string(event, "item_id"), + audio_start_ms=event.get("audio_start_ms"), + ) + self._cancel_output() + return + if event_type in { + "input_audio_buffer.speech_stopped", + "conversation.item.input_audio_transcription.delta", + "conversation.item.input_audio_transcription.completed", + "response.output_audio_transcript.delta", + "response.output_audio_transcript.done", + "response.done", + }: + self._record_text_event(event_type, event) + return + self._event(event_type) + + def _queue_output_delta(self, event: Mapping[str, Any]) -> None: + response = self._matching_response(event) + encoded = event.get("delta") + if ( + not isinstance(encoded, str) + or not encoded + or len(encoded) > _MAX_EVENT_BYTES + ): + raise ValueError("OpenAI Realtime audio delta has an invalid size") + try: + pcm16le = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError("OpenAI Realtime audio delta is not Base64") from error + if len(pcm16le) > _MAX_EVENT_BYTES or len(pcm16le) % 2: + raise ValueError("OpenAI Realtime audio delta has an invalid size") + self._output_chunks_received += 1 + self._queue_output( + _OutputChunk(response.response_id, response.generation, pcm16le) + ) + + def _queue_output(self, chunk: _OutputChunk) -> None: + if not chunk.generation.active: + self._output_chunks_dropped += 1 + return + try: + self._output_queue.put_nowait(chunk) + except asyncio.QueueFull: + self._output_chunks_dropped += 1 + if chunk.generation.active: + chunk.generation.cancel() + self._output_generations_cancelled += 1 + self._event( + "pocketstation.provider_output.full", + response_id=chunk.response_id, + output_generation_id=chunk.generation.id, + ) + + async def _write_output(self) -> None: + converters: dict[int, _Pcm24To48] = {} + discontinuity = False + while True: + chunk = await self._output_queue.get() + try: + if chunk is None: + return + if not chunk.generation.active: + self._output_chunks_dropped += 1 + discontinuity = True + continue + converter = converters.setdefault(chunk.generation.id, _Pcm24To48()) + frames = ( + converter.finish() + if chunk.done + else converter.append(chunk.pcm16le) + ) + for samples in frames: + try: + await self._output.write( + samples, + discontinuity=discontinuity, + generation=chunk.generation, + timeout_s=1.0, + ) + except AudioInputFullError: + self._output_chunks_dropped += 1 + discontinuity = True + self._event( + "pocketstation.output.full", + response_id=chunk.response_id, + output_generation_id=chunk.generation.id, + ) + else: + discontinuity = False + self._output_frames_written += 1 + if chunk.done: + converters.pop(chunk.generation.id, None) + finally: + self._output_queue.task_done() + + async def _read_events( + self, + running: pks.RunningSession, + subscription: BusSubscription[bytes], + ) -> None: + async for envelope in running.signals(subscription): + record = _decode_event(envelope) + record["pocketstation_timestamp_ns"] = envelope.timing.observed_timestamp_ns + self._event_records.append(record) + if record.get("type") in { + "input_audio_buffer.speech_started", + "pocketstation.output.cancelled", + "provider.connection.failed", + "error", + }: + print( + f"{envelope.timing.observed_timestamp_ns / 1_000_000_000:12.3f} " + f"{record['type']}", + flush=True, + ) + + async def _send(self, event: Mapping[str, Any]) -> None: + if self._socket is None: + raise RuntimeError("OpenAI Realtime connection is not open") + await self._socket.send( + json.dumps(event, ensure_ascii=True, separators=(",", ":")) + ) + + def _matching_response(self, event: Mapping[str, Any]) -> _ResponseOutput: + response_id = _required_string(event, "response_id") + if self._response is None or self._response.response_id != response_id: + raise ValueError("OpenAI Realtime output has no matching response") + return self._response + + def _cancel_output(self) -> None: + response = self._response + if response is None or not response.generation.active: + return + response.generation.cancel() + self._output_generations_cancelled += 1 + self._event( + "pocketstation.output.cancelled", + response_id=response.response_id, + item_id=response.item_id, + output_generation_id=response.generation.id, + browser_playout_position="unavailable", + ) + + def _record_text_event(self, event_type: str, event: Mapping[str, Any]) -> None: + text = event.get("delta") + if not isinstance(text, str): + text = event.get("transcript") + values: dict[str, object] = {} + if isinstance(text, str): + values["text"] = text[:8_192] + for name in ("item_id", "response_id"): + value = event.get(name) + if isinstance(value, str): + values[name] = value[:128] + self._event(event_type, **values) + if event_type == "conversation.item.input_audio_transcription.delta" and text: + print(text, end="", flush=True) + elif ( + event_type == "conversation.item.input_audio_transcription.completed" + and text + ): + print(flush=True) + + def _event(self, event_type: str, **values: object) -> None: + event = {"type": event_type, **values} + try: + self._events.try_write(event, timestamp_ns=monotonic_ns()) + except EventInputFullError: + self._event_input_drops += 1 + + +def silent_frame() -> array[float]: + """Return one exact 10 ms Session frame for Relay attachment.""" + return array("f", [0.0] * _SESSION_FRAME_SAMPLES) + + +def _encode_microphone_frame(frame: Any) -> str: + if frame.sample_rate_hz != _SESSION_SAMPLE_RATE_HZ or frame.channel_count != 1: + raise ValueError("the OpenAI example requires 48 kHz mono Session audio") + samples = _f32le(frame.samples_f32le) + if len(samples) != _SESSION_FRAME_SAMPLES: + raise ValueError("the OpenAI example requires exact 10 ms Session frames") + pcm = array( + "h", + ( + _pcm16((float(samples[index]) + float(samples[index + 1])) * 0.5) + for index in range(0, len(samples), 2) + ), + ) + if sys.byteorder != "little": + pcm.byteswap() + return base64.b64encode(pcm.tobytes()).decode("ascii") + + +def _pcm16(value: float) -> int: + return max(-32_768, min(32_767, round(value * 32_767.0))) + + +def _f32le(payload: bytes) -> array[float]: + samples = array("f") + samples.frombytes(payload) + if sys.byteorder != "little": + samples.byteswap() + return samples + + +def _pcm16le(payload: bytes) -> array[int]: + samples = array("h") + samples.frombytes(payload) + if sys.byteorder != "little": + samples.byteswap() + return samples + + +def _observe_frame(timeline: _RouteTimeline, frame: Any) -> None: + if timeline.first_sequence is None: + timeline.first_sequence = frame.sequence_number + elif ( + timeline.last_sequence is not None + and frame.sequence_number != timeline.last_sequence + 1 + ): + timeline.sequence_gaps += 1 + timeline.last_sequence = frame.sequence_number + timeline.discontinuities.add(frame.discontinuity_epoch) + timeline.maximum_route_delay_ns = max( + timeline.maximum_route_delay_ns, + frame.route_received_at_ns - frame.route_enqueued_at_ns, + ) + samples = _f32le(frame.samples_f32le) + rms = sqrt(sum(float(value) ** 2 for value in samples) / len(samples)) + if rms >= 0.01: + timeline.voiced_frames.append((frame.route_received_at_ns, frame.duration_ns)) + + +def _decode_event(envelope: SignalEnvelope[bytes]) -> dict[str, Any]: + decoded = json.loads(envelope.payload.decode("utf-8")) + if not isinstance(decoded, dict): + raise ValueError("event input emitted a non-object JSON value") + return decoded + + +def _required_string(event: Mapping[str, Any], name: str) -> str: + value = event.get(name) + if not isinstance(value, str) or not value or len(value) > 8_192: + raise ValueError(f"OpenAI Realtime event has invalid {name}") + return value + + +def _optional_string(event: Mapping[str, Any], name: str) -> str | None: + value = event.get(name) + return value[:128] if isinstance(value, str) else None + + +def _nested_string(event: Mapping[str, Any], parent: str, name: str) -> str: + nested = event.get(parent) + if not isinstance(nested, dict): + raise ValueError(f"OpenAI Realtime event has invalid {parent}") + return _required_string(nested, name) + + +def _event_time( + events: deque[dict[str, Any]], + event_type: str, + *, + after: int | None = None, +) -> int | None: + return next( + ( + int(event["pocketstation_timestamp_ns"]) + for event in events + if event.get("type") == event_type + and (after is None or int(event["pocketstation_timestamp_ns"]) >= after) + ), + None, + ) + + +def _relative(value: int | None, origin: int) -> str: + return ( + "not observed" if value is None else f"{(value - origin) / 1_000_000_000:.3f}s" + ) + + +__all__ = [ + "OpenAIRealtimeVoice", + "RealtimeVoiceConfig", + "RealtimeVoiceObservations", + "silent_frame", +] diff --git a/tests/test_aio_event_input.py b/tests/test_aio_event_input.py new file mode 100644 index 0000000..4e89d47 --- /dev/null +++ b/tests/test_aio_event_input.py @@ -0,0 +1,49 @@ +"""Real Session coverage for bounded asyncio event ingress.""" + +from __future__ import annotations + +import json + +import pocketstation.aio as pks +import pytest +from pocketstation.errors import EventInputFullError +from pocketstation.signal import EndOfStream + + +@pytest.mark.asyncio +async def test_event_input_preserves_json_and_timing_through_session() -> None: + session = pks.Session() + events = session.event_input("provider-events", capacity_events=2) + subscription = session.subscribe(events.output, signal=events.signal) + running = await session.start() + try: + events.try_write({"type": "speech.started", "revision": 1}, timestamp_ns=42) + envelope = await running.signals(subscription).read(timeout_s=1.0) + + assert envelope is not None + assert not isinstance(envelope, EndOfStream) + assert json.loads(envelope.payload) == { + "revision": 1, + "type": "speech.started", + } + assert envelope.timing.source_timestamp_ns == 42 + assert events.observations().accepted_total == 1 + finally: + await events.aclose() + await running.stop() + + +@pytest.mark.asyncio +async def test_event_input_reports_finite_capacity_before_session_start() -> None: + session = pks.Session() + events = session.event_input("provider-events", capacity_events=1) + events.try_write({"type": "first"}) + + with pytest.raises(EventInputFullError, match="event input is full"): + events.try_write({"type": "second"}) + + observations = events.observations() + assert observations.capacity_events == 1 + assert observations.depth_events == 1 + assert observations.accepted_total == 1 + assert observations.full_total == 1 diff --git a/tests/test_station.py b/tests/test_station.py index 4478d23..bad1fa1 100644 --- a/tests/test_station.py +++ b/tests/test_station.py @@ -1,4 +1,5 @@ """Root package ownership and vocabulary tests.""" + from __future__ import annotations import pocketstation diff --git a/tests/test_types.py b/tests/test_types.py index 2129815..3d53fbb 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,5 @@ """Stable exception contract tests.""" + import pytest from pocketstation._api import PocketStationError diff --git a/uv.lock b/uv.lock index 1597cd6..27f0ba8 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,9 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] @@ -86,28 +88,30 @@ wheels = [ [[package]] name = "av" -version = "18.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, - { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, - { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, - { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" }, - { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" }, +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" }, + { url = "https://files.pythonhosted.org/packages/36/90/b5668cddb3c401fcf22553bc495d5b0c6d8a01d118624b26f0db1d0b8653/av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240", size = 22699499, upload-time = "2026-06-07T05:52:30.335Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7e/7be6bfddb823d045ff9fd5d4deb922ee3847605e162c3882e6c45b4c35ff/av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa", size = 18366696, upload-time = "2026-06-07T05:52:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/391dcfa75c1ae1977efca44b753a11b929399b558826670c16a8808dd0e3/av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c", size = 36582649, upload-time = "2026-06-07T05:52:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/fb/32/7312854868b318b9d1b1dcbd1bddb460aaaeac7d57f816e11efec3bef5b1/av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63", size = 38479390, upload-time = "2026-06-07T05:52:37.968Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/af47f59b4458e81ca7d89f477698dbfb3d5a0cd8ae6c1e4441d01074af8a/av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a", size = 27127432, upload-time = "2026-06-07T05:52:40.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/85/c2e6861baf0f8c7d21c4ce811d4d424fedac915e3910d3570ce4377717dc/av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19", size = 37406592, upload-time = "2026-06-07T05:52:43.215Z" }, + { url = "https://files.pythonhosted.org/packages/ba/40/3cc13125aea976101c0858af99ac47257c0654411aa199b5d8e81eea7002/av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b", size = 28336228, upload-time = "2026-06-07T05:52:46.134Z" }, + { url = "https://files.pythonhosted.org/packages/a2/38/c7d9c3e746209a1a695c13e3aa7d817229e84a85d0a84271f313d1befdd3/av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02", size = 39490680, upload-time = "2026-06-07T05:52:48.887Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/9d42da561b7b8f7dabdfaebba07b52977bee58c5c7e4285ac991abcfaa72/av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f", size = 28355673, upload-time = "2026-06-07T05:52:51.614Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" }, ] [[package]] @@ -435,6 +439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -592,7 +605,9 @@ version = "2.5.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } wheels = [ @@ -665,7 +680,7 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.29.0" +version = "1.24.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, @@ -673,32 +688,33 @@ dependencies = [ { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "protobuf" }, + { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload-time = "2026-08-17T22:53:33.429Z" }, - { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload-time = "2026-08-17T22:53:36.47Z" }, - { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload-time = "2026-08-17T22:53:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload-time = "2026-08-17T22:53:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload-time = "2026-08-17T22:53:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" }, - { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" }, - { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" }, - { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, - { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, - { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, - { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, - { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, - { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a3/718e1b83096a1bc7b0fc8014c23d4cf795559fe666961cfac4fc038a4871/onnxruntime-1.29.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e74b278af1d949876f5d91d1268fd6c680e79f2bac194967394eaba9fdf69e7e", size = 21431104, upload-time = "2026-08-17T22:54:20.118Z" }, - { url = "https://files.pythonhosted.org/packages/4e/17/c75e78ddc1fe69b6ebaef7fe88ac83f29bfe10955e3a0d2436d93473c91c/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:939e5d65f332e6d399774b2bd0d3559fd8fa629c1e77833db29d968d2384f23d", size = 20818488, upload-time = "2026-08-17T22:54:23.147Z" }, - { url = "https://files.pythonhosted.org/packages/65/54/9f197c578d3d3d7bea16971e233e5483981228eec73748585cf7b5933403/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c0c37b92f67ed68dd36221ce0403e1d9bd4f7efce724439978a2597848530e5", size = 23136994, upload-time = "2026-08-17T22:54:26.321Z" }, - { url = "https://files.pythonhosted.org/packages/24/53/4616a55d2495679cfd0195f968feb3d74fe30e26467d168ee243ac97c089/onnxruntime-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a3129ae56e70d2618ff773920166916310370a7e3cacb60b9e0e8910092725f", size = 14350643, upload-time = "2026-08-17T22:54:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0f/c338cb5500a522c7e671a3bb1276f4562404fbecce8a0e274565aa968484/onnxruntime-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:e417ef8628dcce310d2d53023e750ea298ec14d4341ae6dc3a572bfd9bc7fa97", size = 14124294, upload-time = "2026-08-17T22:54:31.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e7/61064289a9a1301b25c1f0f574fe98aba31c2d388db3c1dbec664f78621f/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449", size = 20826174, upload-time = "2026-08-17T22:54:34.283Z" }, - { url = "https://files.pythonhosted.org/packages/60/21/d0c04b561b46e9bff89b5f500fb7415b8ca0669f7902204f76ab06bb0c7e/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ea91cef3b971506e51ae9c37c16d027774ec64994a524ec1bdfb027d68a9832", size = 23138547, upload-time = "2026-08-17T22:54:37.491Z" }, + { url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" }, + { url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" }, + { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" }, + { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, + { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] @@ -746,6 +762,9 @@ dev = [ transcription = [ { name = "faster-whisper" }, ] +voice-agent-debug = [ + { name = "websockets" }, +] [package.metadata] requires-dist = [ @@ -755,22 +774,23 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, + { name = "websockets", marker = "extra == 'voice-agent-debug'", specifier = ">=17.0,<18" }, ] -provides-extras = ["transcription", "dev"] +provides-extras = ["transcription", "voice-agent-debug", "dev"] [[package]] name = "protobuf" -version = "7.35.1" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -900,6 +920,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tokenizers" version = "0.23.1" @@ -947,3 +979,158 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload-time = "2026-08-26T14:55:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload-time = "2026-08-26T14:55:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload-time = "2026-08-26T14:55:24.748Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload-time = "2026-08-26T14:55:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload-time = "2026-08-26T14:55:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload-time = "2026-08-26T14:55:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload-time = "2026-08-26T14:55:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload-time = "2026-08-26T14:55:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload-time = "2026-08-26T14:55:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload-time = "2026-08-26T14:55:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload-time = "2026-08-26T14:55:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload-time = "2026-08-26T14:55:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload-time = "2026-08-26T14:55:36.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload-time = "2026-08-26T14:55:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload-time = "2026-08-26T14:55:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload-time = "2026-08-26T14:55:40.498Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload-time = "2026-08-26T14:55:41.763Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload-time = "2026-08-26T14:55:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload-time = "2026-08-26T14:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload-time = "2026-08-26T14:58:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload-time = "2026-08-26T14:58:26.689Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload-time = "2026-08-26T17:25:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload-time = "2026-08-26T17:25:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload-time = "2026-08-26T17:25:28.134Z" }, + { url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload-time = "2026-08-26T17:25:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] From f641cd47c0d8049fc2d4f64ce6ff1bc412e1dfc2 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Thu, 27 Aug 2026 12:41:18 -0700 Subject: [PATCH 25/49] Keep Python output cancellation compatible --- native/src/audio_input.rs | 38 +++++++++++++++++++----- native/src/observations.rs | 10 ++++--- native/src/relay.rs | 4 +-- python/pocketstation/_native.pyi | 4 +-- python/pocketstation/aio/conversation.py | 2 +- python/pocketstation/audio_input.py | 4 +-- python/pocketstation/observations.py | 2 +- tests/test_audio_input.py | 4 +-- tests/test_conversation_interruptions.py | 2 +- 9 files changed, 47 insertions(+), 23 deletions(-) diff --git a/native/src/audio_input.rs b/native/src/audio_input.rs index a455433..4c911fc 100644 --- a/native/src/audio_input.rs +++ b/native/src/audio_input.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use pocketstation::{ AudioInput, AudioInputConfig, AudioInputObservations, AudioInputWriteError, - AudioInputWriteErrorKind, OutputGeneration, + AudioInputWriteErrorKind, AudioOutputWriteError, AudioOutputWriteErrorKind, OutputGeneration, }; use pyo3::buffer::PyBuffer; use pyo3::exceptions::{PyRuntimeError, PyValueError}; @@ -36,6 +36,8 @@ impl PythonOutputGeneration { #[pyclass(name = "_AudioInputObservations", frozen)] pub(crate) struct PythonAudioInputObservations { observations: AudioInputObservations, + discarded_output_frames_total: u64, + cancelled_output_writes_total: u64, } #[pymethods] @@ -72,12 +74,12 @@ impl PythonAudioInputObservations { #[getter] fn discarded_output_frames_total(&self) -> u64 { - self.observations.discarded_output_frames_total + self.discarded_output_frames_total } #[getter] - fn inactive_output_writes_total(&self) -> u64 { - self.observations.inactive_output_writes_total + fn cancelled_output_writes_total(&self) -> u64 { + self.cancelled_output_writes_total } #[getter] @@ -178,9 +180,12 @@ impl PythonAudioInput { buffer.mark_discontinuity(); } if let Some(generation) = generation.as_deref() { - buffer.set_output_generation(&generation.generation); + input + .try_send_for_output(&generation.generation, buffer) + .map_err(audio_output_write_error) + } else { + input.try_send(buffer).map_err(audio_input_write_error) } - input.try_send(buffer).map_err(audio_input_write_error) }) } @@ -195,6 +200,8 @@ impl PythonAudioInput { self.with_input(|input| { Ok(PythonAudioInputObservations { observations: input.observations(), + discarded_output_frames_total: input.discarded_output_frames_total(), + cancelled_output_writes_total: input.cancelled_output_writes_total(), }) }) } @@ -220,7 +227,6 @@ fn audio_input_write_error(error: AudioInputWriteError) -> PyErr { AudioInputWriteErrorKind::Full => "audio_input.full", AudioInputWriteErrorKind::Closed => "audio_input.closed", AudioInputWriteErrorKind::Cancelled => "audio_input.cancelled", - AudioInputWriteErrorKind::OutputGenerationInactive(_) => "audio_input.output_inactive", AudioInputWriteErrorKind::InvalidBuffer(_) => "audio_input.invalid_buffer", }; let message = error.to_string(); @@ -230,6 +236,24 @@ fn audio_input_write_error(error: AudioInputWriteError) -> PyErr { } } +fn audio_output_write_error(error: AudioOutputWriteError) -> PyErr { + let code = match error.kind() { + AudioOutputWriteErrorKind::Full => "audio_input.full", + AudioOutputWriteErrorKind::Closed => "audio_input.closed", + AudioOutputWriteErrorKind::SessionCancelled => "audio_input.cancelled", + AudioOutputWriteErrorKind::OutputCancelled(_) => "audio_input.output_cancelled", + AudioOutputWriteErrorKind::WrongInput => "audio_input.wrong_output_input", + AudioOutputWriteErrorKind::InvalidBuffer(_) => "audio_input.invalid_buffer", + }; + let message = error.to_string(); + match error.kind() { + AudioOutputWriteErrorKind::WrongInput | AudioOutputWriteErrorKind::InvalidBuffer(_) => { + invalid_buffer(message) + } + _ => PyRuntimeError::new_err(coded_reason(code, message)), + } +} + fn invalid_buffer(message: String) -> PyErr { PyValueError::new_err(coded_reason("audio_input.invalid_buffer", message)) } diff --git a/native/src/observations.rs b/native/src/observations.rs index 194f1b4..30258b1 100644 --- a/native/src/observations.rs +++ b/native/src/observations.rs @@ -564,7 +564,7 @@ pub(crate) struct PythonEdgeMetrics { #[pyo3(get)] shutdown_discarded_total: u64, #[pyo3(get)] - discarded_output_frames_total: u64, + discarded_output_frames_total: Option, } #[pyclass(name = "SessionMetrics", frozen)] @@ -1216,7 +1216,9 @@ pub(crate) fn copy_metrics( source_timestamp_to_receive_max_ns: route.edge.source_timestamp_to_receive_max_ns, worker_failures_total: route.edge.worker_failures_total, shutdown_discarded_total: route.edge.shutdown_discarded_total, - discarded_output_frames_total: route.edge.discarded_output_frames_total, + discarded_output_frames_total: running + .route_discarded_output_frames_total(route.route_id) + .unwrap_or(0), endpoint_frames_received_total: endpoint.frames_received_total, endpoint_frames_delivered_total: endpoint.frames_delivered_total, endpoint_frames_dropped_total: endpoint.frames_dropped_total, @@ -1255,7 +1257,7 @@ pub(crate) fn copy_metrics( audio_frames_delivered_total: audio.frames_delivered_total, audio_queue_full_drops_total: audio.queue_full_drops_total, audio_invalid_ownership_drops_total: audio.invalid_ownership_drops_total, - audio_discarded_output_frames_total: audio.discarded_output_frames_total, + audio_discarded_output_frames_total: running.audio_discarded_output_frames_total(), audio_lease_capacity_count: audio.lease_capacity_count, audio_outstanding_leases: audio.outstanding_leases, audio_lease_exhausted_total: audio.lease_exhausted_total, @@ -1708,7 +1710,7 @@ impl From for PythonEdgeMetrics { source_timestamp_to_receive_max_ns: edge.source_timestamp_to_receive_max_ns, worker_failures_total: edge.worker_failures_total, shutdown_discarded_total: edge.shutdown_discarded_total, - discarded_output_frames_total: edge.discarded_output_frames_total, + discarded_output_frames_total: None, } } } diff --git a/native/src/relay.rs b/native/src/relay.rs index 160d614..f3784f8 100644 --- a/native/src/relay.rs +++ b/native/src/relay.rs @@ -99,9 +99,7 @@ pub(crate) fn owned_relay_outcomes(relay: Option<&RelayRuntime>) -> Vec None: routes_drained = bool(routes) and all( route.edge.queue_depth_frames == 0 and route.edge.frames_delivered_total - + route.edge.discarded_output_frames_total + + (route.edge.discarded_output_frames_total or 0) >= self._output_frames_written for route in routes ) diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index 6f16491..d3e809b 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -40,7 +40,7 @@ class AudioInputObservations: full_total: int invalid_total: int discarded_output_frames_total: int - inactive_output_writes_total: int + cancelled_output_writes_total: int cancelled: bool closed: bool @@ -57,7 +57,7 @@ def _from_native( full_total=native.full_total, invalid_total=native.invalid_total, discarded_output_frames_total=native.discarded_output_frames_total, - inactive_output_writes_total=native.inactive_output_writes_total, + cancelled_output_writes_total=native.cancelled_output_writes_total, cancelled=native.cancelled, closed=native.closed, ) diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py index e0a218a..ae4aa7c 100644 --- a/python/pocketstation/observations.py +++ b/python/pocketstation/observations.py @@ -386,7 +386,7 @@ class EdgeMetrics: source_timestamp_to_receive: LatencyHistogram worker_failures_total: int shutdown_discarded_total: int - discarded_output_frames_total: int + discarded_output_frames_total: int | None @classmethod def _from_native(cls, value: _NativeEdgeMetrics) -> EdgeMetrics: diff --git a/tests/test_audio_input.py b/tests/test_audio_input.py index 148796e..c9fc571 100644 --- a/tests/test_audio_input.py +++ b/tests/test_audio_input.py @@ -142,7 +142,7 @@ def test_given_replaced_output_when_read_then_only_active_pcm_is_returned() -> N assert not first.active with pytest.raises(AudioInputError) as inactive: output.try_write(array("f", [-0.75] * 4), generation=first) - assert inactive.value.code == "audio_input.output_inactive" + assert inactive.value.code == "audio_input.output_cancelled" replacement = output.begin_output() output.try_write(array("f", [0.5] * 4), generation=replacement) @@ -152,5 +152,5 @@ def test_given_replaced_output_when_read_then_only_active_pcm_is_returned() -> N assert frame.output_generation_id == replacement.id assert memoryview(frame.samples).cast("f")[0] == pytest.approx(0.5) assert running.audio.read(timeout_s=0.01) is None - assert output.observations().inactive_output_writes_total == 1 + assert output.observations().cancelled_output_writes_total == 1 assert running.stop().success diff --git a/tests/test_conversation_interruptions.py b/tests/test_conversation_interruptions.py index e31889b..b7aaf50 100644 --- a/tests/test_conversation_interruptions.py +++ b/tests/test_conversation_interruptions.py @@ -205,7 +205,7 @@ async def synthesize( assert await running.audio.read(timeout_s=0.01) is None discarded_output_frames_total = ( metrics.polled_audio.discarded_output_frames_total - + sum(route.edge.discarded_output_frames_total for route in metrics.routes) + + sum(route.edge.discarded_output_frames_total or 0 for route in metrics.routes) ) assert discarded_output_frames_total >= 1 assert stopped.success From 3b54e8644a2efed3d64d10f7232a56996e791bbc Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 28 Aug 2026 00:24:51 -0700 Subject: [PATCH 26/49] Add provider-neutral voice composition --- .gitignore | 5 + README.md | 2 +- examples/debug_voice_ai.py | 87 +- examples/stream_any_app_audio.py | 2 +- examples/transcribe_voice_app.py | 2 +- pyproject.toml | 6 +- python/pocketstation/aio/conversation.py | 792 +--------- python/pocketstation/aio/relay.py | 3 +- python/pocketstation/aio/session.py | 92 +- python/pocketstation/conversation.py | 303 +--- python/pocketstation/identity.py | 4 +- python/pocketstation/voice/__init__.py | 106 ++ python/pocketstation/voice/capabilities.py | 158 ++ python/pocketstation/voice/configuration.py | 265 ++++ python/pocketstation/voice/conversation.py | 1396 +++++++++++++++++ python/pocketstation/voice/duplex.py | 58 + python/pocketstation/voice/errors.py | 59 + python/pocketstation/voice/events.py | 40 + python/pocketstation/voice/response.py | 105 ++ .../pocketstation/voice/speech_detection.py | 63 + python/pocketstation/voice/synthesis.py | 74 + python/pocketstation/voice/transcription.py | 120 ++ python/pocketstation/voice/turns.py | 81 + .../__init__.py | 2 +- .../audio_windows.py | 2 +- .../demo.py | 2 +- .../faster_whisper.py | 2 +- .../openai_realtime.py | 422 ++++- .../relay.py | 2 +- .../transcript.py | 2 +- ...un_installed_transcription_cancellation.py | 4 +- tests/run_relay_e2e_publisher.py | 7 +- tests/test_batch_transcription.py | 2 +- tests/test_transcription_example.py | 2 +- tests/transcription/run_source_aware.py | 2 +- 35 files changed, 3083 insertions(+), 1191 deletions(-) create mode 100644 python/pocketstation/voice/__init__.py create mode 100644 python/pocketstation/voice/capabilities.py create mode 100644 python/pocketstation/voice/configuration.py create mode 100644 python/pocketstation/voice/conversation.py create mode 100644 python/pocketstation/voice/duplex.py create mode 100644 python/pocketstation/voice/errors.py create mode 100644 python/pocketstation/voice/events.py create mode 100644 python/pocketstation/voice/response.py create mode 100644 python/pocketstation/voice/speech_detection.py create mode 100644 python/pocketstation/voice/synthesis.py create mode 100644 python/pocketstation/voice/transcription.py create mode 100644 python/pocketstation/voice/turns.py rename python/{pocketstation_examples => pocketstation_demo}/__init__.py (80%) rename python/{pocketstation_examples => pocketstation_demo}/audio_windows.py (98%) rename python/{pocketstation_examples => pocketstation_demo}/demo.py (94%) rename python/{pocketstation_examples => pocketstation_demo}/faster_whisper.py (99%) rename python/{pocketstation_examples => pocketstation_demo}/openai_realtime.py (63%) rename python/{pocketstation_examples => pocketstation_demo}/relay.py (92%) rename python/{pocketstation_examples => pocketstation_demo}/transcript.py (93%) diff --git a/.gitignore b/.gitignore index a054275..4dd4743 100644 --- a/.gitignore +++ b/.gitignore @@ -24,10 +24,15 @@ __pycache__/ .venv/ # Private execution and development records +/AGENTS.md /PHASE*_PROGRESS.md +/docs/REPO_CONTRACT.md /docs/PYTHON_CAPABILITY_MATRIX.md /docs/PYTHON_SDK_DESIGN.md /docs/PYTHON_SDK_CORE_PARITY_REFERENCE.md +/docs/adr/ +/docs/architecture/ +/docs/standards/ /docs/standards/FAKE_SCAFFOLD_INVENTORY.md /tests/test_capability_contract.py venv/ diff --git a/README.md b/README.md index 926757f..fe8f54f 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ audio. The example uses PocketStation's small rate-limited demo service unless you set `POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` to services you -operate. The shared URLs live in `pocketstation_examples`; application code does +operate. The shared URLs live in `pocketstation_demo`; application code does not contain service credentials. ## Read application and microphone audio diff --git a/examples/debug_voice_ai.py b/examples/debug_voice_ai.py index 54edde5..5e9823b 100644 --- a/examples/debug_voice_ai.py +++ b/examples/debug_voice_ai.py @@ -1,71 +1,50 @@ -"""Find where an interruptible voice agent lost audio or time.""" - import asyncio import os import webbrowser +from array import array import pocketstation.aio as pks +from pocketstation.graph import SourceOutput, Stem +from pocketstation_demo import demo_relay_session +from pocketstation_demo.openai_realtime import OpenAIRealtime + from pocketstation import Source -from pocketstation_examples import demo_relay_session -from pocketstation_examples.openai_realtime import OpenAIRealtimeVoice, silent_frame async def main() -> None: - application_name = input("Browser application playing the agent: ") - remote = await demo_relay_session( - required_buses=("application", "microphone", "assistant") - ) + app = input("Browser application playing the agent: ") + buses = ("application", "microphone", "assistant") + remote = await demo_relay_session(required_buses=buses) session = pks.Session(recording_root="recordings/voice-agent-debug") - application = session.capture(Source.application(application_name)) - microphone = session.capture(Source.microphone_default()) + application = session.capture(Source.application(app)) + mic = session.capture(Source.microphone_default()) assistant = session.audio_input("assistant") - audio = session.polled_audio() - routes = { - int(application.send(audio)): "browser-output", - int(microphone.send(audio)): "microphone", - int(assistant.output.send(audio)): "assistant-output", + observed = session.polled_audio() + labels = { + int(application.send(observed)): "browser-output", + int(assistant.output.send(observed)): "assistant-output", } publisher = remote.publisher(session) - application.record("application") - application.publish(publisher, "application") - microphone.record("microphone") - microphone.publish(publisher, "microphone") - assistant.output.record("assistant") - assistant.output.publish(publisher, "assistant") - events = session.event_input("openai-realtime") - event_log = session.subscribe(events.output, signal=events.signal) - voice = OpenAIRealtimeVoice( - api_key=os.environ["OPENAI_API_KEY"], - microphone_route_id=next( - route for route, name in routes.items() if name == "microphone" - ), - output=assistant, - events=events, - route_labels=routes, - ) - async with remote: + for bus, output in zip(buses, (application, mic, assistant.output), strict=True): + assert isinstance(output, (Stem, SourceOutput)) + output.record(bus) + output.publish(publisher, bus) + model = OpenAIRealtime(api_key=os.environ["OPENAI_API_KEY"], route_labels=labels) + conversation = session.conversation(input=mic, output=assistant, voice_model=model) + async with remote, await session.start() as running: + await assistant.write(array("f", [0.0]) * 480) + invite = await remote.wait_for_publisher_and_invitation( + bus_id="assistant", timeout_seconds=30 + ) + print(f"Invitation: {invite.join_code} {invite.join_url}") + webbrowser.open(invite.join_url) + await remote.wait_for_receiver(timeout_seconds=30) + voice = await conversation.start(running) try: - await voice.connect() - async with await session.start() as running: - await voice.start(running, event_log) - for _ in range(10): - await assistant.write(silent_frame()) - await asyncio.sleep(0.01) - await remote.wait_for_publisher(timeout_seconds=30) - invitation = await remote.create_receiver_invitation(bus_id="assistant") - print(f"Invitation code: {invitation.join_code}") - print(f"Agent audio: {invitation.join_url}") - webbrowser.open(invitation.join_url) - await remote.wait_for_receiver(timeout_seconds=30) - voice.enable_input() - print("Speak, interrupt the reply, then press Ctrl-C to stop.") - await voice.wait() + await voice.wait() finally: - await voice.aclose() - voice.print_report() + await voice.aclose(abort=True) + model.print_report() -try: - asyncio.run(main()) -except KeyboardInterrupt: - pass +asyncio.run(main()) diff --git a/examples/stream_any_app_audio.py b/examples/stream_any_app_audio.py index 448f7fc..4a5bbf9 100644 --- a/examples/stream_any_app_audio.py +++ b/examples/stream_any_app_audio.py @@ -4,7 +4,7 @@ import webbrowser import pocketstation.aio as pks -from pocketstation_examples import demo_relay_session +from pocketstation_demo import demo_relay_session async def main() -> None: diff --git a/examples/transcribe_voice_app.py b/examples/transcribe_voice_app.py index dff5768..cc045b1 100644 --- a/examples/transcribe_voice_app.py +++ b/examples/transcribe_voice_app.py @@ -3,7 +3,7 @@ import asyncio import pocketstation.aio as pks -from pocketstation_examples import FasterWhisper +from pocketstation_demo import FasterWhisper async def main() -> None: diff --git a/pyproject.toml b/pyproject.toml index 0ef8585..9d82af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ ] [project.scripts] -pocketstation-demo = "pocketstation_examples:main" +pocketstation-demo = "pocketstation_demo:main" [project.optional-dependencies] transcription = [ @@ -35,7 +35,7 @@ asyncio_mode = "auto" pythonpath = ["."] [tool.mypy] -packages = ["pocketstation", "pocketstation_examples"] +packages = ["pocketstation", "pocketstation_demo"] mypy_path = "python" python_version = "3.11" strict = true @@ -49,6 +49,6 @@ select = ["B", "E", "F", "I", "RUF", "UP"] [tool.maturin] manifest-path = "native/Cargo.toml" python-source = "python" -python-packages = ["pocketstation", "pocketstation_examples"] +python-packages = ["pocketstation", "pocketstation_demo"] module-name = "pocketstation._native" exclude = ["**/__pycache__/**", "**/*.pyc", "**/*.pyo"] diff --git a/python/pocketstation/aio/conversation.py b/python/pocketstation/aio/conversation.py index 8adee9e..51711ea 100644 --- a/python/pocketstation/aio/conversation.py +++ b/python/pocketstation/aio/conversation.py @@ -1,791 +1,11 @@ -"""Continuous voice composition over one running native Session.""" +"""Compatibility imports for the former asyncio conversation module.""" -from __future__ import annotations - -import asyncio -import inspect -from collections import OrderedDict, deque -from collections.abc import AsyncIterable, Awaitable, Callable -from dataclasses import dataclass -from time import monotonic_ns -from typing import TypeAlias, cast - -from ..audio_input import OutputGeneration -from ..conversation import ( - ConversationConfig, - ConversationContext, - ConversationDisposition, - ConversationEvent, - ConversationMessage, - ConversationOutcome, - ConversationResponse, - ConversationResponseChunk, - ConversationRole, - ConversationTurn, - TranscriptUpdate, -) -from ..signal import BusSubscription, EndOfStream, SignalEnvelope -from .audio_input import AudioInput - -ResponseItem: TypeAlias = str | ConversationResponse | ConversationResponseChunk -ResponseResult: TypeAlias = ( - ResponseItem - | AsyncIterable[ResponseItem] - | Awaitable[ResponseItem | AsyncIterable[ResponseItem]] +from ..voice.conversation import ( + Conversation, + ResponseHandler, + SynthesisHandler, + TranscriptDecoder, ) -SynthesisResult: TypeAlias = AsyncIterable[object] | Awaitable[AsyncIterable[object]] - - -ResponseHandler: TypeAlias = Callable[ - [TranscriptUpdate, ConversationContext], ResponseResult -] -SynthesisHandler: TypeAlias = Callable[ - [ConversationResponseChunk, ConversationTurn], SynthesisResult -] -TranscriptDecoder: TypeAlias = Callable[[SignalEnvelope[str]], TranscriptUpdate | None] - - -@dataclass(frozen=True, slots=True) -class _TranscriptRecord: - revision: int - stable_prefix: str - final: bool - - -class _TranscriptState: - def __init__(self, capacity: int, maximum_characters: int) -> None: - self._capacity = capacity - self._maximum_characters = maximum_characters - self._records: OrderedDict[str, _TranscriptRecord] = OrderedDict() - - def accept(self, update: TranscriptUpdate) -> None: - if len(update.text) > self._maximum_characters: - raise ValueError("transcript exceeded maximum_transcript_characters") - previous = self._records.get(update.utterance_id) - if previous is not None: - if previous.final: - raise ValueError("a final utterance cannot receive another revision") - if update.revision <= previous.revision: - raise ValueError("transcript revisions must increase") - if not update.stable_prefix.startswith(previous.stable_prefix): - raise ValueError("stable transcript text cannot change or shrink") - self._records.move_to_end(update.utterance_id) - elif len(self._records) >= self._capacity: - oldest_id, oldest = next(iter(self._records.items())) - if not oldest.final: - raise RuntimeError("transcript_state_capacity is exhausted") - del self._records[oldest_id] - self._records[update.utterance_id] = _TranscriptRecord( - revision=update.revision, - stable_prefix=update.stable_prefix, - final=update.final, - ) - - -@dataclass(slots=True) -class _Speculation: - update: TranscriptUpdate - task: asyncio.Task[tuple[ConversationResponseChunk, ...]] - - -@dataclass(slots=True) -class _Delivery: - turn: ConversationTurn - generation: OutputGeneration - task: asyncio.Task[None] - settled: bool = False - interruption_counted: bool = False - - -class Conversation: - """Coordinate transcript, response, synthesis, and generated audio work. - - The native Session continues to own Sources, routing, recording, and - Connector delivery. This object owns only finite provider work and retained - conversation state. Partial transcripts may prepare a response, but audio - is not emitted until the transcript is final. - """ - - def __init__( - self, - *, - transcripts: BusSubscription[str], - respond: ResponseHandler, - synthesize: SynthesisHandler, - output: AudioInput, - config: ConversationConfig | None = None, - decode_transcript: TranscriptDecoder | None = None, - ) -> None: - if transcripts.session_id != int(output.output.session_id): - raise ValueError("transcripts and output must belong to the same Session") - self._transcripts = transcripts - self._respond = respond - self._synthesize = synthesize - self._output = output - self._config = ConversationConfig() if config is None else config - self._decode_transcript = ( - _default_transcript_decoder - if decode_transcript is None - else decode_transcript - ) - self._transcript_state = _TranscriptState( - self._config.transcript_state_capacity, - self._config.maximum_transcript_characters, - ) - self._history: deque[ConversationMessage] = deque( - maxlen=self._config.history_capacity - ) - self._events: deque[ConversationEvent] = deque( - maxlen=self._config.event_capacity - ) - self._stop_requested = asyncio.Event() - self._running = False - self._has_run = False - self._discontinuity_pending = False - self._turns_started = 0 - self._turns_completed = 0 - self._turns_interrupted = 0 - self._transcript_updates_received = 0 - self._speculative_responses_started = 0 - self._speculative_responses_reused = 0 - self._output_generations_cancelled = 0 - self._output_frames_written = 0 - self._outcome: ConversationOutcome | None = None - - @property - def config(self) -> ConversationConfig: - return self._config - - @property - def outcome(self) -> ConversationOutcome | None: - return self._outcome - - @property - def history(self) -> tuple[ConversationMessage, ...]: - return tuple(self._history) - - @property - def events(self) -> tuple[ConversationEvent, ...]: - return tuple(self._events) - - def stop(self) -> None: - """Request a normal stop at the next finite signal wait.""" - self._stop_requested.set() - - async def run(self, running: object) -> ConversationOutcome: - """Run until the transcript endpoint closes or :meth:`stop` is called.""" - from .session import RunningSession - - if not isinstance(running, RunningSession): - raise TypeError("running must be a pocketstation.aio.RunningSession") - if self._running: - raise RuntimeError("Conversation is already running") - if self._has_run: - raise RuntimeError("Conversation can run only once") - if int(running.session_id) != self._transcripts.session_id: - raise ValueError("running Session does not own this conversation") - - self._running = True - self._has_run = True - disposition = "completed" - failure: str | None = None - delivery: _Delivery | None = None - speculation: _Speculation | None = None - stream = running.signals(self._transcripts) - started_providers: list[object] = [] - try: - for provider in _unique_providers(self._respond, self._synthesize): - await self._provider_lifecycle(provider, "start") - started_providers.append(provider) - while not self._stop_requested.is_set(): - if ( - delivery is not None - and delivery.task.done() - and not delivery.settled - ): - await delivery.task - delivery.settled = True - result = await stream.read(timeout_s=self._config.signal_wait_timeout_s) - if isinstance(result, EndOfStream): - break - if result is None: - continue - update = self._decode_transcript(result) - if update is None: - continue - self._transcript_state.accept(update) - self._transcript_updates_received += 1 - self._event("transcript.updated", update=update) - - if delivery is not None and update.interrupts: - await self._interrupt_delivery(delivery) - delivery = None - - if not update.final: - if not update.text.strip(): - continue - if speculation is not None and ( - speculation.update.utterance_id != update.utterance_id - or speculation.update.text != update.text - ): - await self._cancel_speculation(speculation) - speculation = None - if speculation is None: - self._speculative_responses_started += 1 - self._event("response.preparing", update=update) - speculation = _Speculation( - update=update, - task=asyncio.create_task(self._prepare_response(update)), - ) - continue - - prepared: tuple[ConversationResponseChunk, ...] | None = None - if speculation is not None: - if ( - speculation.update.utterance_id == update.utterance_id - and speculation.update.text == update.text - ): - prepared = await speculation.task - self._speculative_responses_reused += 1 - self._event("response.prepared", update=update) - else: - await self._cancel_speculation(speculation) - speculation = None - - turn = self._turn(result, update) - self._turns_started += 1 - self._append_message("user", update.text, turn.id) - self._event("turn.started", turn=turn, update=update) - generation = self._output.begin_output() - self._event( - "output.started", - turn=turn, - update=update, - generation=generation, - ) - delivery = _Delivery( - turn=turn, - generation=generation, - task=asyncio.create_task( - self._deliver_response(turn, update, generation, prepared) - ), - ) - await asyncio.sleep(0) - - if speculation is not None: - await self._cancel_speculation(speculation) - if delivery is not None: - if self._stop_requested.is_set(): - await self._interrupt_delivery(delivery) - disposition = "stopped" - elif not delivery.settled: - await delivery.task - delivery.settled = True - elif self._stop_requested.is_set(): - disposition = "stopped" - await self._wait_output_drained(running) - except asyncio.CancelledError: - disposition = "cancelled" - if speculation is not None: - await self._cancel_speculation(speculation) - if delivery is not None: - await self._interrupt_delivery(delivery) - raise - except Exception as error: - if speculation is not None: - await self._cancel_speculation(speculation) - if delivery is not None and not delivery.task.done(): - await self._interrupt_delivery(delivery) - disposition = "failed" - failure = f"{type(error).__name__}: {error}" - self._event("conversation.failed", detail=failure) - finally: - for provider in reversed(started_providers): - try: - await self._provider_lifecycle(provider, "aclose") - except Exception as error: - disposition = "failed" - failure = f"provider close failed: {type(error).__name__}: {error}" - self._event("provider.close_failed", detail=failure) - self._outcome = ConversationOutcome( - disposition=cast(ConversationDisposition, disposition), - turns_started=self._turns_started, - turns_completed=self._turns_completed, - turns_interrupted=self._turns_interrupted, - transcript_updates_received=self._transcript_updates_received, - speculative_responses_started=self._speculative_responses_started, - speculative_responses_reused=self._speculative_responses_reused, - output_generations_cancelled=self._output_generations_cancelled, - output_frames_written=self._output_frames_written, - history=tuple(self._history), - events=tuple(self._events), - failure=failure, - ) - self._running = False - return self._outcome - - async def _prepare_response( - self, - update: TranscriptUpdate, - ) -> tuple[ConversationResponseChunk, ...]: - chunks: list[ConversationResponseChunk] = [] - characters = 0 - async for chunk in self._response_chunks(update, committed=False): - if chunk.tool_events: - raise ValueError("a speculative response cannot request tool work") - chunks.append(chunk) - characters += len(chunk.text) - self._check_response_bounds(len(chunks), characters, 0) - if not any(chunk.text for chunk in chunks): - raise ValueError("response provider produced no text") - return tuple(chunks) - - async def _deliver_response( - self, - turn: ConversationTurn, - update: TranscriptUpdate, - generation: OutputGeneration, - prepared: tuple[ConversationResponseChunk, ...] | None, - ) -> None: - response_started = monotonic_ns() - response_text: list[str] = [] - response_chunks = 0 - response_characters = 0 - tool_events = 0 - frames = 0 - synthesis_started: int | None = None - synthesis_deadline = ( - asyncio.get_running_loop().time() + self._config.synthesis_timeout_s - ) - chunks: AsyncIterable[ConversationResponseChunk] - if prepared is None: - chunks = self._response_chunks(update, committed=True) - else: - chunks = _iter_prepared(prepared) - - async for chunk in chunks: - response_chunks += 1 - response_characters += len(chunk.text) - tool_events += len(chunk.tool_events) - self._check_response_bounds( - response_chunks, - response_characters, - tool_events, - ) - response_text.append(chunk.text) - self._event( - "response.chunk", - turn=turn, - update=update, - generation=generation, - ) - for tool in chunk.tool_events: - detail = f": {tool.detail}" if tool.detail else "" - self._append_message( - "tool", - f"{tool.name}: {tool.outcome}{detail}", - turn.id, - ) - self._event( - "tool.completed", - turn=turn, - update=update, - generation=generation, - detail=f"{tool.name}:{tool.outcome}", - ) - if not chunk.text: - continue - if synthesis_started is None: - synthesis_started = monotonic_ns() - self._event( - "synthesis.started", - turn=turn, - update=update, - generation=generation, - ) - produced = self._synthesize(chunk, turn) - if inspect.isawaitable(produced): - produced = await asyncio.wait_for( - produced, - timeout=self._remaining(synthesis_deadline, "synthesis"), - ) - async for samples in _iterate_until( - produced, - synthesis_deadline, - "synthesis", - ): - if not generation.active: - raise asyncio.CancelledError - frames += 1 - if frames > self._config.maximum_output_frames_per_turn: - raise ValueError( - "synthesis exceeded maximum_output_frames_per_turn" - ) - await self._output.write( - samples, - discontinuity=self._discontinuity_pending, - generation=generation, - timeout_s=min( - self._config.output_write_timeout_s, - self._remaining(synthesis_deadline, "synthesis"), - ), - ) - self._discontinuity_pending = False - self._output_frames_written += 1 - - text = "".join(response_text) - if not text.strip(): - raise ValueError("response provider produced no text") - self._event( - "response.completed", - turn=turn, - update=update, - generation=generation, - duration_ns=monotonic_ns() - response_started, - ) - self._event( - "synthesis.completed", - turn=turn, - update=update, - generation=generation, - duration_ns=( - None - if synthesis_started is None - else monotonic_ns() - synthesis_started - ), - detail=f"frames={frames}", - ) - self._append_message("assistant", text, turn.id) - self._turns_completed += 1 - self._event( - "turn.completed", - turn=turn, - update=update, - generation=generation, - duration_ns=monotonic_ns() - response_started, - ) - - async def _response_chunks( - self, - update: TranscriptUpdate, - *, - committed: bool, - ) -> AsyncIterable[ConversationResponseChunk]: - deadline = asyncio.get_running_loop().time() + self._config.response_timeout_s - produced = self._respond( - update, - ConversationContext(tuple(self._history), committed=committed), - ) - if inspect.isawaitable(produced): - produced = await asyncio.wait_for( - produced, - timeout=self._remaining(deadline, "response"), - ) - if isinstance(produced, AsyncIterable): - async for value in _iterate_until(produced, deadline, "response"): - yield _response_chunk(value) - else: - yield _response_chunk(produced) - - def _check_response_bounds( - self, - chunks: int, - characters: int, - tool_events: int, - ) -> None: - if chunks > self._config.maximum_response_chunks_per_turn: - raise ValueError("response exceeded maximum_response_chunks_per_turn") - if characters > self._config.maximum_response_characters: - raise ValueError("response exceeded maximum_response_characters") - if tool_events > self._config.maximum_tool_events_per_turn: - raise ValueError("response exceeded maximum_tool_events_per_turn") - - async def _interrupt_delivery(self, delivery: _Delivery) -> None: - if delivery.generation.active: - delivery.generation.cancel() - self._output_generations_cancelled += 1 - self._discontinuity_pending = True - self._event( - "output.cancelled", - turn=delivery.turn, - generation=delivery.generation, - ) - if not delivery.task.done(): - delivery.task.cancel() - try: - await asyncio.wait_for( - delivery.task, - timeout=self._config.cancellation_timeout_s, - ) - except asyncio.CancelledError: - pass - except TimeoutError as error: - raise TimeoutError( - "provider did not stop within cancellation_timeout_s" - ) from error - if not delivery.interruption_counted: - self._turns_interrupted += 1 - delivery.interruption_counted = True - self._event("turn.interrupted", turn=delivery.turn) - - async def _cancel_speculation(self, speculation: _Speculation) -> None: - if speculation.task.done(): - await speculation.task - return - speculation.task.cancel() - try: - await asyncio.wait_for( - speculation.task, - timeout=self._config.cancellation_timeout_s, - ) - except asyncio.CancelledError: - self._event("response.preparation_cancelled", update=speculation.update) - except TimeoutError as error: - raise TimeoutError( - "speculative response did not stop within cancellation_timeout_s" - ) from error - - async def _provider_lifecycle(self, provider: object, method_name: str) -> None: - method = getattr(provider, method_name, None) - if method is None: - return - timeout_s = ( - self._config.provider_close_timeout_s - if method_name == "aclose" - else self._config.provider_start_timeout_s - ) - if inspect.iscoroutinefunction(method): - result = await asyncio.wait_for(method(), timeout=timeout_s) - else: - result = await asyncio.wait_for( - asyncio.to_thread(method), - timeout=timeout_s, - ) - if inspect.isawaitable(result): - await asyncio.wait_for(result, timeout=timeout_s) - - async def _wait_output_drained(self, running: object) -> None: - from .session import RunningSession - - if not isinstance(running, RunningSession): - raise TypeError("running must be a pocketstation.aio.RunningSession") - deadline = ( - asyncio.get_running_loop().time() + self._config.output_drain_timeout_s - ) - route_ids, endpoint_ids = self._output.output._delivery_targets() - wait_s = 0.000_25 - while True: - observations = await self._output.observations() - metrics = await running.metrics() - routes = tuple( - route - for route in metrics.routes - if route.route_id in route_ids or route.endpoint_id in endpoint_ids - ) - if any( - route.frames_dropped_total > 0 - or route.endpoint.frames_dropped_total > 0 - or route.endpoint.failures_total > 0 - for route in routes - ): - raise RuntimeError( - "generated audio delivery failed before output drained" - ) - routes_drained = bool(routes) and all( - route.edge.queue_depth_frames == 0 - and route.edge.frames_delivered_total - + (route.edge.discarded_output_frames_total or 0) - >= self._output_frames_written - for route in routes - ) - buffers_reclaimed = ( - observations.available_buffers == observations.buffer_slots - ) - no_declared_delivery = not route_ids and not endpoint_ids - if routes_drained or (no_declared_delivery and buffers_reclaimed): - self._event("output.drained") - return - remaining = deadline - asyncio.get_running_loop().time() - if remaining <= 0: - raise TimeoutError( - "generated audio did not drain within output_drain_timeout_s" - ) - await asyncio.sleep(min(wait_s, remaining)) - wait_s = min(wait_s * 2, 0.005) - - def _turn( - self, - envelope: SignalEnvelope[str], - update: TranscriptUpdate, - ) -> ConversationTurn: - lineage = envelope.lineage - return ConversationTurn( - id=self._turns_started + 1, - utterance_id=update.utterance_id, - text=update.text, - source_id=( - update.source_id - if update.source_id is not None - else None - if lineage is None - else lineage.source_id - ), - stream_id=( - update.stream_id - if update.stream_id is not None - else None - if lineage is None - else lineage.stream_id - ), - source_sequence=( - update.source_sequence - if update.source_sequence is not None - else None - if lineage is None - else lineage.sequence_number - ), - source_timestamp_ns=( - update.source_timestamp_ns - if update.source_timestamp_ns is not None - else envelope.timing.source_timestamp_ns - ), - audio_start_ns=update.audio_start_ns, - audio_end_ns=update.audio_end_ns, - received_timestamp_ns=monotonic_ns(), - ) - - def _append_message(self, role: str, content: str, turn_id: int) -> None: - self._history.append( - ConversationMessage( - role=cast(ConversationRole, role), - content=content, - turn_id=turn_id, - timestamp_ns=monotonic_ns(), - ) - ) - - def _event( - self, - kind: str, - *, - turn: ConversationTurn | None = None, - update: TranscriptUpdate | None = None, - generation: OutputGeneration | None = None, - duration_ns: int | None = None, - detail: str | None = None, - ) -> None: - self._events.append( - ConversationEvent( - kind=kind, - timestamp_ns=monotonic_ns(), - turn_id=None if turn is None else turn.id, - utterance_id=( - update.utterance_id - if update is not None - else None - if turn is None - else turn.utterance_id - ), - transcript_revision=None if update is None else update.revision, - output_generation_id=None if generation is None else generation.id, - duration_ns=duration_ns, - detail=detail, - ) - ) - - @staticmethod - def _remaining(deadline: float, operation: str) -> float: - remaining = deadline - asyncio.get_running_loop().time() - if remaining <= 0: - raise TimeoutError(f"{operation} exceeded its configured timeout") - return remaining - - -def _default_transcript_decoder( - envelope: SignalEnvelope[str], -) -> TranscriptUpdate | None: - if not isinstance(envelope.payload, str): - raise TypeError("conversation transcript signals must contain text") - text = envelope.payload.strip() - if not text: - return None - lineage = envelope.lineage - source = "unknown" if lineage is None else str(lineage.source_id) - sequence = 0 if lineage is None else lineage.sequence_number - audio_start_ns = envelope.timing.source_timestamp_ns - duration_ns = envelope.timing.duration_ns - audio_end_ns = ( - None - if audio_start_ns is None or duration_ns is None - else audio_start_ns + duration_ns - ) - return TranscriptUpdate( - utterance_id=f"{source}:{sequence}", - revision=1, - text=text, - stable_prefix=text, - final=True, - source_id=None if lineage is None else lineage.source_id, - stream_id=None if lineage is None else lineage.stream_id, - source_sequence=None if lineage is None else lineage.sequence_number, - source_timestamp_ns=envelope.timing.source_timestamp_ns, - audio_start_ns=audio_start_ns, - audio_end_ns=audio_end_ns, - ) - - -def _response_chunk(value: object) -> ConversationResponseChunk: - if isinstance(value, ConversationResponseChunk): - return value - if isinstance(value, ConversationResponse): - return ConversationResponseChunk(value.text, value.tool_events) - if isinstance(value, str): - return ConversationResponseChunk(value) - raise TypeError( - "response provider must return text, ConversationResponse, " - "ConversationResponseChunk, or an async iterable of those values" - ) - - -async def _iterate_until( - values: AsyncIterable[object], - deadline: float, - operation: str, -) -> AsyncIterable[object]: - iterator = aiter(values) - try: - while True: - remaining = deadline - asyncio.get_running_loop().time() - if remaining <= 0: - raise TimeoutError(f"{operation} exceeded its configured timeout") - try: - yield await asyncio.wait_for(anext(iterator), timeout=remaining) - except StopAsyncIteration: - return - finally: - close = getattr(iterator, "aclose", None) - if close is not None: - result = close() - if inspect.isawaitable(result): - await result - - -async def _iter_prepared( - chunks: tuple[ConversationResponseChunk, ...], -) -> AsyncIterable[ConversationResponseChunk]: - for chunk in chunks: - yield chunk - - -def _unique_providers(*providers: object) -> tuple[object, ...]: - unique: list[object] = [] - identities: set[int] = set() - for provider in providers: - if id(provider) not in identities: - unique.append(provider) - identities.add(id(provider)) - return tuple(unique) - __all__ = [ "Conversation", diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py index 39a5c33..7b7588e 100644 --- a/python/pocketstation/aio/relay.py +++ b/python/pocketstation/aio/relay.py @@ -157,6 +157,7 @@ async def create_receiver_invitation( async def wait_for_publisher_and_invitation( self, *, + bus_id: str = "mix", timeout_seconds: float = 10.0, poll_interval_seconds: float = 0.1, ) -> ReceiverInvitation: @@ -164,7 +165,7 @@ async def wait_for_publisher_and_invitation( timeout_seconds=timeout_seconds, poll_interval_seconds=poll_interval_seconds, ) - return await self.create_receiver_invitation() + return await self.create_receiver_invitation(bus_id=bus_id) async def wait_for_receiver( self, diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index de44283..aeb6be0 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -72,11 +72,19 @@ from .streams import AudioStream, SignalStream if TYPE_CHECKING: - from ..conversation import ConversationConfig, TranscriptUpdate from ..relay import RelayPublisher from ..signal import SignalEnvelope - from .conversation import ( + from ..voice import ( Conversation, + ConversationConfig, + DuplexVoiceModel, + ResponseModel, + SpeechDetector, + SpeechSynthesizer, + StreamingTranscriber, + TranscriptUpdate, + ) + from ..voice.conversation import ( ResponseHandler, SynthesisHandler, ) @@ -534,10 +542,16 @@ def relay(self, remote: RelaySession) -> RelayPublisher: def conversation( self, *, - transcripts: BusSubscription[str], - respond: ResponseHandler, - synthesize: SynthesisHandler, + input: object | None = None, output: AudioInput, + voice_model: DuplexVoiceModel | None = None, + stt: StreamingTranscriber | None = None, + llm: ResponseModel | None = None, + tts: SpeechSynthesizer | None = None, + vad: SpeechDetector | None = None, + transcripts: BusSubscription[str] | None = None, + respond: ResponseHandler | None = None, + synthesize: SynthesisHandler | None = None, config: ConversationConfig | None = None, decode_transcript: Callable[[SignalEnvelope[str]], TranscriptUpdate | None] | None = None, @@ -549,7 +563,73 @@ def conversation( object owns only bounded turn, provider, history, and interruption orchestration. """ - from .conversation import Conversation + from ..voice import Conversation + from ..voice.errors import VoiceConfigurationError + + provider_components = (stt, llm, tts, vad) + low_level_components = (transcripts, respond, synthesize) + if voice_model is not None: + if input is None: + raise VoiceConfigurationError( + "input is required with voice_model", + stage="configuration", + next_action="pass a SourceOutput or Stem as input", + ) + if any( + value is not None + for value in (*provider_components, *low_level_components) + ): + raise VoiceConfigurationError( + "voice_model cannot be combined with stt, llm, tts, vad, " + "transcripts, respond, or synthesize", + stage="configuration", + next_action="choose one duplex model or separate voice components", + ) + return Conversation.from_duplex( + session=self, + input=input, + output=output, + voice_model=voice_model, + config=config, + ) + + if any(value is not None for value in provider_components): + if input is None or stt is None or llm is None or tts is None: + raise VoiceConfigurationError( + "input, stt, llm, and tts are required for component " + "voice composition", + stage="configuration", + next_action="provide all four required component arguments", + ) + if any(value is not None for value in low_level_components): + raise VoiceConfigurationError( + "stt, llm, and tts cannot be combined with low-level " + "transcript callbacks", + stage="configuration", + next_action=( + "choose provider components or the low-level callback API" + ), + ) + return Conversation.from_components( + session=self, + input=input, + output=output, + stt=stt, + llm=llm, + tts=tts, + vad=vad, + config=config, + ) + + if transcripts is None or respond is None or synthesize is None: + raise VoiceConfigurationError( + "transcripts, respond, and synthesize are required by the " + "low-level callback API", + stage="configuration", + next_action=( + "provide the three callback arguments or use voice providers" + ), + ) return Conversation( transcripts=transcripts, diff --git a/python/pocketstation/conversation.py b/python/pocketstation/conversation.py index d53fc6a..dd0d3a7 100644 --- a/python/pocketstation/conversation.py +++ b/python/pocketstation/conversation.py @@ -1,286 +1,23 @@ -"""Bounded provider-neutral contracts for interruptible voice composition.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -from .identity import SourceId, StreamId - - -@dataclass(frozen=True, slots=True) -class ConversationConfig: - """Finite work, retention, deadline, and output limits for one conversation.""" - - history_capacity: int = 32 - event_capacity: int = 128 - transcript_state_capacity: int = 128 - maximum_transcript_characters: int = 32_768 - maximum_response_characters: int = 16_384 - maximum_response_chunks_per_turn: int = 1_024 - maximum_tool_events_per_turn: int = 32 - maximum_output_frames_per_turn: int = 3_000 - provider_start_timeout_s: float = 10.0 - provider_close_timeout_s: float = 10.0 - response_timeout_s: float = 60.0 - synthesis_timeout_s: float = 60.0 - output_write_timeout_s: float = 1.0 - output_drain_timeout_s: float = 5.0 - cancellation_timeout_s: float = 2.0 - signal_wait_timeout_s: float = 0.1 - - def __post_init__(self) -> None: - _bounded_integer("history_capacity", self.history_capacity, maximum=4_096) - _bounded_integer("event_capacity", self.event_capacity, maximum=16_384) - _bounded_integer( - "transcript_state_capacity", - self.transcript_state_capacity, - maximum=16_384, - ) - _bounded_integer( - "maximum_transcript_characters", - self.maximum_transcript_characters, - maximum=1_000_000, - ) - _bounded_integer( - "maximum_response_characters", - self.maximum_response_characters, - maximum=1_000_000, - ) - _bounded_integer( - "maximum_response_chunks_per_turn", - self.maximum_response_chunks_per_turn, - maximum=65_536, - ) - _bounded_integer( - "maximum_tool_events_per_turn", - self.maximum_tool_events_per_turn, - maximum=4_096, - ) - _bounded_integer( - "maximum_output_frames_per_turn", - self.maximum_output_frames_per_turn, - maximum=1_000_000, - ) - _bounded_seconds( - "provider_start_timeout_s", self.provider_start_timeout_s, maximum=300 - ) - _bounded_seconds( - "provider_close_timeout_s", self.provider_close_timeout_s, maximum=300 - ) - _bounded_seconds("response_timeout_s", self.response_timeout_s, maximum=900) - _bounded_seconds("synthesis_timeout_s", self.synthesis_timeout_s, maximum=900) - _bounded_seconds( - "output_write_timeout_s", self.output_write_timeout_s, maximum=60 - ) - _bounded_seconds( - "output_drain_timeout_s", self.output_drain_timeout_s, maximum=60 - ) - _bounded_seconds( - "cancellation_timeout_s", self.cancellation_timeout_s, maximum=60 - ) - _bounded_seconds("signal_wait_timeout_s", self.signal_wait_timeout_s, maximum=1) - - -@dataclass(frozen=True, slots=True) -class TranscriptUpdate: - """One bounded revision of speech recognized from a Session stem.""" - - utterance_id: str - revision: int - text: str - stable_prefix: str = "" - final: bool = False - interrupts: bool = True - source_id: SourceId | None = None - stream_id: StreamId | None = None - source_sequence: int | None = None - source_timestamp_ns: int | None = None - audio_start_ns: int | None = None - audio_end_ns: int | None = None - - def __post_init__(self) -> None: - if not self.utterance_id.strip(): - raise ValueError("utterance_id must not be empty") - if len(self.utterance_id) > 128: - raise ValueError("utterance_id must not exceed 128 characters") - if isinstance(self.revision, bool) or not isinstance(self.revision, int): - raise TypeError("revision must be an integer") - if self.revision < 1: - raise ValueError("revision must be greater than zero") - if not self.text.startswith(self.stable_prefix): - raise ValueError("stable_prefix must be a prefix of text") - if self.final and not self.text.strip(): - raise ValueError("a final transcript update must contain text") - if self.final and self.stable_prefix != self.text: - raise ValueError("a final transcript update must make all text stable") - _optional_identity("source_id", self.source_id) - _optional_identity("stream_id", self.stream_id) - _optional_sequence("source_sequence", self.source_sequence) - _optional_timestamp("source_timestamp_ns", self.source_timestamp_ns) - _optional_timestamp("audio_start_ns", self.audio_start_ns) - _optional_timestamp("audio_end_ns", self.audio_end_ns) - if ( - self.audio_start_ns is not None - and self.audio_end_ns is not None - and self.audio_end_ns < self.audio_start_ns - ): - raise ValueError("audio_end_ns must not precede audio_start_ns") - - -@dataclass(frozen=True, slots=True) -class ConversationTurn: - """One final transcript retained with its Session timing and source identity.""" - - id: int - utterance_id: str - text: str - source_id: SourceId | None - stream_id: StreamId | None - source_sequence: int | None - source_timestamp_ns: int | None - audio_start_ns: int | None - audio_end_ns: int | None - received_timestamp_ns: int - - -ConversationRole = Literal["user", "assistant", "tool"] -ConversationDisposition = Literal["completed", "stopped", "cancelled", "failed"] - - -@dataclass(frozen=True, slots=True) -class ConversationMessage: - """One retained bounded-history message.""" - - role: ConversationRole - content: str - turn_id: int - timestamp_ns: int - - -@dataclass(frozen=True, slots=True) -class ToolEvent: - """One explicit tool observation returned by a response provider.""" - - name: str - outcome: str - detail: str = "" - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("tool event name must not be empty") - if not self.outcome.strip(): - raise ValueError("tool event outcome must not be empty") - - -@dataclass(frozen=True, slots=True) -class ConversationResponse: - """Text and bounded tool observations produced for one user turn.""" - - text: str - tool_events: tuple[ToolEvent, ...] = () - - def __post_init__(self) -> None: - if not self.text.strip(): - raise ValueError("conversation response text must not be empty") - - -@dataclass(frozen=True, slots=True) -class ConversationResponseChunk: - """One ordered response fragment supplied to streaming synthesis.""" - - text: str = "" - tool_events: tuple[ToolEvent, ...] = () - - def __post_init__(self) -> None: - if not self.text and not self.tool_events: - raise ValueError("a response chunk must contain text or a tool event") - - -@dataclass(frozen=True, slots=True) -class ConversationContext: - """Immutable history and commit state presented to a response provider.""" - - history: tuple[ConversationMessage, ...] - committed: bool - - -@dataclass(frozen=True, slots=True) -class ConversationEvent: - """One retained lifecycle, latency, interruption, tool, or failure event.""" - - kind: str - timestamp_ns: int - turn_id: int | None = None - utterance_id: str | None = None - transcript_revision: int | None = None - output_generation_id: int | None = None - duration_ns: int | None = None - detail: str | None = None - - -@dataclass(frozen=True, slots=True) -class ConversationOutcome: - """Terminal facts for one bounded conversation run.""" - - disposition: ConversationDisposition - turns_started: int - turns_completed: int - turns_interrupted: int - transcript_updates_received: int - speculative_responses_started: int - speculative_responses_reused: int - output_generations_cancelled: int - output_frames_written: int - history: tuple[ConversationMessage, ...] - events: tuple[ConversationEvent, ...] - failure: str | None = None - - @property - def success(self) -> bool: - return self.disposition in {"completed", "stopped"} and self.failure is None - - -def _bounded_integer(name: str, value: int, *, maximum: int) -> None: - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"{name} must be an integer") - if not 1 <= value <= maximum: - raise ValueError(f"{name} must be between 1 and {maximum}") - - -def _bounded_seconds(name: str, value: float, *, maximum: float) -> None: - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError(f"{name} must be a number") - if not 0 < value <= maximum: - raise ValueError(f"{name} must be greater than 0 and at most {maximum}") - - -def _optional_timestamp(name: str, value: int | None) -> None: - if value is None: - return - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"{name} must be an integer or None") - if value < 0: - raise ValueError(f"{name} must not be negative") - - -def _optional_identity(name: str, value: int | None) -> None: - if value is None: - return - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"{name} must be an integer or None") - if value < 1: - raise ValueError(f"{name} must be greater than zero") - - -def _optional_sequence(name: str, value: int | None) -> None: - if value is None: - return - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"{name} must be an integer or None") - if value < 0: - raise ValueError(f"{name} must not be negative") - +"""Compatibility imports for the former conversation module. + +New code should import these contracts from :mod:`pocketstation.voice`. +""" + +from .voice import ( + ConversationConfig, + ConversationContext, + ConversationMessage, + ConversationOutcome, + ConversationResponse, + ConversationResponseChunk, + ConversationTurn, + ToolEvent, + TranscriptUpdate, +) +from .voice import ( + VoiceEvent as ConversationEvent, +) +from .voice.turns import ConversationDisposition, ConversationRole __all__ = [ "ConversationConfig", diff --git a/python/pocketstation/identity.py b/python/pocketstation/identity.py index 5e775e4..afa51a9 100644 --- a/python/pocketstation/identity.py +++ b/python/pocketstation/identity.py @@ -17,11 +17,11 @@ ClockDomainOrigin: TypeAlias = Literal[ "unspecified", "process-start", "provider-defined" ] +RouteId = NewType("RouteId", int) +SidecarId = NewType("SidecarId", int) EndpointId = NewType("EndpointId", int) ConnectorId = NewType("ConnectorId", int) -RouteId = NewType("RouteId", int) OperatorInstanceId = NewType("OperatorInstanceId", int) -SidecarId = NewType("SidecarId", int) __all__ = [ "ClockDomainId", diff --git a/python/pocketstation/voice/__init__.py b/python/pocketstation/voice/__init__.py new file mode 100644 index 0000000..6149262 --- /dev/null +++ b/python/pocketstation/voice/__init__.py @@ -0,0 +1,106 @@ +"""Provider-neutral contracts and composition for live voice applications. + +Provider packages implement these contracts. PocketStation continues to own +the native Session, source-aware audio paths, bounded routing, recording, +Relay delivery, and output cancellation. +""" + +from .capabilities import ( + DuplexVoiceCapabilities, + ResponseCapabilities, + SpeechDetectionCapabilities, + SynthesisCapabilities, + TranscriptionCapabilities, + VoiceCapabilities, +) +from .configuration import ( + ConversationConfig, + InterruptionConfig, + VoiceDeadlines, + VoiceLimits, +) +from .conversation import Conversation, RunningConversation +from .duplex import ( + DuplexVoiceConnection, + DuplexVoiceContext, + DuplexVoiceModel, +) +from .errors import ( + MissingProviderCredentialError, + ProviderStartupError, + ProviderTimeoutError, + ProviderUnavailableError, + UnsupportedVoiceCapabilityError, + VoiceConfigurationError, + VoiceError, +) +from .events import VoiceEvent +from .response import ( + ConversationResponse, + ConversationResponseChunk, + ResponseChunk, + ResponseModel, + ResponseRequest, + ToolEvent, +) +from .speech_detection import SpeechActivity, SpeechDetector +from .synthesis import ( + SpeechSynthesizer, + SynthesisChunk, + SynthesisRequest, +) +from .transcription import ( + StreamingTranscriber, + TranscriptionConnection, + TranscriptUpdate, +) +from .turns import ( + ConversationContext, + ConversationMessage, + ConversationOutcome, + ConversationTurn, +) + +__all__ = [ + "Conversation", + "ConversationConfig", + "ConversationContext", + "ConversationMessage", + "ConversationOutcome", + "ConversationResponse", + "ConversationResponseChunk", + "ConversationTurn", + "DuplexVoiceCapabilities", + "DuplexVoiceConnection", + "DuplexVoiceContext", + "DuplexVoiceModel", + "InterruptionConfig", + "MissingProviderCredentialError", + "ProviderStartupError", + "ProviderTimeoutError", + "ProviderUnavailableError", + "ResponseCapabilities", + "ResponseChunk", + "ResponseModel", + "ResponseRequest", + "RunningConversation", + "SpeechActivity", + "SpeechDetectionCapabilities", + "SpeechDetector", + "SpeechSynthesizer", + "StreamingTranscriber", + "SynthesisCapabilities", + "SynthesisChunk", + "SynthesisRequest", + "ToolEvent", + "TranscriptUpdate", + "TranscriptionCapabilities", + "TranscriptionConnection", + "UnsupportedVoiceCapabilityError", + "VoiceCapabilities", + "VoiceConfigurationError", + "VoiceDeadlines", + "VoiceError", + "VoiceEvent", + "VoiceLimits", +] diff --git a/python/pocketstation/voice/capabilities.py b/python/pocketstation/voice/capabilities.py new file mode 100644 index 0000000..0f3859e --- /dev/null +++ b/python/pocketstation/voice/capabilities.py @@ -0,0 +1,158 @@ +"""Capability declarations used to validate voice components before capture.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .configuration import InterruptionTrigger + + +@dataclass(frozen=True, slots=True) +class TranscriptionCapabilities: + """Speech-recognition behavior supported by one transcriber.""" + + streaming: bool + transcript_revisions: bool = False + stable_prefix: bool = False + provider_timestamps: bool = False + supported_sample_rates_hz: tuple[int, ...] = () + input_formats: tuple[str, ...] = () + maximum_session_duration_s: float | None = None + + def __post_init__(self) -> None: + _sample_rates(self.supported_sample_rates_hz) + _duration(self.maximum_session_duration_s) + + +@dataclass(frozen=True, slots=True) +class ResponseCapabilities: + """Incremental response behavior supported by one response model.""" + + streaming: bool + speculative_requests: bool = False + cancellation: bool = False + tools: bool = False + usage_reporting: bool = False + provider_history_truncation: bool = False + maximum_context_characters: int | None = None + + def __post_init__(self) -> None: + _optional_positive( + "maximum_context_characters", self.maximum_context_characters + ) + + +@dataclass(frozen=True, slots=True) +class SynthesisCapabilities: + """Generated-audio behavior supported by one speech synthesizer.""" + + streaming: bool + cancellation: bool = False + output_formats: tuple[str, ...] = () + supported_sample_rates_hz: tuple[int, ...] = () + usage_reporting: bool = False + + def __post_init__(self) -> None: + _sample_rates(self.supported_sample_rates_hz) + + +@dataclass(frozen=True, slots=True) +class SpeechDetectionCapabilities: + """Speech-activity information supported by one detector.""" + + streaming: bool = True + provisional_events: bool = False + confidence: bool = False + provider_timestamps: bool = False + supported_sample_rates_hz: tuple[int, ...] = () + + def __post_init__(self) -> None: + _sample_rates(self.supported_sample_rates_hz) + + +@dataclass(frozen=True, slots=True) +class DuplexVoiceCapabilities: + """Voice behavior supplied through one stateful audio connection.""" + + transcript_revisions: bool = False + stable_prefix: bool = False + provider_speech_detection: bool = False + interruption: bool = False + interruption_triggers: tuple[InterruptionTrigger, ...] = () + response_cancellation: bool = False + provider_history_truncation: bool = False + receiver_playout_clear: bool = False + playout_acknowledgement: bool = False + tools: bool = False + usage_reporting: bool = False + input_formats: tuple[str, ...] = () + output_formats: tuple[str, ...] = () + supported_sample_rates_hz: tuple[int, ...] = () + maximum_session_duration_s: float | None = None + + def __post_init__(self) -> None: + _sample_rates(self.supported_sample_rates_hz) + _duration(self.maximum_session_duration_s) + if len(set(self.interruption_triggers)) != len(self.interruption_triggers): + raise ValueError("interruption_triggers must not contain duplicates") + if any( + trigger not in {"speech-started", "transcript-update"} + for trigger in self.interruption_triggers + ): + raise ValueError("interruption_triggers contains an unsupported value") + if self.interruption and not self.interruption_triggers: + raise ValueError( + "interruption_triggers is required when interruption is supported" + ) + if not self.interruption and self.interruption_triggers: + raise ValueError( + "interruption_triggers requires interruption to be supported" + ) + + +@dataclass(frozen=True, slots=True) +class VoiceCapabilities: + """Capabilities of one validated voice composition.""" + + transcription: TranscriptionCapabilities | None = None + response: ResponseCapabilities | None = None + synthesis: SynthesisCapabilities | None = None + speech_detection: SpeechDetectionCapabilities | None = None + duplex: DuplexVoiceCapabilities | None = None + + def __post_init__(self) -> None: + separate = (self.transcription, self.response, self.synthesis) + if self.duplex is not None and any(value is not None for value in separate): + raise ValueError( + "duplex capabilities cannot be combined with separate " + "transcription, response, or synthesis capabilities" + ) + + +def _sample_rates(values: tuple[int, ...]) -> None: + if any(isinstance(value, bool) or value <= 0 for value in values): + raise ValueError("supported sample rates must be positive integers") + if len(set(values)) != len(values): + raise ValueError("supported sample rates must not contain duplicates") + + +def _duration(value: float | None) -> None: + if value is not None and (isinstance(value, bool) or not 0 < value <= 86_400): + raise ValueError("maximum_session_duration_s must be between 0 and 86400") + + +def _optional_positive(name: str, value: int | None) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + ): + raise ValueError(f"{name} must be a positive integer or None") + + +__all__ = [ + "DuplexVoiceCapabilities", + "ResponseCapabilities", + "SpeechDetectionCapabilities", + "SynthesisCapabilities", + "TranscriptionCapabilities", + "VoiceCapabilities", +] diff --git a/python/pocketstation/voice/configuration.py b/python/pocketstation/voice/configuration.py new file mode 100644 index 0000000..190f655 --- /dev/null +++ b/python/pocketstation/voice/configuration.py @@ -0,0 +1,265 @@ +"""Finite configuration for one voice conversation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True, slots=True) +class VoiceLimits: + """Finite retained state and work limits for voice composition.""" + + history_messages: int = 32 + retained_events: int = 128 + transcript_states: int = 128 + transcript_characters: int = 32_768 + response_characters: int = 16_384 + response_chunks_per_turn: int = 1_024 + tool_observations_per_turn: int = 32 + generated_audio_frames_per_turn: int = 3_000 + provider_event_bytes: int = 262_144 + provider_event_queue: int = 128 + + def __post_init__(self) -> None: + for name, integer_value, maximum in ( + ("history_messages", self.history_messages, 4_096), + ("retained_events", self.retained_events, 16_384), + ("transcript_states", self.transcript_states, 16_384), + ("transcript_characters", self.transcript_characters, 1_000_000), + ("response_characters", self.response_characters, 1_000_000), + ("response_chunks_per_turn", self.response_chunks_per_turn, 65_536), + ("tool_observations_per_turn", self.tool_observations_per_turn, 4_096), + ( + "generated_audio_frames_per_turn", + self.generated_audio_frames_per_turn, + 1_000_000, + ), + ("provider_event_bytes", self.provider_event_bytes, 4_194_304), + ("provider_event_queue", self.provider_event_queue, 16_384), + ): + _bounded_integer(name, integer_value, maximum=maximum) + + +@dataclass(frozen=True, slots=True) +class VoiceDeadlines: + """Deadlines for provider, output, cancellation, and shutdown work.""" + + provider_start_s: float = 10.0 + provider_close_s: float = 10.0 + response_s: float = 60.0 + synthesis_s: float = 60.0 + output_write_s: float = 1.0 + output_drain_s: float = 5.0 + cancellation_s: float = 2.0 + signal_wait_s: float = 0.1 + + def __post_init__(self) -> None: + for name, seconds, maximum in ( + ("provider_start_s", self.provider_start_s, 300.0), + ("provider_close_s", self.provider_close_s, 300.0), + ("response_s", self.response_s, 900.0), + ("synthesis_s", self.synthesis_s, 900.0), + ("output_write_s", self.output_write_s, 60.0), + ("output_drain_s", self.output_drain_s, 60.0), + ("cancellation_s", self.cancellation_s, 60.0), + ("signal_wait_s", self.signal_wait_s, 1.0), + ): + _bounded_seconds(name, seconds, maximum=maximum) + + +InterruptionTrigger = Literal["speech-started", "transcript-update"] + + +@dataclass(frozen=True, slots=True) +class InterruptionConfig: + """Policy for cancelling a response after new input is observed.""" + + enabled: bool = True + trigger: InterruptionTrigger = "speech-started" + minimum_speech_ms: int = 120 + cancel_provider_work: bool = True + cancel_pending_output: bool = True + require_receiver_observation: bool = False + + def __post_init__(self) -> None: + if self.trigger not in {"speech-started", "transcript-update"}: + raise ValueError("trigger must be speech-started or transcript-update") + if isinstance(self.minimum_speech_ms, bool) or not isinstance( + self.minimum_speech_ms, int + ): + raise TypeError("minimum_speech_ms must be an integer") + if not 0 <= self.minimum_speech_ms <= 10_000: + raise ValueError("minimum_speech_ms must be between 0 and 10000") + if self.enabled and not ( + self.cancel_provider_work or self.cancel_pending_output + ): + raise ValueError( + "enabled interruption must cancel provider work, pending output, " + "or both" + ) + + +@dataclass(frozen=True, slots=True) +class ConversationConfig: + """Validated limits, deadlines, and interruption policy for one run. + + The flat fields preserve the existing 0.1 developer API. ``limits`` and + ``deadlines`` expose the same values as grouped provider-neutral records. + """ + + history_capacity: int = 32 + event_capacity: int = 128 + transcript_state_capacity: int = 128 + maximum_transcript_characters: int = 32_768 + maximum_response_characters: int = 16_384 + maximum_response_chunks_per_turn: int = 1_024 + maximum_tool_events_per_turn: int = 32 + maximum_output_frames_per_turn: int = 3_000 + provider_event_bytes: int = 262_144 + provider_event_queue_capacity: int = 128 + provider_start_timeout_s: float = 10.0 + provider_close_timeout_s: float = 10.0 + response_timeout_s: float = 60.0 + synthesis_timeout_s: float = 60.0 + output_write_timeout_s: float = 1.0 + output_drain_timeout_s: float = 5.0 + cancellation_timeout_s: float = 2.0 + signal_wait_timeout_s: float = 0.1 + interruption: InterruptionConfig = InterruptionConfig() + + def __post_init__(self) -> None: + for name, integer_value, maximum in ( + ("history_capacity", self.history_capacity, 4_096), + ("event_capacity", self.event_capacity, 16_384), + ("transcript_state_capacity", self.transcript_state_capacity, 16_384), + ( + "maximum_transcript_characters", + self.maximum_transcript_characters, + 1_000_000, + ), + ( + "maximum_response_characters", + self.maximum_response_characters, + 1_000_000, + ), + ( + "maximum_response_chunks_per_turn", + self.maximum_response_chunks_per_turn, + 65_536, + ), + ( + "maximum_tool_events_per_turn", + self.maximum_tool_events_per_turn, + 4_096, + ), + ( + "maximum_output_frames_per_turn", + self.maximum_output_frames_per_turn, + 1_000_000, + ), + ("provider_event_bytes", self.provider_event_bytes, 4_194_304), + ( + "provider_event_queue_capacity", + self.provider_event_queue_capacity, + 16_384, + ), + ): + _bounded_integer(name, integer_value, maximum=maximum) + for name, seconds, maximum_seconds in ( + ("provider_start_timeout_s", self.provider_start_timeout_s, 300.0), + ("provider_close_timeout_s", self.provider_close_timeout_s, 300.0), + ("response_timeout_s", self.response_timeout_s, 900.0), + ("synthesis_timeout_s", self.synthesis_timeout_s, 900.0), + ("output_write_timeout_s", self.output_write_timeout_s, 60.0), + ("output_drain_timeout_s", self.output_drain_timeout_s, 60.0), + ("cancellation_timeout_s", self.cancellation_timeout_s, 60.0), + ("signal_wait_timeout_s", self.signal_wait_timeout_s, 1.0), + ): + _bounded_seconds(name, seconds, maximum=maximum_seconds) + _ = (self.limits, self.deadlines) + + @property + def limits(self) -> VoiceLimits: + return VoiceLimits( + history_messages=self.history_capacity, + retained_events=self.event_capacity, + transcript_states=self.transcript_state_capacity, + transcript_characters=self.maximum_transcript_characters, + response_characters=self.maximum_response_characters, + response_chunks_per_turn=self.maximum_response_chunks_per_turn, + tool_observations_per_turn=self.maximum_tool_events_per_turn, + generated_audio_frames_per_turn=self.maximum_output_frames_per_turn, + provider_event_bytes=self.provider_event_bytes, + provider_event_queue=self.provider_event_queue_capacity, + ) + + @property + def deadlines(self) -> VoiceDeadlines: + return VoiceDeadlines( + provider_start_s=self.provider_start_timeout_s, + provider_close_s=self.provider_close_timeout_s, + response_s=self.response_timeout_s, + synthesis_s=self.synthesis_timeout_s, + output_write_s=self.output_write_timeout_s, + output_drain_s=self.output_drain_timeout_s, + cancellation_s=self.cancellation_timeout_s, + signal_wait_s=self.signal_wait_timeout_s, + ) + + @classmethod + def from_parts( + cls, + *, + limits: VoiceLimits | None = None, + deadlines: VoiceDeadlines | None = None, + interruption: InterruptionConfig | None = None, + ) -> ConversationConfig: + selected_limits = VoiceLimits() if limits is None else limits + selected_deadlines = VoiceDeadlines() if deadlines is None else deadlines + return cls( + history_capacity=selected_limits.history_messages, + event_capacity=selected_limits.retained_events, + transcript_state_capacity=selected_limits.transcript_states, + maximum_transcript_characters=selected_limits.transcript_characters, + maximum_response_characters=selected_limits.response_characters, + maximum_response_chunks_per_turn=selected_limits.response_chunks_per_turn, + maximum_tool_events_per_turn=selected_limits.tool_observations_per_turn, + maximum_output_frames_per_turn=( + selected_limits.generated_audio_frames_per_turn + ), + provider_event_bytes=selected_limits.provider_event_bytes, + provider_event_queue_capacity=selected_limits.provider_event_queue, + provider_start_timeout_s=selected_deadlines.provider_start_s, + provider_close_timeout_s=selected_deadlines.provider_close_s, + response_timeout_s=selected_deadlines.response_s, + synthesis_timeout_s=selected_deadlines.synthesis_s, + output_write_timeout_s=selected_deadlines.output_write_s, + output_drain_timeout_s=selected_deadlines.output_drain_s, + cancellation_timeout_s=selected_deadlines.cancellation_s, + signal_wait_timeout_s=selected_deadlines.signal_wait_s, + interruption=InterruptionConfig() if interruption is None else interruption, + ) + + +def _bounded_integer(name: str, value: int, *, maximum: int) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not 1 <= value <= maximum: + raise ValueError(f"{name} must be between 1 and {maximum}") + + +def _bounded_seconds(name: str, value: float, *, maximum: float) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a number") + if not 0 < value <= maximum: + raise ValueError(f"{name} must be greater than 0 and at most {maximum}") + + +__all__ = [ + "ConversationConfig", + "InterruptionConfig", + "InterruptionTrigger", + "VoiceDeadlines", + "VoiceLimits", +] diff --git a/python/pocketstation/voice/conversation.py b/python/pocketstation/voice/conversation.py new file mode 100644 index 0000000..eb6f510 --- /dev/null +++ b/python/pocketstation/voice/conversation.py @@ -0,0 +1,1396 @@ +"""Continuous voice composition over one running native Session.""" + +from __future__ import annotations + +import asyncio +import inspect +from collections import OrderedDict, deque +from collections.abc import AsyncIterable, Awaitable, Callable +from dataclasses import dataclass +from time import monotonic_ns +from typing import TypeAlias, cast + +from ..aio.audio_input import AudioInput +from ..audio_input import OutputGeneration +from ..signal import BusSubscription, EndOfStream, SignalEnvelope +from .capabilities import ( + DuplexVoiceCapabilities, + ResponseCapabilities, + SpeechDetectionCapabilities, + SynthesisCapabilities, + TranscriptionCapabilities, + VoiceCapabilities, +) +from .configuration import ConversationConfig +from .duplex import DuplexVoiceConnection, DuplexVoiceContext, DuplexVoiceModel +from .errors import UnsupportedVoiceCapabilityError, VoiceConfigurationError +from .events import VoiceEvent +from .response import ( + ConversationResponse, + ConversationResponseChunk, + ResponseModel, + ResponseRequest, +) +from .speech_detection import SpeechActivity, SpeechDetector +from .synthesis import SpeechSynthesizer, SynthesisChunk, SynthesisRequest +from .transcription import ( + StreamingTranscriber, + TranscriptUpdate, +) +from .turns import ( + ConversationContext, + ConversationDisposition, + ConversationMessage, + ConversationOutcome, + ConversationRole, + ConversationTurn, +) + +ResponseItem: TypeAlias = str | ConversationResponse | ConversationResponseChunk +ResponseResult: TypeAlias = ( + ResponseItem + | AsyncIterable[ResponseItem] + | Awaitable[ResponseItem | AsyncIterable[ResponseItem]] +) +SynthesisResult: TypeAlias = AsyncIterable[object] | Awaitable[AsyncIterable[object]] + + +ResponseHandler: TypeAlias = Callable[ + [TranscriptUpdate, ConversationContext], ResponseResult +] +SynthesisHandler: TypeAlias = Callable[ + [ConversationResponseChunk, ConversationTurn], SynthesisResult +] +TranscriptDecoder: TypeAlias = Callable[[SignalEnvelope[str]], TranscriptUpdate | None] + + +@dataclass(frozen=True, slots=True) +class _TranscriptRecord: + revision: int + stable_prefix: str + final: bool + + +class _TranscriptState: + def __init__(self, capacity: int, maximum_characters: int) -> None: + self._capacity = capacity + self._maximum_characters = maximum_characters + self._records: OrderedDict[str, _TranscriptRecord] = OrderedDict() + + def accept(self, update: TranscriptUpdate) -> None: + if len(update.text) > self._maximum_characters: + raise ValueError("transcript exceeded maximum_transcript_characters") + previous = self._records.get(update.utterance_id) + if previous is not None: + if previous.final: + raise ValueError("a final utterance cannot receive another revision") + if update.revision <= previous.revision: + raise ValueError("transcript revisions must increase") + if not update.stable_prefix.startswith(previous.stable_prefix): + raise ValueError("stable transcript text cannot change or shrink") + self._records.move_to_end(update.utterance_id) + elif len(self._records) >= self._capacity: + oldest_id, oldest = next(iter(self._records.items())) + if not oldest.final: + raise RuntimeError("transcript_state_capacity is exhausted") + del self._records[oldest_id] + self._records[update.utterance_id] = _TranscriptRecord( + revision=update.revision, + stable_prefix=update.stable_prefix, + final=update.final, + ) + + +@dataclass(slots=True) +class _Speculation: + update: TranscriptUpdate + task: asyncio.Task[tuple[ConversationResponseChunk, ...]] + + +@dataclass(slots=True) +class _Delivery: + turn: ConversationTurn + generation: OutputGeneration + task: asyncio.Task[None] + settled: bool = False + interruption_counted: bool = False + + +class Conversation: + """Coordinate transcript, response, synthesis, and generated audio work. + + The native Session continues to own Sources, routing, recording, and + Connector delivery. This object owns only finite provider work and retained + conversation state. Partial transcripts may prepare a response, but audio + is not emitted until the transcript is final. + """ + + def __init__( + self, + *, + transcripts: BusSubscription[str] | None, + respond: ResponseHandler | None, + synthesize: SynthesisHandler | None, + output: AudioInput, + config: ConversationConfig | None = None, + decode_transcript: TranscriptDecoder | None = None, + providers: tuple[object, ...] = (), + speech_activity: AsyncIterable[SpeechActivity] | None = None, + voice_model: DuplexVoiceModel | None = None, + voice_context: DuplexVoiceContext | None = None, + duplex_connection: DuplexVoiceConnection | None = None, + capabilities: VoiceCapabilities | None = None, + ) -> None: + if voice_model is None and ( + transcripts is None or respond is None or synthesize is None + ): + raise ValueError( + "transcripts, respond, and synthesize are required for a " + "component voice conversation" + ) + if voice_model is not None and voice_context is None: + raise ValueError("voice_context is required with voice_model") + if voice_model is not None and duplex_connection is None: + raise ValueError("duplex_connection is required with voice_model") + if voice_model is not None and any( + value is not None for value in (transcripts, respond, synthesize) + ): + raise ValueError( + "voice_model cannot be combined with transcripts, respond, " + "or synthesize" + ) + if transcripts is not None and transcripts.session_id != int( + output.output.session_id + ): + raise ValueError("transcripts and output must belong to the same Session") + self._transcripts = transcripts + self._respond = respond + self._synthesize = synthesize + self._output = output + self._config = ConversationConfig() if config is None else config + self._decode_transcript = ( + _default_transcript_decoder + if decode_transcript is None + else decode_transcript + ) + self._providers = providers + self._speech_activity = speech_activity + self._voice_model = voice_model + self._voice_context = voice_context + self._capabilities = capabilities + self._duplex_connection = duplex_connection + self._transcript_state = _TranscriptState( + self._config.transcript_state_capacity, + self._config.maximum_transcript_characters, + ) + self._history: deque[ConversationMessage] = deque( + maxlen=self._config.history_capacity + ) + self._events: deque[VoiceEvent] = deque(maxlen=self._config.event_capacity) + self._stop_requested = asyncio.Event() + self._running = False + self._has_run = False + self._discontinuity_pending = False + self._turns_started = 0 + self._turns_completed = 0 + self._turns_interrupted = 0 + self._transcript_updates_received = 0 + self._speculative_responses_started = 0 + self._speculative_responses_reused = 0 + self._output_generations_cancelled = 0 + self._output_frames_written = 0 + self._outcome: ConversationOutcome | None = None + self._active_delivery: _Delivery | None = None + self._provider_tasks_cancelled = 0 + + @classmethod + def from_components( + cls, + *, + session: object, + input: object, + output: AudioInput, + stt: StreamingTranscriber, + llm: ResponseModel, + tts: SpeechSynthesizer, + vad: SpeechDetector | None = None, + config: ConversationConfig | None = None, + ) -> Conversation: + """Declare separate STT, response, synthesis, and optional VAD stages.""" + selected = ConversationConfig() if config is None else config + capabilities = _validate_components(stt, llm, tts, vad, selected) + transcription = stt.transcribe(session=session, input=input) + if not _is_transcription_connection(transcription): + raise TypeError("stt.transcribe() must return a TranscriptionConnection") + speech_activity = ( + None if vad is None else vad.detect(session=session, input=input) + ) + providers: tuple[object, ...] = tuple( + provider + for provider in (transcription, llm, tts, vad) + if provider is not None + ) + return cls( + transcripts=transcription.subscription, + respond=_ResponseModelAdapter(llm), + synthesize=_SpeechSynthesizerAdapter(tts, output), + output=output, + config=selected, + decode_transcript=transcription.decode, + providers=providers, + speech_activity=speech_activity, + capabilities=capabilities, + ) + + @classmethod + def from_duplex( + cls, + *, + session: object, + input: object, + output: AudioInput, + voice_model: DuplexVoiceModel, + config: ConversationConfig | None = None, + ) -> Conversation: + """Declare one stateful duplex provider over existing Session boundaries.""" + selected = ConversationConfig() if config is None else config + capabilities = _validate_duplex(voice_model, selected) + context = DuplexVoiceContext( + session=session, + input=input, + output=output, + config=selected, + ) + connection = voice_model.connect(context) + if not isinstance(connection, DuplexVoiceConnection): + raise TypeError("voice_model.connect() must return a DuplexVoiceConnection") + return cls( + transcripts=None, + respond=None, + synthesize=None, + output=output, + config=selected, + voice_model=voice_model, + voice_context=context, + duplex_connection=connection, + capabilities=capabilities, + ) + + @property + def config(self) -> ConversationConfig: + return self._config + + @property + def capabilities(self) -> VoiceCapabilities | None: + """Return capabilities validated before the Session starts.""" + return self._capabilities + + @property + def outcome(self) -> ConversationOutcome | None: + return self._outcome + + @property + def history(self) -> tuple[ConversationMessage, ...]: + return tuple(self._history) + + @property + def events(self) -> tuple[VoiceEvent, ...]: + return tuple(self._events) + + def stop(self) -> None: + """Request a normal stop at the next finite signal wait.""" + self._stop_requested.set() + if self._duplex_connection is not None: + self._duplex_connection.stop() + + async def interrupt(self) -> None: + """Cancel active response work and its pending output.""" + if self._duplex_connection is not None: + await self._duplex_connection.interrupt() + return + delivery = self._active_delivery + if delivery is not None: + await self._interrupt_delivery(delivery) + + async def cancel_output(self) -> None: + """Cancel pending output without stopping unrelated Session work.""" + if self._duplex_connection is not None: + await self._duplex_connection.cancel_output() + return + delivery = self._active_delivery + if delivery is not None: + self._cancel_delivery_output(delivery) + + async def start(self, running: object) -> RunningConversation: + """Start once and return a handle for waiting or cancellation.""" + task = asyncio.create_task(self.run(running), name="pocketstation-voice") + await asyncio.sleep(0) + return RunningConversation(self, task) + + async def run(self, running: object) -> ConversationOutcome: + """Run until the transcript endpoint closes or :meth:`stop` is called.""" + from ..aio.session import RunningSession + + if not isinstance(running, RunningSession): + raise TypeError("running must be a pocketstation.aio.RunningSession") + if self._running: + raise RuntimeError("Conversation is already running") + if self._has_run: + raise RuntimeError("Conversation can run only once") + expected_session_id = int(self._output.output.session_id) + if int(running.session_id) != expected_session_id: + raise ValueError("running Session does not own this conversation") + + if self._voice_model is not None: + return await self._run_duplex(running) + + assert self._transcripts is not None + assert self._respond is not None + assert self._synthesize is not None + + self._running = True + self._has_run = True + disposition = "completed" + failure: str | None = None + delivery: _Delivery | None = None + speculation: _Speculation | None = None + stream = running.signals(self._transcripts) + started_providers: list[object] = [] + speech_task: asyncio.Task[None] | None = None + try: + for provider in _unique_providers( + *self._providers, + self._respond, + self._synthesize, + ): + await self._provider_lifecycle(provider, "start") + started_providers.append(provider) + if self._speech_activity is not None: + speech_task = asyncio.create_task( + self._watch_speech(self._speech_activity), + name="pocketstation-speech-activity", + ) + while not self._stop_requested.is_set(): + if ( + delivery is not None + and delivery.task.done() + and not delivery.settled + ): + await delivery.task + delivery.settled = True + if self._active_delivery is delivery: + self._active_delivery = None + result = await stream.read(timeout_s=self._config.signal_wait_timeout_s) + if isinstance(result, EndOfStream): + break + if result is None: + continue + update = self._decode_transcript(result) + if update is None: + continue + self._transcript_state.accept(update) + self._transcript_updates_received += 1 + self._event("transcript.updated", update=update) + + if ( + delivery is not None + and update.interrupts + and ( + not self._providers + or self._config.interruption.trigger == "transcript-update" + ) + ): + await self._interrupt_delivery( + delivery, + cancel_provider_work=( + self._config.interruption.cancel_provider_work + ), + cancel_pending_output=( + self._config.interruption.cancel_pending_output + ), + ) + delivery = None + self._active_delivery = None + + if not update.final: + if not update.text.strip(): + continue + if speculation is not None and ( + speculation.update.utterance_id != update.utterance_id + or speculation.update.text != update.text + ): + await self._cancel_speculation(speculation) + speculation = None + if speculation is None: + self._speculative_responses_started += 1 + self._event("response.preparing", update=update) + speculation = _Speculation( + update=update, + task=asyncio.create_task(self._prepare_response(update)), + ) + continue + + prepared: tuple[ConversationResponseChunk, ...] | None = None + if speculation is not None: + if ( + speculation.update.utterance_id == update.utterance_id + and speculation.update.text == update.text + ): + prepared = await speculation.task + self._speculative_responses_reused += 1 + self._event("response.prepared", update=update) + else: + await self._cancel_speculation(speculation) + speculation = None + + turn = self._turn(result, update) + self._turns_started += 1 + self._append_message("user", update.text, turn.id) + self._event("turn.started", turn=turn, update=update) + generation = self._output.begin_output() + self._event( + "output.started", + turn=turn, + update=update, + generation=generation, + ) + delivery = _Delivery( + turn=turn, + generation=generation, + task=asyncio.create_task( + self._deliver_response(turn, update, generation, prepared) + ), + ) + self._active_delivery = delivery + await asyncio.sleep(0) + + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None: + if self._stop_requested.is_set(): + await self._interrupt_delivery(delivery) + disposition = "stopped" + elif not delivery.settled: + await delivery.task + delivery.settled = True + elif self._stop_requested.is_set(): + disposition = "stopped" + await self._wait_output_drained(running) + except asyncio.CancelledError: + disposition = "cancelled" + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None: + await self._interrupt_delivery(delivery) + raise + except Exception as error: + if speculation is not None: + await self._cancel_speculation(speculation) + if delivery is not None and not delivery.task.done(): + await self._interrupt_delivery(delivery) + disposition = "failed" + failure = f"{type(error).__name__}: {error}" + self._event("conversation.failed", detail=failure) + finally: + if speech_task is not None: + speech_task.cancel() + await asyncio.gather(speech_task, return_exceptions=True) + for provider in reversed(started_providers): + try: + await self._provider_lifecycle(provider, "aclose") + except Exception as error: + disposition = "failed" + failure = f"provider close failed: {type(error).__name__}: {error}" + self._event("provider.close_failed", detail=failure) + self._outcome = ConversationOutcome( + disposition=cast(ConversationDisposition, disposition), + turns_started=self._turns_started, + turns_completed=self._turns_completed, + turns_interrupted=self._turns_interrupted, + transcript_updates_received=self._transcript_updates_received, + speculative_responses_started=self._speculative_responses_started, + speculative_responses_reused=self._speculative_responses_reused, + output_generations_cancelled=self._output_generations_cancelled, + output_frames_written=self._output_frames_written, + history=tuple(self._history), + events=tuple(self._events), + failure=failure, + provider_tasks_cancelled=self._provider_tasks_cancelled, + ) + self._running = False + self._active_delivery = None + return self._outcome + + async def _run_duplex(self, running: object) -> ConversationOutcome: + assert self._voice_model is not None + assert self._voice_context is not None + assert self._duplex_connection is not None + self._running = True + self._has_run = True + result: ConversationOutcome | None = None + cancelled: asyncio.CancelledError | None = None + try: + await asyncio.wait_for( + self._duplex_connection.start(running), + timeout=self._config.provider_start_timeout_s, + ) + result = await self._duplex_connection.wait() + except asyncio.CancelledError as error: + cancelled = error + if self._duplex_connection is not None: + await self._duplex_connection.interrupt() + result = _empty_outcome("cancelled") + except Exception as error: + failure = f"{type(error).__name__}: {error}" + self._event("conversation.failed", detail=failure) + result = _empty_outcome("failed", failure=failure) + finally: + if self._duplex_connection is not None: + try: + await asyncio.wait_for( + self._duplex_connection.aclose(), + timeout=self._config.provider_close_timeout_s, + ) + except Exception as error: + failure = f"provider close failed: {type(error).__name__}: {error}" + self._event("provider.close_failed", detail=failure) + result = _empty_outcome("failed", failure=failure) + self._running = False + self._outcome = result + assert result is not None + if cancelled is not None: + raise cancelled + return result + + async def _watch_speech( + self, + activities: AsyncIterable[SpeechActivity], + ) -> None: + pending: asyncio.Task[None] | None = None + try: + async for activity in activities: + self._event( + f"input.{activity.kind}", + stage="speech-detection", + detail=activity.provider_id, + ) + if ( + activity.kind == "speech.started" + and self._config.interruption.enabled + and self._config.interruption.trigger == "speech-started" + ): + if pending is not None: + pending.cancel() + pending = asyncio.create_task( + self._interrupt_after_minimum_speech(), + name="pocketstation-minimum-speech", + ) + elif activity.kind in {"speech.stopped", "speech.cancelled"}: + if pending is not None and not pending.done(): + pending.cancel() + pending = None + finally: + if pending is not None: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + + async def _interrupt_after_minimum_speech(self) -> None: + await asyncio.sleep(self._config.interruption.minimum_speech_ms / 1_000) + delivery = self._active_delivery + if delivery is None: + return + await self._interrupt_delivery( + delivery, + cancel_provider_work=self._config.interruption.cancel_provider_work, + cancel_pending_output=self._config.interruption.cancel_pending_output, + ) + + async def _prepare_response( + self, + update: TranscriptUpdate, + ) -> tuple[ConversationResponseChunk, ...]: + chunks: list[ConversationResponseChunk] = [] + characters = 0 + async for chunk in self._response_chunks(update, committed=False): + if chunk.tool_events: + raise ValueError("a speculative response cannot request tool work") + chunks.append(chunk) + characters += len(chunk.text) + self._check_response_bounds(len(chunks), characters, 0) + if not any(chunk.text for chunk in chunks): + raise ValueError("response provider produced no text") + return tuple(chunks) + + async def _deliver_response( + self, + turn: ConversationTurn, + update: TranscriptUpdate, + generation: OutputGeneration, + prepared: tuple[ConversationResponseChunk, ...] | None, + ) -> None: + response_started = monotonic_ns() + response_text: list[str] = [] + response_chunks = 0 + response_characters = 0 + tool_events = 0 + frames = 0 + synthesis_started: int | None = None + synthesis_deadline = ( + asyncio.get_running_loop().time() + self._config.synthesis_timeout_s + ) + chunks: AsyncIterable[ConversationResponseChunk] + if prepared is None: + chunks = self._response_chunks(update, committed=True) + else: + chunks = _iter_prepared(prepared) + + async for chunk in chunks: + response_chunks += 1 + response_characters += len(chunk.text) + tool_events += len(chunk.tool_events) + self._check_response_bounds( + response_chunks, + response_characters, + tool_events, + ) + response_text.append(chunk.text) + self._event( + "response.chunk", + turn=turn, + update=update, + generation=generation, + ) + for tool in chunk.tool_events: + detail = f": {tool.detail}" if tool.detail else "" + self._append_message( + "tool", + f"{tool.name}: {tool.outcome}{detail}", + turn.id, + ) + self._event( + "tool.completed", + turn=turn, + update=update, + generation=generation, + detail=f"{tool.name}:{tool.outcome}", + ) + if not chunk.text: + continue + if synthesis_started is None: + synthesis_started = monotonic_ns() + self._event( + "synthesis.started", + turn=turn, + update=update, + generation=generation, + ) + assert self._synthesize is not None + produced = self._synthesize(chunk, turn) + if inspect.isawaitable(produced): + produced = await asyncio.wait_for( + produced, + timeout=self._remaining(synthesis_deadline, "synthesis"), + ) + async for samples in _iterate_until( + produced, + synthesis_deadline, + "synthesis", + ): + if not generation.active: + raise asyncio.CancelledError + frames += 1 + if frames > self._config.maximum_output_frames_per_turn: + raise ValueError( + "synthesis exceeded maximum_output_frames_per_turn" + ) + await self._output.write( + samples, + discontinuity=self._discontinuity_pending, + generation=generation, + timeout_s=min( + self._config.output_write_timeout_s, + self._remaining(synthesis_deadline, "synthesis"), + ), + ) + self._discontinuity_pending = False + self._output_frames_written += 1 + + text = "".join(response_text) + if not text.strip(): + raise ValueError("response provider produced no text") + self._event( + "response.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=monotonic_ns() - response_started, + ) + self._event( + "synthesis.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=( + None + if synthesis_started is None + else monotonic_ns() - synthesis_started + ), + detail=f"frames={frames}", + ) + self._append_message("assistant", text, turn.id) + self._turns_completed += 1 + self._event( + "turn.completed", + turn=turn, + update=update, + generation=generation, + duration_ns=monotonic_ns() - response_started, + ) + + async def _response_chunks( + self, + update: TranscriptUpdate, + *, + committed: bool, + ) -> AsyncIterable[ConversationResponseChunk]: + deadline = asyncio.get_running_loop().time() + self._config.response_timeout_s + assert self._respond is not None + produced = self._respond( + update, + ConversationContext(tuple(self._history), committed=committed), + ) + if inspect.isawaitable(produced): + produced = await asyncio.wait_for( + produced, + timeout=self._remaining(deadline, "response"), + ) + if isinstance(produced, AsyncIterable): + async for value in _iterate_until(produced, deadline, "response"): + yield _response_chunk(value) + else: + yield _response_chunk(produced) + + def _check_response_bounds( + self, + chunks: int, + characters: int, + tool_events: int, + ) -> None: + if chunks > self._config.maximum_response_chunks_per_turn: + raise ValueError("response exceeded maximum_response_chunks_per_turn") + if characters > self._config.maximum_response_characters: + raise ValueError("response exceeded maximum_response_characters") + if tool_events > self._config.maximum_tool_events_per_turn: + raise ValueError("response exceeded maximum_tool_events_per_turn") + + async def _interrupt_delivery( + self, + delivery: _Delivery, + *, + cancel_provider_work: bool = True, + cancel_pending_output: bool = True, + ) -> None: + if cancel_pending_output: + self._cancel_delivery_output(delivery) + if cancel_provider_work and not delivery.task.done(): + self._event( + "response.cancel_requested", + turn=delivery.turn, + generation=delivery.generation, + stage="provider", + ) + delivery.task.cancel() + try: + await asyncio.wait_for( + delivery.task, + timeout=self._config.cancellation_timeout_s, + ) + except asyncio.CancelledError: + pass + except TimeoutError as error: + raise TimeoutError( + "provider did not stop within cancellation_timeout_s" + ) from error + self._provider_tasks_cancelled += 1 + self._event( + "response.cancelled", + turn=delivery.turn, + generation=delivery.generation, + stage="provider", + ) + if not delivery.interruption_counted: + self._turns_interrupted += 1 + delivery.interruption_counted = True + self._event("turn.interrupted", turn=delivery.turn) + + def _cancel_delivery_output(self, delivery: _Delivery) -> None: + if not delivery.generation.active: + return + self._event( + "output.cancel_requested", + turn=delivery.turn, + generation=delivery.generation, + stage="core", + ) + delivery.generation.cancel() + self._output_generations_cancelled += 1 + self._discontinuity_pending = True + self._event( + "output.cancelled", + turn=delivery.turn, + generation=delivery.generation, + stage="core", + ) + self._event( + "connector.output_observation", + turn=delivery.turn, + generation=delivery.generation, + stage="connector", + available=False, + detail="connector queue acknowledgement unavailable", + ) + self._event( + "receiver.playout_observation", + turn=delivery.turn, + generation=delivery.generation, + stage="receiver", + available=False, + detail="receiver playout position unavailable", + ) + self._event( + "acoustic.hearing_observation", + turn=delivery.turn, + generation=delivery.generation, + stage="acoustic", + available=False, + detail="acoustic hearing cannot be inferred from sender state", + ) + + async def _cancel_speculation(self, speculation: _Speculation) -> None: + if speculation.task.done(): + await speculation.task + return + speculation.task.cancel() + try: + await asyncio.wait_for( + speculation.task, + timeout=self._config.cancellation_timeout_s, + ) + except asyncio.CancelledError: + self._event("response.preparation_cancelled", update=speculation.update) + except TimeoutError as error: + raise TimeoutError( + "speculative response did not stop within cancellation_timeout_s" + ) from error + + async def _provider_lifecycle(self, provider: object, method_name: str) -> None: + method = getattr(provider, method_name, None) + if method is None: + return + timeout_s = ( + self._config.provider_close_timeout_s + if method_name == "aclose" + else self._config.provider_start_timeout_s + ) + if inspect.iscoroutinefunction(method): + result = await asyncio.wait_for(method(), timeout=timeout_s) + else: + result = await asyncio.wait_for( + asyncio.to_thread(method), + timeout=timeout_s, + ) + if inspect.isawaitable(result): + await asyncio.wait_for(result, timeout=timeout_s) + + async def _wait_output_drained(self, running: object) -> None: + from ..aio.session import RunningSession + + if not isinstance(running, RunningSession): + raise TypeError("running must be a pocketstation.aio.RunningSession") + deadline = ( + asyncio.get_running_loop().time() + self._config.output_drain_timeout_s + ) + route_ids, endpoint_ids = self._output.output._delivery_targets() + wait_s = 0.000_25 + while True: + observations = await self._output.observations() + metrics = await running.metrics() + routes = tuple( + route + for route in metrics.routes + if route.route_id in route_ids or route.endpoint_id in endpoint_ids + ) + if any( + route.frames_dropped_total > 0 + or route.endpoint.frames_dropped_total > 0 + or route.endpoint.failures_total > 0 + for route in routes + ): + raise RuntimeError( + "generated audio delivery failed before output drained" + ) + routes_drained = bool(routes) and all( + route.edge.queue_depth_frames == 0 + and route.edge.frames_delivered_total + + (route.edge.discarded_output_frames_total or 0) + >= self._output_frames_written + for route in routes + ) + buffers_reclaimed = ( + observations.available_buffers == observations.buffer_slots + ) + no_declared_delivery = not route_ids and not endpoint_ids + if routes_drained or (no_declared_delivery and buffers_reclaimed): + self._event("output.drained") + return + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError( + "generated audio did not drain within output_drain_timeout_s" + ) + await asyncio.sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) + + def _turn( + self, + envelope: SignalEnvelope[str], + update: TranscriptUpdate, + ) -> ConversationTurn: + lineage = envelope.lineage + return ConversationTurn( + id=self._turns_started + 1, + utterance_id=update.utterance_id, + text=update.text, + source_id=( + update.source_id + if update.source_id is not None + else None + if lineage is None + else lineage.source_id + ), + stream_id=( + update.stream_id + if update.stream_id is not None + else None + if lineage is None + else lineage.stream_id + ), + source_sequence=( + update.source_sequence + if update.source_sequence is not None + else None + if lineage is None + else lineage.sequence_number + ), + source_timestamp_ns=( + update.source_timestamp_ns + if update.source_timestamp_ns is not None + else envelope.timing.source_timestamp_ns + ), + audio_start_ns=update.audio_start_ns, + audio_end_ns=update.audio_end_ns, + received_timestamp_ns=monotonic_ns(), + ) + + def _append_message(self, role: str, content: str, turn_id: int) -> None: + self._history.append( + ConversationMessage( + role=cast(ConversationRole, role), + content=content, + turn_id=turn_id, + timestamp_ns=monotonic_ns(), + ) + ) + + def _event( + self, + kind: str, + *, + turn: ConversationTurn | None = None, + update: TranscriptUpdate | None = None, + generation: OutputGeneration | None = None, + duration_ns: int | None = None, + stage: str | None = None, + available: bool = True, + detail: str | None = None, + ) -> None: + self._events.append( + VoiceEvent( + kind=kind, + timestamp_ns=monotonic_ns(), + stage=stage, + turn_id=None if turn is None else turn.id, + utterance_id=( + update.utterance_id + if update is not None + else None + if turn is None + else turn.utterance_id + ), + transcript_revision=None if update is None else update.revision, + output_generation_id=None if generation is None else generation.id, + duration_ns=duration_ns, + available=available, + detail=detail, + ) + ) + + @staticmethod + def _remaining(deadline: float, operation: str) -> float: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"{operation} exceeded its configured timeout") + return remaining + + +class RunningConversation: + """A started conversation that can be waited, interrupted, or stopped.""" + + def __init__( + self, + conversation: Conversation, + task: asyncio.Task[ConversationOutcome], + ) -> None: + self._conversation = conversation + self._task = task + + @property + def outcome(self) -> ConversationOutcome | None: + return self._conversation.outcome + + @property + def events(self) -> tuple[VoiceEvent, ...]: + return self._conversation.events + + async def wait(self) -> ConversationOutcome: + return await self._task + + async def interrupt(self) -> None: + await self._conversation.interrupt() + + async def cancel_output(self) -> None: + await self._conversation.cancel_output() + + def stop(self) -> None: + self._conversation.stop() + + async def aclose(self, *, abort: bool = False) -> ConversationOutcome: + if abort and not self._task.done(): + self._task.cancel() + elif not self._task.done(): + self.stop() + timeout_s = ( + self._conversation.config.cancellation_timeout_s + + self._conversation.config.output_drain_timeout_s + + self._conversation.config.provider_close_timeout_s + ) + try: + return await asyncio.wait_for(self._task, timeout=timeout_s) + except asyncio.CancelledError: + outcome = self.outcome + if outcome is None: + raise + return outcome + + async def __aenter__(self) -> RunningConversation: + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: object, + ) -> None: + await self.aclose(abort=exception_type is not None) + + +class _ResponseModelAdapter: + def __init__(self, model: ResponseModel) -> None: + self._model = model + + def __call__( + self, + update: TranscriptUpdate, + context: ConversationContext, + ) -> ResponseResult: + return self._model.respond(ResponseRequest(update, context)) + + +class _SpeechSynthesizerAdapter: + def __init__(self, synthesizer: SpeechSynthesizer, output: AudioInput) -> None: + self._synthesizer = synthesizer + self._sample_rate_hz = output.config.sample_rate_hz + self._channels = output.config.channels + + async def __call__( + self, + chunk: ConversationResponseChunk, + turn: ConversationTurn, + ) -> AsyncIterable[object]: + produced = self._synthesizer.synthesize(SynthesisRequest(chunk, turn)) + if inspect.isawaitable(produced): + produced = await produced + + async def samples() -> AsyncIterable[object]: + async for value in produced: + if not isinstance(value, SynthesisChunk): + raise TypeError( + "SpeechSynthesizer must yield SynthesisChunk values" + ) + if value.sample_rate_hz != self._sample_rate_hz: + raise ValueError( + "synthesis sample rate must match the AudioInput sample rate" + ) + if value.channels != self._channels: + raise ValueError( + "synthesis channel count must match the AudioInput " + "channel count" + ) + yield value.samples + + return samples() + + +def _empty_outcome( + disposition: ConversationDisposition, + *, + failure: str | None = None, +) -> ConversationOutcome: + return ConversationOutcome( + disposition=disposition, + turns_started=0, + turns_completed=0, + turns_interrupted=0, + transcript_updates_received=0, + speculative_responses_started=0, + speculative_responses_reused=0, + output_generations_cancelled=0, + output_frames_written=0, + history=(), + events=(), + failure=failure, + ) + + +def _default_transcript_decoder( + envelope: SignalEnvelope[str], +) -> TranscriptUpdate | None: + if not isinstance(envelope.payload, str): + raise TypeError("conversation transcript signals must contain text") + text = envelope.payload.strip() + if not text: + return None + lineage = envelope.lineage + source = "unknown" if lineage is None else str(lineage.source_id) + sequence = 0 if lineage is None else lineage.sequence_number + audio_start_ns = envelope.timing.source_timestamp_ns + duration_ns = envelope.timing.duration_ns + audio_end_ns = ( + None + if audio_start_ns is None or duration_ns is None + else audio_start_ns + duration_ns + ) + return TranscriptUpdate( + utterance_id=f"{source}:{sequence}", + revision=1, + text=text, + stable_prefix=text, + final=True, + source_id=None if lineage is None else lineage.source_id, + stream_id=None if lineage is None else lineage.stream_id, + source_sequence=None if lineage is None else lineage.sequence_number, + source_timestamp_ns=envelope.timing.source_timestamp_ns, + audio_start_ns=audio_start_ns, + audio_end_ns=audio_end_ns, + ) + + +def _response_chunk(value: object) -> ConversationResponseChunk: + if isinstance(value, ConversationResponseChunk): + return value + if isinstance(value, ConversationResponse): + return ConversationResponseChunk(value.text, value.tool_events) + if isinstance(value, str): + return ConversationResponseChunk(value) + raise TypeError( + "response provider must return text, ConversationResponse, " + "ConversationResponseChunk, or an async iterable of those values" + ) + + +async def _iterate_until( + values: AsyncIterable[object], + deadline: float, + operation: str, +) -> AsyncIterable[object]: + iterator = aiter(values) + try: + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"{operation} exceeded its configured timeout") + try: + yield await asyncio.wait_for(anext(iterator), timeout=remaining) + except StopAsyncIteration: + return + finally: + close = getattr(iterator, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + + +async def _iter_prepared( + chunks: tuple[ConversationResponseChunk, ...], +) -> AsyncIterable[ConversationResponseChunk]: + for chunk in chunks: + yield chunk + + +def _unique_providers(*providers: object) -> tuple[object, ...]: + unique: list[object] = [] + identities: set[int] = set() + for provider in providers: + if id(provider) not in identities: + unique.append(provider) + identities.add(id(provider)) + return tuple(unique) + + +def _validate_components( + transcriber: StreamingTranscriber, + response_model: ResponseModel, + synthesizer: SpeechSynthesizer, + speech_detector: SpeechDetector | None, + config: ConversationConfig, +) -> VoiceCapabilities: + transcription = getattr(transcriber, "capabilities", None) + response = getattr(response_model, "capabilities", None) + synthesis = getattr(synthesizer, "capabilities", None) + speech_detection = ( + None + if speech_detector is None + else getattr(speech_detector, "capabilities", None) + ) + if not isinstance(transcription, TranscriptionCapabilities): + raise _configuration_error("stt.capabilities must be TranscriptionCapabilities") + if not isinstance(response, ResponseCapabilities): + raise _configuration_error("llm.capabilities must be ResponseCapabilities") + if not isinstance(synthesis, SynthesisCapabilities): + raise _configuration_error("tts.capabilities must be SynthesisCapabilities") + if speech_detector is not None and not isinstance( + speech_detection, SpeechDetectionCapabilities + ): + raise _configuration_error( + "vad.capabilities must be SpeechDetectionCapabilities" + ) + if not transcription.streaming: + raise _unsupported("stt must provide streaming transcript updates") + if not response.streaming: + raise _unsupported("llm must produce response chunks incrementally") + if not synthesis.streaming: + raise _unsupported("tts must produce audio chunks incrementally") + if config.interruption.enabled: + if config.interruption.trigger == "speech-started": + if speech_detection is None: + raise _unsupported( + "vad is required when interruption is triggered by speech start" + ) + if not speech_detection.streaming: + raise _unsupported("vad must stream speech activity") + if config.interruption.cancel_provider_work and not response.cancellation: + raise _unsupported("llm must support cancellation when interruption is on") + if config.interruption.cancel_provider_work and not synthesis.cancellation: + raise _unsupported("tts must support cancellation when interruption is on") + if config.interruption.require_receiver_observation: + raise _unsupported( + "separate voice components do not yet provide receiver playout " + "observations" + ) + return VoiceCapabilities( + transcription=transcription, + response=response, + synthesis=synthesis, + speech_detection=cast( + SpeechDetectionCapabilities | None, + speech_detection, + ), + ) + + +def _validate_duplex( + voice_model: DuplexVoiceModel, + config: ConversationConfig, +) -> VoiceCapabilities: + capabilities = getattr(voice_model, "capabilities", None) + if not isinstance(capabilities, DuplexVoiceCapabilities): + raise _configuration_error( + "voice_model.capabilities must be DuplexVoiceCapabilities" + ) + if config.interruption.enabled: + if not capabilities.interruption: + raise _unsupported("voice_model must support interruption") + if config.interruption.trigger not in capabilities.interruption_triggers: + raise _unsupported( + "voice_model does not support the configured interruption trigger" + ) + if ( + config.interruption.trigger == "speech-started" + and not capabilities.provider_speech_detection + ): + raise _unsupported( + "voice_model must report speech activity for speech-started " + "interruption" + ) + if ( + config.interruption.cancel_provider_work + and not capabilities.response_cancellation + ): + raise _unsupported( + "voice_model must support response cancellation when interruption is on" + ) + if config.interruption.require_receiver_observation and not ( + capabilities.receiver_playout_clear and capabilities.playout_acknowledgement + ): + raise _unsupported( + "voice_model must clear receiver playout and acknowledge the cutoff" + ) + return VoiceCapabilities(duplex=capabilities) + + +def _is_transcription_connection(value: object) -> bool: + subscription = getattr(value, "subscription", None) + return ( + isinstance(subscription, BusSubscription) + and callable(getattr(value, "decode", None)) + and callable(getattr(value, "start", None)) + and callable(getattr(value, "aclose", None)) + ) + + +def _configuration_error(message: str) -> VoiceConfigurationError: + return VoiceConfigurationError( + message, + stage="configuration", + next_action="select components whose declared capabilities match the policy", + ) + + +def _unsupported(message: str) -> UnsupportedVoiceCapabilityError: + return UnsupportedVoiceCapabilityError( + message, + stage="configuration", + next_action=( + "select a provider with the required capability or change the policy" + ), + ) + + +__all__ = [ + "Conversation", + "ResponseHandler", + "RunningConversation", + "SynthesisHandler", + "TranscriptDecoder", +] diff --git a/python/pocketstation/voice/duplex.py b/python/pocketstation/voice/duplex.py new file mode 100644 index 0000000..8aeeea5 --- /dev/null +++ b/python/pocketstation/voice/duplex.py @@ -0,0 +1,58 @@ +"""Contracts for stateful providers that accept and produce live audio.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from .capabilities import DuplexVoiceCapabilities +from .configuration import ConversationConfig +from .turns import ConversationOutcome + + +@dataclass(frozen=True, slots=True) +class DuplexVoiceContext: + """Existing Session boundaries supplied to a duplex provider adapter.""" + + session: object + input: object + output: object + config: ConversationConfig + + +@runtime_checkable +class DuplexVoiceConnection(Protocol): + """One finite provider connection attached to a PocketStation Session.""" + + async def start(self, running: object) -> None: ... + + async def wait(self) -> ConversationOutcome: ... + + async def interrupt(self) -> None: ... + + async def cancel_output(self) -> None: ... + + def stop(self) -> None: ... + + async def aclose(self) -> None: ... + + +DuplexConnectResult = DuplexVoiceConnection + + +@runtime_checkable +class DuplexVoiceModel(Protocol): + """Declare one stateful provider connection before the Session starts.""" + + @property + def capabilities(self) -> DuplexVoiceCapabilities: ... + + def connect(self, context: DuplexVoiceContext) -> DuplexConnectResult: ... + + +__all__ = [ + "DuplexConnectResult", + "DuplexVoiceConnection", + "DuplexVoiceContext", + "DuplexVoiceModel", +] diff --git a/python/pocketstation/voice/errors.py b/python/pocketstation/voice/errors.py new file mode 100644 index 0000000..aa8c73e --- /dev/null +++ b/python/pocketstation/voice/errors.py @@ -0,0 +1,59 @@ +"""Errors raised by provider-neutral voice composition.""" + +from __future__ import annotations + + +class VoiceError(Exception): + """Base error with cleanup and recovery facts for one voice operation.""" + + def __init__( + self, + message: str, + *, + stage: str, + provider_id: str | None = None, + cleaned_up: tuple[str, ...] = (), + input_remains_active: bool = False, + next_action: str | None = None, + ) -> None: + super().__init__(message) + self.stage = stage + self.provider_id = provider_id + self.cleaned_up = cleaned_up + self.input_remains_active = input_remains_active + self.next_action = next_action + + +class VoiceConfigurationError(VoiceError, ValueError): + """The declared voice components cannot form one valid conversation.""" + + +class MissingProviderCredentialError(VoiceConfigurationError): + """A selected provider did not receive a required credential.""" + + +class ProviderStartupError(VoiceError): + """A provider failed before the conversation became ready.""" + + +class ProviderTimeoutError(VoiceError, TimeoutError): + """A provider operation exceeded its configured deadline.""" + + +class ProviderUnavailableError(VoiceError): + """A provider could not serve the requested voice operation.""" + + +class UnsupportedVoiceCapabilityError(VoiceConfigurationError): + """A provider cannot satisfy a capability required by the composition.""" + + +__all__ = [ + "MissingProviderCredentialError", + "ProviderStartupError", + "ProviderTimeoutError", + "ProviderUnavailableError", + "UnsupportedVoiceCapabilityError", + "VoiceConfigurationError", + "VoiceError", +] diff --git a/python/pocketstation/voice/events.py b/python/pocketstation/voice/events.py new file mode 100644 index 0000000..98e2320 --- /dev/null +++ b/python/pocketstation/voice/events.py @@ -0,0 +1,40 @@ +"""Measured events retained by one voice conversation.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class VoiceEvent: + """One measured voice lifecycle or media-boundary event.""" + + kind: str + timestamp_ns: int + stage: str | None = None + provider_id: str | None = None + turn_id: int | None = None + utterance_id: str | None = None + transcript_revision: int | None = None + response_id: str | None = None + output_generation_id: int | None = None + duration_ns: int | None = None + available: bool = True + detail: str | None = None + + def __post_init__(self) -> None: + if not self.kind.strip(): + raise ValueError("voice event kind must not be empty") + if self.timestamp_ns < 0: + raise ValueError("timestamp_ns must not be negative") + if self.duration_ns is not None and self.duration_ns < 0: + raise ValueError("duration_ns must not be negative") + + +ConversationEvent = VoiceEvent + + +__all__ = [ + "ConversationEvent", + "VoiceEvent", +] diff --git a/python/pocketstation/voice/response.py b/python/pocketstation/voice/response.py new file mode 100644 index 0000000..c19e207 --- /dev/null +++ b/python/pocketstation/voice/response.py @@ -0,0 +1,105 @@ +"""Incremental response contracts for provider-neutral voice composition.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Awaitable +from dataclasses import dataclass +from typing import Protocol, TypeAlias, runtime_checkable + +from .capabilities import ResponseCapabilities +from .transcription import TranscriptUpdate +from .turns import ConversationContext + + +@dataclass(frozen=True, slots=True) +class ToolEvent: + """One bounded observation returned by provider-managed tool work.""" + + name: str + outcome: str + detail: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("tool event name must not be empty") + if not self.outcome.strip(): + raise ValueError("tool event outcome must not be empty") + + +@dataclass(frozen=True, slots=True) +class ResponseRequest: + """A transcript revision and finite history presented to a response model.""" + + transcript: TranscriptUpdate + context: ConversationContext + + +@dataclass(frozen=True, slots=True) +class ResponseChunk: + """One ordered response fragment.""" + + text: str = "" + tool_events: tuple[ToolEvent, ...] = () + response_id: str | None = None + turn_id: int | None = None + final: bool = False + provider_timestamp_ns: int | None = None + + def __post_init__(self) -> None: + if not self.text and not self.tool_events and not self.final: + raise ValueError( + "a response chunk must contain text, a tool event, or final" + ) + if self.response_id is not None and not self.response_id.strip(): + raise ValueError("response_id must not be empty") + if self.turn_id is not None and self.turn_id < 1: + raise ValueError("turn_id must be greater than zero") + if self.provider_timestamp_ns is not None and self.provider_timestamp_ns < 0: + raise ValueError("provider_timestamp_ns must not be negative") + + +@dataclass(frozen=True, slots=True) +class ConversationResponse: + """Compatibility value for a complete non-streaming response.""" + + text: str + tool_events: tuple[ToolEvent, ...] = () + + def __post_init__(self) -> None: + if not self.text.strip(): + raise ValueError("conversation response text must not be empty") + + +ConversationResponseChunk = ResponseChunk +ResponseItem: TypeAlias = str | ConversationResponse | ResponseChunk +ResponseResult: TypeAlias = ( + ResponseItem + | AsyncIterable[ResponseItem] + | Awaitable[ResponseItem | AsyncIterable[ResponseItem]] +) + + +@runtime_checkable +class ResponseModel(Protocol): + """Produce bounded incremental text from a transcript and history.""" + + @property + def capabilities(self) -> ResponseCapabilities: ... + + def respond(self, request: ResponseRequest) -> ResponseResult: ... + + async def start(self) -> None: ... + + async def aclose(self) -> None: ... + + +__all__ = [ + "ConversationResponse", + "ConversationResponseChunk", + "ResponseChunk", + "ResponseItem", + "ResponseModel", + "ResponseRequest", + "ResponseResult", + "ToolEvent", +] diff --git a/python/pocketstation/voice/speech_detection.py b/python/pocketstation/voice/speech_detection.py new file mode 100644 index 0000000..598ff3a --- /dev/null +++ b/python/pocketstation/voice/speech_detection.py @@ -0,0 +1,63 @@ +"""Speech-activity events and detector integration contracts.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +from dataclasses import dataclass +from typing import Literal, Protocol, runtime_checkable + +from ..identity import SourceId, StreamId +from .capabilities import SpeechDetectionCapabilities + +SpeechActivityKind = Literal[ + "speech.started", + "speech.updated", + "speech.stopped", + "speech.cancelled", +] + + +@dataclass(frozen=True, slots=True) +class SpeechActivity: + """One speech boundary observed from a source-aware audio stream.""" + + kind: SpeechActivityKind + source_id: SourceId + stream_id: StreamId + audio_timestamp_ns: int + detection_timestamp_ns: int + provider_id: str + final: bool + confidence: float | None = None + + def __post_init__(self) -> None: + if self.audio_timestamp_ns < 0 or self.detection_timestamp_ns < 0: + raise ValueError("speech timestamps must not be negative") + if not self.provider_id.strip(): + raise ValueError("provider_id must not be empty") + if self.confidence is not None and not 0 <= self.confidence <= 1: + raise ValueError("confidence must be between 0 and 1") + + +@runtime_checkable +class SpeechDetector(Protocol): + """Observe speech activity without deciding conversation policy.""" + + @property + def capabilities(self) -> SpeechDetectionCapabilities: ... + + def detect( + self, *, session: object, input: object + ) -> AsyncIterable[SpeechActivity]: ... + + async def start(self) -> None: ... + + async def aclose(self) -> None: ... + + +__all__ = [ + "SpeechActivity", + "SpeechActivityKind", + "SpeechDetectionCapabilities", + "SpeechDetector", +] diff --git a/python/pocketstation/voice/synthesis.py b/python/pocketstation/voice/synthesis.py new file mode 100644 index 0000000..f04504b --- /dev/null +++ b/python/pocketstation/voice/synthesis.py @@ -0,0 +1,74 @@ +"""Streaming speech-synthesis contracts over the existing PCM input boundary.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Awaitable +from dataclasses import dataclass +from typing import Protocol, TypeAlias, runtime_checkable + +from .capabilities import SynthesisCapabilities +from .response import ResponseChunk +from .turns import ConversationTurn + + +@dataclass(frozen=True, slots=True) +class SynthesisRequest: + """One response fragment selected for speech synthesis.""" + + response: ResponseChunk + turn: ConversationTurn + + +@dataclass(frozen=True, slots=True) +class SynthesisChunk: + """One generated PCM chunk with response and turn ownership.""" + + samples: object + sample_rate_hz: int + channels: int + sequence: int + response_id: str | None = None + turn_id: int | None = None + timestamp_ns: int | None = None + final: bool = False + provider_observations: tuple[object, ...] = () + + def __post_init__(self) -> None: + if self.sample_rate_hz <= 0: + raise ValueError("sample_rate_hz must be greater than zero") + if not 1 <= self.channels <= 32: + raise ValueError("channels must be between 1 and 32") + if self.sequence < 0: + raise ValueError("sequence must not be negative") + if self.turn_id is not None and self.turn_id < 1: + raise ValueError("turn_id must be greater than zero") + if self.timestamp_ns is not None and self.timestamp_ns < 0: + raise ValueError("timestamp_ns must not be negative") + + +SynthesisResult: TypeAlias = ( + AsyncIterable[SynthesisChunk | object] + | Awaitable[AsyncIterable[SynthesisChunk | object]] +) + + +@runtime_checkable +class SpeechSynthesizer(Protocol): + """Produce PCM incrementally for one response fragment.""" + + @property + def capabilities(self) -> SynthesisCapabilities: ... + + def synthesize(self, request: SynthesisRequest) -> SynthesisResult: ... + + async def start(self) -> None: ... + + async def aclose(self) -> None: ... + + +__all__ = [ + "SpeechSynthesizer", + "SynthesisChunk", + "SynthesisRequest", + "SynthesisResult", +] diff --git a/python/pocketstation/voice/transcription.py b/python/pocketstation/voice/transcription.py new file mode 100644 index 0000000..2ce88c2 --- /dev/null +++ b/python/pocketstation/voice/transcription.py @@ -0,0 +1,120 @@ +"""Streaming transcript revisions and transcriber integration contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from ..identity import SourceId, StreamId +from ..signal import BusSubscription, SignalEnvelope +from .capabilities import TranscriptionCapabilities + + +@dataclass(frozen=True, slots=True) +class TranscriptUpdate: + """One revision of speech recognized from a source-aware audio stream.""" + + utterance_id: str + revision: int + text: str + stable_prefix: str = "" + final: bool = False + interrupts: bool = True + source_id: SourceId | None = None + stream_id: StreamId | None = None + source_sequence: int | None = None + source_timestamp_ns: int | None = None + audio_start_ns: int | None = None + audio_end_ns: int | None = None + provider_timestamp_ns: int | None = None + session_timestamp_ns: int | None = None + + def __post_init__(self) -> None: + if not self.utterance_id.strip(): + raise ValueError("utterance_id must not be empty") + if len(self.utterance_id) > 128: + raise ValueError("utterance_id must not exceed 128 characters") + if isinstance(self.revision, bool) or not isinstance(self.revision, int): + raise TypeError("revision must be an integer") + if self.revision < 1: + raise ValueError("revision must be greater than zero") + if not self.text.startswith(self.stable_prefix): + raise ValueError("stable_prefix must be a prefix of text") + if self.final and not self.text.strip(): + raise ValueError("a final transcript update must contain text") + if self.final and self.stable_prefix != self.text: + raise ValueError("a final transcript update must make all text stable") + _optional_identity("source_id", self.source_id) + _optional_identity("stream_id", self.stream_id) + _optional_sequence("source_sequence", self.source_sequence) + for name, value in ( + ("source_timestamp_ns", self.source_timestamp_ns), + ("audio_start_ns", self.audio_start_ns), + ("audio_end_ns", self.audio_end_ns), + ("provider_timestamp_ns", self.provider_timestamp_ns), + ("session_timestamp_ns", self.session_timestamp_ns), + ): + _optional_timestamp(name, value) + if ( + self.audio_start_ns is not None + and self.audio_end_ns is not None + and self.audio_end_ns < self.audio_start_ns + ): + raise ValueError("audio_end_ns must not precede audio_start_ns") + + +@runtime_checkable +class TranscriptionConnection(Protocol): + """A declared transcript signal and its provider-specific decoder.""" + + @property + def subscription(self) -> BusSubscription[str]: ... + + def decode(self, envelope: SignalEnvelope[str]) -> TranscriptUpdate | None: ... + + async def start(self) -> None: ... + + async def aclose(self) -> None: ... + + +@runtime_checkable +class StreamingTranscriber(Protocol): + """Attach speech recognition to one existing Session audio stream.""" + + @property + def capabilities(self) -> TranscriptionCapabilities: ... + + def transcribe( + self, + *, + session: object, + input: object, + ) -> TranscriptionConnection: ... + + +def _optional_timestamp(name: str, value: int | None) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise TypeError(f"{name} must be a non-negative integer or None") + + +def _optional_identity(name: str, value: int | None) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 1 + ): + raise TypeError(f"{name} must be a positive integer or None") + + +def _optional_sequence(name: str, value: int | None) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise TypeError(f"{name} must be a non-negative integer or None") + + +__all__ = [ + "StreamingTranscriber", + "TranscriptUpdate", + "TranscriptionConnection", +] diff --git a/python/pocketstation/voice/turns.py b/python/pocketstation/voice/turns.py new file mode 100644 index 0000000..0ba86f4 --- /dev/null +++ b/python/pocketstation/voice/turns.py @@ -0,0 +1,81 @@ +"""Committed conversation turns and finite retained context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ..identity import SourceId, StreamId + +ConversationRole = Literal["user", "assistant", "tool"] +ConversationDisposition = Literal["completed", "stopped", "cancelled", "failed"] + + +@dataclass(frozen=True, slots=True) +class ConversationTurn: + """A final transcript with its source identity and Session timing.""" + + id: int + utterance_id: str + text: str + source_id: SourceId | None + stream_id: StreamId | None + source_sequence: int | None + source_timestamp_ns: int | None + audio_start_ns: int | None + audio_end_ns: int | None + received_timestamp_ns: int + + +@dataclass(frozen=True, slots=True) +class ConversationMessage: + """One message retained in finite conversation history.""" + + role: ConversationRole + content: str + turn_id: int + timestamp_ns: int + + +@dataclass(frozen=True, slots=True) +class ConversationContext: + """Immutable history and commit state presented to a response model.""" + + history: tuple[ConversationMessage, ...] + committed: bool + + +@dataclass(frozen=True, slots=True) +class ConversationOutcome: + """Terminal facts from one bounded conversation run.""" + + disposition: ConversationDisposition + turns_started: int + turns_completed: int + turns_interrupted: int + transcript_updates_received: int + speculative_responses_started: int + speculative_responses_reused: int + output_generations_cancelled: int + output_frames_written: int + history: tuple[ConversationMessage, ...] + events: tuple[object, ...] + failure: str | None = None + provider_tasks_cancelled: int = 0 + connector_queues_cleared: int = 0 + receiver_observations_received: int = 0 + acoustic_hearing_known: bool = False + + @property + def success(self) -> bool: + return self.disposition in {"completed", "stopped"} and self.failure is None + + +__all__ = [ + "ConversationContext", + "ConversationDisposition", + "ConversationMessage", + "ConversationOutcome", + "ConversationRole", + "ConversationTurn", +] diff --git a/python/pocketstation_examples/__init__.py b/python/pocketstation_demo/__init__.py similarity index 80% rename from python/pocketstation_examples/__init__.py rename to python/pocketstation_demo/__init__.py index ba6f738..6e90f40 100644 --- a/python/pocketstation_examples/__init__.py +++ b/python/pocketstation_demo/__init__.py @@ -1,4 +1,4 @@ -"""Example-owned provider integrations over the installed PocketStation SDK.""" +"""Runnable PocketStation demos and their replaceable provider adapters.""" from .demo import main from .faster_whisper import FasterWhisper, FasterWhisperConfiguration diff --git a/python/pocketstation_examples/audio_windows.py b/python/pocketstation_demo/audio_windows.py similarity index 98% rename from python/pocketstation_examples/audio_windows.py rename to python/pocketstation_demo/audio_windows.py index 156dcab..eeca983 100644 --- a/python/pocketstation_examples/audio_windows.py +++ b/python/pocketstation_demo/audio_windows.py @@ -1,4 +1,4 @@ -"""Finite source-aware PCM windows for the example transcription provider.""" +"""Finite source-aware PCM windows for demo transcription adapters.""" from __future__ import annotations diff --git a/python/pocketstation_examples/demo.py b/python/pocketstation_demo/demo.py similarity index 94% rename from python/pocketstation_examples/demo.py rename to python/pocketstation_demo/demo.py index cf8f75f..d11c698 100644 --- a/python/pocketstation_examples/demo.py +++ b/python/pocketstation_demo/demo.py @@ -1,4 +1,4 @@ -"""Run the installed application-and-microphone product demo.""" +"""Run the installed application-and-microphone PocketStation demo.""" import asyncio import webbrowser diff --git a/python/pocketstation_examples/faster_whisper.py b/python/pocketstation_demo/faster_whisper.py similarity index 99% rename from python/pocketstation_examples/faster_whisper.py rename to python/pocketstation_demo/faster_whisper.py index 5aeac02..9023dcb 100644 --- a/python/pocketstation_examples/faster_whisper.py +++ b/python/pocketstation_demo/faster_whisper.py @@ -1,4 +1,4 @@ -"""Transcribe source-aware example audio with faster-whisper.""" +"""Transcribe source-aware demo audio with faster-whisper.""" from __future__ import annotations diff --git a/python/pocketstation_examples/openai_realtime.py b/python/pocketstation_demo/openai_realtime.py similarity index 63% rename from python/pocketstation_examples/openai_realtime.py rename to python/pocketstation_demo/openai_realtime.py index a58cdc2..5bab730 100644 --- a/python/pocketstation_examples/openai_realtime.py +++ b/python/pocketstation_demo/openai_realtime.py @@ -1,4 +1,4 @@ -"""Example-owned OpenAI Realtime adapter for the voice debugging demo.""" +"""Demo-owned OpenAI Realtime adapter for the voice debugging workflow.""" from __future__ import annotations @@ -9,11 +9,11 @@ import sys from array import array from collections import deque -from collections.abc import Mapping +from collections.abc import Coroutine, Mapping from dataclasses import dataclass, field from math import sqrt -from time import monotonic_ns -from typing import Any +from time import monotonic, monotonic_ns +from typing import Any, Literal from urllib.parse import urlencode import pocketstation.aio as pks @@ -24,15 +24,25 @@ EventInputFullError, ) from pocketstation.signal import BusSubscription, SignalEnvelope +from pocketstation.voice import ( + ConversationConfig, + ConversationMessage, + ConversationOutcome, + DuplexVoiceCapabilities, + DuplexVoiceConnection, + DuplexVoiceContext, + VoiceEvent, +) from websockets.asyncio.client import ClientConnection, connect _MODEL_SAMPLE_RATE_HZ = 24_000 _SESSION_SAMPLE_RATE_HZ = 48_000 -_SESSION_FRAME_SAMPLES = 480 +_MICROPHONE_FRAME_SAMPLES = 960 +_OUTPUT_FRAME_SAMPLES = 480 +_OUTPUT_FRAME_DURATION_S = _OUTPUT_FRAME_SAMPLES / _SESSION_SAMPLE_RATE_HZ _MAX_EVENT_BYTES = 262_144 _MAX_INPUT_QUEUE_FRAMES = 64 _MAX_OUTPUT_QUEUE_CHUNKS = 32 -_MAX_RETAINED_EVENTS = 65_536 _MAX_RETAINED_VOICED_FRAMES = 12_000 @@ -47,6 +57,8 @@ class RealtimeVoiceConfig: ) connect_timeout_s: float = 10.0 close_timeout_s: float = 5.0 + maximum_session_s: float = 300.0 + maximum_output_tokens: int = 512 input_queue_frames: int = _MAX_INPUT_QUEUE_FRAMES output_queue_chunks: int = _MAX_OUTPUT_QUEUE_CHUNKS @@ -58,9 +70,17 @@ def __post_init__(self) -> None: for name, value in ( ("connect_timeout_s", self.connect_timeout_s), ("close_timeout_s", self.close_timeout_s), + ("maximum_session_s", self.maximum_session_s), + ): + maximum = 3_600 if name == "maximum_session_s" else 60 + if isinstance(value, bool) or not 0 < value <= maximum: + raise ValueError(f"{name} must be greater than 0 and at most {maximum}") + if ( + isinstance(self.maximum_output_tokens, bool) + or not isinstance(self.maximum_output_tokens, int) + or not 1 <= self.maximum_output_tokens <= 4_096 ): - if isinstance(value, bool) or not 0 < value <= 60: - raise ValueError(f"{name} must be greater than 0 and at most 60") + raise ValueError("maximum_output_tokens must be between 1 and 4096") for name, value, maximum in ( ("input_queue_frames", self.input_queue_frames, 4_096), ("output_queue_chunks", self.output_queue_chunks, 1_024), @@ -82,6 +102,7 @@ class RealtimeVoiceObservations: output_frames_written: int output_generations_cancelled: int provider_errors: int + media_worker_errors: int event_input_drops: int @@ -132,20 +153,150 @@ def finish(self) -> tuple[array[float], ...]: if self._previous is not None: self._pending.extend((self._previous, self._previous)) self._previous = None - remainder = len(self._pending) % _SESSION_FRAME_SAMPLES + remainder = len(self._pending) % _OUTPUT_FRAME_SAMPLES if remainder: - self._pending.extend([0.0] * (_SESSION_FRAME_SAMPLES - remainder)) + self._pending.extend([0.0] * (_OUTPUT_FRAME_SAMPLES - remainder)) return self._take_frames() def _take_frames(self) -> tuple[array[float], ...]: frames: list[array[float]] = [] - while len(self._pending) >= _SESSION_FRAME_SAMPLES: - frames.append(array("f", self._pending[:_SESSION_FRAME_SAMPLES])) - del self._pending[:_SESSION_FRAME_SAMPLES] + while len(self._pending) >= _OUTPUT_FRAME_SAMPLES: + frames.append(array("f", self._pending[:_OUTPUT_FRAME_SAMPLES])) + del self._pending[:_OUTPUT_FRAME_SAMPLES] return tuple(frames) -class OpenAIRealtimeVoice: +class OpenAIRealtime: + """Create one OpenAI Realtime connection over a PocketStation Session. + + This demo adapter owns the provider WebSocket and PCM conversion. The + Session supplied by :class:`pocketstation.voice.Conversation` continues to + own capture, routing, recording, Relay, and generated-audio cancellation. + """ + + def __init__( + self, + *, + api_key: str, + config: RealtimeVoiceConfig | None = None, + route_labels: Mapping[int, str] | None = None, + ) -> None: + if not api_key.strip(): + raise ValueError("api_key must not be empty") + self._api_key = api_key + self._config = RealtimeVoiceConfig() if config is None else config + self._route_labels = {} if route_labels is None else dict(route_labels) + self._voice: _OpenAIRealtimeVoice | None = None + + @property + def capabilities(self) -> DuplexVoiceCapabilities: + return DuplexVoiceCapabilities( + transcript_revisions=False, + stable_prefix=False, + provider_speech_detection=True, + interruption=True, + interruption_triggers=("speech-started",), + response_cancellation=True, + provider_history_truncation=False, + receiver_playout_clear=False, + playout_acknowledgement=False, + tools=False, + usage_reporting=False, + input_formats=("pcm-s16le",), + output_formats=("pcm-s16le",), + supported_sample_rates_hz=(_MODEL_SAMPLE_RATE_HZ,), + maximum_session_duration_s=self._config.maximum_session_s, + ) + + def connect(self, context: DuplexVoiceContext) -> DuplexVoiceConnection: + if self._voice is not None: + raise RuntimeError("OpenAIRealtime can create only one connection") + if not isinstance(context.session, pks.Session): + raise TypeError("OpenAIRealtime requires a pocketstation.aio.Session") + if not isinstance(context.output, pks.AudioInput): + raise TypeError("OpenAIRealtime output must be an asyncio AudioInput") + send = getattr(context.input, "send", None) + if send is None or not callable(send): + raise TypeError("OpenAIRealtime input must be a Session audio stream") + microphone_route_id = int(send(context.session.polled_audio())) + route_labels = {**self._route_labels, microphone_route_id: "microphone"} + events = context.session.event_input( + "openai-realtime", + capacity_events=context.config.provider_event_queue_capacity, + maximum_event_bytes=context.config.provider_event_bytes, + ) + event_log = context.session.subscribe(events.output, signal=events.signal) + voice = _OpenAIRealtimeVoice( + api_key=self._api_key, + microphone_route_id=microphone_route_id, + output=context.output, + events=events, + route_labels=route_labels, + config=self._config, + conversation_config=context.config, + ) + self._voice = voice + return _OpenAIRealtimeConnection(voice, event_log) + + def print_report(self) -> None: + """Print the measured timeline after the connection closes.""" + if self._voice is None: + raise RuntimeError("the OpenAI Realtime connection has not started") + self._voice.print_report() + + @property + def observations(self) -> RealtimeVoiceObservations: + """Return measured provider and generated-audio boundary counters.""" + if self._voice is None: + raise RuntimeError("the OpenAI Realtime connection has not been declared") + return self._voice.observations + + @property + def route_labels(self) -> Mapping[int, str]: + """Return the finite observation routes declared for this connection.""" + if self._voice is None: + return dict(self._route_labels) + return self._voice.route_labels + + +class _OpenAIRealtimeConnection: + def __init__( + self, + voice: _OpenAIRealtimeVoice, + event_log: BusSubscription[bytes], + ) -> None: + self._voice = voice + self._event_log = event_log + self._started = False + + async def start(self, running: object) -> None: + if not isinstance(running, pks.RunningSession): + raise TypeError("running must be a pocketstation.aio.RunningSession") + await self._voice.connect() + await self._voice.start(running, self._event_log) + self._voice.enable_input() + self._started = True + + async def wait(self) -> ConversationOutcome: + if not self._started: + raise RuntimeError("start() must complete before wait()") + await self._voice.wait() + return self._voice.outcome("stopped") + + async def interrupt(self) -> None: + await self._voice.interrupt() + + async def cancel_output(self) -> None: + self._voice.cancel_output() + + def stop(self) -> None: + self._voice.request_stop() + + async def aclose(self) -> None: + await self._voice.aclose() + + +class _OpenAIRealtimeVoice: """Move one PocketStation microphone stem through OpenAI Realtime. PocketStation owns capture, media identity, bounded fan-out, recording, @@ -162,6 +313,7 @@ def __init__( events: EventInput, route_labels: Mapping[int, str], config: RealtimeVoiceConfig | None = None, + conversation_config: ConversationConfig | None = None, ) -> None: if not api_key.strip(): raise ValueError("api_key must not be empty") @@ -171,6 +323,9 @@ def __init__( self._events = events self._route_labels = dict(route_labels) self._config = RealtimeVoiceConfig() if config is None else config + self._conversation_config = ( + ConversationConfig() if conversation_config is None else conversation_config + ) self._input_queue: asyncio.Queue[str | None] = asyncio.Queue( self._config.input_queue_frames ) @@ -179,12 +334,15 @@ def __init__( ) self._input_enabled = asyncio.Event() self._ready = asyncio.Event() + self._stop_requested = asyncio.Event() self._socket: ClientConnection | None = None self._tasks: list[asyncio.Task[None]] = [] self._response: _ResponseOutput | None = None self._failure: BaseException | None = None self._timelines: dict[int, _RouteTimeline] = {} - self._event_records: deque[dict[str, Any]] = deque(maxlen=_MAX_RETAINED_EVENTS) + self._event_records: deque[dict[str, Any]] = deque( + maxlen=self._conversation_config.event_capacity + ) self._input_frames_sent = 0 self._input_frames_dropped = 0 self._output_chunks_received = 0 @@ -192,6 +350,7 @@ def __init__( self._output_frames_written = 0 self._output_generations_cancelled = 0 self._provider_errors = 0 + self._media_worker_errors = 0 self._event_input_drops = 0 self._started = False self._closed = False @@ -206,9 +365,14 @@ def observations(self) -> RealtimeVoiceObservations: output_frames_written=self._output_frames_written, output_generations_cancelled=self._output_generations_cancelled, provider_errors=self._provider_errors, + media_worker_errors=self._media_worker_errors, event_input_drops=self._event_input_drops, ) + @property + def route_labels(self) -> Mapping[int, str]: + return dict(self._route_labels) + async def connect(self) -> None: """Open and configure one finite provider connection.""" if self._socket is not None: @@ -226,15 +390,14 @@ async def connect(self) -> None: max_queue=16, write_limit=32_768, ) - self._tasks.append( - asyncio.create_task(self._receive(), name="pks-openai-receive") - ) + self._spawn(self._receive(), "pks-openai-receive") await self._send( { "type": "session.update", "session": { "type": "realtime", "instructions": self._config.instructions, + "max_output_tokens": self._config.maximum_output_tokens, "output_modalities": ["audio"], "audio": { "input": { @@ -246,7 +409,9 @@ async def connect(self) -> None: "turn_detection": { "type": "server_vad", "create_response": True, - "interrupt_response": True, + "interrupt_response": ( + self._conversation_config.interruption.enabled + ), }, }, "output": { @@ -279,16 +444,31 @@ async def start( if int(running.session_id) != event_log.session_id: raise ValueError("event_log and running must belong to one Session") self._started = True - self._tasks.extend( - ( - asyncio.create_task(self._read_audio(running), name="pks-openai-media"), - asyncio.create_task(self._send_audio(), name="pks-openai-input"), - asyncio.create_task(self._write_output(), name="pks-openai-output"), - asyncio.create_task( - self._read_events(running, event_log), name="pks-openai-events" - ), - ) + self._spawn(self._read_audio(running), "pks-openai-media") + self._spawn(self._send_audio(), "pks-openai-input") + self._spawn(self._write_output(), "pks-openai-output") + self._spawn(self._read_events(running, event_log), "pks-openai-events") + + def _spawn(self, worker: Coroutine[Any, Any, None], name: str) -> None: + task = asyncio.create_task(worker, name=name) + task.add_done_callback(self._worker_finished) + self._tasks.append(task) + + def _worker_finished(self, task: asyncio.Task[None]) -> None: + if task.cancelled(): + return + failure = task.exception() + if failure is None: + return + self._media_worker_errors += 1 + if self._failure is None: + self._failure = failure + self._event( + "pocketstation.media_worker.failed", + worker=task.get_name(), + detail=f"{type(failure).__name__}: {failure}"[:2_048], ) + self._stop_requested.set() def enable_input(self) -> None: """Begin forwarding microphone frames after the receiver is ready.""" @@ -301,10 +481,81 @@ async def wait(self) -> None: """Wait until the provider connection closes or fails.""" if not self._tasks: raise RuntimeError("connect() must complete before wait()") - await self._tasks[0] + stop_task = asyncio.create_task(self._stop_requested.wait()) + try: + done, _ = await asyncio.wait( + {self._tasks[0], stop_task}, + timeout=self._config.maximum_session_s, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + self._event( + "pocketstation.provider_session.limit_reached", + maximum_session_s=self._config.maximum_session_s, + ) + self.request_stop() + return + if self._tasks[0] in done: + await self._tasks[0] + finally: + stop_task.cancel() + await asyncio.gather(stop_task, return_exceptions=True) if self._failure is not None: raise RuntimeError("OpenAI Realtime connection failed") from self._failure + def request_stop(self) -> None: + """Request a normal stop without closing the PocketStation Session.""" + self._stop_requested.set() + + async def interrupt(self) -> None: + """Cancel active provider work and pending Core-owned output.""" + response = self._response + if response is not None and response.generation.active: + self._event( + "provider.response.cancel_requested", + response_id=response.response_id, + output_generation_id=response.generation.id, + ) + await self._send( + {"type": "response.cancel", "response_id": response.response_id} + ) + self._cancel_output() + + def cancel_output(self) -> None: + """Discard pending Core-owned output without claiming provider cancellation.""" + self._cancel_output() + + def outcome( + self, + disposition: Literal["completed", "stopped", "cancelled", "failed"], + ) -> ConversationOutcome: + """Return measured provider and media-boundary facts collected so far.""" + history = _conversation_history(self._event_records) + events = tuple(_voice_event(record) for record in self._event_records) + turns_started = sum(message.role == "user" for message in history) + turns_completed = sum(message.role == "assistant" for message in history) + return ConversationOutcome( + disposition=disposition, + turns_started=turns_started, + turns_completed=turns_completed, + turns_interrupted=self._output_generations_cancelled, + transcript_updates_received=sum( + event.kind.startswith("conversation.item.input_audio_transcription") + for event in events + ), + speculative_responses_started=0, + speculative_responses_reused=0, + output_generations_cancelled=self._output_generations_cancelled, + output_frames_written=self._output_frames_written, + history=history, + events=events, + failure=( + None + if self._failure is None + else f"{type(self._failure).__name__}: {self._failure}" + ), + ) + async def aclose(self) -> None: if self._closed: return @@ -480,7 +731,9 @@ def _handle_event(self, event: Mapping[str, Any]) -> None: item_id=_optional_string(event, "item_id"), audio_start_ms=event.get("audio_start_ms"), ) - self._cancel_output() + interruption = self._conversation_config.interruption + if interruption.enabled and interruption.trigger == "speech-started": + self._cancel_output() return if event_type in { "input_audio_buffer.speech_stopped", @@ -533,6 +786,7 @@ def _queue_output(self, chunk: _OutputChunk) -> None: async def _write_output(self) -> None: converters: dict[int, _Pcm24To48] = {} + next_frame_at_s: dict[int, float] = {} discontinuity = False while True: chunk = await self._output_queue.get() @@ -540,6 +794,8 @@ async def _write_output(self) -> None: if chunk is None: return if not chunk.generation.active: + converters.pop(chunk.generation.id, None) + next_frame_at_s.pop(chunk.generation.id, None) self._output_chunks_dropped += 1 discontinuity = True continue @@ -550,6 +806,19 @@ async def _write_output(self) -> None: else converter.append(chunk.pcm16le) ) for samples in frames: + frame_at_s = max( + next_frame_at_s.get(chunk.generation.id, monotonic()), + monotonic(), + ) + delay_s = frame_at_s - monotonic() + if delay_s > 0: + await asyncio.sleep(delay_s) + if not chunk.generation.active: + converters.pop(chunk.generation.id, None) + next_frame_at_s.pop(chunk.generation.id, None) + self._output_chunks_dropped += 1 + discontinuity = True + break try: await self._output.write( samples, @@ -568,8 +837,12 @@ async def _write_output(self) -> None: else: discontinuity = False self._output_frames_written += 1 + next_frame_at_s[chunk.generation.id] = ( + monotonic() + _OUTPUT_FRAME_DURATION_S + ) if chunk.done: converters.pop(chunk.generation.id, None) + next_frame_at_s.pop(chunk.generation.id, None) finally: self._output_queue.task_done() @@ -611,6 +884,12 @@ def _cancel_output(self) -> None: response = self._response if response is None or not response.generation.active: return + self._event( + "pocketstation.output.cancel_requested", + response_id=response.response_id, + item_id=response.item_id, + output_generation_id=response.generation.id, + ) response.generation.cancel() self._output_generations_cancelled += 1 self._event( @@ -620,6 +899,27 @@ def _cancel_output(self) -> None: output_generation_id=response.generation.id, browser_playout_position="unavailable", ) + self._event( + "pocketstation.connector.output_observation", + response_id=response.response_id, + output_generation_id=response.generation.id, + available=False, + detail="connector acknowledgement unavailable", + ) + self._event( + "pocketstation.receiver.playout_observation", + response_id=response.response_id, + output_generation_id=response.generation.id, + available=False, + detail="receiver playout position unavailable", + ) + self._event( + "pocketstation.acoustic.hearing_observation", + response_id=response.response_id, + output_generation_id=response.generation.id, + available=False, + detail="acoustic hearing cannot be inferred from sender state", + ) def _record_text_event(self, event_type: str, event: Mapping[str, Any]) -> None: text = event.get("delta") @@ -649,17 +949,12 @@ def _event(self, event_type: str, **values: object) -> None: self._event_input_drops += 1 -def silent_frame() -> array[float]: - """Return one exact 10 ms Session frame for Relay attachment.""" - return array("f", [0.0] * _SESSION_FRAME_SAMPLES) - - def _encode_microphone_frame(frame: Any) -> str: if frame.sample_rate_hz != _SESSION_SAMPLE_RATE_HZ or frame.channel_count != 1: raise ValueError("the OpenAI example requires 48 kHz mono Session audio") samples = _f32le(frame.samples_f32le) - if len(samples) != _SESSION_FRAME_SAMPLES: - raise ValueError("the OpenAI example requires exact 10 ms Session frames") + if len(samples) != _MICROPHONE_FRAME_SAMPLES: + raise ValueError("the OpenAI example requires exact 20 ms Session frames") pcm = array( "h", ( @@ -712,6 +1007,48 @@ def _observe_frame(timeline: _RouteTimeline, frame: Any) -> None: timeline.voiced_frames.append((frame.route_received_at_ns, frame.duration_ns)) +def _voice_event(record: Mapping[str, Any]) -> VoiceEvent: + kind = str(record.get("type", "provider.unknown"))[:128] + timestamp_ns = int(record.get("pocketstation_timestamp_ns", 0)) + output_generation = record.get("output_generation_id") + available = record.get("available", True) + return VoiceEvent( + kind=kind, + timestamp_ns=timestamp_ns, + stage=("pocketstation" if kind.startswith("pocketstation.") else "provider"), + provider_id="openai-realtime", + response_id=_optional_mapping_string(record, "response_id"), + output_generation_id=( + int(output_generation) if isinstance(output_generation, int) else None + ), + available=available if isinstance(available, bool) else True, + detail=_optional_mapping_string(record, "detail"), + ) + + +def _conversation_history( + events: deque[dict[str, Any]], +) -> tuple[ConversationMessage, ...]: + history: list[ConversationMessage] = [] + turn_id = 0 + for event in events: + event_type = event.get("type") + text = event.get("text") + timestamp_ns = event.get("pocketstation_timestamp_ns") + if not isinstance(text, str) or not text.strip(): + continue + if not isinstance(timestamp_ns, int): + continue + if event_type == "conversation.item.input_audio_transcription.completed": + turn_id += 1 + history.append(ConversationMessage("user", text, turn_id, timestamp_ns)) + elif event_type == "response.output_audio_transcript.done" and turn_id > 0: + history.append( + ConversationMessage("assistant", text, turn_id, timestamp_ns) + ) + return tuple(history) + + def _decode_event(envelope: SignalEnvelope[bytes]) -> dict[str, Any]: decoded = json.loads(envelope.payload.decode("utf-8")) if not isinstance(decoded, dict): @@ -731,6 +1068,14 @@ def _optional_string(event: Mapping[str, Any], name: str) -> str | None: return value[:128] if isinstance(value, str) else None +def _optional_mapping_string( + event: Mapping[str, Any], + name: str, +) -> str | None: + value = event.get(name) + return value[:2_048] if isinstance(value, str) else None + + def _nested_string(event: Mapping[str, Any], parent: str, name: str) -> str: nested = event.get(parent) if not isinstance(nested, dict): @@ -762,8 +1107,7 @@ def _relative(value: int | None, origin: int) -> str: __all__ = [ - "OpenAIRealtimeVoice", + "OpenAIRealtime", "RealtimeVoiceConfig", "RealtimeVoiceObservations", - "silent_frame", ] diff --git a/python/pocketstation_examples/relay.py b/python/pocketstation_demo/relay.py similarity index 92% rename from python/pocketstation_examples/relay.py rename to python/pocketstation_demo/relay.py index 74564c8..2361fcd 100644 --- a/python/pocketstation_examples/relay.py +++ b/python/pocketstation_demo/relay.py @@ -1,4 +1,4 @@ -"""Connect an example to the small shared PocketStation Relay service.""" +"""Connect a demo to the small shared PocketStation Relay service.""" import os from collections.abc import Sequence diff --git a/python/pocketstation_examples/transcript.py b/python/pocketstation_demo/transcript.py similarity index 93% rename from python/pocketstation_examples/transcript.py rename to python/pocketstation_demo/transcript.py index 6f24f01..5766c96 100644 --- a/python/pocketstation_examples/transcript.py +++ b/python/pocketstation_demo/transcript.py @@ -1,4 +1,4 @@ -"""Typed transcript values emitted by the example transcription provider.""" +"""Typed transcript values emitted by demo transcription adapters.""" from __future__ import annotations diff --git a/tests/run_installed_transcription_cancellation.py b/tests/run_installed_transcription_cancellation.py index 4e391b8..6810543 100644 --- a/tests/run_installed_transcription_cancellation.py +++ b/tests/run_installed_transcription_cancellation.py @@ -22,11 +22,11 @@ SDK_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SDK_ROOT)) -from pocketstation_examples import ( # noqa: E402 +from pocketstation_demo import ( # noqa: E402 FasterWhisper, FasterWhisperConfiguration, ) -from pocketstation_examples.faster_whisper import ( # noqa: E402 +from pocketstation_demo.faster_whisper import ( # noqa: E402 WhisperInfo, WhisperSegment, ) diff --git a/tests/run_relay_e2e_publisher.py b/tests/run_relay_e2e_publisher.py index 375cc87..e031e32 100644 --- a/tests/run_relay_e2e_publisher.py +++ b/tests/run_relay_e2e_publisher.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, cast import pocketstation._api as pks +import pocketstation._native as native if TYPE_CHECKING: from tests.transcription.wav_input import WavInput @@ -115,7 +116,7 @@ def _collect_transcripts( def main() -> int: - from pocketstation_examples import ( + from pocketstation_demo import ( TRANSCRIPT_SIGNAL, FasterWhisper, FasterWhisperConfiguration, @@ -159,7 +160,7 @@ def main() -> int: arguments.application_name is None and arguments.application_process_id is None and not use_application_audio_inputs - and not hasattr(pks._native.Session, "conformance") + and not hasattr(native.Session, "conformance") ): emit("failure", code="relay.conformance_fixture_unavailable") return 2 @@ -243,7 +244,7 @@ def main() -> int: and arguments.application_process_id is None ): session = pks.Session._from_native( - pks._native.Session.conformance(arguments.recording_root) + native.Session.conformance(arguments.recording_root) ) application_source = pks.Source.application("PocketStation Python Fixture") source_mode = "conformance-fixture" diff --git a/tests/test_batch_transcription.py b/tests/test_batch_transcription.py index 5b57a1d..3c86417 100644 --- a/tests/test_batch_transcription.py +++ b/tests/test_batch_transcription.py @@ -11,7 +11,7 @@ import pocketstation._api as pocketstation import pocketstation.aio as pks_aio import pytest -from pocketstation_examples import FasterWhisper, FasterWhisperConfiguration +from pocketstation_demo import FasterWhisper, FasterWhisperConfiguration from tests.transcription.run_source_aware import transcribe_sources from tests.transcription.wav_input import read_pcm16_wav diff --git a/tests/test_transcription_example.py b/tests/test_transcription_example.py index 5053812..532f171 100644 --- a/tests/test_transcription_example.py +++ b/tests/test_transcription_example.py @@ -7,7 +7,7 @@ import pocketstation.aio as pks_aio import pytest -from pocketstation_examples import ( +from pocketstation_demo import ( FasterWhisper, FasterWhisperConfiguration, ) diff --git a/tests/transcription/run_source_aware.py b/tests/transcription/run_source_aware.py index eec29ad..0bbdddf 100644 --- a/tests/transcription/run_source_aware.py +++ b/tests/transcription/run_source_aware.py @@ -11,7 +11,7 @@ import pocketstation._api as pocketstation import pocketstation.aio as pks_aio -from pocketstation_examples import ( +from pocketstation_demo import ( FasterWhisper, FasterWhisperConfiguration, ) From a53e10301369bbff33dffb0353d04979bdd0bc62 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 28 Aug 2026 08:51:21 -0700 Subject: [PATCH 27/49] Align Python with Core 1.1.3 --- README.md | 12 ++++++------ native/Cargo.lock | 8 ++------ native/Cargo.toml | 6 +++--- python/pocketstation/compatibility.py | 4 ++-- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fe8f54f..e09b768 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Relay media transport. > **Status: preview.** The package is not published to PyPI. The macOS wheel has > been tested with the workflow below. Linux and Windows wheels, plus WAN and -> TURN testing, are still in progress. The source distribution builds against -> PocketStation Core `1.1.2` and Relay `0.1.1`. +> TURN testing, are still in progress. The current candidate requires +> PocketStation Core `1.1.3` and Relay Connector `0.1.2`. ## Capture a desktop application @@ -201,12 +201,12 @@ does not have the same execution cost as Rust. | Linux wheel | Not yet tested externally | | Windows wheel | Not yet tested externally | | Receiver over WAN or TURN | Not yet tested externally | -| Standalone source distribution | Builds from the published Core 1.1.2 dependency | +| Standalone source distribution | Pending the Relay Connector 0.1.2 registry release | | PyPI release | Not published | -The native binding pins published Core `1.1.2` and the shared Relay connector -`0.1.1`. Wheel and source-distribution builds resolve those immutable registry -artifacts without a sibling repository checkout. +The native binding pins Core `1.1.3` and the shared Relay Connector `0.1.2`. +Core `1.1.3` is published. Relay Connector `0.1.2` must pass its release gate +and reach crates.io before the Python source distribution can be released. The Rust-to-Python audio read currently copies native samples into Python-owned bytes before exposing a `memoryview`. The view avoids another Python-side copy; diff --git a/native/Cargo.lock b/native/Cargo.lock index 5d4f615..b496075 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1413,9 +1413,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f53262a88999cacefba43c5d8cfbf6ff257a86acf2f1827bb05c237664d1febf" +version = "1.1.3" dependencies = [ "alsa", "cc", @@ -1447,9 +1445,7 @@ dependencies = [ [[package]] name = "pocketstation-relay" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9855d9850057ec6020001c4bd9487c42c08d99a53eab47a1ca69c54dcffdb6f0" +version = "0.1.2" dependencies = [ "base64", "pocketstation", diff --git a/native/Cargo.toml b/native/Cargo.toml index 6479f31..32438b6 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -16,10 +16,10 @@ default = [] conformance-fixtures = ["pocketstation/conformance-fixtures"] [dependencies] -pocketstation = "=1.1.2" -pocketstation-relay = "=0.1.1" +pocketstation = "=1.1.3" +pocketstation-relay = "=0.1.2" pyo3 = { version = "0.27", features = ["abi3-py311"] } [dev-dependencies] -pocketstation = { version = "=1.1.2", features = ["conformance-fixtures"] } +pocketstation = { version = "=1.1.3", features = ["conformance-fixtures"] } tempfile = "3" diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py index 22344f0..3de0a17 100644 --- a/python/pocketstation/compatibility.py +++ b/python/pocketstation/compatibility.py @@ -19,8 +19,8 @@ class RuntimeCompatibility: RUNTIME_COMPATIBILITY = RuntimeCompatibility( sdk_version="0.1.0", - core_version="1.1.2", - relay_connector_version="0.1.1", + core_version="1.1.3", + relay_connector_version="0.1.2", python_requires=">=3.11", python_abi="abi3-py311", free_threaded_cpython=False, From 74e9687a14bc05b1eefd9d58f79be65b7f842915 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Sun, 30 Aug 2026 21:54:44 -0400 Subject: [PATCH 28/49] Drain voice media during provider startup --- python/pocketstation_demo/openai_realtime.py | 45 ++++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/python/pocketstation_demo/openai_realtime.py b/python/pocketstation_demo/openai_realtime.py index 5bab730..31eeaca 100644 --- a/python/pocketstation_demo/openai_realtime.py +++ b/python/pocketstation_demo/openai_realtime.py @@ -95,6 +95,7 @@ def __post_init__(self) -> None: class RealtimeVoiceObservations: """Finite provider and PocketStation boundary counters.""" + input_ready: bool input_frames_sent: int input_frames_dropped: int output_chunks_received: int @@ -272,8 +273,9 @@ def __init__( async def start(self, running: object) -> None: if not isinstance(running, pks.RunningSession): raise TypeError("running must be a pocketstation.aio.RunningSession") + await self._voice.start_observers(running, self._event_log) await self._voice.connect() - await self._voice.start(running, self._event_log) + self._voice.start_provider_io() self._voice.enable_input() self._started = True @@ -337,6 +339,7 @@ def __init__( self._stop_requested = asyncio.Event() self._socket: ClientConnection | None = None self._tasks: list[asyncio.Task[None]] = [] + self._receive_task: asyncio.Task[None] | None = None self._response: _ResponseOutput | None = None self._failure: BaseException | None = None self._timelines: dict[int, _RouteTimeline] = {} @@ -353,11 +356,13 @@ def __init__( self._media_worker_errors = 0 self._event_input_drops = 0 self._started = False + self._provider_io_started = False self._closed = False @property def observations(self) -> RealtimeVoiceObservations: return RealtimeVoiceObservations( + input_ready=self._input_enabled.is_set(), input_frames_sent=self._input_frames_sent, input_frames_dropped=self._input_frames_dropped, output_chunks_received=self._output_chunks_received, @@ -390,7 +395,7 @@ async def connect(self) -> None: max_queue=16, write_limit=32_768, ) - self._spawn(self._receive(), "pks-openai-receive") + self._receive_task = self._spawn(self._receive(), "pks-openai-receive") await self._send( { "type": "session.update", @@ -431,28 +436,41 @@ async def connect(self) -> None: if self._failure is not None: raise RuntimeError("OpenAI Realtime setup failed") from self._failure - async def start( + async def start_observers( self, running: pks.RunningSession, event_log: BusSubscription[bytes], ) -> None: - """Start bounded media and event workers for one running Session.""" - if self._socket is None: - raise RuntimeError("connect() must complete before start()") + """Drain Session media while the provider connection is starting.""" if self._started: - raise RuntimeError("OpenAI Realtime media workers already started") + raise RuntimeError("OpenAI Realtime observers already started") if int(running.session_id) != event_log.session_id: raise ValueError("event_log and running must belong to one Session") self._started = True self._spawn(self._read_audio(running), "pks-openai-media") + self._spawn(self._read_events(running, event_log), "pks-openai-events") + + def start_provider_io(self) -> None: + """Start provider input and output workers after authentication.""" + if not self._started: + raise RuntimeError("start_observers() must complete before provider I/O") + if self._socket is None: + raise RuntimeError("connect() must complete before provider I/O") + if self._provider_io_started: + raise RuntimeError("OpenAI Realtime provider workers already started") + self._provider_io_started = True self._spawn(self._send_audio(), "pks-openai-input") self._spawn(self._write_output(), "pks-openai-output") - self._spawn(self._read_events(running, event_log), "pks-openai-events") - def _spawn(self, worker: Coroutine[Any, Any, None], name: str) -> None: + def _spawn( + self, + worker: Coroutine[Any, Any, None], + name: str, + ) -> asyncio.Task[None]: task = asyncio.create_task(worker, name=name) task.add_done_callback(self._worker_finished) self._tasks.append(task) + return task def _worker_finished(self, task: asyncio.Task[None]) -> None: if task.cancelled(): @@ -479,12 +497,13 @@ def enable_input(self) -> None: async def wait(self) -> None: """Wait until the provider connection closes or fails.""" - if not self._tasks: + receive_task = self._receive_task + if receive_task is None: raise RuntimeError("connect() must complete before wait()") stop_task = asyncio.create_task(self._stop_requested.wait()) try: done, _ = await asyncio.wait( - {self._tasks[0], stop_task}, + {receive_task, stop_task}, timeout=self._config.maximum_session_s, return_when=asyncio.FIRST_COMPLETED, ) @@ -495,8 +514,8 @@ async def wait(self) -> None: ) self.request_stop() return - if self._tasks[0] in done: - await self._tasks[0] + if receive_task in done: + await receive_task finally: stop_task.cancel() await asyncio.gather(stop_task, return_exceptions=True) From 752da1c86f33f04bb92b1b832bf9457db4dc68e1 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Sun, 30 Aug 2026 22:14:55 -0400 Subject: [PATCH 29/49] Accept recording manifest schema 2 --- tests/test_recording.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_recording.py b/tests/test_recording.py index 2877d56..14962b1 100644 --- a/tests/test_recording.py +++ b/tests/test_recording.py @@ -49,7 +49,7 @@ def test_application_and_microphone_record_as_independent_stems(tmp_path) -> Non stop.recording.session_directory / "manifest.json" ) assert stop.recording.manifest_path.is_file() - assert stop.recording.manifest_schema_version == 1 + assert stop.recording.manifest_schema_version == 2 outcomes = {stem.stem_name: stem for stem in stop.recording.stems} assert set(outcomes) == {"application", "microphone"} assert all(stem.frames_written_total > 0 for stem in outcomes.values()) From a6c9fb68f3959d28fc44967090869061ab91ffc3 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Sun, 30 Aug 2026 22:33:04 -0400 Subject: [PATCH 30/49] Retain Realtime output by bounded audio duration --- python/pocketstation_demo/openai_realtime.py | 204 ++++++++++++------- 1 file changed, 131 insertions(+), 73 deletions(-) diff --git a/python/pocketstation_demo/openai_realtime.py b/python/pocketstation_demo/openai_realtime.py index 31eeaca..2fec161 100644 --- a/python/pocketstation_demo/openai_realtime.py +++ b/python/pocketstation_demo/openai_realtime.py @@ -42,7 +42,8 @@ _OUTPUT_FRAME_DURATION_S = _OUTPUT_FRAME_SAMPLES / _SESSION_SAMPLE_RATE_HZ _MAX_EVENT_BYTES = 262_144 _MAX_INPUT_QUEUE_FRAMES = 64 -_MAX_OUTPUT_QUEUE_CHUNKS = 32 +_MAX_OUTPUT_QUEUE_CHUNKS = 1_024 +_MAX_BUFFERED_OUTPUT_S = 30.0 _MAX_RETAINED_VOICED_FRAMES = 12_000 @@ -53,7 +54,7 @@ class RealtimeVoiceConfig: model: str = "gpt-realtime-2.1" voice: str = "marin" instructions: str = ( - "Answer clearly and in enough detail that the user can interrupt you." + "Answer clearly in under 20 seconds so the user can interrupt you." ) connect_timeout_s: float = 10.0 close_timeout_s: float = 5.0 @@ -61,18 +62,19 @@ class RealtimeVoiceConfig: maximum_output_tokens: int = 512 input_queue_frames: int = _MAX_INPUT_QUEUE_FRAMES output_queue_chunks: int = _MAX_OUTPUT_QUEUE_CHUNKS + maximum_buffered_output_s: float = _MAX_BUFFERED_OUTPUT_S def __post_init__(self) -> None: if not self.model.strip() or not self.voice.strip(): raise ValueError("model and voice must not be empty") if not self.instructions.strip(): raise ValueError("instructions must not be empty") - for name, value in ( - ("connect_timeout_s", self.connect_timeout_s), - ("close_timeout_s", self.close_timeout_s), - ("maximum_session_s", self.maximum_session_s), + for name, value, maximum in ( + ("connect_timeout_s", self.connect_timeout_s, 60), + ("close_timeout_s", self.close_timeout_s, 60), + ("maximum_session_s", self.maximum_session_s, 3_600), + ("maximum_buffered_output_s", self.maximum_buffered_output_s, 300), ): - maximum = 3_600 if name == "maximum_session_s" else 60 if isinstance(value, bool) or not 0 < value <= maximum: raise ValueError(f"{name} must be greater than 0 and at most {maximum}") if ( @@ -100,7 +102,10 @@ class RealtimeVoiceObservations: input_frames_dropped: int output_chunks_received: int output_chunks_dropped: int + output_chunks_cancelled: int + output_chunks_rejected: int output_frames_written: int + output_frames_rejected: int output_generations_cancelled: int provider_errors: int media_worker_errors: int @@ -128,6 +133,57 @@ class _OutputChunk: done: bool = False +class _OutputBuffer: + """Retain a finite amount of provider audio without blocking event intake.""" + + def __init__(self, *, maximum_chunks: int, maximum_bytes: int) -> None: + self._maximum_chunks = maximum_chunks + self._maximum_bytes = maximum_bytes + self._chunks: deque[_OutputChunk] = deque() + self._buffered_bytes = 0 + self._ready = asyncio.Event() + + def try_push(self, chunk: _OutputChunk) -> bool: + chunk_bytes = len(chunk.pcm16le) + if ( + len(self._chunks) >= self._maximum_chunks + or self._buffered_bytes + chunk_bytes > self._maximum_bytes + ): + return False + self._chunks.append(chunk) + self._buffered_bytes += chunk_bytes + self._ready.set() + return True + + async def pop(self) -> _OutputChunk: + while not self._chunks: + self._ready.clear() + if self._chunks: + break + await self._ready.wait() + chunk = self._chunks.popleft() + self._buffered_bytes -= len(chunk.pcm16le) + if not self._chunks: + self._ready.clear() + return chunk + + def discard(self, generation_id: int) -> int: + retained: deque[_OutputChunk] = deque() + discarded = 0 + buffered_bytes = 0 + for chunk in self._chunks: + if chunk.generation.id == generation_id: + discarded += int(bool(chunk.pcm16le)) + continue + retained.append(chunk) + buffered_bytes += len(chunk.pcm16le) + self._chunks = retained + self._buffered_bytes = buffered_bytes + if not self._chunks: + self._ready.clear() + return discarded + + @dataclass(slots=True) class _ResponseOutput: response_id: str @@ -331,8 +387,11 @@ def __init__( self._input_queue: asyncio.Queue[str | None] = asyncio.Queue( self._config.input_queue_frames ) - self._output_queue: asyncio.Queue[_OutputChunk | None] = asyncio.Queue( - self._config.output_queue_chunks + self._output_buffer = _OutputBuffer( + maximum_chunks=self._config.output_queue_chunks, + maximum_bytes=int( + self._config.maximum_buffered_output_s * _MODEL_SAMPLE_RATE_HZ * 2 + ), ) self._input_enabled = asyncio.Event() self._ready = asyncio.Event() @@ -350,7 +409,10 @@ def __init__( self._input_frames_dropped = 0 self._output_chunks_received = 0 self._output_chunks_dropped = 0 + self._output_chunks_cancelled = 0 + self._output_chunks_rejected = 0 self._output_frames_written = 0 + self._output_frames_rejected = 0 self._output_generations_cancelled = 0 self._provider_errors = 0 self._media_worker_errors = 0 @@ -367,7 +429,10 @@ def observations(self) -> RealtimeVoiceObservations: input_frames_dropped=self._input_frames_dropped, output_chunks_received=self._output_chunks_received, output_chunks_dropped=self._output_chunks_dropped, + output_chunks_cancelled=self._output_chunks_cancelled, + output_chunks_rejected=self._output_chunks_rejected, output_frames_written=self._output_frames_written, + output_frames_rejected=self._output_frames_rejected, output_generations_cancelled=self._output_generations_cancelled, provider_errors=self._provider_errors, media_worker_errors=self._media_worker_errors, @@ -788,82 +853,72 @@ def _queue_output_delta(self, event: Mapping[str, Any]) -> None: def _queue_output(self, chunk: _OutputChunk) -> None: if not chunk.generation.active: - self._output_chunks_dropped += 1 + if chunk.pcm16le: + self._output_chunks_dropped += 1 + self._output_chunks_cancelled += 1 return - try: - self._output_queue.put_nowait(chunk) - except asyncio.QueueFull: - self._output_chunks_dropped += 1 - if chunk.generation.active: - chunk.generation.cancel() - self._output_generations_cancelled += 1 - self._event( - "pocketstation.provider_output.full", - response_id=chunk.response_id, - output_generation_id=chunk.generation.id, - ) + if self._output_buffer.try_push(chunk): + return + self._output_chunks_dropped += 1 + self._output_chunks_rejected += 1 + raise RuntimeError( + "OpenAI Realtime output exceeded the configured buffered duration" + ) async def _write_output(self) -> None: converters: dict[int, _Pcm24To48] = {} next_frame_at_s: dict[int, float] = {} discontinuity = False while True: - chunk = await self._output_queue.get() - try: - if chunk is None: - return + chunk = await self._output_buffer.pop() + if not chunk.generation.active: + converters.pop(chunk.generation.id, None) + next_frame_at_s.pop(chunk.generation.id, None) + if chunk.pcm16le: + self._output_chunks_dropped += 1 + self._output_chunks_cancelled += 1 + discontinuity = True + continue + converter = converters.setdefault(chunk.generation.id, _Pcm24To48()) + frames = ( + converter.finish() if chunk.done else converter.append(chunk.pcm16le) + ) + for samples in frames: + frame_at_s = max( + next_frame_at_s.get(chunk.generation.id, monotonic()), + monotonic(), + ) + delay_s = frame_at_s - monotonic() + if delay_s > 0: + await asyncio.sleep(delay_s) if not chunk.generation.active: converters.pop(chunk.generation.id, None) next_frame_at_s.pop(chunk.generation.id, None) - self._output_chunks_dropped += 1 - discontinuity = True - continue - converter = converters.setdefault(chunk.generation.id, _Pcm24To48()) - frames = ( - converter.finish() - if chunk.done - else converter.append(chunk.pcm16le) - ) - for samples in frames: - frame_at_s = max( - next_frame_at_s.get(chunk.generation.id, monotonic()), - monotonic(), - ) - delay_s = frame_at_s - monotonic() - if delay_s > 0: - await asyncio.sleep(delay_s) - if not chunk.generation.active: - converters.pop(chunk.generation.id, None) - next_frame_at_s.pop(chunk.generation.id, None) - self._output_chunks_dropped += 1 - discontinuity = True - break - try: - await self._output.write( - samples, - discontinuity=discontinuity, - generation=chunk.generation, - timeout_s=1.0, - ) - except AudioInputFullError: + if chunk.pcm16le: self._output_chunks_dropped += 1 - discontinuity = True - self._event( - "pocketstation.output.full", - response_id=chunk.response_id, - output_generation_id=chunk.generation.id, - ) - else: - discontinuity = False - self._output_frames_written += 1 - next_frame_at_s[chunk.generation.id] = ( - monotonic() + _OUTPUT_FRAME_DURATION_S + self._output_chunks_cancelled += 1 + discontinuity = True + break + try: + await self._output.write( + samples, + discontinuity=discontinuity, + generation=chunk.generation, + timeout_s=1.0, ) - if chunk.done: - converters.pop(chunk.generation.id, None) - next_frame_at_s.pop(chunk.generation.id, None) - finally: - self._output_queue.task_done() + except AudioInputFullError: + self._output_frames_rejected += 1 + raise RuntimeError( + "generated-audio input remained full for one second" + ) from None + discontinuity = False + self._output_frames_written += 1 + next_frame_at_s[chunk.generation.id] = ( + monotonic() + _OUTPUT_FRAME_DURATION_S + ) + if chunk.done: + converters.pop(chunk.generation.id, None) + next_frame_at_s.pop(chunk.generation.id, None) async def _read_events( self, @@ -911,6 +966,9 @@ def _cancel_output(self) -> None: ) response.generation.cancel() self._output_generations_cancelled += 1 + discarded = self._output_buffer.discard(response.generation.id) + self._output_chunks_cancelled += discarded + self._output_chunks_dropped += discarded self._event( "pocketstation.output.cancelled", response_id=response.response_id, From 3c788190d56027635a882f5f156ef346f0303629 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 00:40:17 -0400 Subject: [PATCH 31/49] Expose selectable capture frame duration --- native/src/session.rs | 20 ++++++++++++++++---- python/pocketstation/_native.pyi | 1 + python/pocketstation/aio/session.py | 18 ++++++++++++------ python/pocketstation/errors.py | 1 + python/pocketstation/session.py | 18 ++++++++++++------ tests/test_session.py | 13 +++++++++++++ 6 files changed, 55 insertions(+), 16 deletions(-) diff --git a/native/src/session.rs b/native/src/session.rs index 398d089..a8014ca 100644 --- a/native/src/session.rs +++ b/native/src/session.rs @@ -148,13 +148,14 @@ impl PythonSessionStartCancellation { #[pymethods] impl PythonSession { #[new] - #[pyo3(signature = (*, recording_root=None, trace_path=None, trace_capacity_records=256, sample_rate_hz=48_000, channels=1))] + #[pyo3(signature = (*, recording_root=None, trace_path=None, trace_capacity_records=256, sample_rate_hz=48_000, channels=1, frame_duration_ms=20))] fn new( recording_root: Option, trace_path: Option, trace_capacity_records: usize, sample_rate_hz: u32, channels: u8, + frame_duration_ms: u16, ) -> PyResult { if trace_path.is_some() && trace_capacity_records == 0 { return Err(PyValueError::new_err(coded_reason( @@ -168,12 +169,23 @@ impl PythonSession { "sample_rate_hz must be non-zero and channels must be 1 or 2", ))); } - let mut builder = - pocketstation::Session::builder().sample_spec(pocketstation::SampleSpec::new( + let audio_frame_duration = match frame_duration_ms { + 10 => pocketstation::AudioFrameDuration::Ms10, + 20 => pocketstation::AudioFrameDuration::Ms20, + _ => { + return Err(PyValueError::new_err(coded_reason( + "session.invalid_frame_duration", + "frame_duration_ms must be 10 or 20", + ))) + } + }; + let mut builder = pocketstation::Session::builder() + .sample_spec(pocketstation::SampleSpec::new( sample_rate_hz, channels, pocketstation::SampleFormat::F32Interleaved, - )); + )) + .audio_frame_duration(audio_frame_duration); if let Some(root) = recording_root { builder = builder.recording_root(root); } diff --git a/python/pocketstation/_native.pyi b/python/pocketstation/_native.pyi index 1a7ddc3..44163bb 100644 --- a/python/pocketstation/_native.pyi +++ b/python/pocketstation/_native.pyi @@ -1215,6 +1215,7 @@ class Session: trace_capacity_records: int = 256, sample_rate_hz: int = 48_000, channels: int = 1, + frame_duration_ms: int = 20, ) -> None: ... @staticmethod def conformance( diff --git a/python/pocketstation/aio/session.py b/python/pocketstation/aio/session.py index aeb6be0..1445a8e 100644 --- a/python/pocketstation/aio/session.py +++ b/python/pocketstation/aio/session.py @@ -300,17 +300,22 @@ def __init__( trace: SessionTraceConfiguration | None = None, sample_rate_hz: int = 48_000, channels: int = 1, + frame_duration_ms: int = 20, ) -> None: root = None if recording_root is None else Path(recording_root) - self._native = _NativeSession( - recording_root=root, - trace_path=None if trace is None else trace.path, - trace_capacity_records=256 if trace is None else trace.capacity_records, - sample_rate_hz=sample_rate_hz, - channels=channels, + self._native = _native_call( + lambda: _NativeSession( + recording_root=root, + trace_path=None if trace is None else trace.path, + trace_capacity_records=256 if trace is None else trace.capacity_records, + sample_rate_hz=sample_rate_hz, + channels=channels, + frame_duration_ms=frame_duration_ms, + ) ) self._sample_rate_hz = sample_rate_hz self._channels = channels + self._frame_duration_ms = frame_duration_ms self._connector_registrations: dict[ int, tuple[ @@ -335,6 +340,7 @@ def _from_native(cls, native: _NativeSession) -> Session: session._native = native session._sample_rate_hz = 48_000 session._channels = 1 + session._frame_duration_ms = 20 session._connector_registrations = {} session._endpoint_registrations = {} return session diff --git a/python/pocketstation/errors.py b/python/pocketstation/errors.py index 136f371..ed93b58 100644 --- a/python/pocketstation/errors.py +++ b/python/pocketstation/errors.py @@ -243,6 +243,7 @@ def _normalize_native_error(error: Exception) -> PocketStationError: "session.no_routes", "session.no_source_outputs", "session.invalid_selector", + "session.invalid_frame_duration", "session.invalid_endpoint", "session.invalid_operator", "session.invalid_route", diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index b73e8a7..99eba5e 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -263,17 +263,22 @@ def __init__( trace: SessionTraceConfiguration | None = None, sample_rate_hz: int = 48_000, channels: int = 1, + frame_duration_ms: int = 20, ) -> None: root = None if recording_root is None else Path(recording_root) - self._native = _NativeSession( - recording_root=root, - trace_path=None if trace is None else trace.path, - trace_capacity_records=256 if trace is None else trace.capacity_records, - sample_rate_hz=sample_rate_hz, - channels=channels, + self._native = _native_call( + lambda: _NativeSession( + recording_root=root, + trace_path=None if trace is None else trace.path, + trace_capacity_records=256 if trace is None else trace.capacity_records, + sample_rate_hz=sample_rate_hz, + channels=channels, + frame_duration_ms=frame_duration_ms, + ) ) self._sample_rate_hz = sample_rate_hz self._channels = channels + self._frame_duration_ms = frame_duration_ms self._connector_registrations: dict[ int, tuple[Connector, _NativeRegisteredConnector] ] = {} @@ -288,6 +293,7 @@ def _from_native(cls, native: _NativeSession) -> Session: session._native = native session._sample_rate_hz = 48_000 session._channels = 1 + session._frame_duration_ms = 20 session._connector_registrations = {} session._endpoint_registrations = {} return session diff --git a/tests/test_session.py b/tests/test_session.py index 165d252..5a11e0f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -28,6 +28,19 @@ def test_given_app_and_mic_when_routed_then_native_session_owns_routes(tmp_path) assert application_route != microphone_route +@pytest.mark.parametrize("frame_duration_ms", [10, 20]) +def test_given_supported_frame_duration_when_session_declared_then_it_is_accepted( + frame_duration_ms: int, +) -> None: + assert Session(frame_duration_ms=frame_duration_ms) + + +def test_given_unsupported_frame_duration_when_declared_then_it_is_rejected() -> None: + with pytest.raises(PocketStationError, match="must be 10 or 20") as failure: + Session(frame_duration_ms=15) + assert failure.value.code == "session.invalid_frame_duration" + + @pytest.mark.parametrize("name", ["", " ", "\t"]) def test_given_empty_application_name_when_declared_then_rejected(name): with pytest.raises(PocketStationError, match="must not be empty") as failure: From 224b47b6eac2d5162da8e22222913d609992fb4b Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 02:57:42 -0400 Subject: [PATCH 32/49] Retain Realtime transcript revisions at 10 ms --- .github/workflows/ci.yml | 2 +- python/pocketstation_demo/openai_realtime.py | 64 ++++++++++++++++-- tests/test_openai_realtime_demo.py | 71 ++++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/test_openai_realtime_demo.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db2955a..de7103b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: working-directory: sdk-python run: | python -m pip install --upgrade pip - python -m pip install "maturin>=1.9.4,<2.0" "pytest>=8.0" "pytest-asyncio>=0.23" "mypy>=1.15" "ruff>=0.11" + python -m pip install "maturin>=1.9.4,<2.0" "pytest>=8.0" "pytest-asyncio>=0.23" "mypy>=1.15" "ruff>=0.11" "websockets>=17.0,<18" maturin develop --release --locked --features conformance-fixtures - name: Rust formatting diff --git a/python/pocketstation_demo/openai_realtime.py b/python/pocketstation_demo/openai_realtime.py index 2fec161..3cfe656 100644 --- a/python/pocketstation_demo/openai_realtime.py +++ b/python/pocketstation_demo/openai_realtime.py @@ -37,7 +37,7 @@ _MODEL_SAMPLE_RATE_HZ = 24_000 _SESSION_SAMPLE_RATE_HZ = 48_000 -_MICROPHONE_FRAME_SAMPLES = 960 +_MICROPHONE_FRAME_SAMPLE_COUNTS = (480, 960) _OUTPUT_FRAME_SAMPLES = 480 _OUTPUT_FRAME_DURATION_S = _OUTPUT_FRAME_SAMPLES / _SESSION_SAMPLE_RATE_HZ _MAX_EVENT_BYTES = 262_144 @@ -191,6 +191,12 @@ class _ResponseOutput: item_id: str | None = None +@dataclass(slots=True) +class _TranscriptProgress: + text: str = "" + revision: int = 0 + + class _Pcm24To48: def __init__(self) -> None: self._previous: float | None = None @@ -248,7 +254,7 @@ def __init__( @property def capabilities(self) -> DuplexVoiceCapabilities: return DuplexVoiceCapabilities( - transcript_revisions=False, + transcript_revisions=True, stable_prefix=False, provider_speech_detection=True, interruption=True, @@ -405,6 +411,7 @@ def __init__( self._event_records: deque[dict[str, Any]] = deque( maxlen=self._conversation_config.event_capacity ) + self._transcripts: dict[str, _TranscriptProgress] = {} self._input_frames_sent = 0 self._input_frames_dropped = 0 self._output_chunks_received = 0 @@ -1003,7 +1010,16 @@ def _record_text_event(self, event_type: str, event: Mapping[str, Any]) -> None: if not isinstance(text, str): text = event.get("transcript") values: dict[str, object] = {} - if isinstance(text, str): + if event_type.startswith("conversation.item.input_audio_transcription."): + values.update( + _transcript_event_values( + self._transcripts, + event_type, + event, + text, + ) + ) + elif isinstance(text, str): values["text"] = text[:8_192] for name in ("item_id", "response_id"): value = event.get(name) @@ -1030,8 +1046,8 @@ def _encode_microphone_frame(frame: Any) -> str: if frame.sample_rate_hz != _SESSION_SAMPLE_RATE_HZ or frame.channel_count != 1: raise ValueError("the OpenAI example requires 48 kHz mono Session audio") samples = _f32le(frame.samples_f32le) - if len(samples) != _MICROPHONE_FRAME_SAMPLES: - raise ValueError("the OpenAI example requires exact 20 ms Session frames") + if len(samples) not in _MICROPHONE_FRAME_SAMPLE_COUNTS: + raise ValueError("the OpenAI example requires 10 ms or 20 ms Session frames") pcm = array( "h", ( @@ -1044,6 +1060,38 @@ def _encode_microphone_frame(frame: Any) -> str: return base64.b64encode(pcm.tobytes()).decode("ascii") +def _transcript_event_values( + transcripts: dict[str, _TranscriptProgress], + event_type: str, + event: Mapping[str, Any], + text: object, +) -> dict[str, object]: + item_id = _required_string(event, "item_id") + progress = transcripts.setdefault(item_id, _TranscriptProgress()) + progress.revision += 1 + if event_type.endswith(".delta"): + if not isinstance(text, str): + raise ValueError("OpenAI Realtime transcript delta is missing text") + progress.text = (progress.text + text)[:8_192] + return { + "text": progress.text, + "stable_prefix": "", + "utterance_id": item_id, + "transcript_revision": progress.revision, + "final": False, + } + if not isinstance(text, str) or not text.strip(): + raise ValueError("OpenAI Realtime final transcript is missing text") + progress.text = text[:8_192] + return { + "text": progress.text, + "stable_prefix": progress.text, + "utterance_id": item_id, + "transcript_revision": progress.revision, + "final": True, + } + + def _pcm16(value: float) -> int: return max(-32_768, min(32_767, round(value * 32_767.0))) @@ -1094,6 +1142,12 @@ def _voice_event(record: Mapping[str, Any]) -> VoiceEvent: timestamp_ns=timestamp_ns, stage=("pocketstation" if kind.startswith("pocketstation.") else "provider"), provider_id="openai-realtime", + utterance_id=_optional_mapping_string(record, "utterance_id"), + transcript_revision=( + int(record["transcript_revision"]) + if isinstance(record.get("transcript_revision"), int) + else None + ), response_id=_optional_mapping_string(record, "response_id"), output_generation_id=( int(output_generation) if isinstance(output_generation, int) else None diff --git a/tests/test_openai_realtime_demo.py b/tests/test_openai_realtime_demo.py new file mode 100644 index 0000000..5c1d40d --- /dev/null +++ b/tests/test_openai_realtime_demo.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import base64 +from array import array +from types import SimpleNamespace + +import pytest +from pocketstation_demo.openai_realtime import ( + OpenAIRealtime, + _encode_microphone_frame, + _transcript_event_values, + _TranscriptProgress, +) + + +@pytest.mark.parametrize("sample_count", [480, 960]) +def test_realtime_input_accepts_ten_and_twenty_millisecond_frames( + sample_count: int, +) -> None: + samples = array("f", [0.25] * sample_count) + frame = SimpleNamespace( + sample_rate_hz=48_000, + channel_count=1, + samples_f32le=samples.tobytes(), + ) + + encoded = _encode_microphone_frame(frame) + + assert len(base64.b64decode(encoded, validate=True)) == sample_count + + +def test_realtime_transcript_deltas_receive_stable_identity_and_revision() -> None: + transcripts: dict[str, _TranscriptProgress] = {} + first = _transcript_event_values( + transcripts, + "conversation.item.input_audio_transcription.delta", + {"item_id": "item-1"}, + "hello", + ) + second = _transcript_event_values( + transcripts, + "conversation.item.input_audio_transcription.delta", + {"item_id": "item-1"}, + " world", + ) + final = _transcript_event_values( + transcripts, + "conversation.item.input_audio_transcription.completed", + {"item_id": "item-1"}, + "hello world", + ) + + assert first == { + "text": "hello", + "stable_prefix": "", + "utterance_id": "item-1", + "transcript_revision": 1, + "final": False, + } + assert second["text"] == "hello world" + assert second["transcript_revision"] == 2 + assert final["transcript_revision"] == 3 + assert final["stable_prefix"] == "hello world" + assert final["final"] is True + + +def test_realtime_capabilities_do_not_invent_stable_partial_text() -> None: + provider = OpenAIRealtime(api_key="test-only") + + assert provider.capabilities.transcript_revisions is True + assert provider.capabilities.stable_prefix is False From 6f5939ec5e15bb413b833fb9591998d0c9359b3d Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 04:30:55 -0400 Subject: [PATCH 33/49] Require PocketStation Core 1.1.4 --- native/Cargo.lock | 2 +- native/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/native/Cargo.lock b/native/Cargo.lock index b496075..7e12df6 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" -version = "1.1.3" +version = "1.1.4" dependencies = [ "alsa", "cc", diff --git a/native/Cargo.toml b/native/Cargo.toml index 32438b6..236da5b 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -16,10 +16,10 @@ default = [] conformance-fixtures = ["pocketstation/conformance-fixtures"] [dependencies] -pocketstation = "=1.1.3" +pocketstation = "=1.1.4" pocketstation-relay = "=0.1.2" pyo3 = { version = "0.27", features = ["abi3-py311"] } [dev-dependencies] -pocketstation = { version = "=1.1.3", features = ["conformance-fixtures"] } +pocketstation = { version = "=1.1.4", features = ["conformance-fixtures"] } tempfile = "3" From 9d3f5b3b8b25e0489557f8f16fa10ccf12833633 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 05:53:27 -0400 Subject: [PATCH 34/49] Retry transient Relay readiness requests --- python/pocketstation/aio/relay.py | 26 +++++++++++++++-------- python/pocketstation/relay.py | 17 ++++++++++----- tests/test_aio_relay.py | 35 +++++++++++++++++++++++++++++++ tests/test_relay.py | 32 ++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py index 7b7588e..ff38bdb 100644 --- a/python/pocketstation/aio/relay.py +++ b/python/pocketstation/aio/relay.py @@ -8,7 +8,7 @@ from types import TracebackType from typing import TYPE_CHECKING -from ..control import SessionCredentials, SessionId, SessionSnapshot +from ..control import ControlPlaneError, SessionCredentials, SessionId, SessionSnapshot from ..errors import _native_call from ..relay import ( PublisherActivation, @@ -240,14 +240,22 @@ async def _wait_for_snapshot( remaining = deadline - monotonic() if remaining <= 0: raise RelayTimeoutError(timeout_message, timeout_code) - snapshot = await self._control.session( - self.session_id, - self.credentials.source_token, - timeout_seconds=_bounded_request_timeout( - remaining, - self._request_timeout_seconds, - ), - ) + try: + snapshot = await self._control.session( + self.session_id, + self.credentials.source_token, + timeout_seconds=_bounded_request_timeout( + remaining, + self._request_timeout_seconds, + ), + ) + except ControlPlaneError as error: + if error.code != "control.request": + raise + await asyncio.sleep( + min(poll_interval_seconds, max(0.0, deadline - monotonic())) + ) + continue if predicate(snapshot): return snapshot await asyncio.sleep( diff --git a/python/pocketstation/relay.py b/python/pocketstation/relay.py index ad2a672..fae0054 100644 --- a/python/pocketstation/relay.py +++ b/python/pocketstation/relay.py @@ -12,6 +12,7 @@ from ._native import RelayPublisher as _NativeRelayPublisher from .control import ( ControlClient, + ControlPlaneError, SessionCredentials, SessionId, SessionSnapshot, @@ -306,11 +307,17 @@ def _wait_for_snapshot( remaining, self._request_timeout_seconds, ) - snapshot = self._control.session( - self.session_id, - self.credentials.source_token, - timeout_seconds=request_timeout, - ) + try: + snapshot = self._control.session( + self.session_id, + self.credentials.source_token, + timeout_seconds=request_timeout, + ) + except ControlPlaneError as error: + if error.code != "control.request": + raise + sleep(min(poll_interval_seconds, max(0.0, deadline - monotonic()))) + continue if predicate(snapshot): return snapshot sleep(min(poll_interval_seconds, max(0.0, deadline - monotonic()))) diff --git a/tests/test_aio_relay.py b/tests/test_aio_relay.py index 1cdcac8..200a845 100644 --- a/tests/test_aio_relay.py +++ b/tests/test_aio_relay.py @@ -101,6 +101,41 @@ async def control_handler(request: httpx.Request) -> httpx.Response: ] +@pytest.mark.asyncio +async def test_async_relay_wait_retries_transient_control_transport_failure() -> None: + get_calls = 0 + + async def control_handler(request: httpx.Request) -> httpx.Response: + nonlocal get_calls + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + get_calls += 1 + if get_calls == 1: + raise httpx.ReadTimeout("temporary read timeout", request=request) + return httpx.Response(200, json=_snapshot(ready=True, subscription_count=0)) + return httpx.Response(204) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(control_handler) + ) as control_http: + control = ControlClient("https://control.example", http_client=control_http) + remote = await RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + control_client=control, + ) + + activation = await remote.wait_for_publisher( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + assert activation.snapshot.ready is True + assert get_calls == 2 + await remote.aclose() + + def _snapshot(*, ready: bool, subscription_count: int) -> dict[str, object]: return { "session_id": "session_123", diff --git a/tests/test_relay.py b/tests/test_relay.py index 476e76d..98d3e80 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -151,6 +151,38 @@ def control_handler(request: httpx.Request) -> httpx.Response: remote.close() +def test_relay_wait_retries_transient_control_transport_failure() -> None: + get_calls = 0 + + def control_handler(request: httpx.Request) -> httpx.Response: + nonlocal get_calls + if request.method == "POST": + return httpx.Response(201, json=CREATE_RESPONSE) + if request.method == "GET": + get_calls += 1 + if get_calls == 1: + raise httpx.ReadTimeout("temporary read timeout", request=request) + return httpx.Response(200, json=_snapshot(ready=True, subscription_count=0)) + return httpx.Response(204) + + with httpx.Client(transport=httpx.MockTransport(control_handler)) as control_http: + control = ControlClient("https://control.example", http_client=control_http) + remote = RelaySession.create( + control_plane_url="https://control.example", + relay_url="https://relay.example", + control_client=control, + ) + + activation = remote.wait_for_publisher( + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + assert activation.snapshot.ready is True + assert get_calls == 2 + remote.close() + + @pytest.mark.parametrize( "join_url", [ From abbbc003fe5cfe729b5cb4aaed4672f7eec02eae Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 09:36:01 -0400 Subject: [PATCH 35/49] Prepare the Python 0.1.0 public release --- README.md | 90 +++++++++++---------------- docs/README.md | 4 +- examples/README.md | 21 +++---- examples/debug_voice_ai.py | 14 ++--- python/pocketstation/compatibility.py | 2 +- tests/test_operator_authoring.py | 60 ++++++++++++------ 6 files changed, 95 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index e09b768..202c217 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ -# PocketStation Python SDK +# PocketStation for Python -PocketStation lets Python applications capture one desktop application and an -optional microphone as separate live audio stems. One native Session can route -those stems to Python model code, Relay, and recording without mixing their -source identities. +PocketStation captures one desktop application and an optional microphone as +separate live audio stems. A single native Session can send those stems to +Python model code, a remote browser, and a multistem recording without mixing +their source identities. -The Python SDK uses the PocketStation Rust engine. Python owns application and -model logic; it does not reimplement capture, routing, timing, recording, or -Relay media transport. - -> **Status: preview.** The package is not published to PyPI. The macOS wheel has -> been tested with the workflow below. Linux and Windows wheels, plus WAN and -> TURN testing, are still in progress. The current candidate requires -> PocketStation Core `1.1.3` and Relay Connector `0.1.2`. +The Python package uses the PocketStation Rust engine for capture, routing, +timing, recording, and Relay transport. Your Python code owns the model and +application logic. ## Capture a desktop application -Install a development wheel: +Install PocketStation: ```bash -python -m pip install 'pocketstation @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' +python -m pip install pocketstation ``` Capture one application without opening a microphone or writing files: @@ -36,30 +31,23 @@ 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. -## Find where a voice agent lost time +## Debug a voice interruption [`examples/debug_voice_ai.py`](examples/debug_voice_ai.py) sends a physical microphone to OpenAI Realtime without another voice framework. PocketStation -keeps the microphone, generated assistant audio, and the selected browser's -output as independent recorded stems. It also records provider lifecycle and -interruption events on the same monotonic timeline. +keeps microphone input, generated assistant audio, and the selected browser's +output as independent recorded stems. Provider events and media events share +one monotonic timeline, so you can see whether delay occurred before the model, +inside the provider, in local output, or after Relay delivery. See the [voice-agent debugger instructions](examples/README.md#debug-a-voice-agent-interruption-from-the-media-boundary). ## Transcribe both sides of a voice application -The transcription example requires: - -- macOS with Screen Recording and Microphone permission; -- Python 3.11 or newer; -- a PocketStation development wheel built for your Python and macOS target; -- internet access on the first run to download the default faster-whisper - model. - Install the transcription extra, then run the example: ```bash -python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' +python -m pip install 'pocketstation[transcription]' python examples/transcribe_voice_app.py ``` @@ -77,9 +65,9 @@ physical microphone┘ ``` The complete composition is visible in -[`examples/transcribe_voice_app.py`](examples/transcribe_voice_app.py). The example-owned -adapter imports `faster_whisper.WhisperModel` when the Operator starts; it is -not built into the `pocketstation` SDK namespace. +[`examples/transcribe_voice_app.py`](examples/transcribe_voice_app.py). The +example adapter imports `faster_whisper.WhisperModel` when the Operator starts; +the provider is not part of the `pocketstation` namespace. This example does not debug turn handling, interruption, agent latency, or browser playout. PocketStation does not receive those events in this program. @@ -97,7 +85,7 @@ that application as one named AudioBus, waits for Relay readiness, and prints a single-use word code and browser URL. It does not open the microphone or record audio. -The example uses PocketStation's small rate-limited demo service unless you set +The example uses PocketStation's small, rate-limited demo service unless you set `POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` to services you operate. The shared URLs live in `pocketstation_demo`; application code does not contain service credentials. @@ -140,7 +128,7 @@ with session.start(): The input uses finite preallocated Core buffers. Writes report full, closed, cancelled, and invalid-buffer outcomes explicitly. -## Choose the right extension point +## Build an integration PocketStation uses four open boundaries: @@ -151,8 +139,7 @@ PocketStation uses four open boundaries: | `Connector` | Media or signals leave for an external system. | | `Endpoint` | You need the lower-level outbound execution contract. | -Import advanced contracts from their owning module so application code shows -which boundary it uses: +Import an authoring contract from the module that owns that boundary: ```python from pocketstation.connector import Connector, ConnectorManifest @@ -160,9 +147,8 @@ from pocketstation.operator_authoring import OperatorProvider from pocketstation.source_authoring import SourceProvider ``` -The package root contains only the common Session, capture, audio-input, and -error contracts. Advanced imports name the boundary they use; there is no -second flat compatibility API. +The package root contains the common Session, capture, audio-input, and error +contracts. Provider authoring stays in explicit modules. Python provider callbacks execute on bounded off-realtime workers. They cannot be used as native capture callbacks. Compiled native extensions remain the path @@ -189,30 +175,24 @@ Python callbacks still cross the interpreter boundary. Capture, routing, recording, and Relay transport remain native-speed; arbitrary Python model code does not have the same execution cost as Rust. -## Current package status +## Platform support -| Area | Current status | +| Area | Support | |---|---| -| Native Rust, Python, Ruff, and MyPy checks | Pass locally | -| Installed macOS wheel | Tested | -| Real faster-whisper inference | Tested | -| Relay and Chromium receiver | Tested on the publisher host only | -| Physical application and microphone | Tested on the recorded macOS host | -| Linux wheel | Not yet tested externally | -| Windows wheel | Not yet tested externally | -| Receiver over WAN or TURN | Not yet tested externally | -| Standalone source distribution | Pending the Relay Connector 0.1.2 registry release | -| PyPI release | Not published | - -The native binding pins Core `1.1.3` and the shared Relay Connector `0.1.2`. -Core `1.1.3` is published. Relay Connector `0.1.2` must pass its release gate -and reach crates.io before the Python source distribution can be released. +| Python | 3.11 and newer | +| macOS Apple silicon | Installed wheel, application capture, physical microphone, 10 ms voice path, Relay, Chromium, and multistem recording tested | +| Linux | Core application selection and 10 ms capture tested; installed Python distribution qualification in progress | +| Windows 11 ARM64 | Core application selection and 10 ms capture tested in a VM; installed Python distribution and physical-device qualification in progress | +| WAN and TURN | Not yet qualified | + +The native binding uses PocketStation Core `1.1.4` and the shared Relay +Connector `0.1.2`. The Rust-to-Python audio read currently copies native samples into Python-owned bytes before exposing a `memoryview`. The view avoids another Python-side copy; the complete boundary is not zero-copy. -## Verify a local change +## Develop the SDK ```bash uv sync --extra transcription diff --git a/docs/README.md b/docs/README.md index f1eadae..ce6e99c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,5 +23,5 @@ need the lower-level API. and [`pocketstation.connector`](../python/pocketstation/connector.py) are the open provider boundaries. -For package status and current qualification limits, read the -[repository README](../README.md#current-package-status). +For supported platforms and current qualification limits, read the +[repository README](../README.md#platform-support). diff --git a/examples/README.md b/examples/README.md index 8271a81..6a36b20 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ provider logs as media evidence. Install the optional dependencies and provide an OpenAI API key: ```bash -python -m pip install 'pocketstation[voice-agent-debug] @ file:///absolute/path/to/pocketstation.whl' +python -m pip install 'pocketstation[voice-agent-debug]' export OPENAI_API_KEY='...' ``` @@ -40,12 +40,11 @@ Speak, interrupt the assistant while it is replying, then press `Ctrl-C`. Use headphones for this run: the example does not provide acoustic echo cancellation. -The final report includes source continuity, bounded queue drops, routing -delay, the speech-start event, and PocketStation's output-cancellation time. -It reports browser playout position as unavailable because the receiver does -not yet return a played-sample acknowledgement to the publisher. Therefore the -example does not claim that the model conversation was truncated to the exact -sample a person heard. +The final report includes source continuity, bounded queue drops, provider +events, and PocketStation's output-cancellation events. The receiver reports +WebRTC statistics, but it does not acknowledge the exact sample played through +the loudspeaker. The example therefore reports acoustic hearing and exact +provider-history truncation as unavailable. The installed examples use the small shared demo service by default. It has strict admission limits. Set `POCKETSTATION_CONTROL_URL` and @@ -67,7 +66,7 @@ explicit in the source, and no recording or cloud service starts. Install the optional model dependency before the first run: ```bash -python -m pip install 'pocketstation[transcription] @ file:///absolute/path/to/pocketstation-0.1.0-cp311-abi3-macosx_11_0_arm64.whl' +python -m pip install 'pocketstation[transcription]' ``` The first run may download the configured faster-whisper model. Model work runs @@ -91,11 +90,11 @@ To use services you operate, set `POCKETSTATION_CONTROL_URL` and `POCKETSTATION_RELAY_URL` before running the command. No shared secret belongs in application code. -## Run capture, transcription, browser audio, and recording together +## Run the complete demo The installed `pocketstation-demo` command combines independent application and microphone capture, faster-whisper transcripts, two Relay/browser AudioBuses, and a finalized two-stem recording. -The current browser test runs on the same host as the publisher. WAN and TURN -behavior have not been verified yet. +The current physical voice proof runs the browser on the publisher host through +the deployed Relay. WAN and TURN behavior have not been qualified yet. diff --git a/examples/debug_voice_ai.py b/examples/debug_voice_ai.py index 5e9823b..a1a1e81 100644 --- a/examples/debug_voice_ai.py +++ b/examples/debug_voice_ai.py @@ -4,12 +4,10 @@ from array import array import pocketstation.aio as pks -from pocketstation.graph import SourceOutput, Stem +from pocketstation import Source from pocketstation_demo import demo_relay_session from pocketstation_demo.openai_realtime import OpenAIRealtime -from pocketstation import Source - async def main() -> None: app = input("Browser application playing the agent: ") @@ -25,10 +23,12 @@ async def main() -> None: int(assistant.output.send(observed)): "assistant-output", } publisher = remote.publisher(session) - for bus, output in zip(buses, (application, mic, assistant.output), strict=True): - assert isinstance(output, (Stem, SourceOutput)) - output.record(bus) - output.publish(publisher, bus) + application.record("application") + application.publish(publisher, "application") + mic.record("microphone") + mic.publish(publisher, "microphone") + assistant.output.record("assistant") + assistant.output.publish(publisher, "assistant") model = OpenAIRealtime(api_key=os.environ["OPENAI_API_KEY"], route_labels=labels) conversation = session.conversation(input=mic, output=assistant, voice_model=model) async with remote, await session.start() as running: diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py index 3de0a17..9af14e3 100644 --- a/python/pocketstation/compatibility.py +++ b/python/pocketstation/compatibility.py @@ -19,7 +19,7 @@ class RuntimeCompatibility: RUNTIME_COMPATIBILITY = RuntimeCompatibility( sdk_version="0.1.0", - core_version="1.1.3", + core_version="1.1.4", relay_connector_version="0.1.2", python_requires=">=3.11", python_abi="abi3-py311", diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py index b49bea8..0fc5acd 100644 --- a/tests/test_operator_authoring.py +++ b/tests/test_operator_authoring.py @@ -27,8 +27,10 @@ SourceProvider, ) +_VOICE_FRAME_SAMPLES = 480 -def _pcm_media(*, frame_samples: int = 4) -> MediaCaps: + +def _pcm_media(*, frame_samples: int = _VOICE_FRAME_SAMPLES) -> MediaCaps: return MediaCaps.audio( AudioCaps( sample_rate_hz=48_000, @@ -41,7 +43,7 @@ def _pcm_media(*, frame_samples: int = 4) -> MediaCaps: def _pcm_operator( *, samples: array[float], - frame_samples: int = 4, + frame_samples: int = _VOICE_FRAME_SAMPLES, ) -> tuple[OperatorProvider, Event]: input_signal = SignalSpec.audio(role="audio.input") output_signal = SignalSpec.audio(role="audio.generated") @@ -284,7 +286,9 @@ async def uppercase(input_port, envelope): def test_python_operator_emits_pcm_into_core_reentry_and_recording(tmp_path) -> None: - provider, closed = _pcm_operator(samples=array("f", [0.25, -0.25, 0.5, -0.5])) + generated_samples = array("f", [0.0]) * _VOICE_FRAME_SAMPLES + generated_samples[:4] = array("f", [0.25, -0.25, 0.5, -0.5]) + provider, closed = _pcm_operator(samples=generated_samples) delivered = Event() connector_frames = [] @@ -298,8 +302,10 @@ def deliver(frame, _context): deliver, package_version="1.0.0", ) - session = Session(recording_root=tmp_path) - source = session.audio_input("operator-input", frame_samples_per_channel=4) + session = Session(recording_root=tmp_path, frame_duration_ms=10) + source = session.audio_input( + "operator-input", frame_samples_per_channel=_VOICE_FRAME_SAMPLES + ) operator = session.register_operator(provider).declare() source.output.connect(operator.input("input")) generated = operator.output("output").reenter_audio() @@ -308,13 +314,15 @@ def deliver(frame, _context): generated.record("generated") running = session.start() - source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + source.write(array("f", [0.0]) * _VOICE_FRAME_SAMPLES) frame = running.audio.read(timeout_s=1.0) assert delivered.wait(1.0) stop = running.stop() assert frame is not None - assert list(frame.samples.cast("f")) == pytest.approx([0.25, -0.25, 0.5, -0.5]) + values = list(frame.samples.cast("f")) + assert len(values) == _VOICE_FRAME_SAMPLES + assert values[:4] == pytest.approx([0.25, -0.25, 0.5, -0.5]) assert frame.sample_rate_hz == 48_000 assert frame.channel_count == 1 assert frame.sequence_number == 0 @@ -332,22 +340,26 @@ def deliver(frame, _context): def test_python_operator_rejects_wrong_pcm_frame_size() -> None: - provider, closed = _pcm_operator(samples=array("f", [0.0, 0.0, 0.0])) - session = Session() - source = session.audio_input("operator-input", frame_samples_per_channel=4) + provider, closed = _pcm_operator( + samples=array("f", [0.0]) * (_VOICE_FRAME_SAMPLES - 1) + ) + session = Session(frame_duration_ms=10) + source = session.audio_input( + "operator-input", frame_samples_per_channel=_VOICE_FRAME_SAMPLES + ) operator = session.register_operator(provider).declare() source.output.connect(operator.input("input")) operator.output("output").reenter_audio().send(session.polled_audio()) running = session.start() - source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + source.write(array("f", [0.0]) * _VOICE_FRAME_SAMPLES) assert running.audio.read(timeout_s=0.2) is None stop = running.stop() assert not stop.success assert stop.terminal_event is not None assert any( - "expected 4" + "expected 480" in " ".join( value for value in ( @@ -427,7 +439,7 @@ def test_pcm_emission_rejects_non_contiguous_input() -> None: def test_pcm_operator_fails_explicitly_when_its_native_pool_is_saturated() -> None: signal = SignalSpec.audio(role="audio.generated") - samples = array("f", [0.0, 0.0, 0.0, 0.0]) + samples = array("f", [0.0]) * _VOICE_FRAME_SAMPLES class EmitBeyondCapacity(OperatorNode): def process(self, _input_port, _envelope): @@ -449,8 +461,10 @@ def create(self, _configuration) -> EmitBeyondCapacity: ), Factory(), ) - session = Session() - source = session.audio_input("operator-input", frame_samples_per_channel=4) + session = Session(frame_duration_ms=10) + source = session.audio_input( + "operator-input", frame_samples_per_channel=_VOICE_FRAME_SAMPLES + ) operator = session.register_operator(provider).declare() source.output.connect(operator.input("input")) operator.output("output").reenter_audio().send(session.polled_audio()) @@ -479,9 +493,13 @@ def create(self, _configuration) -> EmitBeyondCapacity: @pytest.mark.asyncio async def test_async_operator_pcm_uses_the_same_core_reentry(tmp_path) -> None: - provider, closed = _pcm_operator(samples=array("f", [0.1, 0.2, 0.3, 0.4])) - session = pks_aio.Session(recording_root=tmp_path) - source = session.audio_input("operator-input", frame_samples_per_channel=4) + generated_samples = array("f", [0.0]) * _VOICE_FRAME_SAMPLES + generated_samples[:4] = array("f", [0.1, 0.2, 0.3, 0.4]) + provider, closed = _pcm_operator(samples=generated_samples) + session = pks_aio.Session(recording_root=tmp_path, frame_duration_ms=10) + source = session.audio_input( + "operator-input", frame_samples_per_channel=_VOICE_FRAME_SAMPLES + ) operator = session.register_operator(provider).declare() source.output.connect(operator.input("input")) generated = operator.output("output").reenter_audio() @@ -489,12 +507,14 @@ async def test_async_operator_pcm_uses_the_same_core_reentry(tmp_path) -> None: generated.record("generated") running = await session.start() - await source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + await source.write(array("f", [0.0]) * _VOICE_FRAME_SAMPLES) frame = await running.audio.read(timeout_s=1.0) stop = await running.stop() assert frame is not None - assert list(frame.samples.cast("f")) == pytest.approx([0.1, 0.2, 0.3, 0.4]) + values = list(frame.samples.cast("f")) + assert len(values) == _VOICE_FRAME_SAMPLES + assert values[:4] == pytest.approx([0.1, 0.2, 0.3, 0.4]) assert stop.success assert stop.recording is not None assert stop.recording.complete From e85734221cfb90c9d70ea59e10088d13a8756fe1 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 09:57:13 -0400 Subject: [PATCH 36/49] Publish Python through trusted GitHub releases --- .github/CODEOWNERS | 5 +- .github/workflows/release.yml | 191 ++++++++++++++++++++++++++++++++++ README.md | 2 + RELEASE_NOTES.md | 48 +++++++++ pyproject.toml | 16 +++ tests/installed_consumer.py | 15 ++- 6 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASE_NOTES.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3ebf74c..3c1b0a3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,2 @@ -# Replace @raph with your GitHub username/team before pushing. -* @raph -/.github/ @raph +* @Raphjacksun7 +/.github/ @Raphjacksun7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3560845 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,191 @@ +name: release-python + +'on': + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing version-matched release tag to resume + required: true + type: string + +permissions: + contents: read + +concurrency: + group: pypi-publish + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + ref: ${{ github.event.release.tag_name || inputs.release_tag }} + + - name: Validate the release tag + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + set -euo pipefail + version="$( + python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])' + )" + expected_tag="pocketstation-v${version}" + if [[ "${RELEASE_TAG}" != "${expected_tag}" ]]; then + echo "release tag ${RELEASE_TAG} must equal ${expected_tag}" >&2 + exit 1 + fi + tag_commit="$(git rev-list -n 1 "${RELEASE_TAG}")" + git fetch origin main + if ! git merge-base --is-ancestor "${tag_commit}" origin/main; then + echo "release commit is not contained in origin/main" >&2 + exit 1 + fi + if [[ "${EVENT_NAME}" == "release" && "${tag_commit}" != "${GITHUB_SHA}" ]]; then + echo "release tag does not resolve to the checked-out commit" >&2 + exit 1 + fi + + wheels: + needs: validate + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: Linux x86-64 wheel + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + manylinux: "2_28" + before_script: dnf install -y alsa-lib-devel pipewire-devel clang cmake ninja-build + - name: Linux ARM64 wheel + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2_28" + before_script: dnf install -y alsa-lib-devel pipewire-devel clang cmake ninja-build + - name: macOS Apple silicon wheel + os: macos-15 + target: aarch64-apple-darwin + manylinux: "off" + before_script: "" + - name: macOS Intel wheel + os: macos-15-intel + target: x86_64-apple-darwin + manylinux: "off" + before_script: "" + - name: Windows x86-64 wheel + os: windows-latest + target: x86_64-pc-windows-msvc + manylinux: "off" + before_script: "" + - name: Windows ARM64 wheel + os: windows-11-arm + target: aarch64-pc-windows-msvc + manylinux: "off" + before_script: "" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.release.tag_name || inputs.release_tag }} + + - name: Install Python 3.13 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.13" + + - name: Build the wheel + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b + with: + command: build + maturin-version: v1.13.0 + rust-toolchain: 1.95.0 + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + before-script-linux: ${{ matrix.before_script }} + args: --release --locked --compatibility pypi --out dist + + - name: Test the installed wheel + run: >- + python tests/run_artifact_consumer.py + --artifact-kind wheel + --artifact-dir dist + + - name: Upload the wheel + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wheel-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error + + sdist: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.release.tag_name || inputs.release_tag }} + + - name: Install Rust 1.95 + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.95.0 + + - name: Install Python 3.13 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.13" + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes libasound2-dev libpipewire-0.3-dev + python -m pip install --disable-pip-version-check "maturin==1.13.0" + + - name: Build and test the source distribution + run: | + maturin sdist --manifest-path native/Cargo.toml --out dist + python tests/run_artifact_consumer.py \ + --artifact-kind sdist \ + --artifact-dir dist + + - name: Upload the source distribution + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: source-distribution + path: dist/*.tar.gz + if-no-files-found: error + + publish: + needs: [wheels, sdist] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: pypi + url: https://pypi.org/p/pocketstation + permissions: + id-token: write + steps: + - name: Download wheels + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: wheel-* + path: dist + merge-multiple: true + + - name: Download the source distribution + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: source-distribution + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e diff --git a/README.md b/README.md index 202c217..fae111c 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,8 @@ uv run mypy python tests/qualification/typing_contract.py examples ## Reference +- [`RELEASE_NOTES.md`](RELEASE_NOTES.md) — user-visible changes and upgrade + guidance. - [`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 new file mode 100644 index 0000000..bc8f054 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,48 @@ +# PocketStation for Python release notes + +## 0.1.0 — Capture, inspect, and route live desktop audio + +PocketStation for Python captures one desktop application and an optional +microphone as independent live stems. A single native Session can send those +stems to Python model code, PocketStation Relay, and a multistem recording +without mixing their source identities. + +The first release includes: + +- synchronous and asyncio Session APIs; +- exact application selection and default-microphone capture; +- 10 ms and 20 ms audio profiles; +- bounded audio and typed-signal streams; +- Python-authored Sources, Operators, Connectors, and Endpoints; +- application-owned PCM input and generated-audio output cancellation; +- provider-neutral voice composition with revisable transcripts; +- Relay publication and short-lived browser invitations; and +- source-aware multistem recording with structured outcomes. + +The package uses PocketStation Core for capture, routing, timing, recording, +and lifecycle. Python provider work runs on bounded off-realtime workers and +does not execute on native capture callbacks. + +### Voice interruption example + +`examples/debug_voice_ai.py` connects a physical microphone directly to OpenAI +Realtime, routes generated speech through the normal Session audio path, and +records microphone input, generated output, and browser playback separately. +The resulting timeline distinguishes provider cancellation from Core output +cancellation and receiver delivery. + +The current receiver does not acknowledge the exact sample played through a +loudspeaker. PocketStation therefore reports acoustic hearing and exact +provider-history truncation as unavailable instead of inferring them. + +### Platform support + +- macOS Apple silicon has installed-wheel evidence for application capture, + physical microphone input, the 10 ms voice path, Relay, Chromium, and + multistem recording. +- Linux and Windows have Core application-selection and 10 ms capture evidence. + Installed Python distributions are qualified separately by the release + workflow. +- WAN and TURN behavior are not yet qualified. + +This is the first public Python release. There is no earlier package migration. diff --git a/pyproject.toml b/pyproject.toml index 9d82af3..674f8fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,26 @@ description = "Source-aware live audio capture, processing, and routing for Pyth readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Typing :: Typed", +] dependencies = [ "httpx>=0.27", ] +[project.urls] +Documentation = "https://github.com/pocketstation-io/sdk-python/tree/main/docs" +Issues = "https://github.com/pocketstation-io/sdk-python/issues" +Repository = "https://github.com/pocketstation-io/sdk-python" +"Release notes" = "https://github.com/pocketstation-io/sdk-python/blob/main/RELEASE_NOTES.md" + [project.scripts] pocketstation-demo = "pocketstation_demo:main" diff --git a/tests/installed_consumer.py b/tests/installed_consumer.py index 00b50d6..15de1da 100644 --- a/tests/installed_consumer.py +++ b/tests/installed_consumer.py @@ -11,6 +11,8 @@ import pocketstation._api as pocketstation +_VOICE_FRAME_SAMPLES = 480 + class InstalledSource(pocketstation.SourceDriver): def __init__( @@ -336,11 +338,12 @@ def _exercise_operator_pcm_reentry() -> None: media = pocketstation.MediaCaps.audio( pocketstation.AudioCaps( sample_rate_hz=48_000, - frame_samples=4, + frame_samples=_VOICE_FRAME_SAMPLES, channel_layout=pocketstation.ChannelLayout.MONO, ) ) - emitted = array("f", [0.25, -0.25, 0.5, -0.5]) + emitted = array("f", [0.0]) * _VOICE_FRAME_SAMPLES + emitted[:4] = array("f", [0.25, -0.25, 0.5, -0.5]) closed = Event() class InstalledPcmOperator(pocketstation.OperatorNode): @@ -367,14 +370,16 @@ def create(self, _configuration: object) -> InstalledPcmOperator: ), InstalledPcmFactory(), ) - session = pocketstation.Session() - source = session.audio_input("installed-pcm", frame_samples_per_channel=4) + session = pocketstation.Session(frame_duration_ms=10) + source = session.audio_input( + "installed-pcm", frame_samples_per_channel=_VOICE_FRAME_SAMPLES + ) operator = session.register_operator(provider).declare() source.output.connect(operator.input("input")) operator.output("output").reenter_audio().send(session.polled_audio()) running = session.start() - source.write(array("f", [0.0, 0.0, 0.0, 0.0])) + source.write(array("f", [0.0]) * _VOICE_FRAME_SAMPLES) frame = running.audio.read(timeout_s=1.0) result = running.stop() if frame is None or list(frame.samples.cast("f")) != list(emitted): From 2d9c400df063c94a62e92b898168df836bc5ec49 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 10:09:46 -0400 Subject: [PATCH 37/49] Pin Python CI actions --- .github/workflows/ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de7103b..3d0a6eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Check out Python SDK - uses: actions/checkout@v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: path: sdk-python - name: Install Rust 1.95 - uses: dtolnay/rust-toolchain@master + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.95.0 components: clippy, rustfmt - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: ${{ matrix.python }} @@ -90,7 +90,7 @@ jobs: --artifact-dir dist - name: Upload wheel for inspection - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: pocketstation-${{ matrix.os }}-py${{ matrix.python }} path: sdk-python/dist/*.whl @@ -102,17 +102,17 @@ jobs: steps: - name: Check out Python SDK - uses: actions/checkout@v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: path: sdk-python - name: Install Rust 1.95 - uses: dtolnay/rust-toolchain@master + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.95.0 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: "3.11" @@ -136,7 +136,7 @@ jobs: --artifact-dir dist - name: Upload source distribution for inspection - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: pocketstation-sdist path: sdk-python/dist/*.tar.gz From 7fa1d2def28286aefed7d4e563bce88fcfba4ef2 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 10:55:43 -0400 Subject: [PATCH 38/49] Complete the Python developer documentation --- README.md | 2 + RELEASE_NOTES.md | 14 ++++- docs/README.md | 38 ++++++++---- docs/concepts/session-and-bounds.md | 50 ++++++++++++++++ docs/getting-started/capture.md | 75 ++++++++++++++++++++++++ docs/guides/integrations.md | 50 ++++++++++++++++ docs/guides/relay.md | 58 +++++++++++++++++++ docs/guides/voice.md | 90 +++++++++++++++++++++++++++++ docs/operations/platform-support.md | 43 ++++++++++++++ docs/reference/api-map.md | 66 +++++++++++++++++++++ docs/troubleshooting.md | 58 +++++++++++++++++++ python/pocketstation/audio_input.py | 2 +- python/pocketstation/session.py | 2 +- python/pocketstation/sources.py | 2 +- tests/test_graph.py | 2 +- 15 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 docs/concepts/session-and-bounds.md create mode 100644 docs/getting-started/capture.md create mode 100644 docs/guides/integrations.md create mode 100644 docs/guides/relay.md create mode 100644 docs/guides/voice.md create mode 100644 docs/operations/platform-support.md create mode 100644 docs/reference/api-map.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index fae111c..b4b293c 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,8 @@ uv run mypy python tests/qualification/typing_contract.py examples - [`RELEASE_NOTES.md`](RELEASE_NOTES.md) — user-visible changes and upgrade guidance. +- [`docs/README.md`](docs/README.md) — task guides, concepts, operations, and + API ownership. - [`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 bc8f054..566bef3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,12 +1,16 @@ # PocketStation for Python release notes -## 0.1.0 — Capture, inspect, and route live desktop audio +## 0.1.0 — 2026-08-31 + +Capture, inspect, and route live desktop audio. PocketStation for Python captures one desktop application and an optional microphone as independent live stems. A single native Session can send those stems to Python model code, PocketStation Relay, and a multistem recording without mixing their source identities. +### Added + The first release includes: - synchronous and asyncio Session APIs; @@ -35,7 +39,7 @@ The current receiver does not acknowledge the exact sample played through a loudspeaker. PocketStation therefore reports acoustic hearing and exact provider-history truncation as unavailable instead of inferring them. -### Platform support +### Supported and qualified environments - macOS Apple silicon has installed-wheel evidence for application capture, physical microphone input, the 10 ms voice path, Relay, Chromium, and @@ -45,4 +49,10 @@ provider-history truncation as unavailable instead of inferring them. workflow. - WAN and TURN behavior are not yet qualified. +### Compatibility and upgrade + This is the first public Python release. There is no earlier package migration. + +```console +python -m pip install pocketstation==0.1.0 +``` diff --git a/docs/README.md b/docs/README.md index ce6e99c..d1010b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,23 +5,41 @@ need the lower-level API. ## Get started -- [Capture one desktop application](../README.md#capture-a-desktop-application) +- [Capture one desktop application](getting-started/capture.md) - [Transcribe both sides of a voice application](../README.md#transcribe-both-sides-of-a-voice-application) - [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 -- [`pocketstation.session`](../python/pocketstation/session.py) owns synchronous +- [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) + +## Understand the system + +- [Session ownership, bounds, and shutdown](concepts/session-and-bounds.md) + +## Operate and upgrade + +- [Prepare and qualify each platform](operations/platform-support.md) +- [Troubleshoot capture, delivery, and shutdown](troubleshooting.md) +- [Read the release notes](../RELEASE_NOTES.md) + +## Reference + +- [`pocketstation.session`](../python/pocketstation/session.py) — synchronous Session declaration and lifecycle. -- [`pocketstation.aio`](../python/pocketstation/aio/__init__.py) provides the - asyncio projection of the same native Session. -- [`pocketstation.graph`](../python/pocketstation/graph.py) declares stems, - routes, ports, and typed signals. -- [`pocketstation.source_authoring`](../python/pocketstation/source_authoring.py), - [`pocketstation.operator_authoring`](../python/pocketstation/operator_authoring.py), - and [`pocketstation.connector`](../python/pocketstation/connector.py) are the - open provider boundaries. +- [`pocketstation.aio`](../python/pocketstation/aio/__init__.py) — asyncio over + the same native Session. +- [`pocketstation.voice`](../python/pocketstation/voice/__init__.py) — + provider-neutral voice composition contracts. +- [`pocketstation.graph`](../python/pocketstation/graph.py) — stems, routes, + ports, and signals. +- [`pocketstation.observations`](../python/pocketstation/observations.py) — + runtime metrics and outcomes. +- [Python API map](reference/api-map.md) — public entry points and advanced + modules by task. For supported platforms and current qualification limits, read the [repository README](../README.md#platform-support). diff --git a/docs/concepts/session-and-bounds.md b/docs/concepts/session-and-bounds.md new file mode 100644 index 0000000..1732093 --- /dev/null +++ b/docs/concepts/session-and-bounds.md @@ -0,0 +1,50 @@ +# How one Session owns media and failure + +`Session` is the lifecycle owner for sources, routes, Operators, Connectors, +recording, observations, and shutdown. Python declares work; the Rust engine +captures and routes audio through finite queues. + +```text +application ─┐ +microphone ──┼─ Session ─┬─ Python or native Operator +owned PCM ───┘ ├─ Connector or Endpoint + ├─ bounded frame iterator + └─ multistem recording +``` + +## Source identity survives fan-out + +An audio frame retains source, stream, stem, sequence, timestamp, clock, source +generation, and discontinuity information. Sending one stem to several +destinations does not mix that identity or recapture the source. + +## Every crossing is finite + +Audio input, polling, Python provider work, signals, Relay, and recording use +declared capacities. When a boundary is full, PocketStation returns or records +pressure according to that route's policy. It does not hide pressure in an +unbounded `asyncio.Queue`. + +A slow Python consumer can still lose frames at its own bounded endpoint. +Inspect the route counters and discontinuities whenever complete delivery +matters. + +## Python does not run on capture callbacks + +Python-authored Sources, Operators, Connectors, and Endpoints execute on +bounded off-realtime workers. Native capture callbacks remain allocation-free, +lock-free, blocking-free, async-free, log-free, and panic-free. + +Use a compiled extension when code must stay native. Use a process sidecar when +crash isolation matters. Neither boundary creates a second Session engine. + +## Stop and cancel are different + +Normal close requests a drain and joins every Session-owned worker. Cancellation +stops active asynchronous work before the same joined shutdown. Inspect the +terminal `StopResult`, recording outcome, provider outcome, and structured +errors before reporting success. + +Provider cancellation, Core output cancellation, Connector queue clearing, +receiver playout clearing, and acoustic hearing are separate facts. A sender +must not infer a receiver or loudspeaker result it cannot observe. diff --git a/docs/getting-started/capture.md b/docs/getting-started/capture.md new file mode 100644 index 0000000..d51486d --- /dev/null +++ b/docs/getting-started/capture.md @@ -0,0 +1,75 @@ +# Capture one desktop application + +Install PocketStation, select one running application, and read its +source-aware audio frames. Microphone capture and recording remain off unless +you request them. + +## Prerequisites + +- Python 3.11 or newer; +- a supported desktop operating system and native capture permissions; +- one application producing audio. + +Install the package: + +```bash +python -m pip install pocketstation +``` + +## Run the shortest capture path + +```python +import pocketstation + +with pocketstation.capture(application="Spotify") as live: + for frame in live.audio: + print(frame.source_id, frame.stem_id) +``` + +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. + +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. + +## Add a microphone or recording + +```python +import pocketstation + +with pocketstation.capture( + application="Zoom", + microphone=True, + record_to="recordings", +) as live: + for frame in live.audio: + print(frame.source_id, frame.stem_id) +``` + +Application and microphone frames retain different source and stem identities. +Recording writes a separate stem for each selected source. + +## Use asyncio + +```python +import pocketstation.aio as pks + +async with pks.capture(application="Spotify") as live: + async for frame in live.audio: + print(frame.source_id, frame.stem_id) +``` + +The synchronous and asyncio APIs control the same Rust Session. Capture, +routing, recording, and Relay remain native; only application and provider work +crosses into Python. + +## Handle setup and delivery failures + +Application selection, permission, graph validation, and provider preparation +fail before a running Session is returned. During execution, inspect route +metrics and discontinuities instead of treating missing frames as silence. + +Continue with [Session bounds and shutdown](../concepts/session-and-bounds.md) +or [Relay publication](../guides/relay.md). diff --git a/docs/guides/integrations.md b/docs/guides/integrations.md new file mode 100644 index 0000000..42c6859 --- /dev/null +++ b/docs/guides/integrations.md @@ -0,0 +1,50 @@ +# Build 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 +shutdown. + +| Boundary | Use it when | +|---|---| +| `Source` | Media or signals enter the Session. | +| `Operator` | Computation transforms media or emits signals. | +| `Connector` | Media or signals leave for an external provider. | +| `Endpoint` | An outbound integration needs the lower-level execution SPI. | + +## Keep provider code outside Core + +Provider packages own credentials, protocol framing, codecs, provider +deadlines, retry behavior, and provider-specific errors. PocketStation Core +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. + +## Start with the focused authoring module + +```python +from pocketstation.connector import Connector, ConnectorManifest +from pocketstation.operator_authoring import OperatorProvider +from pocketstation.source_authoring import SourceProvider +``` + +Use `session.destination(connector)` for one configured outbound destination. +Use `session.register_connector(connector)` when the same implementation must +declare several independently configured Endpoints. + +## Declare capabilities and limits + +An integration manifest should expose stable identity, named ports, signal and +media capabilities, typed configuration, secret classification, finite startup +and request deadlines, and structured failures. Reject unsupported +combinations before the Session starts. + +Secret values may be read during provider setup, but they must not appear in +errors, logs, metrics, observations, or object representations. + +## Prove the package outside its repository + +Build and install the distribution into a clean environment. Run the provider +through a normal Session, cause saturation and cancellation, and verify joined +shutdown. A mock proves only the adapter contract; a network integration needs +provider and receiver evidence. diff --git a/docs/guides/relay.md b/docs/guides/relay.md new file mode 100644 index 0000000..1933629 --- /dev/null +++ b/docs/guides/relay.md @@ -0,0 +1,58 @@ +# Publish a named AudioBus through Relay + +Use `RelaySession` when a Session stem must reach a browser or another remote +receiver. The Python control client creates credentials and invitations; the +shared Rust Connector handles WebRTC publication. + +## Connect to services you operate + +```python +import pocketstation.aio as pks + +remote = await pks.RelaySession.create( + control_plane_url="https://control.example.com", + relay_url="https://relay.example.com", + required_buses=("application",), +) +live = pks.capture(application="Spotify", stream_audio=False) +live.application_stem.publish(remote.publisher(live.session), "application") + +async with remote, live: + invitation = await remote.wait_for_publisher_and_invitation( + bus_id="application", + timeout_seconds=30, + ) + print(invitation.join_code, invitation.join_url) + await remote.wait_for_receiver(timeout_seconds=30) +``` + +Declare every bus before starting the Session. Create an invitation only after +publisher readiness succeeds, and delete the remote Session during shutdown. + +## Use the shared demo service for a quick test + +`examples/stream_any_app_audio.py` uses the small rate-limited demo deployment +through `pocketstation_demo`. The deployment may reject a session when its +capacity is in use and is not a hosted production service. + +Set these variables to run the same example against services you operate: + +```bash +export POCKETSTATION_CONTROL_URL="https://control.example.com" +export POCKETSTATION_RELAY_URL="https://relay.example.com" +python examples/stream_any_app_audio.py +``` + +Do not put control-plane secrets, signing keys, or shared internal credentials +in application code. Applications receive scoped session credentials from the +control plane. + +## Know what readiness proves + +Publisher readiness confirms that Relay accepted the declared publication. +Receiver readiness confirms an active subscription. Browser WebRTC statistics +can report received and jitter-buffered samples. + +Those observations do not prove which sample a loudspeaker played. End-to-end +audible cancellation requires a receiver capability that clears playout and +acknowledges the last rendered sample. diff --git a/docs/guides/voice.md b/docs/guides/voice.md new file mode 100644 index 0000000..0dff4ca --- /dev/null +++ b/docs/guides/voice.md @@ -0,0 +1,90 @@ +# Compose a bounded voice workflow + +`pocketstation.voice` defines provider-neutral voice contracts. +`pocketstation.aio.Session` composes those providers around one native Session. +The package does not contain a model provider, capture engine, Relay +implementation, or agent framework. + +Start with a declared asyncio Session, one input stem, one `AudioInput` for +generated speech, and provider objects that implement the selected contracts. + +## Choose one provider shape + +Use separate components when the application selects independent stages: + +```python +conversation = session.conversation( + input=microphone, + output=assistant, + stt=transcriber, + llm=response_model, + tts=synthesizer, + vad=speech_detector, +) +``` + +Each object implements the matching provider-neutral protocol: +`StreamingTranscriber`, `ResponseModel`, `SpeechSynthesizer`, or +`SpeechDetector`. + +Use a duplex model when one stateful provider accepts audio and produces audio: + +```python +conversation = session.conversation( + input=microphone, + output=assistant, + voice_model=voice_model, +) +``` + +The two forms are mutually exclusive. Declaration fails before capture starts +when required components are missing or their capabilities do not satisfy the +selected configuration. + +## Preserve transcript revisions + +`TranscriptUpdate` carries one utterance identity, a monotonic revision, +current text, stable prefix when the provider guarantees it, final state, audio +time, and source lineage. Partial text may be replaced. A final update commits +one `ConversationTurn`. + +PocketStation can prepare a response from stable partial text, but external +side effects still need an application commit barrier or idempotency policy. + +## Interrupt without stopping input + +When new speech begins, the conversation can cancel the active provider work +and the matching pending output while microphone capture, transcript input, +recording, and unrelated routes continue. Finite deadlines and retained-state +limits live in `ConversationConfig`. + +An interruption report separates: + +- provider task cancellation; +- Core output discarded before local delivery; +- Connector queue clearing when supported; +- receiver playout observation when supported; +- acoustic hearing, which may remain unavailable. + +Do not describe sender cancellation as complete audible interruption when the +receiver cannot acknowledge playout. + +## Run the current provider proof + +`examples/debug_voice_ai.py` uses the example-owned OpenAI Realtime adapter so +its provider code remains visible and replaceable. Install the optional extra, +export `OPENAI_API_KEY`, and follow the example instructions: + +```bash +python -m pip install 'pocketstation[voice-agent-debug]' +export OPENAI_API_KEY="..." +python examples/debug_voice_ai.py +``` + +The example records microphone, assistant output, and browser application +output separately. It does not claim AEC, exact loudspeaker playout, or +provider-history truncation. + +After the Session stops, inspect `ConversationOutcome`, retained `VoiceEvent` +values, Session route metrics, and the recording outcome. A successful provider +close does not replace those media and lifecycle checks. diff --git a/docs/operations/platform-support.md b/docs/operations/platform-support.md new file mode 100644 index 0000000..94f1914 --- /dev/null +++ b/docs/operations/platform-support.md @@ -0,0 +1,43 @@ +# Prepare and qualify each Python platform + +The wheel contains the native PocketStation engine. Python code remains the +same across platforms, while capture permissions and native audio mechanisms +follow the host operating system. + +## Supported Python + +PocketStation 0.1.0 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. + +## macOS + +Application capture needs screen and system-audio recording permission. +Microphone capture needs microphone permission. Restart the application after +changing consent when macOS does not update the running process. + +The release evidence includes an Apple-silicon installed wheel using a physical +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. + +## 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. + +## Separate correctness from performance + +An installed import and component test establish package correctness. A device +claim needs the physical device. A latency claim needs p50, p95, p99, and +maximum measurements from the same frame definition and clock boundary. + +The 10 ms profile sets PocketStation's frame cadence. It does not guarantee +sub-10 ms capture-to-Python, network, browser, or acoustic latency. WAN and TURN +remain outside the current release evidence. diff --git a/docs/reference/api-map.md b/docs/reference/api-map.md new file mode 100644 index 0000000..641d574 --- /dev/null +++ b/docs/reference/api-map.md @@ -0,0 +1,66 @@ +# Find the Python API for a task + +Use the package root for capture and Session lifecycle. Import advanced graph, +provider, Relay, and diagnostic contracts from the module that owns them. + +## Start and stop a Session + +| Task | API | +|---|---| +| Capture one application | `pocketstation.capture` | +| Declare a synchronous Session | `pocketstation.Session` | +| Declare an asyncio Session | `pocketstation.aio.Session` | +| Select a source | `pocketstation.Source` | +| Discover running sources | `pocketstation.discover_sources` | +| Feed application-owned PCM | `Session.audio_input` | + +`capture()` is the short path. Use `Session` when you need more than one +destination, custom route policies, provider composition, Relay, or detailed +observations. Both paths use the same Rust engine. + +## Compose and route media + +| Task | Module | +|---|---| +| Stems, ports, and routes | `pocketstation.graph` | +| Typed signals and subscriptions | `pocketstation.signal` | +| Source declarations and discovery | `pocketstation.sources` | +| Application-owned PCM | `pocketstation.audio_input` | +| Runtime events, metrics, and outcomes | `pocketstation.observations` | + +## Connect external systems + +| Task | Module | +|---|---| +| Publish through Relay | `pocketstation.relay` or `pocketstation.aio.relay` | +| Author an outbound Connector | `pocketstation.connector` | +| Author a computation | `pocketstation.operator_authoring` | +| Author an inbound Source | `pocketstation.source_authoring` | +| Author a lower-level Endpoint | `pocketstation.endpoint_authoring` | +| Run a managed process | `pocketstation.sidecar` | +| Load a trusted native extension | `pocketstation.extensions` | + +Provider callbacks run on bounded off-realtime workers. Native capture +callbacks never call Python. + +## Build a voice workflow + +`pocketstation.voice` contains the provider-neutral contracts. +`pocketstation.aio.Session.conversation()` composes either: + +- a `StreamingTranscriber`, `ResponseModel`, and `SpeechSynthesizer`, with an + optional `SpeechDetector`; or +- one `DuplexVoiceModel`. + +Provider implementations remain in example or separately installed provider +packages. Read [Compose a bounded voice workflow](../guides/voice.md) before +depending on interruption or playout observations. + +## Handle failures + +Start with `PocketStationError`, `CaptureError`, and `SessionError` from the +package root. Advanced modules expose errors for their own boundary. Preserve +the structured error and inspect the Session outcome before retrying. + +Continue with [Session ownership, bounds, and shutdown](../concepts/session-and-bounds.md) +or [troubleshooting](../troubleshooting.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..821dd36 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,58 @@ +# Troubleshoot capture, delivery, and shutdown + +Start with the boundary that did not produce the expected result. PocketStation +keeps source opening, bounded delivery, provider work, Relay delivery, +recording, and receiver playout as separate observations. + +## No application audio arrives + +1. Call `pocketstation.discover_sources()` and confirm the application is + present and producing audio. +2. Pass its display name or application identifier, or pass its process ID as + a positive integer. +3. Check operating-system capture permission for the process running Python. +4. Inspect Session events for permission, source-open, and source-unavailable + failures. + +Do not treat an empty iterator as proof of silence when source opening failed. + +## Python misses frames + +Inspect route capacity, queue depth, delivered frames, drops, and +discontinuities. The Python iterator is bounded; a consumer that does not read +on time can lose frames according to its route policy. + +Move expensive model work into an Operator or provider worker. Do not perform +inference in the loop that must keep the frame Endpoint drained. + +## Relay never becomes ready + +Confirm that the control-plane and Relay URLs refer to services you operate or +to the rate-limited demo deployment. Verify the declared `required_buses`, then +wait for publisher readiness before creating an invitation. + +If a receiver does not connect, keep publisher readiness and receiver readiness +as separate results. Neither result proves loudspeaker playout. + +## Generated speech continues after interruption + +Check each boundary separately: + +1. provider response cancellation; +2. Core pending-output cancellation; +3. Connector queue clearing, when supported; +4. receiver playout clearing and acknowledgement, when supported. + +Cancelling a Python task cannot recall audio already accepted by a transport or +receiver. Report receiver and acoustic state as unavailable when the receiver +does not expose it. + +## Shutdown does not complete + +Use finite provider and network deadlines. Close application-owned inputs when +no more frames will arrive. Request normal stop to drain accepted work, or +cancel when active asynchronous work must abort. Then inspect the `StopResult`, +provider outcome, and recording outcome for the boundary that did not join. + +When reporting an issue, include the PocketStation versions, operating system, +source selector, Session events, route metrics, and structured terminal error. diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py index d3e809b..34cb80a 100644 --- a/python/pocketstation/audio_input.py +++ b/python/pocketstation/audio_input.py @@ -150,7 +150,7 @@ def observations(self) -> AudioInputObservations: class AudioInput(PcmSource): - """Intent-first input for audio already owned by the embedding application.""" + """Feed application-owned audio into a Session through bounded native buffers.""" def write( self, diff --git a/python/pocketstation/session.py b/python/pocketstation/session.py index 99eba5e..6beb819 100644 --- a/python/pocketstation/session.py +++ b/python/pocketstation/session.py @@ -389,7 +389,7 @@ def destination( ) -> Endpoint: """Declare one Connector destination using an idempotent registration. - This is the intent-first form for the common one-destination case. + Use this form for the common one-destination case. :meth:`register_connector` remains available when one implementation must declare several independently configured Endpoints. """ diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index 7d20933..8e8cb8e 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -178,7 +178,7 @@ def _from_native( class CapturePermissionLifecycle: - """Canonical control-plane permission epoch owner. + """Track host-reported capture permission changes for one epoch. The host supplies authoritative platform observations. Equal observations produce no transition; PocketStation never converts generic backend errors diff --git a/tests/test_graph.py b/tests/test_graph.py index 095f6ae..993bc98 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,4 +1,4 @@ -"""Exact typed graph declarations owned by the canonical Rust Session.""" +"""Typed graph declarations owned by the Rust Session.""" from __future__ import annotations From e930e6ab11ccedb610386e5d5fe2d8fd377e89bd Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 11:00:20 -0400 Subject: [PATCH 39/49] Package the Python guides and examples --- README.md | 7 +++++++ examples/README.md | 7 +++++++ pyproject.toml | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index b4b293c..77d6c48 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ one monotonic timeline, so you can see whether delay occurred before the model, inside the provider, in local output, or after Relay delivery. See the [voice-agent debugger instructions](examples/README.md#debug-a-voice-agent-interruption-from-the-media-boundary). +Run repository examples from a source checkout or source archive. The installed +`pocketstation-demo` command is the packaged application-and-microphone demo. + +```bash +python -m pip install 'pocketstation[transcription]' +pocketstation-demo +``` ## Transcribe both sides of a voice application diff --git a/examples/README.md b/examples/README.md index 6a36b20..809ac43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,8 @@ # Python examples Each example is a complete Python program. Start with the task you want to try. +Run these files from a repository checkout or from the source archive. Use +`pocketstation-demo` when you want the installed command. ## Debug a voice-agent interruption from the media boundary @@ -96,5 +98,10 @@ The installed `pocketstation-demo` command combines independent application and microphone capture, faster-whisper transcripts, two Relay/browser AudioBuses, and a finalized two-stem recording. +```bash +python -m pip install 'pocketstation[transcription]' +pocketstation-demo +``` + The current physical voice proof runs the browser on the publisher host through the deployed Relay. WAN and TURN behavior have not been qualified yet. diff --git a/pyproject.toml b/pyproject.toml index 674f8fb..d0a817a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,3 +68,9 @@ python-source = "python" python-packages = ["pocketstation", "pocketstation_demo"] module-name = "pocketstation._native" exclude = ["**/__pycache__/**", "**/*.pyc", "**/*.pyo"] +include = [ + { path = "RELEASE_NOTES.md", format = "sdist" }, + { path = "docs/**/*.md", format = "sdist" }, + { path = "examples/*.py", format = "sdist" }, + { path = "examples/README.md", format = "sdist" }, +] From ab6e1db6cb3ec813e99dc4a23e2f362c8f6bb49c Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 11:19:42 -0400 Subject: [PATCH 40/49] Document application audio and Session outcomes --- README.md | 4 ++ docs/README.md | 2 + docs/guides/application-audio.md | 66 +++++++++++++++++++++++++++++ docs/guides/record-and-observe.md | 69 +++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 docs/guides/application-audio.md create mode 100644 docs/guides/record-and-observe.md diff --git a/README.md b/README.md index 77d6c48..7cf77ba 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,10 @@ uv run mypy python tests/qualification/typing_contract.py examples guidance. - [`docs/README.md`](docs/README.md) — task guides, concepts, operations, and API ownership. +- [Write application-owned audio](docs/guides/application-audio.md) — bounded + PCM input and selective output cancellation. +- [Record and observe a Session](docs/guides/record-and-observe.md) — multistem + outcomes, route metrics, and lifecycle events. - [`examples/README.md`](examples/README.md) — runnable examples and prerequisites. - `pocketstation.capture` — concise application and microphone capture. diff --git a/docs/README.md b/docs/README.md index d1010b5..3142969 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ need the lower-level API. ## Build 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) diff --git a/docs/guides/application-audio.md b/docs/guides/application-audio.md new file mode 100644 index 0000000..66249d2 --- /dev/null +++ b/docs/guides/application-audio.md @@ -0,0 +1,66 @@ +# Write application-owned audio into a Session + +Use `Session.audio_input()` when your application already has PCM. Common +examples include generated speech, decoded call audio, and media received from +another SDK. The input becomes a source-aware stem that can use the same +recording, polling, Operator, and Connector routes as captured audio. + +## Match the Session audio format + +This example declares a 48 kHz mono Session with 10 ms frames. Each write +contains exactly 480 interleaved `float32` samples: + +```python +from array import array + +import pocketstation + +session = pocketstation.Session( + recording_root="recordings", + frame_duration_ms=10, +) +assistant = session.audio_input( + "assistant", + frame_samples_per_channel=480, +) +assistant.output.record("assistant") + +with session.start(): + assistant.write(array("f", [0.0]) * 480) + assistant.close() +``` + +Declare routes before starting the Session. Call `close()` after the producer +has submitted its last frame so normal Session shutdown can drain accepted +audio. + +## Handle backpressure explicitly + +`write()` waits only until its finite `timeout_s` expires. Use `try_write()` +when the producer must receive an immediate `AudioInputFullError` instead. +Neither method adds an unbounded Python queue. + +Inspect `assistant.observations()` for accepted, full, invalid, discarded, and +cancelled-write counts. Choose whether the application retries, drops, or slows +its producer; PocketStation does not choose that policy silently. + +## Cancel replaceable output + +Generated speech may stop being relevant when a person interrupts it. Attach +those frames to one owned output, then cancel only that output: + +```python +generation = assistant.begin_output() +assistant.write(samples, generation=generation) +generation.cancel() +``` + +Core discards matching frames that are still in its bounded sender paths. +Microphone capture, recording, and unrelated outputs continue. + +This operation cannot recall audio already accepted by a remote service or +playback device. A Connector and receiver need their own clear operation and +playout acknowledgement before an application can claim audible cancellation. + +Continue with [multistem recording and observations](record-and-observe.md) or +[bounded voice composition](voice.md). diff --git a/docs/guides/record-and-observe.md b/docs/guides/record-and-observe.md new file mode 100644 index 0000000..b0ff515 --- /dev/null +++ b/docs/guides/record-and-observe.md @@ -0,0 +1,69 @@ +# Record stems and inspect Session delivery + +Recording and observations follow the same source-aware routes as model and +network destinations. Add recording before the Session starts, inspect live +metrics while it runs, and require a successful terminal outcome after it +stops. + +## Record independent stems + +```python +import pocketstation + +live = pocketstation.capture( + application="Zoom", + microphone=True, + record_to="recordings", +) + +with live: + for index, frame in enumerate(live.audio): + print(frame.source_id, frame.stem_id) + if index == 99: + break + +result = live.stop_result +if result is None or not result.success: + raise RuntimeError("PocketStation did not stop cleanly") +if result.recording is None or not result.recording.complete: + raise RuntimeError("PocketStation did not complete the recording") +``` + +The recording manifest and WAV files are written beneath the directory passed +to `record_to`. Application and microphone stems retain separate source, +stream, stem, timing, and discontinuity information. + +## Inspect live delivery + +Call `live.metrics()` while the context is active. `SessionMetrics` reports +finite source, route, polling, event, Operator, and reentry state. For each +route, inspect its declared capacity, current and peak depth, delivered frames, +drops, and latency fields where that route can measure them. + +Read `live.events` for permission, source, Endpoint, rollback, and terminal +lifecycle events. Metrics are snapshots; events explain changes between +snapshots. + +Unavailable measurements remain unavailable. A sender timestamp is not a +receiver playout timestamp, and a completed local recording is not proof that +a browser played the same sample. + +## Stop or cancel deliberately + +Leaving the context requests normal stop and drains accepted bounded work. +Call `cancel()` on the explicit `RunningSession` API when active provider or +sidecar work must abort. Both paths join Session-owned workers before returning +a terminal `StopResult`. + +After shutdown, inspect: + +- `StopResult.success` and structured errors; +- `RecordingOutcome.complete` and every stem outcome; +- frame drops and recorded discontinuities; and +- provider or Connector outcomes required by the workflow. + +A successful start establishes lifecycle readiness. It does not prove that a +source produced media or that every destination received it. + +Continue with [Session ownership and bounds](../concepts/session-and-bounds.md) +or [Relay publication](relay.md). From 76e6426c9450bd3e9a10bfbbeb708aa2eeebdb36 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 11:48:34 -0400 Subject: [PATCH 41/49] Build CI wheels before installing them --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d0a6eb..938058a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,8 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install "maturin>=1.9.4,<2.0" "pytest>=8.0" "pytest-asyncio>=0.23" "mypy>=1.15" "ruff>=0.11" "websockets>=17.0,<18" - maturin develop --release --locked --features conformance-fixtures + maturin build --release --locked --features conformance-fixtures --out dist-conformance + python -c "from pathlib import Path; import subprocess, sys; wheel = next(Path('dist-conformance').glob('*.whl')); subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--force-reinstall', str(wheel)])" - name: Rust formatting if: matrix.os == 'ubuntu-latest' && matrix.python == '3.11' From 4698c0411e2fdb10299b7732bde8cb8973e0e474 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 11:55:36 -0400 Subject: [PATCH 42/49] Lock published native dependencies --- native/Cargo.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/Cargo.lock b/native/Cargo.lock index 7e12df6..1d48b9c 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1414,6 +1414,8 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c320a8850b385affb4b6f985ea1b7db91a24692a48448f2b9ea8cb7e12245eef" dependencies = [ "alsa", "cc", @@ -1446,6 +1448,8 @@ dependencies = [ [[package]] name = "pocketstation-relay" version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5389b4483333c9dd7b68e25dc3d2b5790fa66f75864bb02267d9fed449c64b30" dependencies = [ "base64", "pocketstation", From e961145fe8d1d0f018de039caf84d9958cccbb2e Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 12:01:04 -0400 Subject: [PATCH 43/49] Support bundled Opus with CMake 4 --- .github/workflows/ci.yml | 3 +++ .github/workflows/release.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938058a..1e73c15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: permissions: contents: read +env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + jobs: test: name: ${{ matrix.os }} / Python ${{ matrix.python }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3560845..bf6c661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ name: release-python permissions: contents: read +env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + concurrency: group: pypi-publish cancel-in-progress: false From 38ace3476b729d414e8650b7dab2ddf18bd8ef3b Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 12:09:27 -0400 Subject: [PATCH 44/49] Make Python tests standalone --- tests/fixtures/native_extension_plugin.rs | 427 ++++++++++++++++++++++ tests/test_extensions.py | 9 +- tests/test_realtime_boundary.py | 6 - 3 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/native_extension_plugin.rs diff --git a/tests/fixtures/native_extension_plugin.rs b/tests/fixtures/native_extension_plugin.rs new file mode 100644 index 0000000..a9d5f76 --- /dev/null +++ b/tests/fixtures/native_extension_plugin.rs @@ -0,0 +1,427 @@ +#![allow(dead_code)] + +use std::ffi::c_void; +use std::fs::OpenOptions; +use std::io::Write; +use std::mem::size_of; + +const ABI_MAJOR: u16 = 1; +const ABI_MINOR: u16 = 2; +const STATUS_OK: u32 = 0; +const STATUS_INVALID_ARGUMENT: u32 = 10; +const KIND_SOURCE: u32 = 1; +const KIND_OPERATOR: u32 = 2; +const KIND_ENDPOINT: u32 = 3; +const PORT_INPUT: u32 = 1; +const PORT_OUTPUT: u32 = 2; +const END_OF_STREAM: u32 = 1; + +#[repr(C)] +#[derive(Clone, Copy)] +struct Status { + code: u32, + detail: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Utf8 { + data: *const u8, + len_bytes: u32, +} + +unsafe impl Sync for Utf8 {} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Descriptor { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + kind: u32, + revision: u32, + generation: u32, + port_count: u32, + extension_id: Utf8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Port { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + direction: u32, + required: u32, + name: Utf8, + signal_id: Utf8, + semantic_role: Utf8, + schema: Utf8, +} + +unsafe impl Sync for Port {} + +#[repr(C)] +struct SignalView { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + data: *const u8, + len_bytes: u32, + flags: u32, + observed_timestamp_ns: u64, + source_timestamp_ns: u64, + duration_ns: u64, + sequence_number: u64, +} + +#[repr(C)] +struct SignalBuffer { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + data: *mut u8, + capacity_bytes: u32, + len_bytes: u32, + flags: u32, + observed_timestamp_ns: u64, + source_timestamp_ns: u64, + duration_ns: u64, +} + +type Validate = Option Status>; +type Create = + Option Status>; +type Prepare = Option Status>; +type SourceNext = Option< + unsafe extern "C-unwind" fn(*mut c_void, u32, *mut SignalBuffer) -> Status, +>; +type OperatorProcess = Option< + unsafe extern "C-unwind" fn(*mut c_void, *const SignalView, *mut SignalBuffer) -> Status, +>; +type EndpointConsume = + Option Status>; +type Lifecycle = Option Status>; +type Destroy = Option; + +#[repr(C)] +#[derive(Clone, Copy)] +struct Callbacks { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + registration_context: *mut c_void, + max_payload_bytes: u32, + reserved: u32, + validate_configuration: Validate, + create: Create, + prepare: Prepare, + source_next: SourceNext, + operator_process: OperatorProcess, + endpoint_consume: EndpointConsume, + request_stop: Lifecycle, + finish: Lifecycle, + destroy_instance: Destroy, + destroy_registration: Destroy, +} + +type Acquire = Option< + unsafe extern "C-unwind" fn( + *mut c_void, + u32, + *mut Descriptor, + *mut *const Port, + *mut u32, + *mut Callbacks, + ) -> Status, +>; + +#[repr(C)] +struct ExtensionLibrary { + struct_size_bytes: u32, + abi_major: u16, + abi_minor: u16, + registration_count: u32, + reserved: u32, + library_context: *mut c_void, + acquire_registration: Acquire, +} + +struct RegistrationContext { + kind: u32, +} + +struct InstanceContext { + kind: u32, + emitted: bool, +} + +const fn utf8(bytes: &'static [u8]) -> Utf8 { + Utf8 { + data: bytes.as_ptr(), + len_bytes: bytes.len() as u32, + } +} + +const SOURCE_ID: &[u8] = b"dev.pocketstation.source.fixture.v1"; +const OPERATOR_ID: &[u8] = b"dev.pocketstation.fixture.operator.v1"; +const ENDPOINT_ID: &[u8] = b"dev.pocketstation.fixture.endpoint.v1"; +const SIGNAL_ID: &[u8] = b"dev.pocketstation.fixture.signal.v1"; +const SCHEMA: &[u8] = b"urn:pocketstation:fixture:native-extension:v1"; +const EMPTY: &[u8] = b""; + +static SOURCE_PORTS: [Port; 1] = [Port { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + direction: PORT_OUTPUT, + required: 1, + name: utf8(b"out"), + signal_id: utf8(SIGNAL_ID), + semantic_role: utf8(EMPTY), + schema: utf8(SCHEMA), +}]; + +static OPERATOR_PORTS: [Port; 2] = [ + Port { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + direction: PORT_INPUT, + required: 1, + name: utf8(b"in"), + signal_id: utf8(SIGNAL_ID), + semantic_role: utf8(EMPTY), + schema: utf8(SCHEMA), + }, + Port { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + direction: PORT_OUTPUT, + required: 1, + name: utf8(b"out"), + signal_id: utf8(SIGNAL_ID), + semantic_role: utf8(EMPTY), + schema: utf8(SCHEMA), + }, +]; + +static ENDPOINT_PORTS: [Port; 1] = [Port { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + direction: PORT_INPUT, + required: 1, + name: utf8(b"in"), + signal_id: utf8(SIGNAL_ID), + semantic_role: utf8(EMPTY), + schema: utf8(SCHEMA), +}]; + +fn status(code: u32) -> Status { + Status { code, detail: 0 } +} + +fn marker(line: &str) { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(env!("PKS_FIXTURE_MARKER")) + .expect("open fixture marker"); + writeln!(file, "{line}").expect("write fixture marker"); +} + +unsafe extern "C-unwind" fn validate(_context: *mut c_void, _configuration: Utf8) -> Status { + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn create( + registration_context: *mut c_void, + _configuration: Utf8, + output_instance: *mut *mut c_void, +) -> Status { + if registration_context.is_null() || output_instance.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: the host retains the registration context until destruction. + let registration = unsafe { &*(registration_context as *const RegistrationContext) }; + let instance = Box::new(InstanceContext { + kind: registration.kind, + emitted: false, + }); + // SAFETY: validated writable output; ownership transfers to the host. + unsafe { output_instance.write(Box::into_raw(instance).cast()) }; + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn prepare(_context: *mut c_void) -> Status { + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn source_next( + context: *mut c_void, + _cancelled: u32, + output: *mut SignalBuffer, +) -> Status { + if context.is_null() || output.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: host supplies the retained instance and writable output record. + let instance = unsafe { &mut *(context as *mut InstanceContext) }; + let output = unsafe { &mut *output }; + if instance.emitted { + output.flags = END_OF_STREAM; + output.len_bytes = 0; + return status(STATUS_OK); + } + let payload = b"hello"; + if output.capacity_bytes < payload.len() as u32 || output.data.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: the host declared at least payload.len() writable bytes. + unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), output.data, payload.len()) }; + output.len_bytes = payload.len() as u32; + instance.emitted = true; + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn operator_process( + _context: *mut c_void, + input: *const SignalView, + output: *mut SignalBuffer, +) -> Status { + if input.is_null() || output.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: both views are valid for the callback duration. + let input = unsafe { &*input }; + let output = unsafe { &mut *output }; + if input.len_bytes > output.capacity_bytes || (input.len_bytes != 0 && input.data.is_null()) { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: validated input and output lengths above. + unsafe { + std::ptr::copy_nonoverlapping(input.data, output.data, input.len_bytes as usize); + } + output.len_bytes = input.len_bytes; + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn endpoint_consume( + _context: *mut c_void, + input: *const SignalView, +) -> Status { + if input.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: input view and bytes remain readable for this call. + let input = unsafe { &*input }; + let bytes = unsafe { std::slice::from_raw_parts(input.data, input.len_bytes as usize) }; + marker(&format!("consume:{}", String::from_utf8_lossy(bytes))); + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn lifecycle(_context: *mut c_void) -> Status { + status(STATUS_OK) +} + +unsafe extern "C-unwind" fn destroy_instance(context: *mut c_void) { + if !context.is_null() { + // SAFETY: final exactly-once callback returns Box ownership. + let instance = unsafe { Box::from_raw(context as *mut InstanceContext) }; + marker(&format!("destroy_instance:{}", instance.kind)); + } +} + +unsafe extern "C-unwind" fn destroy_registration(context: *mut c_void) { + if !context.is_null() { + // SAFETY: final exactly-once callback returns Box ownership. + let registration = unsafe { Box::from_raw(context as *mut RegistrationContext) }; + marker(&format!("destroy_registration:{}", registration.kind)); + } +} + +fn callbacks(kind: u32) -> Callbacks { + Callbacks { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + registration_context: Box::into_raw(Box::new(RegistrationContext { kind })).cast(), + max_payload_bytes: 1_024, + reserved: 0, + validate_configuration: Some(validate), + create: Some(create), + prepare: Some(prepare), + source_next: (kind == KIND_SOURCE).then_some(source_next), + operator_process: (kind == KIND_OPERATOR).then_some(operator_process), + endpoint_consume: (kind == KIND_ENDPOINT).then_some(endpoint_consume), + request_stop: Some(lifecycle), + finish: Some(lifecycle), + destroy_instance: Some(destroy_instance), + destroy_registration: Some(destroy_registration), + } +} + +unsafe extern "C-unwind" fn acquire( + _library_context: *mut c_void, + index: u32, + output_descriptor: *mut Descriptor, + output_ports: *mut *const Port, + output_port_count: *mut u32, + output_callbacks: *mut Callbacks, +) -> Status { + if output_descriptor.is_null() + || output_ports.is_null() + || output_port_count.is_null() + || output_callbacks.is_null() + { + return status(STATUS_INVALID_ARGUMENT); + } + let (kind, id, ports): (u32, &[u8], &[Port]) = match index { + 0 => (KIND_SOURCE, SOURCE_ID, &SOURCE_PORTS), + 1 => (KIND_OPERATOR, OPERATOR_ID, &OPERATOR_PORTS), + 2 => (KIND_ENDPOINT, ENDPOINT_ID, &ENDPOINT_PORTS), + _ => return status(STATUS_INVALID_ARGUMENT), + }; + let descriptor = Descriptor { + struct_size_bytes: size_of::() as u32, + abi_major: ABI_MAJOR, + abi_minor: ABI_MINOR, + kind, + revision: if cfg!(invalid_registration) { 0 } else { 1 }, + generation: 1, + port_count: ports.len() as u32, + extension_id: utf8(id), + }; + // SAFETY: host supplied writable output records for this acquisition. + unsafe { + output_descriptor.write(descriptor); + output_ports.write(ports.as_ptr()); + output_port_count.write(ports.len() as u32); + output_callbacks.write(callbacks(kind)); + } + status(STATUS_OK) +} + +#[cfg(not(no_entrypoint))] +#[no_mangle] +unsafe extern "C-unwind" fn pks_extension_library_v1(output: *mut ExtensionLibrary) -> Status { + if output.is_null() { + return status(STATUS_INVALID_ARGUMENT); + } + // SAFETY: host supplies one writable current-version descriptor. + unsafe { + output.write(ExtensionLibrary { + struct_size_bytes: size_of::() as u32, + abi_major: if cfg!(unsupported_abi) { 99 } else { ABI_MAJOR }, + abi_minor: ABI_MINOR, + registration_count: if cfg!(invalid_registration) { 1 } else { 3 }, + reserved: 0, + library_context: std::ptr::null_mut(), + acquire_registration: Some(acquire), + }) + }; + status(STATUS_OK) +} + diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2578c60..a80b6ca 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -31,14 +31,7 @@ def native_extension_library( ) prefix = "" if sys.platform == "win32" else "lib" library = directory / f"{prefix}pks_python_fixture{suffix}" - source = ( - Path(__file__).resolve().parents[1] - / ".." - / "pocketstation" - / "tests" - / "fixtures" - / "native_extension_plugin.rs" - ).resolve() + source = Path(__file__).with_name("fixtures") / "native_extension_plugin.rs" environment = os.environ.copy() environment["PKS_FIXTURE_MARKER"] = str(marker) subprocess.run( diff --git a/tests/test_realtime_boundary.py b/tests/test_realtime_boundary.py index af2b3a9..a4b6e3d 100644 --- a/tests/test_realtime_boundary.py +++ b/tests/test_realtime_boundary.py @@ -10,7 +10,6 @@ from pocketstation._native import Session as NativeSession ROOT = Path(__file__).parents[1] -CORE = ROOT.parent / "pocketstation" CHILD = Path(__file__).with_name("_pkss_child.py") @@ -38,15 +37,10 @@ def session_with_hung_sidecar(tmp_path: Path) -> pks.RunningSession: def test_sidecar_binding_contains_no_python_callback_contract() -> None: sidecar_source = (ROOT / "native/src/sidecar.rs").read_text() - core_extension = (CORE / "src/abi/executable_extension.rs").read_text() assert "PyAny" not in sidecar_source assert "PyObject" not in sidecar_source assert "callable" not in sidecar_source.lower() - assert ( - "PCM audio remains on the native fixed-capacity realtime lane" in core_extension - ) - assert "blocking/async Session" in core_extension def test_blocking_sidecar_reap_detaches_from_python(tmp_path: Path) -> None: From 93f42e2862df041be1fbb993162d37c4cdc17be7 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 12:23:47 -0400 Subject: [PATCH 45/49] Make interruption qualification deterministic --- tests/conversation_support.py | 39 ++++++++++++++++++++++++ tests/test_conversation_interruptions.py | 9 ++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/conversation_support.py b/tests/conversation_support.py index 571ab0a..e153c79 100644 --- a/tests/conversation_support.py +++ b/tests/conversation_support.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterator +from threading import Event from pocketstation._api import ( MediaCaps, @@ -43,6 +44,44 @@ def emissions() -> Iterator[SourceEmission]: ) +def transcript_source_after( + first: str, + second: str, + *, + ready: Event, + timeout_s: float = 5, +) -> SourceProvider: + """Emit the second transcript after a finite external readiness gate.""" + + def emissions() -> Iterator[SourceEmission]: + yield SourceEmission.text( + "transcript", + first, + signal=TRANSCRIPT_SIGNAL, + ) + if not ready.wait(timeout_s): + raise TimeoutError("second transcript readiness gate timed out") + yield SourceEmission.text( + "transcript", + second, + signal=TRANSCRIPT_SIGNAL, + ) + + return SourceProvider.from_iterable( + SourceManifest( + "io.pocketstation.source.gated-conversation-test.v1", + outputs=( + PortSpec.output( + "transcript", + TRANSCRIPT_SIGNAL, + media=MediaCaps.text(), + ), + ), + ), + lambda _configuration: emissions(), + ) + + def transcript_operator() -> OperatorProvider: class PassTranscript(OperatorNode): def process( diff --git a/tests/test_conversation_interruptions.py b/tests/test_conversation_interruptions.py index b7aaf50..4210026 100644 --- a/tests/test_conversation_interruptions.py +++ b/tests/test_conversation_interruptions.py @@ -4,6 +4,7 @@ from array import array from collections.abc import AsyncIterator from pathlib import Path +from threading import Event import pocketstation.aio as pocketstation import pytest @@ -11,6 +12,7 @@ TRANSCRIPT_SIGNAL, transcript_operator, transcript_source, + transcript_source_after, ) from pocketstation.conversation import ( ConversationConfig, @@ -149,7 +151,10 @@ async def test_given_queued_output_when_interrupted_then_only_replacement_is_rea tmp_path: Path, ) -> None: session = pocketstation.Session(recording_root=tmp_path) - source = session.register_source(transcript_source("old", "new")).declare() + old_frame_queued = Event() + source = session.register_source( + transcript_source_after("old", "new", ready=old_frame_queued) + ).declare() operator = session.register_operator(transcript_operator()).declare() source.output("transcript").connect(operator.input("transcript")) transcripts = session.subscribe( @@ -162,8 +167,6 @@ async def test_given_queued_output_when_interrupted_then_only_replacement_is_rea frame_samples_per_channel=480, ) output.output.send(session.polled_audio()) - old_frame_queued = asyncio.Event() - async def respond( update: TranscriptUpdate, _context: ConversationContext, From 3200a9bd545ab1e8db4e55f84bdb02526886108d Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 12:33:20 -0400 Subject: [PATCH 46/49] Format deterministic interruption test --- tests/test_conversation_interruptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_conversation_interruptions.py b/tests/test_conversation_interruptions.py index 4210026..2d07a45 100644 --- a/tests/test_conversation_interruptions.py +++ b/tests/test_conversation_interruptions.py @@ -167,6 +167,7 @@ async def test_given_queued_output_when_interrupted_then_only_replacement_is_rea frame_samples_per_channel=480, ) output.output.send(session.polled_audio()) + async def respond( update: TranscriptUpdate, _context: ConversationContext, From 22d8986401da68af987c20a29d5ddd0392ba7a81 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 12:42:41 -0400 Subject: [PATCH 47/49] Pace transcription test at represented media time --- tests/test_transcription_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_transcription_example.py b/tests/test_transcription_example.py index 532f171..eef6b63 100644 --- a/tests/test_transcription_example.py +++ b/tests/test_transcription_example.py @@ -71,7 +71,7 @@ async def test_faster_whisper_is_the_concise_source_aware_python_path() -> None: try: for _ in range(10): await audio.write(array("f", [0.0] * 480)) - await asyncio.sleep(0.001) + await asyncio.sleep(0.01) envelope = await asyncio.wait_for( anext(running.signals(transcripts).__aiter__()), timeout=5.0, From 07d381d00968474245bb31a366fd5271eb3a2cbe Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 13:03:27 -0400 Subject: [PATCH 48/49] Respect Windows path and apartment semantics --- python/pocketstation/aio/sources.py | 4 ++-- tests/test_extensions.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/pocketstation/aio/sources.py b/python/pocketstation/aio/sources.py index ff5fc57..dc57b7c 100644 --- a/python/pocketstation/aio/sources.py +++ b/python/pocketstation/aio/sources.py @@ -31,8 +31,8 @@ async def application_capture_available() -> bool: async def microphone_permission_observation() -> PermissionObservation: - """Read non-prompting native microphone authorization off the event loop.""" - return await asyncio.to_thread(_microphone_permission_observation) + """Read the current microphone authorization without prompting.""" + return _microphone_permission_observation() __all__ = [ diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a80b6ca..6606409 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -171,7 +171,7 @@ def test_native_library_receipt_is_typed_and_immutable( library, _ = native_extension_library receipt = pks.Session().load_native_extension_library(library) - assert receipt.canonical_path == library.resolve() + assert receipt.canonical_path.samefile(library) assert receipt.registrations == ( pks.NativeExtensionRegistration(SOURCE_ID, pks.ExtensionKind.SOURCE, 1, 1), pks.NativeExtensionRegistration( @@ -232,7 +232,7 @@ def test_async_session_uses_the_same_native_library_declaration( library, _ = native_extension_library receipt = aio.Session().load_native_extension_library(library) - assert receipt.canonical_path == library.resolve() + assert receipt.canonical_path.samefile(library) assert [registration.kind for registration in receipt.registrations] == [ pks.ExtensionKind.SOURCE, pks.ExtensionKind.OPERATOR, From 147ac92ba7235b2e8c751cbbc2695c991ba6339d Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Mon, 31 Aug 2026 15:55:32 -0400 Subject: [PATCH 49/49] Fail closed for Windows permission observation --- RELEASE_NOTES.md | 3 +++ native/src/sources.rs | 8 ++++++++ python/pocketstation/sources.py | 6 +++--- tests/test_permissions.py | 9 +++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 566bef3..b3a20f3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -47,6 +47,9 @@ provider-history truncation as unavailable instead of inferring them. - Linux and Windows have Core application-selection and 10 ms capture evidence. Installed Python distributions are qualified separately by the release workflow. +- Python reports Windows microphone permission as `NOT_OBSERVABLE` before + capture. Opening the microphone remains authoritative and returns its real + 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 diff --git a/native/src/sources.rs b/native/src/sources.rs index d2c1fc6..fef7177 100644 --- a/native/src/sources.rs +++ b/native/src/sources.rs @@ -577,6 +577,14 @@ fn python_application_capture_available() -> bool { #[pyfunction(name = "microphone_permission_observation")] fn python_microphone_permission_observation() -> &'static str { + #[cfg(target_os = "windows")] + { + // Core 1.1.4 can invalidate cached WinRT state after a repeated + // non-prompting query in an embedded host. Until the corrected Core + // patch is published, fail closed instead of risking process failure. + return permission_observation_name(PermissionObservation::NotObservable); + } + #[cfg(not(target_os = "windows"))] permission_observation_name(pocketstation::microphone_permission_observation()) } diff --git a/python/pocketstation/sources.py b/python/pocketstation/sources.py index 8e8cb8e..82ecfa0 100644 --- a/python/pocketstation/sources.py +++ b/python/pocketstation/sources.py @@ -586,9 +586,9 @@ def application_capture_available() -> bool: def microphone_permission_observation() -> PermissionObservation: """Read microphone authorization without prompting. - Linux 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 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. """ observation = _native_call(_native_microphone_permission_observation) return PermissionObservation(observation) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 1213452..bea819b 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -65,6 +65,15 @@ def test_linux_truth_is_not_reinterpreted_as_allowed_or_denied() -> None: ) +def test_windows_binding_fails_closed_until_safe_core_query_is_available() -> None: + if sys.platform != "win32": + pytest.skip("Windows-specific platform contract") + assert ( + pocketstation.microphone_permission_observation() + is PermissionObservation.NOT_OBSERVABLE + ) + + @pytest.mark.asyncio async def test_async_permission_observation_shares_native_policy() -> None: assert (