# 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/八度) (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](/web/css.md) ⟩ [position](/web/css/layout/position.md), [z-index](/web/css/layout/position.md#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 %}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://lochiwei.gitbook.io/web/js/proj/synth-keyboard.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
