# Synth Keyboard ❤️

{% tabs %}
{% tab title="📗 參考" %}
* CSSTricks ⟩ [How to Code a Playable Synth Keyboard](https://css-tricks.com/how-to-code-a-playable-synth-keyboard/) ⭐️
* 維基 ⟩ [八度](https://zh.wikipedia.org/wiki/%E5%85%AB%E5%BA%A6) (Octave)
{% endtab %}

{% tab title="📘 手冊" %}
* MDN ⟩&#x20;
  * EventTarget ⟩&#x20;
    * BaseAudioContext ⟩&#x20;
      * [currentTime](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/currentTime) : number - **ever-increasing** timestamp in **seconds**
      * [createOscillator()](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createOscillator) : [OscillatorNode](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode)
      * [createGain()](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createGain) : GainNode
      * 📁 [AudioContext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext)
    * AudioNode ⟩&#x20;
      * 📁 AudioScheduledSourceNode ⟩&#x20;
        * 📁 [OscillatorNode](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode) - a **constant tone**&#x20;
      * 📁 [GainNode](https://developer.mozilla.org/en-US/docs/Web/API/GainNode) - control overall **gain** (**volume**) of the audio graph
        * [gain](https://developer.mozilla.org/en-US/docs/Web/API/GainNode/gain): AudioParam
  * [AudioParam](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam) ⟩
    * [exponentialRampToValueAtTime()](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/exponentialRampToValueAtTime)\
      **schedules** a **gradual change** in the value of the [`AudioParam`](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam)
  * CSS ⟩&#x20;
    * [user-select](https://developer.mozilla.org/en-US/docs/Web/CSS/user-select) - controls **whether** user can **select text**.
    * [Understanding CSS z-index](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index)
{% endtab %}

{% tab title="👥 相關" %}
* [CSS](../../css) ⟩ [position](../../css/layout/position), [z-index](../../../css/layout/position#z-index)
{% endtab %}
{% endtabs %}

{% embed url="https://codesandbox.io/embed/synth-keyboard-7jk1p?fontsize=14&hidenavigation=1&theme=dark" %}
synth keyboard
{% endembed %}

{% hint style="info" %}
**八度**（英語：**Octave**，亦稱為**完全八度**）是[音程](https://zh.wikipedia.org/wiki/%E9%9F%B3%E7%A8%8B)的一種，它的組成是由2個相同[音名](https://zh.wikipedia.org/wiki/%E9%9F%B3%E5%90%8D)但來自不同[音域](https://zh.wikipedia.org/wiki/%E9%9F%B3%E5%9F%9F)所組成。兩音的距離為 **12** 個[半音](https://zh.wikipedia.org/wiki/%E5%8D%8A%E9%9F%B3)，而[頻率](https://zh.wikipedia.org/wiki/%E9%A0%BB%E7%8E%87)的比例是 **2:1**，換而言之，較高音的頻率為較低音的**兩倍**。
{% endhint %}

## Pitch Formula

{% hint style="info" %}
計算音頻的公式：

$$\boxed{\text{pitch }=a\cdot 2^{\frac{n}{12}}}$$&#x20;

* $$a$$: 440 Hz ( pitch of $$A_4$$)
* $$n$$:  number of notes above or below $$A_4$$&#x20;
{% endhint %}

## Notes

{% hint style="warning" %}
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`](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam) interface.\
📘 [GainNode](https://developer.mozilla.org/en-US/docs/Web/API/GainNode)
{% endhint %}

{% hint style="warning" %}
* **z-index** only works on **positioned** elements❗️
{% endhint %}

## Live Demo

{% tabs %}
{% tab title="3. JS" %}
{% embed url="https://codepen.io/lochiwei/pen/gORdZPB" %}
{% endtab %}

{% tab title="2. CSS" %}
{% embed url="https://codepen.io/lochiwei/pen/yLXxRJo" %}


{% endtab %}

{% tab title="1. HTML" %}
{% embed url="https://codepen.io/lochiwei/pen/GREXYZR" %}
{% endtab %}
{% endtabs %}

## Code

💾 [replit](https://replit.com/@pegasusroe/synth-keyboard#script.js)

{% tabs %}
{% tab title="HTML" %}
```markup
<!-- 
 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>
```
{% endtab %}

{% tab title="CSS" %}
```css
/* 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;
}
```
{% endtab %}

{% tab title="JS" %}
```javascript
// ⭐️ 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);
});
```
{% endtab %}
{% endtabs %}
