Synth Keyboard ❤️
Last updated
Last updated
CSSTricks ⟩ How to Code a Playable Synth Keyboard ⭐️
維基 ⟩ 八度 (Octave)
MDN ⟩
EventTarget ⟩
BaseAudioContext ⟩
currentTime : number - ever-increasing timestamp in seconds
createGain() : GainNode
AudioNode ⟩
📁 AudioScheduledSourceNode ⟩
📁 OscillatorNode - a constant tone
exponentialRampToValueAtTime()
schedules a gradual change in the value of the AudioParam
CSS ⟩
user-select - controls whether user can select text.
計算音頻的公式:
pitch =a⋅212n
a: 440 Hz ( pitch of A4)
n: number of notes above or below A4
The gain is a unitless value, changing with time, that is multiplied to each corresponding sample of all input channels. If modified, the new gain is instantly applied, causing unaesthetic 'clicks' in the resulting audio. To prevent this from happening, never change the value directly but use the exponential interpolation methods on the AudioParam
interface.
📘 GainNode
z-index only works on positioned elements❗️
💾 replit
<!--
store the note value in a custom `note` attribute
so it’s easy to access in JavaScript
our keyboard covers just over an octave,
starting at C₃ and ending at F₄.
-->
<ul id="keyboard">
<li note="C" octave="3" class="white no-offset">A</li>
<li note="C#" octave="3" class="black">W</li>
<li note="D" octave="3" class="white">S</li>
<li note="D#" octave="3" class="black">E</li>
<li note="E" octave="3" class="white">D</li>
<li note="F" octave="3" class="white no-offset">F</li>
<li note="F#" octave="3" class="black">T</li>
<li note="G" octave="3" class="white">G</li>
<li note="G#" octave="3" class="black">Y</li>
<li note="A" octave="4" class="white">H</li>
<li note="A#" octave="4" class="black">U</li>
<li note="B" octave="4" class="white">J</li>
<li note="C2" octave="4" class="white no-offset">K</li>
<li note="C#2" octave="4" class="black">O</li>
<li note="D2" octave="4" class="white">L</li>
<li note="D#2" octave="4" class="black">P</li>
<li note="E2" octave="4" class="white">;</li>
<li note="F2" octave="4" class="white no-offset">'</li>
</ul>
/* css reset */
/* :root = html (with higher specificity) */
:root {
box-sizing: border-box;
}
/* "*" doesn't include pseudo-elements */
*, ::before, ::after {
/* box-sizing isn’t normally an inherited property, */
/* use `inherit` to force it. */
box-sizing: inherit;
}
/* css vars for colors */
:root {
--keyboard: hsl(300, 100%, 16%);
--keyboard-shadow: hsla(19, 50%, 66%, 0.2);
--keyboard-border: hsl(20, 91%, 5%);
--black-10: hsla(0, 0%, 0%, 0.1);
--black-20: hsla(0, 0%, 0%, 0.2);
--black-30: hsla(0, 0%, 0%, 0.3);
--black-50: hsla(0, 0%, 0%, 0.5);
--black-60: hsla(0, 0%, 0%, 0.6);
--white-20: hsla(0, 0%, 100%, 0.2);
--white-50: hsla(0, 0%, 100%, 0.5);
--white-80: hsla(0, 0%, 100%, 0.8);
}
/* keys */
.white, .black {
/* ⭐️⭐️⭐️ 有此設定 z-index 才有作用 ⭐️⭐️⭐️ */
position: relative;
/* ⭐️ 讓所有鍵靠在一起 */
float: left;
/* ⭐️ 所有鍵往左移(自己一半的寬度) */
margin: 0 0 0 -1rem;
/* ⭐️ list-style 自動消失❓ */
display: flex;
/* ⭐️ 英文字母放到下方中間 */
justify-content: center;
align-items: flex-end;
padding: .5rem 0;
/* ⭐️ user can't select text */
user-select: none;
cursor: pointer;
/* border: 1px solid black; */
}
#keyboard li:first-child {
border-radius: 5px 0 5px 5px;
}
#keyboard li:last-child {
border-radius: 0 5px 5px 5px;
}
.white {
height: 12.5rem;
width: 3.5rem;
z-index: 1;
border-left: 1px solid hsl(0, 0%, 73%);
border-bottom: 1px solid hsl(0, 0%, 73%);
border-radius: 0 0 5px 5px;
box-shadow:
-1px 0 0 var(--white-80) inset,
0 0 5px hsl(0, 0%, 80%) inset,
0 0 3px var(--black-20);
background: linear-gradient(
to bottom,
hsl(0, 0%, 93%) 0%,
white 100%
);
color: var(--black-30);
}
.black {
height: 8rem;
width: 2rem;
/* ⭐️ 黑鍵在白鍵上方 */
z-index: 2;
border: 1px solid black;
border-radius: 0 0 3px 3px;
box-shadow:
-1px -1px 2px var(--white-20) inset,
0 -5px 2px 3px var(--black-60) inset,
0 2px 4px var(--black-50);
background: linear-gradient(
45deg, hsl(0, 0%, 13%) 0%, hsl(0, 0%, 33%) 100%
);
color: var(--white-50);
}
/* ⭐️ A, F, K 鍵不需左移 */
.no-offset {
margin: 0;
}
/* pressed state */
.white.pressed {
border-top: 1px solid hsl(0, 0%, 47%);
border-left: 1px solid hsl(0, 0%, 60%);
border-bottom: 1px solid hsl(0, 0%, 60%);
box-shadow:
2px 0 3px var(--black-10) inset,
-5px 5px 20px var(--black-20) inset,
0 0 3px var(--black-20);
background: linear-gradient(
to bottom,
white 0%,
hsl(0, 0%, 91%) 100%
);
/* outline: none; */
}
.black.pressed {
box-shadow:
-1px -1px 2px var(--white-20) inset,
0 -2px 2px 3px var(--black-60) inset,
0 1px 2px var(--black-50);
background: linear-gradient(
to right,
hsl(0, 0%, 27%) 0%,
hsl(0, 0%, 13%) 100%
);
/* outline: none; */
}
/* keyboard */
#keyboard {
background-color: var(--keyboard);
box-shadow:
0 0 50px var(--black-50) inset,
0 1px var(--keyboard-shadow) inset,
0 5px 15px var(--black-50);
border: 1px solid var(--keyboard-border);
border-radius: 1rem;
height: 15.25rem;
width: 41rem;
margin: 0.5rem auto;
/* ⭐️ 將鍵盤移往下方偏右 */
padding: 3rem 0 0 1.25rem;
}
// ⭐️ audio context
const audioContext = new AudioContext();
// all note elements
const allNoteElems = document.querySelectorAll(`[note]`);
// ⭐️ keyboard keys (dictionary)
// keys[key] = {element: , note: , octave: }
let keys = {};
allNoteElems.forEach(elem => {
let key = elem.textContent;
keys[key] = {
element: elem,
note: elem.getAttribute('note'),
octave: +elem.getAttribute('octave')
};
});
// console.log(keys);
/*
pitch = 440 * 2^(n/12)
• 440 Hz: pitch value of A₄
• n: number of notes above or below that pitch
Musical Notation
----------------
• A# == Bb
*/
// ⭐️ usage: pitch("B#", 5)
function pitch(note="A", octave=4){
const notes = {
A: 0, B: 2, C: 3, D: 5, E:7, F: 8, G: 10
};
let n = notes[note[0]];
if(note.includes('#')) n += 1;
if(note.includes('b')) n -= 1;
if(note.includes('Ab')) n += 12; // "Ab" == 11
n += 12 * (octave - 4); // 1 octave = 12 notes
// pitch = 440 Hz * 2^(n/12)
return 440 * Math.pow(2, n/12);
}
// console.log(pitch("C#", 4));
/*
⭐️ pressedNotes
---------------
• notes that are playing at any given time.
• its unique key constraint can help prevent triggering
the same note multiple times in a single press.
*/
const pressedNotes = new Map();
// ⭐️
// user can only click one key at a time.
let clickedKey = "";
// ⭐️ usage: playKey('A')
const playKey = (key) => {
// no such key
if (!keys[key]) {
console.log(`no such key: ${key}`)
return;
}
// playback timestamp
const t = audioContext.currentTime;
// ⭐️ note's pitch
const freq = pitch(keys[key].note, keys[key].octave);
const GAIN = {
zero : 0.00001, // 為何不用 0 ?
max : 0.5,
sustained: 0.05,
};
// ⭐️ overall gain (volumn)
const noteGainNode = audioContext.createGain(); // ?
noteGainNode.connect(audioContext.destination); // ?
noteGainNode.gain.value = GAIN.zero; // 音量 ?
// ⭐️ attack:
// how quickly a sound goes from nothing to max volume
noteGainNode.gain.exponentialRampToValueAtTime(GAIN.max, t + 0.01);
// ⭐️ decay:
// time taken from peak volume to sustained volume
noteGainNode.gain.exponentialRampToValueAtTime(GAIN.sustained, t + 1);
// ⭐️ release:
// how long it takes to fade to nothing
noteGainNode.gain.exponentialRampToValueAtTime(GAIN.zero, t + 2);
// ⭐️ a constant tone
const osc = audioContext.createOscillator(); // ?
osc.connect(noteGainNode); // ?
osc.type = "triangle"; // ⭐️ timbre (音色) of the sound
osc.frequency.value = freq; // ⭐️ set pitch (音高)
// ⭐️ "pressed" state (改變外觀)
keys[key].element.classList.add("pressed");
// ⭐️
pressedNotes.set(key, osc);
pressedNotes.get(key).start();
};
// ⭐️ usage: stopKey('A')
const stopKey = (key) => {
// no such key
if (!keys[key]) {
console.log(`no such key: ${key}`)
return;
}
// ⭐️ remove "pressed" status
keys[key].element.classList.remove("pressed");
const osc = pressedNotes.get(key);
if (osc) {
setTimeout(() => osc.stop(), 2000);
pressedNotes.delete(key);
}
};
// "keydown" event
document.addEventListener("keydown", (e) => {
let key = e.key.toUpperCase();
if (pressedNotes.get(key)) return; // already playing?
playKey(key);
});
// "keyup" event
document.addEventListener("keyup", (e) => {
let key = e.key.toUpperCase();
stopKey(key);
});
// mouse events
for (const [key, { element }] of Object.entries(keys)) {
element.addEventListener("mousedown", () => {
playKey(key);
clickedKey = key;
});
}
document.addEventListener("mouseup", () => {
stopKey(clickedKey);
});