How a Web Worker Fixed My Dying-Battery Audio (And What I Learned About PWAs the Hard Way)

від

у

Як Web Worker виправив моє зношене аудіо батареї (І що я навчився про PWAs на практиці)

https://ift.tt/fsdU0ri

Я провів минулий тиждень, модифікуючи відкритий NES-емулятор, щоб він працював у браузері як PWA. Я — розробник для Android за фахом — Kotlin, Jetpack Compose, Flutter тоді, коли проєкт того потребує. Це було моє перше справжнє занурення у Web Worker, SharedArrayBuffer і перетворення вкладки браузера на щось, що відчувається як нативний додаток.

Ось що я дізнався. Деяке з цього було очевидним з огляду назад. Більшість — ні.

Проблема, якаeverything почала все

Я хотів додати реальні реґульовані слайдери моделювання гри до браузерного NES-емулятора. Множник швидкості, посилення вогню, безкінечні життя — те, що тривіально, якщо маєш доступ до пам’яті гри. Емулятор (JSNES, з відкритим кодом) надає прямий доступ до RAM CPU NES через JavaScript. Написати слайдер, який прибирає cpu.mem[0x0487] кожного кадру — це, можливо, 10 рядків коду.

Я запустив GitHub Codespace, запустив емулятор у браузері та протестував його. Усе працювало чудово. Потім відкрив той же URL на старішому Android-телефоні, що лежить на моєму столі.

Графіка гри була досить плавною. Але аудіо — легендарна 8-бітна музика — звучало як іграшка із садком батареї. Повільно, тяглося, боляче. Наче хтось тримав АРУ NES під водою.

Чому причина в одному потоці

Ось що відбувалося. NES генерує аудіо зразки з частотою 44 100 Гц, безпосередньо прив’язану до емуляції CPU. Кожен кадр емуляції виробляє ~735 аудіозразків. Браузерний Web Audio API очікує, що ці зразки надійдуть з постійною швидкістю.

На пристойному комп’ютері головний потік легко запускав емулятор на 60fps + рендерив холст + подавав аудіозразки. Ніяких конфліктів. На повільному Android-телефоні рендеринг холста заважав головному потоку. Кадри впали до 30fps. Півквадра зразків генерувалося за секунду. Web Audio API відтворював їх із очікуваною швидкістю, але до середини вичерпувався — з’являвся той «тихий» звук із вмираючою батареєю.

Я спробував кожну хитрість, яку лише міг вигадати:

  • Адаптивне скидання зразків — моніторив FPS і скидав аудіозразки, коли пристрій відчував труднощі. Результат: лажане аудіо замість повільного. Не краще.
  • Динамічне керування швидкістю — розтягував доступні зразки через інтерполяцію (алгоритм, який використовує RetroArch). Результат: незрозумілий «інопланетний» звук. Висота тону була неправильною, бо неможливо розтягувати 22k зразків, щоб заповнити 44k вакансії без зміни фундаментальної частоти.
  • Багатокадровий догін — запускав 2 кадри NES за requestAnimationFrame, коли пристрій відставати. Результат: ще повільніше, бо пристрій не міг впоратися з 2 кадрами, якщо вже збирався з 1.

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

Виправлення: Web Worker

Рішення було архітектурно просте. Перемістити емуляцію NES (CPU + генерацію аудіо) у Web Worker. Головний потік обробляє лише рендеринг канви, введення користувача та UI.

Поток-робітник (setInterval @ 60fps)
├── емуляція JSNES (CPU, PPU, APU)
├── генерація аудіо-зразків → SharedArrayBuffer
├── логіка модифікації гри (швидкість, потужність, життя)
└── конвертація кадру в пікселі → postMessage (Transferable)

Головний потік (requestAnimationFrame)
├── рендеринг канви (пікселі надходять від Worker)
├── відтворення аудіо (зчитування з SharedArrayBuffer)
├── введення з клавіатури/тач → postMessage до Worker
└── UI (регулятори, перемикачі, збереження/завантаження, повноекранний режим)

Ключове розуміння: setInterval у Web Worker не пригальмовується коли вкладка у фоновому режимі. requestAnimationFrame на головному потоці — так. Це означає, що Worker продовжує генерувати аудіо з постійною швидкістю незалежно від того, чим зайнятий рендерер. Аудіобуфер ніколи не голодує.

SharedArrayBuffer: нуль-копійний аудіо міст

Це була та частина, яка мене найбільше зацікавила: раніше міжпотокова комунікація на мобільних пристроях зазвичай означала Handler.post() або Kotlin-канали корутин.

Worker генерує ~735 аудіозразків за кадр. Ці зразки потрібно дістати до основного потоку ScriptProcessorNode з мінімальними затримками. postMessage додає накладні затримки на серіалізацію — добре для подій вводу, не дуже для 44 100 зразків за секунду.

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

Простий розклад:

SharedArrayBuffer:
[0-3]   Int32: індекс запису (Worker пише за допомогою Atomics.store)
[4-7]   Int32: індекс читання (Головний читає за допомогою Atomics.store)
[8+]    Float32[]: міжвідсортовані аудіо зразки L/R

Worker пише зразки після кожного виклику nes.frame(). ScriptProcessorNode на головному потоці читає їх у колбеку onaudioprocess. Операції Atomics забезпечують гарантії впорядкування пам’яті — не потрібні блокування для одноразпакувуваного кільцевого буфера.

Один нюанс, який коштував мені годину: перехресно записані аудіо зразки завжди мають бути записані парами (ліве та праве канали). Якщо доступний простір буфера непарний, ви записуєте один зразок L без його Rs, і кожен подальший читання зміщується на один канал. Виправлення — одна лінія: samplesToWrite = available & ~1 — зробити парним.

SharedArrayBuffer вимагає спеціальних заголовків HTTP:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Без них typeof SharedArrayBuffer === 'undefined' у кожному браузері. Я побудував fallback-путь за допомогою postMessage з передаванням Float32Array для середовищ, де заголовки не можуть бути встановлені.

Перенесення кадрів: Transferable Objects

NES виводить 256×240 пікселів за кадр. Це приблизно 245KB даних пікселів за 60fps. Копіювання через postMessage було б витратним. Transferable objects вирішують це — ArrayBuffer передається між потоками, не копіюється. Відправний потік втрачає до нього доступ (стане «ожерельеним»), але сама передача майже безкоштовна.

// Робітник: конвертує пікселі та передає
const pixels = new Uint32Array(61440);
// ... заповнення пікселів з кадрБуфера JSNES ...
postMessage({ type: 'frame', pixels }, [pixels.buffer]);
// pixels.buffer тепер "нечіпаний" — довжина 0 у Робітника

Я використав подвійне буферизацію: два масиви пікселів у Робітнику, що чергують: який зараз заповнювати та передавати. Практично, я з’ясував, що простіше просто виділяти новий Uint32Array(61440) після кожної передачі — це швидко, і 245KB алокація за 60fps добре вписується в комфорт V8.

Частина PWA

Зробити це Progressive Web App стало окремим навчанням. Декілька речей, які я дізнався:

iOS Safari не має Fullscreen API. Ні requestFullscreen, ні webkitRequestFullscreen, ні будь-який варіант. Я помітив це, коли кнопка повного екрану просто нічого не робила на iPhone. Єдиний спосіб отримати «повний екран» на iPhone — це display: standalone у вашому вебманіфесті + додавання до головного екрана. Навіть тоді статус-бар залишається — Apple ніколи не дозволяє його приховати.

Я врешті зібрав CSS-імітацію повноекранного режиму: перемикав клас на body, який сховає все, окрім геймпаду та контролів дотику. Але тоді кнопка виходу не працювала. Виявилося, що на iOS кнопка з position: fixed, розташована поза основним контейнером для дотику, тихо не отримує події торкання. Кнопка рендериться, ви бачите її, але торкання нічого не робить. Мені довелося перемістити контрол виходу всередину того самого оверлею, що обробляє введення гри. Це коштувало мені кілька годин заплутаної налагодження.

Іконки PWA на iOS мають бути PNG, а не SVG, і RGB, а не RGBA. Safari повністю ігнорує посилання apple-touch-icon з SVG. А якщо ваш PNG має альфа-канал, iOS іноді відображає порожній значок або використає свій за замовчуванням. Мій власний іконок із піксельного мистецтва з’явився лише після конвертації з RGBA в RGB за допомогою Pillow.

Кешування Service Worker агресивне і окреме від кешу Safari. Видалення даних Safari не очищає кеш PWA. Тобі потрібно спочатку видалити іконку програми з домашнього екрану, потім очистити дані Safari, потім заново додати. Я це зрозумів на практиці, коли тестери бачили старі версії.

Meta-тег viewport-fit: cover дозволяє вашому додатку розширитися під виріз iPhone. Без нього ви отримуєте чорні смуги.

Бонус: Контроль за виконанням у фоновому режимі

Одне, чого я не очікував — архітектура Worker дає легкий контроль над поведінкою у фоновому режимі. Оскільки цикл емуляції запускається через setInterval всередині Web Worker (який браузери не пригальмовують у фонових вкладках), гра продовжує працювати навіть коли користувач перемикається між програмами чи вкладками. Це чудово для безперервності аудіо, але жахливо для батареї.

Виправлення примітивне: слухати на головному потоці подію visibilitychange та надсилати Worker-пауза/відновлення. Емуляція повністю зупиняється, коли застосунок у фоновому режимі, і точно продовжується там, де залишився, коли користувач повертається. Без втрати стану, без артефактів аудіо при resume. Якщо вам коли-небудь потрібно фонове виконання (наприклад, музичний плеєр або довговічна обчислювальна задача), просто не надсилайте паузу — Worker продовжує тикати незалежно від того, що робить головний потік. Наявність цього як свідомого вибору, а не обмеження браузера, є гарним побічним ефектом архітектури.

Результат

На тому ж повільному Android-телефоні, де раніше аудіо зникало через однопоточну архітектуру: плавне, стабільне, аудіо правильного темпу. Web Worker генерує зразки на стабільних 60fps за допомогою setInterval, повністю незалежно від частоти кадрів головного потоку. Місток SharedArrayBuffer додає фактично нульову затримку.

Візуальні кадри можуть падати до 30fps на повільному пристрої — гра виглядає трошки менш плавно — але аудіо залишаєтья незмінним. Це правильна компромісна перевага. Люди краще переносять «тупе» відео, ніж «тупе» аудіо.

Польові висновки

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

SharedArrayBuffer — правильний інструмент для високочастотного міжпотокового обміну даними. Для аудіо з 44 100 зразками/секунд postMessage додає забагато джіттера. Для подій вводу з частотою 10–30/с postMessage цілком підходить. Підбирайте інструмент під частоту.

Transferable objects — це безкоштовно. Якщо передаєте великі ArrayBuffer між потоками через postMessage, позначайте їх як transferable. Нульове копіювання, нульові накладні витрати. Просто пам’ятайте, що відправник втрачає доступ.

PWAs на iOS — це зовсім інша платформа. Не припускайте, що веб-API працюють так само. Фуллскрін API не існує. Події дотику поводяться по-іншому для елементів з фіксованим положенням. Іконки мають специфічні формати. Тестуйте на реальному iPhone, а не лише в мобільному емуляторі Chrome DevTools.

Тестуйте спочатку на найповільнішому пристрої. Якби я тестував на старому Android-томі на першому дні, я б з самого початку розробляв з урахуванням Worker’ів. Тестування лише на швидкому обладнанні приховає архітектурні проблеми, які згодом стають дуже дорогими у виправленні.

Я мобільний розробник (Android/Kotlin, Flutter), який досліджує браузер як платформу для застосувань у реальному часі. Якщо ви працювали з Web Worker, SharedArrayBuffer або підводними каменями PWAs на iOS, з радістю вислухаю ваш досвід у коментарях.

HI-FI News

через DEV Community https://dev.to

13 квітня 2026 р. о 14:18

April 13, 2026 at 02:18PM


Коментарі

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *