SPLIT TIMER、動機は本当に単純で「パスタ茹でながらソース煮詰めて、オーブンの予熱も気にしなきゃいけないのに、手元のタイマーは1個しかない」という日常のちょっとしたイライラです。スマホのタイマーアプリを何度もセットし直すの、地味にストレスなんですよね。それだけの理由で作り始めました。
ただ「タイマーを並べるだけでしょ」と最初はなめてかかっていたら、ブラウザの仕様やOSの省電力機能と向き合う羽目になって、想像以上に骨のある開発になりました。今回はその裏側を記録しておきます。
複数のタイマーを「対等に」並べる
画面設計で最初に決めたのは、タイマー同士に主従関係を作らないことでした。最初は大きなメインタイマーの周りに小さいサブタイマーを添える案も検討したんですが、料理の工程にしろインターバルトレーニングにしろ、「どれが主でどれが従か」なんて使う人の状況次第でコロコロ変わります。なので、最大8個までのタイマーを完全に対等なカードとして並べて、それぞれに以下を独立して持たせる形に落ち着きました。
- 自由なラベル名(「パスタ」「ソース」「休憩」みたいに一目で分かるように)
- 時・分・秒の個別入力とプリセットボタン
- 個別のお知らせ方法(音のみ・通知のみ・両方・無音)
- 円形の進捗リング(残り時間を直感的に把握できるように)
バックグラウンドでのズレと、地味にハマった話
開発中に一番泣かされたのが、「別タブを開いたり画面を最小化すると、タイマーが遅れる」という現象でした。最初は普通に setInterval で1秒(1000ミリ秒)ごとに残り秒数を引き算するだけの、いわゆる素朴な実装をしていて、動作確認してるときは何の問題もなかったので、「これで完成だな」と当時の自分は結構満足していました。
// ✖ 失敗しやすい素朴なカウントダウン実装
timerId = setInterval(() => {
remainingSeconds -= 1;
updateDisplay(remainingSeconds);
}, 1000);
ところが実際に公開してしばらくして気づいたんですが、近年の主要ブラウザ(Chrome, Safari, Edgeなど)は省電力のため、非アクティブなタブのタイマー処理(setInterval / setTimeout)の実行間隔を1分〜数分に一度へと強制的に間引く仕様になっています。タブを裏に回して5分放置したのに、開いたら「まだ1分しか進んでない」という致命的なズレが発生する。自分のスマホで実際にこの現象を目にしたとき、「うわ、これ根本から直さないとダメなやつだ」と一瞬固まりました。
結局、タイマーの進行を「1秒ごとの引き算」でやるのをやめて、開始した絶対時刻と目標終了時刻だけを記録しておき、毎回そのときの現在時刻との差分から残り時間を逆算する方式に根本から作り直しました。「間引かれても平気な設計にする」という発想の転換に気づくまでが一番遠回りだった気がします。
// ◯ バックグラウンドでも絶対にズレない実装
const startTime = Date.now();
const targetEndTime = startTime + (totalSeconds * 1000);
timerId = setInterval(() => {
const now = Date.now();
const remainingMs = Math.max(0, targetEndTime - now);
const remainingSeconds = Math.ceil(remainingMs / 1000);
updateDisplay(remainingSeconds);
if (remainingMs <= 0) {
clearInterval(timerId);
triggerAlarm();
}
}, 250); // 描画更新は少し細かく回す
この仕組みにしてからは、たとえ間引きが発生しても、タブをアクティブに戻した瞬間に正しい残り時間へちゃんと復帰するようになりました。
直った瞬間、思わず「よし」と声が出たのを覚えています。
音は外部ファイルに頼らない
アラーム音でも1つ工夫を入れました。普通は <audio src="alarm.mp3"> みたいに外部の音声ファイルを読み込むことが多いんですが、これだと「ファイルの読み込みに失敗するかも」「通信が遅いと鳴るのが遅れるかも」という不安が常について回ります。時間を計るツールで肝心の音が遅れたら本末転倒なので、ブラウザ標準の Web Audio API を使って、正弦波をその場でリアルタイムに計算して鳴らす方式にしました。
// Web Audio APIによる軽量・即時発音ロジック
function playBeep(freq = 880, duration = 0.35) {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') {
ctx.resume();
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine'; // 耳に優しいサイン波
osc.frequency.setValueAtTime(freq, ctx.currentTime);
// ポップノイズを防ぐエンベロープ(音量のフェードイン・フェードアウト)
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.35, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration + 0.05);
} catch (e) {
console.error("音声の再生に失敗しました", e);
}
}
これのメリットは結構絶大で、音声ファイルのダウンロードが要らないので通信量ゼロ・完全オフラインでも即座に鳴りますし、ピッチや長さ、音量もミリ秒単位でコードから完全に制御できます。クリックした瞬間の遅延もほぼゼロ。地味な機能ですが、個人的には結構気に入っている実装です。
逆に、画面通知(Web Notifications API)については開発中「ブラウザの限界ってこういうことか」と痛感させられた領域でした。許可ボタンを押してもiframe埋め込み環境や一部のブラウザ設定では許可ダイアログ自体が抑制されることがありますし、許可が下りていてもOS側の集中モードや通知の重複間引きによって、バナーが表示されないケースが普通にあります。試行錯誤した結果、「画面通知はあくまで補助、確実性の高い音とタブタイトルの点滅(⏰ 時間です!)をメインの通知手段にする」という割り切りに落ち着きました。全部を画面通知に頼るのは早々に諦めた方が精神衛生上よかったです。
UIの見た目を整えるのなんて、正直このツールを作る作業の中では入り口に過ぎませんでした。「タブを閉じたらどうなるか」「バックグラウンドで時間がズレないか」「通信が途切れても音が鳴るか」、こういうブラウザという実行環境の制約とどれだけ向き合えるかが全てだったな、と作り終えて振り返るとしみじみ思います。おかげさまでSPLIT TIMERは、今日も自分の台所でパスタの茹で時間を律儀に計り続けています。
