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.
- omi_audio-0.1.0a1/CHANGELOG.md +81 -0
- omi_audio-0.1.0a1/CONTRIBUTING.md +161 -0
- omi_audio-0.1.0a1/LICENSE +21 -0
- omi_audio-0.1.0a1/MANIFEST.in +11 -0
- omi_audio-0.1.0a1/PKG-INFO +188 -0
- omi_audio-0.1.0a1/README.md +142 -0
- omi_audio-0.1.0a1/SECURITY.md +81 -0
- omi_audio-0.1.0a1/docs/ARCHITECTURE.md +113 -0
- omi_audio-0.1.0a1/docs/DATA-MODEL.md +176 -0
- omi_audio-0.1.0a1/docs/GAME-INTEGRATION.md +238 -0
- omi_audio-0.1.0a1/docs/MIXING.md +151 -0
- omi_audio-0.1.0a1/docs/README.md +46 -0
- omi_audio-0.1.0a1/docs/SPATIALISATION.md +203 -0
- omi_audio-0.1.0a1/docs/VRML97.md +116 -0
- omi_audio-0.1.0a1/docs/api/conf.py +63 -0
- omi_audio-0.1.0a1/docs/api/index.rst +76 -0
- omi_audio-0.1.0a1/docs/images/cone.svg +38 -0
- omi_audio-0.1.0a1/docs/images/distance-models.svg +43 -0
- omi_audio-0.1.0a1/docs/images/ellipsoids.svg +37 -0
- omi_audio-0.1.0a1/docs/images/emitter-types.svg +40 -0
- omi_audio-0.1.0a1/docs/images/pan.svg +37 -0
- omi_audio-0.1.0a1/docs/make_diagrams.py +479 -0
- omi_audio-0.1.0a1/docs/reviews/2026-07-31-code-review.md +708 -0
- omi_audio-0.1.0a1/examples/orbit.py +135 -0
- omi_audio-0.1.0a1/pyproject.toml +138 -0
- omi_audio-0.1.0a1/setup.cfg +4 -0
- omi_audio-0.1.0a1/src/omi_audio/__init__.py +86 -0
- omi_audio-0.1.0a1/src/omi_audio/_backend.py +47 -0
- omi_audio-0.1.0a1/src/omi_audio/clip.py +290 -0
- omi_audio-0.1.0a1/src/omi_audio/device.py +191 -0
- omi_audio-0.1.0a1/src/omi_audio/engine.py +353 -0
- omi_audio-0.1.0a1/src/omi_audio/library.py +229 -0
- omi_audio-0.1.0a1/src/omi_audio/mixer.py +562 -0
- omi_audio-0.1.0a1/src/omi_audio/model.py +479 -0
- omi_audio-0.1.0a1/src/omi_audio/py.typed +0 -0
- omi_audio-0.1.0a1/src/omi_audio/spatial.py +397 -0
- omi_audio-0.1.0a1/src/omi_audio/synth.py +103 -0
- omi_audio-0.1.0a1/src/omi_audio.egg-info/PKG-INFO +188 -0
- omi_audio-0.1.0a1/src/omi_audio.egg-info/SOURCES.txt +54 -0
- omi_audio-0.1.0a1/src/omi_audio.egg-info/dependency_links.txt +1 -0
- omi_audio-0.1.0a1/src/omi_audio.egg-info/requires.txt +20 -0
- omi_audio-0.1.0a1/src/omi_audio.egg-info/top_level.txt +1 -0
- omi_audio-0.1.0a1/tests/conftest.py +90 -0
- omi_audio-0.1.0a1/tests/support.py +115 -0
- omi_audio-0.1.0a1/tests/test_backend.py +58 -0
- omi_audio-0.1.0a1/tests/test_clip.py +377 -0
- omi_audio-0.1.0a1/tests/test_device.py +316 -0
- omi_audio-0.1.0a1/tests/test_diagrams.py +111 -0
- omi_audio-0.1.0a1/tests/test_engine.py +447 -0
- omi_audio-0.1.0a1/tests/test_examples.py +57 -0
- omi_audio-0.1.0a1/tests/test_library.py +275 -0
- omi_audio-0.1.0a1/tests/test_mixer.py +676 -0
- omi_audio-0.1.0a1/tests/test_model.py +393 -0
- omi_audio-0.1.0a1/tests/test_output_level.py +225 -0
- omi_audio-0.1.0a1/tests/test_spatial.py +377 -0
- 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).
|