開発ブログ

BLOG

拍に合わせて画面が光るようにした

メトロノームの拍に合わせて画面を光らせる設定を作った。光過敏性発作を避けるために 1 秒に 3 回を超えて光らせない工夫(WCAG 2.3.1)、色に頼らずにアクセントの拍とそれ以外の拍を見分けられる光り方、OS で動きを減らす設定にしているときの扱いを書く。

公開 2026年10月10日

拍に合わせて画面が光るようにしました。耳で拍を取りにくいときや、譜面台に置いたスマホ・ドラムの前のタブレットを離れて見るときに使えます。オーディオ設定のミュートで音全体を消しても光は止まらないので、音を出さずに光だけで拍を取れます。

光らせるだけなら、拍ごとに画面の色を変えれば済みます。ただ、画面全体を点滅させる機能は、光過敏性発作を起こす点滅にならないように作る必要がありました。この記事では、光る回数の抑え方と、アクセント(強く鳴らす拍)とノーマル(それ以外の拍)の見分け方を中心に書きます。

設定の場所

メニューの「表示設定」に「拍のフラッシュ」の節があります。「拍に合わせて画面を光らせる」をオンにすると、再生中に拍に合わせて画面が光ります。はじめはオフです。

表示設定のモーダルの一部。番号 1 は「拍の表示」の下にある「拍のフラッシュ」の節で、「拍に合わせて画面を光らせる」が ON になっている
表示設定
  1. 拍のフラッシュ。光り方と、光る回数を抑えていることが説明に書かれている

全画面モードと組み合わせると、拍の表示と BPM を大きく出したまま、画面の縁まで使って光らせられます。

色だけに頼らず、光る広さで見分ける

アクセントの拍は画面全体が光り、ノーマルの拍は画面の縁に太さ 20px の枠が出ます。拍の設定でミュートにした拍(音を鳴らさない拍)は光りません。

スマホの幅の全画面モードを 3 つ並べたもの。番号 1 はアクセントの拍で、画面全体がターコイズで塗られている。番号 2 はノーマルの拍で、画面の縁にターコイズの太い枠が出ている。番号 3 は拍と拍のあいだで、光っていない
全画面モードで光ったところ(スマホの幅)
  1. アクセントの拍。画面全体が光る
  2. ノーマルの拍。画面の縁だけが光る
  3. 拍と拍のあいだ。光っていない

アクセントとノーマルを色の違いだけで分けると、色を見分けにくい人には同じ光に見えます。そこで、光る面の広さと形を変えました。全面が光るか、縁だけが光るかなら、少し離れていても見分けられます。光の色は、ユーザーが選んだアクセントカラー(アプリの差し色)で、アクセントの拍にもノーマルの拍にも同じ色を使っています。

カスタムビートでは、拍の中で何度も音が鳴ります。音ごとに光らせると点滅が速くなりすぎるので、拍の頭で 1 回だけ光らせ、拍の中にアクセントの音が 1 つでもあればアクセントの光り方にします。

1 秒に 3 回を超えて光らせない

画面全体の点滅は、光過敏性発作を起こすことがあります。WCAG 2.3.1(Three Flashes or Below Threshold)は、1 秒のどの区間をとっても 3 回を超えて点滅しないこと、または点滅が閾値を下回ることを求めています。閾値の一つは光る面の広さです。画面上で視野の 10 度に収まる範囲を考え、その 25% を超える面が点滅すると、閾値を上回ります。画面全体を塗るアクセントの光はこの広さを明らかに超えるので、回数の条件を守ることにしました。

メトロノームは BPM を上げると拍の間隔が縮みます。180 BPM なら 1 秒に 3 拍、240 BPM なら 4 拍です。そこで、光と光のあいだを 0.35 秒以上空けるようにしました。

1/3 秒ちょうどにしなかったのは、区間の両端に光が入ると 4 回と数えられるからです。0 秒・1/3 秒・2/3 秒・1 秒に光ると、0 秒から 1 秒までの区間に 4 回入ります。少し広げて 0.35 秒にしました。BPM に直すと、だいたい 171 より速いときに間隔が足りなくなります。

間隔が足りないときは、まずノーマルの拍を光らせず、アクセントの拍だけにします。小節の頭が光れば拍の位置はわかるからです。アクセントの拍だけでも間隔が足りないときは、前の光から 0.35 秒たっていない光を飛ばします。1/4 拍子(1 小節に 1 拍しかなく、どの拍も小節の頭のアクセントになる)を 300 BPM で鳴らすときなどです。

export const MIN_FLASH_GAP_SECONDS = 0.35;

export const planBeatFlash = ({ time, interval, pulses, lastFlashTime }) => {
  // ミュートの拍(クリックの音が鳴らない拍)は光らせない
  if (pulses.length === 0) return null;
  const accent = pulses.some((pulse) => pulse.accent);
  // 拍が短いときは、アクセントの拍だけにする
  if (interval < MIN_FLASH_GAP_SECONDS && !accent) return null;
  // 前に光ってから間が空いていなければ、アクセントでも光らせない
  if (lastFlashTime !== null && time - lastFlashTime < MIN_FLASH_GAP_SECONDS) {
    return null;
  }
  return {
    time,
    level: accent ? 2 : 1,
    durationMs: Math.min(100, (interval * 1000) / 2),
  };
};

単体テストでは、300 BPM・1/4 拍子の拍を 15 拍ぶん渡し、光る時刻が 0.4 秒おきになること(拍は 0.2 秒おきなので、1 つおきに光る)と、どの 1 秒の区間にも 3 回までしか入らないことを見ています。

光っている長さは、ふだんは 100ms です(拍の間隔がそれより短いときは、間隔の半分)。このアプリの見た目の決まり(デザインシステム)では、拍の表示の塗りはフェードせずに瞬時に切り替えると決めています。拍のフラッシュもそれに合わせ、ふわっと消さずに点けて消すだけにしました。

動きを減らす設定のとき

端末で動きを減らす設定(iPhone の「視差効果を減らす」など。CSS の prefers-reduced-motion: reduce)をオンにしているときは、画面全体を塗りません。アクセントの拍は太さ 44px、ノーマルの拍は 20px の枠にして、枠の太さで見分けます。

動きを減らす設定のときの全画面モードを 2 つ並べたもの。番号 1 はアクセントの拍で、画面の縁に太い枠が出ている。番号 2 はノーマルの拍で、それより細い枠が出ている
動きを減らす設定のとき
  1. アクセントの拍。太い枠
  2. ノーマルの拍。細い枠

動きを減らす設定は、名前のとおりアニメーションのための設定で、点滅をどう扱うかまでは決まっていません。ただ、この設定をオンにする人には、画面の大きな変化がつらい人もいると考えました。拍が見えることは残しつつ、変わる面を小さくしました。

音とずらさずに光らせる

このアプリの音は、画面とは別のスレッドで動く AudioWorklet が作っていて、拍ごとに知らせ(拍の中でクリック音が鳴る時刻と、それがアクセントかどうか)を画面の側へ送ってきます。音はスピーカーから出るまでに少し遅れる(outputLatency)ので、いま耳に届いている時刻を求め、そこから拍が鳴る時刻までの分だけ待って光らせます。拍に合わせてスマホを振動させる機能でも、同じ求め方をしています。

const now = context.currentTime - (context.outputLatency || 0);
const flash = planBeatFlash({
  time: event.time,
  interval: event.interval,
  pulses: event.pulses,
  lastFlashTime: current.lastFlashTime,
});
if (flash) {
  const delayMs = (flash.time - now) * 1000;
  current.lastFlashTime = flash.time;
  setTimeout(() => setLevel(flash.level), delayMs);
  setTimeout(() => setLevel(0), delayMs + flash.durationMs);
}

光る面は画面いっぱいに重ねた要素で、押せないようにしてあります(pointer-events: none)。光っているあいだも、下のボタンを押せます。光はメトロノームの画面と全画面モードの上に出て、設定などのモーダルを開いているときはモーダルの下で光ります。

最初のアクセントがときどき抜けていた

E2E では、光る面の data-level(消灯は 0、ノーマルは 1、アクセントは 2)が変わるたびに、MutationObserver で記録しています。120 BPM・4/4 で再生し、最初の 4 回が 2・1・1・1(アクセント・ノーマル・ノーマル・ノーマル)の順に光ることを確かめます。

await page.addInitScript(() => {
  window.__flashLevels = [];
  const record = () => {
    const level = document
      .querySelector('[data-testid="beat-flash"]')
      ?.getAttribute("data-level");
    if (level && window.__flashLevels.at(-1) !== level) {
      window.__flashLevels.push(level);
    }
  };
  new MutationObserver(record).observe(document, {
    subtree: true,
    childList: true,
    attributes: true,
    attributeFilter: ["data-level"],
  });
});

最初の版では、止めたあとに光が残らないよう、光の強さを返す処理(React のフック)で、再生中でなければ 0(消灯)を返していました。

return isEnabled && isPlaying ? level : 0;

この E2E で、3 回に 1 回ほど、最初のアクセントの光が抜けました。再生を始めた直後は、再生中かどうかの状態が true になる前に 1 拍目の時刻が来ることがあります。その間に光っても、0 が返って光が見えていませんでした。

再生中かどうかで消灯するのをやめ、止めたときは予約した光を取り消して消灯する形に直しました。直したあとは、同じテストを 5 回くり返し、すべて通っています。

この記事を共有

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