中級者向け No.072

音声録音・再生アプリ

マイクから音声を録音してWAVに保存・再生できるレコーダー。波形をCanvas上に簡易表示する。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), pyaudio ⏱️ 制作時間: 30〜90分

1. アプリ概要

マイクから音声を録音してWAVに保存・再生できるレコーダー。波形をCanvas上に簡易表示する。

このアプリはmediaカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)・pyaudio、難易度は ★★★ です。

このアプリは「メディア処理」カテゴリです。画像・音声・動画を扱う領域は Python の強力なライブラリ群が活きる分野で、メディア処理の作法は他の用途にも応用が効きます。tkinter(標準ライブラリ)・pyaudio を活かして実装するこの構造は、他のアプリにも応用が効きます。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

音声録音・再生アプリ 実行画面(Windows)
実行画面(Windows)
音声録音・再生アプリ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「⏺ 録音開始」ボタン(_toggle_record())・「▶ 再生」ボタン(_play_selected())・「⏹ 停止」ボタン(_stop_play())・「💾 保存」ボタン(_save_selected())・「🗑 削除」ボタン(_delete_selected())・「📂 ファイルを開く」ボタン(_open_file())・「🔄 更新」ボタン(_refresh_input_devices())で操作
  • after(500, ...) による500ミリ秒間隔の _update_timer() 呼び出し(画面の自動更新)
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「音声録音・再生アプリ」・サイズ 760x560 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

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

pip install pyaudio

Windows は wheel が配布されているため、上のコマンドだけで導入できます。Linux(Ubuntu・Mint 系)は PyAudio のビルドに portaudio の開発ヘッダが必要で、これが無いと pip が失敗します。先に次を実行してから pip install pyaudio に進んでください。

sudo apt install portaudio19-dev python3-dev build-essential

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install pyaudio
app072.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import threading
import os
import wave
import time
from datetime import datetime

try:
    import pyaudio
    PYAUDIO_AVAILABLE = True
except ImportError:
    PYAUDIO_AVAILABLE = False

try:
    import struct
    STRUCT_AVAILABLE = True
except ImportError:
    STRUCT_AVAILABLE = False


class App072:
    """音声録音・再生アプリ"""

    SAMPLE_RATE = 44100
    CHANNELS    = 1
    FORMAT      = None   # pyaudio.paInt16 (設定時)
    CHUNK       = 1024

    def __init__(self, root):
        self.root = root
        self.root.title("音声録音・再生アプリ")
        self.root.geometry("760x560")
        self.root.configure(bg="#1e1e1e")
        self._recordings = []
        self._recording = False
        self._playing = False
        self._pa = None
        self._stream = None
        self._frames = []
        self._input_devices = []   # [(label, index), ...]
        self._build_ui()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

        if PYAUDIO_AVAILABLE:
            try:
                self._pa = pyaudio.PyAudio()
                self.FORMAT = pyaudio.paInt16
                self._refresh_input_devices()
            except Exception as e:
                self.status_var.set(f"PyAudioエラー: {e}")

    def _refresh_input_devices(self):
        """入力(録音)デバイス一覧を再取得してコンボボックスに反映"""
        self._input_devices = [("既定のデバイス", None)]
        if not self._pa:
            self.device_combo["values"] = [d[0] for d in self._input_devices]
            self.device_var.set(self._input_devices[0][0])
            return
        try:
            default_idx = None
            try:
                default_idx = self._pa.get_default_input_device_info().get("index")
            except Exception:
                pass
            for i in range(self._pa.get_device_count()):
                try:
                    info = self._pa.get_device_info_by_index(i)
                except Exception:
                    continue
                if int(info.get("maxInputChannels", 0)) <= 0:
                    continue
                name = info.get("name", f"Device {i}")
                mark = " ★" if default_idx is not None and i == default_idx else ""
                label = f"[{i}] {name}{mark}"
                self._input_devices.append((label, i))
        except Exception as e:
            self.status_var.set(f"デバイス列挙エラー: {e}")
        self.device_combo["values"] = [d[0] for d in self._input_devices]
        # 現在の選択を維持、なければデフォルト
        if self.device_var.get() not in self.device_combo["values"]:
            self.device_var.set(self._input_devices[0][0])

    def _get_selected_input_index(self):
        """現在選択されている入力デバイスの index (Noneで既定)"""
        label = self.device_var.get()
        for lab, idx in self._input_devices:
            if lab == label:
                return idx
        return None

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🎙 音声録音・再生アプリ",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        if not PYAUDIO_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ pyaudio が未インストールです (pip install pyaudio)",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # 録音コントロール
        ctrl = tk.Frame(self.root, bg="#2d2d2d", pady=8)
        ctrl.pack(fill=tk.X)

        self.rec_btn = tk.Button(
            ctrl, text="⏺ 録音開始", command=self._toggle_record,
            bg="#e53935", fg="white", relief=tk.FLAT,
            font=("Arial", 12, "bold"), padx=16, pady=6,
            activebackground="#c62828", bd=0)
        self.rec_btn.pack(side=tk.LEFT, padx=8)

        self.play_btn = tk.Button(
            ctrl, text="▶ 再生", command=self._play_selected,
            bg="#1565c0", fg="white", relief=tk.FLAT,
            font=("Arial", 12), padx=12, pady=6,
            activebackground="#0d47a1", bd=0)
        self.play_btn.pack(side=tk.LEFT, padx=4)

        tk.Button(ctrl, text="⏹ 停止", command=self._stop_play,
                  bg="#3c3c3c", fg="white", relief=tk.FLAT,
                  font=("Arial", 12), padx=12, pady=6,
                  activebackground="#505050", bd=0).pack(side=tk.LEFT, padx=4)

        tk.Button(ctrl, text="💾 保存", command=self._save_selected,
                  bg="#2e7d32", fg="white", relief=tk.FLAT,
                  font=("Arial", 12), padx=12, pady=6,
                  activebackground="#1b5e20", bd=0).pack(side=tk.LEFT, padx=4)

        tk.Button(ctrl, text="🗑 削除", command=self._delete_selected,
                  bg="#3c3c3c", fg="white", relief=tk.FLAT,
                  font=("Arial", 12), padx=12, pady=6,
                  activebackground="#505050", bd=0).pack(side=tk.LEFT, padx=4)

        ttk.Button(ctrl, text="📂 ファイルを開く",
                   command=self._open_file).pack(side=tk.LEFT, padx=8)

        # 録音設定
        cfg = tk.Frame(self.root, bg="#1e1e1e", pady=4)
        cfg.pack(fill=tk.X, padx=8)
        tk.Label(cfg, text="サンプルレート:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.rate_var = tk.StringVar(value="44100")
        ttk.Combobox(cfg, textvariable=self.rate_var,
                     values=["8000", "16000", "22050", "44100", "48000"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(cfg, text="チャンネル:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        self.ch_var = tk.StringVar(value="モノラル(1)")
        ttk.Combobox(cfg, textvariable=self.ch_var,
                     values=["モノラル(1)", "ステレオ(2)"],
                     state="readonly", width=12).pack(side=tk.LEFT)

        # 録音デバイス選択
        dev = tk.Frame(self.root, bg="#1e1e1e", pady=4)
        dev.pack(fill=tk.X, padx=8)
        tk.Label(dev, text="🎤 録音デバイス:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.device_var = tk.StringVar(value="既定のデバイス")
        self.device_combo = ttk.Combobox(dev, textvariable=self.device_var,
                                         values=["既定のデバイス"],
                                         state="readonly", width=48)
        self.device_combo.pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(dev, text="🔄 更新",
                   command=self._refresh_input_devices,
                   width=8).pack(side=tk.LEFT, padx=4)

        # 波形表示キャンバス
        self.wave_canvas = tk.Canvas(self.root, bg="#0d1117", height=80,
                                      highlightthickness=0)
        self.wave_canvas.pack(fill=tk.X, padx=8, pady=4)

        # 録音リスト
        list_f = tk.Frame(self.root, bg="#1e1e1e")
        list_f.pack(fill=tk.BOTH, expand=True, padx=8)
        tk.Label(list_f, text="録音一覧", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w")
        cols = ("name", "duration", "rate", "size")
        self.rec_tree = ttk.Treeview(list_f, columns=cols, show="headings",
                                      selectmode="browse", height=8)
        self.rec_tree.heading("name",     text="名前")
        self.rec_tree.heading("duration", text="長さ")
        self.rec_tree.heading("rate",     text="サンプルレート")
        self.rec_tree.heading("size",     text="サイズ")
        self.rec_tree.column("name",     width=200, anchor="w")
        self.rec_tree.column("duration", width=80,  anchor="center")
        self.rec_tree.column("rate",     width=100, anchor="center")
        self.rec_tree.column("size",     width=80,  anchor="e")
        rsb = ttk.Scrollbar(list_f, command=self.rec_tree.yview)
        self.rec_tree.configure(yscrollcommand=rsb.set)
        rsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.rec_tree.pack(fill=tk.BOTH, expand=True)

        # レベルメーター
        meter_f = tk.Frame(self.root, bg="#1e1e1e", pady=2)
        meter_f.pack(fill=tk.X, padx=8)
        tk.Label(meter_f, text="レベル:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 8)).pack(side=tk.LEFT)
        self.level_var = tk.DoubleVar(value=0)
        self.level_bar = ttk.Progressbar(meter_f, variable=self.level_var,
                                          maximum=32768, length=400)
        self.level_bar.pack(side=tk.LEFT, padx=4)

        self.timer_lbl = tk.Label(meter_f, text="00:00",
                                   bg="#1e1e1e", fg="#f48771",
                                   font=("Courier New", 12, "bold"))
        self.timer_lbl.pack(side=tk.LEFT, padx=8)

        self.status_var = tk.StringVar(value="録音開始ボタンを押してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _toggle_record(self):
        if self._recording:
            self._stop_record()
        else:
            self._start_record()

    def _start_record(self):
        if not PYAUDIO_AVAILABLE or not self._pa:
            messagebox.showerror("エラー", "PyAudio をインストールしてください:\npip install pyaudio")
            return
        self._frames = []
        self._recording = True
        rate = int(self.rate_var.get())
        channels = 1 if "1" in self.ch_var.get() else 2
        self.SAMPLE_RATE = rate
        self.CHANNELS = channels
        self.rec_btn.config(text="⏹ 録音停止", bg="#f57c00")
        self.status_var.set("録音中...")
        self._rec_start_time = time.time()
        threading.Thread(target=self._record_thread, daemon=True).start()
        self._update_timer()

    def _record_thread(self):
        try:
            device_index = self._get_selected_input_index()
            stream = self._pa.open(
                format=pyaudio.paInt16,
                channels=self.CHANNELS,
                rate=self.SAMPLE_RATE,
                input=True,
                input_device_index=device_index,
                frames_per_buffer=self.CHUNK)
            while self._recording:
                data = stream.read(self.CHUNK, exception_on_overflow=False)
                self._frames.append(data)
                # レベル計算
                level = max(abs(v) for v in struct.unpack(
                    f"{len(data)//2}h", data)) if data else 0
                self.root.after(0, self.level_var.set, level)
            stream.stop_stream()
            stream.close()
        except Exception as e:
            self.root.after(0, self.status_var.set, f"録音エラー: {e}")

    def _stop_record(self):
        self._recording = False
        self.rec_btn.config(text="⏺ 録音開始", bg="#e53935")
        self.level_var.set(0)
        if not self._frames:
            self.status_var.set("録音データなし")
            return
        duration = len(self._frames) * self.CHUNK / self.SAMPLE_RATE
        name = datetime.now().strftime("録音_%Y%m%d_%H%M%S")
        entry = {
            "name": name,
            "frames": list(self._frames),
            "rate": self.SAMPLE_RATE,
            "channels": self.CHANNELS,
            "duration": duration,
        }
        self._recordings.append(entry)
        dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
        size_str = f"{sum(len(f) for f in self._frames)//1024}KB"
        self.rec_tree.insert("", tk.END,
                              values=(name, dur_str,
                                      f"{self.SAMPLE_RATE}Hz", size_str))
        self.status_var.set(f"録音完了: {dur_str}")
        self._draw_waveform(self._frames)

    def _update_timer(self):
        if not self._recording:
            self.timer_lbl.config(text="00:00")
            return
        elapsed = time.time() - self._rec_start_time
        m = int(elapsed // 60)
        s = int(elapsed % 60)
        self.timer_lbl.config(text=f"{m:02d}:{s:02d}")
        self.root.after(500, self._update_timer)

    def _draw_waveform(self, frames):
        if not frames:
            return
        self.wave_canvas.delete("all")
        cw = max(1, self.wave_canvas.winfo_width())
        ch = 80
        # ダウンサンプル
        all_data = b"".join(frames)
        n = len(all_data) // 2
        step = max(1, n // cw)
        vals = struct.unpack(f"{n}h", all_data)
        samples = [vals[i] for i in range(0, n, step)][:cw]
        for i, v in enumerate(samples):
            y = ch//2 - int(v / 32768 * ch//2)
            self.wave_canvas.create_line(i, ch//2, i, y, fill="#4fc3f7")

    def _play_selected(self):
        if not PYAUDIO_AVAILABLE or not self._pa:
            return
        sel = self.rec_tree.selection()
        if not sel:
            messagebox.showinfo("情報", "再生する録音を選択してください")
            return
        idx = self.rec_tree.index(sel[0])
        if idx >= len(self._recordings):
            return
        entry = self._recordings[idx]
        self._playing = True
        threading.Thread(target=self._play_thread, args=(entry,),
                          daemon=True).start()
        self.status_var.set(f"再生中: {entry['name']}")

    def _play_thread(self, entry):
        try:
            stream = self._pa.open(
                format=pyaudio.paInt16,
                channels=entry["channels"],
                rate=entry["rate"],
                output=True)
            for frame in entry["frames"]:
                if not self._playing:
                    break
                stream.write(frame)
            stream.stop_stream()
            stream.close()
            self.root.after(0, self.status_var.set, "再生完了")
        except Exception as e:
            self.root.after(0, self.status_var.set, f"再生エラー: {e}")
        self._playing = False

    def _stop_play(self):
        self._playing = False
        self.status_var.set("停止")

    def _save_selected(self):
        sel = self.rec_tree.selection()
        if not sel:
            return
        idx = self.rec_tree.index(sel[0])
        if idx >= len(self._recordings):
            return
        entry = self._recordings[idx]
        path = filedialog.asksaveasfilename(
            initialfile=f"{entry['name']}.wav",
            defaultextension=".wav",
            filetypes=[("WAV", "*.wav")])
        if not path:
            return
        try:
            with wave.open(path, "wb") as wf:
                wf.setnchannels(entry["channels"])
                wf.setsampwidth(2)
                wf.setframerate(entry["rate"])
                wf.writeframes(b"".join(entry["frames"]))
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _open_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("WAV", "*.wav"), ("すべて", "*.*")])
        if not path:
            return
        try:
            with wave.open(path, "rb") as wf:
                rate = wf.getframerate()
                channels = wf.getnchannels()
                n_frames = wf.getnframes()
                raw = wf.readframes(n_frames)
            duration = n_frames / rate
            chunk_size = self.CHUNK * 2 * channels
            frames = [raw[i:i+chunk_size] for i in range(0, len(raw), chunk_size)]
            name = os.path.splitext(os.path.basename(path))[0]
            entry = {"name": name, "frames": frames,
                     "rate": rate, "channels": channels, "duration": duration}
            self._recordings.append(entry)
            dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
            self.rec_tree.insert("", tk.END,
                                  values=(name, dur_str,
                                          f"{rate}Hz",
                                          f"{len(raw)//1024}KB"))
            self._draw_waveform(frames)
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _delete_selected(self):
        sel = self.rec_tree.selection()
        if not sel:
            return
        idx = self.rec_tree.index(sel[0])
        if idx < len(self._recordings):
            del self._recordings[idx]
        self.rec_tree.delete(sel[0])

    def _on_close(self):
        self._recording = False
        self._playing = False
        if self._pa:
            self._pa.terminate()
        self.root.destroy()


if __name__ == "__main__":
    root = tk.Tk()
    app = App072(root)
    root.mainloop()

5. コード解説

音声録音・再生アプリのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App072 クラスにアプリの全機能をまとめています(メソッド17個・全421行)。__init__ ではタイトル「音声録音・再生アプリ」とウィンドウサイズ 760x560 を設定します。あわせて状態を保持する変数(self._recordingself._playingself._pa)を初期化し、最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×6・tk.Label×9・tk.Button×5・ttk.Button×2・ttk.Combobox×3・tk.Canvasttk.Treeviewttk.Scrollbarttk.Progressbar で構成しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「⏺ 録音開始」ボタン(_toggle_record())・「▶ 再生」ボタン(_play_selected())・「⏹ 停止」ボタン(_stop_play())・「💾 保存」ボタン(_save_selected())・「🗑 削除」ボタン(_delete_selected())・「📂 ファイルを開く」ボタン(_open_file())・「🔄 更新」ボタン(_refresh_input_devices())を command= で接続しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

after() による定期処理

_update_timer() の末尾で after(500, ...) を呼び、500ミリ秒後に自分自身の再実行を予約しています。tkinter では while ループで待つと画面が固まるため、この「予約の連鎖」で定期処理を作るのが定石です。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:SAMPLE_RATE = 44100CHANNELS = 1。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionImportError を捕捉しています。あわせて messagebox.showerror()messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app072.py と保存します。外部ライブラリ pyaudio を使います。先に pip install pyaudio を実行してください。

  2. 2
    クラスの骨格を作る

    App072 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("音声録音・再生アプリ")geometry("760x560")configure(bg="#1e1e1e") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×6・tk.Label×9・tk.Button×5・ttk.Button×2・ttk.Combobox×3・tk.Canvasttk.Treeviewttk.Scrollbarttk.Progressbar を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_toggle_record()_play_selected()_stop_play()_save_selected()_delete_selected()_open_file()_refresh_input_devices())につなぎます。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _update_timer()(9行)・_refresh_input_devices()(30行)・_open_file()(26行)など を実装します。定期処理は after(500, ...) で自分自身を予約し続ける形にします。

  7. 7
    動作確認する

    python app072.py で起動し、各ボタンが反応すること・表示が自動で更新されることを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 更新間隔を変えてみる

after(500, ...) の 500 が更新間隔(ミリ秒)です。大きくすると更新頻度が下がって CPU 負荷が軽くなり、小さくすると更新が細かくなります(表示の刻み幅より細かくしても見た目は変わりません)。数値を変えて違いを確かめてみましょう。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 改造したら表示の更新が二重になった・速くなった

原因:_update_timer()after(500, ...) で自分自身を予約し続ける構造です。予約を止めずにもう一度開始経路を通ると、予約の連鎖が2本になり、更新が倍速になります。

解決法:開始処理の前に動作中かどうかをフラグで判定してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("760x560") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。マイクが無くても「📂 ファイルを開く」で手元の WAV を読み込めば、両方の課題を試せます。再生を確かめる課題1では pyaudio の導入が必要です。試すときは自分の声や自作の音源を使い、他の人の声を録るときは必ず相手の同意を得てください(職場や学校では録音を禁じる規程がある場合もあります)。

  1. 課題1:再生ボタンを続けて押しても二重に鳴らないようにする

    「▶ 再生」は押すたびに _play_thread() のスレッドを起こしますが、再生中かどうかを確かめていません。続けて押すと同じ音が重なり、先に終わった方が self._playingFalse に戻すため、もう一方も途中で切れます。再生中は新しい再生を受け付けないようにしてください。

    期待結果:再生中にもう一度「▶ 再生」を押しても音が重ならず、最後まで途切れずに鳴る。

    合格条件

    • 再生が終わったあとは、もう一度押して最初から再生できる
    • 「⏹ 停止」で止めた直後も、続けて再生し直せる
    • 一覧で何も選ばずに押したときは、これまでどおり選択をうながす案内が出る

    ヒント①(どこを触るか): 触るのは _play_selected() の1か所です。self._playing__init__()False になり、_stop_play()_play_thread() の末尾でも False に戻ります。判断の材料はそろっているので、スレッドを起こす前にこの値を確かめてください。
    ヒント②(使うもの): 案内は同じメソッド内にある messagebox.showinfo("情報", ...) がそのまま手本になります。ボタン自体を押せなくするなら、self.play_btn.config(state=tk.DISABLED)_play_thread() 末尾での復帰をセットにします。
    つまずきやすい点: _play_thread() は別スレッドで動くため、ボタンの見た目を戻すときは self.root.after(0, ...) を経由してください。_stop_play()self._playingFalse にするだけなので、送信中のかたまりを1つ書き終えるまでは音が続きます。

  2. 課題2:録音一覧に「チャンネル」の列を足す

    録音した音は entry["channels"] にモノラルかステレオかを持っていますが、一覧には出ていません。「名前」「長さ」「サンプルレート」「サイズ」の4列に、チャンネル数の列を足して見えるようにしてください。

    期待結果:ステレオの WAV を「📂 ファイルを開く」で読み込むと、その行のチャンネル列に 2 と表示される。

    合格条件

    • 録音して作った行にも、選んだチャンネル数がそのまま出る
    • 列を足しても、名前や長さの表示位置がずれない
    • 「🗑 削除」で行を消しても、残りの行の表示は崩れない

    ヒント①(どこを触るか): 触るのは5か所です。_build_ui()cols = ("name", "duration", "rate", "size")heading()column() に列を足し、行を作っている _stop_record()_open_file()rec_tree.insert() 2か所に値を渡します。
    ヒント②(使うもの): 値は entry["channels"] で取れます(録音時は self.CHANNELS・読み込み時は wf.getnchannels() が入ります)。values= に渡す並びは cols の順番と対応するので、足す位置をそろえてください。
    つまずきやすい点: cols に入れていない名前を heading()column() に渡すと _tkinter.TclError になります。values= の要素数が列数より少ない場合、足りない列は空欄のまま表示されます。

  3. 課題3:クラス定数を書き換えて動きを変える

    クラス定数(SAMPLE_RATECHANNELS)の中身を書き換えて、画面や動きがどう変わるかを確かめてみましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点にも注目してください。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。