Шаг 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 Pisudo 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 processingpip install sounddevice numpy
4.2. Создание модуля core/audio_manager.py
Это «мозг» работы с аудио. Модуль отвечает за:
-
Сканирование всех доступных микрофонов и динамиков
-
Определение устройств по умолчанию
-
Тестирование микрофона (запись 3 секунд + воспроизведение)
-
Тестирование динамика (воспроизведение тестового тона 440 Гц)
Все тесты запускаются в отдельных потоках, чтобы интерфейс не зависал.
Скрытый текст
cat > ~/Zahar/core/audio_manager.py << 'EOF'# ============================================================# Модуль работы с аудио-устройствами# Audio devices management module# ============================================================# Отвечает за сканирование устройств, запись и воспроизведение звука.# Responsible for device scanning, recording and playing sound.import sounddevice as sdimport numpy as npimport threadingfrom typing import List, Tuple, Optional, Callableclass AudioManager: """ Менеджер аудио-устройств. Audio devices manager. Предоставляет методы для работы с микрофонами и динамиками. Provides methods for working with microphones and speakers. """ def __init__(self): # Частота дискретизации (16kHz достаточно для речи) # Sample rate (16kHz is enough for speech) self.sample_rate = 16000 # Количество каналов (1 = моно) # Number of channels (1 = mono) self.channels = 1 def get_input_devices(self) -> List[Tuple[int, str]]: """ Возвращает список устройств ввода (микрофонов). Returns list of input devices (microphones). Формат / Format: [(id, "Название устройства"), ...] """ devices = sd.query_devices() result = [] for i, dev in enumerate(devices): # Фильтруем только устройства с входными каналами # Filter only devices with input channels if dev['max_input_channels'] > 0: result.append((i, dev['name'])) return result def get_output_devices(self) -> List[Tuple[int, str]]: """ Возвращает список устройств вывода (динамиков). Returns list of output devices (speakers). Формат / Format: [(id, "Название устройства"), ...] """ devices = sd.query_devices() result = [] for i, dev in enumerate(devices): # Фильтруем только устройства с выходными каналами # Filter only devices with output channels if dev['max_output_channels'] > 0: result.append((i, dev['name'])) return result def get_default_input_id(self) -> Optional[int]: """ Возвращает ID устройства ввода по умолчанию. Returns default input device ID. """ try: # sd.default.device возвращает кортеж (input_id, output_id) # sd.default.device returns tuple (input_id, output_id) default_input = sd.default.device[0] # Если система не определила устройство, возвращаем None # If system didn't detect device, return None 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]: """ Возвращает ID устройства вывода по умолчанию. Returns default output device ID. """ 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 test_microphone( self, input_device_id: int, output_device_id: int, duration: float = 3.0, on_status: Optional[Callable[[str], None]] = None ) -> threading.Thread: """ Тестирует микрофон: записывает 3 секунды звука и воспроизводит его. Tests microphone: records 3 seconds of sound and plays it back. Параметры / Parameters: input_device_id - ID микрофона / microphone ID output_device_id - ID динамика для воспроизведения / speaker ID for playback duration - длительность записи в секундах / recording duration in seconds on_status - функция обратного вызова для статусов / callback for statuses Статусы / Statuses: "recording", "playing", "done", "error: ..." Возвращает / Returns: threading.Thread - поток с тестом / thread with test """ def _run_test(): try: # Статус: запись # Status: recording if on_status: on_status("recording") # Записываем звук с выбранного микрофона # Record sound from selected microphone audio_data = sd.rec( int(duration * self.sample_rate), samplerate=self.sample_rate, channels=self.channels, device=input_device_id, dtype='float32' ) # Ждем окончания записи # Wait for recording to finish sd.wait() # Статус: воспроизведение # Status: playing if on_status: on_status("playing") # Воспроизводим записанное через выбранный динамик # Play back recorded audio through selected speaker sd.play( audio_data, samplerate=self.sample_rate, device=output_device_id ) sd.wait() # Статус: готово # Status: done if on_status: on_status("done") except Exception as e: # Статус: ошибка # Status: error if on_status: on_status(f"error: {e}") # Запускаем тест в отдельном потоке, чтобы не блокировать GUI # Run test in separate thread to not block GUI # daemon=True - поток завершится вместе с программой # daemon=True - thread will terminate with the program 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: """ Тестирует динамик: воспроизводит тестовый тон (440 Гц, 1 секунда). Tests speaker: plays a test tone (440 Hz, 1 second). Параметры / Parameters: output_device_id - ID динамика / speaker ID on_status - функция обратного вызова для статусов / callback for statuses """ def _run_test(): try: if on_status: on_status("playing") # Генерируем тестовый тон: синусоида 440 Гц (нота "Ля") # Generate test tone: 440 Hz sine wave (note "A") duration = 1.0 t = np.linspace(0, duration, int(self.sample_rate * duration), False) # 0.3 - громкость (30% от максимума, чтобы не оглушить) # 0.3 - volume (30% of max to avoid being too loud) tone = 0.3 * np.sin(2 * np.pi * 440 * t) # Воспроизводим тон через выбранный динамик # Play tone through selected speaker 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: {e}") thread = threading.Thread(target=_run_test, daemon=True) thread.start() return threadEOF
4.3. Создание вкладки ui/audio_tab.py
Это виджет, который отображает выпадающие списки устройств, кнопки тестирования и сохраняет выбор пользователя.
Скрытый текст
cat > ~/Zahar/ui/audio_tab.py << 'EOF'# ============================================================# Вкладка настройки аудио-устройств# Audio devices settings tab# ============================================================# Отображает списки микрофонов и динамиков, позволяет их выбрать,# протестировать и сохранить выбор в конфигурацию.# Displays lists of microphones and speakers, allows selection,# testing and saving choice to configuration.import customtkinter as ctkfrom typing import Dict, List, Tuple, Callablefrom core.audio_manager import AudioManagerclass AudioTab(ctk.CTkFrame): """ Виджет вкладки настроек аудио. Audio settings tab widget. """ def __init__( self, parent, translations: Dict[str, Dict[str, str]], get_lang: Callable[[], str], config: Dict, save_config: Callable[[Dict], None] ): # Инициализируем родительский фрейм # Initialize parent frame super().__init__(parent, fg_color="transparent") # Менеджер аудио (логика работы с устройствами) # Audio manager (device logic) self.audio_manager = AudioManager() # Ссылки на общие объекты приложения # References to common application objects self.translations = translations self.get_lang = get_lang self.config = config self.save_config = save_config # Словари для маппинга "название устройства -> ID" # Dictionaries for mapping "device name -> ID" # Это нужно, так как CTkOptionMenu работает со строками # Needed because CTkOptionMenu works with strings self.input_devices: List[Tuple[int, str]] = [] self.output_devices: List[Tuple[int, str]] = [] # Собираем интерфейс # Build the interface self._build_ui() # Загружаем список устройств и выставляем сохраненные значения # Load device list and set saved values self.refresh_devices() def _t(self, key: str, default: str = "") -> str: """ Получает перевод для текущего языка. Gets translation for current language. """ lang = self.get_lang() return self.translations.get(lang, {}).get(key, default) def _build_ui(self): """ Собирает визуальные элементы вкладки. Builds tab visual elements. """ # ========================================== # ЗАГОЛОВОК ВКЛАДКИ # TAB HEADER # ========================================== self.title_label = ctk.CTkLabel( self, text=self._t("audio_header", "Аудио устройства"), font=ctk.CTkFont(size=24, weight="bold") ) self.title_label.pack(pady=(20, 5), padx=20, anchor="w") self.subtitle_label = ctk.CTkLabel( self, text=self._t("audio_subtitle", "Выберите микрофон и динамик для ассистента"), font=ctk.CTkFont(size=14), text_color="gray" ) self.subtitle_label.pack(pady=(0, 20), padx=20, anchor="w") # ========================================== # СЕКЦИЯ МИКРОФОНА # MICROPHONE SECTION # ========================================== # Рамка для группы элементов микрофона # Frame for microphone elements group self.mic_frame = ctk.CTkFrame(self, fg_color=("gray85", "gray20")) self.mic_frame.pack(fill="x", padx=20, pady=10) # Заголовок секции # Section header 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") # Выпадающий список микрофонов # Microphone dropdown # Значения будут заполнены позже в refresh_devices() # Values will be filled later in refresh_devices() self.mic_menu = ctk.CTkOptionMenu( self.mic_frame, values=["..."], width=500, height=40, font=ctk.CTkFont(size=14) ) self.mic_menu.pack(pady=5, padx=15, fill="x") # Кнопка тестирования микрофона # Microphone test button self.mic_test_btn = ctk.CTkButton( self.mic_frame, text=self._t("test_microphone", "Тест микрофона (3 сек)"), command=self._on_test_mic, height=40, font=ctk.CTkFont(size=14), fg_color=("gray60", "gray40") ) self.mic_test_btn.pack(pady=(10, 15), padx=15, fill="x") # ========================================== # СЕКЦИЯ ДИНАМИКА # SPEAKER SECTION # ========================================== self.speaker_frame = ctk.CTkFrame(self, 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") # Выпадающий список динамиков # Speaker dropdown self.speaker_menu = ctk.CTkOptionMenu( self.speaker_frame, values=["..."], width=500, height=40, font=ctk.CTkFont(size=14) ) self.speaker_menu.pack(pady=5, padx=15, fill="x") # Кнопка тестирования динамика # Speaker test button self.speaker_test_btn = ctk.CTkButton( self.speaker_frame, text=self._t("test_speaker", "Тест динамика (тон 440 Гц)"), command=self._on_test_speaker, height=40, font=ctk.CTkFont(size=14), fg_color=("gray60", "gray40") ) self.speaker_test_btn.pack(pady=(10, 15), padx=15, fill="x") # ========================================== # СТАТУС И КНОПКИ ДЕЙСТВИЙ # STATUS AND ACTION BUTTONS # ========================================== # Метка статуса (показывает результат тестирования) # Status label (shows test result) self.status_label = ctk.CTkLabel( self, text="", font=ctk.CTkFont(size=14), text_color="gray" ) self.status_label.pack(pady=10, padx=20) # Фрейм для кнопок действий # Frame for action buttons self.buttons_frame = ctk.CTkFrame(self, fg_color="transparent") self.buttons_frame.pack(fill="x", padx=20, pady=10) # Кнопка обновления списка устройств # Device list refresh button self.refresh_btn = ctk.CTkButton( self.buttons_frame, text=self._t("refresh_devices", "Обновить список"), command=self.refresh_devices, height=40, font=ctk.CTkFont(size=14), fg_color=("gray60", "gray40"), width=180 ) self.refresh_btn.pack(side="left", padx=5) # Кнопка сохранения настроек # Save settings button self.save_btn = ctk.CTkButton( self.buttons_frame, text=self._t("save", "Сохранить"), command=self._on_save, height=40, font=ctk.CTkFont(size=14), width=180 ) self.save_btn.pack(side="right", padx=5) def refresh_devices(self): """ Перезагружает список устройств и выставляет текущий выбор. Reloads device list and sets current selection. """ # Получаем списки устройств от менеджера # Get device lists from manager self.input_devices = self.audio_manager.get_input_devices() self.output_devices = self.audio_manager.get_output_devices() # Формируем списки названий для выпадающих меню # Form name lists for dropdown menus input_names = [name for _, name in self.input_devices] output_names = [name for _, name in self.output_devices] # Если устройств не найдено, показываем заглушку # If no devices found, show placeholder if not input_names: input_names = [self._t("no_devices", "Устройства не найдены")] if not output_names: output_names = [self._t("no_devices", "Устройства не найдены")] # Обновляем значения в выпадающих меню # Update values in dropdown menus self.mic_menu.configure(values=input_names) self.speaker_menu.configure(values=output_names) # ========================================== # УСТАНОВКА СОХРАНЕННЫХ ЗНАЧЕНИЙ # SET SAVED VALUES # ========================================== # Получаем ID устройств из конфига (если были сохранены) # Get device IDs from config (if were saved) saved_input_id = self.config.get("audio_input_id") saved_output_id = self.config.get("audio_output_id") # Выбираем сохраненный микрофон # Select saved microphone 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 ) if saved_name: self.mic_menu.set(saved_name) else: self.mic_menu.set(input_names[0]) else: # Если в конфиге нет - берем устройство по умолчанию # If not in config - take default device 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]) # Выбираем сохраненный динамик # Select saved speaker 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 ) if saved_name: self.speaker_menu.set(saved_name) else: self.speaker_menu.set(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: """Возвращает ID выбранного микрофона. / Returns selected microphone ID.""" current_name = self.mic_menu.get() for dev_id, name in self.input_devices: if name == current_name: return dev_id # Если не нашли - возвращаем первый доступный # If not found - return first available return self.input_devices[0][0] if self.input_devices else 0 def _get_selected_output_id(self) -> int: """Возвращает ID выбранного динамика. / Returns selected speaker ID.""" 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): """Обработчик кнопки тестирования микрофона. / Microphone test button handler.""" input_id = self._get_selected_input_id() output_id = self._get_selected_output_id() # Блокируем кнопку на время теста # Disable button during test self.mic_test_btn.configure(state="disabled") def on_status(status: str): # Обновляем статус в главном потоке GUI # Update status in main GUI thread 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" ) # Возвращаем кнопку в рабочее состояние через основной поток # Re-enable button via main thread 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")) # Запускаем тест в отдельном потоке # Run test in separate thread self.audio_manager.test_microphone( input_device_id=input_id, output_device_id=output_id, on_status=on_status ) def _on_test_speaker(self): """Обработчик кнопки тестирования динамика. / Speaker test button handler.""" 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): """Обработчик кнопки сохранения. / Save button handler.""" # Получаем ID выбранных устройств # Get IDs of selected devices input_id = self._get_selected_input_id() output_id = self._get_selected_output_id() input_name = self.mic_menu.get() output_name = self.speaker_menu.get() # Сохраняем в конфигурацию # Save to configuration self.config["audio_input_id"] = input_id self.config["audio_output_id"] = output_id self.config["audio_input_name"] = input_name self.config["audio_output_name"] = output_name self.save_config(self.config) self._set_status(self._t("settings_saved", "Настройки сохранены!"), "green") def _set_status(self, text: str, color: str): """ Устанавливает текст и цвет метки статуса. Sets status label text and color. Вызывается через self.after() для безопасности из потоков. Called via self.after() for thread safety. """ def _update(): self.status_label.configure(text=text, text_color=color) self.after(0, _update) def update_language(self): """ Обновляет все надписи при смене языка. Updates all labels on language change. """ 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/activatepip 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 librariesimport customtkinter as ctkimport osimport sysimport atexit# Добавляем корень проекта в путь для импорта / Add project root to pathsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))from core.password_manager import PasswordManagerfrom ui.dashboard_tab import DashboardTabfrom 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 Гц)»
-
Кнопки «Обновить список» и «Сохранить»
-
ссылка на оригинал статьи https://habr.com/ru/articles/1042730/