Reactで、押すと音が鳴る鍵盤を作る方法

​​React​Playwright,TypeScript,Web Audio API

押すと音が鳴る鍵盤は、音声ファイルを用意しなくても作れます。Web Audio API の OscillatorNode で決まった高さの音を作り、GainNode で音量を変えます。鍵盤を押したら鳴らし始め、離したら音量を少しずつ下げてから止めます。

ここでは、C4(ド)から C5 までの13鍵を、マウスとパソコンのキーの両方で弾けるようにします。止め方しだいで波形が途中で途切れることや、押しっぱなしにしたとき・鍵盤の外で離したときに音が鳴りやまなくなることも、対策の部分を外したものと比べて確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用のビルドを StrictMode で包んで動かしました。操作は Playwright で送り、音は人の耳で聞く代わりに、出力を AnalyserNode で受けて周波数と大きさを調べました。タッチ操作、実物のキーボード、Firefox と Safari、スマートフォンのブラウザは試していません。

13鍵の鍵盤の下に「操作 n回目/6回」と「押している鍵盤」の表示がある。C4、E4、G4、C#4 を順にクリックすると、押している間だけその鍵盤が水色になり、表示が「C4(オシレーター 1個)」のように変わる。A4 を押したまま鍵盤の外へ動かすと水色が消え、表示は「なし(オシレーター 1個)」を経て「なし(オシレーター 0個)」になる。最後にパソコンのキー A・D・G を押すと、C4・E4・G4 が同時に水色になり、「オシレーター 3個」と表示される
マウスで4回クリックし、A4 を押したまま外へ動かし、最後にパソコンのキーで和音を弾く様子。離した後も 0.3 秒ほどは音量を下げながら鳴っているので、オシレーターの数は少し遅れて減る。カーソル、操作の回数、押している鍵盤の表示と、表示が変わった瞬間のピンクは GIF 用に重ねたもの

1つの音を鳴らして止める関数

音を鳴らす部分は React に依存しないので、note.ts にまとめました。この記事では、鍵盤ごとに A4 を 69 とする番号を持たせ、半音上がるごとに1つ増やします。周波数は、A4 を 440Hz とし、半音ごとに 2 の 12 乗根倍にして 440 * 2 ** ((midi - 69) / 12) で計算します。検証では、C4・E4・G4・A4 がそれぞれ 261.63Hz、329.63Hz、392Hz、440Hz になりました。

ts
// MIDI のノート番号(A4 = 69)から周波数を出す(平均律、A4 = 440Hz)
export function noteFrequency(midi: number): number {
  return 440 * 2 ** ((midi - 69) / 12);
}

export type Voice = { stop: (at?: number) => void };

// 1つの音を鳴らし始める。止めるときは、返した stop を呼ぶ
export function startNote(ctx: BaseAudioContext, frequency: number, when = ctx.currentTime): Voice {
  const osc = new OscillatorNode(ctx, { type: 'triangle', frequency });
  const gain = new GainNode(ctx, { gain: 0 });
  osc.connect(gain).connect(ctx.destination);

  gain.gain.setValueAtTime(0, when);
  gain.gain.linearRampToValueAtTime(0.3, when + 0.01); // 10ms かけて音量を上げる
  osc.start(when);

  return {
    stop(at = ctx.currentTime) {
      gain.gain.cancelScheduledValues(at);
      gain.gain.setTargetAtTime(0, at, 0.03); // 音量を少しずつ 0 に近づける
      osc.stop(at + 0.3); // 十分に小さくなってから止める
    },
  };
}

オシレーターの音色は、MDN の OscillatorNode の説明にあるとおり、標準で用意されている sine、square、sawtooth、triangle から選べます。ここでは三角波(triangle)にしました。

startNote は、押すたびに新しい OscillatorNode を作ります。1つのオシレーターを使い回すことはできません。MDN の start() の説明には、すでに開始したノードの start() を呼ぶと、stop() で止めた後でも InvalidStateError になると書かれています。検証環境でも、止めたオシレーターの start() をもう一度呼ぶと、InvalidStateError: ... cannot call start more than once. が出ました。

止めるときは、音量を少しずつ下げる

音を止めるときは、いきなり osc.stop() を呼ばずに、setTargetAtTime(0, at, 0.03) で音量を 0 に近づけていきます。MDN の setTargetAtTime() の説明によると、値は指数関数的に目標へ近づき、第3引数の時間(ここでは 0.03 秒)がたつごとに、残りの差の約 63.2% ずつ縮まります。値はちょうど 0 にはならないので、0.3 秒後にオシレーターを止めています。

比べるために、音量を 0.3 のまま変えず、osc.stop() だけで止めるものも作りました。OfflineAudioContext で A4(440Hz)を鳴らし、三角波の山のところで止めて、できた波形を比べています。

上下2つの波形のグラフ。どちらも A4 の三角波で、点線のところで止めている。上の水色の波形は、点線から右へ、山の高さが少しずつ低くなっていく。下の灰色の波形は、点線のところで 0.3 近くから一気に 0 に落ち、その後は平らになっている。落ちたところがピンクの丸で囲まれている
OfflineAudioContext で作った波形の、止める 5ms 前から 25ms 後まで。上が記事の startNote、下が音量を変えずに止めたもの

鳴っている間は、隣り合うサンプルの差は最大で 0.0124 でした。音量を変えずに止めたものは、止めた瞬間に隣り合うサンプルの差が 0.2955 になりました。鳴っている間の最大の24倍ほどの段差です。記事の startNote では、止めた後の 1ms の差も最大 0.012 で、鳴っている間と変わりませんでした。振幅は、止めてから 0.1 秒後に 0.0105、0.2 秒後に 0.0004 まで下がりました。

MDN の GainNode の説明にも、音量をいきなり変えると耳ざわりな「クリック」が出るので、値を直接変えずに AudioParam のメソッドで少しずつ変えるように、と書かれています。

鳴らし始めにも、10ms かけて音量を上げる処理を入れました。ただ、この検証では、音量を上げる処理のないものでも、鳴らし始めの隣り合うサンプルの差は最大 0.0124 で、鳴っている間と同じでした。

鍵盤のコンポーネント

鍵盤は <button> を並べて作りました。鳴っている音は、ノート番号から stop を引ける Map にして useRef に入れます。React の公式リファレンスにある useRef の説明のとおり、ref を書き換えても再レンダーは起きません。画面の表示に使う「押されている鍵盤」だけを state にしています。

tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { noteFrequency, startNote, type Voice } from './note';

type Key = { midi: number; name: string; black: boolean; code: string };

// C4(ド)から C5 までの13鍵と、パソコンのキーの割り当て
const KEYS: Key[] = [
  { midi: 60, name: 'C4', black: false, code: 'KeyA' },
  { midi: 61, name: 'C#4', black: true, code: 'KeyW' },
  { midi: 62, name: 'D4', black: false, code: 'KeyS' },
  { midi: 63, name: 'D#4', black: true, code: 'KeyE' },
  { midi: 64, name: 'E4', black: false, code: 'KeyD' },
  { midi: 65, name: 'F4', black: false, code: 'KeyF' },
  { midi: 66, name: 'F#4', black: true, code: 'KeyT' },
  { midi: 67, name: 'G4', black: false, code: 'KeyG' },
  { midi: 68, name: 'G#4', black: true, code: 'KeyY' },
  { midi: 69, name: 'A4', black: false, code: 'KeyH' },
  { midi: 70, name: 'A#4', black: true, code: 'KeyU' },
  { midi: 71, name: 'B4', black: false, code: 'KeyJ' },
  { midi: 72, name: 'C5', black: false, code: 'KeyK' },
];

export function Piano() {
  const ctxRef = useRef<AudioContext | null>(null);
  const voices = useRef(new Map<number, Voice>());
  const [pressed, setPressed] = useState<ReadonlySet<number>>(new Set());

  const press = useCallback((midi: number) => {
    if (voices.current.has(midi)) return; // 鳴っている音は重ねない
    ctxRef.current ??= new AudioContext(); // 最初に押されたときに作る
    const ctx = ctxRef.current;
    if (ctx.state === 'suspended') void ctx.resume();
    voices.current.set(midi, startNote(ctx, noteFrequency(midi)));
    setPressed((s) => new Set(s).add(midi));
  }, []);

  const release = useCallback((midi: number) => {
    const voice = voices.current.get(midi);
    if (!voice) return;
    voice.stop();
    voices.current.delete(midi);
    setPressed((s) => {
      const next = new Set(s);
      next.delete(midi);
      return next;
    });
  }, []);

  // パソコンのキーボードでも弾けるようにする
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return; // 押しっぱなしの自動の繰り返しは無視する
      const key = KEYS.find((k) => k.code === e.code);
      if (key) press(key.midi);
    }
    function onKeyUp(e: KeyboardEvent) {
      const key = KEYS.find((k) => k.code === e.code);
      if (key) release(key.midi);
    }
    function onBlur() {
      // ウィンドウからフォーカスが外れたら、鳴っている音をすべて止める
      for (const midi of [...voices.current.keys()]) release(midi);
    }
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);
    window.addEventListener('blur', onBlur);
    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      window.removeEventListener('blur', onBlur);
    };
  }, [press, release]);

  return (
    <div className="piano" role="group" aria-label="鍵盤">
      {KEYS.map((k) => (
        <button
          key={k.midi}
          type="button"
          className={`key ${k.black ? 'black' : 'white'}${pressed.has(k.midi) ? ' pressed' : ''}`}
          aria-label={k.name}
          aria-pressed={pressed.has(k.midi)}
          onPointerDown={() => press(k.midi)}
          onPointerUp={() => release(k.midi)}
          onPointerLeave={() => release(k.midi)}
          onPointerCancel={() => release(k.midi)}
        >
          <span>{k.name}</span>
        </button>
      ))}
    </div>
  );
}

press と release は、ref と setPressed しか使っていないので、useCallback の依存配列を空にできます。React の公式リファレンスにある useCallback の説明のとおり、依存配列が変わらなければ前回と同じ関数が返るので、再レンダーのたびにキーボードのリスナーを登録し直すことはありません。

見た目は CSS で付けました。白鍵を横に並べ、黒鍵には左右に負の margin を付けて、隣の白鍵の上に半分ずつ重ねています。

css
.piano { display: flex; align-items: flex-start; height: 180px; user-select: none; touch-action: none; }
.key { position: relative; display: flex; align-items: flex-end; justify-content: center; padding: 0 0 8px; border: 1px solid #1f2a30; font: 12px sans-serif; cursor: pointer; }
.key.white { width: 48px; height: 100%; background: #fff; color: #1f2a30; border-radius: 0 0 6px 6px; }
.key.black { width: 30px; height: 60%; margin: 0 -15px; z-index: 1; background: #1f2a30; color: #fff; border-radius: 0 0 4px 4px; }
.key.pressed { background: #00c8e8; color: #1f2a30; }

Playwright で A4 の鍵盤をマウスで押すと、オシレーターが1つ開始し、出力からは 440Hz の音が取れました。離して 0.6 秒後には、オシレーターは終わっていて(ended が1回)、出力の大きさは 0 でした。パソコンの A・D・G のキーを同時に押すと、261.63Hz(C4)、329.63Hz(E4)、392Hz(G4)の3つのオシレーターが開始し、3つとも水色になりました。

AudioContext は、最初に押されたときに作る

AudioContext は、ページを表示したときではなく、最初に鍵盤を押したときに作っています。MDN の Web Audio API のベストプラクティスには、ユーザーの操作の中で作れば running になり、操作の外で作ると suspended になるので、操作の後で resume() を呼ぶ必要がある、と書かれています。

検証環境でも、記事のコードで鍵盤を押したときに作った AudioContext は running でした。比べるために、表示したときの Effect で作るものも試したところ、作った直後は suspended でした。この Chromium では、その後に鍵盤を押すと resume() を呼ばなくても running に変わりましたが、記事のコードでは MDN の説明に沿って、suspended なら resume() を呼んでいます。

パソコンのキーは e.code で割り当てる

キーの割り当てには、e.key ではなく e.code を使いました。MDN の KeyboardEvent.code の説明によると、code はキーボード上の物理的なキーを表し、キーボードの配列や Shift などの状態で値が変わりません。A から K の段のキーを、白鍵の並びと同じ位置関係で使えます。

押しっぱなしにすると、音が重なって鳴りやまない

キーを押しっぱなしにすると、keydown が何度も届きます。MDN の KeyboardEvent.repeat の説明のとおり、このとき2回目からは e.repeat が true です。Playwright の keyboard.down() を同じキーで続けて呼ぶと、repeat は false、true、true になりました。これを使って、H を押しっぱなしにし、自動の繰り返しを5回送ってから離しました。

記事のコードでは、オシレーターは1つだけ開始し、離した後は 0 個になりました。

e.repeat の確認と、voices.current.has(midi) の確認の両方を外したものでは、押している間にオシレーターが6つ開始しました。離した後も終わったのは1つだけで、5つが鳴り続けました。出力の大きさ(RMS)は、1つ鳴っているときの4倍以上でした。Map には最後に作った音の stop しか残らないので、keyup で止まるのはその1つだけです。鍵盤の水色は消えているのに、音は鳴りやみません。

voices.current.has(midi) の確認は、マウスとキーで同じ音を押した場合にも効きます。A4 をマウスで押したまま H のキーも押し、両方を離すと、記事のコードではオシレーターは1つで、離した後は 0 個でした。この確認だけを外したものでは、オシレーターが2つ開始し、両方離した後も1つが鳴り続けました。

鍵盤の外で離すと、音が止まらない

マウスのボタンを押したまま鍵盤の外へ動かし、そこで離すと、その鍵盤の pointerup では音を止められません。そこで、ポインターが要素の外に出たときに起きる pointerleave でも音を止めています。

C4 を押したまま、鍵盤の下の端から 80px 下まで動かして離すと、記事のコードでは、離して 0.6 秒後のオシレーターは 0 個でした。onPointerLeave を外したものでは、オシレーターが1つ鳴り続け、C4 も水色のまま残りました。

ウィンドウからフォーカスが外れたときも、window の blur イベントで鳴っている音をすべて止めています。MDN の Window の blur イベントの説明にあるとおり、このイベントはウィンドウがフォーカスを失ったときに起きます。検証では、D を押したまま blur イベントを送ると、0.6 秒後にはオシレーターが 0 個になり、水色も消えました。

​Posted by 86