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
(#12) ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
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())
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">17.3</div>
C♯0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">18.4</div>
D0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">19.4</div>
D♯0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">20.6</div>
E0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">21.8</div>
F0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">23.1</div>
F♯0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">24.5</div>
G0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">26.0</div>
G♯0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">27.5</div>
A0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">29.1</div>
A♯0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">30.9</div>
B0
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">34.6</div>
C♯1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">36.7</div>
D1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">38.9</div>
D♯1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">41.2</div>
E1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">43.7</div>
F1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">46.2</div>
F♯1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">49.0</div>
G1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">51.9</div>
G♯1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">55.0</div>
A1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">58.3</div>
A♯1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">61.7</div>
B1
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">69.3</div>
C♯2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">73.4</div>
D2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">77.8</div>
D♯2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">82.4</div>
E2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">87.3</div>
F2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">92.5</div>
F♯2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">98.0</div>
G2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">103.8</div>
G♯2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">110.0</div>
A2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">116.5</div>
A♯2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">123.5</div>
B2
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">138.6</div>
C♯3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">146.8</div>
D3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">155.6</div>
D♯3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">164.8</div>
E3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">174.6</div>
F3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">185.0</div>
F♯3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">196.0</div>
G3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">207.7</div>
G♯3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">220.0</div>
A3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">233.1</div>
A♯3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">246.9</div>
B3
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">277.2</div>
C♯4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">293.7</div>
D4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">311.1</div>
D♯4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">329.6</div>
E4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">349.2</div>
F4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">370.0</div>
F♯4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">392.0</div>
G4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">415.3</div>
G♯4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">440.0</div>
A4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">466.2</div>
A♯4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">493.9</div>
B4
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">554.4</div>
C♯5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">587.3</div>
D5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">622.3</div>
D♯5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">659.3</div>
E5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">698.5</div>
F5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">740.0</div>
F♯5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">784.0</div>
G5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">830.6</div>
G♯5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">880.0</div>
A5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">932.3</div>
A♯5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">987.8</div>
B5
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">1108.7</div>
C♯6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1174.7</div>
D6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">1244.5</div>
D♯6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1318.5</div>
E6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1396.9</div>
F6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">1480.0</div>
F♯6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1568.0</div>
G6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">1661.2</div>
G♯6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1760.0</div>
A6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">1864.7</div>
A♯6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">1975.5</div>
B6
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">2217.5</div>
C♯7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">2349.3</div>
D7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">2489.0</div>
D♯7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">2637.0</div>
E7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">2793.8</div>
F7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">2960.0</div>
F♯7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">3136.0</div>
G7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">3322.4</div>
G♯7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">3520.0</div>
A7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:24px;">3729.3</div>
A♯7
<div style="font-size:10px;transform:rotate(90deg);position:absolute;left:-6px;bottom:50px;">3951.1</div>
B7
It's coming together!
Remaining TODOs that I may or may not get to, depending on motivation, time, and interest from others:
- Simplify to a list of one oscillator per note
- Connect waveform select
- Allow user to hold down the mouse and slide across keys
- Computer keyboard control to allow for chords
- Get it working on mobile and check non-Chrome browsers
- Limit octaves to the audible ones
Bonus nice-to-haves:
- Make the black keys overlap the white ones correctly so it looks more pianolike
- Songs
We'll see though, no promises.