feat(web): страница говорит, что звука нет из-за отсутствия libopus

libopus грузится динамически и объявлен необязательным: `LoadOpus` при
отсутствии библиотеки молча возвращает False, `FOpusReady` остаётся сброшен,
`PushAudio` выходит первой же строкой, а микрофон из браузера не декодируется
(гейт по `Assigned(FOpusDec)`). Снаружи это выглядело как немой приёмник без
единого намёка на причину — тем более что браузер при этом бодро пишет
«Opus ready»: WASM-декодер тянется с CDN и к серверу отношения не имеет.

В state-JSON добавлено поле `opus` = FOpusReady. Флаг ставится один раз в Start
до запуска потоков и дальше не меняется, поэтому читается без FStateLock.

На странице по `opus:false` поднимается баннер «No audio — libopus is not
installed on the server» со второй строкой о том, что молчат и приём, и
микрофон. Клик закрывает баннер насовсем (`opusWarnHidden`): состояние
рассылается по нескольку раз в секунду, и без флага закрытый баннер возвращался
бы на следующем же кадре.

Заодно оверлей «Click to start audio» в этом случае не показывается вовсе —
AudioContext поднимать не для чего, оверлей только вводил бы в заблуждение.

Проверено живьём: демон с системной libopus отдаёт `"opus":true`, он же с
подложной битой `libopus.so.0` в LD_LIBRARY_PATH — `"opus":false`, JSON целиком
парсится. Логику баннера прогнал на заглушке DOM, JS страницы проверен
`node --check`. В настоящем браузере баннер ещё не видели.
This commit is contained in:
2026-08-26 09:17:42 +03:00
parent 98d90e1e5a
commit 957c3c1761
2 changed files with 37 additions and 6 deletions
+28 -4
View File
@@ -44,6 +44,9 @@ unit WebPageHtml;
из server state: выпадающий список и кнопка отражают десктоп.
- (Mode fix) modGrpSel.onchange при смене группы сразу отправляет
команду mode с дефолтным режимом группы (LSB, CWL, FM, AM).
- (Opus) По флагу state.opus=false (на сервере нет libopus) показывается
баннер "No audio — libopus is not installed on the server", а оверлей
"Click to start audio" не показывается вовсе — звука всё равно не будет.
}
{$IFDEF FPC}
@@ -755,6 +758,8 @@ begin
'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
// На сервере нет libopus — аудио и микрофон мертвы молча, показываем баннер.
'if(msg.opus!=null){srvOpus=!!msg.opus;setOpusWarn(!srvOpus);}' +
// Перестраиваем bandSel если изменился список XVTR или текущий индекс
'if(msg.xvtr_bands!=null||msg.xvtr_current!=null){' +
'const newBands=msg.xvtr_bands||XVTR_BANDS;' +
@@ -999,6 +1004,7 @@ begin
// Opus: async decodeFrame, очередь, оверлей для user gesture
'let ws=null,opDec=null,opRdy=false,opQueue=[];' +
'let srvOpus=true,opusWarnEl=null,opusWarnHidden=false,audioOv=null;' +
'let txMicStream=null,txMicACtx=null,txMicProc=null,txMicEnc=null,txMicGain=null,prevTx=false,txMicStarting=false;' +
'async function initOpus(){if(opRdy)return;' +
'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' +
@@ -1108,16 +1114,34 @@ begin
'else if(t===0x57){drawWf(f32(ab));}' +
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
// Оверлей для user gesture (AudioContext требует клика)
'function showAudioOverlay(){' +
// Баннер «на сервере нет libopus»: без него отказ выглядит как немой звук.
'function setOpusWarn(on){' +
'if(!on){if(opusWarnEl){opusWarnEl.remove();opusWarnEl=null;}opusWarnHidden=false;return;}' +
'if(audioOv){audioOv.remove();audioOv=null;}' +
// Состояние прилетает по нескольку раз в секунду, а setOpusWarn зовётся с
// каждым — без opusWarnHidden закрытый баннер тут же возвращался бы.
'if(opusWarnEl||opusWarnHidden)return;' +
'const w=document.createElement("div");' +
'w.style.cssText="position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:9998;max-width:92vw;padding:8px 12px;border:1px solid #7a3d3d;border-radius:6px;background:#1a0c0c;color:#ffb0b0;font-family:monospace;font-size:12px;line-height:1.5;text-align:center;cursor:pointer;";' +
'w.title="Click to dismiss";' +
'const t1=document.createElement("b");t1.textContent="No audio \u2014 libopus is not installed on the server";' +
'const t2=document.createElement("div");t2.style.color="#c98f8f";' +
't2.textContent="RX audio and the web microphone stay silent until it is installed.";' +
'w.appendChild(t1);w.appendChild(t2);' +
'w.addEventListener("click",function(){w.remove();opusWarnHidden=true;if(opusWarnEl===w)opusWarnEl=null;},{once:true});' +
'document.body.appendChild(w);opusWarnEl=w;}' +
// Оверлей для user gesture (AudioContext требует клика).
// Без libopus звука не будет вовсе — «Click to start audio» только врёт, не показываем.
'function showAudioOverlay(){if(!srvOpus)return;' +
'const ov=document.createElement("div");' +
'ov.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer;";' +
'const inner=document.createElement("div");' +
'inner.textContent="\u25BA Click to start audio";' +
'inner.style.cssText="color:#7fff7f;font-size:22px;font-family:monospace;text-align:center;padding:20px;border:1px solid #3d7a3d;border-radius:6px;background:#0c1a0c;";' +
'ov.appendChild(inner);' +
'ov.addEventListener("click",function(){audioKick();ov.remove();},{once:true});' +
'document.body.appendChild(ov);}' +
'ov.addEventListener("click",function(){audioKick();ov.remove();audioOv=null;},{once:true});' +
'document.body.appendChild(ov);audioOv=ov;}' +
'window.addEventListener("resize",resize);resize();renderVfos();connect();' +
'document.addEventListener("click",function(){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();});' +
+9 -2
View File
@@ -77,6 +77,10 @@ unit WebServer;
гарантирует создание AudioContext при первом кликe пользователя.
- (Audio fix 5) Буферизация Opus-пакетов пока WASM не инициализирован
первые пакеты больше не теряются при медленной загрузке CDN.
- (Audio fix 6) В state-JSON добавлено поле "opus" (= FOpusReady). Если
libopus в системе нет, LoadOpus молча возвращает False, кадры 'A' не
уходят и микрофон не декодируется раньше это выглядело как немой
звук без объяснений. Теперь страница показывает баннер.
}
{$IFDEF FPC}
@@ -1838,7 +1842,7 @@ begin
'"beacon_visible":%s,"beacon_on":%s,"beacon_text":"%s",' +
'"beacon_ref_hz":%.0f,"beacon_track_hz":%.0f,' +
'"rf_agc_visible":%s,"rx_gain_mode":%d,"rx_gain_db":%d,' +
'"rx_mute_visible":%s,"rx_mute_on":%s}',
'"rx_mute_visible":%s,"rx_mute_on":%s,"opus":%s}',
[FFreq, FVfoB, FActiveVfo,
FMode, MODE_N[EnsureRange(FMode, MODE_MIN, MODE_MAX)],
FFilterIdx, FFilterBW,
@@ -1874,7 +1878,10 @@ begin
JsonEscape(FBeaconText), FBeaconRefHz, FBeaconTrackHz,
BoolToStr(FRfAgcVisible, 'true', 'false'), FRxGainMode, FRxGainDb,
BoolToStr(FRxMuteVisible, 'true', 'false'),
BoolToStr(FRxMuteOn, 'true', 'false')
BoolToStr(FRxMuteOn, 'true', 'false'),
// Без libopus сервер молча не шлёт кадры 'A' и не принимает микрофон —
// страница показывает предупреждение, иначе отказ невидим.
BoolToStr(FOpusReady, 'true', 'false')
], FS);
finally
FStateLock.Leave;