「録音と再生」を作り込む:時刻の記録から再生まで
遊びをつくる執筆にClaudeを使用しています。

鍵盤を弾いた順番を覚えておいて、あとで同じように鳴らす。ゲームに録音機能を足すとき、多くの人が最初に思いつくのは「押された音を配列に入れておき、再生では順番に鳴らす」という作りです。ところが、この作りでは音の間隔が失われます。ゆっくり弾いた旋律も、駆け抜けた旋律も、同じ速さで再生されてしまいます。 この記事では、アソバースの公式作品「雨音のピアノ」を参考に、演奏の録音と再生を一から組み立てます。確認済みの仕様として、この作品は30秒・128音まで録音してその場で再生でき、録音は画面を開いている間だけ残ります。この条件を手がかりに、記録するもの、再生のしかた、上限の扱いを順に決めます。最小のHTMLを1枚書き、ブラウザで開くだけで動きます。
この記事の目次
1. 仕様から「記録するもの」を決める
雨音のピアノの内部コードは確認できないため、ここでは公開されている仕様から、設計として読み取れることだけを取り出します。
- 録音の上限が「30秒」と「128音」の二本立てになっています。時間と音数の両方に歯止めがあることから、記録は音ごとの出来事の並びとして持つ作りだと考えられます。
- 音色(フェルトとガラス)、タッチ(3段階)、余韻の切り替えがあります。再生で同じ響きを出すなら、音の高さだけでなく、これらの設定も記録の対象になりえます。この部分の実際の扱いは、作品を開いて確かめることになります。
- 録音は画面を開いている間だけ残ります。保存を前提にしない、軽い機能として設計されています。
ここから、自作の記録に入れる項目を決めます。
- t、内容:録音開始からの経過ミリ秒、理由:間隔を再現するため
- n、内容:音の高さ(MIDIノート番号。60が中央のド)、理由:どの音かを表すため
- vel、内容:強さ(0〜1)、理由:タッチの違いを残すため
時刻は「前の音からの間隔」ではなく「開始からの経過時間」で持つのが要点です。間隔で持つと、再生時に足し算が必要で、誤差も積み重なります。開始からの経過時間なら、各音が独立していて、途中の音を削っても他の音の位置が動きません。
2. 手順:記録の土台を作る
次のコードをファイル(たとえば piano.html)に貼って、ブラウザで開きます。外部ライブラリは使いません。キーはA W S E D F T G Y H Uの並びに合わせ、ドからシまでの12音を鳴らします。
<button id="rec">録音</button> <button id="play">再生</button> <span id="st"></span>
<script>
const ac = new AudioContext(), MAX_MS = 30000, MAX_N = 128;
const keys = {a:60,w:61,s:62,e:63,d:64,f:65,t:66,g:67,y:68,h:69,u:70,j:71};
let start = null, take = [], playing = false;
const st = document.getElementById('st');
function tone(n, vel, when) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.value = 440 * 2 ** ((n - 69) / 12);
g.gain.setValueAtTime(vel, when);
g.gain.exponentialRampToValueAtTime(0.001, when + 0.8);
o.connect(g).connect(ac.destination);
o.start(when); o.stop(when + 0.8);
}
function stopRec() { start = null; st.textContent = take.length + '音を記録'; }
function hit(n, vel) {
tone(n, vel, ac.currentTime);
if (start === null) return;
const t = performance.now() - start;
if (t > MAX_MS || take.length >= MAX_N) return stopRec();
take.push({t: Math.round(t), n, vel});
}
document.addEventListener('keydown', e => {
if (e.repeat || !(e.key in keys)) return;
hit(keys[e.key], 0.5);
});
rec.onclick = () => {
ac.resume(); take = []; start = performance.now(); st.textContent = '録音中';
};
play.onclick = () => {
if (start !== null) stopRec();
if (playing || !take.length) return;
ac.resume(); playing = true;
const base = ac.currentTime + 0.1;
take.forEach(ev => tone(ev.n, ev.vel, base + ev.t / 1000));
setTimeout(() => { playing = false; }, take[take.length - 1].t + 900);
};
</script>コードの要所を順に見ます。
- hit が演奏の入口です。 画面のボタンでもキーボードでも、音を鳴らす処理はここを通します。鳴らす処理と記録する処理を一か所にまとめておくと、入力手段を増やしても記録が抜けません。
- 時刻は performance.now() で取ります。 画面の時計ではなく、ブラウザが提供する高精度の経過時間です。録音ボタンを押した瞬間を0とし、各音の t を引き算で求めています。
- e.repeat を除外しています。 キーを押し続けると自動で連続入力されるため、除外しないと1回の打鍵が何十音にも記録されます。
- rec と play はボタンの id です。 ブラウザは id をそのまま変数として使える仕組みを持つため、rec.onclick と書けます。実際の作品では getElementById で取得するほうが安全です。
まずは A S D F を数回弾いて音が鳴ることを確かめてください。音が出ない場合は、いったんページ内をクリックしてからやり直します。ブラウザは利用者の操作があるまで音声を止めておく決まりがあり、コード内の ac.resume() がそれを解除します。
3. 手順:再生は「予約」で作る
再生の作り方には大きく二つあります。
- setTimeout の連鎖:音ごとに「あと何ミリ秒後に鳴らす」と登録する方法です。単純ですが、ブラウザの処理が混み合うとタイマーが遅れ、音の間隔が揺れます。
- 音声時計への予約:Web Audio API の ac.currentTime(音声専用の時計)を基準に、鳴らす時刻をあらかじめ渡しておく方法です。上のコードはこちらです。
上のコードで該当するのは tone(ev.n, ev.vel, base + ev.t / 1000) の行です。base は「いまから0.1秒後」で、再生開始の余裕を持たせています。各音は base に記録時の経過時間を足した時刻に鳴ります。再生ボタンを押した時点で全部の音を予約し終えるため、その後に画面が重くなっても、予約済みの音の間隔は音声側の時計で保たれます。
一方で、予約方式には弱点があります。予約した音は途中で取り消しにくい、という点です。停止ボタンを付けたいなら、各音を共通のゲイン(音量を調整する部品)に接続し、停止時にそのゲインを0に落とす形に作り変えます。まず再生が動くことを優先し、停止は次の段階で足すのが無理のない順序です。
また、playing フラグは二重再生の防止です。再生中に再生ボタンを何度も押すと、同じ演奏が重なって予約されます。フラグを外しておいた場合に何が聞こえるか、試してみてください。
4. 手順:上限の境界を自分で確かめる
雨音のピアノは30秒・128音で録音を区切っています。自作でも、上限を付けたら境界を必ず検証します。上限まわりは、間違いが起きやすい場所です。次の手順で確かめてください。
- 開発者ツールのコンソールを開き、MAX_N を 5 に書き換えてから録音します(定数なので、ファイル側を 5 に直して読み込み直します)。
- 6音以上を続けて弾いたあと、再生します。何音まで記録され、画面の表示がどうなるかを見ます。
- MAX_N を 128 に戻し、今度は MAX_MS を 3000 にして、3秒を過ぎてから弾きます。3秒を超えた後の音が記録されないことを確かめます。
- 録音中に何も弾かずに再生ボタンを押します。take が空のとき、何も起きずに終わるかを確認します。
ここで見つかる問題が二つあります。
- 時間切れの検知が「次の打鍵」まで遅れます。 上のコードは、音が弾かれたときにしか上限を判定しません。30秒を過ぎても、弾かない限り「録音中」の表示が残ります。作り込むなら、setTimeout(stopRec, MAX_MS) を録音開始時に仕掛けて自動で止めます。そのとき、前回のタイマーを clearTimeout で消さないと、2回目の録音が途中で止まるので注意してください。
- 上限を超えた音は、鳴るが記録されません。 演奏の手応えとしては自然ですが、利用者に伝わらない可能性があります。残り音数や残り秒数を画面に出すと、止まった理由が分かります。
確認する値は、仕様から導けるものに絞ります。たとえば128音ちょうどを弾いたとき、128番目が記録され、129番目が記録されないことです。「以上」と「より大きい」を取り違える誤りは、この境界で見つかります。
5. 作り込みの順番:音色・タッチ・余韻を足す
土台ができたら、雨音のピアノの仕様に倣って、次の順で機能を足していきます。1回に足すのは一つだけにして、そのつど動作を確かめます。
- タッチ(強さ):hit(keys[e.key], 0.5) の 0.5 を、3段階の値(たとえば0.3、0.55、0.8)から選ぶ形にします。記録の vel は、すでに入っているのでそのまま再生に反映されます。
- 音色:tone の中で、オシレーターの波形(o.type)を切り替えます。sine と triangle を切り替えるだけでも、音の印象が変わります。録音に含めるかは設計判断です。音色を録音時の設定で固定して再生するか、再生時に選び直せるようにするかで、記録の形が変わります。
- 余韻:減衰の長さ(コードの 0.8 の部分)を変数にして、短い・長いの二択で切り替えます。再生時の when + 0.8 も同じ変数を使うと、鳴らし終わる時刻がずれません。
- 消えること:録音を保存しない作りは、雨音のピアノの「画面を開いている間だけ残る」と同じ考え方です。ページを閉じて消えるのは、仕様として明記しておけば不具合ではなくなります。
もし保存を足したくなったときは、記録の大きさを見積もります。1音を {"t":29999,"n":60,"vel":0.5} のような文字列にすると約30文字で、128音でも4,000文字前後です。アソバースのクラウドセーブは作品ごとにJSONで128KiBまでと案内されているため、録音1本の大きさとしては余裕があります。ただし、保存の呼び出し方は現在の案内で確認してください。保存を足すと、古い形式のデータを読んだときの扱いも考える必要が出てきます。記録の形に version を1つ入れておくと、あとで形を変えても読み分けられます。
次の行動
まず上のコードを保存して開き、録音して再生するところまでを動かしてください。次に、MAX_N と MAX_MS を小さくして、境界の挙動を確かめます。そのうえで雨音のピアノを開き、30秒・128音の上限に達したときの表示や動きを観察して、自作の止め方と見比べてみてください。
参考資料
- MDN Web Docs「Web Audio API」
- MDN Web Docs「Performance.now()」

