開発ブログ

BLOG

拍に合わせて、Android のスマホが振動するようにした

メトロノームの拍に合わせて、Android のスマホを振動させる設定を作った。音とずれずに振動させる方法、iPhone と Android の Firefox では振動しない理由、手元の実機で振動しなかった原因(端末の触覚フィードバックの設定)を書く。

公開 2026年10月10日

拍に合わせて、Android のスマホが震えるようにしました(iPhone では震えません。理由は後で書きます)。大きな音の中で練習するときや、耳だけでは拍を取りにくいときに使えます。オーディオ設定のミュートで音全体を消しても振動は止まらないので、音を出さずに振動だけで拍を取れます。

ブラウザから振動させるのは navigator.vibrate() を呼ぶだけですが、音とずらさずに震わせる工夫が要りました。また、対応しているブラウザは思ったより少なく、実機では「振動を命じても震えない」ところでも一度つまずきました。この記事では、音とずらさない方法、震えるブラウザ、実機で震えなかった原因の 3 つを書きます。

設定の場所

画面の下の帯にある SOUND を押すと開くオーディオ設定に、「拍に合わせて振動」の行があります。オンにすると、アクセント(強く鳴らす拍)は 60ms、ノーマル(それ以外の拍)は 25ms 震えます。拍の設定でミュートにした拍(音を鳴らさない拍)は震えません。カスタムビートでは、クリック音が鳴る位置ごとに震えます。

スマホの幅で開いたオーディオ設定。番号 1 はミュートの下にある「拍に合わせて振動」の行で、ON になっている
オーディオ設定(Android の Chrome で開いたとき)
  1. 拍に合わせて振動。振動できる環境でだけ出る

Web の Vibration API で決められるのは、震わせる長さと、震える・止まるの並びだけです。強さ(振幅)は指定できません。アクセントとノーマルの違いは、長さで付けています。

音とずらさずに震わせる

このアプリの音は、AudioWorklet の音声スレッドで鳴らしています。拍の進行も音声スレッドで数え、「この拍が何時に鳴る」という知らせをメインスレッドに送ります。振り子の表示は、この知らせを使って音に合わせて動きます。

navigator.vibrate() は音声スレッドからは呼べず、メインスレッドで呼ぶしかありません。そこで、拍の知らせに、クリック音が鳴る時刻と、それがアクセントかどうか(pulses)を加えました。カスタムビートでは 1 拍の中で何度も鳴るので、1 拍の知らせに複数入ります。

// 拍ごとにクリックの音を求めるところで、振動に使う時刻と強さも集める
if (event.beat.state !== "muted") {
  const accent = event.beat.state === "accent";
  const same = pulses.findIndex((pulse) => pulse.time === soundTime);
  if (same === -1) {
    pulses.push({ time: soundTime, accent });
  } else if (accent) {
    // カスタムビートでアクセントとノーマルが重なったら、アクセントにする
    pulses[same] = { time: soundTime, accent };
  }
}

知らせは、音を鳴らす少し前にメインスレッドへ届きます。さらにスピーカーから実際に聞こえるのは、出力の遅れ(outputLatency)の分だけ後です。メインスレッドでは、いまスピーカーから聞こえている音が音声の時計で何秒の音かを求め、そこから拍が鳴る時刻までを待ってから震わせます。

const context = audioEngine.getAudioContext();
// 音は出力の遅れの分だけ後に聞こえるので、いま聞こえている時刻を求める
const now = context.currentTime - (context.outputLatency || 0);
for (const pulse of event.pulses) {
  const delayMs = (pulse.time - now) * 1000;
  // 30ms 以上過ぎた振動は、遅れて感じるので出さない
  if (delayMs < -30) continue;
  setTimeout(
    () => navigator.vibrate(pulse.accent ? 60 : 25),
    Math.max(0, delayMs),
  );
}

setTimeout はメインスレッドが混んでいると遅れるので、音ほど正確ではありません。

再生を止めたときは予約した振動を取り消し、navigator.vibrate(0) で振動も止めます。画面を離れているあいだはブラウザが振動させないので、予約もしません。

オーディオ設定のミュート(音全体を消す)は、音声スレッドで音を作らないだけで、拍の知らせは届き続けます。そのため、ミュート中も振動は止まりません。

震えるのは Android の Chrome などだけ

実装する前に対応状況を調べたところ、実際に震えるのは、Android の Chrome など Chromium で作られたブラウザだけでした。

環境
navigator.vibrate
震えるか
Android の Chrome など
ある
震える
iPhone・iPad(どのブラウザも)
ない
震えない
Android の Firefox
ある
震えない
PC の Chrome など
ある
震えない(震える部品がない)

iPhone の Safari は、Vibration API に一度も対応していません。iPhone のブラウザは Chrome も中身が WebKit なので、どのブラウザでも同じです。

iOS 17.4 からは、<input type="checkbox" switch> のスイッチを切り替えると触覚が返ります。これを JavaScript から押させて震わせる回避策が出回っていましたが、iOS 26.5 で塞がれたと報告されています。今は指で押したときにしか触覚が出ません。メトロノームはタイマーで拍を刻むので、この方法は使えませんでした。iPhone で震わせるには、ネイティブのアプリが要ります。

Android の Firefox は navigator.vibrate を持っていますが、呼んでも震えません。Firefox は、振動の許可を求める画面を出さないのに許可を確かめていて、断られた扱いになると報告されています。W3C が 2024 年に出した Vibration API の実装の報告(ブラウザごとの試験の結果)でも、試験に通ったのは Android の Chrome だけでした。

そこで、振動の行を出す条件を次のようにしました。

export const isVibrationSupported = ({
  hasVibrate, // "vibrate" in navigator
  isCoarsePointer, // matchMedia("(pointer: coarse)").matches
  userAgent,
}) => hasVibrate && isCoarsePointer && !/Firefox\//.test(userAgent);

PC の Chrome も navigator.vibrate を持っているので、タッチが主な端末(pointer: coarse)に絞りました。Firefox は User-Agent で外しています。User-Agent で見分けるのは好きではありませんが、オンにしても何も起きない設定を出すよりはよいと考えました。

実機で震えなかった原因は、端末の触覚フィードバック

手元の Android(Redmi Note 13 Pro 5G。OS は Xiaomi の HyperOS)で試したところ、オンにして再生しても震えませんでした。

Metronome.fm のコードの問題かどうかを確かめるため、ボタンを押すと navigator.vibrate(500) を呼ぶだけのページを作って試しました。戻り値は true でしたが、それでも震えません。true は「ブラウザが依頼を受け付けた」という意味で、モーターが動いたことまでは保証しないからです。

原因は、端末の設定で触覚フィードバックがオフになっていたことでした。オンにすると、Metronome.fm でも震えるようになりました。HyperOS では、触覚フィードバックがオフだとブラウザからの振動も止まるようです。

ブラウザからは、端末の設定で振動が止まっているかを知る方法がありません。マニュアルには「オンにしても震えないときは、端末の触覚フィードバックを確かめてください」と書きました。

テスト

E2E では、Playwright の hasTouch・isMobile でタッチ端末を真似て、navigator.vibrate を記録用の関数に差し替えています。120 BPM・4/4 で再生し、最初の 4 回が 60・25・25・25 の順に呼ばれることを確かめます。ミュート中も呼ばれること、PC の画面では振動の行が出ないことも見ています。

await page.addInitScript(() => {
  window.__vibrations = [];
  Object.defineProperty(Navigator.prototype, "vibrate", {
    configurable: true,
    value: (pattern) => {
      window.__vibrations.push(pattern);
      return true;
    },
  });
});

ただ、今回の実機のように、呼ばれても震えないことはテストではわかりません。最後は実機で確かめるしかありませんでした。

この記事を共有

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