В первой части я рассказал, как настроил NPU AMD XDNA2 на ноутбуке с Ryzen AI 7 350, как убил нейросетевой процессор одной командой pkill -9, как провёл вечер в семи тупиковых попытках восстановления и как обычная перезагрузка вернула всё к жизни. Также я описал архитектуру будущего новостного агента, выбор модели Qwen3-4B и системный промпт для суммаризации. Теперь перехожу к синтезу речи, полному коду агента и первому запуску.
Синтез речи: почему Piper победил, а Silero проиграл
Когда я впервые задумался об озвучке новостей, первой мыслью был Silero TTS. Я слышал о нём восторженные отзывы: естественный голос, шесть вариантов, бесплатная лицензия, работает офлайн. Казалось, вот оно — идеальное решение.
А потом я попробовал его установить.
Команда pip install silero-tts потянула за собой зависимость от PyTorch. На Fedora 44 с Python 3.14 это означало пятьсот пятьдесят четыре мегабайта бинарных пакетов. Плюс CUDA-библиотеки — не потому что у меня есть дискретная видеокарта NVIDIA, а потому что стандартный пакет PyTorch на PyPI собран с поддержкой CUDA по умолчанию. Отдельный CPU-сборки нужно искать вручную, прописывать индекс репозитория. Я начал установку, посмотрел на прогресс-бар, который полз со скоростью улитки, нажал Ctrl+C и понял: это не тот путь.
Пятьсот пятьдесят четыре мегабайта ради одного голоса. Ради того, чтобы произнести шесть предложений утром. Это как купить грузовик, чтобы ездить за хлебом в соседний двор.
Тогда я посмотрел на Piper. И влюбился.
Что такое Piper. Это движок синтеза речи, написанный на C++. Один бинарник. Одна модель в формате ONNX. Никаких зависимостей времени выполнения. Никакого Python-фреймворка. Никакого PyTorch. Вы передаёте текст на стандартный ввод, получаете WAV-файл на выходе. Всё. Размер бинарника — около пятидесяти мегабайт вместе с моделью.
Скорость работы на CPU: одна секунда аудио синтезируется за пятнадцать-двадцать миллисекунд. То есть минутная новостная сводка генерируется за одну-две секунды. На NPU это не нужно — Piper работает на центральном процессоре и делает это мгновенно.
Русские голоса. В Piper четыре русских голоса среднего качества. Все они обучены на открытых датасетах и звучат достойно. Не идеально — не как диктор на радио. Но чисто, разборчиво, без металлических нот и без пауз между словами. Для утренней сводки, которую слушаешь фоном, пока завариваешь кофе, — более чем достаточно.
Я перепробовал все четыре:
Первый — Ирина. Женский голос. Мягкий, спокойный, с лёгкой мелодичностью. Подходит для новостной сводки: не давит, не раздражает, легко воспринимается на слух. Я остановился на нём.
Второй — Денис. Мужской голос. Чёткая дикция, чуть резче, чем Ирина. Хорошо для инструкций и команд, но для новостей звучит суховато.
Третий — Дмитрий. Мужской голос. Похож на Дениса, но с чуть другой интонацией. Разница минимальна.
Четвёртый — Руслан. Мужской голос. Самый спокойный из всех. Говорит размеренно, без эмоций. Для новостей подходит, но Ирина всё равно выигрывает за счёт естественности.
Установка Piper заняла три минуты:
pip install --user piper-tts
Всё. Никаких полгигабайта. Никаких CUDA-зависимостей. Пятьдесят мегабайт, и готово.
Затем я скачал голос:
mkdir -p ~/.local/share/piper/voices
cd ~/.local/share/piper/voices
wget https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx
wget https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json
Два файла. Модель и её конфигурация. Сорок мегабайт. Всё.
Проверка:
echo "Привет, мир. Это тест синтеза речи." | ~/.local/bin/piper \
--model ~/.local/share/piper/voices/ru_RU-irina-medium.onnx \
--output_file /tmp/test.wav
aplay /tmp/test.wav
Из динамиков раздался чистый женский голос: «Привет, мир. Это тест синтеза речи». Без шипения. Без артефактов. Без пауз. Я усмехнулся. Полгигабайта PyTorch мне не нужны.
Подводные камни синтеза речи на русском
Но не всё было гладко. В первые дни я столкнулся с несколькими проблемами, которые не описаны ни в одном руководстве.
Проблема первая: цифры. Если в тексте встречается «2026 год», Piper может произнести это как «двадцать двадцать шесть» или как «две тысячи двадцать шесть». Поведение зависит от контекста и от того, как именно число записано. Процент «15%» вообще произносится непредсказуемо. Решение: в промпте для LLM я указал «числа пиши словами». Модель генерирует текст без цифр, и проблема исчезает.
Проблема вторая: аббревиатуры. «США», «ООН», «РФ» — Piper произносит их по буквам. «Эс-ша-а». Звучит неестественно. Решение: в постобработке текста перед синтезом я добавил замену распространённых аббревиатур на полные формы. «США» на «Соединённые Штаты Америки», «РФ» на «Россия». Не идеально, но работает.
Проблема третья: длинные предложения. Если предложение превышает сорок-пятьдесят слов, интонация начинает «плыть». Голос теряет ударение, паузы становятся неестественными. Решение: в промпте я ограничил длину одним предложением на новость. Модель не генерирует длинные конструкции.
Проблема четвёртая: знаки препинания. Точка с запятой, тире, многоточие — всё это влияет на интонацию. Точка с запятой иногда вызывает неестественную паузу. Многоточие — растягивание последнего слога. Решение: перед передачей в Piper я убираю все знаки препинания, кроме точки и запятой. Простая регулярка.
Проблема пятая: ударения. В русском языке ударение подвижное, и модель иногда ставит его не туда. «Договор» вместо «договор», «звонит» вместо «звонит». Это не ошибка синтеза — это ограничение модели. Для новостной сводки терпимо. Если критично — можно использовать фонетический словарь, но я не стал усложнять.
Полный код агента: от первой до последней строки
Теперь соберу всё вместе. Файл ~/news_agent/news_digest.py. Один файл. Три зависимости. Четыре функции.
Начну с импортов и конфигурации:
#!/usr/bin/env python3
"""
Новостной агент: RSS -> LLM на NPU -> TTS -> динамики.
Запуск: python3 ~/news_agent/news_digest.py
"""
import argparse
import logging
import subprocess
import sys
from pathlib import Path
import feedparser
from openai import OpenAI
# --- Конфигурация ---
RSS_URL = "https://3dnews.ru/breaking/rss/"
LLM_BASE_URL = "http://localhost:52625/v1"
LLM_API_KEY = "sk-local"
LLM_MODEL = "qwen3:4b"
PIPER_BIN = Path.home() / ".local/bin/piper"
PIPER_MODEL = Path.home() / ".local/share/piper/voices/ru_RU-irina-medium.onnx"
PIPER_CONFIG = Path.home() / ".local/share/piper/voices/ru_RU-irina-medium.onnx.json"
OUTPUT_DIR = Path.home() / ".config/news-agent/audio"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
SYSTEM_PROMPT = """Ты — редактор новостного дайджеста.
1. Выдели 3-5 самых важных новостей.
2. Каждую новость опиши одним предложением.
3. Числа пиши словами (TTS не любит цифры).
4. Без маркдауна, списков, нумерации.
5. Не обрывай предложения."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(Path.home() / ".config/news-agent/agent.log"),
logging.StreamHandler(),
],
)
log = logging.getLogger("news-agent")
Функция загрузки новостей:
def fetch_news() -> list[dict]:
"""Загружает свежие новости из RSS-ленты."""
log.info("Загружаю новости из %s", RSS_URL)
feed = feedparser.parse(RSS_URL)
if feed.bozo and not feed.entries:
log.error("RSS-лента недоступна: %s", feed.bozo_exception)
sys.exit(1)
entries = feed.entries[:10]
news = [
{"title": e.title, "summary": e.get("summary", ""), "link": e.get("link", "")}
for e in entries
]
log.info("Загружено %d новостей", len(news))
return news
Функция суммаризации:
def summarize(news: list[dict]) -> str:
"""Прогоняет новости через LLM на NPU и возвращает сводку."""
news_text = "\n".join(
f"- {item['title']}: {item['summary']}" for item in news
)
client = OpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY)
log.info("Отправляю %d новостей в модель %s", len(news), LLM_MODEL)
response = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Новости:\n{news_text}"},
],
temperature=0.3,
max_tokens=2000,
)
summary = response.choices[0].message.content.strip()
log.info("Сводка готова (%d символов)", len(summary))
return summary
Функция синтеза речи:
def synthesize(text: str, output_path: Path) -> None:
"""Синтезирует текст в WAV через Piper."""
log.info("Синтезирую речь (%d символов)", len(text))
cmd = [
str(PIPER_BIN),
"--model", str(PIPER_MODEL),
"--config", str(PIPER_CONFIG),
"--output_file", str(output_path),
]
result = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
if result.returncode != 0:
log.error("Piper завершился с ошибкой: %s", result.stderr.decode())
sys.exit(1)
log.info("Аудио сохранено: %s (%d байт)", output_path, output_path.stat().st_size)
Функция воспроизведения:
def play(audio_path: Path) -> None:
"""Воспроизводит WAV через aplay."""
log.info("Воспроизвожу %s", audio_path)
subprocess.run(["aplay", str(audio_path)], check=True)
И главная функция:
def main():
parser = argparse.ArgumentParser(description="Новостной агент")
parser.add_argument("--save-only", action="store_true", help="Не воспроизводить")
parser.add_argument("--npu-only", action="store_true", help="Только проверить NPU")
args = parser.parse_args()
if args.npu_only:
result = subprocess.run(["xrt-smi", "examine"], capture_output=True, text=True)
if "NPU Krackan" in result.stdout:
log.info("NPU в порядке")
else:
log.error("NPU не найден!")
sys.exit(1)
return
# Шаг 1: загрузка
news = fetch_news()
# Шаг 2: суммаризация
summary = summarize(news)
print("\n--- Сводка ---")
print(summary)
print("--- Конец сводки ---\n")
# Шаг 3: синтез
output_path = OUTPUT_DIR / "digest.wav"
synthesize(summary, output_path)
# Шаг 4: воспроизведение
if not args.save_only:
play(output_path)
else:
log.info("Режим --save-only: воспроизведение пропущено")
if __name__ == "__main__":
main()
Вот и всё. Сто двадцать строк. Четыре шага. Ни одного фреймворка. Ни одного контейнера. Один файл, который делает всё.
Установка зависимостей: три команды
Весь стек ставится за три команды:
pip install --user feedparser openai piper-tts
mkdir -p ~/.local/share/piper/voices
cd ~/.local/share/piper/voices && wget [адрес модели Ирины] && wget [адрес конфига]
Плюс, конечно, уже установленные и работающие:
source ~/activate_npu.sh
flm serve qwen3:4b
FastFlowLM должен быть запущен до того, как вы запустите агента. Модель должна быть загружена в NPU. Если сервер не запущен, агент упадёт с ошибкой соединения. Это нормально и ожидаемо. Я добавил проверку в начале скрипта: если порт 52625 не отвечает, выводится понятное сообщение с инструкцией.
Первый запуск: как это звучит
Я помню момент первого запуска. Всё было готово. Сервер работает. Модель загружена. Голос скачан. Скрипт написан. Я открыл терминал и ввёл:
python3 ~/news_agent/news_digest.py
Три секунды тишины. Потом в логах побежали строки: «Загружаю новости из 3dnews.ru». Потом «Загружено 10 новостей». Потом «Отправляю 10 новостей в модель qwen3:4b». И тут началась пауза. Секунд восемь. Модель думает. Генерирует. На NPU. Локально.
Потом в терминале появился текст сводки. Четыре предложения. Короткие, чёткие, без воды.
А потом из динамиков раздался голос. «Сегодня в мире технологий: компания из Купертино представила новый процессор для мобильных устройств. В сегменте графических чипов ожидается обновление линейки с акцентом на энергоэффективность. Разработчики обсуждают изменения в стандарте беспроводной связи нового поколения».
Голос Ирины. Ровный. Спокойный. Без пауз между словами. Без «э-э-э». Без металлических нот, которые бывают у дешёвых синтезаторов. Просто голос, который читает мне новости.
Я сидел и улыбался. Полторы минуты от запуска до звука. И ни одного пакета данных не ушло в интернет. Всё — на моём железе. На моём NPU. На моём процессоре. В моём ноутбуке.
Малоизвестные факты о локальных нейросетях и NPU
В процессе работы я наткнулся на несколько вещей, которые не лежат на поверхности и не описаны в популярных статьях.
Факт первый. Архитектура AIE в чипах AMD XDNA2 использует не традиционную фон-неймановскую модель, а пространственную потоковую обработку данных. Это значит, что данные не «вытягиваются» из памяти процессором, а «протекают» через массив вычислительных ядер, как вода через систему шлюзов. Отсюда минимальные задержки на перемещение данных и максимальная эффективность на конвейерных операциях. Именно поэтому инференс на NPU быстрее, чем на GPU, для небольших моделей: нет накладных расходов на чтение и запись в общую память.
Факт второй. Модель на четыре миллиарда параметров в формате, оптимизированном для NPU, занимает около трёх гигабайт оперативной памяти. Но при этом для генерации одного токена ей нужно около сорока мегабайт дополнительной памяти для промежуточных активаций. Это значит, что на ноутбуке с тридцатью двумя гигабайтами вы можете одновременно держать модель в памяти, работать в браузере, слушать музыку и запускать агента. Никакого дефицита ресурсов.
Факт третий. Прошивка NPU обновляется не через обычный механизм обновления микрокода процессора. Она загружается каждый раз при инициализации драйвера. Это значит, что файл прошивки можно заменить «на горячую» — без перепрошивки чипа, без утилит, без риска окирпичивания. Просто кладёте новый файл в /lib/firmware/amdnpu/ и перезагружаетесь. Если что-то пойдёт не так — откатываете файл обратно.
Факт четвёртый. Кодовое имя «Krackan» для XDNA2 — это внутреннее обозначение AMD, которое никогда не упоминалось в официальных маркетинговых материалах. Оно всплыло только в исходниках драйвера amdxdna и в логах ядра. Пользователи видят «NPU Krackan 1» в выводе xrt-smi и не понимают, откуда взялось это название. А это просто имя из внутреннего трекера проектов. Как «Strix Point» для предыдущего поколения.
Факт пятый. Библиотека XRT, которая управляет взаимодействием с NPU, содержит в себе код для работы не только с нейросетевыми процессорами, но и с программируемыми матрицами Xilinx Versal и Alveo. Тот же самый стек программного обеспечения, который управляет ускорителями в дата-центрах, работает и в вашем ноутбуке. Разница только в масштабе: в дата-центре массив из тысяч вычислительных ядер, в ноутбуке — из сорока восьми. Но программный интерфейс идентичен.
Настройка автозапуска: чтобы работало само
Агент работает по команде. Но мне хотелось, чтобы он запускался сам. Каждый день. В семь тридцать утра. Чтобы я просыпался, шёл на кухню, ставил чайник, а из ноутбука уже звучала сводка.
Вариантов несколько. Первый — cron. Классика. Второй — systemd timer. Третий — скрипт в автозагрузке KDE.
Я выбрал systemd timer, потому что он надёжнее cron на ноутбуке. Если ноутбук в семь тридцать был в спящем режиме, systemd timer сработает сразу после пробуждения. Cron — нет.
Файл юнита ~/.config/systemd/user/news-agent.service:
[Unit]
Description=News Agent: RSS -> LLM -> TTS
[Service]
Type=oneshot
ExecStartPre=/bin/bash -c 'source /opt/xilinx/xrt/setup.sh && /opt/fastflowlm/bin/flm serve qwen3:4b &'
ExecStart=/usr/bin/python3 %h/news_agent/news_digest.py
TimeoutStartSec=120
Environment=PATH=%h/.local/bin:/opt/fastflowlm/bin:/usr/local/bin:/usr/bin
Файл таймера ~/.config/systemd/user/news-agent.timer:
[Unit]
Description=Daily news digest at 7:30
[Timer]
OnCalendar=*-*-* 07:30:00
Persistent=true
[Install]
WantedBy=timers.target
Активация:
systemctl --user daemon-reload
systemctl --user enable --now news-agent.timer
Параметр Persistent=true означает: если ноутбук был выключен в семь тридцать, таймер сработает при следующем включении. Это важно для ноутбука, который на ночь уходит в сон или вообще выключается.
Проверка статуса:
systemctl --user status news-agent.timer
systemctl --user list-timers
Теперь каждое утро — сводка. Без моего участия. Без кликов. Без команд. Просто голос из динамиков.
Что я изменил бы, если бы начинал заново
Оглядываясь назад, я вижу несколько вещей, которые сделал бы иначе.
Первое. Не пытался бы ставить Silero TTS. Потерял час на установку, прерывание, осознание, что это не то, и переход на Piper. Piper был очевиден с самого начала: лёгкий, быстрый, офлайн, русский голос. Нужно было просто посмотреть на размер зависимостей перед установкой.
Второе. Не патчил бы драйвер. Когда NPU завис, я потратил три часа на поиск патчей, сборку, тестирование. А нужно было просто перезагрузиться. Перезагрузка решила бы проблему за две минуты. Но я не знал этого. Теперь знаю.
Третье. Начал бы с более простой модели. Первые тесты я проводил на Qwen3-8B. Она медленнее, требует больше памяти, а для суммаризации десяти заголовков избыточна. Четырёх миллиардов параметров хватает с головой. Начал бы с неё сразу.
Четвёртое. Написал бы промпт для LLM раньше, чем запустил агента. Первые несколько запусков я получал сводки с маркдауном, с нумерацией, с цифрами. Каждый раз переписывал промпт. Нужно было сесть, подумать, написать финальную версию с учётом ограничений TTS — и только потом запускать.
Пятое. Заложил бы обработку ошибок с самого начала. Что делать, если RSS не отвечает? Что если сервер LLM не запущен? Что если Piper не нашёл модель? Сейчас всё это обработано. Но в первой версии скрипт просто падал с traceback. Неудобно.
Производительность: цифры и ощущения
Для тех, кто любит конкретику, приведу замеры.
Загрузка RSS: полторы-две секунды. Зависит от сети.
Суммаризация на NPU: восемь-двенадцать секунд. Десять новостей, генерация четырёх-пяти предложений. Скорость: двадцать-двадцать пять токенов в секунду.
Синтез речи: одна-две секунды для текста длиной двести-триста слов.
Воспроизведение: сорок-шестьдесят секунд аудио.
Итого от запуска скрипта до начала звучания: пятнадцать-двадцать секунд. Из них двенадцать — это работа модели. Остальное — сеть и синтез.
Для сравнения: на CPU без NPU суммаризация тех же десяти новостей занимает полторы-две минуты. Разница в восемь раз. Это и есть смысл нейросетевого процессора. Не абстрактные «сорок семь триллионов операций в секунду» из маркетинговых буклетов, а конкретные восемь секунд вместо девяноста.
Энергопотребление под нагрузкой: весь ноутбук потребляет около восемнадцати ватт во время генерации. Из них NPU — около пяти ватт. Для сравнения: дискретная видеокарта среднего уровня под нагрузкой инференса потребляет сто пятьдесят-двести ватт. Разница в тридцать-сорок раз. На ноутбуке с батареей на шестьдесят ватт-часов это означает, что я могу сгенерировать триста-четыреста сводок от одного заряда. Или, что более реалистично, вообще не думать о батарее, потому что агент работает полминуты в день.
Сравнение с облачными альтернативами
Я сознательно выбрал локальный путь. Но для полноты картины сравню его с тем, что предлагают облачные сервисы.
Облачный AI-ассистент. Подписка. Данные уходят на сервер. Зависимость от интернета. Задержка на сеть. Качество суммаризации высокое. Но приватность — нулевая. И если сервис закроется или изменит условия, я останусь без инструмента.
Локальный агент на NPU. Бесплатно после покупки ноутбука. Данные не покидают устройство. Работает без интернета. Задержка минимальная. Стоимость — ноль. Качество суммаризации чуть ниже, чем у больших облачных моделей, но для новостной сводки — более чем достаточное. И я полностью контролирую каждый аспект: модель, промпт, голос, расписание.
Гибридный подход. Можно использовать локальную модель для ежедневной сводки и облачную — для сложных запросов. Но это усложнение. Для моей задачи хватает локального решения.
Для меня выбор очевиден. Не хочу, чтобы мои интересы, мои паттерны чтения, мои запросы оседали на чужих серверах. Не хочу зависеть от доступности сети. Локальный агент — это свобода. И тишина. В смысле отсутствия уведомлений, баннеров, всплывающих окон и рекламы.
Часть 2 из 4.
В следующей части: подробный разбор работы с FastFlowLM, альтернативные модели и их сравнение, тонкая настройка параметров генерации, решение проблем с режимом рассуждения в Qwen3, практические советы по оптимизации промптов для TTS, а также планы на будущее — RAG-контур, голосовой ассистент и интеграция с мессенджерами.


Добавить комментарий