fix: improve streaming, alarms, and AI TTS
This commit is contained in:
@@ -283,11 +283,7 @@ def _extract_response_content(cfg, data: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _iter_openai_compatible_stream(response):
|
def _iter_openai_compatible_stream(response):
|
||||||
for line in response.iter_lines(decode_unicode=True):
|
for data_str in _iter_sse_data_lines(response):
|
||||||
if not line or not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
data_str = line[5:].strip()
|
|
||||||
if data_str == "[DONE]":
|
if data_str == "[DONE]":
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -317,11 +313,7 @@ def _iter_openai_compatible_stream(response):
|
|||||||
|
|
||||||
|
|
||||||
def _iter_anthropic_stream(response):
|
def _iter_anthropic_stream(response):
|
||||||
for line in response.iter_lines(decode_unicode=True):
|
for data_str in _iter_sse_data_lines(response):
|
||||||
if not line or not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
data_str = line[5:].strip()
|
|
||||||
if data_str == "[DONE]":
|
if data_str == "[DONE]":
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -344,6 +336,28 @@ def _iter_anthropic_stream(response):
|
|||||||
yield str(text)
|
yield str(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_sse_data_lines(response):
|
||||||
|
"""
|
||||||
|
Читает SSE-стрим и возвращает только payload после "data:".
|
||||||
|
Явно декодируем как UTF-8, чтобы избежать mojibake вида "ÐÑ...".
|
||||||
|
"""
|
||||||
|
for raw_line in response.iter_lines(decode_unicode=False):
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(raw_line, bytes):
|
||||||
|
line = raw_line.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
line = str(raw_line)
|
||||||
|
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_str = line[5:].strip()
|
||||||
|
if data_str:
|
||||||
|
yield data_str
|
||||||
|
|
||||||
|
|
||||||
def _iter_stream_chunks(cfg, response):
|
def _iter_stream_chunks(cfg, response):
|
||||||
if cfg["protocol"] == "anthropic":
|
if cfg["protocol"] == "anthropic":
|
||||||
yield from _iter_anthropic_stream(response)
|
yield from _iter_anthropic_stream(response)
|
||||||
|
|||||||
@@ -18,6 +18,121 @@ ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
|||||||
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
||||||
ASK_ALARM_TIME_PROMPT = "На какое время мне поставить будильник?"
|
ASK_ALARM_TIME_PROMPT = "На какое время мне поставить будильник?"
|
||||||
|
|
||||||
|
_NUMBER_UNITS = {
|
||||||
|
"ноль": 0,
|
||||||
|
"один": 1,
|
||||||
|
"одна": 1,
|
||||||
|
"два": 2,
|
||||||
|
"две": 2,
|
||||||
|
"три": 3,
|
||||||
|
"четыре": 4,
|
||||||
|
"пять": 5,
|
||||||
|
"шесть": 6,
|
||||||
|
"семь": 7,
|
||||||
|
"восемь": 8,
|
||||||
|
"девять": 9,
|
||||||
|
}
|
||||||
|
_NUMBER_TEENS = {
|
||||||
|
"десять": 10,
|
||||||
|
"одиннадцать": 11,
|
||||||
|
"двенадцать": 12,
|
||||||
|
"тринадцать": 13,
|
||||||
|
"четырнадцать": 14,
|
||||||
|
"пятнадцать": 15,
|
||||||
|
"шестнадцать": 16,
|
||||||
|
"семнадцать": 17,
|
||||||
|
"восемнадцать": 18,
|
||||||
|
"девятнадцать": 19,
|
||||||
|
}
|
||||||
|
_NUMBER_TENS = {
|
||||||
|
"двадцать": 20,
|
||||||
|
"тридцать": 30,
|
||||||
|
"сорок": 40,
|
||||||
|
"пятьдесят": 50,
|
||||||
|
}
|
||||||
|
_PARTS_OF_DAY = {"утра", "дня", "вечера", "ночи"}
|
||||||
|
_FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"}
|
||||||
|
_HOUR_WORDS = {"час", "часа", "часов"}
|
||||||
|
_MINUTE_WORDS = {"минута", "минуту", "минуты", "минут"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_number_tokens(tokens, start_index: int):
|
||||||
|
if start_index >= len(tokens):
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
token = tokens[start_index]
|
||||||
|
if token.isdigit():
|
||||||
|
return int(token), 1
|
||||||
|
|
||||||
|
if token in _NUMBER_TEENS:
|
||||||
|
return _NUMBER_TEENS[token], 1
|
||||||
|
|
||||||
|
if token in _NUMBER_TENS:
|
||||||
|
value = _NUMBER_TENS[token]
|
||||||
|
if start_index + 1 < len(tokens):
|
||||||
|
next_token = tokens[start_index + 1]
|
||||||
|
if next_token in _NUMBER_UNITS:
|
||||||
|
value += _NUMBER_UNITS[next_token]
|
||||||
|
return value, 2
|
||||||
|
return value, 1
|
||||||
|
|
||||||
|
if token in _NUMBER_UNITS:
|
||||||
|
return _NUMBER_UNITS[token], 1
|
||||||
|
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_part_of_day(hour: int, part_of_day: str | None) -> int:
|
||||||
|
if not part_of_day:
|
||||||
|
return hour
|
||||||
|
|
||||||
|
if part_of_day == "утра":
|
||||||
|
return 0 if hour == 12 else hour
|
||||||
|
if part_of_day == "ночи":
|
||||||
|
return 0 if hour == 12 else hour
|
||||||
|
if part_of_day in {"дня", "вечера"} and hour < 12:
|
||||||
|
return hour + 12
|
||||||
|
return hour
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_alarm_time_words(text: str):
|
||||||
|
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
|
||||||
|
markers = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
|
||||||
|
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token not in markers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = index + 1
|
||||||
|
while current < len(tokens) and tokens[current] in _FILLER_WORDS:
|
||||||
|
current += 1
|
||||||
|
|
||||||
|
hour, consumed = _parse_number_tokens(tokens, current)
|
||||||
|
if hour is None:
|
||||||
|
continue
|
||||||
|
current += consumed
|
||||||
|
|
||||||
|
if current < len(tokens) and tokens[current] in _HOUR_WORDS:
|
||||||
|
current += 1
|
||||||
|
|
||||||
|
minute = 0
|
||||||
|
if current < len(tokens) and tokens[current] not in _PARTS_OF_DAY:
|
||||||
|
parsed_minute, minute_consumed = _parse_number_tokens(tokens, current)
|
||||||
|
if parsed_minute is not None:
|
||||||
|
minute = parsed_minute
|
||||||
|
current += minute_consumed
|
||||||
|
if current < len(tokens) and tokens[current] in _MINUTE_WORDS:
|
||||||
|
current += 1
|
||||||
|
|
||||||
|
part_of_day = None
|
||||||
|
if current < len(tokens) and tokens[current] in _PARTS_OF_DAY:
|
||||||
|
part_of_day = tokens[current]
|
||||||
|
|
||||||
|
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||||||
|
return _apply_part_of_day(hour, part_of_day), minute
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AlarmClock:
|
class AlarmClock:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -70,10 +185,10 @@ class AlarmClock:
|
|||||||
if re.search(r"\b(каждый день|ежедневно)\b", text):
|
if re.search(r"\b(каждый день|ежедневно)\b", text):
|
||||||
return [0, 1, 2, 3, 4, 5, 6]
|
return [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
|
||||||
if re.search(r"\b(по будн|в будн|будние)\b", text):
|
if re.search(r"\b(?:по\s+будн\w*|в\s+будн\w*|будн\w*)\b", text):
|
||||||
days.update([0, 1, 2, 3, 4])
|
days.update([0, 1, 2, 3, 4])
|
||||||
|
|
||||||
if re.search(r"\b(по выходн|в выходн|выходные)\b", text):
|
if re.search(r"\b(?:по\s+выходн\w*|в\s+выходн\w*|выходн\w*)\b", text):
|
||||||
days.update([5, 6])
|
days.update([5, 6])
|
||||||
|
|
||||||
day_patterns = {
|
day_patterns = {
|
||||||
@@ -268,32 +383,32 @@ class AlarmClock:
|
|||||||
|
|
||||||
days = self._extract_alarm_days(text)
|
days = self._extract_alarm_days(text)
|
||||||
|
|
||||||
# Поиск формата "7:30", "7.30"
|
# Поиск формата "7:30", "7.30" и вариантов с "в/на/к".
|
||||||
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
|
match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text)
|
||||||
if match:
|
if match:
|
||||||
h, m = int(match.group(1)), int(match.group(2))
|
h, m = int(match.group(1)), int(match.group(2))
|
||||||
|
period_match = re.search(
|
||||||
|
r"\b(?:на|в|во|к)?\s*" + re.escape(match.group(0).strip()) + r"\s+(утра|дня|вечера|ночи)\b",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
part_of_day = period_match.group(1) if period_match else None
|
||||||
|
h = _apply_part_of_day(h, part_of_day)
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
self.add_alarm_with_days(h, m, days=days)
|
self.add_alarm_with_days(h, m, days=days)
|
||||||
days_phrase = self._format_days_phrase(days)
|
days_phrase = self._format_days_phrase(days)
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
return f"Я установил будильник на {h} часов {m} минут{suffix}."
|
return f"Я установил будильник на {h} часов {m} минут{suffix}."
|
||||||
|
|
||||||
# Поиск формата словами "на 7 часов 15 минут"
|
# Поиск формата цифрами: "в 7 утра", "на 7", "к 6 30"
|
||||||
match_time = re.search(
|
match_time = re.search(
|
||||||
r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
|
r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?(?:\s+(утра|дня|вечера|ночи))?\b",
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if match_time:
|
if match_time:
|
||||||
h = int(match_time.group(1))
|
h = int(match_time.group(1))
|
||||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
m = int(match_time.group(2)) if match_time.group(2) else 0
|
||||||
|
h = _apply_part_of_day(h, match_time.group(3))
|
||||||
# Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра)
|
|
||||||
# Здесь простая логика AM/PM
|
|
||||||
if "вечера" in text and h < 12:
|
|
||||||
h += 12
|
|
||||||
elif "утра" in text and h == 12:
|
|
||||||
h = 0
|
|
||||||
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
self.add_alarm_with_days(h, m, days=days)
|
self.add_alarm_with_days(h, m, days=days)
|
||||||
@@ -301,6 +416,15 @@ class AlarmClock:
|
|||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
|
# Поиск формата словами: "в семь утра", "будильник семь тридцать"
|
||||||
|
word_time = _extract_alarm_time_words(text)
|
||||||
|
if word_time:
|
||||||
|
h, m = word_time
|
||||||
|
self.add_alarm_with_days(h, m, days=days)
|
||||||
|
days_phrase = self._format_days_phrase(days)
|
||||||
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
|
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
|
||||||
"будильник",
|
"будильник",
|
||||||
"поставь будильник",
|
"поставь будильник",
|
||||||
|
|||||||
68
app/main.py
68
app/main.py
@@ -2,14 +2,9 @@
|
|||||||
Smart Speaker - Main Application
|
Smart Speaker - Main Application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
@@ -566,38 +561,8 @@ def main():
|
|||||||
# AI запрос
|
# AI запрос
|
||||||
chat_history.append({"role": "user", "content": user_text})
|
chat_history.append({"role": "user", "content": user_text})
|
||||||
|
|
||||||
# Очередь для TTS
|
|
||||||
tts_q = queue.Queue()
|
|
||||||
interrupt_event = threading.Event()
|
|
||||||
|
|
||||||
def tts_worker():
|
|
||||||
"""Фоновый поток для озвучки."""
|
|
||||||
while True:
|
|
||||||
item = tts_q.get()
|
|
||||||
if item is None:
|
|
||||||
tts_q.task_done()
|
|
||||||
break
|
|
||||||
|
|
||||||
text, lang = item
|
|
||||||
|
|
||||||
if interrupt_event.is_set():
|
|
||||||
tts_q.task_done()
|
|
||||||
continue
|
|
||||||
|
|
||||||
completed = speak(
|
|
||||||
text, check_interrupt=check_wakeword_once, language=lang
|
|
||||||
)
|
|
||||||
|
|
||||||
if not completed:
|
|
||||||
interrupt_event.set()
|
|
||||||
|
|
||||||
tts_q.task_done()
|
|
||||||
|
|
||||||
worker_thread = threading.Thread(target=tts_worker, daemon=True)
|
|
||||||
worker_thread.start()
|
|
||||||
|
|
||||||
full_response = ""
|
full_response = ""
|
||||||
buffer = ""
|
interrupted = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Streaming от AI
|
# Streaming от AI
|
||||||
@@ -606,33 +571,20 @@ def main():
|
|||||||
print("🤖 AI: ", end="", flush=True)
|
print("🤖 AI: ", end="", flush=True)
|
||||||
|
|
||||||
for chunk in stream_generator:
|
for chunk in stream_generator:
|
||||||
if interrupt_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
buffer += chunk
|
|
||||||
full_response += chunk
|
full_response += chunk
|
||||||
print(chunk, end="", flush=True)
|
print(chunk, end="", flush=True)
|
||||||
|
|
||||||
# Конец предложения
|
|
||||||
if re.search(r"[.!?\n]+(?:\s|$)", buffer):
|
|
||||||
clean_chunk = clean_response(buffer, language="ru")
|
|
||||||
if clean_chunk.strip():
|
|
||||||
tts_q.put((clean_chunk, "ru"))
|
|
||||||
buffer = ""
|
|
||||||
|
|
||||||
# Остаток
|
|
||||||
if buffer.strip() and not interrupt_event.is_set():
|
|
||||||
clean_chunk = clean_response(buffer, language="ru")
|
|
||||||
if clean_chunk.strip():
|
|
||||||
tts_q.put((clean_chunk, "ru"))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Ошибка: {e}")
|
print(f"\n❌ Ошибка: {e}")
|
||||||
speak("Произошла ошибка при получении ответа.")
|
speak("Произошла ошибка при получении ответа.")
|
||||||
|
else:
|
||||||
# Ждем окончания озвучки
|
clean_ai_response = clean_response(full_response, language="ru")
|
||||||
tts_q.put(None)
|
if clean_ai_response.strip():
|
||||||
worker_thread.join()
|
interrupted = not speak(
|
||||||
|
clean_ai_response,
|
||||||
|
check_interrupt=check_wakeword_once,
|
||||||
|
language="ru",
|
||||||
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -643,7 +595,7 @@ def main():
|
|||||||
stop_wakeword_monitoring()
|
stop_wakeword_monitoring()
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
|
|
||||||
if interrupt_event.is_set():
|
if interrupted:
|
||||||
print("⏹️ Ответ прерван")
|
print("⏹️ Ответ прерван")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
Reference in New Issue
Block a user