拍に合わせて、Android のスマホが震えるようにしました(iPhone では震えません。理由は後で書きます)。大きな音の中で練習するときや、耳だけでは拍を取りにくいときに使えます。オーディオ設定のミュートで音全体を消しても振動は止まらないので、音を出さずに振動だけで拍を取れます。
ブラウザから振動させるのは navigator.vibrate() を呼ぶだけですが、音とずらさずに震わせる工夫が要りました。また、対応しているブラウザは思ったより少なく、実機では「振動を命じても震えない」ところでも一度つまずきました。この記事では、音とずらさない方法、震えるブラウザ、実機で震えなかった原因の 3 つを書きます。
設定の場所
画面の下の帯にある SOUND を押すと開くオーディオ設定に、「拍に合わせて振動」の行があります。オンにすると、アクセント(強く鳴らす拍)は 60ms、ノーマル(それ以外の拍)は 25ms 震えます。拍の設定でミュートにした拍(音を鳴らさない拍)は震えません。カスタムビートでは、クリック音が鳴る位置ごとに震えます。

- 拍に合わせて振動。振動できる環境でだけ出る
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;
},
});
});ただ、今回の実機のように、呼ばれても震えないことはテストではわかりません。最後は実機で確かめるしかありませんでした。