
Продолжаю разрабатывать DIY голосового ассистента на SOC-платформе Rockchip.
В первой части мы соединили в единый конвейер вызов распознавания речи, локального чат-бота и синтез ответа.
Если еще не читали, то вам сюда.
Во второй части поговорим об улучшениях работы с синтезом речи. Научим нашего ИИ-помощника произносить текст, содержащий сложные для моделей сущности, а также сделаем его более плавным.
Нормализация текста для синтеза
Современные state-of-the-art-системы синтеза речи становятся все более самообучаемыми. Получая на вход сотни тысяч часов звука и соответствующего ему текста в любом формате, они пытаются выучить соответствие, в том числе для довольно неоднозначных случаев.
Обычно сложности для них вызывают следующие типы входящего текста:
Омонимы с неоднозначным/контекстозависимым ударением (например, зАмок/замОк)
Переключение между языками в одном предложении (Я хочу телевизор Samsung с поддержкой HDTV)
Формулы и прочие символьные обозначения (В XVIII веке люди еще не знали формулу e=mc2)
Неоднозначные сокращения (Приехав в г. Череповец г. Иванов встретил г-ку Петрову 1985 г. р.)
Склоняемые сокращения (У меня было 3 р., мне дали еще 2 р., и стало 5 р. Тут р. должно раскрываться либо в «рубля», либо в «рублей»)
Номера в разных контекстах (Владелец автомобиля а100аа111, вы задели мой самокат. Переведите 5000 р. на карту 0000-0000-0000-0000 или позвоните по телефону 8-800-888-88-88). Есть бесконечное число способов читать числа: по одной цифре, разделяя на группы, или как одно большое число (восемь — восемьсот… или восемь миллиардов восемьсот миллионов…)
Эти тонкости сложно надежно выучить автоматически, поэтому практически любая система синтеза со��ержит предобработку текста. Обычно это какое-то сочетание вручную прописанных и запрограммированных правил и ML-моделей для обработки естественного языка.
У нас в MWS AI выделена отдельная команда, которая обучает специализированные нейросети на базе архитектуры трансформеров, а также пишет высокопроизводительный код на языке Rust, позволяющий в итоге применить к каждой входящей строке весьма сложную логику с минимальными задержками.
Для нашего DIY-решения мы реализуем самую базовую версию предобработки: раскроем числа, транслитерируем их, уберем неподдерживаемые символы, вырежем содержимое тегов, включая размышления самой языковой модели.
Рассчитывать на помощь локальной LLM мы в данном вопросе не можем. Модельки на устройстве слишком легковесны, чтобы надежно поддержать промпт с форматом вывода для русского языка. Поддержки пользовательских ударений (когда мы можем явно указать, что слово надо читать как з`амок), в mms-tts тоже, к сожалению, нет. Добавлять в пайплайн отдельные модели для обработки естественного языка тоже не очень хочется из-за замедления и так не самой быстрой обработки запросов. Поэтому у нас будет обработка текста, основанная на правилах.
Более того, наша задача еще существенно усложняется необходимостью быстрого отклика на запрос пользователя. По мере поступления очередной порции текста (не превышающей максимальную длину входной последовательности для модели), необходимо «отрезать» чанк текста и отправить на озвучку. Но мы не можем просто обрезать исходный текст по порогу длины. Например, фраза «1985 г. р.» в исходном тексте занимает всего девять символов, но после раскрытия в «тысяча девятьсот восемьдесят пятого года рождения» — уже 50.
Реализация даже самого базового набора правил для этой задачи очень быстро упирается в огромный набор корнер-кейсов, обработка каждого из которых сильно усложняет код.
Мы подойдем к задаче системно, и используем помощь мудрых китайских коллег, умеющих работать без сна и отдыха, — в нашем случае это Qwen.
Но если просто написать ему запрос с описанием как выше, финальный код будет полон багов, которые обнаружатся только при запуске и будут преследовать нас постоянно. Поэтому для реализации чего-то нетривиального, где требуется еще и надежность, у меня сформировалась такая схема:
Сначала обсуждаем и утверждаем ADR-документ (Architecture Decision Records).
Затем просим написать максимально полный набор юнит-тестов.
Дополнительно пишем интеграционный тест или демо-приложение.
И только потом просим реализовать код и проверяем его корректность.
Вот такой вот ADR у нас получился после долгого обсуждения и нескольких попыток реализации:
Architecture Decision Record: StreamTextProcessor для встраиваемого TTS
Статус: Принято
Дата: 3 февраля 2026
Автор: [Ваше имя]
Версия: 1.0
1. Мотивация: зачем это нужно?
При разработке голосового ассистента Орин для встраиваемой платформы Rockchip RK3588 мы столкнулись с фундаментальной проблемой: аппаратный декодер TTS (MMS-TTS в формате RKNN) имеет жестко ограниченный буфер вывода — 102 400 сэмплов (≈6,4 секунды аудио). При этом:
Проблема | Последствия без обработки |
Цифры в тексте | Словарь модели не содержит цифр → синтез падает или выдает шум |
Длинные фразы | Превышение буфера декодера → обрезка речи в середине слова |
Спецсимволы (+, $, %) | Не распознаются моделью → тишина или артефакты |
Аббревиатуры (NASA, JS) | Произносятся как неизвестные токены → искажение |
Потоковая генерация (LLM) | Нельзя ждать конца текста — нужна мгновенная обработка |
Бизнес-требование: голосовой ассистент должен реагировать мгновенно (<300 мс задержка) и говорить без обрывов даже на сложных технических текстах.
2. Цели и не-цели
✅ Цели
— Гарантированная безопасность для декодера: каждый фрагмент ≤28 символов после полной трансформации
— 100% отсутствие цифр в выводе: все числа → слова (включая дробную часть: 3.14 → три точка четырнадцать)
— Потоковая обработка без буферизации: фрагменты возвращаются сразу после завершения сущности (не ждем конца текста)
— Минимальная задержка: обработка символа <1 мс на RK3588
— Устойчивость к «грязному» входу: теги, эмодзи, иероглифы — автоматическая фильтрация
❌ Не-цели
— Сохранение оригинального форматирования (курсив, жирный шрифт)
— Поддержка языков кроме русского
— Оптимизация для скорости трансформации (приоритет — корректность)
— Распознавание математических формул как единого выражения
3. Архитектурные ограничения платформы
Ограничение | Влияние на архитектуру |
RKNN — статический рантайм | Выходной тензор имеет фиксированный размер → жесткий лимит длины фрагмента |
Словарь модели без цифр | Обязательная трансформация всех цифр в слова до подачи в декодер |
Ограниченная память (4 ГБ) | Запрет на буферизацию всего текста → потоковая обработка по символам |
Python 3.7 на целевой платформе | Запрет на синтаксис Python 3.10+ |
4. API процессора
class StreamTextProcessor: def init(self, max_chunk_size: int = 28): """ :param max_chunk_size: Максимальная длина фрагмента ПОСЛЕ трансформации. Для RK3588 с буфером 102400 сэмплов рекомендуется 26–28. """ def feed(self, char: str) -> List[str]: """ Обрабатывает один символ из потока LLM. :param char: Символ для обработки :return: Список готовых фрагментов (каждый ≤ max_chunk_size символов). Пустой список = еще не накоплено достаточно для отправки """ def flush(self) -> List[str]: """ Отправляет остаток буфера при завершении потока. :return: Список финальных фрагментов (каждый ≤ max_chunk_size символов) """
Пример использования
processor = StreamTextProcessor(max_chunk_size=28) # Потоковая обработка от LLM for char in llm_stream: for fragment in processor.feed(char): audio = tts.synthesize(fragment) # Гарантированно безопасно для декодера audio_player.play(audio) # Финальная отправка остатка for fragment in processor.flush(): audio = tts.synthesize(fragment) audio_player.play(audio)
5. Формальные требования
🔒 Критические гарантии (проверяются тестами)
№ | Требование | Пример нарушения | Последствия |
1 | Ноль цифр в выводе | 3.14 → 3 точка 14 | Синтез падает (словарь без цифр) |
2 | Лимит длины | Фрагмент 35 симв. при лимите 28 | Обрезка аудио в середине слова |
3 | Целостность чисел | 3.14 → 3 . + 14 | Неправильное произношение |
4 | Раскрытие спецсимволов | $99 → $99 | Тишина вместо «доллар» |
5 | Ноль латиницы | example.com, HDTV | Тишина вместо аббревиатур, адресов веб-страниц и формул |
5 | Фильтрация недопустимых символов | 😊 в выводе | Артефакты |
🧪 Матрица тестового покрытия
Категория | Тест-кейс | Ожидаемый результат |
Числа | 3.14159 | три точка четырнадцать тысяч сто пятьдесят девять |
Версии | 2.15.3 | два точка пятнадцать точка три |
Смешанные | JS2022 | джей эс две тысячи двадцать два |
Спецсимволы | 5<10 | пять меньше десять |
Теги | <b>текст</b> | текст |
Фильтрация | 😊🚀北京 | `` (пусто) |
Ё | ёжик | ёжик (сохраняется) |
Лимит | Длинный текст | Все фрагменты ≤28 симв. |
6. Архитектура реализации
6.1. Конечный автомат состояний
STATE_NORMAL # Обычный текст (кириллица/латиница) STATE_NUMBER # Цифры + точки между цифрами (3.14, 2.15.3) STATE_ABBREV # Заглавные латинские буквы (NASA, PhD) STATE_SPECIAL # Спецсимволы (+, $, %) STATE_TAG # Внутри <...> — игнорирование STATE_AFTER_LT # После '<' — ожидание буквы для определения тега
6.2. Алгоритм обработки символа
def feed(char): 1. Если спецсимвол (кроме < >) → немедленно раскрыть → добавить в буфер 2. Если '<' → установить флаг after_lt 3. Если после '<' пришла буква → начать игнорирование тега 4. Если после '<' пришел не буква → раскрыть как «меньше» 5. Если '>' внутри тега → завершить игнорирование 6. Если '>' вне тега → раскрыть как «больше» 7. Если цифра → накопить в current_entity (state=NUMBER) 8. Если точка после цифры → накопить как часть числа 9. Если точка не после цифры → завершить сущность + добавить точку как пунктуацию 10. Если граница слова → завершить сущность + попытаться извлечь фрагменты 11. Если недопустимый символ → игнорировать 12. Иначе → накопить в current_entity (state=NORMAL)
6.3. Ключевая логика трансформации
def flushentity(): # Разделить сущность на число и завершающие точки # Пример: "2.15.3." → число="2.15.3", точки="." entity = current_entity.rstrip('.') trailing_dots = current_entity[len(entity): # Трансформировать КАЖДУЮ часть числа через num2words # "2.15.3" → ["два", "пятнадцать", "три"] → "два точка пятнадцать точка три" if is_number(entity): parts = entity.split('.') words = [num2words(int(p)) for p in parts] transformed = ' точка '.join(words) # Добавить завершающие точки как пунктуацию if trailing_dots: add_to_buffer(trailing_dots)
6.4. Гарантия лимита длины
def splithard(text): while len(text) > max_chunk_size: # Искать границу разбивки справа налево for i in range(max_chunk_size-1, max_chunk_size//2, -1): if text[i] in ' ,.!?;:-': # Защита от разрыва "точка" в числах if text[i-6:i+1].lower() != ' точка': split at i+1 break # Абсолютная гарантия assert len(fragment) <= max_chunk_size
7. Почему не другие подходы?
Альтер��атива | Почему отклонена |
Постобработка всего текста | Требует буферизации → задержка синтеза >1с (неприемлемо для ассистента) |
Динамический лимит на основе эвристики | Ненадежно: 1000 → тысяча (7 симв.) но 1111 → одна тысяча сто одиннадцать (28 симв.) |
Сохранение цифр в выводе | Словарь модели не содержит цифр → гарантированный крах синтеза |
Разбиение по словам без учета чисел | 3.14 → 3 + . + 14 → разрыв числа → неправильное произношение |
Использование стороннего нормализатора | Зависимости недопустимы на встраиваемой платформе (ограниченное хранилище) |
8. Последствия принятых решений
✅ Положительные
— Нулевая вероятность обрезки аудио — жесткий лимит на уровне процессора
— Мгновенный старт синтеза — первый фрагмент через 15–20 символов (≈0,5 с — задержка)
— Полная совместимость со словарем модели — 100%-е отсутствие цифр и спецсимволов
— Минимальный отпечаток памяти — буфер <1 КБ
⚠️ Компромиссы
— Увеличение длины текста — 1000000 → один миллион (7 → 12 симв.) → требуется уменьшение лимита с 30 до 28
— Потеря форматирования — теги <b>, <i> игнорируются (но это не критично для аудио)
— Упрощенное произношение дробей — 3.14159 → три точка четырнадцать тысяч... вместо три целых четырнадцать тысяч... (для краткости)
9. Валидация на реальных данных
Стресс-тест: обработка технического текста длиной 650 символов (смешанные числа, аббревиатуры, спецсимволы):
Метрика | Результат |
Входной текст | 650 символов |
Выходной текст (после трансформации) | 1 561 символ (раздутие ×2,4) |
Количество фрагментов | 9 |
Макс. длина фрагмента | 179 симв. → разбито на части ≤28 симв. |
Цифр в выводе | 0 |
Нераскрытых спецсимволов | 0 |
Задержка обработки символа | < 0,3 мс на RK3588 |
Вывод: процессор успешно обрабатывает сложные технические тексты без нарушения ограничений декодера.
10. Заключение
StreamTextProcessor решает фундаментальную проблему безопасной передачи данных из LLM в аппаратный декодер TTS на встраиваемых платформах.
Ключевые достижения:
Гарантированная безопасность: жесткий лимит длины + полная трансформация цифр
Реальная потоковость: задержка синтеза < 500 мс даже на длинных текстах
Минимализм: 120 строк кода без внешних зависимостей (кроме num2words)
Предсказуемость: детерминированное поведение на любых входных данных
Эта архитектура позволяет создавать надежные голосовые интерфейсы для встраиваемых систем с минимальными аппаратными требованиями — критически важное свойство для массовых устройств (умные колонки, автомобильные системы, IoT).
Тесты — обычный формат pytest — выглядят примерно так:
# tests/test_stream_processor.py import re import pytest from normalizer import StreamTextProcessor class TestStreamTextProcessorGuarantees: """Критические гарантии — падение любого теста = нерабочий продукт.""" def test_guarantee_no_digits_in_output(self): """ГАРАНТИЯ #1: В финальном выводе НЕТ цифр (0-9).""" processor = StreamTextProcessor(max_chunk_size=200) text = "Цена $99.99 за 100 единиц. Версия 2.15.3. Дата 2024-12-31. π≈3.14159" fragments = [] for ch in text: fragments.extend(processor.feed(ch)) fragments.extend(processor.flush() full_text = " ".join(fragments) # Извлекаем все цифры из вывода digits_found = re.findall(r'\d', full_text) assert digits_found == [], ( f"НАРУШЕНА ГАРАНТИЯ #1: обнаружены цифры в выводе: {digits_found}\n" f"Полный вывод: '{full_text}'" )
Всего порядка 30 условий.
Ну и проверочный скрипт (https://github.com/vzaguskin/orin/blob/main/stream_demo.py) — попросил напихать в строку жести, и преобразовать ее в подходящий формат
МАКСИМАЛЬНЫЙ СТРЕСС-ТЕСТ: посимвольная обработка сложного текста
==========================================================================================
Исходный текст (650 символов):
Версия 2.0 и π≈3,14159 — это дробные числа. Цены: $19,99, €49,50, £99,99, ¥1000,50 — валюты с копейками. Аббревиатуры: HTML5, CSS3, JS2022, API v3.0, NASA, FBI, PhD. Математика: 2+2=4, 100/3≈33,33, (a+b)^2 = a^2 + 2ab + b^2, x^2 + y^2 = z^2. Спецсимволы: & | ~ ` ' " # % @ $ ^ * \ / < > = — все должны расширяться. Иероглифы: 北京办公室 и эмодзи 😊🚀🤖 — должны быть удалены. Дата: 2024-12-31, время: 14:30:45. Телефон: +7 (495) 123-45-67, почта: test@example.com. Дроби: 0,5, 1,25, 3,1415926535. Валюты без пробелов: $1,99€2,50. Сложная формула: E=mc^2, F=ma, a^2+b^2=c^2. Ёжик ёлку ел — буква ё должна сохраниться. Завершение без точки, чтобы проверить flush
==========================================================================================
ПОТОКОВАЯ ОБРАБОТКА:
==========================================================================================
📤 ФРАГМЕНТ # 1 | 66 симв. |
«версия два точка ноль и три точка четырнадцать тысяч сто пятьдесят»
Прочитано 3% текста
📤 ФРАГМЕНТ # 2 | 69 симв. |
«девять это дробные числа . цены : доллар девятнадцать точка девяносто»
Прочитано 8% текста
📤 ФРАГМЕНТ # 3 | 66 симв. |
«девять , евро сорок девять точка пятьдесят , фунт девяносто девять»
Прочитано 11% текста
📤 ФРАГМЕНТ # 4 | 66 симв. |
«точка девяносто девять , иена одна тысяча точка пятьдесят валюты с»
Прочитано 15% текста
📤 ФРАГМЕНТ # 5 | 69 симв. |
«копейками . аббревиатуры : аш ти эм эль пять , си эс эс три , джей эс»
Прочитано 21% текста
📤 ФРАГМЕНТ # 6 | 67 симв. |
«две тысячи двадцать два , эй пи ай в три точка ноль , эн эй эс эй ,»
Прочитано 24% текста
📤 ФРАГМЕНТ # 7 | 67 симв. |
«эф би ай , пи эйч ди . математика : два плюс два равно четыре , сто»
Прочитано 28% текста
📤 ФРАГМЕНТ # 8 | 69 симв. |
«разделить на три тридцать три точка тридцать три , скобка открывается»
Прочитано 30% текста
📤 ФРАГМЕНТ # 9 | 66 симв. |
«эй плюс би скобка закрывается в степени два равно эй в степени два»
Прочитано 32% текста
📤 ФРАГМЕНТ #10 | 64 симв. |
«плюс два аб плюс би в степени два , икс в степени два плюс уай в»
Прочитано 35% текста
📤 ФРАГМЕНТ #11 | 66 симв. |
«степени два равно зед в степени два . спецсимволы : и или примерно»
Прочитано 41% текста
📤 ФРАГМЕНТ #12 | 69 симв. |
«решетка процент собака доллар в степени умножить на бэкслэш разделить»
Прочитано 43% текста
📤 ФРАГМЕНТ #13 | 68 симв. |
«на меньше больше равно все должны расширяться . Иероглифы : и эмодзи»
Прочитано 54% текста
📤 ФРАГМЕНТ #14 | 61 симв. |
«должны быть удалены . дата : две тысячи двадцать четыре минус»
Прочитано 58% текста
📤 ФРАГМЕНТ #15 | 66 симв. |
«двенадцать тридцать один , время : четырнадцать : тридцать : сорок»
Прочитано 61% текста
📤 ФРАГМЕНТ #16 | 65 симв. |
«пять . телефон : плюс семь скобка открывается четыреста девяносто»
Прочитано 64% текста
📤 ФРАГМЕНТ #17 | 68 симв. |
«пять скобка закрывается сто двадцать три минус сорок пять шестьдесят»
Прочитано 66% текста
📤 ФРАГМЕНТ #18 | 69 симв. |
«семь , почта : тест собака ексампле . сом . дроби : ноль точка пять ,»
Прочитано 72% текста
📤 ФРАГМЕНТ #19 | 60 симв. |
«один точка двадцать пять , три точка один миллиард четыреста»
Прочитано 75% текста
📤 ФРАГМЕНТ #20 | 68 симв. |
«пятнадцать миллионов девятьсот двадцать шесть тысяч пятьсот тридцать»
Прочитано 75% текста
📤 ФРАГМЕНТ #21 | 68 симв. |
«пять . валюты без пробелов : доллар один точка девяносто девять евро»
Прочитано 80% текста
📤 ФРАГМЕНТ #22 | 69 симв. |
«два точка пятьдесят . сложная формула : и равно мс в степени два , эф»
Прочитано 84% текста
📤 ФРАГМЕНТ #23 | 68 симв. |
«равно ма , эй в степени два плюс би в степени два равно си в степени»
Прочитано 86% текста
📤 ФРАГМЕНТ #24 | 68 симв. |
«два . ёжик ёлку ел буква ё должна сохраниться . завершение без точки»
Прочитано 97% текста
------------------------------------------------------------------------------------------
FLUSH — отправка остатка буфера:
------------------------------------------------------------------------------------------
📤 ФИНАЛ #25 | 21 симв. |
«чтобы проверить флусх»
==========================================================================================
ИТОГОВАЯ СТАТИСТИКА:
==========================================================================================
Всего фрагментов: 25
Общая длина фрагментов: 1623 символов
------------------------------------------------------------------------------------------
FLUSH — отправка остатка буфера:
------------------------------------------------------------------------------------------
==========================================================================================
ИТОГОВАЯ СТАТИСТИКА:
==========================================================================================
Всего фрагментов: 25
Общая длина фрагментов: 1623 символов
==========================================================================================
🔍 АНАЛИЗ КРИТИЧЕСКИХ КЕЙСОВ:
✅ Дроби целы → дроби не разорваны
✅ Валюты целы → валюты обработаны
✅ Аббревиатуры → HTML5/CSS3 расшифрованы
✅ Математика → операторы расширены
✅ Иероглифы → иероглифы удалены
✅ Эмодзи → эмодзи удалены
✅ Буква ё → буква ё сохранена
✅ Спецсимволы → @ и % расширены
Неидеально и есть что улучшать, но по крайней мере все, что написано, будет озвучено, и можно понять, что имелось в виду.
Ну и да, в продуктовой системе хочется для этой задачи использовать мощную нейросеть или вообще GPT-модель. Только вот тут возникает две проблемы:
1. Задержка до формирования первого чанка звука в диалоговых движках должна быть в пределах нескольких сотен мс (учитывая, что она не только из синтеза речи состоит).
2. Галлюцинации — неизбежный спутник всех моделей на трансформероподобных архитектурах с авторегрессионным декодингом. А пользователи очень не любят, когда синтез начинает читать не по тексту.
Поэтому на данный момент реализация подобной предобработки по-прежнему весьма актуальна.
Полный код нормализатора — можно посмотреть тут: https://github.com/vzaguskin/orin/blob/main/normalizer.py
А юнит-тесты здесь:
https://github.com/vzaguskin/orin/blob/main/tests/test_stream_processor.py -
Напоследок: плавность синтеза
В первой версии мы совершали все операции последовательно: получали запрос от пользователя, отправляли его в чат-бота, получали ответ и направляли на озвучку. После произнесения каждой фразы ответа озвучивали следующую.
Понятно, что в такой последовательной системе мы не утилизируем вычислительные ресурсы и постоянно заставляем пользователя ждать подготовки новой порции информации. Поэтому нам нужна асинхронная архитектура:
Запрос от пользователя идет в LLM.
Ответ от LLM обрабатывается в потоковом режиме, включая предобработку текста для синтеза.
После накопления необходимого минимального количества текста для озвучки этот предобработанный текст отправляется в очередь, ожидающую отправки в модель синтеза.
Очередь на озвучку разгребается в отдельном потоке, генерируется wav-файл и отправляется в очередь на воспроизведение.
Еще один поток разгребает очередь воспроизведения и озвучивает накопившиеся звуковые файлы один за другим.
При таком подходе мы получаем минимально возможную задержку первого звука (время между окончанием реплики пользователя и началом ответа ассистента) и озвучку дальнейшего ответа любой длины без пауз между фразами. Именно это нам и требуется.
В главном цикле создаем в отдельных потоках стадии конвейера — синтез аудиофайла из текста и проигрывание звука.
# 🚀 ЗАПУСК КОНВЕЙЕРА — ОДИН РАЗ, НА ВСЕ ВРЕМЯ print("🚀 Запускаю асинхронный конвейер (постоянно работает)...") synth_task = asyncio.create_task(audio_synthesizer()) player_task = asyncio.create_task(audio_player())
Первый в бесконечном цикле выгребает фрагменты текста и кидает в очередь второго, второй воспроизводит подряд все, что успело прийти из первого.
Дальше остается только дождаться завершения проигрывания и затухания эха, и можно слушать следующий запрос.
# 👇 ЭТАП 1: ЖДЁМ, ПОКА LLM ЗАКОНЧИЛ ОТПРАВЛЯТЬ ТЕКСТ print("⏳ Жду, пока LLM закончит отправлять фрагменты...") while not text_queue.empty(): await asyncio.sleep(0.1) print("✅ Все фрагменты текста в очереди.") # 👇 ЭТАП 2: ЖДЁМ, ПОКА ВСЕ ФРАГМЕНТЫ БУДУТ ПРОИГРАНЫ print("⏳ Жду, пока все фрагменты будут проиграны...") while True: async with audio_count_lock: if expected_audio_count == 0: break print(f"⏳ Осталось {expected_audio_count} фрагментов — жду...") await asyncio.sleep(0.2) print("✅ Все фрагменты проиграны!") # 👇 ЭТАП 3: ЖДЁМ РЕАЛЬНОЕ ВРЕМЯ ПРОИГРЫВАНИЯ — чтобы эхо затухло print("⏸️ Жду, чтобы звук реально затух... (1.5с)") await asyncio.sleep(1.5) print("✅ Ответ проигран. Можно слушать снова.") # 👇 ТОЛЬКО ТЕПЕРЬ — МИКРОФОН ВКЛЮЧАЕТСЯ print("\n--- Ожидаю речи... ---")
С необходимостью ждать затухания эха мы поборемся в следующих сериях. А пока у нас есть вполне неплохо разговаривающий с нами по-русски и отвечающий без задержек локальный голосовой ассистент.
Небольшое демо: https://rutube.ru/video/f723cbaa66aa0e937c7749fa5ed4f913/
И всем добра!
