omi-audio 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. omi_audio-0.1.0a1/CHANGELOG.md +81 -0
  2. omi_audio-0.1.0a1/CONTRIBUTING.md +161 -0
  3. omi_audio-0.1.0a1/LICENSE +21 -0
  4. omi_audio-0.1.0a1/MANIFEST.in +11 -0
  5. omi_audio-0.1.0a1/PKG-INFO +188 -0
  6. omi_audio-0.1.0a1/README.md +142 -0
  7. omi_audio-0.1.0a1/SECURITY.md +81 -0
  8. omi_audio-0.1.0a1/docs/ARCHITECTURE.md +113 -0
  9. omi_audio-0.1.0a1/docs/DATA-MODEL.md +176 -0
  10. omi_audio-0.1.0a1/docs/GAME-INTEGRATION.md +238 -0
  11. omi_audio-0.1.0a1/docs/MIXING.md +151 -0
  12. omi_audio-0.1.0a1/docs/README.md +46 -0
  13. omi_audio-0.1.0a1/docs/SPATIALISATION.md +203 -0
  14. omi_audio-0.1.0a1/docs/VRML97.md +116 -0
  15. omi_audio-0.1.0a1/docs/api/conf.py +63 -0
  16. omi_audio-0.1.0a1/docs/api/index.rst +76 -0
  17. omi_audio-0.1.0a1/docs/images/cone.svg +38 -0
  18. omi_audio-0.1.0a1/docs/images/distance-models.svg +43 -0
  19. omi_audio-0.1.0a1/docs/images/ellipsoids.svg +37 -0
  20. omi_audio-0.1.0a1/docs/images/emitter-types.svg +40 -0
  21. omi_audio-0.1.0a1/docs/images/pan.svg +37 -0
  22. omi_audio-0.1.0a1/docs/make_diagrams.py +479 -0
  23. omi_audio-0.1.0a1/docs/reviews/2026-07-31-code-review.md +708 -0
  24. omi_audio-0.1.0a1/examples/orbit.py +135 -0
  25. omi_audio-0.1.0a1/pyproject.toml +138 -0
  26. omi_audio-0.1.0a1/setup.cfg +4 -0
  27. omi_audio-0.1.0a1/src/omi_audio/__init__.py +86 -0
  28. omi_audio-0.1.0a1/src/omi_audio/_backend.py +47 -0
  29. omi_audio-0.1.0a1/src/omi_audio/clip.py +290 -0
  30. omi_audio-0.1.0a1/src/omi_audio/device.py +191 -0
  31. omi_audio-0.1.0a1/src/omi_audio/engine.py +353 -0
  32. omi_audio-0.1.0a1/src/omi_audio/library.py +229 -0
  33. omi_audio-0.1.0a1/src/omi_audio/mixer.py +562 -0
  34. omi_audio-0.1.0a1/src/omi_audio/model.py +479 -0
  35. omi_audio-0.1.0a1/src/omi_audio/py.typed +0 -0
  36. omi_audio-0.1.0a1/src/omi_audio/spatial.py +397 -0
  37. omi_audio-0.1.0a1/src/omi_audio/synth.py +103 -0
  38. omi_audio-0.1.0a1/src/omi_audio.egg-info/PKG-INFO +188 -0
  39. omi_audio-0.1.0a1/src/omi_audio.egg-info/SOURCES.txt +54 -0
  40. omi_audio-0.1.0a1/src/omi_audio.egg-info/dependency_links.txt +1 -0
  41. omi_audio-0.1.0a1/src/omi_audio.egg-info/requires.txt +20 -0
  42. omi_audio-0.1.0a1/src/omi_audio.egg-info/top_level.txt +1 -0
  43. omi_audio-0.1.0a1/tests/conftest.py +90 -0
  44. omi_audio-0.1.0a1/tests/support.py +115 -0
  45. omi_audio-0.1.0a1/tests/test_backend.py +58 -0
  46. omi_audio-0.1.0a1/tests/test_clip.py +377 -0
  47. omi_audio-0.1.0a1/tests/test_device.py +316 -0
  48. omi_audio-0.1.0a1/tests/test_diagrams.py +111 -0
  49. omi_audio-0.1.0a1/tests/test_engine.py +447 -0
  50. omi_audio-0.1.0a1/tests/test_examples.py +57 -0
  51. omi_audio-0.1.0a1/tests/test_library.py +275 -0
  52. omi_audio-0.1.0a1/tests/test_mixer.py +676 -0
  53. omi_audio-0.1.0a1/tests/test_model.py +393 -0
  54. omi_audio-0.1.0a1/tests/test_output_level.py +225 -0
  55. omi_audio-0.1.0a1/tests/test_spatial.py +377 -0
  56. omi_audio-0.1.0a1/tox.ini +71 -0
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ Notable changes to `omi_audio`. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
+ follows [semantic versioning](https://semver.org/) — with the usual caveat that
6
+ `0.x` makes no compatibility promise, and this is an alpha.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0a1] — 2026-07-31
11
+
12
+ **Initial public release.** An alpha: the API may still move
13
+
14
+ ### The package
15
+
16
+ Renderer-agnostic spatial audio built natively on glTF's `KHR_audio_emitter`
17
+ model — which is the Web Audio `PannerNode` model — and mixed in NumPy. NumPy is
18
+ the only hard dependency; `miniaudio` is an optional extra for decoding files
19
+ and reaching a sound card, and without it the library still mixes and stays
20
+ silent.
21
+
22
+ ### What is in it
23
+
24
+ - **The data model** (`model`) — `KHR_audio_emitter` as plain dataclasses, field
25
+ for field, with the extension's own names and defaults. `from_gltf` /
26
+ `to_gltf` round-trip, and `from_gltf` never raises whatever a third-party
27
+ document contains. Node and scene emitter references are read, so a positional
28
+ emitter can be located.
29
+ - **Resolving a document's audio** (`library`) — `AudioLibrary` holds what one
30
+ document's `audio` array has resolved to. **A `uri` is never resolved, opened
31
+ or interpreted by this package**: the application supplies a `fetch` callback,
32
+ because only it knows where its content lives and what a third-party document
33
+ may reach. Audio arrives as a local file, as bytes, or as an already-decoded
34
+ clip — so `bufferView` audio (every `.glb`) and `data:` URIs play, and a
35
+ download that lands three frames later is an ordinary silence until it does.
36
+ - **Spatialisation** (`spatial`) — the extension's three distance models
37
+ implemented as written, the Web Audio cone, equal-power stereo panning with
38
+ the behind-the-listener fold, and VRML97's two ellipsoids for `Sound` nodes,
39
+ which nothing else can express. Every curve is a pure function of geometry.
40
+ - **Clips** (`clip`) — encoded audio decoded to mono float32 at one rate, from a
41
+ file or from bytes, decoded once and cached by name.
42
+ - **The mixer** (`mixer`) — a fixed voice pool summed into stereo blocks:
43
+ allocation-free, lock-free on the audio thread, priority-based voice stealing,
44
+ per-block gain ramping, and an underwater low-pass.
45
+ - **The device seam** (`device`) — `miniaudio`, or silence. A missing package, a
46
+ device that will not open and a machine with no audio hardware all end in one
47
+ warning and a `NullDevice`; `open_device()` cannot raise.
48
+ - **The engine** (`engine`) — the one object an application holds, keeping
49
+ decoding and path resolution off the audio thread.
50
+ - **Synthesised sounds** (`synth`) — tones, chirps, noise and impacts made out of
51
+ arithmetic, so a demo or a test needs no assets and no licences.
52
+
53
+ ### Known limitations
54
+
55
+ Stated here because finding them out later is worse:
56
+
57
+ - **Stereo only**, and the pan carries azimuth alone — a sound overhead and one
58
+ dead ahead are indistinguishable. Height needs an HRTF, surround needs more
59
+ than two channels, and neither is implemented.
60
+ - **No reverb, occlusion or doppler.** `muffle` is the only effect and it is a
61
+ master-bus low-pass.
62
+ - **No streaming**: clips are decoded whole, into memory.
63
+ - **No scheduling**: nothing here has a clock, so `autoplay` starts when the
64
+ application says its scene has begun.
65
+ - **`maxDistance` follows the extension's formulas, not its prose** — the two
66
+ disagree, and only the `linear` model uses it. `PositionalProperties.in_range()`
67
+ is the other reading, kept explicit. See
68
+ [SPATIALISATION.md](docs/SPATIALISATION.md#maxdistance-means-two-different-things-and-this-library-picks-one).
69
+
70
+ ### Quality of the release
71
+
72
+ - 435 tests, 99% branch coverage, `ruff` and `mypy --strict` clean, all gated in
73
+ CI across Python 3.10–3.15 with and without the optional backend.
74
+ - `py.typed` shipped, so the annotations are a promise downstream type checkers
75
+ can use.
76
+ - **Largely LLM-written**, and the README, the package docstring and
77
+ [SECURITY.md](SECURITY.md) all say so. Review it before relying on it for
78
+ anything that matters.
79
+
80
+ [Unreleased]: https://github.com/mcfletch/omi_audio/compare/v0.1.0a1...HEAD
81
+ [0.1.0a1]: https://github.com/mcfletch/omi_audio/releases/tag/v0.1.0a1
@@ -0,0 +1,161 @@
1
+ # Contributing
2
+
3
+ Bug reports, questions and patches all welcome. This file is short because the
4
+ rules that matter are few.
5
+
6
+ ## Getting set up
7
+
8
+ ```bash
9
+ git clone https://github.com/mcfletch/omi_audio
10
+ cd omi_audio
11
+ python -m venv .venv && . .venv/bin/activate
12
+ pip install -e ".[dev]"
13
+ pytest
14
+ ```
15
+
16
+ That runs the whole suite in under a second. `miniaudio` comes in with the
17
+ `dev` extra; without it the package still works and simply stays silent, which
18
+ is a path the suite exercises on purpose.
19
+
20
+ ## Before you open a pull request
21
+
22
+ ```bash
23
+ ruff check . # lint
24
+ mypy --strict src/omi_audio # types
25
+ pytest --cov --cov-branch # tests, with the coverage floor
26
+ ```
27
+
28
+ or, all of it the way CI does:
29
+
30
+ ```bash
31
+ tox -e lint,typecheck,py312-playback,py312-nobackend
32
+ ```
33
+
34
+ All three are gates. A merge needs them green.
35
+
36
+ ## What the code is held to
37
+
38
+ This is a small library that other people are meant to read, so:
39
+
40
+ - **Tests come first, and they must fail before they pass.** Write the assertion,
41
+ watch it go red, then make it green. A test written after the code often only
42
+ proves the code does what it does.
43
+ - **A test must be able to fail for the reason it is named after.** `assert
44
+ handle is not None` in a test called *the source's gain is applied* is not a
45
+ test of the gain. If you are unsure a test is real, break the thing it covers
46
+ and check that it notices.
47
+ - **Docstrings explain *why*, not *what*.** The signature says what. The
48
+ docstring says what problem this shape avoids, in prose, and names the failure
49
+ it is designed against. That habit is the most valuable thing in this
50
+ codebase; please keep it up.
51
+ - **Documentation ships with the change, not after it.** A new option, a changed
52
+ default, a new file format, a changed public API — none is finished while
53
+ `docs/` still describes the old world. Say in your pull request which
54
+ documentation you changed, and if you changed none, say that and why.
55
+ - **`%`-formatting, not f-strings**, matching the surrounding code and required
56
+ by the logging calls (`log.warning('%s', x)` defers the work until somebody is
57
+ listening). `ruff` is configured accordingly.
58
+
59
+ ## Two rules with teeth
60
+
61
+ ### The audio thread
62
+
63
+ Everything in `mixer.py` from `mix()` down runs on the device's own thread.
64
+ There it must **not allocate, block, decode, resolve a path, take a lock, or
65
+ log**. An allocation on the audio thread is a garbage collection on the audio
66
+ thread, and that is an audible gap.
67
+
68
+ Pre-allocate in `__init__` and write with NumPy's `out=`. Two tests assert this
69
+ under `tracemalloc`; if you make them fail, the answer is to allocate less, not
70
+ to raise the threshold.
71
+
72
+ There is exactly one sanctioned exception, and it is documented where it lives:
73
+ the oversized-block path may grow its silence buffer, once per size, on a path
74
+ that is already broken.
75
+
76
+ Numbers crossing *onto* the thread are checked on the control side, where it is
77
+ free — see `_finite()`. A NaN gain survives `np.clip` and every voice shares one
78
+ buffer, so one bad emitter would otherwise silence the whole scene.
79
+
80
+ ### The document is not trusted
81
+
82
+ `omi_audio` consumes glTF from third parties. A `uri` is **never** resolved,
83
+ opened or interpreted here; that is the application's job, through
84
+ `AudioLibrary`. `model.from_gltf()` must never raise and must never let a value
85
+ of the wrong type into the model. See [SECURITY.md](SECURITY.md).
86
+
87
+ ## Changing a gain curve
88
+
89
+ The diagrams in `docs/images/` are generated from `omi_audio.spatial` by
90
+ `docs/make_diagrams.py`, and `tests/test_diagrams.py` fails if the committed
91
+ files stop matching. So:
92
+
93
+ ```bash
94
+ python docs/make_diagrams.py # or: tox -e diagrams
95
+ ```
96
+
97
+ and commit what changes. A picture of a gain curve is a claim about the code.
98
+
99
+ ## Releasing
100
+
101
+ **Bumping the version is what cuts a release.** `.github/workflows/release.yml`
102
+ runs on every push to `main`: it runs the whole test workflow, then reads
103
+ `__version__` from `src/omi_audio/__init__.py` and asks PyPI whether that
104
+ version exists. If it does, the push is an ordinary CI run and nothing is
105
+ published. If it does not, the sdist and wheel are built and uploaded.
106
+
107
+ So a release is:
108
+
109
+ 1. Update `CHANGELOG.md` under a new heading.
110
+ 2. Bump `__version__` in `src/omi_audio/__init__.py`.
111
+ 3. Merge to `main`.
112
+
113
+ Nothing is published from a red build. PyPI versions are immutable, so a
114
+ release number burned on a broken build cannot be taken back — which is why the
115
+ publish job waits on every released Python, both backends, lint, types, the
116
+ coverage floor and the docs build, and why the version check treats anything
117
+ other than a clean 200 or 404 from PyPI as a reason to stop rather than to guess.
118
+
119
+ The one check that is *not* binding is Python 3.15, which is still a
120
+ prerelease. It runs on every push and its failures are visible, but a
121
+ regression in a CPython beta is not a reason to hold up a merge or a release.
122
+ Make it binding by moving `"3.15"` back into the `test` matrix in
123
+ `.github/workflows/test.yml` and deleting the `prerelease` job.
124
+
125
+ ### One-time setup
126
+
127
+ The workflow needs two things that do not live in this repository, and it
128
+ cannot publish until both exist:
129
+
130
+ **A PyPI trusted publisher** for this project — owner `mcfletch`, repository
131
+ `omi_audio`, workflow `release.yml`, and the **environment field left blank**.
132
+ The workflow uses no GitHub environment, so its OIDC claim carries none; a
133
+ publisher that names one will not match, and the upload is rejected with an
134
+ error that does not obviously say why.
135
+
136
+ Before the first release the project does not exist on PyPI yet, so this has to
137
+ be added as a *pending* publisher (PyPI → Your projects → Publishing).
138
+
139
+ No API token is stored anywhere: the upload authenticates over OIDC. Note that
140
+ without an environment there is no place to require a reviewer, so a green push
141
+ to `main` with a new version publishes without anyone approving it — the version
142
+ bump is the decision.
143
+
144
+ ## Licensing
145
+
146
+ `omi_audio` is MIT, and everything it depends on is MIT or public domain. **Do
147
+ not contribute code copied or translated from a GPL, LGPL, AGPL, SSPL or CC-BY-SA
148
+ source**, in whole or in part. The `miniaudio` choice was made on exactly these
149
+ grounds — the convenient alternatives (`libsndfile`, PyAV, `pydub`-via-ffmpeg)
150
+ are copyleft — and the reasoning is recorded in `pyproject.toml` so it is
151
+ not quietly undone.
152
+
153
+ Behaviour taken from a published *specification* is fine and welcome; cite it
154
+ from the docstring, as the existing code cites `KHR_audio_emitter`, the Web
155
+ Audio API and ISO/IEC 14772-1.
156
+
157
+ ## A note on how this was written
158
+
159
+ Much of this package is LLM-written, and it says so in the README. That is not a
160
+ reason to hold a contribution to a lower standard — it is the reason the tests,
161
+ the review in `docs/reviews/` and the gates above exist.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike C. Fletcher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CONTRIBUTING.md
5
+ include SECURITY.md
6
+ include pyproject.toml
7
+ include tox.ini
8
+ recursive-include src/omi_audio py.typed
9
+ recursive-include docs *.md *.py *.rst *.svg
10
+ recursive-include examples *.py
11
+ recursive-include tests *.py
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: omi_audio
3
+ Version: 0.1.0a1
4
+ Summary: Renderer-agnostic spatial audio on the glTF KHR_audio_emitter model, mixed in NumPy
5
+ Author-email: "Mike C. Fletcher" <mcfletch@vrplumber.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mcfletch/omi_audio
8
+ Project-URL: Repository, https://github.com/mcfletch/omi_audio
9
+ Project-URL: Documentation, https://github.com/mcfletch/omi_audio/tree/main/docs
10
+ Project-URL: Changelog, https://github.com/mcfletch/omi_audio/blob/main/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/mcfletch/omi_audio/issues
12
+ Keywords: audio,spatial audio,3D sound,panning,mixer,OMI,glTF,KHR_audio_emitter,numpy,game
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Topic :: Multimedia :: Sound/Audio
24
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Mixers
25
+ Classifier: Topic :: Games/Entertainment
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=2.0
30
+ Provides-Extra: playback
31
+ Requires-Dist: miniaudio>=1.59; extra == "playback"
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest; extra == "test"
34
+ Requires-Dist: pytest-cov; extra == "test"
35
+ Requires-Dist: miniaudio>=1.59; extra == "test"
36
+ Provides-Extra: docs
37
+ Requires-Dist: sphinx>=7.0; extra == "docs"
38
+ Requires-Dist: furo; extra == "docs"
39
+ Provides-Extra: dev
40
+ Requires-Dist: omi_audio[test]; extra == "dev"
41
+ Requires-Dist: omi_audio[docs]; extra == "dev"
42
+ Requires-Dist: tox>=4.0; extra == "dev"
43
+ Requires-Dist: mypy; extra == "dev"
44
+ Requires-Dist: ruff; extra == "dev"
45
+ Dynamic: license-file
46
+
47
+ # omi_audio
48
+
49
+ Renderer-agnostic **spatial audio** for Python, built natively on the glTF
50
+ [`KHR_audio_emitter`](https://github.com/omigroup/gltf-extensions/tree/main/extensions/2.0/KHR_audio_emitter)
51
+ data model — which is the Web Audio `PannerNode` model. Emitters have a distance
52
+ curve, an optional directional cone and a gain; a listener hears them panned
53
+ about its own forward axis. The mixing is a fixed voice pool summed in NumPy.
54
+
55
+ - **NumPy is the only hard dependency.** No graphics library, no scenegraph, no
56
+ audio library is required to mix.
57
+ - **`miniaudio` is optional** — it decodes files and reaches the sound card.
58
+ Without it the library still mixes and simply stays silent.
59
+ - **Nothing here is copyleft**, and neither is anything it depends on:
60
+ `miniaudio` and every decoder it bundles are MIT or public domain.
61
+ - **Silence is a backend.** A missing package, a device that will not open, or a
62
+ machine with no audio hardware all end in one warning and a `NullDevice`.
63
+ `open_device()` cannot raise.
64
+ - **The audio thread never allocates, blocks, decodes or logs.** Pre-allocated
65
+ buffers, NumPy `out=` everywhere, and a lock the mixing never takes.
66
+ - **A document's `uri` is never resolved here.** A glTF file comes from
67
+ somebody you have never met; deciding what one of its strings is allowed to
68
+ mean is the application's call, not a library's. See
69
+ [`AudioLibrary`](docs/DATA-MODEL.md#getting-the-actual-audio-audiolibrary).
70
+
71
+ > ⚠️ **This code is largely LLM-written.** It has a test suite, but it comes with
72
+ > **no guarantees** of correctness, accuracy, or fitness for any purpose (see
73
+ > [`LICENSE`](LICENSE), MIT). Review it before relying on it for anything that
74
+ > matters.
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install omi_audio # mixing only (NumPy)
80
+ pip install "omi_audio[playback]" # + miniaudio, for files and a sound card
81
+ ```
82
+
83
+ ## Quick start
84
+
85
+ ```python
86
+ from omi_audio import AudioEngine, model, synth
87
+
88
+ engine = AudioEngine() # opens a device, or silence
89
+
90
+ # A sound with no file behind it, so this runs anywhere.
91
+ engine.clips.put('ping', synth.impact(0.4, seed=1))
92
+
93
+ emitter = model.AudioEmitter(
94
+ gain=0.8,
95
+ positional=model.PositionalProperties(refDistance=2.0, rolloffFactor=1.0),
96
+ )
97
+
98
+ handle = engine.play('ping', emitter=emitter, position=(3.0, 0.0, -5.0))
99
+ ```
100
+
101
+ Run it for real, with no assets and no sound card required:
102
+
103
+ ```bash
104
+ python examples/orbit.py # a sound orbits the listener for ten seconds
105
+ python examples/orbit.py --silent # never opens a device, still prints levels
106
+ ```
107
+
108
+ Each frame, move the listener and re-aim whatever is still playing. A moving
109
+ sound is *re-aimed*, never restarted: `aim()` writes two floats and the mixer
110
+ ramps to them across the next block.
111
+
112
+ ```python
113
+ engine.listen(camera) # anything with .position/.quaternion
114
+ engine.aim(handle, emitter, position=emitter_world_position)
115
+ ```
116
+
117
+ ## Testing without a sound card
118
+
119
+ Everything below the device is arithmetic over arrays, so build an engine on a
120
+ `NullDevice` and assert on the mix:
121
+
122
+ ```python
123
+ from omi_audio import AudioEngine, NullDevice, synth
124
+
125
+ engine = AudioEngine(device=NullDevice(sample_rate=8000), voices=8)
126
+ engine.mixer.play(synth.tone(440.0, 1.0, sample_rate=8000), pan=1.0)
127
+ block = engine.mixer.mix(64) # (64, 2) float32
128
+ assert block[:, 0].max() < 1e-9 # nothing in the left ear
129
+ assert block[:, 1].max() > 0.1 # and plenty in the right
130
+ ```
131
+
132
+ ## The pieces
133
+
134
+ In the order sound travels through them:
135
+
136
+ | Module | Answers |
137
+ |---|---|
138
+ | `model` | `KHR_audio_emitter` as typed records, with the extension's own field names and defaults; `from_gltf`/`to_gltf` round-trip, and the node/scene references that say where an emitter *is* |
139
+ | `library` | What a document's audio references have resolved to — the seam where **your** resolver, not this library, decides what a `uri` means |
140
+ | `spatial` | Every gain curve — three glTF distance models, the Web Audio cone, VRML97's two ellipsoids, equal-power panning — and the listener's pose |
141
+ | `clip` | Encoded audio → mono float32 at one rate, from a file or from bytes (`.glb` buffer views, `data:` URIs, downloads), decoded once |
142
+ | `synth` | Tones, chirps, noise and impacts made out of arithmetic, so demos and tests need no assets and no licences |
143
+ | `mixer` | A fixed voice pool summed into stereo blocks: allocation-free, lock-free on the audio thread, priority stealing, gain ramping, an underwater low-pass |
144
+ | `device` | Where blocks go — `miniaudio`, or silence |
145
+ | `engine` | The one object an application holds |
146
+
147
+ Deeper documentation is in [`docs/`](docs/README.md) — start with
148
+ [**GAME-INTEGRATION.md**](docs/GAME-INTEGRATION.md) if you are building
149
+ something, or [SPATIALISATION.md](docs/SPATIALISATION.md) for every gain curve
150
+ with a diagram of each, generated from the code that implements it.
151
+
152
+ ## What this does not do
153
+
154
+ Stated up front, because finding out later is worse:
155
+
156
+ - **Stereo only, and the pan carries azimuth alone.** A sound directly overhead
157
+ and one dead ahead are indistinguishable. Height needs an HRTF and surround
158
+ needs more than two channels; neither is here.
159
+ - **No reverb, occlusion or doppler.** `muffle` is the only effect, and it is a
160
+ master-bus low-pass.
161
+ - **No streaming.** Clips are decoded whole into memory.
162
+ - **No scheduling.** Nothing here has a clock; `autoplay` starts when your
163
+ application says the scene has begun.
164
+
165
+ ## Using it from a scenegraph
166
+
167
+ `omi_audio` is deliberately ignorant of scenegraphs: it is handed world
168
+ positions, a listener pose and clips. [OpenGLContext](https://github.com/mcfletch/openglcontext)
169
+ is the reference integration — `AudioEmitter`, `AudioSource` and VRML97's
170
+ `Sound` nodes, a per-context engine driven once a frame from the render pass,
171
+ and a glTF loader that reads `KHR_audio_emitter` blocks straight into this
172
+ model.
173
+
174
+ ## Contributing, changes, security
175
+
176
+ - [CONTRIBUTING.md](CONTRIBUTING.md) — how to run the gates, and the two rules
177
+ with teeth (the audio thread, and the untrusted document).
178
+ - [CHANGELOG.md](CHANGELOG.md)
179
+ - [SECURITY.md](SECURITY.md) — the threat model, and why your resolver is the
180
+ boundary.
181
+
182
+ ## Licence
183
+
184
+ MIT. Note that some jurisdictions do not allow for LLM generated code to have
185
+ a copyright, as such this library may be in the Public Domain in your
186
+ jurisdiction. There is **NO** warranty of any kind on the software.
187
+
188
+ See [`LICENSE`](LICENSE).
@@ -0,0 +1,142 @@
1
+ # omi_audio
2
+
3
+ Renderer-agnostic **spatial audio** for Python, built natively on the glTF
4
+ [`KHR_audio_emitter`](https://github.com/omigroup/gltf-extensions/tree/main/extensions/2.0/KHR_audio_emitter)
5
+ data model — which is the Web Audio `PannerNode` model. Emitters have a distance
6
+ curve, an optional directional cone and a gain; a listener hears them panned
7
+ about its own forward axis. The mixing is a fixed voice pool summed in NumPy.
8
+
9
+ - **NumPy is the only hard dependency.** No graphics library, no scenegraph, no
10
+ audio library is required to mix.
11
+ - **`miniaudio` is optional** — it decodes files and reaches the sound card.
12
+ Without it the library still mixes and simply stays silent.
13
+ - **Nothing here is copyleft**, and neither is anything it depends on:
14
+ `miniaudio` and every decoder it bundles are MIT or public domain.
15
+ - **Silence is a backend.** A missing package, a device that will not open, or a
16
+ machine with no audio hardware all end in one warning and a `NullDevice`.
17
+ `open_device()` cannot raise.
18
+ - **The audio thread never allocates, blocks, decodes or logs.** Pre-allocated
19
+ buffers, NumPy `out=` everywhere, and a lock the mixing never takes.
20
+ - **A document's `uri` is never resolved here.** A glTF file comes from
21
+ somebody you have never met; deciding what one of its strings is allowed to
22
+ mean is the application's call, not a library's. See
23
+ [`AudioLibrary`](docs/DATA-MODEL.md#getting-the-actual-audio-audiolibrary).
24
+
25
+ > ⚠️ **This code is largely LLM-written.** It has a test suite, but it comes with
26
+ > **no guarantees** of correctness, accuracy, or fitness for any purpose (see
27
+ > [`LICENSE`](LICENSE), MIT). Review it before relying on it for anything that
28
+ > matters.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install omi_audio # mixing only (NumPy)
34
+ pip install "omi_audio[playback]" # + miniaudio, for files and a sound card
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from omi_audio import AudioEngine, model, synth
41
+
42
+ engine = AudioEngine() # opens a device, or silence
43
+
44
+ # A sound with no file behind it, so this runs anywhere.
45
+ engine.clips.put('ping', synth.impact(0.4, seed=1))
46
+
47
+ emitter = model.AudioEmitter(
48
+ gain=0.8,
49
+ positional=model.PositionalProperties(refDistance=2.0, rolloffFactor=1.0),
50
+ )
51
+
52
+ handle = engine.play('ping', emitter=emitter, position=(3.0, 0.0, -5.0))
53
+ ```
54
+
55
+ Run it for real, with no assets and no sound card required:
56
+
57
+ ```bash
58
+ python examples/orbit.py # a sound orbits the listener for ten seconds
59
+ python examples/orbit.py --silent # never opens a device, still prints levels
60
+ ```
61
+
62
+ Each frame, move the listener and re-aim whatever is still playing. A moving
63
+ sound is *re-aimed*, never restarted: `aim()` writes two floats and the mixer
64
+ ramps to them across the next block.
65
+
66
+ ```python
67
+ engine.listen(camera) # anything with .position/.quaternion
68
+ engine.aim(handle, emitter, position=emitter_world_position)
69
+ ```
70
+
71
+ ## Testing without a sound card
72
+
73
+ Everything below the device is arithmetic over arrays, so build an engine on a
74
+ `NullDevice` and assert on the mix:
75
+
76
+ ```python
77
+ from omi_audio import AudioEngine, NullDevice, synth
78
+
79
+ engine = AudioEngine(device=NullDevice(sample_rate=8000), voices=8)
80
+ engine.mixer.play(synth.tone(440.0, 1.0, sample_rate=8000), pan=1.0)
81
+ block = engine.mixer.mix(64) # (64, 2) float32
82
+ assert block[:, 0].max() < 1e-9 # nothing in the left ear
83
+ assert block[:, 1].max() > 0.1 # and plenty in the right
84
+ ```
85
+
86
+ ## The pieces
87
+
88
+ In the order sound travels through them:
89
+
90
+ | Module | Answers |
91
+ |---|---|
92
+ | `model` | `KHR_audio_emitter` as typed records, with the extension's own field names and defaults; `from_gltf`/`to_gltf` round-trip, and the node/scene references that say where an emitter *is* |
93
+ | `library` | What a document's audio references have resolved to — the seam where **your** resolver, not this library, decides what a `uri` means |
94
+ | `spatial` | Every gain curve — three glTF distance models, the Web Audio cone, VRML97's two ellipsoids, equal-power panning — and the listener's pose |
95
+ | `clip` | Encoded audio → mono float32 at one rate, from a file or from bytes (`.glb` buffer views, `data:` URIs, downloads), decoded once |
96
+ | `synth` | Tones, chirps, noise and impacts made out of arithmetic, so demos and tests need no assets and no licences |
97
+ | `mixer` | A fixed voice pool summed into stereo blocks: allocation-free, lock-free on the audio thread, priority stealing, gain ramping, an underwater low-pass |
98
+ | `device` | Where blocks go — `miniaudio`, or silence |
99
+ | `engine` | The one object an application holds |
100
+
101
+ Deeper documentation is in [`docs/`](docs/README.md) — start with
102
+ [**GAME-INTEGRATION.md**](docs/GAME-INTEGRATION.md) if you are building
103
+ something, or [SPATIALISATION.md](docs/SPATIALISATION.md) for every gain curve
104
+ with a diagram of each, generated from the code that implements it.
105
+
106
+ ## What this does not do
107
+
108
+ Stated up front, because finding out later is worse:
109
+
110
+ - **Stereo only, and the pan carries azimuth alone.** A sound directly overhead
111
+ and one dead ahead are indistinguishable. Height needs an HRTF and surround
112
+ needs more than two channels; neither is here.
113
+ - **No reverb, occlusion or doppler.** `muffle` is the only effect, and it is a
114
+ master-bus low-pass.
115
+ - **No streaming.** Clips are decoded whole into memory.
116
+ - **No scheduling.** Nothing here has a clock; `autoplay` starts when your
117
+ application says the scene has begun.
118
+
119
+ ## Using it from a scenegraph
120
+
121
+ `omi_audio` is deliberately ignorant of scenegraphs: it is handed world
122
+ positions, a listener pose and clips. [OpenGLContext](https://github.com/mcfletch/openglcontext)
123
+ is the reference integration — `AudioEmitter`, `AudioSource` and VRML97's
124
+ `Sound` nodes, a per-context engine driven once a frame from the render pass,
125
+ and a glTF loader that reads `KHR_audio_emitter` blocks straight into this
126
+ model.
127
+
128
+ ## Contributing, changes, security
129
+
130
+ - [CONTRIBUTING.md](CONTRIBUTING.md) — how to run the gates, and the two rules
131
+ with teeth (the audio thread, and the untrusted document).
132
+ - [CHANGELOG.md](CHANGELOG.md)
133
+ - [SECURITY.md](SECURITY.md) — the threat model, and why your resolver is the
134
+ boundary.
135
+
136
+ ## Licence
137
+
138
+ MIT. Note that some jurisdictions do not allow for LLM generated code to have
139
+ a copyright, as such this library may be in the Public Domain in your
140
+ jurisdiction. There is **NO** warranty of any kind on the software.
141
+
142
+ See [`LICENSE`](LICENSE).