ギターのコードチェンジ練習ツールを作成しました

## 作成したきっかけ

youtubeを見ながらギターのコードを勉強をしてたのですが、
動画に出てくる順でコードを覚えているところがあり
それを改善しようと思い作ってみました。

## 機能

先に書いておきますが、指板図の表示機能やメトロノーム機能はないです。
ツールの見た目は下図のような感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604560/1998041f-51bb-88b9-519b-0b583b513a5f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604560/55dc0560-88a0-0fca-2b41-670d9d9c5d06.png)

開始すると指定した秒数ごとにコードを画面に表示し、読み上げます。
(ギター見ながら練習できる!)

下記ショートカットを付けています。
スペースキー押下: 開始・停止
mキー押下: ミュートオン・オフ
↑キー押下: 秒数の加算
↓キー押下: 秒数の減産

秒数は3-10秒まで指定できます。
コード表示を実行している最中に値を変更できますが、反映はされないです。
停止してから開始してください。

## 全体のコード

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ChordChange</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/mousetrap/1.6.5/mousetrap.min.js" ></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
  <!-- <script src="chord.js"></script> -->
</head>
<body>
  <div>
    <!-- 3秒未満は読み上げが追い付かない -->
    <label for="second">秒数:</label>
    <input type="number" id="second" value="3" min="3" max="10"
    onchange="this.value = Math.max(3, Math.min(this.value, 10))">
  </div>
  <br>
  <div>
    <button onclick="startShowChord()">開始</button>
    <button onclick="stopShowChord()">停止</button>
    <input type="checkbox" id="mute">
    <label for="mute">ミュート</label>
  </div>

  <div hidden>
    <input type="text" id="intervalId">
    <input type="checkbox" id="process">
  </div>

  <h1 id="output">停止中</h1>

  <script>
    function startShowChord() {
      const process = document.querySelector('#process');
      // 二重実行防止
      if (process.checked) {
        return;
      }
      process.checked = true;

      // 1回目のコード表示
      chordChange();

      const second = document.querySelector('#second').value;
      const interval = second * 1000;

      // 2回目以降のコード表示
      const intervalId = setInterval(() => chordChange(), interval);
      document.querySelector('#intervalId').value = intervalId;
    }

    function stopShowChord() {
      const process = document.querySelector('#process');
      // 二重実行防止
      if (!process.checked) {
        return;
      }
      process.checked = false;

      document.querySelector('#output').innerText = '停止中';
      const intervalId = document.querySelector('#intervalId').value;
      clearInterval(intervalId);
    }
    
    function chordChange() {
      const showChord = Object.keys(chord).filter(key => chord[key] === 1);
      // コード表示
      const randomIndex = Math.floor(Math.random() * showChord.length);
      const randomText = sharpConvert(showChord[randomIndex]);
      document.querySelector('#output').innerText = randomText;

      // コード読み上げ
      if (!document.querySelector('#mute').checked) {
        const readText = textConvert(randomText);
        const firstSpeech = new SpeechSynthesisUtterance(readText);
        speechSynthesis.speak(firstSpeech);
      }
    }

    // スペースキー押下時に開始停止
    Mousetrap.bind('space', function() {
      // イベントのデフォルトの動作をキャンセル
      event.preventDefault();

      document.querySelector('#process').checked 
      ? stopShowChord() : startShowChord();
    });

    // mキー押下時にミュートのオンオフ
    Mousetrap.bind('m', function() {
      const muteState = document.querySelector('#mute');
      muteState.checked = !muteState.checked;
    });

    // ↑キー押下時に秒数を加算
    Mousetrap.bind('up', function() {
      const second = document.querySelector('#second');
      if (second.value < parseInt(second.max)) {
        second.value = parseInt(second.value) + 1;
      }
    });

    // ↓キー押下時に秒数を減算
    Mousetrap.bind('down', function() {
      const second = document.querySelector('#second');
      if (second.value > parseInt(second.min)) {
        second.value = parseInt(second.value) - 1;
      }
    });

    function sharpConvert(text) {
      return text.replace(/sp/g, '#');
    }

    function textConvert(text) {
      // text = text.replace(/7/g, 'セブンス');
      // text = text.replace(/sus4/g, 'サスペンデッドフォース');
      // text = text.replace(/M/g, 'メジャー');
      // text = text.replace(/add9/g, 'アドナイン');
      // text = text.replace(/b/g, 'フラット');
      // text = text.replace(/m/g, 'マイナー');
      // return text;
      const replacements = {
        '7': 'セブンス',
        'sus4': 'サスペンデッドフォース',
        'M': 'メジャー',
        'add9': 'アドナイン',
        'b': 'フラット',
        'm': 'マイナー'
      };
      return Object.entries(replacements).reduce((acc, [key, value]) => acc.replace(new RegExp(key, 'g'), value), text);
    };

  </script>

  <script>
    // #は使えないのでspに置き換え
    // 値が1なら画面表示の対象
    const chord = {
      E: 1,
      Em: 1,
      E7: 1,
      Em7: 1,
      Esus4: 1,
      Am: 1,
      Am7: 1,
      C: 1,
      CM7: 1,
      Cadd9: 1,
      C7: 1,
      D: 1,
      Dadd9: 1,
      Dsus4: 1,
      G: 1,
      G7: 1,
      Dm: 1,
      Dm7: 1,
      A: 1,
      A7: 1,
      Aadd9: 1,
      Asus4: 1,
      F: 1,
      Fm: 1,
      Fsp: 1,
      Fspm: 1,
      Bb: 1,
      B: 1,
      Bm: 1,
      Bbm: 1,
      Cm: 1
    };
  </script>
</body>
</html>
```

## コード解説

### 秒数の処理

``` html
    <!-- 3秒未満は読み上げが追い付かない -->
    <label for="second">秒数:</label>
    <input type="number" id="second" value="3" min="3" max="10"
    onchange="this.value = Math.max(3, Math.min(this.value, 10))">
```
input type="text"ならmaxLengthなどが機能しますが、
type="number"だとmin, maxを指定しても手入力したときに閾値を超える動きをします。
なのでonchange処理により変更があった際は値が3-10以内に収まるようにしています。
min, max属性は後続処理で使用していますが、後続使用がない場合はいらないと思います。

### 開始ボタン押下時処理

``` js
    function startShowChord() {
      const process = document.querySelector('#process');
      // 二重実行防止
      if (process.checked) {
        return;
      }
      process.checked = true;

      // 1回目のコード表示
      chordChange();

      const second = document.querySelector('#second').value;
      const interval = second * 1000;

      // 2回目以降のコード表示
      const intervalId = setInterval(() => chordChange(), interval);
      document.querySelector('#intervalId').value = intervalId;
    }
```

実行中に再度開始ボタン押すと二重で処理が実行されるため
二重実行できないようにしています。

setIntervalを使用することで指定秒数ごとにコード表示をしていますが、
1回目が呼ばれるのは指定秒数後になります。なので3秒を指定した場合、
開始ボタン押下から3秒後に処理が実行されます。
開始ボタン押下後すぐに処理を実行してほしかったのでchordChangeを2回呼んでいます。

インターバル実行中に時間変更できないか調べたりchatGPTに質問してみましたが、
一度clearIntervalをしてから再度setIntervalをしてという感じでした。

秒数を途中で変えた場合にsetIntervalの秒数をリアルタイムで更新したかったのですが、
インターバル実行中にclear・setIntervalを挟むことになり、
秒数を3→4→5という感じで連続で変更した場合に挙動になりそうだったので
秒数のリアルタイム更新は断念しました。

### コード表示・読み上げ処理

``` js
    function chordChange() {
      const showChord = Object.keys(chord).filter(key => chord[key] === 1);
      // コード表示
      const randomIndex = Math.floor(Math.random() * showChord.length);
      const randomText = sharpConvert(showChord[randomIndex]);
      document.querySelector('#output').innerText = randomText;

      // コード読み上げ
      if (!document.querySelector('#mute').checked) {
        const readText = textConvert(randomText);
        const firstSpeech = new SpeechSynthesisUtterance(readText);
        speechSynthesis.speak(firstSpeech);
      }
    }
```

``` js
    // #は使えないのでspに置き換え
    // 値が1なら画面表示の対象
    const chord = {
      E: 1,
      Em: 1,
      E7: 1,
      Em7: 1,
      Esus4: 1,
      Am: 1,
      Am7: 1,
      C: 1,
      CM7: 1,
      Cadd9: 1,
      C7: 1,
      D: 1,
      Dadd9: 1,
      Dsus4: 1,
      G: 1,
      G7: 1,
      Dm: 1,
      Dm7: 1,
      A: 1,
      A7: 1,
      Aadd9: 1,
      Asus4: 1,
      F: 1,
      Fm: 1,
      Fsp: 1,
      Fspm: 1,
      Bb: 1,
      B: 1,
      Bm: 1,
      Bbm: 1,
      Cm: 1
    };
```

chordの値が1のものを取得し、配列に変換します。
配列内のコードをランダムに取得しコードを表示・読み上げします。
~~連想配列で「#」は使用できないので「sp」にしておき、~~　(修正)
連想配列でクオーテ括りしない場合は「#」を使用できないので
「sp」にしておき、後続処理で「#」に置き換えています。

追記
chordをクオーテで括ってない理由としては新しく覚えたコードをchordに追加する度に
クオーテを入力しないといけなくなり大変だと思ったためクオーテで括ってないです。

### スペースキー押下時処理

``` js
    // スペースキー押下時に開始停止
    Mousetrap.bind('space', function() {
      // イベントのデフォルトの動作をキャンセル
      event.preventDefault();

      document.querySelector('#process').checked 
      ? stopShowChord() : startShowChord();
    });
```

Mousetrapライブラリを使用することでショートカットを使用できます。
今回はスペース押下時にコード表示の開始・停止を実行するようにしています。

ボタンにカーソルがある状態でスペースを押下するとブラウザ側により
ボタンが押下されるのですが、event.preventDefaultでボタン押下を無効化できます。
開始停止処理とブラウザのボタン押下処理が被らないようにするため記載しています。

ですが、ミュートにカーソルがある状態でスペースを押下すると
処理が無効化されていませんでした。原因は不明です。

### 文字変換

``` js
    function sharpConvert(text) {
      return text.replace(/sp/g, '#');
    }

    function textConvert(text) {
      // text = text.replace(/7/g, 'セブンス');
      // text = text.replace(/sus4/g, 'サスペンデッドフォース');
      // text = text.replace(/M/g, 'メジャー');
      // text = text.replace(/add9/g, 'アドナイン');
      // text = text.replace(/b/g, 'フラット');
      // text = text.replace(/m/g, 'マイナー');
      // return text;
      const replacements = {
        '7': 'セブンス',
        'sus4': 'サスペンデッドフォース',
        'M': 'メジャー',
        'add9': 'アドナイン',
        'b': 'フラット',
        'm': 'マイナー'
      };
      return Object.entries(replacements).reduce((acc, [key, value]) => acc.replace(new RegExp(key, 'g'), value), text);
    };

```

コードを読み上げるときに「7」と記載してあるとコードの呼び名である「セブンス」と
読み上げてくれないので「7」があれば「セブンス」という文字列に置き換えるようにしています。

reduceで記載しているとこはコメント部分と同じです。
もともとコメント側で記載していたのですが再代入をあまりしたくなかったので
chatGPTにいい感じにしてくれとお願いしたらreduceで帰ってきました。
reduceは普段使いしてなかったので何の処理かわかるようコメント側も残しています。

## おわり

メトロノーム機能つけてみようと思い、chatGPTに丸投げしたところ
それっぽいのは作ってくれましたが、どんな音にするかはヘルツで指定していたため
いいのが見つからない。耳が痛い。

そもそもメトロノーム音と読み上げ機能を全く同じタイミングで鳴らすのは無理なのでは
と思いました。並列処理は同時に2処理を実行できるだけで、
2処理実行のタイミングを同じにするなんてできるのかという

仮にできたとしてもBPMが速いと読み上げが追い付かなくなるし、
読み上げ速度を上げることもできますが、コード名長かったら読み上げ速度上げても対応不可だしな

という感じでメトロノームは断念しました。