Нормализация TTS текста на Rockchip: гайд для голосовых ассистентов | AiManual
AiManual Logo Ai / Manual.
28 Фев 2026 Гайд

DIY голосовой ассистент на Rockchip: решаем проблему нормализации текста для TTS (омонимы, числа, сокращения)

Практическое руководство по реализации нормализации текста для синтеза речи в голосовом ассистенте на платформе Rockchip. Решаем проблемы омонимов, чисел и сокр

Зачем мучиться с нормализацией на Rockchip?

Вы собрали локального голосового ассистента, как в нашем прошлом гайде, но на мощной видеокарте. Потом попробовали запихнуть его в коробочку на Rockchip RK3588 — и тут начался ад. Ассистент внезапно стал говорить 'двадцать двадцать шесть' вместо '2026', 'кгб' вместо 'КГБ' и 'он ударил по замку' в контексте про дверь. Это не баг, это нормализация текста — та самая невидимая магия, которая превращает сырой текст в то, что можно произнести. На сервере вы бы кинули вызов тяжелой нейросети, но на Rockchip с его скромными ядрами Cortex-A76 и ограниченной RAM такой фокус не пройдет. Ресурсы здесь считаются в мегабайтах, а не в гигабайтах.

Нормализация — это не опция, а обязательный препроцессинг. Без нее даже лучшая TTS-модель вроде Qwen3-TTS или Kokoro будет звучать как дешевый робот из 90-х.

Почему обычные библиотеки отказывают на embedded?

Вы открываете PyPI, находите популярную библиотеку для нормализации текста, ставите ее на свой Rockchip-девайс — и через пять минут получаете 'Killed'. Поздравляю, OOM killer сделал свое дело. Библиотеки вроде num2words с кучей зависимостей или тяжеловесные NLP-пайплайны просто не предназначены для работы в условиях 1-2 ГБ оперативной памяти и без swap. Они загружают гигабайтные модели, создают кучу временных объектов и благополучно умирают.

Решение? Писать свой, максимально легковесный механизм, который работает по принципу 'вопрос жизни и смерти'. Мы разделим задачу на две части: детерминированные правила (числа, даты, аббревиатуры) и контекстную обработку (омонимы). Первое делаем на регулярках и словарях, второе — на крошечной ML-модели или вообще отказываемся от нее в пользу эвристик, если контекст известен (например, ассистент отвечает только на вопросы о погоде).

1Голая настройка окружения на Rockchip

Забудьте про Conda и толстые виртуальные окружения. Мы будем компилировать только необходимое. Предположим, у вас уже стоит образ Armbian или Debian для RK3588. Первым делом — чистим автозагрузку от всего лишнего и ставим минимальный Python 3.11 (на февраль 2026 это все еще актуальная и стабильная версия для embedded).

# Освобождаем RAM: отключаем ненужные сервисы
sudo systemctl disable bluetooth.service
sudo systemctl disable avahi-daemon.service

# Ставим голый Python и компилируем зависимости с оптимизацией под ARM
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3.11-dev build-essential

# Создаем легковесное venv без ensurepip
python3.11 -m venv --without-pip ~/tts_norm_env
source ~/tts_norm_env/bin/activate
# Устанавливаем pip вручную
curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11
💡
Не используйте apt install python3-pip — он часто тянет старый pip и кучу ненужных пакетов. Метод с get-pip.py чище и легче.

2Пишем rule-based нормализатор с нуля

Начнем с чисел — самой частой проблемы. Библиотека num2words слишком большая? Выдернем из нее только логику для русского языка и упростим. Вот каркас класса, который живет в памяти десятки килобайт, а не мегабайты.

import re

class LightweightRussianNormalizer:
    def __init__(self):
        # Минимальные словари
        self.ones = ['', 'один', 'два', 'три', 'четыре', 'пять',
                     'шесть', 'семь', 'восемь', 'девять']
        self.tens = ['', 'десять', 'двадцать', 'тридцать', 'сорок',
                     'пятьдесят', 'шестьдесят', 'семьдесят',
                     'восемьдесят', 'девяносто']
        self.teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать',
                      'четырнадцать', 'пятнадцать', 'шестнадцать',
                      'семнадцать', 'восемнадцать', 'девятнадцать']
        
    def normalize_numbers(self, text: str) -> str:
        # Ищем целые числа до 9999 (хватит для большинства бытовых сценариев)
        def replace_number(match):
            num = int(match.group(0))
            if num == 0:
                return 'ноль'
            return self._int_to_words(num)
            
        return re.sub(r'\b\d{1,4}\b', replace_number, text)
    
    def _int_to_words(self, n: int) -> str:
        # Упрощенная конвертация без учета рода и падежей
        if n < 10:
            return self.ones[n]
        elif n < 20:
            return self.teens[n - 10]
        elif n < 100:
            tens_part = self.tens[n // 10]
            ones_part = self.ones[n % 10]
            return f'{tens_part} {ones_part}'.strip()
        elif n < 1000:
            # Для экономии места пропускаем сложные склонения
            hundreds = ['', 'сто', 'двести', 'триста', 'четыреста',
                        'пятьсот', 'шестьсот', 'семьсот', 'восемьсот',
                        'девятьсот']
            return f'{hundreds[n // 100]} {self._int_to_words(n % 100)}'.strip()
        else:
            # Тысячи
            thousands = self._int_to_words(n // 1000)
            # Простейшая замена для женского рода
            if thousands.endswith('один'):
                thousands = thousands[:-4] + 'одна'
            elif thousands.endswith('два'):
                thousands = thousands[:-3] + 'две'
            rest = self._int_to_words(n % 1000)
            return f'{thousands} тысяча {rest}'.strip()
    
    def normalize_text(self, text: str) -> str:
        text = self.normalize_numbers(text)
        # Другие правила можно добавить здесь
        return text

# Использование
norm = LightweightRussianNormalizer()
print(norm.normalize_text("Температура 25 градусов, время 14:30."))
# Вывод: "Температура двадцать пять градусов, время четырнадцать:тридцать."

Этот код занимет ~50 строк и не требует внешних зависимостей. Да, он не обрабатывает миллионы и склонения для 'два градуса' vs 'две тысячи', но для первого приближения сойдет. Главное — он работает в реальном времени на ядре Cortex-A55.

3Аббревиатуры и сокращения: хардкод или словарь?

Здесь rule-based подход идеален. Создаем Python-словарь с наиболее частыми сокращениями. Но не берите готовые списки из интернета — они содержат тысячи записей, 90% которых вам не нужны. Проанализируйте логи вашего ассистента: какие аббревиатуры действительно встречаются?

class AbbreviationNormalizer:
    def __init__(self):
        # Минимальный набор для бытового ассистента
        self.abbr_map = {
            'кг': 'килограмм',
            'г': 'грамм',
            'см': 'сантиметр',
            'км': 'километр',
            'л': 'литр',
            'м': 'метр',
            'ул.': 'улица',
            'д.': 'дом',
            'кв.': 'квартира',
            'руб.': 'рубль',
            'USD': 'доллар',
            'EUR': 'евро',
            'КГБ': 'К Г Б',  # Произносится по буквам
            'НАТО': 'НА ТО',  # Аналогично
            'СМИ': 'С М И',
        }
        # Регулярка для поиска сокращений с точками и без
        self.pattern = re.compile(r'\b(' + '|'.join(re.escape(k) for k in self.abbr_map.keys()) + r')\b')
    
    def normalize(self, text: str) -> str:
        return self.pattern.sub(lambda m: self.abbr_map[m.group(0)], text)

# Добавляем в общий нормализатор
class ComprehensiveNormalizer(LightweightRussianNormalizer, AbbreviationNormalizer):
    def normalize_text(self, text: str) -> str:
        text = super().normalize_numbers(text)
        text = self.normalize(text)  # Из AbbreviationNormalizer
        return text

Не пытайтесь обработать все возможные сокращения. Каждая запись в словаре — это дополнительное сравнение при каждом вызове. Для ассистента, который заказывает пиццу и сообщает погоду, хватит 50-100 ключей.

Омонимы: тот самый камень преткновения

Вот мы и подошли к самой сложной части. 'Замок' (дверной) и 'замок' (крепость), 'ключ' (от двери) и 'ключ' (источник). На сервере вы бы использовали модель для разрешения лексической многозначности, но на Rockchip нужно идти на компромиссы.

Вариант 1: Игнорировать проблему. Да, просто пусть TTS произносит как получится. В 60% случаев контекст ясен из интонации (шутка). Реально это сработает, если ваш ассистент узкоспециализирован. Например, умный дом никогда не скажет про 'кредитный замок'.

Вариант 2: Контекстные эвристики. Если в предложении есть слова 'дверь', 'открыть', 'сломать' — вероятно, речь о замке-устройстве. Добавляем простые правила.

def disambiguate_homonyms(text: str) -> str:
    # Примитивный, но работающий на маломощном железе метод
    words = text.split()
    for i, word in enumerate(words):
        if word.lower() == 'замок':
            # Смотрим на соседние слова
            context = ' '.join(words[max(0, i-2):i+3]).lower()
            if any(c in context for c in ['дверь', 'ключ', 'открыть', 'железный']):
                words[i] = 'замок_дверной'
            elif any(c in context for c in ['король', 'рыцарь', 'гора', 'старый']):
                words[i] = 'замок_крепость'
    return ' '.join(words)

Вариант 3: Микро-модель ONNX. Если на устройстве есть хотя бы 100 МБ свободной RAM и NPU (например, Rockchip RK3588 с его 6 TOPS), можно взять крошечную бинарную классификационную модель. Обучьте ее на нескольких тысячах примеров, конвертируйте в ONNX и запускайте через onnxruntime для ARM. Веса займут пару мегабайт, инференс — миллисекунды.

💡
В 2026 году для таких задач популярны ультралегкие архитектуры вроде MobileBERT или дистиллированные версии моделей от Qwen. Ищите готовые ONNX-модели для классификации омонимов на Hugging Face — возможно, сообщество уже что-то подготовило.

Интеграция с TTS-движком

Вы написали нормализатор. Теперь его нужно встроить в пайплайн перед отправкой текста в TTS. Если вы используете Pocket-TTS или его порт для Rockchip, добавьте вызов нормализатора прямо перед вызовом модели. Важно: нормализатор должен работать в том же потоке, что и TTS, чтобы избежать накладных расходов на межпоточное взаимодействие.

# Пример интеграции
from your_tts_engine import TTSWrapper

class TTSPipeline:
    def __init__(self, tts_model_path: str):
        self.tts = TTSWrapper(tts_model_path)  # Ваш загрузчик TTS
        self.normalizer = ComprehensiveNormalizer()
        
    def speak(self, text: str):
        normalized_text = self.normalizer.normalize_text(text)
        # Дополнительно можно вызвать disambiguate_homonyms
        audio = self.tts.generate(normalized_text)
        return audio

Где все падает: частые ошибки и как их обойти

ОшибкаПричинаРешение
Ассистент 'задумывается' на 2-3 секунды перед ответомНормализатор обрабатывает весь текст целиком, включая длинные числа или множественные сокращения.Кэшируйте результаты нормализации для часто встречающихся фраз (например, 'температура сегодня'). Используйте LRU-кэш на 100-200 записей.
После нормализации TTS выдает ошибку кодировкиВаш rule-движок мог вставить символы, которые TTS-модель не понимает (например, подчеркивания в 'замок_дверной').Финализируйте текст: удалите все не-алфавитные символы, кроме пробелов и знаков препинания, которые поддерживает TTS.
Память растет с каждым запросом и не освобождаетсяУтечка в Python-объектах или кэше без ограничений.Используйте weakref для кэшей, перезапускайте процесс нормализатора каждые N часов (crash-only design).

FAQ: коротко о главном

Можно ли использовать готовую библиотеку типа ruaccent или Natasha на Rockchip?

Нет. Они зависят от TensorFlow/PyTorch и моделей на сотни мегабайт. Вы либо не запустите, либо устройство начнет троттлить из-за нехватки памяти.

Как быть с датами и временем?

Добавьте отдельный модуль, который регулярками вылавливает шаблоны вроде '12.02.2026' или '14:30' и преобразует их в 'двенадцатое февраля две тысячи двадцать шестого года' и 'четырнадцать тридцать'. Не пытайтесь покрыть все форматы — только те, что встречаются в диалогах.

А если я хочу поддержать несколько языков?

Придется писать отдельные rule-наборы для каждого языка и переключать их в зависимости от входного текста. Но помните: каждый дополнительный словарь — это память. На embedded лучше сделать монолингвального ассистента.

Стоит ли использовать аппаратное ускорение NPU для нормализации?

Только если вы пошли по пути микро-ML-модели для омонимов. Для rule-based логики NPU бесполезен — он предназначен для матричных операций. Ваши регулярки быстрее выполнятся на CPU.

Что в итоге?

Нормализация текста на Rockchip — это искусство компромиссов. Вы отказываетесь от полноты в пользу скорости и минимального потребления памяти. Начните с самого простого rule-движка, обрабатывающего числа и топ-50 сокращений. Запустите, послушайте, где ассистент косячит. Добавляйте правила точечно, как заплатки. Через неделю у вас будет система, которая работает в реальном времени и не съедает больше 5% ресурсов процессора.

И последний совет: не делайте нормализатор универсальным. Жестко заточите его под фразы, которые говорит именно ваш ассистент. Если он никогда не обсуждает финансовые транзакции, не нужно учить его произносить '1.000.000 рублей'. Сконцентрируйтесь на 20% функционала, который используется в 80% случаев. Остальное — просто проигнорируйте. (Да, это ересь с точкичения чистого компьютерного лингвиста, но embedded-разработка — это всегда ересь).

Подписаться на канал