FastHTML Piano, Part 3

from fastcore.all import * from fasthtml.common import *

settings_js = """window.audioContext = new AudioContext();
window.gainNode = audioContext.createGain();
window.gainNode.gain.value = 0.5;
window.activeOscillators = new Map();
"""

Piano Settings Bar

A volume slider to change the gainNode's value:

def VolumeInput(v=0.5):
    return Div(
            Span("Volume: ",style="vertical-align:middle;"),
            Input(type="range",min=0,max=1.0,step=0.01,value=v,name="volume",
                onchange=f"window.gainNode.gain.value=event.target.value;console.log('Volume changed to ', event.target.value);"))
show(VolumeInput())
show(VolumeInput(0.8))

A dropdown to choose a waveform type:

def WaveformInput():
    return Div(
            Script("function updateWaveform(type) {window.waveformType = type;console.log('Waveform set to', type)}"),
            Span("Waveform: "),
            Select(
                Option("Sine", value="sine"),
                Option("Square", value="square"),
                Option("Sawtooth", value="sawtooth"),
                Option("Triangle", value="triangle"),
                Option("Custom", value="custom"),
                onchange="updateWaveform(event.target.value)",
                name="waveform"))
show(WaveformInput())

Note: I defined a JS function in Script just to see if I can do that and call it in onchange. But it's simple enough that inline would have been fine.

The SettingsBar combines both inputs:

def SettingsBar():
    return Div(
        Script(settings_js),
        VolumeInput(),
        WaveformInput())
show(SettingsBar())

Notes and Frequencies

Let's modify the Key function to add click handling, and create the corresponding JavaScript to start and stop the oscillator when clicking. Here's how:

notes_in_octave = L(['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'])
notes_in_octave
npo = len(notes_in_octave)
a4_freq = 440.0
a4i = notes_in_octave.index('A') + (4 * npo)
def freq(note, octave):
    ni = notes_in_octave.index(note) + (octave * npo)
    semitones_from_a4 = ni - a4i
    freq = a4_freq * (2 ** (semitones_from_a4 / npo))
    return round(freq, ndigits=1)
key_js = """
function playTone(freq) {
    const osc = window.audioContext.createOscillator();
    osc.type = window.waveformType || 'sine';
    osc.frequency.value = freq;
    osc.connect(window.gainNode);
    window.gainNode.connect(window.audioContext.destination);
    osc.start();
    window.activeOscillators.set(freq, osc);
}

function stopTone(freq) {
    const osc = window.activeOscillators.get(freq);
    if (osc) {
        osc.stop();
        osc.disconnect();
        window.activeOscillators.delete(freq);
    }
}"""
def Key(note, octave): 
    f = freq(note,octave)
    is_black = '#' in note
    style = ('cursor:pointer;font:10px "Open Sans","Lucida Grande","Arial",sans-serif;text-align:center;'
            f'border:1px solid black;border-radius:5px;width:{20 if not is_black else 16}px;'
            f'height:{80 if not is_black else 50}px;'
            f'background-color:{" black" if is_black else "white"};color:{" white" if is_black else "black"};'
            f'box-shadow:2px 2px darkgray;display:inline-block;position:relative;'
            f'{"top:-30px;" if is_black else ""}'
            'user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;')
    return Div(
        Script(key_js),
        Div(Div(f, style=f'font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;{"bottom:24px;" if is_black else "bottom:50px;"}'), 
            note.replace('#','♯'), Sub(octave, style='font-size:10px;pointer-events:none;'), 
            style='position:absolute;bottom:1px;text-align:center;width:100%;pointer-events:none;'),
        onmouseover="event.target.style.backgroundColor = '#eef';",
        onmouseout=f"event.target.style.backgroundColor = '#{'000' if is_black else 'fff'}';",
        onmousedown=f"playTone({f})",
        onmouseup=f"stopTone({f})",
        onmouseleave=f"stopTone({f})",
        style=style)
def Octave(n):
    return Div(*notes_in_octave.map(partial(Key,octave=n)),
        style='display:inline-block;padding:0 6px 0 0;')
show(Octave(4))
def Keyboard(): return Div(*L(range(8)).map(Octave), style="width:auto;padding:0;margin:0;")
show(Keyboard())

It's coming together!

Remaining TODOs that I may or may not get to, depending on motivation, time, and interest from others:

Bonus nice-to-haves:

We'll see though, no promises.