開発ブログ

BLOG

クリック音を抜いた小節でテンポ感を確かめる、ギャップクリックを作る

決めた小節数ごと、またはランダムにクリック音を鳴らさないギャップクリックを作った。音がないあいだも自分のテンポ感で拍を保てているかを確かめる練習に使う。音を消した拍で画面の光と振動もまとめて止める方法、小節を再生の始まりから数える理由、拍のメーターの止め方、設定の型を React の外に置いて音の処理(AudioWorklet)と共有する方法を書く。

公開 2026年10月13日

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

ギャップクリックのパネル。1 小節鳴らして 1 小節抜く設定。番号 1 は「抜いている間も拍を表示する」のスイッチ(オフ)、番号 2 は画面の下の帯の、アクセントカラーで光っている GAP のボタン
画面の下のコントロールの帯にある GAP から開いた、ギャップクリックの設定
  1. 「抜いている間も拍を表示する」をオフにすると、抜いているあいだは画面の拍の表示も止まる
  2. ギャップクリックがオンのあいだは、帯の 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)。オフにすると、抜いた小節では今の拍を光らせません。

拍のメーター。4 つの拍のうち、番号 1 の 2 拍目が今の拍として光っている
鳴らしている小節の拍のメーター
  1. 今の拍(2 拍目)が光る
拍のメーター。4 つの拍のどれも光っていない
抜いている小節の拍のメーター。どの拍も光らない

拍のメーターは、鳴っている拍の番号をストア(画面の状態を置いておく場所)から読んで光らせています。ストアに「この拍は抜いた拍か」も書くようにして、スイッチがオフで抜いた拍なら、今の拍を -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 バイトになり、増えたのは判定の処理の分だけです。

この記事を共有

X でポストはてなブックマーク