Back |

Pythonで作る!BGM付きポモドーロタイマーデスクトップアプリ

今回はポモドーロタイマーをちょちょっとつくっていきます。

ポモドーロタイマーとは

そもそもポモドーロタイマーとは、25分間の集中作業と5分間の短い休憩を繰り返す「ポモドーロ・テクニック」に使用するタイマーです。

ポモドーロとはイタリア語でトマトを意味し、考案者がトマト型のキッチンタイマーを使っていたことにちなんでいます。

25分を「1ポモドーロ」とし、1セットとし、4ポモドーロを終えた後に20~30分の長めの休憩を取ることで脳をリフレッシュします。

25分を短く思われる方もいるかと思いますが、「25分に集中し1つのタスクを片付ける」意識で取り組むことで生産性を向上させることができます。

また、合間の5分休憩が脳の休憩にとても良く、集中力を結果的に長時間維持できるようになります。

私もポモドーロテクニックを取り入れる前と後では生産性や一日の満足度まで変化したので最初の頃は妙な高揚感を覚えたりもしました。

百均のタイマーでも試せますので、是非ご自身の仕事や作業に取り入れてみてください。

デスクトップアプリとしてポモドーロタイマーを作る

今回作成するポモドーロタイマーは、Pythonの標準ライブラリであるTkinterでGUIを、pygameで音楽再生を実装します。

Windowsのデスクトップアプリケーションとしてビルドするまでの手順を解説していきます。

使用する技術スタック

  • Python 3.x - プログラミング言語
  • Tkinter - GUI構築(標準ライブラリで追加インストール不要)
  • pygame - BGM・効果音の再生
  • PyInstaller - exe化ツール

必要なライブラリのインストール

まずpygameをインストールします。Tkinterは標準で含まれているので不要です。

pip install pygame

開発完了後、実行ファイル化するためにPyInstallerもインストールしておきます。

pip install pyinstaller

プロジェクトのフォルダ構成

以下のようなディレクトリ構造でプロジェクトを作成します。

pomodoro/
├── pomodoro_timer.py       # メインプログラム
├── volume.txt              # 音量設定ファイル(0.0〜1.0)
├── image/
│   └── tomato.ico          # アプリケーションアイコン
├── sound/                  # 作業中のBGM
│   ├── 1.ogg
│   ├── 2.ogg               # 25分間BGMをかける場合ファイルは軽いほうが良いため.oggを採用
│   ├── 3.ogg               # 配布もしているが、自信の好きな音楽を25分に調整して使用できる
│   └── 4.ogg
└── soundeffect/            # 通知音
    ├── work.mp3            # 作業終了時の音
    └── break.mp3           # 休憩終了時の音

主要な機能実装

1. タイマーのコアロジック

ポモドーロタイマーの基本的な動作は以下の通りです:

  • 作業時間:25分
  • 短い休憩:5分
  • 長い休憩:20分(4回の作業セット後)

これを実装するため、セッションカウンター(reps)を使用し、偶数・奇数で作業と休憩を切り替えます。

# セッション管理
reps = 0  # セッションカウンター

def start_timer():
    global reps
    reps += 1

    work_sec = WORK_MIN * 60
    short_break_sec = SHORT_BREAK_MIN * 60
    long_break_sec = LONG_BREAK_MIN * 60

    if reps % 8 == 0:
        # 4回の作業後 → 長い休憩
        count_down(long_break_sec)
        title_label.config(text="Break", fg=RED)
    elif reps % 2 == 0:
        # 偶数回 → 短い休憩
        count_down(short_break_sec)
        title_label.config(text="Break", fg=PINK)
    else:
        # 奇数回 → 作業時間
        count_down(work_sec)
        title_label.config(text="Work", fg=GREEN)

2. カウントダウン機能

Tkinterのafter()メソッドを使って1秒ごとにカウントダウンします。

def count_down(count):
    count_min = math.floor(count / 60)
    count_sec = count % 60

    # 秒数を2桁表示にフォーマット
    if count_sec < 10:
        count_sec = f"0{count_sec}"

    canvas.itemconfig(timer_text, text=f"{count_min}:{count_sec}")

    if count > 0:
        global timer
        timer = window.after(1000, count_down, count - 1)
    else:
        # カウント終了時の処理
        start_timer()
        play_notification_sound()

3. BGM再生システム

pygameを使用して作業中にランダムなBGMを再生します。メモリ効率のため、音楽ファイルはストリーミング再生を採用しています。

import pygame
import random

# pygame初期化
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=1024)

def play_random_bgm():
    # ランダムにBGMを選択
    bgm_file = random.choice(work_bgm_files)

    # 音量を読み込み
    volume = float(open("volume.txt", "r").read())

    # BGMを再生(無限ループ)
    pygame.mixer.music.load(bgm_file)
    pygame.mixer.music.set_volume(volume)
    pygame.mixer.music.play(loops=-1)

def stop_bgm():
    pygame.mixer.music.pause()

def resume_bgm():
    pygame.mixer.music.unpause()

4. 一時停止・再開機能

残り時間を保存することで、タイマーを一時停止・再開できるようにします。

remaining_count = None  # 残り秒数を保持
is_running = False      # タイマー実行中フラグ

def stop_timer():
    global is_running, remaining_count, timer

    if timer:
        window.after_cancel(timer)
        is_running = False
        stop_bgm()

def resume_timer():
    global is_running

    if remaining_count is not None:
        is_running = True
        count_down(remaining_count)
        resume_bgm()

全コード:

import tkinter as tk
import pygame
import random
import os
from pathlib import Path

# -----------------------
# Pomodoro — 軽量化版
# 変更点(要約)
# - 長尺BGMは pygame.mixer.music を使ってストリーミング再生(メモリ節約)
# - 事前に長尺音声をメモリに読み込まない(ファイルパスのみ保持)
# - 効果音は遅延ロード(初回再生時にSoundを生成)
# - mixer の buffer を小さめに設定してメモリ使用量を抑える
# - 既存機能(Start/Stop(=pause)/Reset、volume.txt、ランダムBGM、ループ等)は維持
# -----------------------

PINK = "#e2979c"
RED = "#e7305b"
GREEN = "#9bdeac"
YELLOW = "#f7f5dd"
FONT_NAME = "Courier"
WORK_MIN = 25
SHORT_BREAK_MIN = 5
LONG_BREAK_MIN = 20

reps = 0
timer = None
remaining_count = None  # 残り秒数。Noneなら新しいセッションとして扱う
is_running = False      # True のときタイマーが動作中

# BGM (work_sounds) 関連 — ファイルパスのみ保持して streaming 再生
VOLUME_FILENAME = "./volume.txt"
bgm_index = None                # 現在再生中の work_sounds のインデックス
bgm_paused = False              # 一時停止中か
current_session_type = None     # 'work' または 'break' または None

# 効果音は遅延ロード(メモリ節約)
work_sound_path = Path("./soundeffect/work.mp3")
break_sound_path = Path("./soundeffect/break.mp3")
_work_sound = None
_break_sound = None

# BGM ファイルリスト
# sound フォルダ内の.oggファイルを自動検出
sound_dir = Path("./sound")
work_bgm_files = []
if sound_dir.exists():
    work_bgm_files.extend(sound_dir.glob("*.ogg"))
    work_bgm_files.sort()  # ファイル名順にソート

# --- 初期化 ---
# buffer を小さめにしてメモリを抑える(デフォルトより小さめだが音切れが出る場合は 1024 〜 2048 に戻す)
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=1024)
# BGM は pygame.mixer.music で扱うのでチャンネル数は少なめに
pygame.mixer.set_num_channels(8)

# --- ユーティリティ: volume.txt の取り扱い ---
try:
    base_dir = Path(__file__).parent
except NameError:
    base_dir = Path(os.getcwd())

def get_volume_path():
    return base_dir / VOLUME_FILENAME

def load_volume():
    path = get_volume_path()
    default = 0.5
    try:
        if not path.exists():
            path.write_text(str(default))
            return default
        content = path.read_text().strip()
        vol = float(content)
        if vol < 0.0:
            vol = 0.0
        elif vol > 1.0:
            vol = 1.0
        return vol
    except Exception:
        return default

# --- 効果音の遅延ロード ---
def _ensure_work_sound():
    global _work_sound
    if _work_sound is None:
        try:
            _work_sound = pygame.mixer.Sound(str(work_sound_path))
            _work_sound.set_volume(1.0)
        except Exception as e:
            print(f"Failed to load work sound: {e}")

def _ensure_break_sound():
    global _break_sound
    if _break_sound is None:
        try:
            _break_sound = pygame.mixer.Sound(str(break_sound_path))
            _break_sound.set_volume(1.0)
        except Exception as e:
            print(f"Failed to load break sound: {e}")

# --- BGM: streaming via pygame.mixer.music ---
def start_bgm_new():
    """ランダムに BGM を選んでストリーミング再生(ループ)。"""
    global bgm_index, bgm_paused
    if not work_bgm_files:
        return
    bgm_index = random.randrange(len(work_bgm_files))
    vol = load_volume()
    try:
        pygame.mixer.music.load(str(work_bgm_files[bgm_index]))
        pygame.mixer.music.set_volume(vol)
        # -1 で無限ループ
        pygame.mixer.music.play(loops=-1)
        bgm_paused = False
    except Exception as e:
        print(f"Failed to play BGM (streaming): {e}")
        bgm_index = None
        bgm_paused = False

def pause_bgm():
    global bgm_paused
    try:
        # music.get_busy() は再生中かどうか
        if pygame.mixer.music.get_busy():
            pygame.mixer.music.pause()
            bgm_paused = True
    except Exception as e:
        print(f"Failed to pause BGM: {e}")
        bgm_paused = False

def unpause_bgm():
    global bgm_paused
    try:
        if bgm_paused:
            vol = load_volume()
            pygame.mixer.music.set_volume(vol)
            pygame.mixer.music.unpause()
            bgm_paused = False
    except Exception as e:
        print(f"Failed to unpause BGM: {e}")
        bgm_paused = False

def stop_bgm():
    global bgm_index, bgm_paused
    try:
        # stop() は再生停止
        pygame.mixer.music.stop()
        bgm_index = None
        bgm_paused = False
    except Exception as e:
        print(f"Failed to stop BGM: {e}")
        bgm_index = None
        bgm_paused = False

# --- 停止(一時停止) ---
def stop_timer():
    global timer, is_running
    if timer:
        window.after_cancel(timer)
        timer = None
    is_running = False
    start_button.config(state="normal")

    # BGM を一時停止(work セッション中のみ)
    if current_session_type == "work":
        pause_bgm()

# --- リセット ---
def reset_timer():
    global reps, timer, remaining_count, is_running, current_session_type
    if timer:
        window.after_cancel(timer)
        timer = None
    reps = 0
    remaining_count = None
    is_running = False
    current_session_type = None
    canvas.itemconfig(timer_text, text="25:00")
    title_label.config(text="Timer", fg=GREEN)
    check_marks.config(text="")
    start_button.config(state="normal")
    # BGM 停止
    stop_bgm()

# --- 開始(新規 or 再開) ---
def start_timer():
    global reps, is_running, remaining_count, current_session_type
    if is_running:
        return  # 既に動作中

    # 再開(残り秒数がある) -> reps を増やさずにそのままカウントダウン
    if remaining_count is not None:
        is_running = True
        start_button.config(state="disabled")
        # 再開時、以前停止したのが work セッションなら BGM を unpause(ただし再生済みで pause されている場合)
        if current_session_type == "work":
            if bgm_paused:
                unpause_bgm()
            else:
                # まれに bgm が stop されている(例: reset 後に remaining_count が残る異常状態)→ 新規再生
                start_bgm_new()
        start_button.config(state="disabled")
        count_down(remaining_count)
        return

    # 新しいセッション開始
    start_button.config(state="disabled")
    reps += 1
    work_sec = int(WORK_MIN * 60)
    short_break_sec = int(SHORT_BREAK_MIN * 60)
    long_break_sec = int(LONG_BREAK_MIN * 60)

    if reps % 8 == 0:
        title_label.config(text="Break", fg=RED)
        current_session_type = "break"
        is_running = True
        # break セッションでは BGM を停止
        stop_bgm()
        count_down(long_break_sec)
    elif reps % 2 == 0:
        title_label.config(text="Break", fg=PINK)
        current_session_type = "break"
        is_running = True
        stop_bgm()
        count_down(short_break_sec)
    else:
        title_label.config(text="Work", fg=GREEN)
        current_session_type = "work"
        # Work セッションでは BGM を新規再生(ランダム選択), ループ再生
        start_bgm_new()
        is_running = True
        count_down(work_sec)

# --- カウントダウン ---
def count_down(count):
    global timer, remaining_count, is_running, current_session_type, _work_sound, _break_sound
    remaining_count = count

    count_min = count // 60
    count_sec = count % 60
    if count_sec < 10:
        count_sec = f"0{count_sec}"

    canvas.itemconfig(timer_text, text=f"{count_min}:{count_sec}")

    if count > 0:
        # 次の秒へ
        timer = window.after(1000, count_down, count - 1)
    else:
        # 終了処理
        remaining_count = None
        is_running = False
        timer = None

        # サウンド再生(reps はセッション開始時にインクリメント済み)
        if reps % 2 == 0:
            _ensure_break_sound()
            if _break_sound:
                _break_sound.play()
        else:
            _ensure_work_sound()
            if _work_sound:
                _work_sound.play()

        # Work 終了時は BGM を停止して次は Break 表示
        if current_session_type == "work":
            stop_bgm()

        # チェックマーク更新
        marks = ""
        work_sessions = reps // 2
        for _ in range(work_sessions):
            marks += "✔"
        check_marks.config(text=marks)

        # 次セッション表示(表示のみ)
        if reps % 8 == 0:
            title_label.config(text="Work", fg=GREEN)
            canvas.itemconfig(timer_text, text="25:00")
        elif reps % 2 == 0:
            title_label.config(text="Work", fg=GREEN)
            canvas.itemconfig(timer_text, text="25:00")
        else:
            if reps % 7 == 0:
                title_label.config(text="Break", fg=RED)
                canvas.itemconfig(timer_text, text="20:00")
            else:
                title_label.config(text="Break", fg=PINK)
                canvas.itemconfig(timer_text, text="05:00")

        start_button.config(state="normal")
        current_session_type = None

# --- UI セットアップ ---
window = tk.Tk()
window.title("Pomodoro")
window.config(padx=96, pady=32, bg=YELLOW)

# タイトル
title_label = tk.Label(text="Timer", fg=GREEN, bg=YELLOW, font=(FONT_NAME, 80, 'bold'), width=5, anchor="center")
title_label.grid(column=1, row=0)

# カウントダウン表示
canvas = tk.Canvas(width=256, height=128, bg=YELLOW, highlightthickness=0)
timer_text = canvas.create_text(128, 64, text="25:00", fill="black", font=(FONT_NAME, 64, "bold"))
canvas.grid(column=1, row=1)

# メモ欄(UI 上に表示されるだけで保存は行いません)
memo_text = tk.Text(window, width=30, height=5, font=(FONT_NAME, 16, "bold"))
memo_text.grid(column=1, row=3, pady=(8, 16))

# チェックマーク
check_marks = tk.Label(fg=GREEN, bg=YELLOW, font=(FONT_NAME, 12, 'bold'))
check_marks.grid(column=1, row=4)

# ボタン群(Stop を Reset の左側に小さく配置)
start_button = tk.Button(text="Start", font=(FONT_NAME, 32), highlightthickness=0, command=start_timer)
start_button.grid(column=0, row=5)

stop_button = tk.Button(text="Stop", font=(FONT_NAME, 16), highlightthickness=0, command=stop_timer)
stop_button.grid(column=1, row=5, sticky="e", padx=(0, 16))

reset_button = tk.Button(text="Reset", font=(FONT_NAME, 32), highlightthickness=0, command=reset_timer)
reset_button.grid(column=2, row=5)

# 起動時に volume.txt が無ければ作成
_ = load_volume()

window.mainloop()

実行ファイルの作成

開発が完了したら、PyInstallerでWindows用の実行ファイル(.exe)を作成します。

1. まずはテストしてみる

python pomodoro_timer.py

タイマーが動き、音がなればビルドに移りましょう。

2. ビルド実行

pyinstaller --onefile --noconsole --icon=image/tomato.ico pomodoro_timer.py

ビルドが成功すると、dist/フォルダにpomodoro_timer.exeが作成されます。distから出してルートディレクトリに配置します。

3. 配布用フォルダの準備

実行ファイル単体では音楽ファイルやアイコンが読み込めないため、以下のファイルも一緒に配布します:

BGM ダウンロード

アイコン ダウンロード

効果音・開始 ダウンロード

効果音・終了 ダウンロード

配置/
├── pomodoro_timer.exe
├── volume.txt
├── image/
│   └── tomato.ico
├── sound/
│   └── *.ogg
└── soundeffect/
    └── *.mp3

これで.exeをダブルクリックするだけで起動するポモドーロタイマーアプリの完成です!

BGMやアイコンの変更方法

作成したポモドーロタイマーは、簡単にカスタマイズできます。

BGMの変更・追加

作業中に流れるBGMを変更したい場合は、sound/フォルダ内のOGGファイルを差し替えるだけです。

手順:

  1. お好きな音楽ファイルを用意(軽量が望ましい)
  2. OGG形式に変換(online-convert.comなどで変換可能)
  3. sound/フォルダに配置(ファイル名は1.ogg, 2.ogg…)

通知音の変更

作業終了・休憩終了時に鳴る通知音も簡単に変更できます。

  1. お好きな効果音をMP3形式で用意
  2. soundeffect/work.mp3(作業終了音)を差し替え
  3. soundeffect/break.mp3(休憩終了音)を差し替え

短め(1〜3秒)の音が通知音として最適です。

アイコンの変更

アプリのアイコンを変更する場合

別のアイコンファイル(.ico形式)をimage/フォルダに配置します。

ICO形式への変換:

PNG/JPG画像をICO形式に変換するには、converticon.comなどのオンラインツールが便利です。

音量の調整

BGMの音量はvolume.txtファイルで管理されています。

0.5

この数値を0.0(無音)〜1.0(最大音量)の範囲で変更できます。例えば、音量を小さくしたい場合は0.3、大きくしたい場合は0.8などに変更します。

作業時間のカスタマイズ

標準的なポモドーロは25分ですが、自分に合った時間に変更することもできます。

pomodoro_timer.py内の以下の定数を編集:

WORK_MIN = 25          # 作業時間(分)
SHORT_BREAK_MIN = 5    # 短い休憩(分)
LONG_BREAK_MIN = 20    # 長い休憩(分)

例えば、50分作業 + 10分休憩にしたい場合:

WORK_MIN = 50
SHORT_BREAK_MIN = 10
LONG_BREAK_MIN = 30

UI色のカスタマイズ

タイマーの表示色も変更できます。

PINK = "#e2979c"      # 短い休憩の色
RED = "#e7305b"       # 長い休憩の色
GREEN = "#9bdeac"     # 作業時間の色
YELLOW = "#f7f5dd"    # 背景色

お好みのカラーコード(16進数)に変更することで、自分だけのオリジナルタイマーにできます。

pomodoro_timer.pyファイルの編集内容を反映させたい場合は毎回ビルドします。

pyinstaller --onefile --noconsole --icon=image/tomato.ico pomodoro_timer.py

まとめ

今回はPythonのTkinterとpygameを使って、BGM付きのポモドーロタイマーデスクトップアプリを作成しました。

このアプリの特徴:

  • ✅ 25分作業 + 5分休憩の自動管理
  • ✅ ランダムBGM再生機能
  • ✅ 一時停止・再開に対応
  • ✅ Windows実行ファイルとして配布可能
  • ✅ BGM・アイコン・色など簡単カスタマイズ

ポモドーロテクニックは、実際に使ってみると想像以上に集中力が向上し、作業効率が上がります。

自分だけのカスタマイズしたタイマーで、より生産性の高い作業環境を作ってみてください!

参考リンク: