クリックをときどき鳴らさずに、自分のテンポ感で拍を保てているかを確かめる「ギャップクリック」を作りました。クリックを鳴らさない(抜く)しかたは 2 つから選べます。決めた小節数を鳴らしたあと、決めた小節数を抜く「決まった間隔」と、1 拍ずつ、決めた割合でランダムに抜く「ランダム」です。抜いているあいだにテンポが走ったり(速くなったり)もたったり(遅くなったり)していれば、クリックが戻ってきたときのずれで気づけます。

- 「抜いている間も拍を表示する」をオフにすると、抜いているあいだは画面の拍の表示も止まる
- ギャップクリックがオンのあいだは、帯の GAP が光る
この記事では、作るときに決めたことを書きます。抜く判定をどこに置いて何を止めるか、小節をどう数えるか、拍の表示をどう止めるか、設定の型をどこに置くかの 4 つです。
抜く判定はシーケンサーに置く
Metronome.fm の音は、AudioWorklet の中のシーケンサー(拍の時刻と鳴らす音を決める部分)が、音声のスレッドで拍ごとに作っています。どの拍をいつ鳴らすか・小節をどう数えるかは、すべてここで決めています。抜くかどうかも、ここで決めることにしました。
抜いた拍では、拍の音を作る処理そのものを呼びません。クリックだけを消すと、ガイド音(8 分や 3 連の音)やカスタムビートのベース音、声のカウントが残り、手がかりになってしまうためです。
const beatDuration = 60 / bpm;
// 抜いた拍は、何も鳴らさない(拍・小節は数え続ける)
const isGap = isGapBeat({ settings: gap, bar: gapBarCount, random });
const pulses = isGap ? [] : pushBeatSounds(beatTime, beatDuration);
emit({
type: "beat",
time: beatTime,
beat: currentBeat,
bar: barCount,
interval: beatDuration,
pulses,
...(isGap ? { gap: true } : {}),
});pulses は、その拍の中でクリックが鳴る時刻の並びで、画面を光らせる処理と端末を振動させる処理がこれを見ています。抜いた拍では空にするので、光らせる処理と振動させる処理を直さなくても、どちらも止まります。
拍のイベント(beat)そのものは出し続けます。小節の数・タイマー・プログレッシブモード(テンポを段々上げる機能)・マルチトラックはこのイベントで進むので、抜いているあいだも鳴らしているときと同じように進みます。
小節は再生の始まりから通しで数える
シーケンサーには、もともと小節を数える変数が 2 つありました。画面に出す小節の数と、小節のタイマー(決めた小節数を鳴らしたら止まるタイマー)に使う barCount と、トラックごとに数え直す trackBarCount です。ギャップクリックにはどちらも使わず、3 つめの変数を用意しました。
// 再生を始めてから通しで数え、トラックの切り替え・小節タイマーの数え直しでは戻さない。
// 設定を変えたら -1 にして、次の小節の頭を 0 として数え直す
let gapBarCount = 0;barCount は、小節のタイマーの設定を変えると 0 に戻ります。trackBarCount は、マルチトラックでトラックが切り替わると 0 に戻ります。どちらを使っても、練習の途中で「鳴らす → 抜く」の並びが崩れます。
再生中に抜き方や小節数を変えたときは、gapBarCount を -1 にします。下の判定の関数は小節の数が負のときは抜かないので、いま鳴っている小節の残りは抜きません。次の小節の始まりを 0 として、「鳴らす小節」から数え直します。
判定は、引数(設定・小節の数・乱数)だけで決まる関数にしました。
export const isGapBeat = ({ settings, bar, random }) => {
if (!settings.isEnabled || bar < 0) return false;
if (settings.type === "interval") {
const cycle = settings.playBars + settings.muteBars;
return cycle > 0 && bar % cycle >= settings.playBars;
}
return bar > 0 && random() < settings.randomPercent / 100;
};「決まった間隔」は、鳴らす小節数と抜く小節数を足した周期の、後ろの小節を抜きます。小節は 0 から数えるので、3 小節鳴らして 1 小節抜くなら、0・1・2 小節目を鳴らし、3 小節目を抜きます。
「ランダム」を小節ごとでなく拍ごとにしたのは、小節ごとに抜くと「決まった間隔」に似てしまうためです。どこで抜けるかわからない練習にしました。どちらも最初の小節(bar が 0)は必ず鳴らし、テンポをつかんでから抜き始めます。
乱数は引数で受け取ります。テストでは決まった値を返す関数を渡して、どの拍が抜かれるかを見ています。
拍の表示も止められる
抜いているあいだに画面の拍の表示が進んでいると、目で拍を追えてしまいます。そこで、設定に「抜いている間も拍を表示する」のスイッチを用意しました(最初の図の 1)。オフにすると、抜いた小節では今の拍を光らせません。

- 今の拍(2 拍目)が光る

拍のメーターは、鳴っている拍の番号をストア(画面の状態を置いておく場所)から読んで光らせています。ストアに「この拍は抜いた拍か」も書くようにして、スイッチがオフで抜いた拍なら、今の拍を -1(どの拍でもない)として描きます。
拍の表示には、拍のメーターのほかに振り子とリングもあります。この 2 つはストアを通さず、画面を描き直すたび(毎フレーム)に、音声の時計から今の拍の位置を求めています。そこで、スイッチがオフのときは、抜いた拍の位置を求めると null を返すようにしました。null は止まっているときと同じ扱いなので、振り子はなめらかに真ん中へ戻ります。
if (
!current ||
(current.gap && !useUserSettingsStore.getState().gapClick.showBeats)
) {
return null;
}画面の下の帯に出る小節の数と経過時間は、抜いているあいだも進みます。
設定の型を React の外に置く
設定の型と範囲(小節数は 1〜16、割合は 10〜90%)は、設定のパネルとシーケンサーの両方が使います。パネルのファイルに置くと、シーケンサーがそのファイルを読み込むので、音声のスレッドで動かすファイル(AudioWorklet のバンドル)に、使わない React や翻訳のライブラリまで入ってしまいます。
そこで、型と範囲だけを React を含まないファイル(gap-click-options.ts)に分け、パネルとシーケンサーの両方からそこを読むようにしました。ギャップクリックを作る前と後で、AudioWorklet のバンドルは 26,238 バイトから 26,968 バイトになり、増えたのは判定の処理の分だけです。