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.
@@ -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,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-06-28
4
+
5
+ - Initial public package for code-first audio rendering.
6
+ - Added the `shipwright` console command.
7
+ - Added Buffer-based SFX rendering and RenderSpec-based DawDreamer music rendering.
@@ -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,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .registry import sound, Buffer, Note, Instrument, Track, RenderSpec, names, get
4
+ from . import dsp, instruments, compose
@@ -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,3 @@
1
+ Drop General-MIDI SoundFonts (`.sf2`) here when you want real sampled
2
+ instruments instead of the built-in Faust synths. A good free starter is
3
+ `FluidR3_GM.sf2`. Then play them with pyfluidsynth (see project README).
@@ -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)