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/ci.yml b/.github/workflows/ci.yml index 8e11319..1e73c15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,147 @@ -name: ci-python -on: [pull_request, push] +name: python-sdk-ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + 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 - 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 + - name: Check out Python SDK + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + path: sdk-python + + - name: Install Rust 1.95 + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.95.0 + components: clippy, rustfmt + + - name: Install Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + 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" "websockets>=17.0,<18" + 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' + 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 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 examples tests/qualification + + - 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: 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@ea165f8d65b6e75b540449e92b4886f43607fa02 + 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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + path: sdk-python + + - name: Install Rust 1.95 + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.95.0 + + - name: Install Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + 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@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: pocketstation-sdist + path: sdk-python/dist/*.tar.gz + 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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf6c661 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,194 @@ +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 + +env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + +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/.gitignore b/.gitignore index 22b7634..4dd4743 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,24 @@ __pycache__/ *.egg-info/ *.egg .eggs/ +*.so +*.pyd .pytest_cache/ .tox/ .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/ .mypy_cache/ .ruff_cache/ diff --git a/README.md b/README.md index 19055f3..7cf77ba 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,230 @@ -# PocketStation Python SDK +# PocketStation for Python -Use Python to connect to a PocketStation control plane and Relay, receive PCM -audio, and send application-owned PCM back over the same session. +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. -> **Status: preview.** This repository does not have a PyPI release. The API in -> `main` may change while the native PocketStation Session binding is completed. +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. -## Develop locally +## Capture a desktop application -You need Python 3.11 or newer. +Install PocketStation: ```bash -python -m venv .venv -source .venv/bin/activate -python -m pip install -e '.[dev]' -python -m pytest +python -m pip install pocketstation ``` -The current package uses HTTP for session creation and a WebSocket for binary -PCM. Configure the control-plane and Relay URLs in your application; the SDK -does not start hidden infrastructure or select a hosted service for you. +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 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 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). +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 + +Install the transcription extra, then run the example: + +```bash +python -m pip install 'pocketstation[transcription]' +python examples/transcribe_voice_app.py +``` + +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 ─┐ + ├─ one bounded faster-whisper Operator ─ transcripts +physical microphone┘ +``` + +The complete composition is visible in +[`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. + +## Stream any application audio to a browser + +Run the Relay example when you want another person to listen in a browser: + +```bash +python examples/stream_any_app_audio.py +``` + +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. + +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. + +## Read application and microphone audio + +Set the optional microphone and recording parameters when the workflow needs +both sides: + +```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) +``` + +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. + +## Send application-owned audio into a Session + +Use `audio_input()` when your application already owns PCM, such as generated +speech or audio received from a call provider: + +```python +session = pocketstation.Session(recording_root="recordings") +agent = session.audio_input("agent-output") +agent.output.record("agent") + +with session.start(): + agent.write(samples) +``` + +The input uses finite preallocated Core buffers. Writes report full, closed, +cancelled, and invalid-buffer outcomes explicitly. + +## Build an integration + +PocketStation uses four open boundaries: + +| 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. | + +Import an authoring contract from the module that owns that boundary: + +```python +from pocketstation.connector import Connector, ConnectorManifest +from pocketstation.operator_authoring import OperatorProvider +from pocketstation.source_authoring import SourceProvider +``` + +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 +for native provider code, and process sidecars remain available when crash +isolation is required. + +## 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 +service forwards WebRTC audio. Python does not encode Opus, write RTP, or own a +second media plane. + +The control client uses finite request deadlines, bounded response bodies, +redacted secrets, and matching synchronous and asyncio APIs. + +## Sync and asyncio + +`pocketstation` and `pocketstation.aio` operate the same native Session. The +asyncio namespace provides awaitable lifecycle, stream, Relay, provider, and +audio-input operations without creating another audio queue. + +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. + +## Platform support + +| Area | Support | +|---|---| +| 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. + +## Develop the SDK + +```bash +uv sync --extra transcription +uv run pytest -q +uv run ruff check python tests examples +uv run ruff format --check python tests examples +uv run mypy python tests/qualification/typing_contract.py examples +``` + +## Reference + +- [`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. +- [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. +- `pocketstation.session` — Session declarations and lifecycle. +- `pocketstation.graph` — stems, ports, routes, and signal contracts. +- `pocketstation.connector` — outbound provider authoring. +- `pocketstation.operator_authoring` — computation authoring. +- `pocketstation.source_authoring` — inbound provider authoring. +- `pocketstation.aio` — asyncio projection of the same engine. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..b3a20f3 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,61 @@ +# PocketStation for Python release notes + +## 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; +- 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. + +### 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 + multistem recording. +- 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 + +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 new file mode 100644 index 0000000..3142969 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# 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](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 + +- [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) + +## 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) — 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/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/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/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). 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/examples/README.md b/examples/README.md new file mode 100644 index 0000000..809ac43 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,107 @@ +# 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 + +[`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]' +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, 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 +`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 +the person said into the microphone. PocketStation keeps both sources separate +while one faster-whisper Operator transcribes them. + +```bash +python examples/transcribe_voice_app.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 +python -m pip install 'pocketstation[transcription]' +``` + +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 +``` + +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 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. + +```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/examples/debug_voice_ai.py b/examples/debug_voice_ai.py new file mode 100644 index 0000000..a1a1e81 --- /dev/null +++ b/examples/debug_voice_ai.py @@ -0,0 +1,50 @@ +import asyncio +import os +import webbrowser +from array import array + +import pocketstation.aio as pks +from pocketstation import Source +from pocketstation_demo import demo_relay_session +from pocketstation_demo.openai_realtime import OpenAIRealtime + + +async def main() -> None: + 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(app)) + mic = session.capture(Source.microphone_default()) + assistant = session.audio_input("assistant") + 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") + 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: + 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.wait() + finally: + await voice.aclose(abort=True) + model.print_report() + + +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..4a5bbf9 --- /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_demo 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/examples/transcribe_voice_app.py b/examples/transcribe_voice_app.py new file mode 100644 index 0000000..cc045b1 --- /dev/null +++ b/examples/transcribe_voice_app.py @@ -0,0 +1,23 @@ +"""Transcribe a desktop voice application and microphone as separate stems.""" + +import asyncio + +import pocketstation.aio as pks +from pocketstation_demo 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/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..1d48b9c --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,2816 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dimpl" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afb6878ee6941d3ee770bd8a391c0c083ee2102a7e8e91a730fb722ef1e46b9" +dependencies = [ + "aes", + "arrayvec", + "aws-lc-rs", + "ccm", + "der", + "log", + "nom 8.0.0", + "once_cell", + "pkcs8", + "rand", + "rcgen", + "sec1", + "signature", + "spki", + "subtle", + "time", + "x509-cert", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840878b6e30d40e5bda1a7116100f1a18b7bdb91814513b87be80bbfb5d41879" +dependencies = [ + "crc", + "serde", + "str0m-proto", + "subtle", + "tracing", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pocketstation" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c320a8850b385affb4b6f985ea1b7db91a24692a48448f2b9ea8cb7e12245eef" +dependencies = [ + "alsa", + "cc", + "cpal", + "hound", + "libc", + "libloading", + "opus", + "pipewire", + "rtrb", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasapi", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "pocketstation-python" +version = "0.1.0" +dependencies = [ + "pocketstation", + "pocketstation-relay", + "pyo3", + "tempfile", +] + +[[package]] +name = "pocketstation-relay" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5389b4483333c9dd7b68e25dc3d2b5790fa66f75864bb02267d9fed449c64b30" +dependencies = [ + "base64", + "pocketstation", + "serde", + "serde_json", + "str0m", + "thiserror 1.0.69", + "tungstenite", + "url", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "aws-lc-rs", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtrb" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "sctp-proto" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8423ea59db998985015bc5d0145837eab48f60ec449a2dc01f5870499afe0a4" +dependencies = [ + "bytes", + "crc", + "log", + "rand", + "rustc-hash", + "slab", + "thiserror 2.0.20", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "str0m" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca05746700d3621a27d7b99beaf2e724f8940608cbab00c3e4ebd974620668af" +dependencies = [ + "arrayvec", + "base64ct", + "combine", + "dimpl", + "fastrand", + "is", + "sctp-proto", + "serde", + "str0m-aws-lc-rs", + "str0m-proto", + "subtle", + "time", + "tracing", +] + +[[package]] +name = "str0m-aws-lc-rs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1908a6439b68fd22c275d44cbf4b50b2a75cc45f2b626160d87a194023c18fcd" +dependencies = [ + "aws-lc-rs", + "dimpl", + "str0m-proto", + "time", +] + +[[package]] +name = "str0m-proto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02836118cf7384413d7e8beb8b9ab56a4007d1b6514c11df35150a2e7aef8f1a" +dependencies = [ + "base64ct", + "dimpl", + "fastrand", + "serde", + "subtle", + "time", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 2.0.20", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasapi" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80c3aa5d6b0e7acc3ea10cb19c334df0c8d825060f14a30d9e3b03385e6e5175" +dependencies = [ + "log", + "num-integer", + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..236da5b --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pocketstation-python" +version = "0.1.0" +edition = "2021" +publish = false +description = "Native PocketStation runtime bindings for the Python SDK" +license = "MIT" +repository = "https://github.com/pocketstation-io/sdk-python" + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[features] +default = [] +conformance-fixtures = ["pocketstation/conformance-fixtures"] + +[dependencies] +pocketstation = "=1.1.4" +pocketstation-relay = "=0.1.2" +pyo3 = { version = "0.27", features = ["abi3-py311"] } + +[dev-dependencies] +pocketstation = { version = "=1.1.4", features = ["conformance-fixtures"] } +tempfile = "3" diff --git a/native/build.rs b/native/build.rs new file mode 100644 index 0000000..26c272a --- /dev/null +++ b/native/build.rs @@ -0,0 +1,73 @@ +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=PYO3_PYTHON"); + println!("cargo:rerun-if-env-changed=VIRTUAL_ENV"); + println!("cargo:rerun-if-env-changed=CONDA_PREFIX"); + + if env::var_os("CARGO_CFG_TARGET_OS").as_deref() != Some("macos".as_ref()) { + return; + } + + let Some(python) = selected_python() else { + return; + }; + let Ok(output) = Command::new(python) + .args([ + "-c", + "import sysconfig; print(sysconfig.get_config_var('LIBDIR') or '')", + ]) + .output() + else { + return; + }; + if !output.status.success() { + return; + } + + let Ok(directory) = String::from_utf8(output.stdout) else { + return; + }; + let directory = PathBuf::from(directory.trim()); + if directory.as_os_str().is_empty() || !contains_python_dylib(&directory) { + return; + } + + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", directory.display()); +} + +fn selected_python() -> 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..4c911fc --- /dev/null +++ b/native/src/audio_input.rs @@ -0,0 +1,289 @@ +use std::sync::Mutex; + +use pocketstation::{ + AudioInput, AudioInputConfig, AudioInputObservations, AudioInputWriteError, + AudioInputWriteErrorKind, AudioOutputWriteError, AudioOutputWriteErrorKind, OutputGeneration, +}; +use pyo3::buffer::PyBuffer; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +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, + discarded_output_frames_total: u64, + cancelled_output_writes_total: u64, +} + +#[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 discarded_output_frames_total(&self) -> u64 { + self.discarded_output_frames_total + } + + #[getter] + fn cancelled_output_writes_total(&self) -> u64 { + self.cancelled_output_writes_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(), + }) + }) + } + + 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( + "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(); + } + if let Some(generation) = generation.as_deref() { + 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) + } + }) + } + + fn close(&self) -> PyResult<()> { + self.with_input(|input| { + input.close(); + Ok(()) + }) + } + + fn observations(&self) -> PyResult { + 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(), + }) + }) + } +} + +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 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)) +} + +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::()?; + module.add_class::()?; + Ok(()) +} diff --git a/native/src/connector/driver.rs b/native/src/connector/driver.rs new file mode 100644 index 0000000..721f1d4 --- /dev/null +++ b/native/src/connector/driver.rs @@ -0,0 +1,636 @@ +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(coded_reason( + "connector.observations_unavailable", + 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(coded_reason( + "connector.observation_lookup_failed", + error.to_string(), + )) + })? + .map(|handle| { + handle + .snapshot() + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "connector.observation_unavailable", + 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(coded_reason( + "connector.invalid_contract", + error.to_string(), + )) + })?; + session + .register_connector(connector) + .map(|registered| PythonRegisteredConnector { registered }) + .map_err(|error| { + PyValueError::new_err(coded_reason( + "connector.registration_failed", + 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(coded_reason( + "connector.declaration_failed", + 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..095a925 --- /dev/null +++ b/native/src/connector/worker.rs @@ -0,0 +1,580 @@ +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::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}; + +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, + Some(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(coded_reason( + "connector.invalid_contract", + 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(coded_reason( + "connector.invalid_contract", + error.to_string(), + )) + })?; + session + .register_connector(connector) + .map(|registered| PythonRegisteredConnector { registered }) + .map_err(|error| { + PyRuntimeError::new_err(coded_reason( + "connector.registration_failed", + error.to_string(), + )) + }) +} 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/errors.rs b/native/src/errors.rs new file mode 100644 index 0000000..8c1c49e --- /dev/null +++ b/native/src/errors.rs @@ -0,0 +1,131 @@ +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 { + 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. +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..110933e --- /dev/null +++ b/native/src/graph.rs @@ -0,0 +1,1363 @@ +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 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) + } +} + +#[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..d5142db --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,38 @@ +#![allow(clippy::redundant_pub_crate)] + +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; +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; + +use pyo3::prelude::*; + +#[pymodule] +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)?; + 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..30258b1 --- /dev/null +++ b/native/src/observations.rs @@ -0,0 +1,2385 @@ +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)] + session_id: u64, + #[pyo3(get)] + group_id: String, + #[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)] + manifest_path: String, + #[pyo3(get)] + manifest_schema_version: u32, + #[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)] + error_code: Option, + #[pyo3(get)] + retryability: Option, + #[pyo3(get)] + component: Option, + #[pyo3(get)] + component_kind: 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, + #[pyo3(get)] + discarded_output_frames_total: Option, +} + +#[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_discarded_output_frames_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)] + discarded_output_frames_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 = "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, +} + +#[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 records(&self) -> Vec { + self.trace + .records() + .iter() + .copied() + .map(PythonSessionTraceRecord::from) + .collect() + } + + 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 { + 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, + 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, + error_code: Option, + retryability: Option, + component: Option, + component_kind: 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_discarded_output_frames_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, + discarded_output_frames_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, + 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, + 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_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, + 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(), + )); + } + 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(), + ) + })); + 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, + 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()), + 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() + } +} + +fn owned_control_failure( + kind: &str, + stage: Option, + failure: &pocketstation::SessionControlFailure, +) -> 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( + 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, + 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, + 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, + discarded_output_frames_total: None, + } + } +} + +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, + } + } +} + +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", + 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, + 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, + 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_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, + 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 { + 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 + .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, + }) +} + +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 { + 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, + }, + ) +} + +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::()?; + 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/operator_authoring/driver.rs b/native/src/operator_authoring/driver.rs new file mode 100644 index 0000000..3e73b3c --- /dev/null +++ b/native/src/operator_authoring/driver.rs @@ -0,0 +1,533 @@ +use std::sync::Arc; + +use pocketstation::graph::NodeConfig; +use pocketstation::{ + 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, PythonOperatorPayload}; +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<()> { + 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( + "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, + audio_output: Option, +} + +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, + audio_output: self.audio_output.map(OperatorAudioOutput::new), + }) as Box) + }) + } +} + +struct PythonOperatorNode { + node: Py, + operator_id: OperatorId, + 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 { + 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()))?; + 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( + payload, + emission.signal, + timing.observed_timestamp_ns(), + ) + .with_lineage(lineage, timing) + .with_derivation(derivation)) + }) + .collect() + } +} + +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, +) -> 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..f646001 --- /dev/null +++ b/native/src/operator_authoring/values.rs @@ -0,0 +1,229 @@ +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::*; + +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 { + Audio(Arc<[f32]>), + Text(String), + Bytes(Vec), +} + +impl PythonOperatorPayload { + pub(super) fn into_non_audio_core(self) -> Option { + match self { + Self::Audio(_) => None, + Self::Text(value) => Some(SignalPayload::Text(value)), + Self::Bytes(value) => Some(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 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) + } + + #[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::Audio(_) => matches!( + signal.value.class(), + pocketstation::SignalClass::Any | pocketstation::SignalClass::PcmAudio + ), + 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/relay.rs b/native/src/relay.rs new file mode 100644 index 0000000..f3784f8 --- /dev/null +++ b/native/src/relay.rs @@ -0,0 +1,141 @@ +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)] + cancelled_output_frames_total: u64, + #[pyo3(get)] + cancelled_output_samples_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) cancelled_output_frames_total: u64, + pub(crate) cancelled_output_samples_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, + cancelled_output_frames_total: 0, + cancelled_output_samples_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, + cancelled_output_frames_total: result.statistics.cancelled_output_frames_total, + cancelled_output_samples_total: result + .statistics + .cancelled_output_samples_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, + 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, + }, + ) +} + +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..a8014ca --- /dev/null +++ b/native/src/session.rs @@ -0,0 +1,1436 @@ +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::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, +}; +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::operator_authoring::{register_operator, PythonOperatorManifest}; +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::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, + request_audio_batch_wait, OwnedAudioFrame, PythonAudioBatch, +}; + +pub(crate) enum SessionCommand { + PollAudio { + response: SyncSender>, String>>, + }, + WaitAudio { + timeout: Duration, + response: SyncSender>, String>>, + }, + LifecycleState { + response: SyncSender<&'static str>, + }, + 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, 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( + "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 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); + } + 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_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 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, + 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, + configuration: &PythonConnectorConfiguration, + edge: &PythonEdgeContract, + ) -> PyResult { + 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| { + 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, + terminal_state: Mutex>, +} + +#[pymethods] +impl PythonRunningSession { + #[getter] + const fn session_id(&self) -> u64 { + 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))?; + 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 = 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)) + .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 = 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)) + .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 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<'_>, + 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, + terminal_state: Mutex::new(None), + }) + } +} + +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::LifecycleState { response } => { + let _ = response.send(core_lifecycle_state_name(running.state())); + } + 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 { + lifecycle_state: core_lifecycle_state_name(running.state()), + 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 { + lifecycle_state: core_lifecycle_state_name(running.state()), + 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(); +} + +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 + .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..cce73c7 --- /dev/null +++ b/native/src/signals.rs @@ -0,0 +1,1004 @@ +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, +} + +#[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, + 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(), + } +} + +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()), + 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, + }, + ) +} + +pub(crate) 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/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/native/src/sources.rs b/native/src/sources.rs new file mode 100644 index 0000000..fef7177 --- /dev/null +++ b/native/src/sources.rs @@ -0,0 +1,692 @@ +use pocketstation::{ + 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}; + +#[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 { + source: CaptureSource, + #[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, +} + +#[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] + 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", + } +} + +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) { + ( + 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 { + source: source.clone(), + 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, + } +} + +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), + "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 { + #[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()) +} + +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, + 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 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()); + 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..698fc0c --- /dev/null +++ b/native/src/streams.rs @@ -0,0 +1,399 @@ +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::time::Duration; + +use pocketstation::PolledAudioPollError; +use pyo3::exceptions::{PyIndexError, PyRuntimeError}; +use pyo3::prelude::*; +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, + 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, + sequence_number: 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)] + output_generation_id: Option, + #[pyo3(get)] + endpoint_id: u64, + #[pyo3(get)] + 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] +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_number, + self.timestamp_start_ns, + self.sample_count, + self.sample_rate_hz, + self.channel_count, + self.discontinuity_epoch, + ) + } + + /// 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()) + } + + /// 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_number + } + + #[getter] + fn clock(&self) -> PythonClockDomainDescriptor { + clock_domain_descriptor(pocketstation::ClockDomainId::new(self.clock_id)) + } +} + +#[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_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) output_generation_id: Option, + pub(crate) endpoint_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( + 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()), + }; + 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 + .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_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(), + 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(), + 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(frames) +} + +pub(crate) fn copy_audio_batch_until( + running: &pocketstation::RunningSession, + timeout: Duration, +) -> Result>, String> { + match running + .wait_audio(timeout) + .map_err(|error| error.to_string())? + { + Some(batch) => copy_polled_audio_batch(batch).map(Some), + None => 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, 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(pocketstation::ConnectorId::get), + input.route_id().get(), + ) +} + +pub(crate) fn owned_endpoint_audio_frame_for_route( + frame: pocketstation::EndpointAudioFrame, + endpoint_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()), + 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_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(), + output_generation_id: frame.output_generation_id().map(|id| id.get()), + endpoint_id, + connector_id, + route_id, + route_enqueued_at_ns, + route_received_at_ns, + endpoint_enqueued_at_ns: None, + polled_at_ns: None, + } +} + +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_number: frame.sequence_number, + 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, + output_generation_id: frame.output_generation_id, + 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, + } +} + +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::()?; + module.add_class::()?; + Ok(()) +} diff --git a/pocketstation/__init__.py b/pocketstation/__init__.py deleted file mode 100644 index bc204f1..0000000 --- a/pocketstation/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""PocketStation Python SDK.""" -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 3bfc0b2..0000000 --- a/pocketstation/station.py +++ /dev/null @@ -1,199 +0,0 @@ -"""PocketStation session API. - -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. - -This preview uses a WebSocket transport and does not implement WebRTC. -""" -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: - """Receive and send PCM through one PocketStation session. - - 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). - :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 18d440f..0000000 --- a/pocketstation/types.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Public values returned by the PocketStation Python SDK.""" -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.""" - 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. - 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 returned by the control plane.""" - 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..d0a817a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,76 @@ +[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" } +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", - "websockets>=12.0", ] +[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" + [project.optional-dependencies] +transcription = [ + "faster-whisper>=1.2.1,<2.0", +] +voice-agent-debug = [ + "websockets>=17.0,<18", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", - "respx>=0.21", + "mypy>=1.15", + "ruff>=0.11", ] [tool.pytest.ini_options] asyncio_mode = "auto" +pythonpath = ["."] + +[tool.mypy] +packages = ["pocketstation", "pocketstation_demo"] +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" +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" }, +] diff --git a/python/pocketstation/__init__.py b/python/pocketstation/__init__.py new file mode 100644 index 0000000..699fb05 --- /dev/null +++ b/python/pocketstation/__init__.py @@ -0,0 +1,37 @@ +"""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, PcmSource +from .capture import Capture, capture +from .compatibility import RUNTIME_COMPATIBILITY, RuntimeCompatibility +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", + "AudioInput", + "AudioInputConfig", + "Capture", + "CaptureError", + "PcmSource", + "PocketStationError", + "RecordingOutcome", + "RunningSession", + "RuntimeCompatibility", + "Session", + "SessionError", + "Source", + "StopResult", + "aio", + "capture", + "discover_sources", +] diff --git a/python/pocketstation/_api.py b/python/pocketstation/_api.py new file mode 100644 index 0000000..dc1af6a --- /dev/null +++ b/python/pocketstation/_api.py @@ -0,0 +1,617 @@ +"""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, + EventInputClosedError, + EventInputError, + EventInputFullError, + 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", + "EventInputClosedError", + "EventInputError", + "EventInputFullError", + "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/_native.pyi b/python/pocketstation/_native.pyi new file mode 100644 index 0000000..44163bb --- /dev/null +++ b/python/pocketstation/_native.pyi @@ -0,0 +1,1354 @@ +"""Static interface for the PyO3 extension.""" + +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 + 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: SourceId + 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 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", + 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 negotiate(self, other: _MediaCaps) -> _MediaCaps | None: ... + 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: RuntimeSessionId + route_id: RouteId + 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: RuntimeSessionId + stream_id: StreamId + source_id: SourceId + clock_id: ClockDomainId + clock: ClockDomainDescriptor + 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: ConnectorId | None + +class _SignalAudioPayload: + samples: memoryview + samples_f32le: bytes + sample_count: int + sample_rate_hz: int + channel_count: int + stream_id: StreamId + source_id: SourceId + 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 _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: EndpointId + session_id: RuntimeSessionId + connector_id: ConnectorId | None + +class RelayPublisher: ... + +class OperatorInput: + port_name: str + +class OperatorInstance: + 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) -> 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, + 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) -> RouteId: ... + session_id: RuntimeSessionId + +class DerivedStream: + session_id: RuntimeSessionId + operator_instance_id: OperatorInstanceId + output_port: str | None + def output(self, port_name: str) -> DerivedStream: ... + def connect(self, input: OperatorInput) -> RouteId: ... + 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) -> RouteId: ... + def send_to(self, endpoint: Endpoint, input_port: str | None) -> RouteId: ... + def reenter_audio(self) -> Stem: ... + +class SourceInstance: + session_id: RuntimeSessionId + instance_id: SourceInstanceId + source_id: SourceId + def output(self, port_name: str) -> SourceOutput: ... + +class SourceOutput: + session_id: RuntimeSessionId + source_instance_id: SourceInstanceId + source_id: SourceId + stream_id: StreamId + output_port: str + def connect(self, input: OperatorInput) -> RouteId: ... + 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) -> 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) -> 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: 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 + output_generation_id: int | None + 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 + 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: + 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]: ... + +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 + cancelled_output_frames_total: int + cancelled_output_samples_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 + 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 + 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 + discarded_output_frames_total: int | None + +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 + discarded_output_frames_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_discarded_output_frames_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 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: + 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 + discarded_output_frames_total: int + cancelled_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: ... + +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: StreamId + +class _SourcePrepareContext: + source_type_id: str + session_id: RuntimeSessionId | None + source_id: SourceId | 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 audio(payload: object, signal: _SignalSpec) -> _OperatorEmission: ... + @staticmethod + def text(payload: str, signal: _SignalSpec) -> _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 + 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, + *, + recording_root: Path | None = None, + trace_path: Path | None = None, + trace_capacity_records: int = 256, + sample_rate_hz: int = 48_000, + channels: int = 1, + frame_duration_ms: int = 20, + ) -> 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 register_connector( + self, + manifest: _ConnectorManifest, + factory: object, + ) -> _RegisteredConnector: ... + def register_connector_worker( + self, + manifest: _ConnectorManifest, + factory: object, + maximum_batch_items: int, + ) -> _RegisteredConnector: ... + def register_endpoint_provider( + self, + manifest: _EndpointManifest, + factory: object, + ) -> _RegisteredEndpoint: ... + 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, + 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, + ) -> _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 + 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: ... + 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..7d2cf62 --- /dev/null +++ b/python/pocketstation/aio/__init__.py @@ -0,0 +1,20 @@ +"""PocketStation's concise asyncio entry point.""" + +from __future__ import annotations + +from .audio_input import AudioInput, PcmSource +from .capture import Capture, capture +from .relay import RelaySession +from .session import RunningSession, Session +from .sources import discover_sources + +__all__ = [ + "AudioInput", + "Capture", + "PcmSource", + "RelaySession", + "RunningSession", + "Session", + "capture", + "discover_sources", +] diff --git a/python/pocketstation/aio/_api.py b/python/pocketstation/aio/_api.py new file mode 100644 index 0000000..01a3c4d --- /dev/null +++ b/python/pocketstation/aio/_api.py @@ -0,0 +1,226 @@ +"""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 .event_input import EventInput, EventInputObservations +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", + "EventInput", + "EventInputObservations", + "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 new file mode 100644 index 0000000..b51921d --- /dev/null +++ b/python/pocketstation/aio/audio_input.py @@ -0,0 +1,108 @@ +"""Asyncio projection of bounded application-owned PCM input.""" + +from __future__ import annotations + +import asyncio +from time import monotonic + +from ..audio_input import ( + AudioInputConfig, + AudioInputObservations, + OutputGeneration, +) +from ..audio_input import ( + PcmSource as SyncPcmSource, +) +from ..errors import AudioInputFullError +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 + + 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. + + 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, + generation=generation, + ) + + async def close(self) -> None: + """Close the native input immediately after its accepted frames drain.""" + self._source.close() + + async def observations(self) -> AudioInputObservations: + """Read one immediate point-in-time snapshot from Core.""" + return self._source.observations() + + +class AudioInput(PcmSource): + """Write application-owned PCM to a bounded native Source with asyncio.""" + + async def write( + self, + 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.""" + 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: + await self.try_write( + samples, + discontinuity=discontinuity, + generation=generation, + ) + return + except AudioInputFullError: + remaining = deadline - monotonic() + if remaining <= 0: + raise + await asyncio.sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) + + +__all__ = ["AudioInput", "PcmSource"] diff --git a/python/pocketstation/aio/capture.py b/python/pocketstation/aio/capture.py new file mode 100644 index 0000000..c658b7a --- /dev/null +++ b/python/pocketstation/aio/capture.py @@ -0,0 +1,205 @@ +"""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 typing import TypeVar + +from .._native import AudioBatch +from ..graph import Stem +from ..observations import ( + RecordingOutcome, + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from ..signal import BusSubscription +from ..sources import Source, _capture_application +from .observations import EventStream +from .session import RunningSession, Session +from .streams import AudioStream, SignalStream + +_PayloadT = TypeVar("_PayloadT") + + +class Capture: + """One application and optional microphone captured as independent stems.""" + + def __init__( + self, + *, + application: str | int, + microphone: bool | str = False, + record_to: str | Path | None = None, + stream_audio: bool = True, + trace: SessionTraceConfiguration | None = None, + ) -> None: + 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(): + 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._stream_audio = stream_audio + 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(_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)) + + 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: + microphone.record("microphone") + + self.session = session + 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 + + @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 + + 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") + 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 | int, + microphone: bool | str = False, + record_to: str | Path | None = None, + stream_audio: bool = True, + trace: SessionTraceConfiguration | None = None, +) -> Capture: + """Capture one application and optionally add a microphone.""" + return Capture( + application=application, + microphone=microphone, + record_to=record_to, + stream_audio=stream_audio, + trace=trace, + ) + + +__all__ = ["Capture", "capture"] diff --git a/python/pocketstation/aio/connector.py b/python/pocketstation/aio/connector.py new file mode 100644 index 0000000..f7de346 --- /dev/null +++ b/python/pocketstation/aio/connector.py @@ -0,0 +1,613 @@ +"""Author bounded Python Connectors with asyncio lifecycle methods.""" + +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 .._native import AudioFrame +from ..connector import ( + Connector as SyncConnector, +) +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 ( + 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], +] +AudioConnectorHandler: TypeAlias = Callable[ + [AudioFrame, 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 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, + 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__ = [ + "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", +] diff --git a/python/pocketstation/aio/control.py b/python/pocketstation/aio/control.py new file mode 100644 index 0000000..55e368a --- /dev/null +++ b/python/pocketstation/aio/control.py @@ -0,0 +1,264 @@ +"""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, + Invitation, + SecretToken, + SessionCredentials, + SessionId, + SessionSnapshot, + SubscriberCredentials, + _bus_id, + _bus_ids, + _invitation, + _normalize_base_url, + _resolve_timeout, + _session_credentials, + _session_snapshot, + _subscriber_credentials, + _validate_timeout, +) + + +class ControlClient: + """Reusable, bounded asyncio HTTP client for Session lifecycle operations.""" + + def __init__( + self, + control_plane_url: str, + *, + 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 = _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 + + 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: + identifier = SessionId(str(session_id)) + payload = await self._json_request( + "GET", + 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, + 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, + 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=authorization, + expect_json=True, + json_body=json_body, + ) + + async def _request( + self, + method: str, + path: str, + *, + expected_status: int, + 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") + headers = {} + redacted_values: tuple[str, ...] = () + if authorization is not None: + exposed = authorization.expose_secret() + headers["Authorization"] = f"Bearer {exposed}" + redacted_values = (exposed,) + timeout = _resolve_timeout(self._timeout_seconds, timeout_seconds) + try: + async with self._http_client.stream( + method, + urljoin(self.control_plane_url, path), + headers=headers, + json=json_body, + 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/conversation.py b/python/pocketstation/aio/conversation.py new file mode 100644 index 0000000..51711ea --- /dev/null +++ b/python/pocketstation/aio/conversation.py @@ -0,0 +1,15 @@ +"""Compatibility imports for the former asyncio conversation module.""" + +from ..voice.conversation import ( + Conversation, + ResponseHandler, + SynthesisHandler, + TranscriptDecoder, +) + +__all__ = [ + "Conversation", + "ResponseHandler", + "SynthesisHandler", + "TranscriptDecoder", +] 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/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/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/operator_authoring.py b/python/pocketstation/aio/operator_authoring.py new file mode 100644 index 0000000..2511d85 --- /dev/null +++ b/python/pocketstation/aio/operator_authoring.py @@ -0,0 +1,302 @@ +"""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[object] + ) -> 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): + async def create(self, configuration: Mapping[str, str]) -> OperatorNode: ... + + +OperatorNodeBuilder: TypeAlias = Callable[ + [Mapping[str, str]], Coroutine[Any, Any, OperatorNode] +] +OperatorHandler: TypeAlias = Callable[ + [str, SignalEnvelope[object]], 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[object] + ) -> 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[object] + ) -> 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__ = [ + "OperatorConfigValidator", + "OperatorDeadlines", + "OperatorEmission", + "OperatorFactory", + "OperatorHandler", + "OperatorManifest", + "OperatorNode", + "OperatorNodeBuilder", + "OperatorPrepareContext", + "OperatorProvider", + "RegisteredOperator", + "operator", +] diff --git a/python/pocketstation/aio/relay.py b/python/pocketstation/aio/relay.py new file mode 100644 index 0000000..ff38bdb --- /dev/null +++ b/python/pocketstation/aio/relay.py @@ -0,0 +1,278 @@ +"""Create and operate PocketStation RelaySessions with asyncio.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from time import monotonic +from types import TracebackType +from typing import TYPE_CHECKING + +from ..control import ControlPlaneError, SessionCredentials, SessionId, SessionSnapshot +from ..errors import _native_call +from ..relay import ( + PublisherActivation, + ReceiverActivation, + ReceiverInvitation, + RelayError, + RelayPublisher, + RelayTimeoutError, + _bounded_request_timeout, + _normalize_relay_url, + _receiver_invitation, + _validate_request_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, + owns_control: bool, + request_timeout_seconds: float, + ) -> None: + self.relay_url = _normalize_relay_url(relay_url) + self.credentials = credentials + self._control = control + self._owns_control = owns_control + 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 = 10.0, + required_buses: tuple[str, ...] = ("application", "microphone"), + control_client: ControlClient | 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 + control = control_client or ControlClient( + control_plane_url, + timeout_seconds=request_timeout_seconds, + ) + try: + credentials = await control.create_session( + required_buses=required_buses, + timeout_seconds=request_timeout_seconds, + ) + except BaseException: + if owns_control: + await control.aclose() + raise + return cls( + relay_url=normalized_relay_url, + credentials=credentials, + control=control, + owns_control=owns_control, + 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.ready, + 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, *, 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", + ) + 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(created, self.session_id) + self._invitation = invitation + return 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: + await self.wait_for_publisher( + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + return await self.create_receiver_invitation(bus_id=bus_id) + + 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.ready 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_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) + 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( + min(poll_interval_seconds, max(0.0, deadline - monotonic())) + ) + + def _require_open(self) -> None: + if self._closed: + raise RelayError("RelaySession has closed", "relay.closed") + + +__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..1445a8e --- /dev/null +++ b/python/pocketstation/aio/session.py @@ -0,0 +1,697 @@ +"""Build and operate a native PocketStation Session with asyncio.""" + +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, cast + +from .._native import ( + AudioBatch, + _SessionStartCancellation, +) +from .._native import ( + RunningSession as _NativeRunningSession, +) +from .._native import ( + 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 ( + EdgeContract, + Endpoint, + SignalSpec, + Stem, + _GraphSessionDeclarations, +) +from ..identity import RuntimeSessionId +from ..observations import ( + SessionEvent, + SessionLifecycleState, + SessionMetrics, + 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 .endpoint_authoring import EndpointProvider, RegisteredEndpoint +from .event_input import EventInput +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: + from ..relay import RelayPublisher + from ..signal import SignalEnvelope + from ..voice import ( + Conversation, + ConversationConfig, + DuplexVoiceModel, + ResponseModel, + SpeechDetector, + SpeechSynthesizer, + StreamingTranscriber, + TranscriptUpdate, + ) + from ..voice.conversation import ( + ResponseHandler, + SynthesisHandler, + ) + from .relay import RelaySession + +_Result = TypeVar("_Result") +_PayloadT = TypeVar("_PayloadT") +_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[object]] = {} + self._sidecars: dict[int, SidecarConnection] = {} + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def is_stopped(self) -> bool: + 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: + 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[_PayloadT] + ) -> SignalStream[_PayloadT]: + """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 cast(SignalStream[_PayloadT], 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): + """Build and operate one Rust Session from asyncio code.""" + + def __init__( + self, + *, + recording_root: str | Path | None = None, + 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 = _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 | SyncConnector, + SyncConnector, + _NativeRegisteredConnector, + ], + ] = {} + self._endpoint_registrations: dict[ + int, + tuple[ + EndpointProvider | SyncEndpointProvider, + SyncEndpointProvider, + _NativeRegisteredEndpoint, + ], + ] = {} + + @classmethod + def _from_native(cls, native: _NativeSession) -> Session: + """Construct an internal façade around a conformance Session.""" + session = cls.__new__(cls) + 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 + + @property + def id(self) -> RuntimeSessionId: + return RuntimeSessionId(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), + self._destination_for_stream, + ) + ) + + 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, 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( + config.sample_rate_hz, + config.channels, + config.capacity_frames, + config.frame_samples_per_channel, + ) + ) + return PcmSource(SyncPcmSource(native, config, self._destination_for_stream)) + + 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.""" + 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) + 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, + ) + ) + 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_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: + """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( + 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 conversation( + self, + *, + 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, + ) -> 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 ..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, + 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() + 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__ = [ + "Connector", + "RegisteredConnector", + "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/source_authoring.py b/python/pocketstation/aio/source_authoring.py new file mode 100644 index 0000000..6070b31 --- /dev/null +++ b/python/pocketstation/aio/source_authoring.py @@ -0,0 +1,300 @@ +"""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): + 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", + "SourceConfigValidator", + "SourceDeadlines", + "SourceDriver", + "SourceDriverBuilder", + "SourceEmission", + "SourceFactory", + "SourceIterableFactory", + "SourceManifest", + "SourcePrepareContext", + "SourceProvider", + "source", +] diff --git a/python/pocketstation/aio/sources.py b/python/pocketstation/aio/sources.py new file mode 100644 index 0000000..dc57b7c --- /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 native source discovery without blocking 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 the current microphone authorization without prompting.""" + return _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..e34754d --- /dev/null +++ b/python/pocketstation/aio/streams.py @@ -0,0 +1,268 @@ +"""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 typing import Generic, TypeVar, cast + +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, + AudioBatchReadResult, + _iteration_timeout_milliseconds, + _ReaderState, + _timeout_milliseconds, +) + +_PayloadT = TypeVar("_PayloadT") + + +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 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) + token = self._state.claim("batches") + try: + return None if self.is_closed else await self._wait_batch(timeout_ms) + 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() + + 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(Generic[_PayloadT]): + """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[_PayloadT]: + 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[_PayloadT]: + 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[_PayloadT]]: + return self.iter_signals() + + def iter_signals( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> AsyncIterator[SignalEnvelope[_PayloadT]]: + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + async def iterate() -> AsyncIterator[SignalEnvelope[_PayloadT]]: + 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[_PayloadT]: + if result.status == "item": + if result.envelope is None: + raise StreamError( + "native signal read omitted its envelope", + "stream.invalid_read", + ) + return cast( + SignalEnvelope[_PayloadT], + 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__ = ["AudioBatchReadResult", "AudioStream", "SignalStream"] diff --git a/python/pocketstation/audio_input.py b/python/pocketstation/audio_input.py new file mode 100644 index 0000000..34cb80a --- /dev/null +++ b/python/pocketstation/audio_input.py @@ -0,0 +1,213 @@ +"""Bounded application-owned PCM input for a PocketStation Session.""" + +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 ._native import _OutputGeneration as _NativeOutputGeneration +from .errors import AudioInputBufferError, AudioInputFullError, _native_call +from .graph import Endpoint, SourceOutput +from .identity import SourceId, StreamId + + +@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 + discarded_output_frames_total: int + cancelled_output_writes_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, + discarded_output_frames_total=native.discarded_output_frames_total, + cancelled_output_writes_total=native.cancelled_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.""" + + def __init__( + self, + native: _NativeAudioInput, + config: AudioInputConfig, + destination: Callable[[object], Endpoint], + ) -> None: + self._native = native + self._config = config + self._output = SourceOutput(native.output, destination) + + @property + def config(self) -> AudioInputConfig: + return self._config + + @property + def source_id(self) -> SourceId: + return SourceId(self._native.source_id) + + @property + def stream_id(self) -> StreamId: + return StreamId(self._native.stream_id) + + @property + def output(self) -> SourceOutput: + return self._output + + 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, + generation=None if generation is None else generation._native, + ) + ) + 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.""" + _native_call(self._native.close) + + def observations(self) -> AudioInputObservations: + return AudioInputObservations._from_native( + _native_call(self._native.observations) + ) + + +class AudioInput(PcmSource): + """Feed application-owned audio into a Session through bounded native buffers.""" + + def write( + self, + samples: object, + *, + discontinuity: bool = False, + generation: OutputGeneration | None = None, + 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, + generation=generation, + timeout_s=timeout_s, + ) + + +def _write_with_timeout( + source: PcmSource, + samples: object, + *, + discontinuity: bool, + generation: OutputGeneration | None, + 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, + generation=generation, + ) + return + except AudioInputFullError: + remaining = deadline - monotonic() + if remaining <= 0: + raise + sleep(min(wait_s, remaining)) + wait_s = min(wait_s * 2, 0.005) + + +__all__ = [ + "AudioInput", + "AudioInputConfig", + "AudioInputObservations", + "OutputGeneration", + "PcmSource", +] diff --git a/python/pocketstation/capture.py b/python/pocketstation/capture.py new file mode 100644 index 0000000..52c2c4e --- /dev/null +++ b/python/pocketstation/capture.py @@ -0,0 +1,201 @@ +"""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 typing import TypeVar + +from ._native import AudioBatch +from .graph import Stem +from .observations import ( + EventStream, + RecordingOutcome, + SessionEvent, + SessionMetrics, + SessionTraceConfiguration, + StopResult, +) +from .session import RunningSession, Session +from .signal import BusSubscription +from .sources import Source, _capture_application +from .streams import AudioStream, SignalStream + +_PayloadT = TypeVar("_PayloadT") + + +class Capture: + """One application and optional microphone captured as independent stems.""" + + def __init__( + self, + *, + application: str | int, + microphone: bool | str = False, + record_to: str | Path | None = None, + stream_audio: bool = True, + trace: SessionTraceConfiguration | None = None, + ) -> None: + 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(): + 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._stream_audio = stream_audio + 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(_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)) + + 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: + microphone.record("microphone") + + self.session = session + 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 + + @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 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") + 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 | int, + microphone: bool | str = False, + record_to: str | Path | None = None, + stream_audio: bool = True, + trace: SessionTraceConfiguration | None = None, +) -> Capture: + """Capture one application and optionally add a microphone.""" + return Capture( + application=application, + microphone=microphone, + record_to=record_to, + stream_audio=stream_audio, + trace=trace, + ) + + +__all__ = ["Capture", "capture"] diff --git a/python/pocketstation/compatibility.py b/python/pocketstation/compatibility.py new file mode 100644 index 0000000..9af14e3 --- /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.4", + relay_connector_version="0.1.2", + 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 new file mode 100644 index 0000000..19fe3a9 --- /dev/null +++ b/python/pocketstation/connector.py @@ -0,0 +1,1120 @@ +"""Author in-process Python Connectors on the Core worker 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, 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 + self.retryability = retryability + + +@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: + 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] + ) + ) + object.__setattr__(self, "_native", native) + + def configuration( + self, values: ConnectorConfigurationInput = () + ) -> _NativeConnectorConfiguration: + 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: + 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[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 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[object] | 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 +] +AudioConnectorHandler: TypeAlias = Callable[ + [AudioFrame, 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)) + + @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.""" + + __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 _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() + return EdgeContract.bounded_async() + + +__all__ = [ + "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", +] diff --git a/python/pocketstation/control.py b/python/pocketstation/control.py new file mode 100644 index 0000000..766d0d3 --- /dev/null +++ b/python/pocketstation/control.py @@ -0,0 +1,614 @@ +"""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, cast +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 +_MAX_SESSION_ID_BYTES = 128 +_MAX_SECRET_BYTES = 4_096 +_MAX_ICE_SERVERS = 32 +_MAX_ICE_URLS = 16 + + +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 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 '_'" + ) + 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") + 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: + 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: SecretToken | None = None + + +@dataclass(frozen=True, slots=True) +class SessionCredentials: + session_id: SessionId + required_buses: tuple[str, ...] + source_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 + 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 + + +class ControlClient: + """Reusable, bounded HTTP client for Session lifecycle operations.""" + + def __init__( + self, + control_plane_url: str, + *, + 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 = _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 + + 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: + identifier = SessionId(str(session_id)) + payload = self._json_request( + "GET", + 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, + 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, + 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=authorization, + expect_json=True, + json_body=json_body, + ) + + def _request( + self, + method: str, + path: str, + *, + expected_status: int, + 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") + headers = {} + redacted_values: tuple[str, ...] = () + if authorization is not None: + exposed = authorization.expose_secret() + headers["Authorization"] = f"Bearer {exposed}" + redacted_values = (exposed,) + timeout = _resolve_timeout(self._timeout_seconds, timeout_seconds) + try: + with self._http_client.stream( + method, + urljoin(self.control_plane_url, path), + headers=headers, + json=json_body, + 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") + 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: + 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) + 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", + ) + 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", + ) + 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): + 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", + ) + 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): + 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, + None if credential is None else SecretToken(credential), + ) + ) + return tuple(servers) + + +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)), + 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: + 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( + "control-plane subscription_count must not be negative", + "control.response_decode", + ) + return SessionSnapshot( + session_id=SessionId(_required(payload, "session_id", str)), + 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), + ) + + +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): + raise ControlPlaneError( + f"control-plane response field {key!r} has the wrong type", + "control.response_decode", + ) + return value + + +__all__ = [ + "BusState", + "ControlClient", + "ControlPlaneError", + "IceServer", + "Invitation", + "SecretToken", + "SessionCredentials", + "SessionId", + "SessionSnapshot", + "SubscriberCredentials", + "SubscriptionState", +] diff --git a/python/pocketstation/conversation.py b/python/pocketstation/conversation.py new file mode 100644 index 0000000..dd0d3a7 --- /dev/null +++ b/python/pocketstation/conversation.py @@ -0,0 +1,35 @@ +"""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", + "ConversationContext", + "ConversationDisposition", + "ConversationEvent", + "ConversationMessage", + "ConversationOutcome", + "ConversationResponse", + "ConversationResponseChunk", + "ConversationRole", + "ConversationTurn", + "ToolEvent", + "TranscriptUpdate", +] 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/errors.py b/python/pocketstation/errors.py new file mode 100644 index 0000000..ed93b58 --- /dev/null +++ b/python/pocketstation/errors.py @@ -0,0 +1,333 @@ +"""Stable exception hierarchy for the PocketStation Python SDK.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +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 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.""" + + +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.""" + + +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: + 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) + 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_frame_duration", + "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", + "EventInputClosedError", + "EventInputError", + "EventInputFullError", + "ExtensionError", + "GraphError", + "OperatorError", + "PocketStationError", + "SessionCompileDiagnostic", + "SessionDeclarationError", + "SessionError", + "SessionRuntimeError", + "SessionStartError", + "SidecarBackpressureError", + "SidecarError", + "SidecarProtocolError", + "SidecarTimeoutError", + "SourceError", + "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..ea1bf85 --- /dev/null +++ b/python/pocketstation/graph.py @@ -0,0 +1,1146 @@ +"""Declare Python graph routes for the Rust ``Session`` to compile.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar, cast + +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 +from .identity import ( + ConnectorId, + EndpointId, + OperatorInstanceId, + RouteId, + RuntimeSessionId, + SourceId, + SourceInstanceId, + StemId, + StreamId, +) + +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) + + +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(Generic[_PayloadT_co]): + """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[object]: + 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[object]: + return cls(SignalKind.ANY, role=role, schema=schema) + + @classmethod + 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( + cls, + codec: Codec, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec[bytes]: + return cast( + SignalSpec[bytes], + 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[str]: + return cast( + SignalSpec[str], 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[bytes]: + return cast( + SignalSpec[bytes], + cls(SignalKind.EVENT, format, role=role, schema=schema), + ) + + @classmethod + def metrics( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec[object]: + return cls(SignalKind.METRICS, role=role, schema=schema) + + @classmethod + def control( + cls, *, role: str | None = None, schema: str | None = None + ) -> SignalSpec[object]: + 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[bytes]: + return cast( + SignalSpec[bytes], + cls(SignalKind.BINARY, format, role=role, schema=schema), + ) + + @classmethod + def custom( + cls, + signal_id: str, + *, + role: str | None = None, + schema: str | None = None, + ) -> SignalSpec[object]: + 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[object]) -> 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" + + @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" + + +@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 _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()) + + @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) + + @classmethod + def for_signal(cls, signal: SignalSpec[object]) -> MediaCaps: + """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: + 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) + + 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) + + +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[object] + 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) + + @classmethod + def input( + cls, + name: str, + signal: SignalSpec[object], + *, + 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[object], + *, + 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" + PLAYBACK = "playback" + NETWORK = "network" + INHERITED = "inherited" + WALLCLOCK = "wallclock" + + @property + def is_realtime(self) -> bool: + return self in {ClockDomain.CAPTURE, ClockDomain.PLAYBACK} + + +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" + + @property + def rank(self) -> int: + return { + EdgeObservabilityLevel.OFF: 0, + EdgeObservabilityLevel.COUNTERS: 1, + EdgeObservabilityLevel.FULL: 2, + }[self] + + +@dataclass(frozen=True, slots=True) +class EdgeContract: + """Configure a bounded edge with the public Rust policy 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 = 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)) + + +@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) -> EndpointId: + return EndpointId(self._native.id) + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def connector_id(self) -> ConnectorId | None: + value = self._native.connector_id + return None if value is None else ConnectorId(value) + + +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__ = ("_destination", "_native") + + def __init__( + self, + native: _NativeOperatorInstance, + destination: _DestinationResolver, + ) -> None: + self._native = native + self._destination = destination + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + 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))) + + def output(self, port_name: str) -> DerivedStream: + return _native_call( + lambda: DerivedStream(self._native.output(port_name), self._destination) + ) + + +class _RoutableStream: + __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: + 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, + 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: + 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, + 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, + ), + self._destination, + ) + ) + + +class Stem(_RoutableStream): + """Independent source-aware PCM path declared on a Session.""" + + __slots__ = ("_native",) + _native: _NativeStem + + 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: + return StemId(self._native.id) + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + def record(self, stem_name: str) -> Endpoint: + 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.""" + 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)) + self._route_ids.add(route_id) + return RelayRoute(bus_id=bus_id, route_id=RouteId(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, + destination: _DestinationResolver, + ) -> None: + self._native = native + self._destination = destination + self._route_ids = set() + self._endpoint_ids = set() + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def operator_instance_id(self) -> OperatorInstanceId: + return OperatorInstanceId(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), self._destination) + ) + + def reenter_audio(self) -> Stem: + """Return generated PCM through Core without a Python audio callback.""" + return _native_call( + lambda: Stem(self._native.reenter_audio(), self._destination) + ) + + +class SourceInstance: + """Open registered source declaration scoped to one Session.""" + + __slots__ = ("_destination", "_native") + + def __init__( + self, + native: _NativeSourceInstance, + destination: _DestinationResolver, + ) -> None: + self._native = native + self._destination = destination + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def instance_id(self) -> SourceInstanceId: + return SourceInstanceId(self._native.instance_id) + + @property + 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), self._destination) + ) + + +class SourceOutput(_RoutableStream): + """One named output from an externally registered source instance.""" + + __slots__ = ("_native",) + _native: _NativeSourceOutput + + def __init__( + self, + native: _NativeSourceOutput, + destination: _DestinationResolver, + ) -> None: + self._native = native + self._destination = destination + self._route_ids = set() + self._endpoint_ids = set() + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def source_instance_id(self) -> SourceInstanceId: + return SourceInstanceId(self._native.source_instance_id) + + @property + def source_id(self) -> SourceId: + return SourceId(self._native.source_id) + + @property + def stream_id(self) -> StreamId: + return StreamId(self._native.stream_id) + + @property + def output_port(self) -> str: + return self._native.output_port + + def record(self, stem_name: str) -> Endpoint: + 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.""" + 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)) + self._route_ids.add(route_id) + return RelayRoute(bus_id=bus_id, route_id=RouteId(route_id)) + + +class _GraphSessionDeclarations: + """One shared sync/async policy for immediate Rust draft declarations.""" + + _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: + """Return the stable identity allocated by the Rust Session.""" + return RuntimeSessionId(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()), + self._destination_for_stream, + ) + ) + + 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(), + ), + self._destination_for_stream, + ) + ) + + def endpoint(self, descriptor: EndpointDescriptor) -> Endpoint: + """Declare one open Endpoint descriptor on the Session 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[_PayloadT], + edge: EdgeContract | None = None, + ) -> BusSubscription[_PayloadT]: + """Declare one bounded, exclusive typed-signal subscription. + + The subscription is an Endpoint in the 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[object]) -> 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/identity.py b/python/pocketstation/identity.py new file mode 100644 index 0000000..afa51a9 --- /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" +] +RouteId = NewType("RouteId", int) +SidecarId = NewType("SidecarId", int) +EndpointId = NewType("EndpointId", int) +ConnectorId = NewType("ConnectorId", int) +OperatorInstanceId = NewType("OperatorInstanceId", int) + +__all__ = [ + "ClockDomainId", + "ClockDomainKind", + "ClockDomainOrigin", + "ConnectorId", + "EndpointId", + "OperatorInstanceId", + "RouteId", + "RuntimeSessionId", + "SidecarId", + "SourceId", + "SourceInstanceId", + "StemId", + "StreamId", +] diff --git a/python/pocketstation/observations.py b/python/pocketstation/observations.py new file mode 100644 index 0000000..ae4aa7c --- /dev/null +++ b/python/pocketstation/observations.py @@ -0,0 +1,1274 @@ +"""Inspect typed, immutable observations from the 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 SessionTraceRecord as _NativeSessionTraceRecord +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 .identity import ( + EndpointId, + OperatorInstanceId, + RouteId, + SidecarId, + StemId, +) +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 SessionComponentKind(StrEnum): + SOURCE = "source" + ENDPOINT = "endpoint" + OPERATOR = "operator" + SIDECAR = "sidecar" + RUNTIME = "runtime" + + +class EndpointFailureStage(StrEnum): + PREPARE = "prepare" + CANCEL_PREPARATION = "cancel-preparation" + START = "start" + REQUEST_STOP = "request-stop" + 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" + 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 + + +@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 + 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 + error_code: str | None + retryability: EndpointFailureRetryability | None + component: SessionComponent | None + component_diagnostic: str | None + message: str | 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), + 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=component, + component_diagnostic=failure.component, + message=failure.message, + 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)), + ) + + +@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 + discarded_output_frames_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 + discarded_output_frames_total: int | None + + @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, + discarded_output_frames_total=value.discarded_output_frames_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, + 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, + 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: + 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, ...] + + @property + def complete(self) -> bool: + return self.state is RecordingState.COMPLETE + + @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() + ), + ) + 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 + cancelled_output_frames_total: int + cancelled_output_samples_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 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.""" + + 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) + + @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)) + + +@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", + "EndpointFailureRetryability", + "EndpointFailureStage", + "EndpointMetrics", + "EndpointObservationStage", + "EventQueueMetrics", + "EventStream", + "ExternalSourceMetrics", + "LatencyHistogram", + "OperatorInputMetrics", + "OperatorMetrics", + "OperatorWorkerMetrics", + "PolledAudioMetrics", + "RecordingDiscontinuity", + "RecordingDiscontinuityKind", + "RecordingOutcome", + "RecordingState", + "RecordingStemOutcome", + "RelayPublishOutcome", + "RouteLatencyBoundary", + "RouteLatencyUnit", + "RouteMetrics", + "RouteObservationInterval", + "SessionComponent", + "SessionComponentKind", + "SessionEvent", + "SessionEventType", + "SessionFailure", + "SessionFailureKind", + "SessionFinalizationStage", + "SessionLifecycleState", + "SessionMetrics", + "SessionRollbackStage", + "SessionTerminalState", + "SessionTrace", + "SessionTraceConfiguration", + "SessionTraceRecord", + "SessionTraceRecordType", + "SessionTraceRecorderOutcome", + "SessionTraceValidation", + "SourceMetrics", + "StopResult", + "TerminationDisposition", + "TypedEdgeMetrics", +] diff --git a/python/pocketstation/operator_authoring.py b/python/pocketstation/operator_authoring.py new file mode 100644 index 0000000..9d414df --- /dev/null +++ b/python/pocketstation/operator_authoring.py @@ -0,0 +1,327 @@ +"""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 SignalAudioPayload, 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[object] + 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 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( + _native_call(lambda: _NativeOperatorEmission.text(payload, signal._native)) + ) + + @classmethod + def bytes(cls, payload: bytes, *, signal: SignalSpec[bytes]) -> 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[object] + ) -> 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 create(self, configuration: Mapping[str, str]) -> OperatorNode: ... + + +OperatorHandler: TypeAlias = Callable[ + [str, SignalEnvelope[object]], 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[object] + ) -> 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: + 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) + 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/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..fae0054 --- /dev/null +++ b/python/pocketstation/relay.py @@ -0,0 +1,419 @@ +"""Create RelaySessions and compose their publication declarations.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from time import monotonic, sleep +from types import TracebackType +from typing import TYPE_CHECKING +from urllib.parse import parse_qs, urlparse + +from ._native import RelayPublisher as _NativeRelayPublisher +from .control import ( + ControlClient, + ControlPlaneError, + 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 + + +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: RouteId + + +@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 control-plane invitation containing no subscriber capability.""" + + 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 Rust Session and shared ``pocketstation-relay`` crate. + """ + + def __init__( + self, + *, + relay_url: str, + credentials: SessionCredentials, + control: ControlClient, + owns_control: bool, + request_timeout_seconds: float, + ) -> None: + self.relay_url = _normalize_relay_url(relay_url) + self.credentials = credentials + self._control = control + self._owns_control = owns_control + 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 = 10.0, + required_buses: tuple[str, ...] = ("application", "microphone"), + control_client: ControlClient | 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 + control = control_client or ControlClient( + control_plane_url, + timeout_seconds=request_timeout_seconds, + ) + try: + credentials = control.create_session( + required_buses=required_buses, + timeout_seconds=request_timeout_seconds, + ) + except Exception: + if owns_control: + control.close() + raise + return cls( + relay_url=normalized_relay_url, + credentials=credentials, + control=control, + owns_control=owns_control, + 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.ready, + 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, *, 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", + ) + 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(created, 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.ready 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_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, + ) + 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()))) + + 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.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_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: + 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, +) -> float: + return min(remaining_seconds, configured_seconds) + + +def _receiver_invitation( + created: ControlInvitation, + expected_session_id: SessionId, +) -> ReceiverInvitation: + if created.session_id != expected_session_id: + raise RelayError( + "control-plane invitation belongs to a different Session", + "relay.response_identity", + ) + 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( + "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(created.session_id, join_code, invitation_url) + + +__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..6beb819 --- /dev/null +++ b/python/pocketstation/session.py @@ -0,0 +1,495 @@ +"""Build and operate a native PocketStation Session synchronously.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path +from types import TracebackType +from typing import TYPE_CHECKING, TypeVar, cast + +from ._native import ( + AudioBatch, + AudioFrame, +) +from ._native import ( + RunningSession as _NativeRunningSession, +) +from ._native import ( + 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 ( + EdgeContract, + Endpoint, + Stem, + _GraphSessionDeclarations, +) +from .identity import RuntimeSessionId +from .observations import ( + EventStream, + RecordingOutcome, + RecordingStemOutcome, + RouteMetrics, + SessionEvent, + SessionLifecycleState, + SessionMetrics, + 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 + +if TYPE_CHECKING: + from .relay import RelayPublisher, RelaySession + +_PayloadT = TypeVar("_PayloadT") + + +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[object]] = {} + self._sidecars: dict[int, SidecarConnection] = {} + + @property + def session_id(self) -> RuntimeSessionId: + return RuntimeSessionId(self._native.session_id) + + @property + def is_stopped(self) -> bool: + 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: + 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[_PayloadT] + ) -> SignalStream[_PayloadT]: + """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 cast(SignalStream[_PayloadT], 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): + """Build and operate one Rust Session from synchronous Python code.""" + + def __init__( + self, + *, + recording_root: str | Path | None = None, + 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 = _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] + ] = {} + self._endpoint_registrations: dict[ + int, tuple[EndpointProvider, _NativeRegisteredEndpoint] + ] = {} + + @classmethod + def _from_native(cls, native: _NativeSession) -> Session: + """Construct an internal façade around a conformance Session.""" + session = cls.__new__(cls) + 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 + + @property + def id(self) -> RuntimeSessionId: + return RuntimeSessionId(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), + self._destination_for_stream, + ) + ) + + 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, self._destination_for_stream) + + 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, self._destination_for_stream) + + 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.""" + 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( + 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, + ) + ) + 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. + + Use this 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_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( + 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( + 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", + "Connector", + "Endpoint", + "OperatorProvider", + "RecordingOutcome", + "RecordingStemOutcome", + "RegisteredConnector", + "RegisteredOperator", + "RegisteredSource", + "RouteMetrics", + "RunningSession", + "Session", + "SessionEvent", + "SessionMetrics", + "SidecarConnection", + "SidecarHandle", + "SidecarProcessSpec", + "SignalStream", + "Source", + "SourceProvider", + "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..c6a1398 --- /dev/null +++ b/python/pocketstation/signal.py @@ -0,0 +1,277 @@ +"""Read immutable typed signals delivered by Rust Session endpoints.""" + +from __future__ import annotations + +from dataclasses import dataclass +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 +from ._native import _SignalLineage as _NativeSignalLineage +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) +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: RuntimeSessionId + stream_id: StreamId + source_id: SourceId + clock_id: ClockDomainId + clock: ClockDomainDescriptor + sequence_number: int + source_generation: int + discontinuity_epoch: int + policy_epoch: int + + @classmethod + def _from_native(cls, value: _NativeSignalLineage) -> SignalLineage: + return cls( + 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, + 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: ConnectorId | 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=( + None if value.connector_id is None else ConnectorId(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: StreamId + source_id: SourceId + 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=StreamId(value.stream_id), + source_id=SourceId(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(Generic[_PayloadT_co]): + """One owned payload with its exact signal, timing, lineage, and derivation.""" + + signal: SignalSpec[_PayloadT_co] + timing: SignalTiming + lineage: SignalLineage | None + derivation: SignalDerivation | None + payload: _PayloadT_co + + @classmethod + 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") + 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 SignalEnvelope[SignalPayload]( + signal=cast( + SignalSpec[SignalPayload], 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(Generic[_PayloadT_co]): + """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[_PayloadT_co]: + return cast( + SignalSpec[_PayloadT_co], 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[_PayloadT] | EndOfStream | None + + +__all__ = [ + "STREAM_EOF", + "BusSubscription", + "EndOfStream", + "SignalAudioPayload", + "SignalDerivation", + "SignalEnvelope", + "SignalLineage", + "SignalPayload", + "SignalReadResult", + "SignalSpec", + "SignalSubscriptionMetrics", + "SignalTiming", +] diff --git a/python/pocketstation/source_authoring.py b/python/pocketstation/source_authoring.py new file mode 100644 index 0000000..d46ea16 --- /dev/null +++ b/python/pocketstation/source_authoring.py @@ -0,0 +1,349 @@ +"""Author typed Python Sources on the 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 +from .identity import RuntimeSessionId, SourceId, StreamId + + +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: StreamId + + @classmethod + def _from_native(cls, value: _NativeSourceOutputIdentity) -> SourceOutputIdentity: + return cls(value.output_port, StreamId(value.stream_id)) + + +@dataclass(frozen=True, slots=True) +class SourcePrepareContext: + """Immutable Session identity supplied before the Source starts.""" + + source_type_id: str + 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=( + 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 + ), + ) + + +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[str], + 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[bytes], + 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 Session.""" + + 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: + 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) + if not hasattr(driver, "next"): + raise TypeError("Source factory must return a SourceDriver") + return _NativeDriverAdapter(driver) + + +class RegisteredSource: + """Register one Python Source implementation in a 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/python/pocketstation/sources.py b/python/pocketstation/sources.py new file mode 100644 index 0000000..82ecfa0 --- /dev/null +++ b/python/pocketstation/sources.py @@ -0,0 +1,637 @@ +"""Typed source declarations, discovery, permissions, and runtime identity.""" + +from __future__ import annotations + +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 +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 +from .identity import SourceId + + +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 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: + """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 + 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" + 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" + SYSTEM_MIX = "system-mix" + + +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: SourceId | None + + +@dataclass(frozen=True, slots=True) +class DiscoveredSource: + """Immutable point-in-time result from native source discovery.""" + + 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 + _native: _NativeDiscoveredSource | None = field( + default=None, repr=False, compare=False + ) + + @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=SourceId(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) + ), + _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) +class SourceQuery: + """Describe a typed query for the native source provider to execute.""" + + _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 Source declaration compiled by the 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. + + 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: + 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", + ) + + +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.""" + + 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=SourceId(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, 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) + + +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__ = [ + "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", +] diff --git a/python/pocketstation/streams.py b/python/pocketstation/streams.py new file mode 100644 index 0000000..eef522b --- /dev/null +++ b/python/pocketstation/streams.py @@ -0,0 +1,355 @@ +"""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 Generic, Literal, TypeAlias, TypeVar, cast + +from ._native import ( + AudioBatch, + AudioFrame, + ClockDomainDescriptor, + _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 + +AudioBatchReadResult: TypeAlias = AudioBatch | EndOfStream | None +_PayloadT = TypeVar("_PayloadT") + + +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 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) + token = self._state.claim("batches") + try: + return None if self.is_closed else self._wait_batch(timeout_ms) + 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() + + 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(Generic[_PayloadT]): + """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 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[_PayloadT]: + """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[_PayloadT]: + """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[_PayloadT]]: + return self.iter_signals() + + def iter_signals( + self, + *, + wait_timeout_s: float = _DEFAULT_ITERATION_TIMEOUT_SECONDS, + ) -> Iterator[SignalEnvelope[_PayloadT]]: + """Yield immutable envelopes until native EOF or explicit close.""" + timeout_ms = _iteration_timeout_milliseconds(wait_timeout_s) + + def iterate() -> Iterator[SignalEnvelope[_PayloadT]]: + 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[_PayloadT]: + if result.status == "item": + if result.envelope is None: + raise StreamError( + "native signal read omitted its envelope", + "stream.invalid_read", + ) + return cast( + SignalEnvelope[_PayloadT], + 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__ = [ + "AudioBatch", + "AudioBatchReadResult", + "AudioFrame", + "AudioStream", + "ClockDomainDescriptor", + "SignalStream", +] 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_demo/__init__.py b/python/pocketstation_demo/__init__.py new file mode 100644 index 0000000..6e90f40 --- /dev/null +++ b/python/pocketstation_demo/__init__.py @@ -0,0 +1,15 @@ +"""Runnable PocketStation demos and their replaceable provider adapters.""" + +from .demo import main +from .faster_whisper import FasterWhisper, FasterWhisperConfiguration +from .relay import demo_relay_session +from .transcript import TRANSCRIPT_SIGNAL, Transcript + +__all__ = [ + "TRANSCRIPT_SIGNAL", + "FasterWhisper", + "FasterWhisperConfiguration", + "Transcript", + "demo_relay_session", + "main", +] diff --git a/python/pocketstation_demo/audio_windows.py b/python/pocketstation_demo/audio_windows.py new file mode 100644 index 0000000..eeca983 --- /dev/null +++ b/python/pocketstation_demo/audio_windows.py @@ -0,0 +1,192 @@ +"""Finite source-aware PCM windows for demo transcription adapters.""" + +from __future__ import annotations + +import sys +from array import array +from dataclasses import dataclass, field + +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 + 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: SignalEnvelope[object], + ) -> tuple[AudioWindow, ...]: + payload = envelope.payload + if not isinstance(payload, 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) + 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 reasons 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, + 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 + + 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 + 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 + ) + # 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, ...]: + 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/python/pocketstation_demo/demo.py b/python/pocketstation_demo/demo.py new file mode 100644 index 0000000..d11c698 --- /dev/null +++ b/python/pocketstation_demo/demo.py @@ -0,0 +1,41 @@ +"""Run the installed application-and-microphone PocketStation demo.""" + +import asyncio +import webbrowser + +import pocketstation.aio as pks + +from .faster_whisper import FasterWhisper +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: ") + remote = await demo_relay_session() + live = pks.capture( + 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().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 transcript in transcripts: + 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_demo/faster_whisper.py b/python/pocketstation_demo/faster_whisper.py new file mode 100644 index 0000000..9023dcb --- /dev/null +++ b/python/pocketstation_demo/faster_whisper.py @@ -0,0 +1,456 @@ +"""Transcribe source-aware demo audio with faster-whisper.""" + +from __future__ import annotations + +import asyncio +import importlib +import json +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, +) +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, Transcript + + +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 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: + 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_demo/openai_realtime.py b/python/pocketstation_demo/openai_realtime.py new file mode 100644 index 0000000..3cfe656 --- /dev/null +++ b/python/pocketstation_demo/openai_realtime.py @@ -0,0 +1,1244 @@ +"""Demo-owned OpenAI Realtime adapter for the voice debugging workflow.""" + +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 Coroutine, Mapping +from dataclasses import dataclass, field +from math import sqrt +from time import monotonic, monotonic_ns +from typing import Any, Literal +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 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 +_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 +_MAX_INPUT_QUEUE_FRAMES = 64 +_MAX_OUTPUT_QUEUE_CHUNKS = 1_024 +_MAX_BUFFERED_OUTPUT_S = 30.0 +_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 in under 20 seconds so the user can interrupt you." + ) + 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 + 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, 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), + ): + 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 + ): + 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), + ): + 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_ready: bool + input_frames_sent: int + 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 + 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 + + +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 + generation: OutputGeneration + 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 + 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) % _OUTPUT_FRAME_SAMPLES + if 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) >= _OUTPUT_FRAME_SAMPLES: + frames.append(array("f", self._pending[:_OUTPUT_FRAME_SAMPLES])) + del self._pending[:_OUTPUT_FRAME_SAMPLES] + return tuple(frames) + + +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=True, + 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.start_observers(running, self._event_log) + await self._voice.connect() + self._voice.start_provider_io() + 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, + 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, + conversation_config: ConversationConfig | 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._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 + ) + 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() + 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] = {} + 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 + 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 + 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, + 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, + 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: + 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._receive_task = 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": { + "format": { + "type": "audio/pcm", + "rate": _MODEL_SAMPLE_RATE_HZ, + }, + "transcription": {"model": "gpt-live-transcribe"}, + "turn_detection": { + "type": "server_vad", + "create_response": True, + "interrupt_response": ( + self._conversation_config.interruption.enabled + ), + }, + }, + "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_observers( + self, + running: pks.RunningSession, + event_log: BusSubscription[bytes], + ) -> None: + """Drain Session media while the provider connection is starting.""" + if self._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") + + 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(): + 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.""" + 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.""" + 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( + {receive_task, 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 receive_task in done: + await receive_task + 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 + 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"), + ) + 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", + "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: + if chunk.pcm16le: + self._output_chunks_dropped += 1 + self._output_chunks_cancelled += 1 + return + 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_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) + if chunk.pcm16le: + self._output_chunks_dropped += 1 + self._output_chunks_cancelled += 1 + discontinuity = True + break + try: + await self._output.write( + samples, + discontinuity=discontinuity, + generation=chunk.generation, + timeout_s=1.0, + ) + 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, + 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 + 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 + 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, + item_id=response.item_id, + 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") + if not isinstance(text, str): + text = event.get("transcript") + values: dict[str, object] = {} + 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) + 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 _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) not in _MICROPHONE_FRAME_SAMPLE_COUNTS: + raise ValueError("the OpenAI example requires 10 ms or 20 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 _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))) + + +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 _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", + 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 + ), + 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): + 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 _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): + 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__ = [ + "OpenAIRealtime", + "RealtimeVoiceConfig", + "RealtimeVoiceObservations", +] diff --git a/python/pocketstation_demo/relay.py b/python/pocketstation_demo/relay.py new file mode 100644 index 0000000..2361fcd --- /dev/null +++ b/python/pocketstation_demo/relay.py @@ -0,0 +1,27 @@ +"""Connect a demo 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_demo/transcript.py b/python/pocketstation_demo/transcript.py new file mode 100644 index 0000000..5766c96 --- /dev/null +++ b/python/pocketstation_demo/transcript.py @@ -0,0 +1,42 @@ +"""Typed transcript values emitted by demo transcription adapters.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from pocketstation.graph import SignalSpec, TextFormat + +TRANSCRIPT_SIGNAL = SignalSpec.text( + TextFormat.JSON, + role="transcript.final", + schema="io.pocketstation.transcript.batch.v1", +) + + +@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/_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(" 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_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( + 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/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/installed_consumer.py b/tests/installed_consumer.py new file mode 100644 index 0000000..15de1da --- /dev/null +++ b/tests/installed_consumer.py @@ -0,0 +1,415 @@ +"""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, Thread +from time import sleep + +import pocketstation._api as pocketstation + +_VOICE_FRAME_SAMPLES = 480 + + +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 + + +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") + 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 + + +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") + + 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, + frame_samples_per_channel=4, + ) + manifest = pocketstation.ConnectorManifest.audio( + "io.pocketstation.test.installed-consumer.v1", + package_version="1.0.0", + ) + connector_driver = InstalledConnector(delivered, connector_stopped) + endpoint = session.destination( + pocketstation.Connector.with_driver( + manifest, InstalledConnectorFactory(connector_driver) + ) + ) + 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() + 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: + 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") + 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", + ) + audio.output.send_to( + pocketstation.Connector.with_driver(manifest, InstalledConnectorFactory(driver)) + ) + 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 _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=_VOICE_FRAME_SAMPLES, + channel_layout=pocketstation.ChannelLayout.MONO, + ) + ) + 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): + 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(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]) * _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): + 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): + raise RuntimeError("PocketStation was not imported from the environment") + print( + json.dumps( + { + "package_path": str(package_path), + "python": sys.version.split()[0], + **provider, + "success": True, + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() 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..c029552 --- /dev/null +++ b/tests/qualification/runtime_resources.py @@ -0,0 +1,347 @@ +"""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 + +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 + 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 + 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, + 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), + 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, + 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), + 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/qualification/typing_contract.py b/tests/qualification/typing_contract.py new file mode 100644 index 0000000..81e1123 --- /dev/null +++ b/tests/qualification/typing_contract.py @@ -0,0 +1,51 @@ +"""Static-only checks for signal payload and runtime identity preservation.""" + +from typing import assert_type + +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: Session, + source: SourceOutput, + connector: Connector, + async_connector: AsyncConnector, +) -> None: + 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, 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: 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 new file mode 100644 index 0000000..b0b99a3 --- /dev/null +++ b/tests/run_artifact_consumer.py @@ -0,0 +1,106 @@ +#!/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, + ) + 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 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run_installed_stream_conformance.py b/tests/run_installed_stream_conformance.py new file mode 100644 index 0000000..e5a2ce8 --- /dev/null +++ b/tests/run_installed_stream_conformance.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Build and test streams and provider authoring from an isolated wheel.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +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_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", + "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_transcription_example.py::test_faster_whisper_is_the_concise_source_aware_python_path", +) + + +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: + 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) + 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( + [ + 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, + environment=process_environment, + ) + 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, + environment=process_environment, + ) + _run( + [ + os.fspath(interpreter), + "-m", + "pytest", + "-q", + "--import-mode=importlib", + *(os.fspath(REPOSITORY / test_case) for test_case in TEST_CASES), + "-rs", + ], + 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), + "-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_installed_transcription_cancellation.py b/tests/run_installed_transcription_cancellation.py new file mode 100644 index 0000000..6810543 --- /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_demo import ( # noqa: E402 + FasterWhisper, + FasterWhisperConfiguration, +) +from pocketstation_demo.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 new file mode 100644 index 0000000..e031e32 --- /dev/null +++ b/tests/run_relay_e2e_publisher.py @@ -0,0 +1,535 @@ +"""Run the real Python → Rust connector → relay path for aggregate E2E.""" + +from __future__ import annotations + +import argparse +import json +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 TYPE_CHECKING, Any, cast + +import pocketstation._api as pks +import pocketstation._native as native + +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: + 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 _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_demo 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) + 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") + 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 + and arguments.application_process_id is None + and not use_application_audio_inputs + and not hasattr(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, + ) + 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: + 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" + ) + 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" + input_mode = "application-audio-input" + elif ( + arguments.application_name is None + and arguments.application_process_id is None + ): + session = pks.Session._from_native( + native.Session.conformance(arguments.recording_root) + ) + 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( + 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" + 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"), + microphone.publish(publisher, "microphone"), + ) + 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. + 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, + ) + 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, + ) + 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], + source_mode=source_mode, + input_mode=input_mode, + ) + + 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.ready, + subscription_count=receiver.snapshot.subscription_count, + ) + if application_audio is not None and microphone_audio is not None: + 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 = [ + { + "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, + "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, + } + for outcome in relay_outcome_values + ] + recording_stem_values = () if recording is None else recording.stems + 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_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 + and recording is not None + and recording.complete + 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_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( + "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"), + message=str(error), + ) + 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_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_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_aio_observations.py b/tests/test_aio_observations.py new file mode 100644 index 0000000..ac0a4f6 --- /dev/null +++ b/tests/test_aio_observations.py @@ -0,0 +1,141 @@ +"""Asyncio event stream ownership tests.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pocketstation._native as _native +import pytest +from pocketstation._api import StreamInUseError, StreamModeError +from pocketstation.aio._api 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: + lifecycle_state = "running" + + 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..200a845 --- /dev/null +++ b/tests/test_aio_relay.py @@ -0,0 +1,164 @@ +"""Async relay surface symmetry over the same Rust and service contracts.""" + +from __future__ import annotations + +import httpx +import pytest +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", + "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_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] = [] + 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" and request.url.path == "/v1/sessions": + 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" + 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: + 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, + ) + + 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"), + ("POST", "/v1/sessions/session_123/invitations"), + ("GET", "/v1/sessions/session_123"), + ("DELETE", "/v1/sessions/session_123"), + ] + + +@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", + "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 new file mode 100644 index 0000000..dca4179 --- /dev/null +++ b/tests/test_aio_session.py @@ -0,0 +1,441 @@ +"""Asyncio Session ownership and cancellation tests.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from array import array + +import pytest +from pocketstation._api import ( + Connector, + ConnectorDeliveryOutcome, + ConnectorManifest, + SessionLifecycleState, +) +from pocketstation.aio._api import ( + Connector as AsyncConnector, +) +from pocketstation.aio._api import ( + ConnectorDeadlines, + ConnectorWorker, + EndpointDriverObservations, + EndpointManifest, + EndpointPortInput, + EndpointProvider, + EndpointShutdownMode, + EndpointStartGate, + PreparedEndpointDriver, + RunningEndpointDriver, + Session, +) +from pocketstation.errors import AudioInputFullError + + +@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() + 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 + 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_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) + 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 + assert observations.accepted_total == 1 + 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() + 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() + + 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_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_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() + 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_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.destination(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 (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() + + 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_aio_streams.py b/tests/test_aio_streams.py new file mode 100644 index 0000000..dd376a6 --- /dev/null +++ b/tests/test_aio_streams.py @@ -0,0 +1,265 @@ +"""Asyncio bounded audio-stream ownership and cancellation tests.""" + +from __future__ import annotations + +import asyncio +import threading +from time import monotonic + +import pocketstation._native as _native +import pytest +from pocketstation._api import STREAM_EOF, StreamInUseError, StreamModeError +from pocketstation.aio._api 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"]] + self.lifecycle_state = "running" + + 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_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, +) -> 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_audio_input.py b/tests/test_audio_input.py new file mode 100644 index 0000000..c9fc571 --- /dev/null +++ b/tests/test_audio_input.py @@ -0,0 +1,156 @@ +"""Application-owned PCM ingress contracts.""" + +from __future__ import annotations + +from array import array + +import pytest +from pocketstation._api import ( + AudioInputBufferError, + AudioInputCancelledError, + AudioInputClosedError, + AudioInputConfig, + AudioInputError, + 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 + + +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_cancelled" + + 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().cancelled_output_writes_total == 1 + assert running.stop().success diff --git a/tests/test_batch_transcription.py b/tests/test_batch_transcription.py new file mode 100644 index 0000000..3c86417 --- /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_demo 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_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..c84df91 --- /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") + + 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_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 new file mode 100644 index 0000000..da6617f --- /dev/null +++ b/tests/test_connector.py @@ -0,0 +1,580 @@ +from __future__ import annotations + +from array import array +from threading import Event +from time import monotonic + +import pytest +from pocketstation._api import ( + Connector, + ConnectorConfigurationField, + ConnectorConfigurationRequirement, + ConnectorConfigurationSchema, + ConnectorConfigurationValue, + ConnectorConfigurationValueKind, + ConnectorDeliveryOutcome, + ConnectorDeliveryReadiness, + ConnectorDriver, + ConnectorError, + ConnectorErrorStage, + ConnectorHealth, + ConnectorInputDescriptor, + ConnectorItem, + ConnectorManifest, + ConnectorRecovery, + ConnectorRetryability, + ConnectorShutdownMode, + ConnectorWorker, + PocketStationError, + Session, +) +from pocketstation.connector import 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.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]) + + +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" + + 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() + 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.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.graph 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_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 + 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_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() + + 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_control.py b/tests/test_control.py new file mode 100644 index 0000000..560a8fa --- /dev/null +++ b/tests/test_control.py @@ -0,0 +1,290 @@ +"""Bounded, typed control-plane client contract tests.""" + +from __future__ import annotations + +import httpx +import pytest +from pocketstation._api import ( + ControlClient, + ControlPlaneError, + SecretToken, + SessionId, +) +from pocketstation.aio._api import ControlClient as AsyncControlClient + +CREATE_RESPONSE = { + "session_id": "session_123", + "required_buses": ["application", "microphone"], + "source_token": "source-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 _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] = [] + + 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=_snapshot(ready=True, subscription_count=1)) + if request.url.path.endswith("/subscribe"): + return httpx.Response( + 200, + json={ + "session_id": "session_123", + "bus_id": "mix", + "subscriber_token": "next-subscriber-secret", + }, + ) + if 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", + }, + ) + 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, 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") + 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 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"), + ] + + +@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=_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", + }, + ) + 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, credentials.source_token + ) + subscriber = await client.issue_subscriber_credentials( + credentials.session_id, credentials.source_token + ) + await client.delete_session( + credentials.session_id, + credentials.source_token, + ) + + 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"), + ("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) + + +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={ + **_snapshot(ready=True, subscription_count=0), + "subscription_count": value, + }, + ) + ) + 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", SecretToken("source-secret")) + 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] 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..2d07a45 --- /dev/null +++ b/tests/test_conversation_interruptions.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import asyncio +from array import array +from collections.abc import AsyncIterator +from pathlib import Path +from threading import Event + +import pocketstation.aio as pocketstation +import pytest +from conversation_support import ( + TRANSCRIPT_SIGNAL, + transcript_operator, + transcript_source, + transcript_source_after, +) +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) + 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( + 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()) + + 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 or 0 for route in metrics.routes) + ) + assert discarded_output_frames_total >= 1 + assert stopped.success diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..b12e083 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pocketstation._api as pocketstation +import pytest +from pocketstation._api 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_endpoint_authoring.py b/tests/test_endpoint_authoring.py new file mode 100644 index 0000000..3f79906 --- /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._api 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_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_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..6606409 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import time +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pocketstation._api as pks +import pocketstation.aio._api 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__).with_name("fixtures") / "native_extension_plugin.rs" + 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.samefile(library) + 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.samefile(library) + 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..23624fd --- /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 pocketstation._native as _native +import pytest +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" + + +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..993bc98 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,313 @@ +"""Typed graph declarations owned by the Rust Session.""" + +from __future__ import annotations + +import pytest +from pocketstation._api import ( + AudioCaps, + BackpressurePolicy, + BinaryFormat, + ChannelLayout, + ClockDomain, + Codec, + CopyPolicy, + DeliverySemantics, + EdgeContract, + EdgeObservabilityLevel, + EndpointConfiguration, + EndpointDescriptor, + EventFormat, + LossPolicy, + MediaCaps, + Multiplicity, + Operator, + OperatorConfiguration, + PocketStationError, + PortDirection, + PortSpec, + Session, + SessionStartError, + 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 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, + 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_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 + 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 + 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 + 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"),) + + +@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")) + 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) + 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: + 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..5ed95bc --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,159 @@ +"""Stop, cancel, and bounded diagnostic trace lifecycle tests.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + EndpointFailureStage, + Session, + SessionComponentKind, + SessionEvent, + SessionFailureKind, + SessionTerminalState, + SessionTrace, + SessionTraceConfiguration, + SessionTraceRecordType, + Source, + TerminationDisposition, +) + + +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 + 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: + 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", + 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"), + 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[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_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..29efca2 --- /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 pocketstation._native as _native +import pytest +from pocketstation._api import ( + EndpointObservationStage, + PocketStationError, + Session, + Source, +) + + +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_discarded_output_frames_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..907aabe --- /dev/null +++ b/tests/test_native_module_structure.py @@ -0,0 +1,74 @@ +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")} + 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: + 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 "#[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..954787a --- /dev/null +++ b/tests/test_observations.py @@ -0,0 +1,141 @@ +"""Synchronous event stream ownership and native-wait tests.""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + EventStream, + RunningSession, + StreamInUseError, + StreamModeError, +) + + +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: + lifecycle_state = "running" + + 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_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 diff --git a/tests/test_operator_authoring.py b/tests/test_operator_authoring.py new file mode 100644 index 0000000..0fc5acd --- /dev/null +++ b/tests/test_operator_authoring.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from array import array +from threading import Event + +import pocketstation.aio._api as pks_aio +import pytest +from pocketstation._api import ( + AudioCaps, + ChannelLayout, + Connector, + ConnectorDeliveryOutcome, + MediaCaps, + OperatorEmission, + OperatorManifest, + OperatorNode, + OperatorPrepareContext, + OperatorProvider, + PocketStationError, + PortDirection, + PortSpec, + Session, + SignalEnvelope, + SignalSpec, + SourceEmission, + SourceManifest, + SourceProvider, +) + +_VOICE_FRAME_SAMPLES = 480 + + +def _pcm_media(*, frame_samples: int = _VOICE_FRAME_SAMPLES) -> 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 = _VOICE_FRAME_SAMPLES, +) -> 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") + 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) + + +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") + 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" + + +def test_python_operator_emits_pcm_into_core_reentry_and_recording(tmp_path) -> None: + 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 = [] + + 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, 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() + generated.send(session.polled_audio()) + generated.send_to(connector) + generated.record("generated") + + running = session.start() + 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 + 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 + 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]) * (_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]) * _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 480" + 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]) * _VOICE_FRAME_SAMPLES + + 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(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(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: + 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() + generated.send(session.polled_audio()) + generated.record("generated") + + running = await session.start() + 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 + 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 + assert closed.wait(1.0) diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py new file mode 100644 index 0000000..4f45436 --- /dev/null +++ b/tests/test_package_structure.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +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" + + +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 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: + 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..bea819b --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import sys + +import pocketstation._api as pocketstation +import pytest +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: + 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_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") + assert ( + pocketstation.microphone_permission_observation() + is PermissionObservation.NOT_OBSERVABLE + ) + + +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 ( + await async_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..d5e4536 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,87 @@ +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_a_small_intentional_entry_point() -> None: + assert set(pocketstation.__all__) == { + "RUNTIME_COMPATIBILITY", + "AudioInput", + "AudioInputConfig", + "Capture", + "CaptureError", + "PcmSource", + "PocketStationError", + "RecordingOutcome", + "RunningSession", + "RuntimeCompatibility", + "Session", + "SessionError", + "Source", + "StopResult", + "aio", + "capture", + "discover_sources", + } + + +def test_advanced_contracts_are_not_duplicated_at_the_package_root() -> None: + assert not hasattr(pocketstation, "Connector") + assert not hasattr(pocketstation, "OperatorProvider") + + +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: + 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) -> RouteId", + "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..a4b6e3d --- /dev/null +++ b/tests/test_realtime_boundary.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +import threading +from pathlib import Path +from time import monotonic + +import pocketstation._api as pks +import pytest +from pocketstation._native import Session as NativeSession + +ROOT = Path(__file__).parents[1] +CHILD = Path(__file__).with_name("_pkss_child.py") + + +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) + 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() + + assert "PyAny" not in sidecar_source + assert "PyObject" not in sidecar_source + assert "callable" not in sidecar_source.lower() + + +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..14962b1 --- /dev/null +++ b/tests/test_recording.py @@ -0,0 +1,104 @@ +"""Multistem recording remains attached to source-aware Rust Session stems.""" + +from __future__ import annotations + +from time import monotonic +from types import SimpleNamespace + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + RecordingDiscontinuityKind, + RecordingOutcome, + RecordingState, + Session, + Source, +) + + +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 + 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 == 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()) + 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( + 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], + ) + ) + + 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..98d3e80 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,287 @@ +"""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._api import ( + ControlClient, + RelayError, + RelaySession, + RelayTimeoutError, + Session, + Source, +) + +CREATE_RESPONSE = { + "session_id": "session_123", + "required_buses": ["application", "microphone"], + "source_token": "source-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_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] = [] + snapshots = iter( + [ + _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" 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)) + 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) + + 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, + ) + + 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.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) + + 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"), + ("POST", "/v1/sessions/session_123/invitations"), + ("GET", "/v1/sessions/session_123"), + ("DELETE", "/v1/sessions/session_123"), + ] + + +def test_relay_wait_uses_a_single_bounded_deadline() -> None: + def control_handler(request: httpx.Request) -> httpx.Response: + 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(ready=False, 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, + ) + 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() + + +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", + [ + "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" 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(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) + + 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, + ) + 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, + ) + assert requests == [] + + +def _snapshot(*, ready: bool, subscription_count: int) -> dict[str, object]: + return { + "session_id": "session_123", + "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_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) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..5a11e0f --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,92 @@ +"""Synchronous public Session contract tests.""" + +from __future__ import annotations + +from array import array + +import pytest +from pocketstation._api import ( + PocketStationError, + Session, + SessionLifecycleState, + 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("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: + 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" + + +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 diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py new file mode 100644 index 0000000..a7a4b73 --- /dev/null +++ b/tests/test_sidecar.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from time import monotonic + +import pocketstation._api 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) + 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: + 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( + 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..5cdc895 --- /dev/null +++ b/tests/test_signal_streams.py @@ -0,0 +1,213 @@ +"""Real Session conformance for bounded typed-signal subscriptions.""" + +from __future__ import annotations + +from pathlib import Path +from time import monotonic + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + STREAM_EOF, + BackpressurePolicy, + BinaryFormat, + Operator, + PocketStationError, + Session, + SignalAudioPayload, + SignalEnvelope, + SignalSpec, + Source, + TextFormat, + 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.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 + 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_authoring.py b/tests/test_source_authoring.py new file mode 100644 index 0000000..0009725 --- /dev/null +++ b/tests/test_source_authoring.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from threading import Event + +import pocketstation.aio._api as pks_aio +import pytest +from pocketstation._api 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 + + +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) + 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_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( + "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 diff --git a/tests/test_source_lifecycle.py b/tests/test_source_lifecycle.py new file mode 100644 index 0000000..786de1b --- /dev/null +++ b/tests/test_source_lifecycle.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from types import SimpleNamespace + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + Platform, + RunningSession, + Session, + Source, + SourceFailureClass, + SourceKind, + SourceRecoveryRequirement, + SourceRuntimeEvent, + SourceRuntimeEventKind, +) +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..3038de8 --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from array import array +from dataclasses import FrozenInstanceError, replace +from types import SimpleNamespace + +import pytest +from pocketstation._api import ( + ApplicationPolicyObservation, + AudioInputBufferError, + AudioInputClosedError, + AudioInputConfig, + AudioInputFullError, + CaptureCapabilityState, + CaptureOpenOutcome, + CaptureScopeKind, + CaptureSessionGrant, + DiscoveredSource, + PermissionObservation, + 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" + + +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(SourceKind.OUTPUT_DEVICE)) + 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 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 + 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 + + +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_station.py b/tests/test_station.py index 2d3209c..bad1fa1 100644 --- a/tests/test_station.py +++ b/tests/test_station.py @@ -1,339 +1,20 @@ -"""Tests for the async PocketStation session client.""" -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 +from pocketstation.control import ControlClient - 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" not in pocketstation.__all__ + assert ControlClient.__module__ == "pocketstation.control" + 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..a9679af --- /dev/null +++ b/tests/test_stream_state_machine.py @@ -0,0 +1,144 @@ +"""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._api import ( + STREAM_EOF, + StreamError, + StreamInUseError, + StreamModeError, +) +from pocketstation.aio._api 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..5c4de49 --- /dev/null +++ b/tests/test_streams.py @@ -0,0 +1,233 @@ +"""Synchronous bounded audio-stream ownership tests.""" + +from __future__ import annotations + +import threading + +import pocketstation._native as _native +import pytest +from pocketstation._api import ( + STREAM_EOF, + AudioStream, + RunningSession, + StreamInUseError, + StreamModeError, +) + + +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"]] + self.lifecycle_state = "running" + + 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_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: + """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.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): + 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_transcription_example.py b/tests/test_transcription_example.py new file mode 100644 index 0000000..eef6b63 --- /dev/null +++ b/tests/test_transcription_example.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio +import json +from array import array +from dataclasses import dataclass + +import pocketstation.aio as pks_aio +import pytest +from pocketstation_demo import ( + FasterWhisper, + FasterWhisperConfiguration, +) + + +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.01) + 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 diff --git a/tests/test_types.py b/tests/test_types.py index d7f16a1..3d53fbb 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,54 +1,11 @@ -"""Tests for the public PocketStation values.""" -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.""" -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" +import pytest +from pocketstation._api 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/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..0bbdddf --- /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_demo 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/tests/transcription/wav_input.py b/tests/transcription/wav_input.py new file mode 100644 index 0000000..516e02d --- /dev/null +++ b/tests/transcription/wav_input.py @@ -0,0 +1,68 @@ +"""Finite PCM WAV input for transcription qualification.""" + +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, + 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): + 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 + ) + if close_when_complete: + await audio.close() + + +__all__ = ["WavInput", "feed_live", "read_pcm16_wav"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..27f0ba8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1136 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "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'", +] + +[[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 = "av" +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]] +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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.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 = [ + { 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.24.4" +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" }, + { name = "sympy" }, +] +wheels = [ + { 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]] +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" }, +] +transcription = [ + { name = "faster-whisper" }, +] +voice-agent-debug = [ + { name = "websockets" }, +] + +[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" }, + { name = "websockets", marker = "extra == 'voice-agent-debug'", specifier = ">=17.0,<18" }, +] +provides-extras = ["transcription", "voice-agent-debug", "dev"] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +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/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]] +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 = "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" +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 = "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 = "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" +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" +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" }, +] + +[[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" }, +]