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 +4 -0
- shipwright/cli.py +132 -0
- shipwright/compose.py +48 -0
- shipwright/config.py +22 -0
- shipwright/dsp.py +70 -0
- shipwright/engine.py +55 -0
- shipwright/instruments.py +63 -0
- shipwright/registry.py +57 -0
- shipwright_audio-0.1.0.dist-info/METADATA +135 -0
- shipwright_audio-0.1.0.dist-info/RECORD +13 -0
- shipwright_audio-0.1.0.dist-info/WHEEL +4 -0
- shipwright_audio-0.1.0.dist-info/entry_points.txt +2 -0
- shipwright_audio-0.1.0.dist-info/licenses/LICENSE +21 -0
shipwright/__init__.py
ADDED
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,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.
|