【Gemini × 音声メモ】録音内容を自動要約するPythonアプリを作ってみた第2回

Gemini x 音声メモ Gemini
Gemini x 音声メモ

こちらの記事は前回の記事の続きになります。【Gemini × 音声メモ】録音内容を自動要約するPythonアプリを作ってみた第1回をご確認ください。

第4章:要件定義

以前に作成した、音声メモアプリの要件に新たな要件を追加してアプリを作成していきます。
過去の要件定義結果は、【後編】音声メモアプリ-要件定義からPythonアプリ作成までをご確認ください。

追加要件の検討

要件としてマストで追加するのは以下。

機能要件
– Geminiで要約できる機能を追加する。
– 要約を行う場合はボタン操作でできるようにする。

他にもは、せっかく要約を行うならば元の文章との比較もできるようにしておきたいと思います。
これはUI/UX上の要件として定義すればいいかなともいます。

非機能要件
・ユーザビリティ
 - 音声メモのテキストと要約後のテキストを並べて表示できるようにする。
 - 保存するときには元データと要約済みの選択ができる。
 - 音声のメモ時は漢字を含めて実施する。
・技術的制約
– モデルはGemini 1.5 Flashを使う。


こんな感じかな。

要件定義

前回の要件定義結果に追加をしてみました。●がついている部分が追加した要件になります。
いくつかの要件は動作デバッグする中で、削除しています。

音声認識メモアプリ

機能要件(= アプリが何をできるべきか)
ユーザーのニーズを実現する「機能」に関する要件です。
・音声認識機能
 音声をもとにメモを作成する。
 音声認識ファイルの一時保存と削除●

・プレビュー画面
 作成されたメモを画面上で確認できるようにする。

・録音の開始ボタン
 任意のタイミングで録音を開始できるようにする。

・録音の終了ボタン
 ユーザー操作で録音を明示的に停止できる。

・データの保存
 文字起こし結果をテキストファイルとして保存する機能。
 
・要約機能●
 メモの内容をGeminiで要約できる機能を追加する。

・要約結果の保存●
 要約した結果をテキストファイルとして保存する機能。

非機能要件(= どう実現するかの制約や品質)
・ユーザビリティ
 マイクをユーザーが選択できるようにする(操作性の向上)。
 マイク選択UIは設定画面に集約●
 直感的なUI設計(例:ボタン表示やメッセージの明確さ)も含めてよい。
 音声がない時に認識できないことをメモ範囲外に記載する。
 ネット接続エラー、API失敗、無音時の注意表示などをメモ範囲外に通知●
 ウィンドウ縮小時にも、操作ボタン・ステータス表示が常に表示されるようにする(スクロールや圧縮によって消えないこと)。
 音声メモのテキストと要約後のテキストを並べて表示できるようにする。●
 保存するときには元データと要約済みの選択ができる。●
 APIキーやモデル選択、音量感度などの設定をGUIで変更可能にする●
 APIキーやモデル、マイクの選択時には情報を保存できる●
 音声のメモ時は漢字を含めて実施する。●

・性能
 リアルタイムで文字起こしできる程度の処理速度を持つ。
 ↑削除、音声の途中で認識が切れるバグの修正のため。
 録音と変換に待ち時間が少ないこと。
 文字起こしや要約結果を編集できるテキストエリアにする。●
 過去の音声入力や要約結果を一覧表示・再読み込みできるようにする。●

・技術的制約(設計制約)
 言語はPythonを使用
 GUIはTkinterで作成する。
 モデルはGemini 1.5 Flashを使う。●
 

コード生成

上記の要件で生成したコードが以下の通りです。

import tkinter as tk
from tkinter import filedialog, scrolledtext, messagebox, ttk
import threading
import speech_recognition as sr
import os
import datetime
import tempfile
import wave
import pyaudio
import json
import google.generativeai as genai

CONFIG_FILE = "settings.json"

DEFAULT_SETTINGS = {
    "api_key": "YOUR_API_KEY_HERE",
    "model": "models/gemini-1.5-flash",
    "sensitivity": 1.0,
    "mic_index": 0
}

def load_settings():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return DEFAULT_SETTINGS.copy()

def save_settings(settings):
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(settings, f, ensure_ascii=False, indent=2)

SETTINGS = load_settings()

def configure_gemini():
    genai.configure(api_key=SETTINGS["api_key"])

configure_gemini()

def get_valid_microphones():
    p = pyaudio.PyAudio()
    valid_devices = []
    for i in range(p.get_device_count()):
        info = p.get_device_info_by_index(i)
        if info["maxInputChannels"] > 0:
            try:
                stream = p.open(format=pyaudio.paInt16,
                                channels=1,
                                rate=int(info.get("defaultSampleRate", 16000)),
                                input=True,
                                input_device_index=i,
                                frames_per_buffer=1024)
                stream.close()
                valid_devices.append((i, info["name"]))
            except Exception:
                continue
    p.terminate()
    return valid_devices

class VoiceMemoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("音声認識メモアプリ")
        self.root.geometry("1000x750")

        self.recognizer = sr.Recognizer()
        self.microphones = get_valid_microphones()
        self.audio_frames = []
        self.recording = False
        self.transcribed_text = ""
        self.summary_text = ""

        self.create_widgets()

    def create_widgets(self):
        self.root.rowconfigure(2, weight=1)
        self.root.columnconfigure(0, weight=1)

        config_frame = tk.Frame(self.root)
        config_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 0))

        tk.Label(config_frame, text="感度:").pack(side=tk.LEFT)
        self.sensitivity_scale = tk.Scale(config_frame, from_=0.5, to=3.0, resolution=0.1,
                                          orient=tk.HORIZONTAL, length=100)
        self.sensitivity_scale.set(SETTINGS["sensitivity"])
        self.sensitivity_scale.pack(side=tk.LEFT)

        tk.Button(config_frame, text="⚙ 設定変更", command=self.open_settings_window).pack(side=tk.LEFT, padx=10)

        control_frame = tk.Frame(self.root)
        control_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=10)

        self.record_button = tk.Button(control_frame, text="🔴 録音開始", command=self.toggle_recording)
        self.record_button.grid(row=0, column=0, padx=5)

        tk.Button(control_frame, text="📂 メモ保存", command=self.save_memo).grid(row=0, column=1, padx=5)
        tk.Button(control_frame, text="📂 ログを読み込み", command=self.load_log).grid(row=0, column=2, padx=5)
        tk.Button(control_frame, text="🧠 要約", command=self.summarize_text).grid(row=0, column=3, padx=5)
        tk.Button(control_frame, text="📝 要約保存", command=self.save_summary).grid(row=0, column=4, padx=5)

        display_frame = tk.Frame(self.root)
        display_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=10)
        display_frame.columnconfigure(0, weight=1)
        display_frame.columnconfigure(1, weight=1)

        left_frame = tk.Frame(display_frame)
        left_frame.grid(row=0, column=0, sticky="nsew")

        tk.Label(left_frame, text="🎧 音声メモ").pack()
        self.text_area = scrolledtext.ScrolledText(left_frame, font=("Meiryo", 12))
        self.text_area.pack(fill=tk.BOTH, expand=True)

        right_frame = tk.Frame(display_frame)
        right_frame.grid(row=0, column=1, sticky="nsew")

        tk.Label(right_frame, text="📌 要約結果").pack()
        self.summary_area = scrolledtext.ScrolledText(right_frame, font=("Meiryo", 12))
        self.summary_area.pack(fill=tk.BOTH, expand=True)

        self.status_bar = tk.Label(self.root, text="準備完了", bd=1, relief=tk.SUNKEN, anchor=tk.W)
        self.status_bar.grid(row=3, column=0, sticky="nsew")
        self.root.rowconfigure(3, weight=0)

    def update_status(self, msg):
        self.status_bar.config(text=msg)
        self.status_bar.update_idletasks()

    def open_settings_window(self):
        settings_window = tk.Toplevel(self.root)
        settings_window.title("設定変更")
        settings_window.geometry("400x250")

        tk.Label(settings_window, text="Gemini APIキー:").pack()
        api_entry = tk.Entry(settings_window, width=50)
        api_entry.insert(0, SETTINGS["api_key"])
        api_entry.pack()

        tk.Label(settings_window, text="モデル名:").pack()
        model_entry = tk.Entry(settings_window, width=50)
        model_entry.insert(0, SETTINGS["model"])
        model_entry.pack()

        tk.Label(settings_window, text="マイク選択:").pack(pady=5)
        mic_combo = ttk.Combobox(settings_window, values=[name for _, name in self.microphones], width=45)
        mic_combo.pack()
        mic_combo.current(min(SETTINGS.get("mic_index", 0), len(self.microphones) - 1))

        def apply():
            SETTINGS["api_key"] = api_entry.get()
            SETTINGS["model"] = model_entry.get()
            SETTINGS["sensitivity"] = self.sensitivity_scale.get()
            SETTINGS["mic_index"] = mic_combo.current()
            save_settings(SETTINGS)
            configure_gemini()
            self.update_status("設定を保存しました")
            settings_window.destroy()

        tk.Button(settings_window, text="保存して閉じる", command=apply).pack(pady=10)

    def toggle_recording(self):
        if self.recording:
            self.recording = False
            self.record_button.config(text="🔴 録音開始")
            self.update_status("録音停止")
        else:
            self.recording = True
            self.record_button.config(text="⏹ 録音停止")
            threading.Thread(target=self.record_audio).start()

    def record_audio(self):
        self.update_status("録音中...( 停止ボタンで終了 )")
        index = SETTINGS.get("mic_index", 0)
        device_index, _ = self.microphones[index]

        p = pyaudio.PyAudio()

        try:
            device_info = p.get_device_info_by_index(device_index)
            rate = int(device_info.get('defaultSampleRate', 16000))

            stream = p.open(format=pyaudio.paInt16,
                            channels=1,
                            rate=rate,
                            input=True,
                            input_device_index=device_index,
                            frames_per_buffer=1024)
        except Exception as e:
            self.update_status(f"⚠ ストリーム開始エラー: {e}")
            p.terminate()
            return

        self.audio_frames = []
        while self.recording:
            try:
                data = stream.read(1024, exception_on_overflow=False)
                if len(data) > 100000:
                    self.update_status("⚠ 異常なデータサイズを検出、録音停止")
                    break
                self.audio_frames.append(data)
            except Exception as e:
                self.update_status(f"⚠ 録音エラー: {e}")
                break

        stream.stop_stream()
        stream.close()
        p.terminate()

        self.update_status("録音完了、文字起こし中...")

        if not self.audio_frames:
            self.update_status("⚠ 録音データが空のため保存をスキップ")
            return

        with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_audio:
            joined = b''.join(self.audio_frames)
            if len(joined) > 0xFFFFFFFF:
                self.update_status("⚠ 録音データが大きすぎます")
                return
            wf = wave.open(temp_audio.name, 'wb')
            wf.setnchannels(1)
            wf.setsampwidth(p.get_sample_size(pyaudio.paInt16))
            wf.setframerate(rate)
            wf.writeframes(joined)
            wf.close()

            with sr.AudioFile(temp_audio.name) as source:
                audio = self.recognizer.record(source)

        try:
            text = self.recognizer.recognize_google(audio, language='ja-JP')
            self.text_area.insert(tk.END, text + "\n")
            self.text_area.see(tk.END)
            self.update_status("文字起こし完了")
        except sr.UnknownValueError:
            self.update_status("⚠ 音声が認識できませんでした")
        except Exception as e:
            self.update_status(f"⚠ エラー: {e}")
        finally:
            os.remove(temp_audio.name)

    def summarize_text(self):
        content = self.text_area.get("1.0", tk.END).strip()
        if not content:
            messagebox.showwarning("警告", "メモが空です。")
            return
        self.update_status("Geminiで要約中...")
        try:
            model = genai.GenerativeModel(SETTINGS["model"])
            response = model.generate_content(f"以下の日本語メモを簡潔に要約してください:\n{content}")
            self.summary_text = response.text.strip() if response.text else "(要約なし)"
            self.summary_area.delete("1.0", tk.END)
            self.summary_area.insert(tk.END, self.summary_text)
            self.update_status("要約完了")
        except Exception as e:
            self.update_status(f"⚠ APIエラー: {e}")

    def save_memo(self):
        content = self.text_area.get("1.0", tk.END)
        if not content.strip():
            messagebox.showwarning("警告", "メモが空です。")
            return
        now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        file_path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            initialfile=f"memo_{now}.txt",
            filetypes=[("Text Files", "*.txt")]
        )
        if file_path:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(content)
            self.update_status(f"メモを保存しました:{file_path}")

    def save_summary(self):
        content = self.summary_area.get("1.0", tk.END)
        if not content.strip():
            messagebox.showwarning("警告", "要約が空です。")
            return
        now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        file_path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            initialfile=f"summary_{now}.txt",
            filetypes=[("Text Files", "*.txt")]
        )
        if file_path:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(content)
            self.update_status(f"要約を保存しました:{file_path}")

    def load_log(self):
        file_path = filedialog.askopenfilename(filetypes=[("Text Files", "*.txt")])
        if file_path:
            with open(file_path, "r", encoding="utf-8") as f:
                content = f.read()
            self.text_area.delete("1.0", tk.END)
            self.text_area.insert(tk.END, content)
            self.update_status("過去のメモを読み込みました")

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

次回は実際にアプリを起動して動作の確認をしてみようと思います。

お問い合わせ・ご質問はフォームよりお願いいたします。

    コメント

    タイトルとURLをコピーしました