Перейти до основного вмісту

Декодер телеграфу, що працює у вкладці

· 6 хв. читання
UR3PKI
Software Engineer

Звук зі звукової карти перетворюється на текст, і все це відбувається у вкладці: ані запису на сервер, ані звернень до чужого API. Нейромережа на 14 МБ лежить поруч зі сторінкою, рушій ONNX зібраний у той самий бандл, і після першого завантаження сторінка працює без мережі.

Цікаве тут не в тому, що воно взагалі запрацювало. Цікаве в тому, скільки способів зробити це неправильно, жоден з яких не повідомляє про себе помилкою.

Тракт

звукова карта (48 кГц)
→ AudioWorklet → кільцевий буфер
→ ресемпл до 3200 Гц із фільтром
→ БПФ 256, періодичне вікно Ганна, reflect-доповнення
→ спектрограма 65 смуг 400–1200 Гц, log1p
→ шумозаглушувач: тон є чи немає
→ ONNX: 8 секунд звуку → розподіли по кадрах
→ жадібне CTC-декодування
→ склеювання з попереднім вікном

Вікно у вісім секунд береться кожні дві, тож сусідні проходи ділять шість секунд звуку. Це і є головна складність, але про неї нижче.

Браузер псує звук за замовчуванням

Мікрофонний вхід приходить із трьома «покращеннями»: ехопридушення, шумозаглушення й автопідсилення. Для слабкого телеграфного сигналу кожне з них фатальне, а найгірше — шумозаглушення: рівний тон це буквально те, що воно вміє прибирати.

audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
}

Друге з тієї ж родини: захоплення живе в AudioWorklet, а не на таймері. У фоновій вкладці таймери придушуються приблизно до одного спрацювання на хвилину, а аудіопотік реального часу не придушується взагалі. З таймером декодер працював би, доки на нього дивляться.

Передобробка не є предметом вибору

Мережа вивчила один конкретний вхід, і кожна константа взята з її метаданих, а не обрана: періодичне вікно Ганна (hanning(N+1)[:-1], а не hanning(N)), reflect-доповнення на 128 відліків, смуги 32…96 при 12,5 Гц на смугу, log1p.

Спектрограма, що відрізняється від навченої вікном або половиною смуги, не псується плавно — вона дає впевнену нісенітницю. Найпідступніше з цього — доповнення: у numpy reflect не повторює крайній відлік, тож [1,2,3] перетворюється на [3,2,1,2,3,2,1]. Помилка тут зсуває кожен кадр на пів вікна й коштує першої літери кожного декоду.

Саме тому в проєкті є scripts/check-dsp.ts: він синтезує телеграф із відомим текстом, проганяє його через справжню частоту дискретизації, справжній ресемплер і справжню спектрограму — і питає модель, що вона почула. Юніт-тестів перетворення для цього мало.

Ресемпл, який не можна робити наївно

Приклад від автора моделі змінює частоту самою лише лінійною інтерполяцією — і чесно про це пише: це приклад із мінімумом залежностей, а документований спосіб готувати звук там — ffmpeg, який спершу фільтрує.

Повторити саму лише інтерполяцію означало б завернути все, що вище 1600 Гц, назад у смугу, яку дивиться модель, а карта, що видає 48 кГц, має там чимало. Тому фільтр, який застосував би ffmpeg, застосований тут: віконний sinc на 255 відводів, вікно Блекмена, зріз 1400 Гц — нижче за Найквіста цільової частоти й вище за 1200 Гц, які читає мережа.

Ядро обчислюється лише в тих позиціях, які справді потрібні, тож вартість пропорційна вихідній частоті, а не вхідній: близько 1,6 мільйона множень-додавань на секунду звуку.

Чому CTC і чому жадібне декодування

Модель навчена з CTC, тож на виході — один розподіл на кожні 15 мс кадру по 41 символу плюс порожній. Найкращий шлях читається жадібно: у кожному кадрі береться найімовірніший клас, порожні викидаються, однакові підряд склеюються.

Це не найкращий можливий декодер — променевий пошук із мовною моделлю читав би краще. Але декодер телеграфу, який тихо вигадує правдоподібні слова, гірший за той, що друкує почуте. Передбачувана поведінка тут дорожча за кілька відсотків точності.

Найдорожча помилка: склеювання вікон

Сусідні вікна ділять шість секунд звуку, тож ділять і текст. Але не той самий текст: спільні секунди декодуються заново й повертаються іншими на літеру-другу. Тому порівняння на рівність не працює — одна розбіжність усередині перекриття, і ціле вікно дописується вдруге.

Саме це перетворило перший живий декод на EN35UKR PSE K, повторене п'ятнадцять разів.

Робоче рішення — шукати перекриття згодою, а не рівністю: найдовший хвіст уже надрукованого, який збігається з головою нового вікна щонайменше на 75% символів.

І друга половина цієї ж задачі: ширину пошуку треба обмежувати часом, а не круглим числом символів. Оператор, що кличе CQ, повторює той самий текст кожні п'ятнадцять секунд, і пошук, достатньо широкий, щоб охопити два виклики, радо склеїть їх в один і втратить передачу.

Тиша, яка читається як текст

Полишена сама на себе, модель читає шум як потік E, I, T і S — найкоротших візерунків абетки. Щось же має бути найімовірнішим класом у кожному кадрі, а шум більше схожий на короткий елемент, ніж на довгий.

Так поводяться і цей декодер, і комерційні застосунки для телефона. Різниця лише в тому, чи показують результат операторові.

Відрізнити одне від одного вистачає одного числа: наскільки найсильніша смуга спектра підноситься над типовою, усереднено по вікну. Несуча вузька й стала, шум не є ні тим, ні тим. Жодного прив'язування до абсолютного рівня тут немає — і саме тому воно однаково працює і з кабелю від трансивера, і з мікрофона біля динаміка.

Поріг не обраний, а виміряний: шум тримається біля 1,5, навіть глибоко закопаний сигнал не опускається нижче 3, тож межа проведена по 2,5 — свідомо низько. Втратити справжню передачу гірше, ніж надрукувати трохи сміття.

Дві станції одночасно

Модель має один вихід і не може стежити за двома відправниками: діставши обох, вона видає текст, що не належить жодному.

Але весь її світогляд — це спектрограма. Тож обнулити смуги подалі від потрібного тону — рівно те саме, що відфільтрувати звук, і коштує це одного проходу масивом.

На двох синтезованих станціях за 350 Гц одна від одної разом вони декодувалися як C?Y06IXA3PKI K, а з обнуленням усього поза ±100 Гц — як CQ CQ DE UR3PKI K і TEST DE DL6LD K, обидві дослівно.

Зсувати натомість вікно аналізу не працює: воно охоплює 812 Гц, і сусід за кілька сотень герців лишається всередині нього.

Межа, яку не переходити

Декодер ніколи не пише в журнал сам.

У FT8 автоматичний запис виправданий: там є контрольна сума, і хибний декод практично неможливий. У телеграфі контрольної суми немає взагалі — програма не відрізнить UR3PKI від UR3PKJ, якщо остання крапка потонула в завмиранні.

Найбільше, що дозволено, — підставити позивний у поле, видимо позначене як здогад декодера. Причина не технічна, а предметна: журнал — це запис того, що відбулося в ефірі, і один вигаданий позивний у ньому коштує дорожче за всю зручність автозаповнення.

Чому це окремий проєкт

Модель узята з e04/deepcw-engine під AGPL-3.0. Ця ліцензія поширюється й на програми, які надаються через мережу, тож вихідний код має бути доступний кожному, хто відкриває сторінку.

Саме тому декодер живе окремим репозиторієм PetroOstapuk/cw-decoder, а не сторінкою всередині платформи: так умова ліцензії виконується повністю, а код платформи лишається окремою роботою.


Розібратися, як улаштована сама абетка, можна на дереві коду, а порахувати, скільки триває крапка й яку смугу займає сигнал, — у калькуляторі таймінгу.