Spaces:
Runtime error
Runtime error
| """ | |
| 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]) | |