Когда радио начинает понимать слова
Представьте: вы слушаете FM-радио через SDR-приёмник, а на экране параллельно появляется текст того, что говорит диктор. Не магия, а комбинация двух технологий, которые обычно живут в разных вселенных. GNU Radio - для обработки радиосигналов, Whisper.cpp - для локального распознавания речи. Соединить их - задача нетривиальная, но выполнимая.
Ключевой момент: Whisper.cpp на январь 2026 года поддерживает все модели Whisper до v3-large, включая оптимизации для AVX2, AVX512 и NEON. Для embedded-систем есть quantized-версии моделей, которые работают даже на Raspberry Pi 5.
Почему именно эта связка, а не что-то проще?
Можно было бы взять готовый Python-скрипт, который слушает аудиовыход и передаёт его в Whisper. Но это неинтересно. GNU Radio даёт полный контроль над сигнальным трактом: от RF-сигнала до аудио. Можно добавить шумоподавление, эквалайзер, компрессор - всё средствами flowgraph, без переписывания кода.
Ещё один аргумент: производительность. Нативный C++ код Whisper.cpp работает быстрее, чем Python-обёртки. В потоковой обработке каждый миллисекунд на счету.
Что нам понадобится перед началом
- GNU Radio 3.10 или новее (на январь 2026 актуальна 3.12)
- Whisper.cpp собранный из исходников
- Модель Whisper (рекомендую medium или large-v3 - лучший баланс точности и скорости)
- Базовые знания C++ и Python
- SDR-приёмник (RTL-SDR, HackRF, LimeSDR - любой)
1 Готовим Whisper.cpp к интеграции
Сначала собираем Whisper.cpp с поддержкой shared library. По умолчанию он собирается как статическая библиотека, нам нужен динамический вариант.
git clone https://github.com/ggerganov/whisper.cpp
cd whisper.cpp
# На январь 2026 актуальна ветка main с поддержкой Whisper v3
make clean
WHISPER_BUILD_SHARED_LIB=on make -j$(nproc)
Проверяем, что библиотека создалась:
ls -la libwhisper.so* # или libwhisper.dylib на macOS
Теперь скачиваем модель. Для FM-радио с относительно чистым звуком подойдёт medium:
# Скачиваем модель и конвертируем в ggml-формат
./models/download-ggml-model.sh medium
Внимание: large-v3 модель точнее, но требует в 3 раза больше оперативной памяти. Для embedded-систем лучше взять tiny или base. Если нужна максимальная точность для диалектов - смотрите сравнение моделей для детекции диалектов.
2 Создаём скелет OOT-модуля GNU Radio
OOT (Out Of Tree) - способ создать пользовательский блок без модификации исходного кода GNU Radio. Используем gr_modtool:
cd ~
gr_modtool new whisper_sdr
cd whisper_sdr/gr-whisper_sdr
gr_modtool add -t general -l python whisper_transcriber
# Выбираем sync block с float input и string output
Теперь у нас есть заготовка блока. Но это только начало. Нужно понять архитектуру:
| Компонент | Назначение | Сложность |
|---|---|---|
| Аудио буфер | Накопление 30 секунд аудио (стандарт для Whisper) | Средняя |
| FFT преобразование | Передискретизация до 16 кГц | Низкая (есть в GNU Radio) |
| Интерфейс с libwhisper | Загрузка модели и инференс | Высокая |
3 Пишем ядро блока на C++
Вот где начинается настоящее веселье. Нужно создать блок, который:
- Принимает аудиопоток (float, 48 кГц типично для FM)
- Ресемплирует до 16 кГц (Whisper работает только с этой частотой)
- Накопливает 30 секунд аудио
- Передаёт в Whisper.cpp
- Выдаёт текст в Message Queue
Основная проблема: GNU Radio работает в real-time, а распознавание речи - batch-процесс. Решение - двойная буферизация.
// Упрощённая структура блока
class whisper_transcriber_impl : public whisper_transcriber
{
private:
// Whisper контекст
struct whisper_context *ctx;
// Аудио буферы
std::vector audio_buffer;
std::vector processing_buffer;
// Параметры
int sample_rate;
int whisper_sample_rate = 16000;
// Флаги состояния
bool is_processing;
public:
whisper_transcriber_impl(const std::string &model_path)
: gr::sync_block("whisper_transcriber",
gr::io_signature::make(1, 1, sizeof(float)),
gr::io_signature::make(0, 0, 0)),
is_processing(false)
{
// Загружаем модель Whisper
ctx = whisper_init_from_file(model_path.c_str());
if (!ctx) {
throw std::runtime_error("Failed to load Whisper model");
}
// Создаём порт для вывода текста
message_port_register_out(pmt::mp("text_out"));
// Буфер на 30 секунд аудио
audio_buffer.reserve(30 * sample_rate);
}
~whisper_transcriber_impl() {
whisper_free(ctx);
}
int work(int noutput_items,
gr_vector_const_void_star &input_items,
gr_vector_void_star &output_items)
{
const float *in = (const float *)input_items[0];
// Добавляем новые семплы в буфер
audio_buffer.insert(audio_buffer.end(), in, in + noutput_items);
// Если накопили 30 секунд и не обрабатываем сейчас
if (audio_buffer.size() >= 30 * sample_rate && !is_processing) {
// Запускаем обработку в отдельном потоке
std::thread(&whisper_transcriber_impl::process_audio, this).detach();
// Очищаем буфер для новых данных
audio_buffer.clear();
}
return noutput_items;
}
void process_audio() {
is_processing = true;
// Ресемплируем 48 кГц -> 16 кГц
std::vector resampled = resample_audio(audio_buffer, sample_rate, whisper_sample_rate);
// Параметры для Whisper
struct whisper_full_params params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
params.print_realtime = false;
params.print_progress = false;
params.print_timestamps = false;
params.print_special = false;
params.translate = false;
params.language = "ru";
params.n_threads = 4;
// Запускаем распознавание
if (whisper_full(ctx, params, resampled.data(), resampled.size()) != 0) {
std::cerr << "Whisper processing failed" << std::endl;
} else {
// Получаем результат
std::string text = get_transcription_text(ctx);
// Отправляем через message port
pmt::pmt_t msg = pmt::make_dict();
msg = pmt::dict_add(msg, pmt::mp("text"), pmt::mp(text));
msg = pmt::dict_add(msg, pmt::mp("timestamp"), pmt::mp(std::time(nullptr)));
message_port_pub(pmt::mp("text_out"), msg);
}
is_processing = false;
}
};
Критически важный момент: ресемплинг. Whisper работает ТОЛЬКО с 16 кГц. Если подать другую частоту дискретизации - получите тишину в текстовом виде. Используйте качественный ресемплер (например, libsamplerate), не линейную интерполяцию.
4 Собираем и тестируем блок
Сборка стандартная для OOT-модулей:
mkdir build
cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr ..
make -j$(nproc)
sudo make install
sudo ldconfig
Теперь проверяем, что блок появился в GNU Radio Companion. Но сначала протестируем на файле:
#!/usr/bin/env python3
# test_whisper_block.py
import numpy as np
from gnuradio import gr
from gnuradio.whisper_sdr import whisper_transcriber
# Создаём flowgraph в коде
class TestFlowgraph(gr.top_block):
def __init__(self):
gr.top_block.__init__(self)
# Источник - WAV файл
src = gr.wavfile_source("test_fm.wav", True)
# Наш блок
whisper = whisper_transcriber("/path/to/ggml-medium.bin")
# Приёмник сообщений
self.sink = gr.message_debug()
# Соединяем
self.connect(src, whisper)
self.msg_connect(whisper, "text_out", self.sink, "store")
# Колбэк при получении сообщения
def print_text(msg):
text = pmt.to_python(pmt.dict_ref(msg, pmt.intern("text"), pmt.PMT_NIL))
print(f"Распознано: {text}")
self.whisper_msg_port = whisper.message_port("text_out")
self.whisper_msg_port.set_msg_handler(print_text)
if __name__ == "__main__":
tb = TestFlowgraph()
tb.start()
tb.wait()
5 Собираем полную SDR-систему
Теперь самое интересное - целый FM-приёмник с распознаванием речи. В GNU Radio Companion создаём flowgraph:
- RTL-SDR Source (частота вашей FM-станции)
- WBFM Receive (стандартный блок для FM-стерео)
- Rational Resampler (48 кГц → 16 кГц)
- Наш whisper_transcriber блок
- QT GUI Text Sink или File Sink для вывода текста
Параметр, который все забывают настроить: squelch (шумоподавитель). Без него Whisper будет пытаться распознавать шум между передачами. Добавьте блок "Simple Squelch" после FM-демодулятора.
Оптимизации, без которых система будет тормозить
Наивная реализация работает, но съедает CPU. Вот что нужно улучшить:
1. Кэширование модели в памяти
Whisper.cpp загружает модель при каждом создании блока. В GNU Radio блоки могут пересоздаваться при изменении flowgraph. Решение - singleton-кэш:
// Глобальный кэш моделей
static std::map model_cache;
whisper_context* get_cached_model(const std::string& path) {
std::lock_guard lock(cache_mutex);
if (model_cache.find(path) == model_cache.end()) {
model_cache[path] = whisper_init_from_file(path.c_str());
}
return model_cache[path];
}
2. Асинхронная обработка с thread pool
Создание потока для каждого 30-секундного фрагмента - overhead. Используйте готовый пул потоков:
#include
static ThreadPool pool(2); // 2 потока для распознавания
// Вместо std::thread(...).detach()
pool.enqueue([this, audio_copy]() {
this->process_audio(audio_copy);
});
3. Quantized модели для embedded
Если система работает на Raspberry Pi или аналогичном железе, используйте квантованные модели:
# Вместо medium скачиваем quantized версию
./models/download-ggml-model.sh medium-q5_0
# Или даже tiny для реального времени
./models/download-ggml-model.sh tiny-q8_0
Типичные ошибки и как их избежать
| Ошибка | Причина | Решение |
|---|---|---|
| Whisper возвращает пустую строку | Неправильная частота дискретизации или тихий сигнал | Добавить блок "AGC" перед ресемплером, проверить 16 кГц |
| Задержка больше 30 секунд | Обработка занимает больше времени, чем запись | Использовать smaller модель или увеличить thread pool |
| Сегфолт при остановке flowgraph | Whisper работает в фоновом потоке при разрушении объекта | Добавить флаг остановки и join потоков в деструкторе |
| Плохое качество распознавания | FM-сигнал с шумами, multipath-искажения | Добавить блоки "Low Pass Filter" и "Noise Reduction" |
Что можно сделать дальше?
Система работает, но это только начало. Дальнейшие улучшения:
- VAD (Voice Activity Detection) - не ждать 30 секунд, а определять начало и конец речи. Есть готовые блоки для GNU Radio
- Диаризация - определять, кто говорит. Для этого понадобится что-то вроде Speakr с диаризацией
- Streaming-режим Whisper - Whisper.cpp поддерживает streaming API с 2024 года. Можно получать текст по мере обработки, а не ждать 30 секунд
- Многоканальность - обрабатывать несколько FM-станций параллельно
- Интеграция с VoIP - транскрибировать не радио, а SIP-звонки
Интересный факт: на DGX Spark с GPU-ускорением можно достичь задержки менее 766 мс от приёма сигнала до текста. Подробности в статье про голосового ассистента на DGX Spark.
Почему это вообще работает в реальном времени?
Секрет в асинхронности. Пока Whisper обрабатывает предыдущие 30 секунд, система продолжает записывать следующие. На современном процессоре (Intel i5/i7, AMD Ryzen) medium-модель обрабатывает 30 секунд аудио за 2-3 секунды. Значит, мы всегда отстаём всего на 2-3 секунды от реального времени.
Для FM-радио это приемлемо. Для двусторонней радиосвязи (рации) - уже нет. Там нужны tiny-модели и оптимизации.
Самое сложное в этой системе - не код, а настройка FM-приёмника. Плохой сигнал = плохая транскрипция. Whisper терпеть не может шум, клиппинг и искажения. Потратьте 80% времени на настройку RF-цепи, 20% - на код.
И последнее: эта система - proof of concept. Для продакшена нужна обработка ошибок, мониторинг, логирование и тесты. Но как отправная точка для экспериментов со SDR и AI - идеально.
Кстати, тот же подход работает для DAB+, DRM и даже аналогового TV с аудиодорожкой. Меняется только демодулятор в GNU Radio. Whisper-блок остаётся тем же. Вот что значит modular design.