import numpy as np
from browseraudio import stream
SR = 44100
class Sinusoid:
def __init__(self):
self.pitch, self.phase = 60, 0.0
self.on = False # is a key held?
self.amp = 0.0 # level at the end of the last block
def callback(self, block): # block: (B, 1), with .sample_rate
B = len(block)
f = 440 * 2 ** ((self.pitch - 69) / 12) # read the controls once
target = 0.3 if self.on else 0.0
amp = np.linspace(self.amp, target, B, endpoint=False) # glide to it: no clicks
dphase = 2 * np.pi * f / block.sample_rate
block[:] = (amp * np.sin(self.phase + dphase * np.arange(B)))[:, np.newaxis]
self.phase = (self.phase + dphase * B) % (2 * np.pi)
self.amp = target
synth = Sinusoid()
def on_note(midi, on):
if on:
synth.pitch, synth.on = midi, True # a new key takes over, phase intact
elif midi == synth.pitch:
synth.on = False # the sounding key was released
s = stream(synth.callback, sample_rate=SR, block_size=512, on_note=on_note)
Feature · Real-time
Real-time synthesis, block by block
The browser asks for audio one block at a time; your NumPy callback fills each one, live, while you play.
Runs the real browseraudio on an in-browser Python kernel
(thebe + Pyodide). Cells are editable; the first
Run downloads ~25 MB. Reload to reset.
A callback with state
The pitch is read once per block; phase and amplitude carry over between blocks,
and the amplitude glides (de-zippering) so notes don't click. Run, press Start,
click the keyboard and hold keys: A S D F … white, W E T …
black, Z/X octave. One voice: a new key takes over.
The browser asks the kernel for each next block, so a key is heard after the queue plus one round trip; the widget shows both.
Block size: time budget vs. latency
Small blocks cost more callback calls (B = 1: watch % of budget);
big ones delay your keys (B = 16384). Run, then press Start again.
s.block_size = 16384 # try 1, 64, 512, 16384 — then Start
Polyphony: one voice per key
Each held key gets a voice with an attack/release envelope; the callback mixes them and soft-clips. Hold a chord.
s.stop() # stop the stream above
M = 2048 # one cycle of a soft, organ-like wave
table = sum(a * np.sin(2 * np.pi * k * np.arange(M) / M)
for k, a in enumerate([1, 0.5, 0.3, 0.2, 0.1], 1)) / 2
class Voice:
"""One note: a waveform shaped by an envelope that glides, never jumps."""
attack, release = 0.01, 0.25 # seconds (time constants)
def __init__(self, pitch):
self.f = 440 * 2 ** ((pitch - 69) / 12)
self.held, self.level, self.k = True, 0.0, 0 # k: samples played so far
def envelope(self, n):
target, tau = (1.0, self.attack) if self.held else (0.0, self.release)
env = target + (self.level - target) * np.exp(-np.arange(1, n + 1) / (tau * SR))
self.level = env[-1]
return env
def wave(self, k): # k: sample indices since the note began
p = (k * self.f * M / SR) % M
i = p.astype(int)
return table[i] + (p - i) * (table[(i + 1) % M] - table[i])
def render(self, n):
out = self.envelope(n) * self.wave(self.k + np.arange(n))
self.k += n
return out
class Poly:
"""Polyphony: one voice per held key, plus voices still fading out."""
def __init__(self, kind=Voice):
self.kind, self.held, self.fading = kind, {}, []
def on_note(self, midi, on):
if midi in self.held: # release the key's previous voice
self.held[midi].held = False
self.fading.append(self.held.pop(midi))
if on:
self.held[midi] = self.kind(midi)
def callback(self, block):
n = len(block)
self.fading = [v for v in self.fading if v.level > 1e-3]
voices = [*self.held.values(), *self.fading]
mix = sum((v.render(n) for v in voices), np.zeros(n))
block[:, 0] = 0.3 * np.tanh(mix / 2) # soft-clip: big chords stay smooth
poly = Poly()
s = stream(poly.callback, sample_rate=SR, block_size=512, on_note=poly.on_note)
Genie mode
The same synth, played with eight buttons (1–8 or
A S D F J K L ;): a neural network picks the piano keys.
s.stop()
poly = Poly()
s = stream(poly.callback, sample_rate=SR, block_size=512, on_note=poly.on_note,
input="genie", genie_range=(48, 84)) # C3 to C6
Genie mode is Piano Genie — Chris Donahue, Ian Simon & Sander Dieleman, Piano Genie, IUI 2019 (arXiv:1810.03226) — running the trained model published by Magenta. Please cite it if you use genie mode in your work.
FM synthesis, with live knobs
A sine whose phase is wobbled by another sine. The sliders are read every block: index for brightness, ratio for character.
import ipywidgets as widgets
ratio = widgets.FloatSlider(value=2, min=0.5, max=7, step=0.5, description="ratio")
index = widgets.FloatSlider(value=3, min=0, max=10, step=0.1, description="index")
class FMVoice(Voice):
"""Frequency modulation: a sine whose phase is wobbled by another sine."""
def __init__(self, pitch):
super().__init__(pitch)
self.mod_phase, self.idx = 0.0, index.value
def wave(self, k):
n = len(k)
dm = 2 * np.pi * ratio.value * self.f / SR # knobs: read once a block
mod = np.sin(self.mod_phase + dm * np.arange(n))
self.mod_phase = (self.mod_phase + dm * n) % (2 * np.pi)
idx = np.linspace(self.idx, index.value, n, endpoint=False) # de-zipper
self.idx = index.value
return np.sin(2 * np.pi * self.f * k / SR + idx * mod)
s.stop()
fm = Poly(FMVoice)
s = stream(fm.callback, sample_rate=SR, block_size=512, on_note=fm.on_note)
widgets.VBox([ratio, index])
sounddevice code, and any widget as a control
A sounddevice.OutputStream callback, with a slider as a de-zippered
volume fader.
import ipywidgets as widgets
import browseraudio.sounddevice as sd
s.stop()
level = widgets.FloatSlider(value=0.5, min=0, max=1, step=0.1, description="level")
phase, previous = 0.0, level.value
def callback(outdata, frames, time, status):
global phase, previous
step = 2 * np.pi * 220 / 44100
tone = np.sin(phase + step * np.arange(frames))
phase = (phase + step * frames) % (2 * np.pi)
ramp = np.linspace(previous, level.value, frames, endpoint=False)
previous = level.value
outdata[:] = 0.3 * (ramp * tone)[:, np.newaxis]
out = sd.OutputStream(samplerate=44100, blocksize=512, channels=1, callback=callback)
out.start()
level
Blocks are only made while no cell runs: a busy kernel is heard as dropouts.
A live score
A loop of (beat, kwargs) events, pyquist-style: each block, the callback
starts the events whose beat it reaches and mixes the sounding notes. Run, press
Start.
out.stop()
def note(pitch, length=0.5):
t = np.arange(int(length * SR)) / SR
f = 440 * 2 ** ((pitch - 69) / 12)
return 0.2 * np.sin(2 * np.pi * f * t) * np.exp(-8 * t) * np.minimum(1, t * 400)
def kick(length=0.4):
t = np.arange(int(length * SR)) / SR
return 0.5 * np.sin(2 * np.pi * (50 * t + 60 * (1 - np.exp(-30 * t)))) * np.exp(-12 * t)
def instrument(pitch=None, drum=False): # event kwargs -> samples
return kick() if drum else note(pitch)
class LiveScore:
"""Loops a score of (beat, kwargs) events. Edit it while it plays."""
def __init__(self, score, instrument, beats=4, bpm=120):
self.score, self.instrument, self.beats, self.bpm = score, instrument, beats, bpm
self.beat, self.voices = 0.0, [] # playhead (beats); [samples, read index]
self.stream = stream(self.callback, sample_rate=SR, block_size=512)
def callback(self, block):
B = len(block)
span = B / SR * self.bpm / 60 # beats in this block
for beat, kwargs in self.score:
ahead = (beat - self.beat) % self.beats
if ahead < span: # starts in this block
self.voices.append([self.instrument(**kwargs), -int(ahead / span * B)])
for v in self.voices: # mix every sounding note
x, i = v
a = max(0, -i)
seg = x[max(i, 0):max(i, 0) + B - a]
block[a:a + len(seg), 0] += seg
v[1] += B
self.voices = [v for v in self.voices if v[1] < len(v[0])]
self.beat = (self.beat + span) % self.beats
score = [(0, {"drum": True}), (2, {"drum": True}),
(0, {"pitch": 60}), (1, {"pitch": 63}), (2, {"pitch": 67}), (3, {"pitch": 70})]
live = LiveScore(score, instrument)
Edit it while it plays
Change the pattern or tempo and run this cell again: the loop keeps going, with your changes.
score[:] = [(0, {"drum": True}), (1.5, {"drum": True}), (2, {"drum": True}),
(0, {"pitch": 60}), (0.5, {"pitch": 67}), (1, {"pitch": 63}),
(1.5, {"pitch": 70}), (2, {"pitch": 72}), (2.5, {"pitch": 70}),
(3, {"pitch": 67}), (3.5, {"pitch": 63})]
live.bpm = 132
Prefer zero round trips? Design a sound up front and play it in the page →