Локальный домашний Ассистент на Raspberry Pi 5 ч.2

от автора

Шаг 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/