notepilot / src /synth.py
jasonmdong's picture
Sync from GitHub via hub-sync
2cc9de5 verified
Raw
History Blame Contribute Delete
6.98 kB
"""
Software synthesizer with instrument presets.
All voices mix in a single OutputStream callback β€” no conflicting sd.play() calls.
Supported instruments:
piano, violin, viola, cello, strings,
flute, clarinet, oboe
"""
import threading
import numpy as np
import sounddevice as sd
SAMPLE_RATE = 44100
BLOCK_SIZE = 256 # ~5.8 ms latency
def midi_to_hz(pitch: int) -> float:
return 440.0 * (2.0 ** ((pitch - 69) / 12.0))
def _render_note(pitch: int, velocity: int, instrument: str = "piano") -> np.ndarray:
hz = midi_to_hz(pitch)
amp = (velocity / 127.0) * 0.35
instr = instrument.lower()
if instr in ("violin", "viola"):
return _render_strings(hz, amp, duration=1.5, brightness=1.0)
elif instr == "cello":
return _render_strings(hz, amp, duration=1.8, brightness=0.7)
elif instr in ("strings", "string ensemble"):
return _render_strings(hz, amp * 0.8, duration=1.8, brightness=0.8)
elif instr == "flute":
return _render_flute(hz, amp, duration=1.4)
elif instr == "clarinet":
return _render_clarinet(hz, amp, duration=1.4)
elif instr == "oboe":
return _render_oboe(hz, amp, duration=1.3)
else:
return _render_piano(hz, amp)
def _render_piano(hz: float, amp: float) -> np.ndarray:
duration = 1.2
n = int(SAMPLE_RATE * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = (
np.sin(2 * np.pi * hz * t) * 0.7
+ np.sin(2 * np.pi * hz * 2 * t) * 0.2
+ np.sin(2 * np.pi * hz * 3 * t) * 0.1
)
attack = int(SAMPLE_RATE * 0.008)
env = np.exp(-3.5 * t)
env[:attack] = np.linspace(0, 1, attack)
return (wave * env * amp).astype(np.float32)
def _render_strings(hz: float, amp: float, duration: float, brightness: float) -> np.ndarray:
"""Bowed string sound: sawtooth-like harmonics, slow attack, vibrato."""
n = int(SAMPLE_RATE * duration)
t = np.linspace(0, duration, n, endpoint=False)
# Sawtooth approximation via harmonics (more = brighter)
harmonics = 8
wave = np.zeros(n)
for k in range(1, harmonics + 1):
wave += (np.sin(2 * np.pi * hz * k * t) / k) * (brightness ** (k - 1))
# Vibrato: ~6 Hz, 0.3% depth
vibrato = 1.0 + 0.003 * np.sin(2 * np.pi * 6.0 * t)
wave_v = np.zeros(n)
for k in range(1, harmonics + 1):
wave_v += (np.sin(2 * np.pi * hz * k * vibrato * t) / k) * (brightness ** (k - 1))
# Blend in vibrato after attack
vib_onset = int(SAMPLE_RATE * 0.15)
blend = np.zeros(n)
blend[vib_onset:] = np.linspace(0, 1, n - vib_onset)
wave = wave * (1 - blend) + wave_v * blend
# Envelope: slow bow attack, long sustain, short release
attack_s = int(SAMPLE_RATE * 0.06)
release_s = int(SAMPLE_RATE * 0.2)
env = np.ones(n)
env[:attack_s] = np.linspace(0, 1, attack_s)
env[-release_s:] = np.linspace(1, 0, release_s)
return (wave * env * amp * 0.3).astype(np.float32)
def _render_flute(hz: float, amp: float, duration: float) -> np.ndarray:
"""Flute: mostly sine with a touch of breathiness (noise), soft attack."""
n = int(SAMPLE_RATE * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = (
np.sin(2 * np.pi * hz * t) * 0.85
+ np.sin(2 * np.pi * hz * 2 * t) * 0.12
+ np.sin(2 * np.pi * hz * 3 * t) * 0.03
)
# Breath noise: band-pass-ish via filtered white noise
noise = np.random.randn(n) * 0.04
wave = wave + noise
attack_s = int(SAMPLE_RATE * 0.04)
release_s = int(SAMPLE_RATE * 0.15)
env = np.ones(n)
env[:attack_s] = np.linspace(0, 1, attack_s)
env[-release_s:] = np.linspace(1, 0, release_s)
env *= np.exp(-0.4 * t)
return (wave * env * amp * 0.8).astype(np.float32)
def _render_clarinet(hz: float, amp: float, duration: float) -> np.ndarray:
"""Clarinet: odd harmonics dominant (cylindrical bore characteristic)."""
n = int(SAMPLE_RATE * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = (
np.sin(2 * np.pi * hz * t) * 0.7 # 1st
+ np.sin(2 * np.pi * hz * 3 * t) * 0.25 # 3rd
+ np.sin(2 * np.pi * hz * 5 * t) * 0.08 # 5th
+ np.sin(2 * np.pi * hz * 7 * t) * 0.03 # 7th
)
attack_s = int(SAMPLE_RATE * 0.025)
release_s = int(SAMPLE_RATE * 0.12)
env = np.ones(n)
env[:attack_s] = np.linspace(0, 1, attack_s)
env[-release_s:] = np.linspace(1, 0, release_s)
env *= np.exp(-0.5 * t)
return (wave * env * amp * 0.6).astype(np.float32)
def _render_oboe(hz: float, amp: float, duration: float) -> np.ndarray:
"""Oboe: nasal, rich in harmonics, reedy tone."""
n = int(SAMPLE_RATE * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = (
np.sin(2 * np.pi * hz * t) * 0.5
+ np.sin(2 * np.pi * hz * 2 * t) * 0.3
+ np.sin(2 * np.pi * hz * 3 * t) * 0.15
+ np.sin(2 * np.pi * hz * 4 * t) * 0.05
)
attack_s = int(SAMPLE_RATE * 0.02)
release_s = int(SAMPLE_RATE * 0.1)
env = np.ones(n)
env[:attack_s] = np.linspace(0, 1, attack_s)
env[-release_s:] = np.linspace(1, 0, release_s)
env *= np.exp(-0.6 * t)
return (wave * env * amp * 0.65).astype(np.float32)
# ── Mixer ──────────────────────────────────────────────────────────────────────
_voices: list[list] = []
_lock = threading.Lock()
def _callback(outdata, frames, time_info, status):
mixed = np.zeros(frames, dtype=np.float32)
with _lock:
alive = []
for voice in _voices:
samples, pos = voice
remaining = len(samples) - pos
if remaining <= 0:
continue
n = min(frames, remaining)
mixed[:n] += samples[pos : pos + n]
voice[1] = pos + n
if voice[1] < len(samples):
alive.append(voice)
_voices[:] = alive
peak = np.max(np.abs(mixed))
if peak > 1.0:
mixed /= peak
outdata[:, 0] = mixed
_stream = sd.OutputStream(
samplerate=SAMPLE_RATE,
channels=1,
dtype="float32",
blocksize=BLOCK_SIZE,
callback=_callback,
)
_stream.start()
# ── Public API ─────────────────────────────────────────────────────────────────
def play_note(pitch: int, velocity: int = 80, instrument: str = "piano"):
samples = _render_note(pitch, velocity, instrument)
with _lock:
_voices.append([samples, 0])
def play_chord(pitches: list, velocity: int = 64, instrument: str = "piano"):
if not pitches:
return
for pitch in pitches:
samples = _render_note(pitch, velocity, instrument)
with _lock:
_voices.append([samples, 0])