From 3818f0ad2249f3d6e4337e9dd06092b694e2cdfc Mon Sep 17 00:00:00 2001 From: nvfuture Date: Sat, 10 Jan 2026 01:50:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=BA=D0=BE=D0=B9=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D1=87=D0=B8=D0=BA=D0=B0=20-vosk?= =?UTF-8?q?=20models=20=D0=B8=20=D0=B2=D1=81=D0=B5=20=D1=83=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- app/audio/local_stt.py | 147 ----------------------------------------- app/audio/tts.py | 16 ++++- app/audio/wakeword.py | 17 ++++- app/core/ai.py | 3 +- app/core/config.py | 4 -- app/features/alarm.py | 13 ++-- ding.wav | Bin 0 -> 13274 bytes requirements.txt | 1 - 9 files changed, 38 insertions(+), 166 deletions(-) delete mode 100644 app/audio/local_stt.py create mode 100644 ding.wav diff --git a/README.md b/README.md index 51fd6cf..bbc37c0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Распознавание речи (Deepgram, RU/EN). - Озвучка (Silero TTS, RU/EN). - Перевод RU↔EN (Perplexity). -- Будильник с локальным распознаванием стоп-команд (Vosk). +- Будильник с голосовым отключением. - Управление громкостью (ALSA amixer). ## Требования @@ -85,7 +85,6 @@ Wake word (Porcupine) ──► STT (Deepgram) ──► Логика коман - `ai.py` — запросы к Perplexity (чат и перевод). - `cleaner.py` — очистка ответа и преобразование чисел (RU). - `alarm.py` — будильник и логика расписания. -- `local_stt.py` — локальный Vosk для стоп-команд. - `sound_level.py` — управление громкостью. ## Частые проблемы diff --git a/app/audio/local_stt.py b/app/audio/local_stt.py deleted file mode 100644 index 516fd53..0000000 --- a/app/audio/local_stt.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Local offline Speech-to-Text module using Vosk. -Used for simple command detection (like "stop") without internet. -""" - -# Модуль локального распознавания речи (Vosk). -# Работает полностью оффлайн (без интернета). -# Используется, когда нужно распознать простые команды (например, "стоп" во время будильника), -# чтобы не тратить трафик и время на обращение к облаку. - -import os -import sys -import json -import pyaudio -from vosk import Model, KaldiRecognizer -from config import VOSK_MODEL_PATH, SAMPLE_RATE - - -class LocalRecognizer: - """Класс для работы с Vosk.""" - - def __init__(self): - self.model = None - self.rec = None - self.pa = None - self.stream = None - - def initialize(self): - """Загрузка модели Vosk.""" - if not os.path.exists(VOSK_MODEL_PATH): - print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}") - return False - - print("📦 Инициализация локального STT (Vosk)...") - - # Трюк для подавления вывода логов Vosk в консоль (он очень шумный) - try: - null_fd = os.open(os.devnull, os.O_WRONLY) - old_stderr = os.dup(2) - sys.stderr.flush() - os.dup2(null_fd, 2) - os.close(null_fd) - - # Сама загрузка модели - self.model = Model(str(VOSK_MODEL_PATH)) - - # Возвращаем stderr обратно - os.dup2(old_stderr, 2) - os.close(old_stderr) - except Exception as e: - print(f"Error initializing Vosk: {e}") - return False - - self.rec = KaldiRecognizer(self.model, SAMPLE_RATE) - self.pa = pyaudio.PyAudio() - return True - - def listen_for_keywords(self, keywords: list, timeout: float = 10.0) -> str: - """ - Слушает микрофон заданное время и проверяет наличие ключевых слов. - - Args: - keywords: Список слов, которые мы ждем (например, ["стоп", "хватит"]). - timeout: Сколько секунд слушать. - - Returns: - Найденное слово или пустую строку. - """ - if not self.model: - if not self.initialize(): - return "" - - # Открываем поток микрофона - try: - stream = self.pa.open( - format=pyaudio.paInt16, - channels=1, - rate=SAMPLE_RATE, - input=True, - frames_per_buffer=4096, - ) - stream.start_stream() - except Exception as e: - print(f"❌ Ошибка микрофона: {e}") - return "" - - import time - - start_time = time.time() - - print(f"👂 Локальное слушание ожидает: {keywords}") - - detected_text = "" - - try: - while time.time() - start_time < timeout: - data = stream.read(4096, exception_on_overflow=False) - - # Vosk обрабатывает аудио чанками - if self.rec.AcceptWaveform(data): - # Полный результат - res = json.loads(self.rec.Result()) - text = res.get("text", "") - if text: - print(f"📝 Локально: {text}") - # Проверяем, есть ли ключевое слово в распознанном тексте - for kw in keywords: - if kw in text: - detected_text = text - break - else: - # Частичный результат (быстрее, чем полный) - res = json.loads(self.rec.PartialResult()) - partial = res.get("partial", "") - if partial: - for kw in keywords: - if kw in partial: - detected_text = partial - break - - if detected_text: - break - finally: - stream.stop_stream() - stream.close() - - return detected_text - - def cleanup(self): - if self.pa: - self.pa.terminate() - - -# Глобальный экземпляр -_local_recognizer = None - - -def get_local_recognizer(): - global _local_recognizer - if _local_recognizer is None: - _local_recognizer = LocalRecognizer() - return _local_recognizer - - -def listen_for_keywords(keywords: list, timeout: float = 5.0) -> str: - """Внешняя функция для поиска ключевых слов.""" - return get_local_recognizer().listen_for_keywords(keywords, timeout) diff --git a/app/audio/tts.py b/app/audio/tts.py index 61c25bf..deab8ad 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -133,9 +133,19 @@ class TextToSpeech: model = self._load_model("ru") speaker = self.speaker_ru - # Проверка наличия спикера в модели (защита от ошибок конфига) - if hasattr(model, "speakers") and speaker not in model.speakers: - if model.speakers: + # Проверка наличия спикера в модели (защита от ошибок конфига). + # Для русского языка сохраняем мужской голос по умолчанию. + if hasattr(model, "speakers") and model.speakers: + if language == "ru": + male_speakers = ("eugene", "aidar") + if speaker not in model.speakers or speaker not in male_speakers: + for candidate in male_speakers: + if candidate in model.speakers: + speaker = candidate + break + else: + speaker = model.speakers[0] + elif speaker not in model.speakers: speaker = model.speakers[0] # Разбиваем текст на куски diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index 5b38b74..f07ed4d 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -21,6 +21,8 @@ class WakeWordDetector: self.audio_stream = None self.pa = None self._stream_closed = True # Флаг состояния потока (закрыт/открыт) + self._last_hit_ts = 0.0 + self._hit_streak = 0 def initialize(self): """Инициализация Porcupine и PyAudio.""" @@ -118,6 +120,8 @@ class WakeWordDetector: Returns: True, если фраза обнаружена прямо сейчас. """ + import time + if not self.porcupine: self.initialize() @@ -131,8 +135,17 @@ class WakeWordDetector: keyword_index = self.porcupine.process(pcm) if keyword_index >= 0: - print("🛑 Wake word обнаружен во время ответа!") - return True + now = time.time() + if now - self._last_hit_ts < 0.6: + self._hit_streak += 1 + else: + self._hit_streak = 1 + self._last_hit_ts = now + + if self._hit_streak >= 2: + self._hit_streak = 0 + print("🛑 Wake word подтвержден во время ответа!") + return True return False except Exception: return False diff --git a/app/core/ai.py b/app/core/ai.py index ef80a48..096a545 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -21,7 +21,8 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а # Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод..."). TRANSLATION_SYSTEM_PROMPT = """You are a translation engine. Translate from {source} to {target}. -Return only the translated text, without quotes, comments, or explanations.""" +Return only the translated text, without quotes, comments, or explanations. +Keep the translation максимально кратким и естественным, без лишних слов.""" def _send_request(messages, max_tokens, temperature, error_text): diff --git a/app/core/config.py b/app/core/config.py index 2f743e9..9fe8dcb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,10 +33,6 @@ PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY") # Путь к файлу модели ключевого слова (.ppn), который лежит в папке assets/models PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Alexandr_en_linux_v4_0_0.ppn" -# --- Настройки локального распознавания (Vosk) --- -# Используется для стоп-команд и будильника, когда не нужен интернет -VOSK_MODEL_PATH = BASE_DIR / "assets" / "models" / "vosk-model-ru-0.42" - # --- Параметры аудио --- # Частота дискретизации для микрофона (стандарт для распознавания речи) SAMPLE_RATE = 16000 diff --git a/app/features/alarm.py b/app/features/alarm.py index 2c55768..33131eb 100644 --- a/app/features/alarm.py +++ b/app/features/alarm.py @@ -9,7 +9,7 @@ import re from datetime import datetime from pathlib import Path from ..core.config import BASE_DIR -from ..audio.local_stt import listen_for_keywords +from ..audio.stt import listen # Файл базы данных будильников ALARM_FILE = BASE_DIR / "data" / "alarms.json" @@ -90,7 +90,7 @@ class AlarmClock: """ Логика срабатывания будильника. Запускает воспроизведение MP3 через mpg123 и слушает команду "Стоп". - Использует локальное распознавание (Vosk), чтобы не зависеть от интернета. + Использует облачное распознавание речи для остановки. """ print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')") @@ -117,11 +117,12 @@ class AlarmClock: # Цикл ожидания стоп-команды while True: - # Слушаем локально (без интернета) - text = listen_for_keywords(stop_words, timeout=3.0) + text = listen(timeout_seconds=3.0, detection_timeout=3.0) if text: - print(f"🛑 Будильник остановлен по команде: '{text}'") - break + text_lower = text.lower() + if any(word in text_lower for word in stop_words): + print(f"🛑 Будильник остановлен по команде: '{text}'") + break except Exception as e: print(f"❌ Ошибка во время будильника: {e}") diff --git a/ding.wav b/ding.wav new file mode 100644 index 0000000000000000000000000000000000000000..451843fecc157ac4ac7c52c01dec719ae0268bc3 GIT binary patch literal 13274 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