Last active
August 24, 2025 03:16
-
-
Save DJStompZone/c0347b251e74a7753710d9f14cca14ae to your computer and use it in GitHub Desktop.
SnekSynth
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
""" | |
SnekSynth | |
A seemingly simple, self-sufficient serpentine sonic synthesizer script. | |
Voicing: | |
• Bass: sine oscillator with a slightly longer ADSR | |
• Stabs: detuned, band-limited saw pair with a snappier ADSR | |
Output: | |
demo.wav | |
""" | |
import math | |
import wave | |
import numpy as np | |
SAMPLE_RATE = 44100 | |
BPM = 160 | |
BEATS_PER_BAR = 3 | |
NOTE_DIVISION = 2 # eighth-note grid | |
SECONDS_PER_BEAT = 60.0 / BPM | |
SECONDS_PER_STEP = SECONDS_PER_BEAT / NOTE_DIVISION | |
OUT_PATH = "demo.wav" | |
NATS = "CDEFGAB"; | |
MASK = f"{110:07b}" | |
STEPS = zip(NATS,MASK) | |
TONES = dict(zip([a for n,b in STEPS for a in (n,(n+"#")*int(b)) if a], range(12))) | |
TONES.update({f"{nx}B": TONES[f"{n}#"] for n,nx,b in zip(NATS,NATS[1:]+NATS[:1],MASK) if b=="1"}) | |
TONES = dict(sorted(list(TONES.items()))) | |
def note_to_freq(name: str) -> float: | |
""" | |
Convert conventional pitch notation (C0=0 indexing) to frequency in Hz. | |
Equal temperament with A4 = 440 Hz. | |
""" | |
a4_index = 9 + 12 * 4 # index 57 | |
name = name.strip().upper() | |
if len(name) < 2: | |
raise ValueError(f"Bad note name: {name}") | |
if name[1] in ["#", "B"]: | |
key = name[:2] | |
octave = int(name[2:]) | |
else: | |
key = name[0] | |
octave = int(name[1:]) | |
if key not in TONES: | |
raise ValueError(f"Bad note key: {key}") | |
idx = TONES[key] + 12 * octave | |
semitones_from_a4 = idx - a4_index | |
return 440.0 * (2.0 ** (semitones_from_a4 / 12.0)) | |
def adsr_env(length_samples: int, sr: int, a: float = 0.005, d: float = 0.08, | |
s: float = 0.45, r: float = 0.06) -> np.ndarray: | |
""" | |
Generate a linear ADSR envelope of a given sample length. | |
Args: | |
length_samples: Total envelope length in samples. | |
sr: Sample rate. | |
a: Attack time in seconds. | |
d: Decay time in seconds. | |
s: Sustain level (0..1). | |
r: Release time in seconds. | |
Returns: | |
Envelope array shaped to length_samples. | |
""" | |
attack = max(1, int(a * sr)) | |
decay = max(1, int(d * sr)) | |
release = max(1, int(r * sr)) | |
sustain_len = max(0, length_samples - (attack + decay + release)) | |
env = np.zeros(length_samples, dtype=np.float64) | |
env[:attack] = np.linspace(0.0, 1.0, attack, endpoint=False) | |
env[attack:attack + decay] = np.linspace(1.0, s, decay, endpoint=False) | |
env[attack + decay:attack + decay + sustain_len] = s | |
env[attack + decay + sustain_len:] = np.linspace(s, 0.0, release, endpoint=False) | |
return env | |
def sine_osc(freq: float, length_s: float, sr: int) -> np.ndarray: | |
""" | |
Generate a pure sine oscillator. | |
Args: | |
freq: Frequency in Hz. | |
length_s: Duration in seconds. | |
sr: Sample rate. | |
Returns: | |
Numpy array containing the oscillator signal. | |
""" | |
n = int(length_s * sr) | |
t = np.linspace(0.0, length_s, n, endpoint=False) | |
return np.sin(2.0 * math.pi * freq * t) | |
def detuned_saws(freq: float, length_s: float, sr: int, detune_cents: float = 7.0) -> np.ndarray: | |
""" | |
Generate two detuned band-limited sawtooth waves and mix them. | |
Args: | |
freq: Base frequency in Hz. | |
length_s: Duration in seconds. | |
sr: Sample rate. | |
detune_cents: Detune amount for the pair in cents. | |
Returns: | |
Normalized mixed waveform of two detuned saws. | |
""" | |
n = int(length_s * sr) | |
t = np.linspace(0.0, length_s, n, endpoint=False) | |
cents = detune_cents / 1200.0 | |
f1 = freq * (2.0 ** cents) | |
f2 = freq * (2.0 ** -cents) | |
def bandlimited_saw(f: float) -> np.ndarray: | |
partials = 12 | |
wave = np.zeros_like(t) | |
for k in range(1, partials + 1): | |
wave += (1.0 / k) * np.sin(2.0 * math.pi * k * f * t) | |
peak = np.max(np.abs(wave)) | |
return wave / peak if peak > 0.0 else wave | |
w1 = bandlimited_saw(f1) | |
w2 = bandlimited_saw(f2) | |
mix = 0.5 * (w1 + w2) | |
peak = np.max(np.abs(mix)) | |
return mix / peak if peak > 0.0 else mix | |
def place_note(buffer: np.ndarray, start_idx: int, length_samples: int, | |
signal: np.ndarray, env: np.ndarray, gain: float = 1.0) -> None: | |
""" | |
Mix a note into the main buffer with envelope and gain. Safely clamps at buffer end. | |
Args: | |
buffer: Destination audio buffer. | |
start_idx: Start sample index. | |
length_samples: Intended note length in samples. | |
signal: Dry oscillator samples. | |
env: Envelope samples. | |
gain: Linear gain multiplier. | |
""" | |
end_idx = min(start_idx + length_samples, buffer.shape[0]) | |
seg_len = end_idx - start_idx | |
if seg_len <= 0: | |
return | |
sig = signal[:seg_len] * env[:seg_len] * gain | |
buffer[start_idx:end_idx] += sig | |
def build_mix() -> np.ndarray: | |
""" | |
Build the full 4-bar mix for the specified waltz pattern. | |
Returns: | |
Mono audio buffer as a float64 numpy array in range roughly [-1, 1]. | |
""" | |
steps_per_bar = BEATS_PER_BAR * NOTE_DIVISION | |
total_bars = 4 | |
total_steps = total_bars * steps_per_bar | |
total_seconds = total_steps * SECONDS_PER_STEP | |
total_samples = int(total_seconds * SAMPLE_RATE) | |
mix = np.zeros(total_samples, dtype=np.float64) | |
bars_spec = [ | |
("D4", "A4", "A4"), | |
("A3", "E4", "E4"), | |
("F3", "C4", "C4"), | |
("G3", "D4", "D4") | |
] * 16 | |
bass_gain = 0.45 | |
stab_gain = 0.35 | |
def bass_env(length_samples: int) -> np.ndarray: | |
return adsr_env(length_samples, SAMPLE_RATE, a=0.003, d=0.08, s=0.55, r=0.10) | |
def stab_env(length_samples: int) -> np.ndarray: | |
return adsr_env(length_samples, SAMPLE_RATE, a=0.002, d=0.05, s=0.35, r=0.06) | |
for bar_index, (bass_note, stab1, stab2) in enumerate(bars_spec): | |
bar_start_step = bar_index * steps_per_bar | |
bass_freq = note_to_freq(bass_note) | |
bass_start_sample = int((bar_start_step + 0) * SECONDS_PER_STEP * SAMPLE_RATE) | |
bass_len_samples = int(2 * SECONDS_PER_STEP * SAMPLE_RATE) | |
bass_sig = sine_osc(bass_freq, 2 * SECONDS_PER_STEP, SAMPLE_RATE) | |
place_note(mix, bass_start_sample, bass_len_samples, bass_sig, bass_env(bass_len_samples), gain=bass_gain) | |
stab1_freq = note_to_freq(stab1) | |
stab1_start = int((bar_start_step + 2) * SECONDS_PER_STEP * SAMPLE_RATE) | |
stab_len = int(SECONDS_PER_STEP * SAMPLE_RATE) | |
stab1_sig = detuned_saws(stab1_freq, SECONDS_PER_STEP, SAMPLE_RATE, detune_cents=8.0) | |
place_note(mix, stab1_start, stab_len, stab1_sig, stab_env(stab_len), gain=stab_gain) | |
stab2_freq = note_to_freq(stab2) | |
stab2_start = int((bar_start_step + 4) * SECONDS_PER_STEP * SAMPLE_RATE) | |
stab2_sig = detuned_saws(stab2_freq, SECONDS_PER_STEP, SAMPLE_RATE, detune_cents=8.0) | |
place_note(mix, stab2_start, stab_len, stab2_sig, stab_env(stab_len), gain=stab_gain) | |
peak = np.max(np.abs(mix)) | |
if peak > 0.0: | |
mix = mix / peak * 0.98 | |
return mix | |
def save_wav(path: str, audio: np.ndarray, sr: int) -> None: | |
""" | |
Save a mono float64 buffer to a 16-bit PCM WAV file. | |
Args: | |
path: Destination file path. | |
audio: Mono audio buffer in range [-1, 1]. | |
sr: Sample rate. | |
""" | |
with wave.open(path, "w") as f: | |
f.setnchannels(1) | |
f.setsampwidth(2) | |
f.setframerate(sr) | |
f.writeframes((np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()) | |
if __name__ == "__main__": | |
mix = build_mix() | |
save_wav(OUT_PATH, mix, SAMPLE_RATE) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment