shipwright-audio 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
shipwright/cli.py ADDED
@@ -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()
shipwright/compose.py ADDED
@@ -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
shipwright/config.py ADDED
@@ -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")
shipwright/dsp.py ADDED
@@ -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)
shipwright/engine.py ADDED
@@ -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
+ """
shipwright/registry.py ADDED
@@ -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,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,13 @@
1
+ shipwright/__init__.py,sha256=tj2kYv0zZXfAx2u5_zLmlxO_FWoudZiUWIC3W8A1eVw,148
2
+ shipwright/cli.py,sha256=c5h4zhchsnGgG9ko-jyP2oC3xvQTX7umdKE9ZsjtFxI,4412
3
+ shipwright/compose.py,sha256=C5_5f1nwws0BlcrTQ5E9mwl3M_ZkB1sPYWB3lk1ru-I,1858
4
+ shipwright/config.py,sha256=pNkblcZ1GR3WUTYF15g1MJsOxdwFRgBIZNndvIrkt4s,837
5
+ shipwright/dsp.py,sha256=UXzDEclQeLpBPsr2CBFX4SIl3qg8JwnFq2-hXzODZOI,2614
6
+ shipwright/engine.py,sha256=jizzsjHh5OAd6ai9m0Y0HZv7AHk2p9EYCJpEjhdSAwI,2072
7
+ shipwright/instruments.py,sha256=DY20PpVS-72SrMB_LTrCdWIrNIILpC5gd3GQ_Eq-ot8,2153
8
+ shipwright/registry.py,sha256=o5HUaAfY93khS1QMNRTdliSXMCbz5aPeabwxwKe87XY,1815
9
+ shipwright_audio-0.1.0.dist-info/METADATA,sha256=etbT1BVnA5FqmD2qPeNwGEPUNMGtsBmyO1SYFY7ZLwc,4779
10
+ shipwright_audio-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ shipwright_audio-0.1.0.dist-info/entry_points.txt,sha256=wUamQqOEB0WpNnoB-iRuS_Q3bFPWwcr3CMTCa-lofeI,51
12
+ shipwright_audio-0.1.0.dist-info/licenses/LICENSE,sha256=iSj3J5zohq1ZCIUkJ6oICrNieGcdRnAuEi0DsdS_ynw,1069
13
+ shipwright_audio-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shipwright = shipwright.cli:main
@@ -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.