React で集中用のタイマーアプリを作ったので、その実装メモです。
単純なポモドーロタイマーではなく、
- 複数の作業ステップを順番に進める
- ブラウザ上でノイズや環境音を鳴らす
- タイマーを Picture-in-Picture で常時表示する
という構成にしました。
この記事は、作ったものの紹介というより、実装時に考えたことの備忘録です。
作ったものの概要
作ったのは、ブラウザで動く集中用タイマーです。
普通のポモドーロタイマーは「25分作業して5分休憩」のような固定された流れが多いですが、実際の作業ではもう少し柔軟な時間管理をしたくなることがあります。
たとえば、資格試験の過去問演習なら、
60分 問題を解く
15分 答え合わせ
10分 間違いを整理する
5分 休憩する
のような流れになります。
このような用途では、単なる countdown ではなく、複数の step を順番に実行する sequence timer として扱った方が自然です。
技術スタック
主な構成は以下です。
React
TypeScript
Vite
Tailwind CSS
Web Audio API
Picture-in-Picture API
フロントエンドは React + Vite です。
音声まわりは Web Audio API を使い、タイマーの常時表示には Picture-in-Picture を使いました。
タイマーを「mode」ではなく「sequence」として扱う
普通のポモドーロタイマーであれば、状態はかなり単純です。
type TimerMode = "work" | "break";
type TimerState = {
mode: TimerMode;
remainingSeconds: number;
isRunning: boolean;
};
しかし、任意の作業ステップを組めるようにする場合、work / break の2状態だけでは足りません。
そこで、タイマーを step の配列として扱うことにしました。
type TimerStep = {
id: string;
title: string;
durationSeconds: number;
note?: string;
soundPresetId?: string;
};
type SequenceState = {
steps: TimerStep[];
currentStepIndex: number;
remainingSeconds: number;
isRunning: boolean;
};
現在の状態は、
現在の step index
残り秒数
実行中かどうか
で表現します。
こうしておくと、現在の step が終わったときに次の step へ進む処理も書きやすくなります。
function moveToNextStep(state: SequenceState): SequenceState {
const nextIndex = state.currentStepIndex + 1;
const nextStep = state.steps[nextIndex];
if (!nextStep) {
return {
...state,
isRunning: false,
remainingSeconds: 0,
};
}
return {
...state,
currentStepIndex: nextIndex,
remainingSeconds: nextStep.durationSeconds,
};
}
「作業」「休憩」のような固定されたモードではなく、配列上のどの step にいるかで状態を表現する、という設計です。
setInterval だけで残り時間を管理しない
タイマーを作るとき、最初は setInterval で1秒ずつ state を減らす実装を考えます。
useEffect(() => {
if (!isRunning) return;
const id = window.setInterval(() => {
setRemainingSeconds((prev) => Math.max(0, prev - 1));
}, 1000);
return () => window.clearInterval(id);
}, [isRunning]);
ただ、この方式は長時間動かすとズレが気になります。
ブラウザのタブが非アクティブになったり、処理が重くなったりすると、setInterval は必ずしも正確に1秒ごとに実行されません。
そのため、残り時間は「前回の値から1秒引く」のではなく、終了予定時刻と現在時刻の差分から計算するようにしました。
const endsAt = Date.now() + durationSeconds * 1000;
function getRemainingSeconds(endsAt: number) {
return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000));
}
表示更新のために setInterval は使いますが、時間の正本としては使いません。
useEffect(() => {
if (!isRunning || !endsAt) return;
const id = window.setInterval(() => {
const remaining = getRemainingSeconds(endsAt);
setRemainingSeconds(remaining);
if (remaining <= 0) {
moveToNextStep();
}
}, 250);
return () => window.clearInterval(id);
}, [isRunning, endsAt]);
この方式だと、UI 更新のタイミングが多少遅れても、次に描画されたときには正しい残り時間に戻ります。
Web Audio API で簡単な音声ミキサーを作る
集中用のタイマーには、環境音もあると便利です。
今回は、以下のような音を扱えるようにしました。
White noise
Pink noise
Brown noise
Rain
Binaural tone
基本的な考え方は、各音源を GainNode に通し、それぞれの音量を UI のスライダーで制御するというものです。
構成としては、ざっくり以下のようになります。
AudioContext
├─ White noise source ─ GainNode ┐
├─ Pink noise source ─ GainNode ├─ destination
├─ Brown noise source ─ GainNode ┤
├─ Rain source ─ GainNode ┘
└─ Binaural oscillator
各トラックごとに GainNode を持たせます。
const audioContext = new AudioContext();
const gainNode = audioContext.createGain();
gainNode.gain.value = volume;
source.connect(gainNode);
gainNode.connect(audioContext.destination);
スライダーの変更時には、対応する GainNode の gain を更新します。
function setTrackVolume(gainNode: GainNode, volume: number) {
gainNode.gain.setTargetAtTime(
volume,
gainNode.context.currentTime,
0.01
);
}
単純に gain.value = volume としても動きますが、急に音量が変わると不自然に聞こえることがあります。
setTargetAtTime を使うと、少し滑らかに変化させることができます。
White noise を生成する
White noise は、ランダムな値を詰めた AudioBuffer をループ再生することで作れます。
function createWhiteNoiseBuffer(audioContext: AudioContext) {
const sampleRate = audioContext.sampleRate;
const length = sampleRate * 2;
const buffer = audioContext.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
作成した buffer を AudioBufferSourceNode に設定します。
const source = audioContext.createBufferSource();
source.buffer = createWhiteNoiseBuffer(audioContext);
source.loop = true;
source.connect(gainNode);
source.start();
これで、音声ファイルを用意しなくてもブラウザ内でノイズを生成できます。
Brown noise を生成する
Brown noise は、White noise よりも低域が強く、少し落ち着いた印象のノイズです。
簡易的には、前回の値を少し残しながらランダム値を足していくことで作れます。
function createBrownNoiseBuffer(audioContext: AudioContext) {
const sampleRate = audioContext.sampleRate;
const length = sampleRate * 2;
const buffer = audioContext.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < length; i++) {
const white = Math.random() * 2 - 1;
data[i] = (lastOut + 0.02 * white) / 1.02;
lastOut = data[i];
data[i] *= 3.5;
}
return buffer;
}
音響的に厳密な実装というより、集中用の環境音として使いやすい音を作るための簡易実装です。
Binaural tone を生成する
Binaural tone は、左右の耳に少し違う周波数の音を流すことで作れます。
たとえば左に 200Hz、右に 210Hz を流すと、差分として 10Hz のうなりを感じる、という考え方です。
Web Audio API では、左右のチャンネルに分けて oscillator を接続します。
function createBinauralTone(audioContext: AudioContext) {
const merger = audioContext.createChannelMerger(2);
const leftOsc = audioContext.createOscillator();
const rightOsc = audioContext.createOscillator();
leftOsc.frequency.value = 200;
rightOsc.frequency.value = 210;
const leftGain = audioContext.createGain();
const rightGain = audioContext.createGain();
leftOsc.connect(leftGain);
rightOsc.connect(rightGain);
leftGain.connect(merger, 0, 0);
rightGain.connect(merger, 0, 1);
merger.connect(audioContext.destination);
leftOsc.start();
rightOsc.start();
}
実際に使う場合は、音量をかなり小さくした方が自然でした。
また、これはヘッドホン前提の機能なので、UI 側でもその前提を示しておいた方がよいと思います。
autoplay policy に対応する
Web Audio API を使うときに注意が必要なのが、ブラウザの autoplay policy です。
多くのブラウザでは、ユーザー操作なしに音声を自動再生できません。
そのため、アプリ起動時にいきなり AudioContext を開始するのではなく、ユーザーが再生ボタンを押したタイミングで resume() します。
async function ensureAudioContext(audioContext: AudioContext) {
if (audioContext.state === "suspended") {
await audioContext.resume();
}
}
再生ボタンの処理では、タイマー開始と音声開始をまとめて扱います。
async function handleStart() {
await ensureAudioContext(audioContext);
startTimer();
startAudio();
}
音が鳴るアプリでは、この部分を雑にすると「押したのに鳴らない」体験になりやすいので注意が必要でした。
Picture-in-Picture でタイマーを常時表示する
Web アプリのタイマーは、別タブや別アプリを開くと見えなくなります。
そこで、タイマーを Picture-in-Picture で表示できるようにしました。
PiP API は基本的に video element 向けですが、canvas にタイマーを描画し、その canvas を captureStream() で video に流すことで、タイマー表示にも応用できます。
const canvas = document.createElement("canvas");
const video = document.createElement("video");
const stream = canvas.captureStream();
video.srcObject = stream;
await video.play();
await video.requestPictureInPicture();
タイマー表示は canvas に描画します。
function drawTimerToCanvas(
canvas: HTMLCanvasElement,
title: string,
remainingSeconds: number
) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "32px sans-serif";
ctx.fillText(title, 24, 48);
ctx.font = "64px sans-serif";
ctx.fillText(formatTime(remainingSeconds), 24, 128);
}
React の state が変わるたびに canvas を更新すれば、PiP 側にも現在のタイマー状態が反映されます。
useEffect(() => {
if (!canvasRef.current) return;
drawTimerToCanvas(
canvasRef.current,
currentStep.title,
remainingSeconds
);
}, [currentStep, remainingSeconds]);
これで、作業中に別の画面を開いていても、タイマーだけは小さく表示しておけます。
音声ファイルは初期バンドルに含めない
ノイズのようにブラウザで生成できる音は Web Audio API で生成できます。
一方で、長めの環境音のようなものはファイルとして扱う方が自然です。
ただし、音声ファイルを初期バンドルに含めると重くなります。
集中用のツールでは、開いた瞬間にすぐ使えることが重要なので、初期ロードはなるべく軽くしたいです。
そのため、音声ファイルは必要になったタイミングで読み込む設計にしました。
async function loadAudioBuffer(url: string, audioContext: AudioContext) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return audioContext.decodeAudioData(arrayBuffer);
}
合成できる音はブラウザ上で生成し、長めの音源は必要になったときだけ lazy load する、という分け方です。
実装してみて難しかったところ
単体の機能としては、それぞれそこまで複雑ではありません。
ただ、実際には次のような状態を同期させる必要があります。
- タイマーの進行
- 現在の step
- 音声ミキサーの状態
- PiP 側の表示
- タブが非アクティブになったときの挙動
とくに、タイマーと音声と表示を別々の機能として扱うと、状態の同期が崩れやすくなります。
実装上は、
タイマーの状態
音声の状態
PiP の描画状態
を分けつつ、step の切り替わりを起点に必要な更新を行うようにすると整理しやすいと感じました。
まとめ
React で集中用タイマーを作る中で、以下のような実装を試しました。
単純な countdown
→ sequence timer
setInterval で1秒ずつ減らす
→ 終了予定時刻との差分で計算
音声ファイルをただ再生する
→ Web Audio API でノイズを生成・ミックス
タブ内だけのタイマー表示
→ Picture-in-Picture で常時表示
タイマーアプリは一見単純ですが、実際に作ると時間、音、表示の同期が必要になり、意外と考えることが多かったです。
ブラウザだけでも、Web Audio API と Picture-in-Picture を組み合わせると、かなり実用的な集中環境を作れると感じました。
作ったものはこちらです。