1. アドカレ完走したい!
アドカレ完走してQiitanのぬいぐるみが欲しいです!
でも普段の業務をしながら25記事ってかなり大変じゃないですか?
少ない時間で集中する必要がありますし、何より1人なので気力がなくなってしまいます、、、
そこで今回は、ずんだもんに励まされながら、ポモドーロ・テクニックを用いて集中する「ずんだもんタイマーアプリ」を作製しました。
2. できたもの!
作業時間や休憩時間を設定できるだけで、見た目は普通のポモドーロタイマーです。
このアプリの特徴的な機能は以下になります。
特徴的な機能
- 作業時間、休憩時間、セット数を自身で選択できる
- 作業時間の開始と終了のタイミングでずんだもんからの励ましコメント
- タブでもタイマーの残り時間が確認できる
ずんだもんが目立ちますが、個人的には3番のタブで時間を確認できるのが推し機能です。
別タブで作業していても時間を確認できるため便利です。
3. 事前準備
3.1. VOICEVOXエンジンをダウンロード
下記URLからダウンロードします。
私はMacで実装しているため、
- OS:Mac
- 対応モード:CPU(Apple)
- パッケージ:インストーラー
を選択してダウンロードしました。
その後、インストーラーを起動してドラッグアンドドロップしてください。
3.2. Docker Desktopをインストール
下記URLからダウンロードします。
4. 実装
4.1. 全体コード
全体コードはこちらです
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ずんだもんタイマー</title>
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@400;700;900&family=Zen+Maru+Gothic:wght@400;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--zunda-green: #7FBA00;
--zunda-light: #B8E986;
--zunda-dark: #5A8700;
--cream: #FFF8DC;
--white: #FFFFFF;
--shadow: rgba(90, 135, 0, 0.2);
--text-dark: #2D5016;
}
body {
font-family: 'M PLUS Rounded 1c', sans-serif;
background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 50%, #A5D6A7 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(127, 186, 0, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(184, 233, 134, 0.15) 0%, transparent 50%);
pointer-events: none;
}
.container {
background: var(--cream);
border-radius: 30px;
padding: 40px;
box-shadow:
0 20px 60px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
max-width: 500px;
width: 100%;
position: relative;
animation: slideIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-family: 'Zen Maru Gothic', serif;
font-size: 2rem;
font-weight: 900;
color: var(--zunda-dark);
margin-bottom: 10px;
text-shadow: 2px 2px 0 rgba(184, 233, 134, 0.3);
letter-spacing: 2px;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.zundamon-face::before,
.zundamon-face::after {
content: '';
position: absolute;
width: 12px;
height: 18px;
background: var(--text-dark);
border-radius: 50%;
top: 28px;
}
.zundamon-face::before {
left: 20px;
}
.zundamon-face::after {
right: 20px;
}
.zundamon-mouth {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 15px;
border: 3px solid var(--text-dark);
border-top: none;
border-radius: 0 0 30px 30px;
}
.input-section {
display: grid;
gap: 20px;
margin-bottom: 30px;
}
.input-group {
background: white;
padding: 20px;
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.input-group:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
.input-group label {
display: block;
color: var(--text-dark);
font-weight: 700;
margin-bottom: 10px;
font-size: 0.95rem;
}
.input-group input {
width: 100%;
padding: 12px;
border: 2px solid var(--zunda-light);
border-radius: 10px;
font-family: 'M PLUS Rounded 1c', sans-serif;
font-size: 1rem;
transition: border-color 0.3s, transform 0.2s;
background: var(--cream);
}
.input-group input:focus {
outline: none;
border-color: var(--zunda-green);
transform: scale(1.02);
}
.timer-display {
background: linear-gradient(135deg, var(--zunda-green), var(--zunda-dark));
padding: 40px;
border-radius: 20px;
text-align: center;
margin-bottom: 30px;
box-shadow: 0 10px 30px var(--shadow);
position: relative;
overflow: hidden;
}
.timer-display::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
animation: shimmer 4s linear infinite;
}
@keyframes shimmer {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.timer-text {
font-size: 4rem;
font-weight: 900;
color: white;
text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
letter-spacing: 4px;
position: relative;
z-index: 1;
}
.timer-label {
color: var(--zunda-light);
font-size: 1.1rem;
margin-top: 10px;
font-weight: 700;
position: relative;
z-index: 1;
}
.progress-info {
color: white;
font-size: 0.9rem;
margin-top: 15px;
opacity: 0.9;
position: relative;
z-index: 1;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
}
.btn {
padding: 18px;
border: none;
border-radius: 15px;
font-family: 'M PLUS Rounded 1c', sans-serif;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:hover::before {
width: 300px;
height: 300px;
}
.btn:active {
transform: scale(0.95);
}
.btn-start {
background: linear-gradient(135deg, var(--zunda-green), var(--zunda-dark));
color: white;
grid-column: 1 / -1;
}
.btn-start:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px var(--shadow);
}
.btn-pause {
background: linear-gradient(135deg, #FFB74D, #FF9800);
color: white;
}
.btn-reset {
background: linear-gradient(135deg, #E57373, #D32F2F);
color: white;
}
.status-message {
background: white;
padding: 15px;
border-radius: 12px;
text-align: center;
color: var(--text-dark);
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-top: 15px;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.notification-permission {
background: linear-gradient(135deg, #FFF59D, #FFE082);
padding: 15px;
border-radius: 12px;
margin-bottom: 20px;
text-align: center;
}
.notification-permission button {
margin-top: 10px;
padding: 10px 20px;
background: var(--zunda-green);
color: white;
border: none;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.notification-permission button:hover {
background: var(--zunda-dark);
transform: scale(1.05);
}
@media (max-width: 600px) {
.container {
padding: 25px;
}
.title {
font-size: 1.6rem;
}
.timer-text {
font-size: 3rem;
}
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">ずんだもんタイマー</h1>
</div>
<div id="notificationRequest" class="notification-permission hidden">
<p>通知を許可すると、ずんだもんが応援してくれるのだ!</p>
<button onclick="requestNotificationPermission()">通知を許可する</button>
</div>
<div class="input-section">
<div class="input-group">
<label for="workTime">作業時間(分)</label>
<input type="number" id="workTime" value="25" min="1" max="180">
</div>
<div class="input-group">
<label for="breakTime">休憩時間(分)</label>
<input type="number" id="breakTime" value="5" min="1" max="60">
</div>
<div class="input-group">
<label for="sets">セット数</label>
<input type="number" id="sets" value="4" min="1" max="20">
</div>
</div>
<div class="timer-display">
<div class="timer-text" id="timerDisplay">25:00</div>
<div class="timer-label" id="timerLabel">準備完了なのだ!</div>
<div class="progress-info" id="progressInfo">セット: 0 / 4</div>
</div>
<div class="controls">
<button class="btn btn-start" id="startBtn" onclick="startTimer()">スタート</button>
<button class="btn btn-pause hidden" id="pauseBtn" onclick="pauseTimer()">一時停止</button>
<button class="btn btn-reset hidden" id="resetBtn" onclick="resetTimer()">リセット</button>
</div>
<div class="status-message" id="statusMessage">
ずんだもんと一緒に頑張るのだ!
</div>
</div>
<script>
// タイマー状態管理
let timerState = {
isRunning: false,
isPaused: false,
isWorkTime: true,
currentSet: 0,
totalSets: 4,
workMinutes: 25,
breakMinutes: 5,
remainingSeconds: 25 * 60,
intervalId: null
};
// Web Audio API用のコンテキスト(ずんだもん音声合成用)
let audioContext = null;
// 初期化
function init() {
// 通知権限をチェック
if ('Notification' in window && Notification.permission === 'default') {
document.getElementById('notificationRequest').classList.remove('hidden');
}
// ページを閉じようとしたときの警告(タイマー実行中)
window.addEventListener('beforeunload', (e) => {
if (timerState.isRunning && !timerState.isPaused) {
e.preventDefault();
e.returnValue = '';
}
});
}
// 通知権限のリクエスト
async function requestNotificationPermission() {
if ('Notification' in window) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
document.getElementById('notificationRequest').classList.add('hidden');
showStatus('通知が有効になったのだ!');
}
}
}
// ずんだもん音声を合成(Web Speech API使用)
async function speakZundamon(text) {
try {
// VOICEVOX のローカルエンジン(例: http://127.0.0.1:50021)
const baseUrl = "http://127.0.0.1:50021";
// audio_query を取得
const query = await fetch(
`${baseUrl}/audio_query?text=${encodeURIComponent(text)}&speaker=1`,
{ method: "POST" }
).then(res => res.json());
// 音声に変換
const wav = await fetch(
`${baseUrl}/synthesis?speaker=1`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(query)
}
).then(res => res.blob());
// 再生
const url = URL.createObjectURL(wav);
const audio = new Audio(url);
audio.play();
} catch (err) {
console.error("Zundamon TTS error:", err);
}
}
// ランダムメッセージ取得
function getRandomMessage(messageArray) {
return messageArray[Math.floor(Math.random() * messageArray.length)];
}
// 作業終了時のメッセージバリエーション
const workEndMessages = [
'お疲れ様なのだ!よく頑張ったのだ!休憩するのだ!',
'ナイスファイトなのだ!休憩して英気を養うのだ!',
'すごく集中してたのだ!ちょっと休むのだ!',
'よくできたのだ!少し休憩するのだ!',
'ここまでお疲れ様なのだ!リフレッシュするのだ!',
'素晴らしい集中力なのだ!休憩タイムなのだ!',
'いい感じなのだ!ひと休みするのだ!'
];
// 休憩終了時のメッセージバリエーション
const breakEndMessages = [
'休憩終了なのだ!次も頑張るのだ!',
'リフレッシュできたのだ?また頑張るのだ!',
'休憩終わりなのだ!集中していくのだ!',
'さあ、次のラウンドなのだ!ファイトなのだ!',
'準備はいいのだ?もうひと踏ん張りなのだ!',
'よし!気合い入れていくのだ!',
'休憩おしまいなのだ!また集中するのだ!'
];
// 効果音を生成(ビープ音)
function playBeep(frequency = 800, duration = 200) {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = frequency;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + duration / 1000);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + duration / 1000);
}
// 通知を表示
function showNotification(title, body) {
if ('Notification' in window && Notification.permission === 'granted') {
const notification = new Notification(title, {
body: body,
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="%237FBA00"/></svg>',
badge: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="%237FBA00"/></svg>'
});
notification.onclick = () => {
window.focus();
notification.close();
};
}
}
// ステータスメッセージを表示
function showStatus(message) {
const statusEl = document.getElementById('statusMessage');
statusEl.textContent = message;
statusEl.style.animation = 'none';
setTimeout(() => statusEl.style.animation = 'fadeIn 0.5s', 10);
}
// タイマー表示を更新
function updateDisplay() {
const minutes = Math.floor(timerState.remainingSeconds / 60);
const seconds = timerState.remainingSeconds % 60;
const display = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
document.getElementById('timerDisplay').textContent = display;
const label = timerState.isWorkTime ? '作業中なのだ!' : '休憩中なのだ!';
document.getElementById('timerLabel').textContent = label;
document.getElementById('progressInfo').textContent =
`セット: ${timerState.currentSet} / ${timerState.totalSets}`;
// タイトルにも表示(バックグラウンドで確認できるように)
document.title = `${display} - ${label}`;
}
// タイマー開始
function startTimer() {
if (!timerState.isRunning || timerState.isPaused) {
// 設定を読み込み
if (!timerState.isRunning) {
timerState.workMinutes = parseInt(document.getElementById('workTime').value);
timerState.breakMinutes = parseInt(document.getElementById('breakTime').value);
timerState.totalSets = parseInt(document.getElementById('sets').value);
timerState.remainingSeconds = timerState.workMinutes * 60;
timerState.currentSet = 1;
timerState.isWorkTime = true;
}
timerState.isRunning = true;
timerState.isPaused = false;
// UI更新
document.getElementById('startBtn').classList.add('hidden');
document.getElementById('pauseBtn').classList.remove('hidden');
document.getElementById('resetBtn').classList.remove('hidden');
// 入力フィールドを無効化
document.querySelectorAll('input').forEach(input => input.disabled = true);
// ずんだもんの応援
if (timerState.isWorkTime) {
speakZundamon('頑張って集中するのだ!応援してるのだ!');
showStatus('集中タイム開始!頑張るのだ!');
} else {
speakZundamon('お疲れ様なのだ!ゆっくり休むのだ!');
showStatus('休憩タイム!リラックスするのだ!');
}
playBeep(600, 150);
// タイマー開始
timerState.intervalId = setInterval(tick, 1000);
updateDisplay();
}
}
// タイマーの1秒ごとの処理
function tick() {
if (timerState.remainingSeconds > 0) {
timerState.remainingSeconds--;
updateDisplay();
} else {
// タイマー終了
handleTimerComplete();
}
}
// タイマー完了時の処理
function handleTimerComplete() {
playBeep(1000, 300);
if (timerState.isWorkTime) {
// 作業時間終了
// 全セット終わったかチェック(最終セットは休憩なし)
if (timerState.currentSet >= timerState.totalSets) {
// 全セット完了!(専用の音声のみ)
speakZundamon('全部完了なのだ!本当にお疲れ様なのだ!素晴らしいのだ!');
showNotification('全セット完了!', '本当にお疲れ様なのだ!🎉');
showStatus('全セット完了!素晴らしいのだ!');
resetTimer();
return;
}
// まだセットが残っている場合は休憩へ(ランダムメッセージ)
const randomWorkEndMsg = getRandomMessage(workEndMessages);
speakZundamon(randomWorkEndMsg);
showNotification('作業時間終了!', randomWorkEndMsg);
showStatus('作業完了!お疲れ様なのだ!');
// 次は休憩時間へ
timerState.isWorkTime = false;
timerState.remainingSeconds = timerState.breakMinutes * 60;
} else {
// 休憩時間終了 → セット数を増やす
timerState.currentSet++;
// 次の作業へ(ランダムメッセージ)
const randomBreakEndMsg = getRandomMessage(breakEndMessages);
speakZundamon(randomBreakEndMsg);
showNotification('休憩時間終了!', randomBreakEndMsg);
showStatus('休憩終了!次も頑張るのだ!');
timerState.isWorkTime = true;
timerState.remainingSeconds = timerState.workMinutes * 60;
}
updateDisplay();
}
// 一時停止
function pauseTimer() {
if (timerState.isRunning && !timerState.isPaused) {
clearInterval(timerState.intervalId);
timerState.isPaused = true;
document.getElementById('pauseBtn').textContent = '再開';
showStatus('⏸一時停止中なのだ');
document.title = '⏸一時停止 - ずんだもんタイマー';
} else if (timerState.isPaused) {
timerState.isPaused = false;
timerState.intervalId = setInterval(tick, 1000);
document.getElementById('pauseBtn').textContent = '一時停止';
showStatus('▶️ 再開したのだ!');
speakZundamon('再開するのだ!頑張るのだ!');
}
}
// リセット
function resetTimer() {
clearInterval(timerState.intervalId);
timerState.isRunning = false;
timerState.isPaused = false;
timerState.isWorkTime = true;
timerState.currentSet = 0;
timerState.remainingSeconds = parseInt(document.getElementById('workTime').value) * 60;
document.getElementById('startBtn').classList.remove('hidden');
document.getElementById('pauseBtn').classList.add('hidden');
document.getElementById('resetBtn').classList.add('hidden');
document.getElementById('pauseBtn').textContent = '一時停止';
// 入力フィールドを有効化
document.querySelectorAll('input').forEach(input => input.disabled = false);
updateDisplay();
showStatus('リセット完了!準備できたのだ!');
document.title = 'ずんだもんタイマー';
}
// Web Speech APIの音声読み込み(ページロード時)
if ('speechSynthesis' in window) {
speechSynthesis.getVoices();
window.speechSynthesis.onvoiceschanged = () => {
speechSynthesis.getVoices();
};
}
// 初期化実行
init();
updateDisplay();
</script>
</body>
</html>
4.2. 技術的なポイント
4.2.1. VOICEVOXローカルエンジンとの連携(音声合成API)
VOICEVOX をローカルで動かして TTS(音声合成)しています。
const query = await fetch(
`${baseUrl}/audio_query?text=${encodeURIComponent(text)}&speaker=1`,
{ method: "POST" }
).then(res => res.json());
4.2.2. ブラウザタイトルに残り時間を表示
個人的な推し機能です。
別タブにいても残り時間を確認できます。
document.title = `${display} - ${label}`;
4.2.3. 励ましメッセージのランダム化
飽きが来ないようにメッセージのバリエーションを用意し、そこからランダムでずんだもんから励まされるようにしました。
// 作業終了時のメッセージバリエーション
const workEndMessages = [
'お疲れ様なのだ!よく頑張ったのだ!休憩するのだ!',
'ナイスファイトなのだ!休憩して英気を養うのだ!',
'すごく集中してたのだ!ちょっと休むのだ!',
'よくできたのだ!少し休憩するのだ!',
'ここまでお疲れ様なのだ!リフレッシュするのだ!',
'素晴らしい集中力なのだ!休憩タイムなのだ!',
'いい感じなのだ!ひと休みするのだ!'
];
// 休憩終了時のメッセージバリエーション
const breakEndMessages = [
'休憩終了なのだ!次も頑張るのだ!',
'リフレッシュできたのだ?また頑張るのだ!',
'休憩終わりなのだ!集中していくのだ!',
'さあ、次のラウンドなのだ!ファイトなのだ!',
'準備はいいのだ?もうひと踏ん張りなのだ!',
'よし!気合い入れていくのだ!',
'休憩おしまいなのだ!また集中するのだ!'
];
5. 実行
ターミナルでVOICEVOXエンジンを起動する。
docker run -it -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu20.04-latest
ブラウザで確認できます。表示されればOKです。
http://127.0.0.1:50021/docs
ローカルサーバーを起動する。
cd (HTMLファイルがあるフォルダ)
python3 -m http.server 8000
ブラウザで開いてアプリが確認できれば大丈夫です。
http://localhost:8000/ファイル名.html
まとめ
ずんだもんの音声を用いたポモドーロタイマーのアプリを作りました。
このような音声が無料で提供されているのはすごいですね。
今回のアドカレは頑張って完走したいなと思っているので、このアプリも利用しながら記事を書いていきたいなと思います。

