|
import tkinter as tk |
|
from tkinter import ttk, messagebox, Scale |
|
from PIL import Image, ImageDraw, ImageFont, ImageTk |
|
import threading |
|
import time |
|
import os |
|
import subprocess |
|
import sys |
|
import json |
|
import wave |
|
import numpy as np |
|
from scipy.io import wavfile |
|
import importlib |
|
import pkg_resources |
|
import platform |
|
import tempfile |
|
from typing import Optional, List, Dict, Any, Union |
|
|
|
# 必要なライブラリとバージョンの定義 |
|
required_packages = { |
|
'PIL': 'pillow', |
|
'pydub': 'pydub', |
|
'ffmpeg': 'ffmpeg-python', |
|
'playsound': 'playsound==1.2.2', |
|
'scipy': 'scipy', |
|
'numpy': 'numpy', |
|
'requests': 'requests' |
|
} |
|
|
|
# アプリケーションの設定ディレクトリとファイルパス |
|
CONFIG_DIR = os.path.join("misc", "config") |
|
CONFIG_FILE = os.path.join(CONFIG_DIR, "training_app.json") |
|
ASSETS_DIR = os.path.join("misc", "assets") |
|
|
|
def load_config(): |
|
"""設定ファイルから設定を読み込む""" |
|
default_config = { |
|
"voice_volume": 1.0, |
|
"sound_volume": 1.0, |
|
"voice_style": 3 # ずんだもん(あまあま)をデフォルトに |
|
} |
|
|
|
# 設定ディレクトリがなければ作成 |
|
os.makedirs(CONFIG_DIR, exist_ok=True) |
|
|
|
# 設定ファイルが存在する場合は読み込み |
|
if os.path.exists(CONFIG_FILE): |
|
try: |
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f: |
|
loaded_config = json.load(f) |
|
# デフォルト設定に読み込んだ設定を上書き |
|
default_config.update(loaded_config) |
|
print(f"設定ファイルを読み込みました: {CONFIG_FILE}") |
|
print(f"読み込まれた設定: {loaded_config}") # デバッグ用ログ |
|
except Exception as e: |
|
print(f"設定ファイルの読み込みエラー: {e}") |
|
else: |
|
print(f"設定ファイルが見つかりません。デフォルト設定を使用します: {CONFIG_FILE}") |
|
|
|
return default_config |
|
|
|
def save_config(config): |
|
"""設定ファイルに設定を保存する""" |
|
# 設定ディレクトリがなければ作成 |
|
os.makedirs(CONFIG_DIR, exist_ok=True) |
|
|
|
try: |
|
with open(CONFIG_FILE, 'w', encoding='utf-8') as f: |
|
json.dump(config, f, ensure_ascii=False, indent=2) |
|
print(f"設定ファイルを保存しました: {CONFIG_FILE}") |
|
return True |
|
except Exception as e: |
|
print(f"設定ファイルの保存エラー: {e}") |
|
return False |
|
|
|
def check_and_install_packages(): |
|
"""必要なライブラリが揃っているかチェックし、なければインストールする""" |
|
missing_packages = [] |
|
|
|
# インストール済みのパッケージを取得 |
|
installed_packages = {pkg.key: pkg.version for pkg in pkg_resources.working_set} |
|
|
|
# 必要なパッケージを確認 |
|
for package, install_name in required_packages.items(): |
|
try: |
|
# パッケージがインストールされているか確認 |
|
importlib.import_module(package) |
|
print(f"✓ {package} はインストール済みです") |
|
except ImportError: |
|
# 見つからない場合はインストールリストに追加 |
|
missing_packages.append(install_name) |
|
print(f"✗ {package} が見つかりません - インストールが必要です") |
|
|
|
# 不足しているパッケージがあればインストール |
|
if missing_packages: |
|
print("必要なライブラリをインストールしています...") |
|
for package in missing_packages: |
|
try: |
|
subprocess.check_call([sys.executable, "-m", "pip", "install", package]) |
|
print(f"✓ {package} をインストールしました") |
|
except Exception as e: |
|
print(f"✗ {package} のインストールに失敗しました: {e}") |
|
return False |
|
|
|
# playsoundモジュールを遅延インポート(インストール後に行う必要がある) |
|
global playsound |
|
try: |
|
from playsound import playsound |
|
except ImportError: |
|
print("playsoundモジュールのインポートに失敗しました。手動でインストールしてください。") |
|
return False |
|
|
|
# requestsモジュールを遅延インポート |
|
global requests |
|
try: |
|
import requests |
|
except ImportError: |
|
print("requestsモジュールのインポートに失敗しました。手動でインストールしてください。") |
|
return False |
|
|
|
# Windowsの場合はwinsoundをインポート |
|
global winsound |
|
if platform.system() == "Windows": |
|
try: |
|
import winsound |
|
except ImportError: |
|
print("Windowsのwinsoundモジュールがインポートできませんでした。") |
|
|
|
return True |
|
|
|
# メイン処理の前にライブラリチェックを実行 |
|
if not check_and_install_packages(): |
|
print("必要なライブラリがインストールできませんでした。セットアップを中断します。") |
|
sys.exit(1) |
|
|
|
# VoiceVox関連の設定 |
|
VOICEVOX_PATH: Optional[str] = None # 自動検出される |
|
VOICEVOX_URL: str = "http://localhost:50021" |
|
VOICEVOX_STARTUP_WAIT: int = 10 # 起動待機秒数 |
|
|
|
class VoiceVoxManager: |
|
"""VoiceVoxの管理とインターフェース""" |
|
|
|
def __init__(self): |
|
self.process = None |
|
self.is_running = False |
|
self.autostart = True |
|
self.api_ready = False |
|
self.output_dir = os.path.join(ASSETS_DIR, "tts_output") |
|
os.makedirs(self.output_dir, exist_ok=True) |
|
|
|
# 既に起動しているか確認 |
|
if self.check_api_available(): |
|
print("VoiceVoxが既に起動しています") |
|
self.api_ready = True |
|
self.is_running = True |
|
|
|
def find_voicevox_path(self): |
|
"""システム内のVoiceVoxを探す""" |
|
global VOICEVOX_PATH |
|
|
|
# パスが既に設定されていればそれを使う |
|
if VOICEVOX_PATH and os.path.exists(VOICEVOX_PATH): |
|
print(f"VoiceVoxのパスが既に設定されています: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
# 環境変数から直接パスを取得(手動設定用) |
|
env_path = os.environ.get("VOICEVOX_PATH", "") |
|
if env_path and os.path.exists(env_path): |
|
VOICEVOX_PATH = env_path |
|
print(f"環境変数からVoiceVoxのパスを読み込みました: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
# WindowsでのVoiceVoxの一般的なインストール場所 |
|
if platform.system() == "Windows": |
|
# ユーザープロファイルのパス |
|
user_profile = os.environ.get("USERPROFILE", "") |
|
|
|
# ユーザープロファイルディレクトリ内のVoiceVox |
|
user_voicevox_path = os.path.join(user_profile, "AppData", "Local", "Programs", "VOICEVOX", "VOICEVOX.exe") |
|
if os.path.exists(user_voicevox_path): |
|
VOICEVOX_PATH = user_voicevox_path |
|
print(f"VoiceVoxをユーザープロファイルで見つけました: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
# Program Filesのパス |
|
program_files = [ |
|
os.environ.get("ProgramFiles", "C:\\Program Files"), |
|
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)") |
|
] |
|
|
|
for pf in program_files: |
|
voicevox_dir = os.path.join(pf, "VOICEVOX") |
|
voicevox_exe = os.path.join(voicevox_dir, "VOICEVOX.exe") |
|
if os.path.exists(voicevox_exe): |
|
VOICEVOX_PATH = voicevox_exe |
|
print(f"VoiceVoxをProgram Filesで見つけました: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
print("WindowsでVoiceVoxが見つかりませんでした。") |
|
|
|
# Macでの一般的なインストール場所 |
|
elif platform.system() == "Darwin": |
|
mac_paths = [ |
|
"/Applications/VOICEVOX.app/Contents/MacOS/VOICEVOX" |
|
] |
|
for path in mac_paths: |
|
if os.path.exists(path): |
|
VOICEVOX_PATH = path |
|
print(f"VoiceVoxをMacで見つけました: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
print("MacでVoiceVoxが見つかりませんでした。") |
|
|
|
# それ以外(Linux等)での一般的なインストール場所 |
|
else: |
|
linux_paths = [ |
|
"/usr/bin/voicevox", |
|
"/usr/local/bin/voicevox", |
|
os.path.expanduser("~/.local/bin/voicevox") |
|
] |
|
for path in linux_paths: |
|
if os.path.exists(path): |
|
VOICEVOX_PATH = path |
|
print(f"VoiceVoxをLinuxで見つけました: {VOICEVOX_PATH}") |
|
return VOICEVOX_PATH |
|
|
|
print("LinuxでVoiceVoxが見つかりませんでした。") |
|
|
|
print("VoiceVoxが見つかりませんでした。手動で起動してください。") |
|
return None |
|
|
|
def start_voicevox(self): |
|
"""VoiceVoxを起動する""" |
|
if self.is_running: |
|
return True |
|
|
|
voicevox_path = self.find_voicevox_path() |
|
if not voicevox_path: |
|
print("VoiceVoxの実行ファイルが見つかりません。手動でVoiceVoxを起動してください。") |
|
return False |
|
|
|
try: |
|
# 起動(非表示モード) |
|
if platform.system() == "Windows": |
|
self.process = subprocess.Popen([voicevox_path], |
|
creationflags=subprocess.CREATE_NO_WINDOW) |
|
else: |
|
self.process = subprocess.Popen([voicevox_path], |
|
stdout=subprocess.DEVNULL, |
|
stderr=subprocess.DEVNULL) |
|
|
|
self.is_running = True |
|
print(f"VoiceVoxを起動しました(PID: {self.process.pid})") |
|
|
|
# APIが利用可能になるまで待機 |
|
for i in range(VOICEVOX_STARTUP_WAIT): |
|
if self.check_api_available(): |
|
self.api_ready = True |
|
print(f"VoiceVox API が利用可能になりました") |
|
return True |
|
time.sleep(1) |
|
print(f"VoiceVox API を待機中... {i+1}/{VOICEVOX_STARTUP_WAIT}") |
|
|
|
print("VoiceVoxは起動しましたが、APIが応答しません。") |
|
return False |
|
|
|
except Exception as e: |
|
print(f"VoiceVoxの起動に失敗しました: {e}") |
|
return False |
|
|
|
def check_api_available(self): |
|
"""VoiceVox APIが利用可能か確認""" |
|
try: |
|
response = requests.get(f"{VOICEVOX_URL}/version") |
|
return response.status_code == 200 |
|
except: |
|
return False |
|
|
|
def stop_voicevox(self): |
|
"""VoiceVoxを停止する""" |
|
if self.process and self.is_running: |
|
try: |
|
self.process.terminate() |
|
self.is_running = False |
|
self.api_ready = False |
|
print("VoiceVoxを停止しました") |
|
return True |
|
except Exception as e: |
|
print(f"VoiceVoxの停止に失敗しました: {e}") |
|
return False |
|
return True |
|
|
|
def generate_speech(self, text, speaker_id=3, volume=1.0): |
|
"""テキストから音声を生成する""" |
|
if not self.check_api_available(): |
|
return None |
|
|
|
try: |
|
# 読み方修正のための前処理 |
|
# 難しい漢字や専門用語にVoiceVoxで正しく読ませるための置換処理 |
|
replacements = { |
|
"肩甲骨": "けんこうこつ", |
|
"腹斜筋": "ふくしゃきん", |
|
"バイシクルクランチ": "バイシクル クランチ" |
|
} |
|
|
|
for word, replacement in replacements.items(): |
|
text = text.replace(word, replacement) |
|
|
|
print(f"読み方修正後のテキスト: {text}") # デバッグ用ログ |
|
|
|
# 音声合成用のクエリを作成 |
|
query_payload = {"text": text, "speaker": speaker_id} |
|
query_response = requests.post(f"{VOICEVOX_URL}/audio_query", params=query_payload) |
|
|
|
if query_response.status_code != 200: |
|
print(f"VoiceVox クエリエラー: {query_response.text}") |
|
return None |
|
|
|
# 音量を調整(VoiceVoxのパラメータを変更) |
|
query_data = query_response.json() |
|
query_data["volumeScale"] = max(0.0, min(2.0, volume)) # 音量を0.0から2.0の範囲に制限 |
|
print(f"音量設定: {query_data['volumeScale']}") # デバッグ用ログ |
|
|
|
# 音声合成 |
|
synthesis_payload = {"speaker": speaker_id} |
|
synthesis_response = requests.post( |
|
f"{VOICEVOX_URL}/synthesis", |
|
headers={"Content-Type": "application/json"}, |
|
params=synthesis_payload, |
|
data=json.dumps(query_data) |
|
) |
|
|
|
if synthesis_response.status_code != 200: |
|
print(f"VoiceVox 合成エラー: {synthesis_response.text}") |
|
return None |
|
|
|
# 一時ファイルに保存 |
|
wav_filename = f"{self.output_dir}/temp_{int(time.time())}.wav" |
|
with open(wav_filename, "wb") as f: |
|
f.write(synthesis_response.content) |
|
|
|
return wav_filename |
|
|
|
except Exception as e: |
|
print(f"音声合成エラー: {e}") |
|
return None |
|
|
|
class SoundManager: |
|
"""効果音の管理""" |
|
|
|
def __init__(self): |
|
self.sound_dir = os.path.join(ASSETS_DIR, "sounds") |
|
os.makedirs(self.sound_dir, exist_ok=True) |
|
self.volume = 1.0 # 効果音の音量(0.0 - 1.0) |
|
self.use_winsound = False # エラー発生時にwinsoundを使用するかどうか |
|
|
|
def set_volume(self, volume): |
|
"""効果音の音量を設定""" |
|
self.volume = max(0.0, min(1.0, volume)) |
|
|
|
def play_sound(self, sound_file): |
|
"""効果音を再生""" |
|
if not os.path.exists(sound_file): |
|
print(f"音声ファイルが見つかりません: {sound_file}") |
|
return False |
|
|
|
# winsoundが優先の場合(Windowsで.wavファイルの場合のみ) |
|
if self.use_winsound and platform.system() == "Windows" and sound_file.endswith('.wav'): |
|
try: |
|
import winsound |
|
winsound.PlaySound(sound_file, winsound.SND_FILENAME | winsound.SND_ASYNC) |
|
print(f"winsoundで音声再生しました: {sound_file}") |
|
return True |
|
except Exception as e: |
|
print(f"winsound再生エラー: {e}") |
|
# winsound失敗時は次の方法を試す |
|
|
|
# playsoundを使用して非同期再生(別スレッドで実行) |
|
def _play_async(): |
|
try: |
|
from playsound import playsound |
|
playsound(sound_file, block=False) # block=Falseで非同期再生 |
|
print(f"playsoundで音声再生しました: {sound_file}") |
|
except Exception as e: |
|
print(f"playsound再生エラー: {e}") |
|
# playsound失敗時はpydubを試す |
|
try: |
|
from pydub import AudioSegment |
|
from pydub.playback import play |
|
sound = AudioSegment.from_mp3(sound_file) if sound_file.endswith('.mp3') else AudioSegment.from_wav(sound_file) |
|
sound = sound - (60 * (1.0 - self.volume)) |
|
play(sound) |
|
print(f"pydubで音声再生しました: {sound_file}") |
|
except Exception as e: |
|
print(f"pydub再生エラー: {e}") |
|
self.use_winsound = True # 次回からwinsoundを試す |
|
|
|
# 別スレッドで再生を開始 |
|
threading.Thread(target=_play_async, daemon=True).start() |
|
return True |
|
|
|
def create_beep_sound(self, filename, duration=1.0, frequency=440, volume=None): |
|
"""シンプルなビープ音を作成する""" |
|
# 指定されたボリュームがなければデフォルト値を使用 |
|
if volume is None: |
|
volume = self.volume |
|
|
|
# ディレクトリが存在しない場合は作成 |
|
os.makedirs(os.path.dirname(filename), exist_ok=True) |
|
|
|
# サイン波を生成 |
|
sample_rate = 44100 |
|
t = np.linspace(0, duration, int(sample_rate * duration), False) |
|
signal = np.sin(2 * np.pi * frequency * t) * volume |
|
|
|
# フェードアウト効果(終わりの0.1秒) |
|
fadeout_len = int(0.1 * sample_rate) |
|
if len(signal) > fadeout_len: |
|
fadeout = np.linspace(1.0, 0.0, fadeout_len) |
|
signal[-fadeout_len:] *= fadeout |
|
|
|
# 16ビット整数に変換 |
|
audio = (signal * 32767).astype(np.int16) |
|
|
|
# WAVファイルとして保存 |
|
try: |
|
wavfile.write(filename, sample_rate, audio) |
|
return True |
|
except Exception as e: |
|
print(f"効果音作成エラー: {e}") |
|
return False |
|
|
|
def create_all_sounds(self): |
|
"""必要な効果音をすべて作成""" |
|
# 各効果音のパラメータ定義 |
|
sounds = [ |
|
{"name": "start.wav", "duration": 0.5, "frequency": 880, "volume": self.volume}, |
|
{"name": "rest.wav", "duration": 0.3, "frequency": 660, "volume": self.volume}, |
|
{"name": "complete.wav", "duration": 1.0, "frequency": 440, "volume": self.volume}, |
|
{"name": "countdown.wav", "duration": 0.3, "frequency": 1000, "volume": self.volume}, |
|
{"name": "beep.wav", "duration": 0.2, "frequency": 800, "volume": self.volume * 0.3} |
|
] |
|
|
|
# 各効果音を作成 |
|
created_count = 0 |
|
for sound in sounds: |
|
filename = os.path.join(self.sound_dir, sound["name"]) |
|
if not os.path.exists(filename) or True: # 常に上書き作成 |
|
if self.create_beep_sound(filename, sound["duration"], sound["frequency"], sound["volume"]): |
|
created_count += 1 |
|
|
|
return created_count |
|
|
|
class ImageManager: |
|
"""画像の管理""" |
|
|
|
def __init__(self): |
|
self.image_dir = os.path.join(ASSETS_DIR, "images") |
|
os.makedirs(self.image_dir, exist_ok=True) |
|
|
|
def create_all_images(self): |
|
"""画像ディレクトリの確認と作成""" |
|
# 各筋トレ種目の情報 |
|
exercises = [ |
|
"0. ハムストリングスストレッチ", |
|
"1. クランチ", |
|
"2. リバースクランチ", |
|
"3. バイシクルクランチ", |
|
"4. レッグレイズ", |
|
"5. プランク" |
|
] |
|
|
|
# 画像ディレクトリが存在することを確認 |
|
print(f"画像ディレクトリを確認: {self.image_dir}") |
|
if not os.path.exists(self.image_dir): |
|
os.makedirs(self.image_dir, exist_ok=True) |
|
print(f"画像ディレクトリを作成しました") |
|
|
|
# 休憩画像が存在しない場合は作成(シンプルな画像を作成) |
|
rest_image_path = os.path.join(self.image_dir, "rest.png") |
|
if not os.path.exists(rest_image_path): |
|
try: |
|
# シンプルな休憩画像を作成 |
|
img = Image.new('RGB', (800, 600), color='#e0f0ff') |
|
d = ImageDraw.Draw(img) |
|
|
|
# フォントを設定(利用可能なフォントを使用) |
|
try: |
|
font = ImageFont.truetype("arial.ttf", 32) |
|
small_font = ImageFont.truetype("arial.ttf", 24) |
|
except: |
|
# デフォルトフォントを使用 |
|
font = ImageFont.load_default() |
|
small_font = ImageFont.load_default() |
|
|
|
# テキスト描画 |
|
d.text((400, 200), "休憩時間", fill='#0066cc', anchor='mm', font=font) |
|
d.text((400, 250), "深呼吸して、次のエクササイズに備えましょう", fill='#333333', anchor='mm', font=small_font) |
|
|
|
# 画像を保存 |
|
img.save(rest_image_path) |
|
print(f"休憩画像を作成しました: {rest_image_path}") |
|
return 1 # 作成した画像の数を返す |
|
except Exception as e: |
|
print(f"休憩画像の作成に失敗しました: {e}") |
|
|
|
return 0 # 作成した画像の数を返す(この場合は0) |
|
|
|
# シングルトンインスタンス |
|
voicevox_manager = VoiceVoxManager() |
|
sound_manager = SoundManager() |
|
image_manager = ImageManager() |
|
|
|
class TrainingApp: |
|
def __init__(self, root): |
|
self.root = root |
|
self.root.title("ものぐさでも出来る寝ながら集中腹筋鍛錬") |
|
self.root.geometry("800x600") |
|
self.root.resizable(False, False) |
|
|
|
# 設定の読み込み |
|
self.config = load_config() |
|
|
|
# 初期セットアップ(ウィンドウ表示前) |
|
self.setup_resources() |
|
|
|
# VoiceVoxとサウンド設定 |
|
self.voicevox_speaker = self.config["voice_style"] # 設定から読み込み |
|
self.voicevox_volume = self.config["voice_volume"] # 設定から読み込み |
|
self.sound_volume = self.config["sound_volume"] # 設定から読み込み |
|
|
|
# サウンドマネージャーに音量設定を適用 |
|
sound_manager.set_volume(self.sound_volume) |
|
|
|
# 難易度とそれに対応する時間(秒) |
|
self.difficulty_times = { |
|
"軽め": 20, |
|
"通常": 30, |
|
"重め": 40 |
|
} |
|
self.selected_difficulty = tk.StringVar(value="通常") |
|
|
|
# トレーニングデータの設定 |
|
self.training_data = [ |
|
{ |
|
"name": "0. ハムストリングスストレッチ", |
|
"description": "仰向けに寝て、片足をまっすぐ上に伸ばします。\n両手で足の裏または足首を持ち、足を頭の方向にゆっくり引き寄せます。\n反対の足も同様に行いましょう。各足20秒ずつキープします。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise0.png") |
|
}, |
|
{ |
|
"name": "1. クランチ", |
|
"description": "仰向けに寝て、両膝を曲げ、両手を耳の横に置きます。\n腹筋を意識しながら上半身を持ち上げ、肩甲骨が床から離れる程度まで上げます。\nゆっくりと元の位置に戻し、反動を使わないようにしましょう。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise1.png") |
|
}, |
|
{ |
|
"name": "2. リバースクランチ", |
|
"description": "仰向けに寝て、両膝を90度に曲げます。\n腹筋を使ってお尻を床から少し浮かし、膝を胸に近づけるようにします。\nゆっくりと元の位置に戻し、下腹部を意識して行いましょう。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise2.png") |
|
}, |
|
{ |
|
"name": "3. バイシクルクランチ", |
|
"description": "仰向けに寝て、両手を耳の横に置き、両膝を90度に曲げます。\n右肘を左膝に、左肘を右膝に交互に近づけるようにします。\n自転車をこぐようなリズムで、腹斜筋を意識しながら行いましょう。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise3.png") |
|
}, |
|
{ |
|
"name": "4. レッグレイズ", |
|
"description": "仰向けに寝て、両手を体の横に置きます。\n両足をそろえたまま、まっすぐ上に持ち上げ、床と垂直になるまで上げます。\nゆっくりと下ろす際も床につけず、下腹部を意識して行いましょう。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise4.png") |
|
}, |
|
{ |
|
"name": "5. プランク", |
|
"description": "うつ伏せになり、肘と前腕、つま先を床につけて体を支えます。\n体が一直線になるようにキープし、お腹を引き締めて腰が沈まないようにします。\n呼吸を止めず、体幹全体を意識して維持しましょう。", |
|
"image_path": os.path.join(ASSETS_DIR, "images", "exercise5.png") |
|
} |
|
] |
|
|
|
# 休憩時の画像 |
|
self.rest_image_path = os.path.join(ASSETS_DIR, "images", "rest.png") |
|
|
|
# 現在のトレーニング状態 |
|
self.current_exercise_index = 0 |
|
self.is_training = False |
|
self.is_rest = False |
|
self.is_preparing = False |
|
self.remaining_time = 0 |
|
self.timer_thread = None |
|
self.is_countdown = False |
|
self.countdown_played = False # カウントダウン音が既に再生されたかどうかを追跡 |
|
|
|
# 音声ファイルパス |
|
self.sound_start = os.path.join(ASSETS_DIR, "sounds", "complete.mp3") |
|
self.sound_rest = os.path.join(ASSETS_DIR, "sounds", "complete.mp3") |
|
self.sound_complete = os.path.join(ASSETS_DIR, "sounds", "complete.mp3") |
|
self.sound_countdown = os.path.join(ASSETS_DIR, "sounds", "countdown.mp3") |
|
self.sound_beep = os.path.join(ASSETS_DIR, "sounds", "countdown.mp3") |
|
|
|
# 画面の初期化 |
|
self.setup_start_screen() |
|
|
|
# 終了時の処理を設定 |
|
self.root.protocol("WM_DELETE_WINDOW", self.on_closing) |
|
|
|
def setup_resources(self): |
|
"""必要なリソース(画像・音声)をセットアップ""" |
|
# 進捗表示用のトップレベルウィンドウ |
|
progress_window = tk.Toplevel(self.root) |
|
progress_window.title("初期化中...") |
|
progress_window.geometry("400x150") |
|
progress_window.resizable(False, False) |
|
progress_window.transient(self.root) |
|
progress_window.grab_set() |
|
|
|
# 進捗メッセージ |
|
message_label = tk.Label(progress_window, text="セットアップを実行中です...", font=("Helvetica", 12)) |
|
message_label.pack(pady=20) |
|
|
|
# 進捗バー |
|
progress_bar = ttk.Progressbar(progress_window, mode='indeterminate', length=300) |
|
progress_bar.pack(pady=10) |
|
progress_bar.start(10) |
|
|
|
# 詳細メッセージ |
|
detail_label = tk.Label(progress_window, text="リソースを準備しています...", font=("Helvetica", 10)) |
|
detail_label.pack(pady=10) |
|
|
|
# UIを更新 |
|
progress_window.update() |
|
|
|
# 画像ディレクトリの確認 |
|
detail_label.config(text="画像ディレクトリを確認しています...") |
|
progress_window.update() |
|
image_dir_check = image_manager.create_all_images() |
|
|
|
# 音声リソースのセットアップ |
|
detail_label.config(text="効果音を準備しています...") |
|
progress_window.update() |
|
sounds_created = sound_manager.create_all_sounds() |
|
|
|
# VoiceVoxのセットアップ(あれば) |
|
if voicevox_manager.autostart: |
|
detail_label.config(text="VoiceVoxの起動を試みています...") |
|
progress_window.update() |
|
voicevox_started = voicevox_manager.start_voicevox() |
|
|
|
if voicevox_started: |
|
detail_label.config(text="VoiceVoxが起動しました!") |
|
else: |
|
detail_label.config(text="VoiceVoxは利用できません(テキスト表示モードで動作します)") |
|
|
|
# セットアップ完了 |
|
time.sleep(1) # 完了メッセージを少し表示 |
|
progress_window.destroy() |
|
|
|
def set_voice_style(self, speaker_id): |
|
"""音声スタイルを設定""" |
|
self.voicevox_speaker = speaker_id |
|
self.config["voice_style"] = speaker_id # 設定を更新 |
|
save_config(self.config) # 設定を即時保存 |
|
|
|
def setup_start_screen(self): |
|
"""スタート画面のセットアップ""" |
|
# 既存のウィジェットをクリア |
|
for widget in self.root.winfo_children(): |
|
widget.destroy() |
|
|
|
# タイトルラベル |
|
title_label = tk.Label(self.root, text="ものぐさでも出来る寝ながら集中腹筋鍛錬", font=("Helvetica", 24, "bold")) |
|
title_label.pack(pady=30) |
|
|
|
# 難易度選択フレーム |
|
difficulty_frame = tk.Frame(self.root) |
|
difficulty_frame.pack(pady=15) |
|
|
|
difficulty_label = tk.Label(difficulty_frame, text="難易度選択:", font=("Helvetica", 14)) |
|
difficulty_label.pack(side=tk.LEFT, padx=10) |
|
|
|
for difficulty in ["軽め", "通常", "重め"]: |
|
rb = tk.Radiobutton(difficulty_frame, text=difficulty, variable=self.selected_difficulty, |
|
value=difficulty, font=("Helvetica", 12)) |
|
rb.pack(side=tk.LEFT, padx=10) |
|
|
|
# 説明テキスト |
|
description_text = f"軽め: 各種目 20秒\n通常: 各種目 30秒\n重め: 各種目 40秒\n\n全5種目、休憩は各10秒" |
|
description_label = tk.Label(self.root, text=description_text, font=("Helvetica", 12), justify=tk.LEFT) |
|
description_label.pack(pady=15) |
|
|
|
# 音声エンジン選択 |
|
tts_frame = tk.Frame(self.root) |
|
tts_frame.pack(pady=10) |
|
|
|
tts_label = tk.Label(tts_frame, text="音声:", font=("Helvetica", 12)) |
|
tts_label.pack(side=tk.LEFT, padx=10) |
|
|
|
# ずんだもんノーマル |
|
zunda_normal_rb = tk.Radiobutton(tts_frame, text="ずんだもん(ノーマル)", |
|
variable=self.voicevox_speaker, # 同じ変数を参照 |
|
value=1, # ノーマルの値 |
|
command=lambda: self.set_voice_style(1), # 変更時に設定を保存 |
|
font=("Helvetica", 12)) |
|
zunda_normal_rb.pack(side=tk.LEFT, padx=5) |
|
zunda_normal_rb.select() # デフォルト選択 |
|
|
|
# ずんだもんあまあま |
|
zunda_sweet_rb = tk.Radiobutton(tts_frame, text="ずんだもん(あまあま)", |
|
variable=self.voicevox_speaker, # 同じ変数を参照 |
|
value=3, # あまあまの値 |
|
command=lambda: self.set_voice_style(3), # 変更時に設定を保存 |
|
font=("Helvetica", 12)) |
|
zunda_sweet_rb.pack(side=tk.LEFT, padx=5) |
|
|
|
# 音量設定セクション |
|
volume_frame = tk.Frame(self.root) |
|
volume_frame.pack(pady=15) |
|
|
|
# VoiceVox音量 |
|
voice_volume_label = tk.Label(volume_frame, text="音声ガイド音量:", font=("Helvetica", 10)) |
|
voice_volume_label.grid(row=0, column=0, padx=10) |
|
|
|
voice_volume = tk.Scale(volume_frame, from_=0, to=100, orient=tk.HORIZONTAL, |
|
command=self.set_voice_volume) |
|
voice_volume.set(int(self.voicevox_volume * 100)) |
|
voice_volume.grid(row=0, column=1, padx=10) |
|
|
|
# 効果音音量 |
|
sound_volume_label = tk.Label(volume_frame, text="効果音音量:", font=("Helvetica", 10)) |
|
sound_volume_label.grid(row=1, column=0, padx=10) |
|
|
|
sound_volume = tk.Scale(volume_frame, from_=0, to=100, orient=tk.HORIZONTAL, |
|
command=self.set_sound_volume) |
|
sound_volume.set(int(self.sound_volume * 100)) |
|
sound_volume.grid(row=1, column=1, padx=10) |
|
|
|
# スタートボタン |
|
start_button = tk.Button(self.root, text="トレーニング開始", font=("Helvetica", 16, "bold"), |
|
command=self.start_training, bg="#4CAF50", fg="white", padx=20, pady=10) |
|
start_button.pack(pady=20) |
|
|
|
# VoiceVox状態表示 |
|
self.voicevox_status_label = tk.Label(self.root, text="", font=("Helvetica", 10), fg="gray") |
|
self.voicevox_status_label.pack() |
|
|
|
# VoiceVoxの状態を確認して表示を更新 |
|
self.update_voicevox_status() |
|
|
|
# 注意書き |
|
note_text = "※「ずんだもん」ボイスを使用するには、VoiceVoxが必要です。" |
|
note_label = tk.Label(self.root, text=note_text, font=("Helvetica", 8), fg="gray") |
|
note_label.pack(pady=5) |
|
|
|
def update_voicevox_status(self): |
|
"""VoiceVoxの状態を確認して表示を更新""" |
|
if voicevox_manager.check_api_available(): |
|
self.voicevox_status_label.config(text="VoiceVox: 接続済み✓", fg="green") |
|
else: |
|
self.voicevox_status_label.config(text="VoiceVox: 利用不可✗", fg="red") |
|
|
|
def set_voice_volume(self, value): |
|
"""VoiceVoxの音量を設定""" |
|
self.voicevox_volume = float(value) / 100.0 |
|
# 設定を更新 |
|
self.config["voice_volume"] = self.voicevox_volume |
|
save_config(self.config) # 設定を即時保存 |
|
|
|
def set_sound_volume(self, value): |
|
"""効果音の音量を設定""" |
|
self.sound_volume = float(value) / 100.0 |
|
sound_manager.set_volume(self.sound_volume) |
|
# 設定を更新 |
|
self.config["sound_volume"] = self.sound_volume |
|
save_config(self.config) # 設定を即時保存 |
|
|
|
def setup_training_screen(self): |
|
"""トレーニング画面のセットアップ""" |
|
# 既存のウィジェットをクリア |
|
for widget in self.root.winfo_children(): |
|
widget.destroy() |
|
|
|
# 現在のエクササイズデータ |
|
exercise_data = self.training_data[self.current_exercise_index] |
|
|
|
# 種目名 |
|
name_label = tk.Label(self.root, text=exercise_data["name"], font=("Helvetica", 20, "bold")) |
|
name_label.pack(pady=20) |
|
|
|
# 画像表示部分 |
|
try: |
|
# 画像が存在するか確認 |
|
if os.path.exists(exercise_data["image_path"]): |
|
img = Image.open(exercise_data["image_path"]) |
|
img = img.resize((300, 200), Image.LANCZOS) |
|
photo = ImageTk.PhotoImage(img) |
|
img_label = tk.Label(self.root, image=photo) |
|
img_label.image = photo # 参照を保持 |
|
img_label.pack(pady=10) |
|
else: |
|
# 画像がない場合はテキストで代替 |
|
img_label = tk.Label(self.root, text=f"[{exercise_data['name']}のイメージ]", |
|
font=("Helvetica", 12), width=40, height=10, |
|
relief=tk.RIDGE, bg="#f0f0f0") |
|
img_label.pack(pady=10) |
|
print(f"画像ファイルが見つかりません: {exercise_data['image_path']}") |
|
except Exception as e: |
|
# エラー時はテキストで代替 |
|
img_label = tk.Label(self.root, text="[画像読み込みエラー]", font=("Helvetica", 12), |
|
width=40, height=10, relief=tk.RIDGE, bg="#ffe0e0") |
|
img_label.pack(pady=10) |
|
print(f"画像読み込みエラー: {e}") |
|
|
|
# 説明テキスト |
|
desc_label = tk.Label(self.root, text=exercise_data["description"], font=("Helvetica", 12), |
|
justify=tk.LEFT, wraplength=600) |
|
desc_label.pack(pady=20) |
|
|
|
# タイマーラベル |
|
self.timer_label = tk.Label(self.root, text=f"残り時間: {self.remaining_time}秒", font=("Helvetica", 16)) |
|
self.timer_label.pack(pady=20) |
|
|
|
# ステータスラベル (休憩中かどうか) |
|
status_text = "カウントダウン中" if self.is_countdown else ("休憩中" if self.is_rest else "トレーニング中") |
|
color = "orange" if self.is_countdown else ("blue" if self.is_rest else "green") |
|
self.status_label = tk.Label(self.root, text=status_text, font=("Helvetica", 14), fg=color) |
|
self.status_label.pack(pady=10) |
|
|
|
# 中止ボタン |
|
stop_button = tk.Button(self.root, text="中止", font=("Helvetica", 12), |
|
command=self.stop_training, bg="#f44336", fg="white") |
|
stop_button.pack(pady=20) |
|
|
|
def setup_complete_screen(self): |
|
"""終了画面のセットアップ""" |
|
# 既存のウィジェットをクリア |
|
for widget in self.root.winfo_children(): |
|
widget.destroy() |
|
|
|
# 完了メッセージ |
|
complete_label = tk.Label(self.root, text="トレーニング完了!", font=("Helvetica", 24, "bold"), fg="green") |
|
complete_label.pack(pady=100) |
|
|
|
message_label = tk.Label(self.root, text="お疲れ様でした!", font=("Helvetica", 16)) |
|
message_label.pack(pady=20) |
|
|
|
# ホームに戻るボタン |
|
home_button = tk.Button(self.root, text="ホームに戻る", font=("Helvetica", 14), |
|
command=self.setup_start_screen, bg="#2196F3", fg="white", padx=10, pady=5) |
|
home_button.pack(pady=50) |
|
|
|
# 完了音を再生 |
|
try: |
|
sound_manager.play_sound(self.sound_complete) |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
|
|
# 音声アナウンス |
|
self.speak("トレーニングが終了しました。お疲れ様でした!") |
|
|
|
def start_training(self): |
|
"""トレーニングを開始する""" |
|
self.is_training = True |
|
self.current_exercise_index = 0 |
|
self.is_rest = False |
|
self.is_countdown = False |
|
self.countdown_played = False |
|
|
|
# 準備時間から開始 |
|
self.start_preparation() |
|
|
|
def start_preparation(self): |
|
"""準備時間を開始する""" |
|
self.is_preparing = True |
|
self.remaining_time = 30 # 準備時間は30秒固定 |
|
|
|
# 準備画面に切り替え |
|
self.setup_preparation_screen() |
|
|
|
# 開始音を再生 |
|
try: |
|
sound_manager.play_sound(self.sound_start) |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
|
|
# 準備開始をアナウンス |
|
self.speak("トレーニングの準備をしましょう。30秒後に開始します。") |
|
|
|
# タイマー開始 |
|
self.start_timer() |
|
|
|
def setup_preparation_screen(self): |
|
"""準備画面のセットアップ""" |
|
# 既存のウィジェットをクリア |
|
for widget in self.root.winfo_children(): |
|
widget.destroy() |
|
|
|
# 準備表示 |
|
title_label = tk.Label(self.root, text="準備時間", font=("Helvetica", 24, "bold"), fg="purple") |
|
title_label.pack(pady=20) |
|
|
|
# 最初のエクササイズを表示 |
|
first_exercise = self.training_data[self.current_exercise_index] |
|
next_text = f"最初のエクササイズ: {first_exercise['name']}" |
|
next_label = tk.Label(self.root, text=next_text, font=("Helvetica", 16)) |
|
next_label.pack(pady=10) |
|
|
|
# 説明 |
|
instruction_text = "これから腹筋トレーニングを始めます。\n姿勢を整え、水分補給をして準備してください。" |
|
instruction_label = tk.Label(self.root, text=instruction_text, font=("Helvetica", 14), wraplength=600) |
|
instruction_label.pack(pady=20) |
|
|
|
# タイマーラベル |
|
self.timer_label = tk.Label(self.root, text=f"残り時間: {self.remaining_time}秒", font=("Helvetica", 16)) |
|
self.timer_label.pack(pady=20) |
|
|
|
# ステータスラベル |
|
self.status_label = tk.Label(self.root, text="準備中", font=("Helvetica", 14), fg="purple") |
|
self.status_label.pack(pady=10) |
|
|
|
# 中止ボタン |
|
stop_button = tk.Button(self.root, text="中止", font=("Helvetica", 12), |
|
command=self.stop_training, bg="#f44336", fg="white") |
|
stop_button.pack(pady=20) |
|
|
|
# スキップボタン |
|
skip_button = tk.Button(self.root, text="準備をスキップ", font=("Helvetica", 12), |
|
command=self.skip_preparation, bg="#ff9800", fg="white") |
|
skip_button.pack(pady=10) |
|
|
|
def skip_preparation(self): |
|
"""準備時間をスキップする""" |
|
if messagebox.askyesno("確認", "準備時間をスキップしますか?"): |
|
self.is_preparing = False |
|
self.start_first_exercise() |
|
|
|
def stop_training(self): |
|
"""トレーニングを中止する""" |
|
if messagebox.askyesno("確認", "トレーニングを中止しますか?"): |
|
self.is_training = False |
|
if self.timer_thread is not None and self.timer_thread.is_alive(): |
|
# タイマースレッドは自動的に終了する |
|
pass |
|
self.setup_start_screen() |
|
|
|
def start_timer(self): |
|
"""タイマーを開始する""" |
|
if self.timer_thread is not None and self.timer_thread.is_alive(): |
|
return # すでに動作中なら何もしない |
|
|
|
self.timer_thread = threading.Thread(target=self.timer_loop) |
|
self.timer_thread.daemon = True |
|
self.timer_thread.start() |
|
|
|
def timer_loop(self): |
|
"""タイマーのメインループ""" |
|
try: |
|
while self.is_training and self.remaining_time > 0: |
|
try: |
|
# カウントダウン表示が必要か確認 |
|
if self.remaining_time <= 5 and not self.is_countdown: |
|
self.is_countdown = True |
|
self.countdown_played = False # カウントダウン開始時にフラグをリセット |
|
# UIの更新(この部分は安全のためafterを使用) |
|
self.root.after(0, self.update_status_display) |
|
|
|
# カウントダウン音を再生(5秒のタイミングで一度だけ再生) |
|
if self.remaining_time == 5 and not self.countdown_played: |
|
self.countdown_played = True # フラグを設定して再度再生されないようにする |
|
# カウントダウン音を直接再生 |
|
try: |
|
print(f"カウントダウン音を再生します... 残り時間: {self.remaining_time}秒") |
|
if os.path.exists(self.sound_countdown): |
|
result = sound_manager.play_sound(self.sound_countdown) |
|
if result: |
|
print(f"カウントダウン音の再生を開始しました") |
|
else: |
|
print(f"カウントダウン音の再生に失敗しました") |
|
else: |
|
print(f"カウントダウン音ファイルが見つかりません: {self.sound_countdown}") |
|
except Exception as e: |
|
print(f"カウントダウン音再生エラー: {e}") |
|
|
|
# ビープ音を鳴らす(カウントダウン中の各秒、残り5,4,3,2,1秒のときだけ) |
|
if self.remaining_time <= 5 and self.remaining_time >= 1: |
|
# ビープ音を直接再生 |
|
try: |
|
print(f"ビープ音を再生します... 残り時間: {self.remaining_time}秒") |
|
if os.path.exists(self.sound_beep): |
|
result = sound_manager.play_sound(self.sound_beep) |
|
if result: |
|
print(f"ビープ音の再生を開始しました") |
|
else: |
|
print(f"ビープ音の再生に失敗しました") |
|
else: |
|
print(f"ビープ音ファイルが見つかりません: {self.sound_beep}") |
|
except Exception as e: |
|
print(f"ビープ音再生エラー: {e}") |
|
|
|
# UIの更新はメインスレッドで行う |
|
self.root.after(0, self.update_timer_display) |
|
except Exception as e: |
|
print(f"タイマーループ内でエラーが発生しました: {e}") |
|
# エラーが発生しても続行 |
|
|
|
# 1秒待機して残り時間を減らす |
|
time.sleep(1) |
|
self.remaining_time -= 1 |
|
|
|
# タイマー終了時の処理(必ずメインスレッドで実行) |
|
if not self.is_training: |
|
return |
|
|
|
# 次のアクションの前にカウントダウン状態をリセット |
|
self.is_countdown = False |
|
self.countdown_played = False |
|
|
|
# このタイマーループ終了後の次のアクションを決定 |
|
if self.is_preparing: |
|
# 準備時間終了、最初のエクササイズへ |
|
self.root.after(0, self.start_first_exercise) |
|
elif self.is_rest: |
|
# 休憩終了、次のエクササイズへ |
|
self.root.after(0, self.after_rest_completed) |
|
else: |
|
# エクササイズ終了、休憩か完了へ |
|
if self.current_exercise_index < len(self.training_data) - 1: |
|
# まだエクササイズが残っている場合は休憩 |
|
self.root.after(0, self.start_rest) |
|
else: |
|
# すべてのエクササイズが終了 |
|
self.root.after(0, self.setup_complete_screen) |
|
except Exception as e: |
|
print(f"タイマーループで重大なエラーが発生しました: {e}") |
|
# 重大なエラーの場合は安全に終了 |
|
self.root.after(0, self.stop_training) |
|
|
|
def start_first_exercise(self): |
|
"""最初のエクササイズを開始する""" |
|
self.is_preparing = False |
|
self.is_countdown = False |
|
self.countdown_played = False |
|
|
|
# 選択された難易度に応じた時間を設定 |
|
difficulty = self.selected_difficulty.get() |
|
self.exercise_time = self.difficulty_times[difficulty] |
|
self.remaining_time = self.exercise_time |
|
|
|
# トレーニング画面に切り替え |
|
self.setup_training_screen() |
|
|
|
# 開始音を再生 |
|
try: |
|
sound_manager.play_sound(self.sound_start) |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
|
|
# 音声アナウンス - 名前と説明を読み上げる |
|
exercise_data = self.training_data[self.current_exercise_index] |
|
self.speak(f"{exercise_data['name']}を始めます。{exercise_data['description']}") |
|
|
|
# タイマー開始 |
|
self.start_timer() |
|
|
|
def after_rest_completed(self): |
|
"""休憩終了後の処理""" |
|
self.is_rest = False |
|
self.is_countdown = False |
|
self.countdown_played = False |
|
self.current_exercise_index += 1 |
|
|
|
if self.current_exercise_index >= len(self.training_data): |
|
# すべてのエクササイズが終了 |
|
self.setup_complete_screen() |
|
return |
|
|
|
# 次のエクササイズ時間をセット |
|
self.remaining_time = self.exercise_time |
|
self.setup_training_screen() |
|
|
|
# 次のエクササイズをアナウンス - 名前と説明を読み上げる |
|
exercise_data = self.training_data[self.current_exercise_index] |
|
self.speak(f"{exercise_data['name']}を始めます。{exercise_data['description']}") |
|
|
|
# 開始音を再生 |
|
try: |
|
sound_manager.play_sound(self.sound_start) |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
|
|
# 新しいタイマーを開始 |
|
self.start_timer() |
|
|
|
def start_rest(self): |
|
"""休憩時間開始""" |
|
self.is_rest = True |
|
self.is_countdown = False |
|
self.countdown_played = False |
|
self.remaining_time = 10 # 休憩時間は10秒固定 |
|
|
|
# 休憩画面に切り替え |
|
self.setup_rest_screen() |
|
|
|
# 休憩をアナウンス |
|
self.speak("休憩時間です") |
|
|
|
# 休憩音を再生 |
|
try: |
|
sound_manager.play_sound(self.sound_rest) |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
|
|
# 新しいタイマーを開始 |
|
self.start_timer() |
|
|
|
def setup_rest_screen(self): |
|
"""休憩画面のセットアップ""" |
|
# 既存のウィジェットをクリア |
|
for widget in self.root.winfo_children(): |
|
widget.destroy() |
|
|
|
# 休憩表示 |
|
title_label = tk.Label(self.root, text="休憩時間", font=("Helvetica", 24, "bold"), fg="blue") |
|
title_label.pack(pady=20) |
|
|
|
# 次のエクササイズ名を表示(最後のエクササイズでなければ) |
|
if self.current_exercise_index < len(self.training_data) - 1: |
|
next_exercise = self.training_data[self.current_exercise_index + 1] |
|
next_text = f"次は: {next_exercise['name']}" |
|
next_label = tk.Label(self.root, text=next_text, font=("Helvetica", 16)) |
|
next_label.pack(pady=10) |
|
|
|
# 休憩画像表示 |
|
try: |
|
# 休憩画像が存在するか確認 |
|
if os.path.exists(self.rest_image_path): |
|
img = Image.open(self.rest_image_path) |
|
img = img.resize((300, 200), Image.LANCZOS) |
|
photo = ImageTk.PhotoImage(img) |
|
img_label = tk.Label(self.root, image=photo) |
|
img_label.image = photo # 参照を保持 |
|
img_label.pack(pady=20) |
|
else: |
|
# 画像がない場合はテキストで代替 |
|
img_label = tk.Label(self.root, text="[休憩中]", |
|
font=("Helvetica", 16), width=40, height=10, |
|
relief=tk.RIDGE, bg="#e0f0ff") |
|
img_label.pack(pady=20) |
|
print(f"休憩画像ファイルが見つかりません: {self.rest_image_path}") |
|
except Exception as e: |
|
# エラー時はテキストで代替 |
|
img_label = tk.Label(self.root, text="[休憩中]", |
|
font=("Helvetica", 16), width=40, height=10, |
|
relief=tk.RIDGE, bg="#e0f0ff") |
|
img_label.pack(pady=20) |
|
print(f"休憩画像読み込みエラー: {e}") |
|
|
|
# 休憩メッセージ |
|
message = "深呼吸して、次のエクササイズに備えましょう" |
|
message_label = tk.Label(self.root, text=message, font=("Helvetica", 14)) |
|
message_label.pack(pady=20) |
|
|
|
# タイマーラベル |
|
self.timer_label = tk.Label(self.root, text=f"残り時間: {self.remaining_time}秒", font=("Helvetica", 16)) |
|
self.timer_label.pack(pady=20) |
|
|
|
# ステータスラベル |
|
self.status_label = tk.Label(self.root, text="休憩中", font=("Helvetica", 14), fg="blue") |
|
self.status_label.pack(pady=10) |
|
|
|
# 中止ボタン |
|
stop_button = tk.Button(self.root, text="中止", font=("Helvetica", 12), |
|
command=self.stop_training, bg="#f44336", fg="white") |
|
stop_button.pack(pady=20) |
|
|
|
# ステータス表示を更新 |
|
self.update_status_display() |
|
|
|
def update_timer_display(self): |
|
"""タイマー表示を更新""" |
|
if hasattr(self, 'timer_label'): |
|
self.timer_label.config(text=f"残り時間: {self.remaining_time}秒") |
|
|
|
def update_status_display(self): |
|
"""ステータス表示を更新""" |
|
if hasattr(self, 'status_label'): |
|
status_text = "カウントダウン中" if self.is_countdown else ("休憩中" if self.is_rest else "準備中" if self.is_preparing else "トレーニング中") |
|
color = "orange" if self.is_countdown else ("blue" if self.is_rest else "purple" if self.is_preparing else "green") |
|
self.status_label.config(text=status_text, fg=color) |
|
|
|
def speak(self, text): |
|
"""テキストを音声で読み上げる(VoiceVox使用)""" |
|
# 音声合成と再生のための別スレッドを起動 |
|
# 別スレッドでは先に音声ファイルを生成してからメインスレッドに通知する |
|
def _speak(): |
|
# VoiceVoxが利用可能か確認 |
|
if not voicevox_manager.check_api_available(): |
|
# テキスト表示のみ |
|
print(f"音声ガイド(テキストのみ): {text}") |
|
return |
|
|
|
# テキストとスピーカーIDからファイル名を生成(一意のIDとなるようにハッシュ化) |
|
import hashlib |
|
text_hash = hashlib.md5(f"{text}_{self.voicevox_speaker}".encode()).hexdigest() |
|
cached_filename = f"{voicevox_manager.output_dir}/cache_{text_hash}.wav" |
|
|
|
# キャッシュファイルが存在するか確認 |
|
if os.path.exists(cached_filename): |
|
print(f"キャッシュから音声を使用: {text}") |
|
# キャッシュを使用 |
|
self.root.after(10, lambda: self._play_speech(cached_filename, False)) |
|
return |
|
|
|
# キャッシュがない場合は新規に音声合成 |
|
print(f"新規に音声合成: {text}") |
|
wav_file = voicevox_manager.generate_speech( |
|
text, |
|
speaker_id=self.voicevox_speaker, |
|
volume=self.voicevox_volume |
|
) |
|
|
|
if wav_file: |
|
# 生成されたファイルをキャッシュとしてコピー |
|
try: |
|
import shutil |
|
shutil.copy2(wav_file, cached_filename) |
|
# 生成が完了したら、メインスレッドで再生を行う(UIをブロックしない) |
|
self.root.after(10, lambda: self._play_speech(wav_file, True)) |
|
except Exception as e: |
|
print(f"キャッシュ保存エラー: {e}") |
|
# エラー時は元のファイルを再生 |
|
self.root.after(10, lambda: self._play_speech(wav_file, True)) |
|
|
|
# 音声合成は時間がかかるので別スレッドで実行 |
|
thread = threading.Thread(target=_speak) |
|
thread.daemon = True |
|
thread.start() |
|
|
|
def _play_speech(self, wav_file, delete_after_play=True): |
|
"""生成済みの音声ファイルを再生する""" |
|
try: |
|
# pydubを使用して音量調整付きで再生 |
|
from pydub import AudioSegment |
|
from pydub.playback import play |
|
|
|
# 音声ファイルを読み込み、音量調整 |
|
sound = AudioSegment.from_wav(wav_file) |
|
sound = sound - (60 * (1.0 - self.voicevox_volume)) # 音量を調整(0.0 - 1.0の範囲) |
|
|
|
# 変数をキャプチャして内部関数のスコープ問題を回避 |
|
file_to_delete = wav_file if delete_after_play else None |
|
|
|
# 音声再生を別スレッドで実行してタイマーをブロックしないようにする |
|
def play_sound_thread(): |
|
try: |
|
# すでに読み込み済みのsoundオブジェクトを使用 |
|
play(sound) |
|
|
|
# 一時ファイルを削除(キャッシュの場合は削除しない) |
|
if file_to_delete: |
|
try: |
|
os.remove(file_to_delete) |
|
except Exception as e: |
|
print(f"ファイル削除エラー: {e}") |
|
except Exception as e: |
|
print(f"音声再生スレッドエラー: {e}") |
|
|
|
# 別スレッドで音声再生 |
|
play_thread = threading.Thread(target=play_sound_thread) |
|
play_thread.daemon = True |
|
play_thread.start() |
|
|
|
return True |
|
except Exception as e: |
|
print(f"音声再生エラー: {e}") |
|
return False |
|
|
|
def on_closing(self): |
|
"""アプリ終了時の処理""" |
|
if self.is_training: |
|
if not messagebox.askyesno("確認", "トレーニング中ですが、終了しますか?"): |
|
return |
|
|
|
# 設定を保存 |
|
self.config["voice_style"] = self.voicevox_speaker |
|
save_config(self.config) |
|
|
|
# VoiceVoxを終了 |
|
voicevox_manager.stop_voicevox() |
|
|
|
# アプリケーション終了 |
|
self.root.destroy() |
|
|
|
def main(): |
|
"""アプリケーションのメイン関数""" |
|
# アセットディレクトリの作成 |
|
os.makedirs(os.path.join(ASSETS_DIR, "sounds"), exist_ok=True) |
|
os.makedirs(os.path.join(ASSETS_DIR, "images"), exist_ok=True) |
|
os.makedirs(os.path.join(ASSETS_DIR, "tts_output"), exist_ok=True) |
|
|
|
# 起動メッセージと環境情報 |
|
print("=" * 60) |
|
print("ものぐさでも出来る寝ながら集中腹筋鍛錬") |
|
print("=" * 60) |
|
print(f"OS: {platform.system()} {platform.release()}") |
|
print(f"Python: {platform.python_version()}") |
|
print("-" * 60) |
|
|
|
# VoiceVoxの情報表示 |
|
print("【VoiceVox設定】") |
|
print("・自動検出: " + ("有効" if voicevox_manager.autostart else "無効")) |
|
print("・手動設定方法: 環境変数 VOICEVOX_PATH にVoiceVoxの実行ファイルパスを設定") |
|
print(" 例: set VOICEVOX_PATH=C:\\path\\to\\VOICEVOX.exe") |
|
if voicevox_manager.check_api_available(): |
|
print("✓ VoiceVoxサーバーは既に起動しています") |
|
else: |
|
voicevox_path = voicevox_manager.find_voicevox_path() |
|
if voicevox_path: |
|
print(f"✓ VoiceVoxの実行ファイルが見つかりました: {voicevox_path}") |
|
else: |
|
print("✗ VoiceVoxの実行ファイルが見つかりませんでした") |
|
print(" ・音声ガイドを使用するにはVoiceVoxをインストールしてください") |
|
print(" ・https://voicevox.hiroshiba.jp/ からダウンロード可能です") |
|
print("-" * 60) |
|
|
|
# 設定情報の表示 |
|
config = load_config() |
|
print(f"音声ボリューム: {int(config['voice_volume'] * 100)}%") |
|
print(f"効果音ボリューム: {int(config['sound_volume'] * 100)}%") |
|
print(f"音声スタイル: {config['voice_style']}") |
|
print("-" * 60) |
|
|
|
# アプリケーション起動 |
|
root = tk.Tk() |
|
app = TrainingApp(root) |
|
root.mainloop() |
|
|
|
if __name__ == "__main__": |
|
main() |
音声ガイダンスを使用するにはVoiceVoxが必要です