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.

ready
01

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.

python
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)

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.

02

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.

python
s.block_size = 16384     # try 1, 64, 512, 16384 — then Start
03

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.

python
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)
04

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.

python
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.

05

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.

python
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])
06

sounddevice code, and any widget as a control

A sounddevice.OutputStream callback, with a slider as a de-zippered volume fader.

python
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.

07

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.

python
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)
08

Edit it while it plays

Change the pattern or tempo and run this cell again: the loop keeps going, with your changes.

python
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 →