shipwright-audio 0.1.0__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.
- shipwright_audio-0.1.0/.github/workflows/ci.yml +42 -0
- shipwright_audio-0.1.0/.github/workflows/publish.yml +29 -0
- shipwright_audio-0.1.0/.gitignore +33 -0
- shipwright_audio-0.1.0/CHANGELOG.md +7 -0
- shipwright_audio-0.1.0/LICENSE +21 -0
- shipwright_audio-0.1.0/PKG-INFO +135 -0
- shipwright_audio-0.1.0/README.md +101 -0
- shipwright_audio-0.1.0/examples/music_sea_bed.py +25 -0
- shipwright_audio-0.1.0/examples/sfx_ui_blip.py +12 -0
- shipwright_audio-0.1.0/pyproject.toml +60 -0
- shipwright_audio-0.1.0/render.py +8 -0
- shipwright_audio-0.1.0/shipwright/__init__.py +4 -0
- shipwright_audio-0.1.0/shipwright/cli.py +132 -0
- shipwright_audio-0.1.0/shipwright/compose.py +48 -0
- shipwright_audio-0.1.0/shipwright/config.py +22 -0
- shipwright_audio-0.1.0/shipwright/dsp.py +70 -0
- shipwright_audio-0.1.0/shipwright/engine.py +55 -0
- shipwright_audio-0.1.0/shipwright/instruments.py +63 -0
- shipwright_audio-0.1.0/shipwright/registry.py +57 -0
- shipwright_audio-0.1.0/soundfonts/README.md +3 -0
- shipwright_audio-0.1.0/tests/test_cli.py +141 -0
- shipwright_audio-0.1.0/tests/test_compose.py +20 -0
- shipwright_audio-0.1.0/tests/test_dsp.py +24 -0
- shipwright_audio-0.1.0/tests/test_engine.py +14 -0
- shipwright_audio-0.1.0/tests/test_metadata.py +12 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
name: ${{ matrix.os }} / Python ${{ matrix.python-version }}
|
|
10
|
+
runs-on: ${{ matrix.os }}
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
include:
|
|
15
|
+
- os: ubuntu-latest
|
|
16
|
+
python-version: "3.10"
|
|
17
|
+
- os: ubuntu-latest
|
|
18
|
+
python-version: "3.11"
|
|
19
|
+
- os: ubuntu-latest
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- os: macos-latest
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- os: windows-latest
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: astral-sh/setup-uv@v6
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
- run: uv run --python ${{ matrix.python-version }} --extra dev pytest
|
|
32
|
+
|
|
33
|
+
package:
|
|
34
|
+
name: Build package
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: astral-sh/setup-uv@v6
|
|
39
|
+
with:
|
|
40
|
+
enable-cache: true
|
|
41
|
+
- run: uv build
|
|
42
|
+
- run: uvx twine check dist/*
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
name: Publish to PyPI
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment:
|
|
16
|
+
name: pypi
|
|
17
|
+
url: https://pypi.org/p/shipwright-audio
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
id-token: write
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: astral-sh/setup-uv@v6
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
- run: uv build
|
|
28
|
+
- run: uvx twine check dist/*
|
|
29
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Project output
|
|
2
|
+
output/
|
|
3
|
+
soundfonts/*.sf2
|
|
4
|
+
|
|
5
|
+
# Python bytecode / caches
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
|
|
13
|
+
# Packaging / build artifacts
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
*.egg
|
|
18
|
+
|
|
19
|
+
# Virtual environments
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
env/
|
|
23
|
+
|
|
24
|
+
# uv
|
|
25
|
+
uv.lock
|
|
26
|
+
|
|
27
|
+
# Editors / IDEs
|
|
28
|
+
.vscode/
|
|
29
|
+
.idea/
|
|
30
|
+
*.swp
|
|
31
|
+
|
|
32
|
+
# macOS
|
|
33
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Braun
|
|
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,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipwright-audio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A code-first audio studio: write -> render -> listen -> adjust.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dinger086/shipwright-audio
|
|
6
|
+
Project-URL: Repository, https://github.com/dinger086/shipwright-audio
|
|
7
|
+
Project-URL: Issues, https://github.com/dinger086/shipwright-audio/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/dinger086/shipwright-audio/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Robert Braun
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: audio,dawdreamer,faust,music,sfx,synthesis
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
20
|
+
Requires-Python: <3.13,>=3.10
|
|
21
|
+
Requires-Dist: dawdreamer
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: scipy<1.15,>=1.11
|
|
24
|
+
Requires-Dist: soundfile
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
27
|
+
Provides-Extra: midi
|
|
28
|
+
Requires-Dist: pretty-midi; extra == 'midi'
|
|
29
|
+
Provides-Extra: soundfont
|
|
30
|
+
Requires-Dist: pyfluidsynth; extra == 'soundfont'
|
|
31
|
+
Provides-Extra: theory
|
|
32
|
+
Requires-Dist: music21; extra == 'theory'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# shipwright-audio
|
|
36
|
+
|
|
37
|
+
A code-first audio studio: describe a sound or a few bars of music in a
|
|
38
|
+
small Python function, run one command, get a `.wav`.
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10 through 3.12.
|
|
41
|
+
|
|
42
|
+
## The shape
|
|
43
|
+
|
|
44
|
+
Four layers, cleanly separated:
|
|
45
|
+
|
|
46
|
+
| Layer | File | Job |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| **Composition** (above) | `shipwright/compose.py` | chord symbols / pitch lists → `Note`s |
|
|
49
|
+
| **Sound sources** (below) | `shipwright/instruments.py` | Faust synths the engine plays |
|
|
50
|
+
| **SFX synthesis** | `shipwright/dsp.py` | numpy oscillators / noise / envelopes / filters |
|
|
51
|
+
| **The spine** | `shipwright/engine.py` | DawDreamer: graph → mix → bus FX → offline render |
|
|
52
|
+
|
|
53
|
+
You author in a `sounds/` directory in your project (create it yourself —
|
|
54
|
+
the tool reads `./sounds` from wherever you run it). Each file is one sound.
|
|
55
|
+
A build-function returns either a **`Buffer`** (raw numpy samples → SFX) or a
|
|
56
|
+
**`RenderSpec`** (tracks of MIDI played by instruments → music). The harness
|
|
57
|
+
routes by type. Copy-paste starting points live in [`examples/`](examples/).
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
As a tool (gets you the `shipwright` command anywhere):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv tool install shipwright-audio # or: pipx install shipwright-audio
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or from a checkout for development:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uv sync # create .venv + install deps
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Use
|
|
74
|
+
|
|
75
|
+
`shipwright` loads sounds from `./sounds` and writes to `./output` relative
|
|
76
|
+
to the directory you run it in (override with `SHIPWRIGHT_SOUNDS` /
|
|
77
|
+
`SHIPWRIGHT_OUTPUT`).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
shipwright # list sounds
|
|
81
|
+
shipwright sea_bed # render one -> output/sea_bed.wav
|
|
82
|
+
shipwright all --ogg # render all, also write .ogg
|
|
83
|
+
shipwright --watch sea_bed # re-render on every save (tight loop)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
From a checkout without installing, `uv run shipwright ...` or
|
|
87
|
+
`python render.py ...` both work.
|
|
88
|
+
|
|
89
|
+
## Develop
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run --extra dev pytest
|
|
93
|
+
env SHIPWRIGHT_SOUNDS=examples uv run shipwright ui_blip
|
|
94
|
+
env SHIPWRIGHT_SOUNDS=examples uv run shipwright sea_bed
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Add a sound
|
|
98
|
+
|
|
99
|
+
Create `sounds/my_thing.py`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from shipwright import sound, Buffer, dsp
|
|
103
|
+
|
|
104
|
+
@sound("zap")
|
|
105
|
+
def zap():
|
|
106
|
+
s = dsp.ad_env(dsp.saw(220, 0.25), attack=0.001, release=0.2)
|
|
107
|
+
s = dsp.lowpass(s, 1200)
|
|
108
|
+
return Buffer(dsp.to_stereo(dsp.normalize(s, 0.9)))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
…then `shipwright zap`. Music works the same way but returns a
|
|
112
|
+
`RenderSpec` of `Track`s — see [`examples/music_sea_bed.py`](examples/music_sea_bed.py).
|
|
113
|
+
|
|
114
|
+
## Why DawDreamer is the spine but not the whole thing
|
|
115
|
+
|
|
116
|
+
DawDreamer mixes, automates (sample-accurate, via numpy arrays), hosts
|
|
117
|
+
plugins, and renders deterministically offline. It does **not** compose and
|
|
118
|
+
ships **no instruments** — so composition lives above it (`compose.py`) and
|
|
119
|
+
sound sources below it (`instruments.py`, here as Faust). That's the whole
|
|
120
|
+
architecture.
|
|
121
|
+
|
|
122
|
+
## Nicer instruments (upgrade path)
|
|
123
|
+
|
|
124
|
+
The built-in Faust synths need zero files. When you want more "produced"
|
|
125
|
+
sound, swap a `Track`'s instrument for either:
|
|
126
|
+
|
|
127
|
+
- **SoundFont**: drop an `.sf2` in `soundfonts/`, render its MIDI with
|
|
128
|
+
`pyfluidsynth`, feed the audio into the DawDreamer graph as a track.
|
|
129
|
+
- **VST/AU**: `engine.make_plugin_processor("name", "/path/to/plugin.vst3")`,
|
|
130
|
+
then `add_midi_note(...)` exactly like the Faust instruments.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
`shipwright-audio` is MIT licensed. Its DawDreamer dependency is GPLv3; review
|
|
135
|
+
that license before redistributing bundled applications or generated tooling.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# shipwright-audio
|
|
2
|
+
|
|
3
|
+
A code-first audio studio: describe a sound or a few bars of music in a
|
|
4
|
+
small Python function, run one command, get a `.wav`.
|
|
5
|
+
|
|
6
|
+
Requires Python 3.10 through 3.12.
|
|
7
|
+
|
|
8
|
+
## The shape
|
|
9
|
+
|
|
10
|
+
Four layers, cleanly separated:
|
|
11
|
+
|
|
12
|
+
| Layer | File | Job |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| **Composition** (above) | `shipwright/compose.py` | chord symbols / pitch lists → `Note`s |
|
|
15
|
+
| **Sound sources** (below) | `shipwright/instruments.py` | Faust synths the engine plays |
|
|
16
|
+
| **SFX synthesis** | `shipwright/dsp.py` | numpy oscillators / noise / envelopes / filters |
|
|
17
|
+
| **The spine** | `shipwright/engine.py` | DawDreamer: graph → mix → bus FX → offline render |
|
|
18
|
+
|
|
19
|
+
You author in a `sounds/` directory in your project (create it yourself —
|
|
20
|
+
the tool reads `./sounds` from wherever you run it). Each file is one sound.
|
|
21
|
+
A build-function returns either a **`Buffer`** (raw numpy samples → SFX) or a
|
|
22
|
+
**`RenderSpec`** (tracks of MIDI played by instruments → music). The harness
|
|
23
|
+
routes by type. Copy-paste starting points live in [`examples/`](examples/).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
As a tool (gets you the `shipwright` command anywhere):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install shipwright-audio # or: pipx install shipwright-audio
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or from a checkout for development:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv sync # create .venv + install deps
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
`shipwright` loads sounds from `./sounds` and writes to `./output` relative
|
|
42
|
+
to the directory you run it in (override with `SHIPWRIGHT_SOUNDS` /
|
|
43
|
+
`SHIPWRIGHT_OUTPUT`).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
shipwright # list sounds
|
|
47
|
+
shipwright sea_bed # render one -> output/sea_bed.wav
|
|
48
|
+
shipwright all --ogg # render all, also write .ogg
|
|
49
|
+
shipwright --watch sea_bed # re-render on every save (tight loop)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
From a checkout without installing, `uv run shipwright ...` or
|
|
53
|
+
`python render.py ...` both work.
|
|
54
|
+
|
|
55
|
+
## Develop
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv run --extra dev pytest
|
|
59
|
+
env SHIPWRIGHT_SOUNDS=examples uv run shipwright ui_blip
|
|
60
|
+
env SHIPWRIGHT_SOUNDS=examples uv run shipwright sea_bed
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Add a sound
|
|
64
|
+
|
|
65
|
+
Create `sounds/my_thing.py`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from shipwright import sound, Buffer, dsp
|
|
69
|
+
|
|
70
|
+
@sound("zap")
|
|
71
|
+
def zap():
|
|
72
|
+
s = dsp.ad_env(dsp.saw(220, 0.25), attack=0.001, release=0.2)
|
|
73
|
+
s = dsp.lowpass(s, 1200)
|
|
74
|
+
return Buffer(dsp.to_stereo(dsp.normalize(s, 0.9)))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
…then `shipwright zap`. Music works the same way but returns a
|
|
78
|
+
`RenderSpec` of `Track`s — see [`examples/music_sea_bed.py`](examples/music_sea_bed.py).
|
|
79
|
+
|
|
80
|
+
## Why DawDreamer is the spine but not the whole thing
|
|
81
|
+
|
|
82
|
+
DawDreamer mixes, automates (sample-accurate, via numpy arrays), hosts
|
|
83
|
+
plugins, and renders deterministically offline. It does **not** compose and
|
|
84
|
+
ships **no instruments** — so composition lives above it (`compose.py`) and
|
|
85
|
+
sound sources below it (`instruments.py`, here as Faust). That's the whole
|
|
86
|
+
architecture.
|
|
87
|
+
|
|
88
|
+
## Nicer instruments (upgrade path)
|
|
89
|
+
|
|
90
|
+
The built-in Faust synths need zero files. When you want more "produced"
|
|
91
|
+
sound, swap a `Track`'s instrument for either:
|
|
92
|
+
|
|
93
|
+
- **SoundFont**: drop an `.sf2` in `soundfonts/`, render its MIDI with
|
|
94
|
+
`pyfluidsynth`, feed the audio into the DawDreamer graph as a track.
|
|
95
|
+
- **VST/AU**: `engine.make_plugin_processor("name", "/path/to/plugin.vst3")`,
|
|
96
|
+
then `add_midi_note(...)` exactly like the Faust instruments.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
`shipwright-audio` is MIT licensed. Its DawDreamer dependency is GPLv3; review
|
|
101
|
+
that license before redistributing bundled applications or generated tooling.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""An 8-bar ambient sea bed: pad + soft lead + sub bass, through reverb.
|
|
2
|
+
Music path: returns a RenderSpec that the DawDreamer engine renders."""
|
|
3
|
+
from shipwright import sound, Track, RenderSpec, instruments, compose
|
|
4
|
+
|
|
5
|
+
@sound("sea_bed")
|
|
6
|
+
def sea_bed():
|
|
7
|
+
bpm = 70
|
|
8
|
+
pad = Track(
|
|
9
|
+
instruments.soft_pad(cutoff=1100),
|
|
10
|
+
compose.progression(["Dm", "Bb", "F", "C"], bpm, beats_per_chord=4, repeats=2),
|
|
11
|
+
gain_db=-6,
|
|
12
|
+
)
|
|
13
|
+
lead = Track(
|
|
14
|
+
instruments.saw_lead(cutoff=1400),
|
|
15
|
+
compose.melody([74, 77, 76, 74, 72, 74, None, 69, 72, 74],
|
|
16
|
+
bpm, note_beats=[2,1,1,2,1,1,2,2,2,4], start_beat=4),
|
|
17
|
+
gain_db=-11,
|
|
18
|
+
)
|
|
19
|
+
bass = Track(
|
|
20
|
+
instruments.sub_bass(),
|
|
21
|
+
compose.bassline(["D", "Bb", "F", "C"], bpm, octave=2, beats_per_note=4, repeats=2),
|
|
22
|
+
gain_db=-7,
|
|
23
|
+
)
|
|
24
|
+
return RenderSpec(tracks=[pad, lead, bass], tempo=bpm,
|
|
25
|
+
master_fx=[instruments.reverb(wet=0.35)])
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A short UI confirm blip. SFX path: synthesise samples -> Buffer."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from shipwright import sound, Buffer, dsp
|
|
4
|
+
|
|
5
|
+
@sound("ui_blip")
|
|
6
|
+
def ui_blip():
|
|
7
|
+
ping1 = dsp.ad_env(dsp.sine(880, 0.09), attack=0.002, release=0.07)
|
|
8
|
+
ping2 = dsp.ad_env(dsp.sine(1320, 0.11), attack=0.002, release=0.09)
|
|
9
|
+
gap = np.zeros(int(0.045 * dsp.SR))
|
|
10
|
+
sig = dsp.layer(ping1, np.concatenate([gap, ping2]))
|
|
11
|
+
sig = dsp.normalize(sig, 0.85)
|
|
12
|
+
return Buffer(dsp.to_stereo(sig, pan=0.0))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shipwright-audio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A code-first audio studio: write -> render -> listen -> adjust."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.13"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Robert Braun" }]
|
|
14
|
+
keywords = ["audio", "synthesis", "dawdreamer", "faust", "sfx", "music"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"dawdreamer",
|
|
26
|
+
"numpy",
|
|
27
|
+
"scipy>=1.11,<1.15",
|
|
28
|
+
"soundfile",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest"]
|
|
33
|
+
soundfont = ["pyfluidsynth"]
|
|
34
|
+
midi = ["pretty_midi"]
|
|
35
|
+
theory = ["music21"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/dinger086/shipwright-audio"
|
|
39
|
+
Repository = "https://github.com/dinger086/shipwright-audio"
|
|
40
|
+
Issues = "https://github.com/dinger086/shipwright-audio/issues"
|
|
41
|
+
Changelog = "https://github.com/dinger086/shipwright-audio/blob/main/CHANGELOG.md"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
shipwright = "shipwright.cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["shipwright"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
exclude = [
|
|
51
|
+
"/.DS_Store",
|
|
52
|
+
"/.venv",
|
|
53
|
+
"/dist",
|
|
54
|
+
"/output",
|
|
55
|
+
"/uv.lock",
|
|
56
|
+
"**/__pycache__",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""In-repo dev shim. The real entry point is `shipwright` (shipwright.cli:main),
|
|
3
|
+
installed via pyproject. This lets you run the CLI from a checkout without
|
|
4
|
+
installing: `python render.py sea_bed`."""
|
|
5
|
+
from shipwright.cli import main
|
|
6
|
+
|
|
7
|
+
if __name__ == "__main__":
|
|
8
|
+
main()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The harness, as a console command. The whole point: edit a file in
|
|
2
|
+
sounds/, run this, listen.
|
|
3
|
+
|
|
4
|
+
shipwright list all sounds
|
|
5
|
+
shipwright sea_bed render one -> output/sea_bed.wav
|
|
6
|
+
shipwright all render everything
|
|
7
|
+
shipwright sea_bed --ogg also write a Vorbis .ogg
|
|
8
|
+
shipwright --watch sea_bed re-render on every save (tight loop)
|
|
9
|
+
|
|
10
|
+
Sounds are loaded from ./sounds and written to ./output relative to the
|
|
11
|
+
current directory (override with SHIPWRIGHT_ROOT / SHIPWRIGHT_SOUNDS /
|
|
12
|
+
SHIPWRIGHT_OUTPUT).
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import importlib.util
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import __version__, config
|
|
20
|
+
from .registry import Buffer, RenderSpec, clear, get, names
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_sounds():
|
|
24
|
+
if not config.SOUNDS_DIR.is_dir():
|
|
25
|
+
raise SystemExit(
|
|
26
|
+
f"no sounds directory at {config.SOUNDS_DIR}\n"
|
|
27
|
+
"cd into a project with a sounds/ folder, or set SHIPWRIGHT_SOUNDS."
|
|
28
|
+
)
|
|
29
|
+
clear()
|
|
30
|
+
for f in sorted(config.SOUNDS_DIR.glob("*.py")):
|
|
31
|
+
spec = importlib.util.spec_from_file_location(f.stem, f)
|
|
32
|
+
if spec is None or spec.loader is None:
|
|
33
|
+
raise SystemExit(f"could not load sound file: {f}")
|
|
34
|
+
mod = importlib.util.module_from_spec(spec)
|
|
35
|
+
spec.loader.exec_module(mod)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _available():
|
|
39
|
+
sound_names = names()
|
|
40
|
+
return ", ".join(sound_names) if sound_names else "(none)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _output_path(name):
|
|
44
|
+
config.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
return config.OUTPUT_DIR / f"{name}.wav"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _display_path(path):
|
|
49
|
+
try:
|
|
50
|
+
return path.relative_to(Path.cwd())
|
|
51
|
+
except ValueError:
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_one(name, ogg=False):
|
|
56
|
+
if name not in names():
|
|
57
|
+
raise SystemExit(f"unknown sound '{name}'. Available sounds: {_available()}")
|
|
58
|
+
|
|
59
|
+
obj = get(name)()
|
|
60
|
+
if isinstance(obj, Buffer):
|
|
61
|
+
from .engine import render_buffer
|
|
62
|
+
audio = render_buffer(obj)
|
|
63
|
+
sample_rate = obj.sr
|
|
64
|
+
elif isinstance(obj, RenderSpec):
|
|
65
|
+
from .engine import render_spec
|
|
66
|
+
audio = render_spec(obj)
|
|
67
|
+
sample_rate = config.SR
|
|
68
|
+
else:
|
|
69
|
+
raise SystemExit(
|
|
70
|
+
f"sound '{name}' returned {type(obj).__name__}; expected Buffer or RenderSpec."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
import soundfile as sf
|
|
74
|
+
|
|
75
|
+
out = _output_path(name)
|
|
76
|
+
sf.write(out, audio, sample_rate)
|
|
77
|
+
if ogg:
|
|
78
|
+
sf.write(out.with_suffix(".ogg"), audio, sample_rate, format="OGG", subtype="VORBIS")
|
|
79
|
+
dur = len(audio) / sample_rate
|
|
80
|
+
print(f" {name:14s} -> {_display_path(out)} {dur:4.1f}s peak {abs(audio).max():.2f}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_parser():
|
|
84
|
+
p = argparse.ArgumentParser(
|
|
85
|
+
prog="shipwright",
|
|
86
|
+
description="Render sounds defined in ./sounds to ./output.",
|
|
87
|
+
)
|
|
88
|
+
p.add_argument("target", nargs="?",
|
|
89
|
+
help="sound name to render, or 'all'. Omit to list sounds.")
|
|
90
|
+
p.add_argument("--ogg", action="store_true",
|
|
91
|
+
help="also write a Vorbis .ogg next to the .wav")
|
|
92
|
+
p.add_argument("--watch", action="store_true",
|
|
93
|
+
help="re-render TARGET on every save (Ctrl-C to stop)")
|
|
94
|
+
p.add_argument("--version", action="version",
|
|
95
|
+
version=f"%(prog)s {__version__}")
|
|
96
|
+
return p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv=None):
|
|
100
|
+
args = build_parser().parse_args(argv)
|
|
101
|
+
|
|
102
|
+
if args.watch:
|
|
103
|
+
if not args.target:
|
|
104
|
+
raise SystemExit("--watch needs a sound name, e.g. shipwright --watch sea_bed")
|
|
105
|
+
load_sounds()
|
|
106
|
+
print(f"watching {config.SOUNDS_DIR} — rendering '{args.target}' on save (Ctrl-C to stop)")
|
|
107
|
+
last = 0
|
|
108
|
+
while True:
|
|
109
|
+
m = max((f.stat().st_mtime for f in config.SOUNDS_DIR.glob("*.py")), default=0)
|
|
110
|
+
if m != last:
|
|
111
|
+
last = m
|
|
112
|
+
try:
|
|
113
|
+
load_sounds()
|
|
114
|
+
render_one(args.target, args.ogg)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(" error:", e)
|
|
117
|
+
time.sleep(0.4)
|
|
118
|
+
|
|
119
|
+
load_sounds()
|
|
120
|
+
if not args.target:
|
|
121
|
+
print("sounds:", _available())
|
|
122
|
+
return
|
|
123
|
+
targets = names() if args.target == "all" else [args.target]
|
|
124
|
+
if args.target != "all" and args.target not in names():
|
|
125
|
+
raise SystemExit(f"unknown sound '{args.target}'. Available sounds: {_available()}")
|
|
126
|
+
print(f"rendering {len(targets)} sound(s) @ {config.SR} Hz")
|
|
127
|
+
for t0 in targets:
|
|
128
|
+
render_one(t0, args.ogg)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The composition layer (above the engine). Tiny helpers to turn chord
|
|
2
|
+
symbols / pitch lists into Note objects. Swap this for music21 if you want
|
|
3
|
+
richer theory; the engine only cares about the Note list."""
|
|
4
|
+
from .registry import Note
|
|
5
|
+
|
|
6
|
+
_NOTE = {'C':0,'C#':1,'Db':1,'D':2,'D#':3,'Eb':3,'E':4,'F':5,'F#':6,'Gb':6,
|
|
7
|
+
'G':7,'G#':8,'Ab':8,'A':9,'A#':10,'Bb':10,'B':11}
|
|
8
|
+
_QUAL = {'':[0,4,7],'m':[0,3,7],'7':[0,4,7,10],'m7':[0,3,7,10],
|
|
9
|
+
'maj7':[0,4,7,11],'sus2':[0,2,7],'sus4':[0,5,7]}
|
|
10
|
+
|
|
11
|
+
def note_to_midi(name, octave=4):
|
|
12
|
+
return 12 * (octave + 1) + _NOTE[name]
|
|
13
|
+
|
|
14
|
+
def _split(sym):
|
|
15
|
+
if len(sym) > 1 and sym[1] in '#b':
|
|
16
|
+
return sym[:2], sym[2:]
|
|
17
|
+
return sym[:1], sym[1:]
|
|
18
|
+
|
|
19
|
+
def parse_chord(sym, octave=3):
|
|
20
|
+
root, qual = _split(sym)
|
|
21
|
+
base = note_to_midi(root, octave)
|
|
22
|
+
return [base + iv for iv in _QUAL.get(qual, [0, 4, 7])]
|
|
23
|
+
|
|
24
|
+
def progression(chords, bpm=70, beats_per_chord=4, repeats=1, octave=3, vel=70):
|
|
25
|
+
spb = 60.0 / bpm; notes = []; cur = 0.0
|
|
26
|
+
for _ in range(repeats):
|
|
27
|
+
for c in chords:
|
|
28
|
+
for p in parse_chord(c, octave):
|
|
29
|
+
notes.append(Note(p, cur, beats_per_chord * spb, vel))
|
|
30
|
+
cur += beats_per_chord * spb
|
|
31
|
+
return notes
|
|
32
|
+
|
|
33
|
+
def melody(pitches, bpm=70, note_beats=None, start_beat=0.0, vel=90):
|
|
34
|
+
spb = 60.0 / bpm; cur = start_beat * spb; notes = []
|
|
35
|
+
nb = note_beats or [1] * len(pitches)
|
|
36
|
+
for p, b in zip(pitches, nb):
|
|
37
|
+
if p is not None:
|
|
38
|
+
notes.append(Note(p, cur, b * spb * 0.95, vel))
|
|
39
|
+
cur += b * spb
|
|
40
|
+
return notes
|
|
41
|
+
|
|
42
|
+
def bassline(roots, bpm=70, octave=2, beats_per_note=4, repeats=1, vel=80):
|
|
43
|
+
spb = 60.0 / bpm; notes = []; cur = 0.0
|
|
44
|
+
for _ in range(repeats):
|
|
45
|
+
for r in roots:
|
|
46
|
+
notes.append(Note(note_to_midi(r, octave), cur, beats_per_note * spb * 0.9, vel))
|
|
47
|
+
cur += beats_per_note * spb
|
|
48
|
+
return notes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Global settings + paths. One place to change sample rate / output dir.
|
|
2
|
+
|
|
3
|
+
Data dirs are anchored to the current working directory so the installed
|
|
4
|
+
command works in whatever project you run it from. Override any of them
|
|
5
|
+
with environment variables (SHIPWRIGHT_ROOT / _SOUNDS / _OUTPUT / _SOUNDFONTS).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SR = 44100 # sample rate for everything
|
|
11
|
+
BLOCK = 512 # DawDreamer render block size
|
|
12
|
+
MASTER_CEILING = 0.97 # peak limit so renders never clip the file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _dir(env, default):
|
|
16
|
+
return Path(os.environ[env]).expanduser() if env in os.environ else default
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ROOT = _dir("SHIPWRIGHT_ROOT", Path.cwd())
|
|
20
|
+
SOUNDS_DIR = _dir("SHIPWRIGHT_SOUNDS", ROOT / "sounds")
|
|
21
|
+
SOUNDFONT_DIR = _dir("SHIPWRIGHT_SOUNDFONTS", ROOT / "soundfonts")
|
|
22
|
+
OUTPUT_DIR = _dir("SHIPWRIGHT_OUTPUT", ROOT / "output")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""numpy synthesis primitives for SFX. Pure code -> samples. Deterministic,
|
|
2
|
+
tiny, no black box. Build blips, creaks, whooshes from oscillators + noise
|
|
3
|
+
+ envelopes + filters."""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.signal import butter, sosfilt
|
|
6
|
+
from .config import SR
|
|
7
|
+
|
|
8
|
+
def t(dur):
|
|
9
|
+
return np.linspace(0, dur, int(SR * dur), endpoint=False)
|
|
10
|
+
|
|
11
|
+
# --- oscillators -----------------------------------------------------------
|
|
12
|
+
def sine(freq, dur, amp=1.0, phase=0.0):
|
|
13
|
+
return amp * np.sin(2 * np.pi * freq * t(dur) + phase)
|
|
14
|
+
|
|
15
|
+
def saw(freq, dur, amp=1.0):
|
|
16
|
+
x = t(dur)
|
|
17
|
+
return amp * (2 * ((freq * x) % 1.0) - 1.0)
|
|
18
|
+
|
|
19
|
+
def square(freq, dur, amp=1.0, duty=0.5):
|
|
20
|
+
return amp * np.where((freq * t(dur)) % 1.0 < duty, 1.0, -1.0)
|
|
21
|
+
|
|
22
|
+
def noise(dur, amp=1.0, seed=None):
|
|
23
|
+
rng = np.random.default_rng(seed)
|
|
24
|
+
return amp * rng.uniform(-1, 1, int(SR * dur))
|
|
25
|
+
|
|
26
|
+
# --- envelopes -------------------------------------------------------------
|
|
27
|
+
def ad_env(sig, attack=0.005, release=0.1):
|
|
28
|
+
"""Apply a linear attack/release to an existing signal."""
|
|
29
|
+
n = len(sig); env = np.ones(n)
|
|
30
|
+
a, r = int(attack * SR), int(release * SR)
|
|
31
|
+
if a: env[:a] = np.linspace(0, 1, a)
|
|
32
|
+
if r: env[-r:] *= np.linspace(1, 0, r)
|
|
33
|
+
return sig * env
|
|
34
|
+
|
|
35
|
+
def perc_env(dur, decay=0.99):
|
|
36
|
+
"""Exponential percussive decay over `dur` seconds."""
|
|
37
|
+
n = int(SR * dur)
|
|
38
|
+
return decay ** np.arange(n) if 0 < decay < 1 else np.exp(-np.linspace(0, 6, n))
|
|
39
|
+
|
|
40
|
+
# --- filters (scipy biquads) ----------------------------------------------
|
|
41
|
+
def _sos(kind, cutoff, order=2):
|
|
42
|
+
nyq = SR / 2
|
|
43
|
+
if kind == 'band':
|
|
44
|
+
wn = [float(np.clip(c / nyq, 1e-4, 0.999)) for c in cutoff]
|
|
45
|
+
else:
|
|
46
|
+
wn = float(np.clip(cutoff / nyq, 1e-4, 0.999))
|
|
47
|
+
return butter(order, wn, btype=kind, output='sos')
|
|
48
|
+
|
|
49
|
+
def lowpass(sig, cutoff, order=2): return sosfilt(_sos('low', cutoff, order=order), sig)
|
|
50
|
+
def highpass(sig, cutoff, order=2): return sosfilt(_sos('high', cutoff, order=order), sig)
|
|
51
|
+
def bandpass(sig, low, high, order=2):
|
|
52
|
+
return sosfilt(_sos('band', [low, high], order=order), sig)
|
|
53
|
+
|
|
54
|
+
# --- utilities -------------------------------------------------------------
|
|
55
|
+
def layer(*sigs):
|
|
56
|
+
"""Sum signals of any length (zero-padded to the longest)."""
|
|
57
|
+
n = max(len(s) for s in sigs)
|
|
58
|
+
out = np.zeros(n)
|
|
59
|
+
for s in sigs:
|
|
60
|
+
out[:len(s)] += s
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def to_stereo(sig, pan=0.0):
|
|
64
|
+
"""Mono -> stereo with equal-power pan (-1 left .. +1 right)."""
|
|
65
|
+
a = (pan + 1) * np.pi / 4
|
|
66
|
+
return np.stack([sig * np.cos(a), sig * np.sin(a)], axis=1)
|
|
67
|
+
|
|
68
|
+
def normalize(sig, peak=0.9):
|
|
69
|
+
m = np.abs(sig).max()
|
|
70
|
+
return sig if m == 0 else sig * (peak / m)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The DawDreamer spine: build a processor graph from a RenderSpec
|
|
2
|
+
(instruments -> per-track gain -> master sum -> bus FX), render offline,
|
|
3
|
+
return stereo samples. Also handles writing Buffers (SFX) straight out."""
|
|
4
|
+
import numpy as np
|
|
5
|
+
import dawdreamer as daw
|
|
6
|
+
from .config import SR, BLOCK, MASTER_CEILING
|
|
7
|
+
from .registry import Buffer, RenderSpec
|
|
8
|
+
|
|
9
|
+
def _limit(audio):
|
|
10
|
+
m = float(np.abs(audio).max())
|
|
11
|
+
if m > MASTER_CEILING:
|
|
12
|
+
audio = audio * (MASTER_CEILING / m)
|
|
13
|
+
return audio.astype(np.float32)
|
|
14
|
+
|
|
15
|
+
def render_buffer(buf: Buffer):
|
|
16
|
+
s = np.asarray(buf.samples, dtype=np.float32)
|
|
17
|
+
return _limit(s)
|
|
18
|
+
|
|
19
|
+
def render_spec(spec: RenderSpec):
|
|
20
|
+
engine = daw.RenderEngine(SR, BLOCK)
|
|
21
|
+
nodes, track_outs = [], []
|
|
22
|
+
|
|
23
|
+
if spec.duration is not None:
|
|
24
|
+
dur = spec.duration
|
|
25
|
+
else:
|
|
26
|
+
end = max((n.start + n.dur for tr in spec.tracks for n in tr.notes), default=1.0)
|
|
27
|
+
dur = end + 2.0 # tail for releases/reverb
|
|
28
|
+
|
|
29
|
+
for i, tr in enumerate(spec.tracks):
|
|
30
|
+
inst = engine.make_faust_processor(f"inst{i}")
|
|
31
|
+
inst.set_dsp_string(tr.instrument.dsp)
|
|
32
|
+
inst.num_voices = tr.instrument.num_voices
|
|
33
|
+
for n in tr.notes:
|
|
34
|
+
inst.add_midi_note(n.pitch, n.vel, n.start, n.dur)
|
|
35
|
+
nodes.append((inst, [])); last = f"inst{i}"
|
|
36
|
+
|
|
37
|
+
for j, fx in enumerate(tr.fx):
|
|
38
|
+
p = engine.make_faust_processor(f"fx{i}_{j}"); p.set_dsp_string(fx)
|
|
39
|
+
nodes.append((p, [last])); last = f"fx{i}_{j}"
|
|
40
|
+
|
|
41
|
+
lin = 10 ** (tr.gain_db / 20.0)
|
|
42
|
+
g = engine.make_faust_processor(f"g{i}")
|
|
43
|
+
g.set_dsp_string(f"process = _,_ : *({lin}),*({lin});")
|
|
44
|
+
nodes.append((g, [last])); track_outs.append(f"g{i}")
|
|
45
|
+
|
|
46
|
+
master = engine.make_add_processor("master", [1.0] * len(track_outs))
|
|
47
|
+
nodes.append((master, track_outs)); last = "master"
|
|
48
|
+
|
|
49
|
+
for k, mfx in enumerate(spec.master_fx):
|
|
50
|
+
p = engine.make_faust_processor(f"m{k}"); p.set_dsp_string(mfx)
|
|
51
|
+
nodes.append((p, [last])); last = f"m{k}"
|
|
52
|
+
|
|
53
|
+
engine.load_graph(nodes)
|
|
54
|
+
engine.render(dur)
|
|
55
|
+
return _limit(engine.get_audio().T) # (n, 2)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The sound-source layer. DawDreamer has no instruments of its own, so we
|
|
2
|
+
supply them. These are Faust programs compiled inside the engine -> zero
|
|
3
|
+
external files, fully reproducible. Swap any of these for a SoundFont or a
|
|
4
|
+
VST later (see README) when you want more 'produced' instruments."""
|
|
5
|
+
from .registry import Instrument
|
|
6
|
+
|
|
7
|
+
# Faust convention for a polyphonic instrument: expose freq/gain/gate; the
|
|
8
|
+
# engine drives them from MIDI. `effect = _,_;` silences the poly warning.
|
|
9
|
+
_HEAD = 'import("stdfaust.lib");\n'
|
|
10
|
+
|
|
11
|
+
def saw_lead(cutoff=1500, voices=8):
|
|
12
|
+
dsp = _HEAD + f"""
|
|
13
|
+
freq=hslider("freq",440,20,8000,0.01);
|
|
14
|
+
gain=hslider("gain",0.4,0,1,0.01);
|
|
15
|
+
gate=button("gate");
|
|
16
|
+
env=en.adsr(0.01,0.15,0.6,0.25,gate);
|
|
17
|
+
process=os.sawtooth(freq)*env*gain : fi.resonlp({cutoff},2,1) <: _,_;
|
|
18
|
+
effect=_,_;
|
|
19
|
+
"""
|
|
20
|
+
return Instrument("saw_lead", dsp, voices)
|
|
21
|
+
|
|
22
|
+
def soft_pad(cutoff=1200, voices=8):
|
|
23
|
+
dsp = _HEAD + f"""
|
|
24
|
+
freq=hslider("freq",220,20,8000,0.01);
|
|
25
|
+
gain=hslider("gain",0.3,0,1,0.01);
|
|
26
|
+
gate=button("gate");
|
|
27
|
+
env=en.adsr(0.6,0.4,0.8,1.2,gate);
|
|
28
|
+
osc=(os.sawtooth(freq)+os.sawtooth(freq*1.006)+os.sawtooth(freq*0.994))/3;
|
|
29
|
+
process=osc*env*gain : fi.lowpass(2,{cutoff}) <: _,_;
|
|
30
|
+
effect=_,_;
|
|
31
|
+
"""
|
|
32
|
+
return Instrument("soft_pad", dsp, voices)
|
|
33
|
+
|
|
34
|
+
def sub_bass(voices=4):
|
|
35
|
+
dsp = _HEAD + """
|
|
36
|
+
freq=hslider("freq",110,20,4000,0.01);
|
|
37
|
+
gain=hslider("gain",0.5,0,1,0.01);
|
|
38
|
+
gate=button("gate");
|
|
39
|
+
env=en.adsr(0.01,0.1,0.9,0.2,gate);
|
|
40
|
+
process=(os.triangle(freq)*0.7+os.osc(freq)*0.3)*env*gain <: _,_;
|
|
41
|
+
effect=_,_;
|
|
42
|
+
"""
|
|
43
|
+
return Instrument("sub_bass", dsp, voices)
|
|
44
|
+
|
|
45
|
+
def pluck(voices=8):
|
|
46
|
+
dsp = _HEAD + """
|
|
47
|
+
freq=hslider("freq",440,20,8000,0.01);
|
|
48
|
+
gain=hslider("gain",0.5,0,1,0.01);
|
|
49
|
+
gate=button("gate");
|
|
50
|
+
process=pm.ks(freq, gate)*gain <: _,_;
|
|
51
|
+
effect=_,_;
|
|
52
|
+
"""
|
|
53
|
+
return Instrument("pluck", dsp, voices)
|
|
54
|
+
|
|
55
|
+
# ---- bus / master effects (2 in -> 2 out) --------------------------------
|
|
56
|
+
def reverb(wet=0.3):
|
|
57
|
+
return _HEAD + f"""
|
|
58
|
+
wet={wet};
|
|
59
|
+
process = _,_ <:
|
|
60
|
+
(_*(1.0-wet), _*(1.0-wet)),
|
|
61
|
+
(re.stereo_freeverb(0.72,0.5,0.5,23) : _*wet, _*wet)
|
|
62
|
+
:> _,_;
|
|
63
|
+
"""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The vocabulary you author in: a @sound registry plus the data types
|
|
2
|
+
that describe a sound. A sound build-function returns EITHER:
|
|
3
|
+
- a Buffer (raw samples you synthesised in numpy -> SFX)
|
|
4
|
+
- a RenderSpec (tracks of MIDI played by instruments -> music)
|
|
5
|
+
The harness looks at the type and routes it to the right renderer.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
import numpy as np
|
|
10
|
+
from .config import SR
|
|
11
|
+
|
|
12
|
+
_REGISTRY = {}
|
|
13
|
+
|
|
14
|
+
def sound(name):
|
|
15
|
+
"""Decorator: register a build-function under a name."""
|
|
16
|
+
def deco(fn):
|
|
17
|
+
_REGISTRY[name] = fn
|
|
18
|
+
return fn
|
|
19
|
+
return deco
|
|
20
|
+
|
|
21
|
+
def get(name): return _REGISTRY[name]
|
|
22
|
+
def names(): return sorted(_REGISTRY)
|
|
23
|
+
def clear(): _REGISTRY.clear()
|
|
24
|
+
|
|
25
|
+
# ---- SFX path -------------------------------------------------------------
|
|
26
|
+
@dataclass
|
|
27
|
+
class Buffer:
|
|
28
|
+
samples: np.ndarray # (n,) mono or (n, 2) stereo, float -1..1
|
|
29
|
+
sr: int = SR
|
|
30
|
+
|
|
31
|
+
# ---- Music path -----------------------------------------------------------
|
|
32
|
+
@dataclass
|
|
33
|
+
class Note:
|
|
34
|
+
pitch: int # MIDI note number (60 = middle C)
|
|
35
|
+
start: float # seconds
|
|
36
|
+
dur: float # seconds
|
|
37
|
+
vel: int = 100 # 1..127
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Instrument:
|
|
41
|
+
name: str
|
|
42
|
+
dsp: str # a Faust program (the sound source)
|
|
43
|
+
num_voices: int = 8 # >0 makes it a polyphonic MIDI instrument
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Track:
|
|
47
|
+
instrument: Instrument
|
|
48
|
+
notes: List[Note]
|
|
49
|
+
gain_db: float = 0.0
|
|
50
|
+
fx: List[str] = field(default_factory=list) # per-track Faust effects
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RenderSpec:
|
|
54
|
+
tracks: List[Track]
|
|
55
|
+
tempo: float = 120.0
|
|
56
|
+
duration: Optional[float] = None # auto from notes if None
|
|
57
|
+
master_fx: List[str] = field(default_factory=list) # bus effects (reverb...)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from shipwright import cli
|
|
8
|
+
from shipwright import config
|
|
9
|
+
from shipwright.registry import clear
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_sound(path: Path, body: str) -> None:
|
|
13
|
+
path.write_text(body, encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def reset_registry():
|
|
18
|
+
clear()
|
|
19
|
+
yield
|
|
20
|
+
clear()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_import_does_not_create_output_dir(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.chdir(tmp_path)
|
|
25
|
+
|
|
26
|
+
assert not Path("output").exists()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_cli_lists_sounds(tmp_path, monkeypatch, capsys):
|
|
30
|
+
sounds = tmp_path / "sounds"
|
|
31
|
+
sounds.mkdir()
|
|
32
|
+
write_sound(
|
|
33
|
+
sounds / "blip.py",
|
|
34
|
+
"""
|
|
35
|
+
from shipwright import sound, Buffer
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
@sound("blip")
|
|
39
|
+
def blip():
|
|
40
|
+
return Buffer(np.zeros((8, 2), dtype=np.float32))
|
|
41
|
+
""".lstrip(),
|
|
42
|
+
)
|
|
43
|
+
monkeypatch.setattr(config, "SOUNDS_DIR", sounds)
|
|
44
|
+
monkeypatch.setattr(config, "OUTPUT_DIR", tmp_path / "output")
|
|
45
|
+
|
|
46
|
+
cli.main([])
|
|
47
|
+
|
|
48
|
+
assert capsys.readouterr().out.strip() == "sounds: blip"
|
|
49
|
+
assert not (tmp_path / "output").exists()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_cli_unknown_sound_has_friendly_error(tmp_path, monkeypatch):
|
|
53
|
+
sounds = tmp_path / "sounds"
|
|
54
|
+
sounds.mkdir()
|
|
55
|
+
write_sound(
|
|
56
|
+
sounds / "blip.py",
|
|
57
|
+
"""
|
|
58
|
+
from shipwright import sound, Buffer
|
|
59
|
+
import numpy as np
|
|
60
|
+
|
|
61
|
+
@sound("blip")
|
|
62
|
+
def blip():
|
|
63
|
+
return Buffer(np.zeros((8, 2), dtype=np.float32))
|
|
64
|
+
""".lstrip(),
|
|
65
|
+
)
|
|
66
|
+
monkeypatch.setattr(config, "SOUNDS_DIR", sounds)
|
|
67
|
+
|
|
68
|
+
with pytest.raises(SystemExit, match="unknown sound 'missing'. Available sounds: blip"):
|
|
69
|
+
cli.main(["missing"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_cli_renders_buffer_sound(tmp_path, monkeypatch, capsys):
|
|
73
|
+
sounds = tmp_path / "sounds"
|
|
74
|
+
output = tmp_path / "rendered"
|
|
75
|
+
sounds.mkdir()
|
|
76
|
+
write_sound(
|
|
77
|
+
sounds / "blip.py",
|
|
78
|
+
"""
|
|
79
|
+
from shipwright import sound, Buffer
|
|
80
|
+
import numpy as np
|
|
81
|
+
|
|
82
|
+
@sound("blip")
|
|
83
|
+
def blip():
|
|
84
|
+
return Buffer(np.zeros((16, 2), dtype=np.float32))
|
|
85
|
+
""".lstrip(),
|
|
86
|
+
)
|
|
87
|
+
monkeypatch.setattr(config, "SOUNDS_DIR", sounds)
|
|
88
|
+
monkeypatch.setattr(config, "OUTPUT_DIR", output)
|
|
89
|
+
|
|
90
|
+
cli.main(["blip"])
|
|
91
|
+
|
|
92
|
+
assert (output / "blip.wav").is_file()
|
|
93
|
+
assert "blip" in capsys.readouterr().out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_cli_rejects_invalid_sound_return(tmp_path, monkeypatch):
|
|
97
|
+
sounds = tmp_path / "sounds"
|
|
98
|
+
sounds.mkdir()
|
|
99
|
+
write_sound(
|
|
100
|
+
sounds / "bad.py",
|
|
101
|
+
"""
|
|
102
|
+
from shipwright import sound
|
|
103
|
+
|
|
104
|
+
@sound("bad")
|
|
105
|
+
def bad():
|
|
106
|
+
return object()
|
|
107
|
+
""".lstrip(),
|
|
108
|
+
)
|
|
109
|
+
monkeypatch.setattr(config, "SOUNDS_DIR", sounds)
|
|
110
|
+
|
|
111
|
+
with pytest.raises(SystemExit, match="expected Buffer or RenderSpec"):
|
|
112
|
+
cli.main(["bad"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_cli_routes_render_spec_without_real_dawdreamer(tmp_path, monkeypatch):
|
|
116
|
+
sounds = tmp_path / "sounds"
|
|
117
|
+
output = tmp_path / "output"
|
|
118
|
+
sounds.mkdir()
|
|
119
|
+
write_sound(
|
|
120
|
+
sounds / "song.py",
|
|
121
|
+
"""
|
|
122
|
+
from shipwright import sound, RenderSpec
|
|
123
|
+
|
|
124
|
+
@sound("song")
|
|
125
|
+
def song():
|
|
126
|
+
return RenderSpec(tracks=[])
|
|
127
|
+
""".lstrip(),
|
|
128
|
+
)
|
|
129
|
+
monkeypatch.setattr(config, "SOUNDS_DIR", sounds)
|
|
130
|
+
monkeypatch.setattr(config, "OUTPUT_DIR", output)
|
|
131
|
+
|
|
132
|
+
def fake_render_spec(spec):
|
|
133
|
+
return np.zeros((16, 2), dtype=np.float32)
|
|
134
|
+
|
|
135
|
+
import shipwright.engine
|
|
136
|
+
|
|
137
|
+
monkeypatch.setattr(shipwright.engine, "render_spec", fake_render_spec)
|
|
138
|
+
|
|
139
|
+
cli.main(["song"])
|
|
140
|
+
|
|
141
|
+
assert (output / "song.wav").is_file()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from shipwright import compose
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_note_to_midi_and_chord_parsing():
|
|
5
|
+
assert compose.note_to_midi("C", 4) == 60
|
|
6
|
+
assert compose.note_to_midi("Bb", 3) == 58
|
|
7
|
+
assert compose.parse_chord("Dm", octave=3) == [50, 53, 57]
|
|
8
|
+
assert compose.parse_chord("Cmaj7", octave=4) == [60, 64, 67, 71]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_progression_and_melody_timing():
|
|
12
|
+
notes = compose.progression(["C"], bpm=120, beats_per_chord=2)
|
|
13
|
+
assert [note.pitch for note in notes] == [48, 52, 55]
|
|
14
|
+
assert {note.start for note in notes} == {0.0}
|
|
15
|
+
assert {note.dur for note in notes} == {1.0}
|
|
16
|
+
|
|
17
|
+
melody = compose.melody([60, None, 62], bpm=60, note_beats=[1, 1, 2])
|
|
18
|
+
assert [note.pitch for note in melody] == [60, 62]
|
|
19
|
+
assert [note.start for note in melody] == [0.0, 2.0]
|
|
20
|
+
assert [note.dur for note in melody] == [0.95, 1.9]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from shipwright import config, dsp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_oscillators_have_expected_length_and_range():
|
|
7
|
+
sig = dsp.sine(440, 0.1)
|
|
8
|
+
|
|
9
|
+
assert len(sig) == int(config.SR * 0.1)
|
|
10
|
+
assert np.max(np.abs(sig)) <= 1.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_layer_pads_to_longest_signal():
|
|
14
|
+
out = dsp.layer(np.ones(3), np.ones(5))
|
|
15
|
+
|
|
16
|
+
np.testing.assert_array_equal(out, np.array([2.0, 2.0, 2.0, 1.0, 1.0]))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_normalize_and_stereo_shape():
|
|
20
|
+
sig = dsp.normalize(np.array([0.0, 2.0, -1.0]), peak=0.5)
|
|
21
|
+
stereo = dsp.to_stereo(sig)
|
|
22
|
+
|
|
23
|
+
assert np.max(np.abs(sig)) == 0.5
|
|
24
|
+
assert stereo.shape == (3, 2)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from shipwright.engine import render_buffer
|
|
4
|
+
from shipwright.registry import Buffer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_render_buffer_limits_peak_and_preserves_shape():
|
|
8
|
+
audio = np.array([[2.0, -2.0], [0.5, -0.5]], dtype=np.float32)
|
|
9
|
+
|
|
10
|
+
rendered = render_buffer(Buffer(audio))
|
|
11
|
+
|
|
12
|
+
assert rendered.shape == audio.shape
|
|
13
|
+
assert rendered.dtype == np.float32
|
|
14
|
+
assert np.max(np.abs(rendered)) <= 0.97
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import shipwright
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_package_version_matches_project_metadata():
|
|
8
|
+
pyproject = Path("pyproject.toml").read_text(encoding="utf-8")
|
|
9
|
+
match = re.search(r'^version = "([^"]+)"$', pyproject, re.MULTILINE)
|
|
10
|
+
|
|
11
|
+
assert match is not None
|
|
12
|
+
assert shipwright.__version__ == match.group(1)
|