From 957c3c17616f2c83254c299072095756f17f84f9 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 26 Aug 2026 09:15:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B8=D1=82?= =?UTF-8?q?,=20=D1=87=D1=82=D0=BE=20=D0=B7=D0=B2=D1=83=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=82=20=D0=B8=D0=B7-=D0=B7=D0=B0=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20libopus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. В настоящем браузере баннер ещё не видели. --- WebPageHtml.pas | 32 ++++++++++++++++++++++++++++---- WebServer.pas | 11 +++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 18183eb..8a45130 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -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();});' + diff --git a/WebServer.pas b/WebServer.pas index 3bc2e03..30536fd 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -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;