diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 00000000..7efe6142 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,174 @@ +# This file is autogenerated by maturin v1.6.0 +# To update, run +# +# maturin generate-ci github +# +name: python + +on: + push: + branches: + - "*" + tags: + - "*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Ignore linux for now - cannot install Alsa? + # linux: + # runs-on: ${{ matrix.platform.runner }} + # strategy: + # matrix: + # platform: + # - runner: ubuntu-latest + # target: x86_64 + # - runner: ubuntu-latest + # target: x86 + # # - runner: ubuntu-latest + # # target: aarch64 + # # - runner: ubuntu-latest + # # target: armv7 + # # - runner: ubuntu-latest + # # target: s390x + # # - runner: ubuntu-latest + # # target: ppc64le + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-python@v5 + # with: + # python-version: 3.x + # - name: Build wheels + # uses: PyO3/maturin-action@v1 + # with: + # target: ${{ matrix.platform.target }} + # args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml + # sccache: "true" + # before-script-linux: yum update -y && yum install -y alsa-dev alsa-utils alsa-lib + # manylinux: auto + # - name: Upload wheels + # uses: actions/upload-artifact@v4 + # with: + # name: wheels-linux-${{ matrix.platform.target }} + # path: dist + # + # Ignore musl for now - issue with pkg-config installed but not found. + # + # musllinux: + # runs-on: ${{ matrix.platform.runner }} + # strategy: + # matrix: + # platform: + # - runner: ubuntu-latest + # target: x86_64 + # - runner: ubuntu-latest + # target: x86 + # - runner: ubuntu-latest + # target: aarch64 + # - runner: ubuntu-latest + # target: armv7 + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-python@v5 + # with: + # python-version: 3.x + # - name: Build wheels + # uses: PyO3/maturin-action@v1 + # with: + # target: ${{ matrix.platform.target }} + # args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml + # sccache: "true" + # before-script-linux: yum update -y && yum install -y alsa-utils alsa-lib + # manylinux: musllinux_1_2 + # - name: Upload wheels + # uses: actions/upload-artifact@v4 + # with: + # name: wheels-musllinux-${{ matrix.platform.target }} + # path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-12 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist --manifest-path python/Cargo.toml + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + # needs: [linux, musllinux, windows, macos, sdist] # musl and linux ignored for now + needs: [windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/Cargo.toml b/Cargo.toml index fd873391..e114846d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ include = [ ] rust-version = "1.76" +[workspace] +members = ["python"] + [dependencies] almost = "0.2.0" arc-swap = "1.6" diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000..c1813783 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,73 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version +/.env diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 00000000..57ec3eb6 --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "web_audio_api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "web_audio_api" +crate-type = ["cdylib"] +doc = false + +[dependencies] +pyo3 = "0.21.1" +web-audio-api-rs = { path = "../", package = "web-audio-api" } diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..1485e747 --- /dev/null +++ b/python/README.md @@ -0,0 +1,25 @@ +# Python bindings for web-audio-api-rs + +## Local development + +```bash +# cd to this directory + +# if not already, create a virtual env +python3 -m venv .env + +# enter the virtual env +source .env/bin/activate + +# (re)build the package +maturin develop +``` + +```python +import web_audio_api +ctx = web_audio_api.AudioContext() +osc = web_audio_api.OscillatorNode(ctx) +osc.connect(ctx.destination()) +osc.start() +osc.frequency().set_value(300) +``` diff --git a/python/main.py b/python/main.py new file mode 100644 index 00000000..6292f713 --- /dev/null +++ b/python/main.py @@ -0,0 +1,15 @@ +import web_audio_api +from time import sleep + +ctx = web_audio_api.AudioContext() +osc = web_audio_api.OscillatorNode(ctx) +osc.connect(ctx.destination()) +osc.start() + +print("freq =", osc.frequency().value); +sleep(4) + +osc.frequency().value = 300 +print("freq =", osc.frequency().value); + +sleep(4) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..df7a30ea --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["maturin>=1.6,<2.0"] +build-backend = "maturin" + +[project] +name = "web-audio-api" +version = "0.1.0" +requires-python = ">=3.8" +description = "A Rust/Python implementation of the Web Audio API, for use in non-browser contexts" +license = {text = "MIT License"} +keywords = ["web-audio-api", "audio", "sound", "dsp"] +classifiers = [ + "Programming Language :: Rust", + "Topic :: Multimedia :: Sound/Audio", + "Programming Language :: Python :: Implementation :: PyPy", +] + +[project.urls] +Repository = "https://github.com/orottier/web-audio-api-rs" +Issues = "https://github.com/orottier/web-audio-api-rs/issues" + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 00000000..2dea4132 --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,97 @@ +use pyo3::prelude::*; +use std::sync::{Arc, Mutex}; + +use web_audio_api_rs::context::BaseAudioContext; +use web_audio_api_rs::node::{AudioNode as RsAudioNode, AudioScheduledSourceNode as _}; + +#[pyclass] +struct AudioContext(web_audio_api_rs::context::AudioContext); + +#[pymethods] +impl AudioContext { + #[new] + fn new() -> Self { + Self(Default::default()) + } + + fn destination(&self) -> AudioNode { + let dest = self.0.destination(); + let node = Arc::new(Mutex::new(dest)) as Arc>; + AudioNode(node) + } +} + +#[pyclass(subclass)] +struct AudioNode(Arc>); + +#[pymethods] +impl AudioNode { + fn connect(&self, other: &Self) { + self.0.lock().unwrap().connect(&*other.0.lock().unwrap()); + } + fn disconnect(&self, other: &Self) { + self.0 + .lock() + .unwrap() + .disconnect_dest(&*other.0.lock().unwrap()); + } +} + +#[pyclass] +struct AudioParam(web_audio_api_rs::AudioParam); + +#[pymethods] +impl AudioParam { + #[getter] + fn value(&self) -> PyResult { + Ok(self.0.value()) + } + + #[setter] + fn set_value(&self, value: f32) -> PyResult<()> { + self.0.set_value(value); + Ok(()) + } +} + +#[pyclass(extends = AudioNode)] +struct OscillatorNode(Arc>); + +#[pymethods] +impl OscillatorNode { + #[new] + fn new(ctx: &AudioContext) -> (Self, AudioNode) { + let osc = ctx.0.create_oscillator(); + let node = Arc::new(Mutex::new(osc)); + let audio_node = Arc::clone(&node) as Arc>; + (OscillatorNode(node), AudioNode(audio_node)) + } + + #[pyo3(signature = (when=0.0))] + fn start(&mut self, when: f64) { + self.0.lock().unwrap().start_at(when) + } + + #[pyo3(signature = (when=0.0))] + fn stop(&mut self, when: f64) { + self.0.lock().unwrap().stop_at(when) + } + + fn frequency(&self) -> AudioParam { + AudioParam(self.0.lock().unwrap().frequency().clone()) + } + + fn detune(&self) -> AudioParam { + AudioParam(self.0.lock().unwrap().detune().clone()) + } +} + +/// A Python module implemented in Rust. +#[pymodule] +fn web_audio_api(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +}