FastHTML Piano, Part 2

FastHTML Piano, Part 2

from fastcore.all import *
from fasthtml.common import *
from fasthtml.jupyter import *
from IPython.display import display, Javascript

Piano Keys

In Part 1 we defined piano keys like this:

def Key(note, octave): return Div(
    Div(note, Sub(octave, style='font-size:10px;pointer-events:none;'), style='position:absolute;bottom:0;text-align:center;width:100%;pointer-events:none;'),
    onmouseover="event.target.style.backgroundColor = '#eef';",
    onmouseout="event.target.style.backgroundColor = '#fff';",
    style='cursor:pointer;font:16px "Open Sans","Lucida Grande","Arial",sans-serif;text-align:center;border:1px solid black;border-radius:5px;width:20px;height:80px;margin-right:3px;box-shadow:2px 2px darkgray;display:inline-block;position:relative;user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;')
show(Key('C','5'), Key('D','5'))
C5
D5

We'll be adding frequencies to the keys.

Frequencies of Notes

Even though our piano has just the white keys, we need all the notes to calculate the frequencies:

notes_in_octave = L(['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'])
notes_in_octave
(#12) ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']

There are 12 notes per octave. I define npo for later use:

npo = len(notes_in_octave)
npo
12

We use a4's frequency to calculate the other note frequencies:

a4_freq = 440.0
notes_in_octave.index('A')
9

And a4's index:

a4i = notes_in_octave.index('A') + (4 * npo)
a4i
57

Instead of a hardcoded table in JS with all the note frequencies for all octaves, we define this Python function:

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)
freq('A', 4)
440.0
freq('C', 3)
130.8

Keys With Frequencies

I like the idea of showing the frequencies on the piano keys.

def Key(note, octave): 
    f = freq(note,octave)
    return Div(
        Div(Div(f, style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;"), note, 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="event.target.style.backgroundColor = '#fff';",
        style='cursor:pointer;font:10px "Open Sans","Lucida Grande","Arial",sans-serif;text-align:center;border:1px solid black;border-radius:5px;width:22px;height:80px;margin-right:3px;box-shadow:2px 2px darkgray;display:inline-block;position:relative;user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;')
show(Key('A', 4))
440.0
A4

Octaves

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))
261.6
C4
277.2
C#4
293.7
D4
311.1
D#4
329.6
E4
349.2
F4
370.0
F#4
392.0
G4
415.3
G#4
440.0
A4
466.2
A#4
493.9
B4

Keyboard

def Keyboard(): return Div(*L(range(8)).map(Octave), style="width:auto;padding:0;margin:0;")
show(Keyboard())
16.4
C0
17.3
C#0
18.4
D0
19.4
D#0
20.6
E0
21.8
F0
23.1
F#0
24.5
G0
26.0
G#0
27.5
A0
29.1
A#0
30.9
B0
32.7
C1
34.6
C#1
36.7
D1
38.9
D#1
41.2
E1
43.7
F1
46.2
F#1
49.0
G1
51.9
G#1
55.0
A1
58.3
A#1
61.7
B1
65.4
C2
69.3
C#2
73.4
D2
77.8
D#2
82.4
E2
87.3
F2
92.5
F#2
98.0
G2
103.8
G#2
110.0
A2
116.5
A#2
123.5
B2
130.8
C3
138.6
C#3
146.8
D3
155.6
D#3
164.8
E3
174.6
F3
185.0
F#3
196.0
G3
207.7
G#3
220.0
A3
233.1
A#3
246.9
B3
261.6
C4
277.2
C#4
293.7
D4
311.1
D#4
329.6
E4
349.2
F4
370.0
F#4
392.0
G4
415.3
G#4
440.0
A4
466.2
A#4
493.9
B4
523.3
C5
554.4
C#5
587.3
D5
622.3
D#5
659.3
E5
698.5
F5
740.0
F#5
784.0
G5
830.6
G#5
880.0
A5
932.3
A#5
987.8
B5
1046.5
C6
1108.7
C#6
1174.7
D6
1244.5
D#6
1318.5
E6
1396.9
F6
1480.0
F#6
1568.0
G6
1661.2
G#6
1760.0
A6
1864.7
A#6
1975.5
B6
2093.0
C7
2217.5
C#7
2349.3
D7
2489.0
D#7
2637.0
E7
2793.8
F7
2960.0
F#7
3136.0
G7
3322.4
G#7
3520.0
A7
3729.3
A#7
3951.1
B7

The MDN article had this:

show(Div(Keyboard(), style="overflow-x:scroll;overflow-y:hidden;width:100%;height:110px;white-space:nowrap;margin:10px;"))
16.4
C0
17.3
C#0
18.4
D0
19.4
D#0
20.6
E0
21.8
F0
23.1
F#0
24.5
G0
26.0
G#0
27.5
A0
29.1
A#0
30.9
B0
32.7
C1
34.6
C#1
36.7
D1
38.9
D#1
41.2
E1
43.7
F1
46.2
F#1
49.0
G1
51.9
G#1
55.0
A1
58.3
A#1
61.7
B1
65.4
C2
69.3
C#2
73.4
D2
77.8
D#2
82.4
E2
87.3
F2
92.5
F#2
98.0
G2
103.8
G#2
110.0
A2
116.5
A#2
123.5
B2
130.8
C3
138.6
C#3
146.8
D3
155.6
D#3
164.8
E3
174.6
F3
185.0
F#3
196.0
G3
207.7
G#3
220.0
A3
233.1
A#3
246.9
B3
261.6
C4
277.2
C#4
293.7
D4
311.1
D#4
329.6
E4
349.2
F4
370.0
F#4
392.0
G4
415.3
G#4
440.0
A4
466.2
A#4
493.9
B4
523.3
C5
554.4
C#5
587.3
D5
622.3
D#5
659.3
E5
698.5
F5
740.0
F#5
784.0
G5
830.6
G#5
880.0
A5
932.3
A#5
987.8
B5
1046.5
C6
1108.7
C#6
1174.7
D6
1244.5
D#6
1318.5
E6
1396.9
F6
1480.0
F#6
1568.0
G6
1661.2
G#6
1760.0
A6
1864.7
A#6
1975.5
B6
2093.0
C7
2217.5
C#7
2349.3
D7
2489.0
D#7
2637.0
E7
2793.8
F7
2960.0
F#7
3136.0
G7
3322.4
G#7
3520.0
A7
3729.3
A#7
3951.1
B7

But I think wrapping the keys without a horizontal scrollbar is nicer.

Settings Bar

For now, I convert the original to a FastTag here:

def SettingsBar():
    return Div(
        Div(
            Span("Volume: ",style="vertical-align:middle;"),
            Input(type="range",min=0,max=1.0,step=0.01,value=0.5,list="volumes",name="volume",style="vertical-align:middle;"),
            Datalist(
                Option(value=0.0,label="Mute"),
                Option(value=1.0,label="100%"),
                id="volumes",),
            style="width:50%;position:absolute;left:0;display:table-cell;vertical-align:middle;"),
        Div(
            Span("Waveform: ", style="vertical-align:middle;"),
            Select(
                Option("Sine", value="sine"),
                Option("Square", value="square"),
                Option("Sawtooth", value="sawtooth"),
                Option("Triangle", value="triangle"),
                Option("Custom", value="custom"),
                name="waveform",
                style="vertical-align:middle;"),
            style="width:50%;position:absolute;right:0;display:table-cell;vertical-align:middle;"),
        style='padding-top:8px;font:14px "Open Sans","Lucida Grande","Arial",sans-serif;position:relative;vertical-align:middle;width:100%;height:80px;')
show(SettingsBar())
Volume:
Waveform:

Creating Oscillators in JS

%%javascript
window.audioContext = new AudioContext();
window.gainNode = audioContext.createGain();
window.gainNode.gain.value = 0.5;

function playTone(freq) {
    const osc = window.audioContext.createOscillator();
    osc.type = 'sine';
    osc.frequency.value = freq;
    osc.connect(window.gainNode);
    window.gainNode.connect(window.audioContext.destination);
    osc.start();
}
playTone(400)
<IPython.core.display.Javascript object>

Creating Oscillators in Python

The MDN example creates 1 oscillator per note. I'm just playing around here and may change this later: instead of doing it in JS, I define a Python function to generate the Web Audio API JS code to do this:

def mk_osc(freq): 
    return f"""if (!window.audioContext) {{
      window.audioContext = new AudioContext();
      window.gainNode = audioContext.createGain();
      window.gainNode.gain.value = 0.5;
    }}
    const osc=window.audioContext.createOscillator();
    osc.type='sine';
    osc.frequency.value={freq};
    osc.connect(window.gainNode);
    window.gainNode.connect(window.audioContext.destination);
    console.log('Starting freq {freq}');
    osc.start()"""
mk_osc(440.0)
"if (!window.audioContext) {\n      window.audioContext = new AudioContext();\n      window.gainNode = audioContext.createGain();\n      window.gainNode.gain.value = 0.5;\n    }\n    const osc=window.audioContext.createOscillator();\n    osc.type='sine';\n    osc.frequency.value=440.0;\n    osc.connect(window.gainNode);\n    window.gainNode.connect(window.audioContext.destination);\n    console.log('Starting freq 440.0');\n    osc.start()"

Running this cell plays the 440Hz tone:

display(Javascript(mk_osc(440.0)))
<IPython.core.display.Javascript object>
def mk_stoposc(freq): return f"console.log('Stopping freq {freq}');osc.stop();";
mk_stoposc(440.0)
"console.log('Stopping freq 440.0');osc.stop();"
display(Javascript(mk_stoposc(440.0)))
<IPython.core.display.Javascript object>

This is tricker than I thought. mk_stoposc needs to get osc from window, I think. We probably want to create a list of oscillators for all note frequencies and waveforms, and attach it to window.

Playing Frequencies

def Key(note, octave): 
    f = freq(note,octave)
    return Div(
        Div(Div(f, style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;"), note, 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="event.target.style.backgroundColor = '#fff';",
        onmousedown=mk_osc(f),
        onmouseup=mk_stoposc(f),
        style='cursor:pointer;font:10px "Open Sans","Lucida Grande","Arial",sans-serif;text-align:center;border:1px solid black;border-radius:5px;width:22px;height:80px;margin-right:3px;box-shadow:2px 2px darkgray;display:inline-block;position:relative;user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;')
show(Key('A', 4))
440.0
A4

The tone plays, but mk_stoposc doesn't actually work here. To be continued...