Шаг 4: Реализация вкладки «Аудио»
Цель шага: Установить библиотеки для работы со звуком, создать модуль core/audio_manager.py для сканирования и тестирования аудио-устройств, создать виджет ui/audio_tab.py с выпадающими списками микрофонов и динамиков, кнопками тестирования и сохранением выбора в config.json.
4.1. Установка системных и Python-зависимостей
⚠️ Перед выполнением этих команд включите то самое ПО из трех букв.
# 1. Устанавливаем системную библиотеку PortAudio # Она нужна для работы sounddevice на Linux/Raspberry Pi # Install PortAudio system library # It's required for sounddevice to work on Linux/Raspberry Pi sudo apt install libportaudio2 -y # 2. Активируем окружение (если ещё не активировано) # Activate environment (if not already activated) cd ~/Zahar && source venv/bin/activate # 3. Устанавливаем Python-библиотеки для работы со звуком # Install Python libraries for audio work # sounddevice - работа с микрофонами и динамиками / working with mics and speakers # numpy - обработка аудиоданных / audio data processing pip install sounddevice numpy
4.2. Создание модуля core/audio_manager.py
Это «мозг» работы с аудио. Модуль отвечает за:
Сканирование всех доступных микрофонов и динамиков
Определение устройств по умолчанию
Тестирование микрофона (запись 3 секунд + воспроизведение)
Тестирование динамика (воспроизведение тестового тона 440 Гц)
Все тесты запускаются в отдельных потоках, чтобы интерфейс не зависал.
Скрытый текст
cat > ~/Zahar/core/audio_manager.py << 'EOF' # ============================================================ # Модуль работы с аудио-устройствами # Audio devices management module # ============================================================ import sounddevice as sd import numpy as np import threading from typing import List, Tuple, Optional, Callable class AudioManager: def __init__(self): self.sample_rate = 16000 self.channels = 1 def get_input_devices(self) -> List[Tuple[int, str]]: devices = sd.query_devices() result = [] for i, dev in enumerate(devices): if dev['max_input_channels'] > 0: # Добавляем ID в название для удобства идентификации result.append((i, f"{dev['name']} (ID: {i})")) return result def get_output_devices(self) -> List[Tuple[int, str]]: devices = sd.query_devices() result = [] for i, dev in enumerate(devices): if dev['max_output_channels'] > 0: result.append((i, f"{dev['name']} (ID: {i})")) return result def get_default_input_id(self) -> Optional[int]: try: default_input = sd.default.device[0] if default_input is None or default_input < 0: return None return int(default_input) except Exception: return None def get_default_output_id(self) -> Optional[int]: try: default_output = sd.default.device[1] if default_output is None or default_output < 0: return None return int(default_output) except Exception: return None def refresh_audio_system(self): """ Принудительно перезагружает аудио-подсистему. Критически важно для Raspberry Pi, чтобы увидеть горяче-подключенные USB-устройства. """ try: sd.terminate() sd.initialize() except Exception: pass def test_microphone( self, input_device_id: int, output_device_id: int, duration: float = 3.0, on_status: Optional[Callable[[str], None]] = None ) -> threading.Thread: def _run_test(): try: if on_status: on_status("recording") # Явно указываем dtype и device audio_data = sd.rec( int(duration * self.sample_rate), samplerate=self.sample_rate, channels=self.channels, device=input_device_id, dtype='float32' ) sd.wait() if on_status: on_status("playing") # Воспроизведение с явными параметрами для избежания конфликтов sd.play( audio_data, samplerate=self.sample_rate, device=output_device_id, dtype='float32' ) sd.wait() if on_status: on_status("done") except Exception as e: if on_status: # Выводим точную ошибку для отладки on_status(f"error: {str(e)}") print(f"Audio Test Error: {e}") thread = threading.Thread(target=_run_test, daemon=True) thread.start() return thread def test_speaker( self, output_device_id: int, on_status: Optional[Callable[[str], None]] = None ) -> threading.Thread: def _run_test(): try: if on_status: on_status("playing") duration = 1.0 t = np.linspace(0, duration, int(self.sample_rate * duration), False) tone = 0.3 * np.sin(2 * np.pi * 440 * t) sd.play( tone.astype(np.float32), samplerate=self.sample_rate, device=output_device_id ) sd.wait() if on_status: on_status("done") except Exception as e: if on_status: on_status(f"error: {str(e)}") print(f"Speaker Test Error: {e}") thread = threading.Thread(target=_run_test, daemon=True) thread.start() return thread EOF
4.3. Создание вкладки ui/audio_tab.py
Это виджет, который отображает выпадающие списки устройств, кнопки тестирования и сохраняет выбор пользователя.
Скрытый текст
cat > ~/Zahar/ui/audio_tab.py << 'EOF' # ============================================================ # Вкладка настройки аудио-устройств # Audio devices settings tab # ============================================================ import customtkinter as ctk from typing import Dict, List, Tuple, Callable from core.audio_manager import AudioManager class AudioTab(ctk.CTkFrame): def __init__( self, parent, translations: Dict[str, Dict[str, str]], get_lang: Callable[[], str], config: Dict, save_config: Callable[[Dict], None] ): super().__init__(parent, fg_color="transparent") self.audio_manager = AudioManager() self.translations = translations self.get_lang = get_lang self.config = config self.save_config = save_config self.input_devices: List[Tuple[int, str]] = [] self.output_devices: List[Tuple[int, str]] = [] # ГЛАВНОЕ ИСПРАВЛЕНИЕ 4: Прокручиваемый фрейм для всего содержимого self.scroll_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") self.scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) self._build_ui() self.refresh_devices() def _t(self, key: str, default: str = "") -> str: lang = self.get_lang() return self.translations.get(lang, {}).get(key, default) def _build_ui(self): # ЗАГОЛОВОК self.title_label = ctk.CTkLabel( self.scroll_frame, text=self._t("audio_header", "Аудио устройства"), font=ctk.CTkFont(size=24, weight="bold") ) self.title_label.pack(pady=(10, 5), padx=20, anchor="w") self.subtitle_label = ctk.CTkLabel( self.scroll_frame, text=self._t("audio_subtitle", "Выберите микрофон и динамик"), font=ctk.CTkFont(size=14), text_color="gray" ) self.subtitle_label.pack(pady=(0, 20), padx=20, anchor="w") # СЕКЦИЯ МИКРОФОНА self.mic_frame = ctk.CTkFrame(self.scroll_frame, fg_color=("gray85", "gray20")) self.mic_frame.pack(fill="x", padx=20, pady=10) self.mic_label = ctk.CTkLabel( self.mic_frame, text=self._t("microphone", "Микрофон (вход)"), font=ctk.CTkFont(size=16, weight="bold") ) self.mic_label.pack(pady=(15, 10), padx=15, anchor="w") # ГЛАВНОЕ ИСПРАВЛЕНИЕ 1: Увеличенный размер для тачскрина self.mic_menu = ctk.CTkOptionMenu( self.mic_frame, values=["..."], width=600, height=50, # Увеличенная высота для удобного нажатия font=ctk.CTkFont(size=16) # Крупный шрифт ) self.mic_menu.pack(pady=5, padx=15, fill="x") self.mic_test_btn = ctk.CTkButton( self.mic_frame, text=self._t("test_microphone", "Тест микрофона (3 сек)"), command=self._on_test_mic, height=45, font=ctk.CTkFont(size=15), fg_color=("gray60", "gray40") ) self.mic_test_btn.pack(pady=(10, 15), padx=15, fill="x") # СЕКЦИЯ ДИНАМИКА self.speaker_frame = ctk.CTkFrame(self.scroll_frame, fg_color=("gray85", "gray20")) self.speaker_frame.pack(fill="x", padx=20, pady=10) self.speaker_label = ctk.CTkLabel( self.speaker_frame, text=self._t("speaker", "Динамик (выход)"), font=ctk.CTkFont(size=16, weight="bold") ) self.speaker_label.pack(pady=(15, 10), padx=15, anchor="w") # ГЛАВНОЕ ИСПРАВЛЕНИЕ 1: Увеличенный размер для тачскрина self.speaker_menu = ctk.CTkOptionMenu( self.speaker_frame, values=["..."], width=600, height=50, font=ctk.CTkFont(size=16) ) self.speaker_menu.pack(pady=5, padx=15, fill="x") self.speaker_test_btn = ctk.CTkButton( self.speaker_frame, text=self._t("test_speaker", "Тест динамика (тон 440 Гц)"), command=self._on_test_speaker, height=45, font=ctk.CTkFont(size=15), fg_color=("gray60", "gray40") ) self.speaker_test_btn.pack(pady=(10, 15), padx=15, fill="x") # СТАТУС self.status_label = ctk.CTkLabel( self.scroll_frame, text="", font=ctk.CTkFont(size=15, weight="bold"), text_color="gray" ) self.status_label.pack(pady=15, padx=20) # КНОПКИ ДЕЙСТВИЙ self.buttons_frame = ctk.CTkFrame(self.scroll_frame, fg_color="transparent") self.buttons_frame.pack(fill="x", padx=20, pady=(10, 30)) # Увеличен нижний отступ self.refresh_btn = ctk.CTkButton( self.buttons_frame, text=self._t("refresh_devices", "Обновить список"), command=self.refresh_devices, height=45, font=ctk.CTkFont(size=15), fg_color=("gray60", "gray40"), width=200 ) self.refresh_btn.pack(side="left", padx=5) self.save_btn = ctk.CTkButton( self.buttons_frame, text=self._t("save", "Сохранить"), command=self._on_save, height=45, font=ctk.CTkFont(size=15), width=200 ) self.save_btn.pack(side="right", padx=5) def refresh_devices(self): # ГЛАВНОЕ ИСПРАВЛЕНИЕ 2: Принудительный опрос оборудования self.audio_manager.refresh_audio_system() self.input_devices = self.audio_manager.get_input_devices() self.output_devices = self.audio_manager.get_output_devices() input_names = [name for _, name in self.input_devices] output_names = [name for _, name in self.output_devices] if not input_names: input_names = [self._t("no_devices", "Устройства не найдены")] if not output_names: output_names = [self._t("no_devices", "Устройства не найдены")] self.mic_menu.configure(values=input_names) self.speaker_menu.configure(values=output_names) saved_input_id = self.config.get("audio_input_id") saved_output_id = self.config.get("audio_output_id") # Логика выбора сохраненного или устройства по умолчанию if saved_input_id is not None: saved_name = next((name for dev_id, name in self.input_devices if dev_id == saved_input_id), None) self.mic_menu.set(saved_name if saved_name else input_names[0]) else: default_id = self.audio_manager.get_default_input_id() default_name = next((name for dev_id, name in self.input_devices if dev_id == default_id), None) self.mic_menu.set(default_name if default_name else input_names[0]) if saved_output_id is not None: saved_name = next((name for dev_id, name in self.output_devices if dev_id == saved_output_id), None) self.speaker_menu.set(saved_name if saved_name else output_names[0]) else: default_id = self.audio_manager.get_default_output_id() default_name = next((name for dev_id, name in self.output_devices if dev_id == default_id), None) self.speaker_menu.set(default_name if default_name else output_names[0]) self._set_status(self._t("devices_loaded", "Устройства загружены"), "gray") def _get_selected_input_id(self) -> int: current_name = self.mic_menu.get() for dev_id, name in self.input_devices: if name == current_name: return dev_id return self.input_devices[0][0] if self.input_devices else 0 def _get_selected_output_id(self) -> int: current_name = self.speaker_menu.get() for dev_id, name in self.output_devices: if name == current_name: return dev_id return self.output_devices[0][0] if self.output_devices else 0 def _on_test_mic(self): input_id = self._get_selected_input_id() output_id = self._get_selected_output_id() self.mic_test_btn.configure(state="disabled") def on_status(status: str): if status == "recording": self._set_status(self._t("status_recording", "Запись 3 сек... Говорите"), "orange") elif status == "playing": self._set_status(self._t("status_playing", "Воспроизведение..."), "orange") elif status == "done": self._set_status(self._t("status_mic_ok", "Микрофон работает!"), "green") self.after(0, lambda: self.mic_test_btn.configure(state="normal")) elif status.startswith("error"): self._set_status(f"{self._t('status_error', 'Ошибка')}: {status}", "red") self.after(0, lambda: self.mic_test_btn.configure(state="normal")) self.audio_manager.test_microphone( input_device_id=input_id, output_device_id=output_id, on_status=on_status ) def _on_test_speaker(self): output_id = self._get_selected_output_id() self.speaker_test_btn.configure(state="disabled") def on_status(status: str): if status == "playing": self._set_status(self._t("status_tone", "Воспроизведение тона..."), "orange") elif status == "done": self._set_status(self._t("status_speaker_ok", "Динамик работает!"), "green") self.after(0, lambda: self.speaker_test_btn.configure(state="normal")) elif status.startswith("error"): self._set_status(f"{self._t('status_error', 'Ошибка')}: {status}", "red") self.after(0, lambda: self.speaker_test_btn.configure(state="normal")) self.audio_manager.test_speaker( output_device_id=output_id, on_status=on_status ) def _on_save(self): input_id = self._get_selected_input_id() output_id = self._get_selected_output_id() self.config["audio_input_id"] = input_id self.config["audio_output_id"] = output_id self.config["audio_input_name"] = self.mic_menu.get() self.config["audio_output_name"] = self.speaker_menu.get() self.save_config(self.config) self._set_status(self._t("settings_saved", "Настройки сохранены!"), "green") def _set_status(self, text: str, color: str): def _update(): self.status_label.configure(text=text, text_color=color) self.after(0, _update) def update_language(self): self.title_label.configure(text=self._t("audio_header", "Аудио устройства")) self.subtitle_label.configure(text=self._t("audio_subtitle", "Выберите микрофон и динамик")) self.mic_label.configure(text=self._t("microphone", "Микрофон (вход)")) self.speaker_label.configure(text=self._t("speaker", "Динамик (выход)")) self.mic_test_btn.configure(text=self._t("test_microphone", "Тест микрофона (3 сек)")) self.speaker_test_btn.configure(text=self._t("test_speaker", "Тест динамика (тон 440 Гц)")) self.refresh_btn.configure(text=self._t("refresh_devices", "Обновить список")) self.save_btn.configure(text=self._t("save", "Сохранить")) EOF
Как проверить успех Шага 4
Убедитесь, что зависимости установлены
cd ~/Zahar && source venv/bin/activate pip list | grep -E "sounddevice|numpy"
Должны увидеть sounddevice и numpy в списке
Проверьте создание файлов
ls -l ~/Zahar/core/audio_manager.py ~/Zahar/ui/audio_tab.py
Оба файла должны существовать.
4.3. Подключение вкладки «Аудио» в main.py
Просто выполните эту команду, и main.py полностью перезапишется с поддержкой новой вкладки:
Скрытый текст
cat > ~/Zahar/main.py << 'EOF' # ============================================================ # Главный файл приложения ZAHAR # Main application file ZAHAR # ============================================================ # Локальный ассистент с модульной архитектурой. # Local assistant with modular architecture. # Импортируем библиотеки / Import libraries import customtkinter as ctk import os import sys import atexit # Добавляем корень проекта в путь для импорта / Add project root to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from core.password_manager import PasswordManager from ui.dashboard_tab import DashboardTab from ui.audio_tab import AudioTab # ========================================== # НАСТРОЙКИ ТЕМЫ / APPEARANCE SETTINGS # ========================================== ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") # ========================================== # СИСТЕМА ЛОКАЛИЗАЦИИ / LOCALIZATION SYSTEM # ========================================== TRANSLATIONS = { "ru": { "window_title": "ZAHAR - Локальный ассистент", "sidebar_title": "ZAHAR", "lang_btn_enter": "Ввести пароль", "lang_btn_clear": "Очистить кэш", "pwd_ok": "[OK] Пароль закэширован", "pwd_req": "[!] Требуется ввод", "timer_prefix": "Кэш:", # === Кнопки меню / Menu buttons === "btn_dashboard": "Дашборд", "btn_audio": "Аудио", "btn_clone": "Клонирование и Диски", "btn_display": "Дисплей", "btn_stt": "Распознавание (STT)", "btn_tts": "Синтез речи (TTS)", "btn_llm": "LLM Модель", "btn_structure": "Структура", "btn_ha": "Home Assistant", # === Заглушки контента / Content placeholders === "content_clone": "Управление дисками и клонирование\n(Монтирование, Форматирование, Клон)", "content_display": "Настройки Дисплея\n(Экранная клавиатура, Яркость)", "content_stt": "Выбор и тестирование модели распознавания речи\n(Whisper / Vosk)", "content_tts": "Выбор и тестирование голоса\n(Piper Voices)", "content_llm": "Выбор локальной языковой модели\n(Ollama / Llama.cpp)", "content_structure": "Структура проекта и исходный код", "content_ha": "Интеграция Home Assistant\n(Опционально)", # === Ключи для вкладки Дашборд / Keys for Dashboard tab === "dashboard_header": "Системные показатели", "dashboard_subtitle": "Мониторинг в реальном времени", "cpu_temp": "Температура CPU", "ram": "Оперативная память", "disk": "Диск (/)", "network": "Сеть", "uptime": "Время работы", "status": "Статус", # === Ключи для вкладки Аудио / Keys for Audio tab === "audio_header": "Аудио устройства", "audio_subtitle": "Выберите микрофон и динамик для ассистента", "microphone": "Микрофон (вход)", "speaker": "Динамик (выход)", "test_microphone": "Тест микрофона (3 сек)", "test_speaker": "Тест динамика (тон 440 Гц)", "refresh_devices": "Обновить список", "save": "Сохранить", "no_devices": "Устройства не найдены", "devices_loaded": "Устройства загружены", "status_recording": "Запись 3 секунды... Говорите в микрофон", "status_playing": "Воспроизведение записи...", "status_mic_ok": "Микрофон работает! Вы должны были услышать свою запись.", "status_tone": "Воспроизведение тестового тона...", "status_speaker_ok": "Динамик работает! Вы должны были услышать тон.", "status_error": "Ошибка", "settings_saved": "Настройки сохранены!" }, "en": { "window_title": "ZAHAR - Local Assistant", "sidebar_title": "ZAHAR", "lang_btn_enter": "Enter Password", "lang_btn_clear": "Clear Cache", "pwd_ok": "[OK] Password cached", "pwd_req": "[!] Input required", "timer_prefix": "Cache:", "btn_dashboard": "Dashboard", "btn_audio": "Audio", "btn_clone": "Clone & Disks", "btn_display": "Display", "btn_stt": "Speech Recognition (STT)", "btn_tts": "Speech Synthesis (TTS)", "btn_llm": "LLM Model", "btn_structure": "Structure", "btn_ha": "Home Assistant", "content_clone": "Disk Management and Cloning\n(Mount, Format, Clone)", "content_display": "Display Settings\n(On-screen keyboard, Brightness)", "content_stt": "Speech recognition model selection and test\n(Whisper / Vosk)", "content_tts": "Voice selection and test\n(Piper Voices)", "content_llm": "Local language model selection\n(Ollama / Llama.cpp)", "content_structure": "Project structure and source code", "content_ha": "Home Assistant Integration\n(Optional)", # === Keys for Dashboard tab === "dashboard_header": "System Metrics", "dashboard_subtitle": "Real-time monitoring", "cpu_temp": "CPU Temperature", "ram": "Random Access Memory", "disk": "Disk (/)", "network": "Network", "uptime": "Uptime", "status": "Status", # === Keys for Audio tab === "audio_header": "Audio Devices", "audio_subtitle": "Select microphone and speaker for the assistant", "microphone": "Microphone (input)", "speaker": "Speaker (output)", "test_microphone": "Test microphone (3 sec)", "test_speaker": "Test speaker (440 Hz tone)", "refresh_devices": "Refresh list", "save": "Save", "no_devices": "No devices found", "devices_loaded": "Devices loaded", "status_recording": "Recording 3 seconds... Speak into microphone", "status_playing": "Playing back recording...", "status_mic_ok": "Microphone works! You should have heard your recording.", "status_tone": "Playing test tone...", "status_speaker_ok": "Speaker works! You should have heard the tone.", "status_error": "Error", "settings_saved": "Settings saved!" } } CONFIG_FILE = os.path.join(os.path.dirname(__file__), "config.json") def load_config(): """Загружает конфигурацию из файла / Loads configuration from file""" import json if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception: return {} return {} def save_config(config): """Сохраняет конфигурацию в файл / Saves configuration to file""" import json try: with open(CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(config, f, ensure_ascii=False, indent=2) except Exception as e: print(f"Config save error: {e}") class ZaharApp(ctk.CTk): """ Главный класс приложения ZAHAR. Main class of ZAHAR application. """ def __init__(self): super().__init__() # Загрузка конфигурации и инициализация менеджера паролей # Load config and initialize password manager self.config = load_config() self.current_lang = self.config.get("language", "ru") self.pwd_manager = PasswordManager(self) # Гарантированная очистка кэша при аварийном завершении (Ctrl+C в терминале) # Guaranteed cache clear on abrupt termination (Ctrl+C in terminal) atexit.register(self.pwd_manager.clear) # ========================================== # БАЗОВЫЕ НАСТРОЙКИ ОКНА / BASIC WINDOW SETTINGS # ========================================== self.title(TRANSLATIONS[self.current_lang]["window_title"]) self.geometry("950x650") self.minsize(850, 550) # Сетка: 2 строки (верхняя панель и контент), 2 колонки (сайдбар и контент) # Grid: 2 rows (top panel and content), 2 columns (sidebar and content) self.grid_rowconfigure(0, weight=0) # Верхняя панель фиксирована / Top panel is fixed self.grid_rowconfigure(1, weight=1) # Контент растягивается / Content stretches self.grid_columnconfigure(0, weight=0) # Сайдбар фиксирован / Sidebar is fixed self.grid_columnconfigure(1, weight=1) # Контент растягивается / Content stretches # ========================================== # ВЕРХНЯЯ ПАНЕЛЬ (HEADER) / TOP PANEL (HEADER) # ========================================== self.header_frame = ctk.CTkFrame( self, height=50, corner_radius=0, fg_color=("gray85", "gray15") ) self.header_frame.grid(row=0, column=0, columnspan=2, sticky="ew") # 1. Переключатель языка / Language switcher ctk.CTkLabel( self.header_frame, text="Lang:", font=ctk.CTkFont(size=12) ).pack(side="left", padx=(15, 5), pady=10) self.lang_switcher = ctk.CTkSegmentedButton( self.header_frame, values=["RU", "EN"], command=self._switch_lang, font=ctk.CTkFont(size=11), width=80 ) self.lang_switcher.set(self.current_lang.upper()) self.lang_switcher.pack(side="left", padx=5, pady=10) ctk.CTkLabel( self.header_frame, text="|", font=ctk.CTkFont(size=12), text_color="gray" ).pack(side="left", padx=10, pady=10) # 2. Кнопка ввода пароля / Enter password button self.btn_enter_pwd = ctk.CTkButton( self.header_frame, text=TRANSLATIONS[self.current_lang]["lang_btn_enter"], command=self._enter_pwd, width=130, height=30, font=ctk.CTkFont(size=12) ) self.btn_enter_pwd.pack(side="left", padx=5, pady=10) # 3. Кнопка очистки кэша / Clear cache button self.btn_clear_pwd = ctk.CTkButton( self.header_frame, text=TRANSLATIONS[self.current_lang]["lang_btn_clear"], command=self._clear_pwd, width=130, height=30, font=ctk.CTkFont(size=12), state="disabled", fg_color="gray" ) self.btn_clear_pwd.pack(side="left", padx=5, pady=10) # 4. ТАЙМЕР ОБРАТНОГО ОТСЧЕТА (ВСЕГДА ВИДЕН) / COUNTDOWN TIMER (ALWAYS VISIBLE) self.lbl_timer = ctk.CTkLabel( self.header_frame, text=f"{TRANSLATIONS[self.current_lang]['timer_prefix']} 00:00", font=ctk.CTkFont(size=12, weight="bold"), text_color="gray" ) self.lbl_timer.pack(side="left", padx=20, pady=10) # 5. Статус пароля (справа) / Password status (right) self.lbl_pwd_status = ctk.CTkLabel( self.header_frame, text="", font=ctk.CTkFont(size=12, weight="bold") ) self.lbl_pwd_status.pack(side="right", padx=20, pady=10) # Инициализация статусов / Initialize statuses self._update_pwd_status() self._update_timer_loop() # Запуск цикла таймера / Start timer loop # ========================================== # БОКОВАЯ ПАНЕЛЬ / SIDEBAR # ========================================== self.sidebar_frame = ctk.CTkFrame( self, width=220, corner_radius=0, fg_color=("gray90", "gray10") ) self.sidebar_frame.grid(row=1, column=0, sticky="nsew") self.sidebar_frame.grid_columnconfigure(0, weight=1) # Логотип / Logo self.logo_label = ctk.CTkLabel( self.sidebar_frame, text=TRANSLATIONS[self.current_lang]["sidebar_title"], font=ctk.CTkFont(size=26, weight="bold") ) self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) # ПРОКРУЧИВАЕМЫЙ КОНТЕЙНЕР ДЛЯ КНОПОК / SCROLLABLE FRAME FOR BUTTONS self.scrollable_frame = ctk.CTkScrollableFrame( self.sidebar_frame, width=190, fg_color="transparent" ) self.scrollable_frame.grid(row=1, column=0, sticky="nsew", padx=10, pady=10) self.sidebar_frame.grid_rowconfigure(1, weight=1) self.scrollable_frame.grid_columnconfigure(0, weight=1) # Список кнопок навигации / Navigation buttons list nav_items = [ ("btn_dashboard", 0, self.show_dashboard), ("btn_audio", 1, self.show_audio), ("btn_clone", 2, self.show_clone), ("btn_display", 3, self.show_display), ("btn_stt", 4, self.show_stt), ("btn_tts", 5, self.show_tts), ("btn_llm", 6, self.show_llm), ("btn_structure", 7, self.show_structure), ("btn_ha", 8, self.show_ha) ] self.nav_btns = {} for key, row, cmd in nav_items: is_optional = (key == "btn_ha") btn = ctk.CTkButton( self.scrollable_frame, text=TRANSLATIONS[self.current_lang][key], command=cmd, height=38, width=170, font=ctk.CTkFont(size=13), fg_color=("gray60", "gray30") if is_optional else None, hover_color=("gray50", "gray40") if is_optional else None ) btn.grid(row=row, column=0, pady=3, padx=5, sticky="ew") self.nav_btns[key] = btn # ========================================== # ОСНОВНАЯ ОБЛАСТЬ КОНТЕНТА / MAIN CONTENT AREA # ========================================== self.content_frame = ctk.CTkFrame( self, corner_radius=0, fg_color="transparent" ) self.content_frame.grid(row=1, column=1, sticky="nsew") # ========================================== # СОЗДАНИЕ ВКЛАДОК / CREATING TABS # ========================================== # Вкладка дашборда (создается один раз, потом просто показывается/скрывается) # Dashboard tab (created once, then just shown/hidden) self.dashboard_tab = DashboardTab( parent=self.content_frame, translations=TRANSLATIONS, get_lang=lambda: self.current_lang ) # Вкладка аудио (создается один раз) # Audio tab (created once) # Передаем config и save_config для сохранения выбора устройств # Pass config and save_config to save device selection self.audio_tab = AudioTab( parent=self.content_frame, translations=TRANSLATIONS, get_lang=lambda: self.current_lang, config=self.config, save_config=save_config ) # Обработчик закрытия окна для очистки кэша # Window close handler to clear cache self.protocol("WM_DELETE_WINDOW", self.on_close) # Показываем начальную страницу / Show initial page self.current_tab = "dashboard" self.show_dashboard() # ========================================== # УПРАВЛЕНИЕ ПАРОЛЯМИ И ТАЙМЕРОМ / PASSWORD & TIMER MANAGEMENT # ========================================== def _update_timer_loop(self): """Цикл обновления таймера каждую секунду / Timer update loop every second""" remaining = self.pwd_manager.get_remaining_time() prefix = TRANSLATIONS[self.current_lang]['timer_prefix'] if remaining > 0: mins = remaining // 60 secs = remaining % 60 time_str = f"{mins:02d}:{secs:02d}" self.lbl_timer.configure( text=f"{prefix} {time_str}", text_color="#4caf50" # Зеленый при активном кэше / Green when cached ) # Планируем следующий вызов через 1 секунду # Schedule next call in 1 second self.after(1000, self._update_timer_loop) else: # Время вышло или кэш не активен. Показываем 00:00 серым цветом. # Time is up or cache inactive. Show 00:00 in gray. self.lbl_timer.configure(text=f"{prefix} 00:00", text_color="gray") self._update_pwd_status() # Продолжаем цикл, чтобы отслеживать возможный новый ввод пароля # Continue loop to track possible new password entry self.after(1000, self._update_timer_loop) def _update_pwd_status(self): """Обновляет текст и цвет статуса пароля / Updates password status text and color""" self.pwd_manager._check_cache() if self.pwd_manager.is_cached: txt = TRANSLATIONS[self.current_lang]["pwd_ok"] col = "#4caf50" self.btn_clear_pwd.configure(state="normal", fg_color=("#3a7ebf", "#1f538d")) else: txt = TRANSLATIONS[self.current_lang]["pwd_req"] col = "#ff9800" self.btn_clear_pwd.configure(state="disabled", fg_color="gray") self.lbl_pwd_status.configure(text=txt, text_color=col) def _enter_pwd(self): """Вызывает диалог ввода пароля / Calls password input dialog""" self.pwd_manager.ensure_password(on_success=self._update_pwd_status) def _clear_pwd(self): """Очищает кэш и обновляет UI / Clears cache and updates UI""" self.pwd_manager.clear() self._update_pwd_status() def on_close(self): """Обработчик закрытия приложения / Application close handler""" # КРИТИЧЕСКИ ВАЖНО: Очищаем кэш sudo при любом закрытии # CRITICAL: Clear sudo cache on any close self.pwd_manager.clear() self.destroy() # ========================================== # УПРАВЛЕНИЕ ИНТЕРФЕЙСОМ / INTERFACE MANAGEMENT # ========================================== def _switch_lang(self, lang_code): """Переключает язык интерфейса / Switches interface language""" new_lang = "ru" if lang_code == "RU" else "en" if new_lang != self.current_lang: self.current_lang = new_lang self.config["language"] = new_lang save_config(self.config) # Обновляем заголовок и кнопки / Update title and buttons self.title(TRANSLATIONS[self.current_lang]["window_title"]) self.logo_label.configure(text=TRANSLATIONS[self.current_lang]["sidebar_title"]) self.btn_enter_pwd.configure(text=TRANSLATIONS[self.current_lang]["lang_btn_enter"]) self.btn_clear_pwd.configure(text=TRANSLATIONS[self.current_lang]["lang_btn_clear"]) for key, btn in self.nav_btns.items(): btn.configure(text=TRANSLATIONS[self.current_lang][key]) # Обновляем статус пароля (текст изменится) # Update password status (text will change) self._update_pwd_status() # Обновляем язык на всех вкладках # Update language on all tabs self.dashboard_tab.update_language() self.audio_tab.update_language() # Перерисовываем текущую вкладку # Redraw current tab getattr(self, f"show_{self.current_tab}")() def _hide_all_tabs(self): """ Скрывает все вкладки перед показом новой. Hides all tabs before showing new one. """ # Скрываем готовые вкладки (не удаляем, так как они созданы заранее) # Hide ready tabs (don't destroy, as they are created in advance) self.dashboard_tab.pack_forget() self.audio_tab.pack_forget() # Удаляем временные заглушки для других вкладок # Remove temporary placeholders for other tabs for widget in self.content_frame.winfo_children(): if widget not in (self.dashboard_tab, self.audio_tab): widget.destroy() def _show_placeholder(self, tab_name, content_key): """Показывает заглушку для вкладки / Shows placeholder for tab""" self.current_tab = tab_name self._hide_all_tabs() lbl = ctk.CTkLabel( self.content_frame, text=TRANSLATIONS[self.current_lang][content_key], font=ctk.CTkFont(size=20), justify="center" ) lbl.pack(expand=True) def show_dashboard(self): """Показывает вкладку дашборда. / Shows dashboard tab.""" self.current_tab = "dashboard" self._hide_all_tabs() self.dashboard_tab.pack(fill="both", expand=True, padx=0, pady=0) def show_audio(self): """Показывает вкладку аудио. / Shows audio tab.""" self.current_tab = "audio" self._hide_all_tabs() self.audio_tab.pack(fill="both", expand=True, padx=0, pady=0) def show_clone(self): self._show_placeholder("clone", "content_clone") def show_display(self): self._show_placeholder("display", "content_display") def show_stt(self): self._show_placeholder("stt", "content_stt") def show_tts(self): self._show_placeholder("tts", "content_tts") def show_llm(self): self._show_placeholder("llm", "content_llm") def show_structure(self): self._show_placeholder("structure", "content_structure") def show_ha(self): self._show_placeholder("ha", "content_ha") # ========================================== # ТОЧКА ВХОТА В ПРИЛОЖЕНИЕ / APPLICATION ENTRY POINT # ========================================== if __name__ == "__main__": app = ZaharApp() app.mainloop() EOF
Как проверить успех Шага 4
Запустите приложение
zaharrun
Проверьте вкладку «Аудио» / Check the "Audio" tab:
Кликните на кнопку «Аудио» в сайдбаре
-
Должны увидеть:
Заголовок «Аудио устройства» / Header "Аудио устройства"
Секцию «Микрофон (вход)» с выпадающим списком
Кнопку «Тест микрофона (3 сек)»
Секцию «Динамик (выход)» с выпадающим списком
Кнопку «Тест динамика (тон 440 Гц)»
Кнопки «Обновить список» и «Сохранить»