Шаг 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 Гц)»

    • Кнопки «Обновить список» и «Сохранить»

Комментарии (0)