Reactで、押すと音が鳴る鍵盤を作る方法
押すと音が鳴る鍵盤は、音声ファイルを用意しなくても作れます。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、スマートフォンのブラウザは試していません。

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 になりました。
// 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)を鳴らし、三角波の山のところで止めて、できた波形を比べています。

鳴っている間は、隣り合うサンプルの差は最大で 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 にしています。
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 を付けて、隣の白鍵の上に半分ずつ重ねています。
.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 個になり、水色も消えました。





