diff --git a/MainForm.pas b/MainForm.pas index 57eb75e..1a30a16 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -255,6 +255,8 @@ type // --- Settings --- FSettings: TSettingsManager; FWebServer: TWebServer; // веб-интерфейс (порт 8080) + FSavedMicSource: TTXMicSource; // сохраняется при переключении на txmsWeb + FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb // --- CAT --- FCATEngine: TCATEngine; FCATSerial: TCATSerialManager; @@ -493,6 +495,7 @@ type procedure WebOnFreqA(Hz: Double); procedure WebOnAttn(Idx: Integer); procedure WebOnTun(On_: Boolean); + procedure WebOnMic(Samples: PSingle; Count: Integer); // Synchronize-обёртки (выполняются в UI-потоке) procedure SyncWebFreq; procedure SyncWebMode; @@ -1101,6 +1104,7 @@ begin FWebServer.OnFreqA := WebOnFreqA; FWebServer.OnAttn := WebOnAttn; FWebServer.OnTun := WebOnTun; + FWebServer.OnWebMic := WebOnMic; FWebServer.Start; FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FCATLastGlobal.CATTcpPort := 19090; @@ -4488,6 +4492,19 @@ begin end; if FWDSPReady then begin + // Переключаем источник микрофона: при TX из браузера — txmsWeb, + // при TX с кнопки без веб-клиента — штатный источник из настроек. + if FTransmitting and Assigned(FWebServer) and FWebServer.WebClientActive then + begin + FSavedMicSource := TTXMicSource(FTXSettings.MicSource); + FWebMicActive := True; + FDSPEngine.SetTXMicSource(txmsWeb); + end + else if not FTransmitting and FWebMicActive then + begin + FWebMicActive := False; + FDSPEngine.SetTXMicSource(FSavedMicSource); + end; // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, // чтобы TX leakage не накапливался в RXA pipeline и FFT-истории // RX-анализатора. В DUP RX-тракт работает как обычно. @@ -5252,6 +5269,21 @@ begin ApplyTUN(FWebSyncBool); end; +procedure TMainForm.WebOnMic(Samples: PSingle; Count: Integer); +var + Singles: array[0..5759] of Single; + Buf: array[0..5759] of Double; + i, n: Integer; +begin + if not FWDSPReady then Exit; + n := Count; + if n > Length(Buf) then n := Length(Buf); + Move(Samples^, Singles[0], n * SizeOf(Single)); + for i := 0 to n - 1 do + Buf[i] := Singles[i]; + FDSPEngine.PushTXMicSamplesD(Buf, n); +end; + procedure TMainForm.TrkDriveChange(Sender: TObject); begin FDriveLevel := CalcDriveByte; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 4ddc564..2f81cd6 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -140,7 +140,7 @@ type TPullMicSamplesFunc = function(Max: Integer): Integer of object; // TX mic source: откуда получаем сэмплы для модулятора. - TTXMicSource = (txmsRadio, txmsSoundCard); + TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb); { TWDSPEngine } TWDSPEngine = class diff --git a/WebPageHtml.pas b/WebPageHtml.pas index a752033..d044c99 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -469,7 +469,8 @@ begin 'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' + 'paintSM(Number(cur.smeter_dbm||-130));' + 'const mn=cur.mode_name||(MODE_N[cur.mode||0]||"?");' + - 'stEl.textContent=(cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")+" | "+(cur.transmitting?"TX":"RX")+" | "+mn+" | "+((activeVfoHz()/1e6).toFixed(3))+" MHz";}' + + 'stEl.textContent=(cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")+" | "+(cur.transmitting?"TX":"RX")+" | "+mn+" | "+((activeVfoHz()/1e6).toFixed(3))+" MHz";' + + 'const nowTx=!!cur.transmitting;if(nowTx!==prevTx){prevTx=nowTx;if(nowTx)startTXMic();else stopTXMic();}}' + 'function pal(db){const v=Math.round(cl((db-wfLo)/Math.max(1,wfHi-wfLo),0,1)*255);const c=PAL[v]|0;return[(c>>16)&255,(c>>8)&255,c&255];}' + 'function fltLH(){const bw=cur.filter_bw||2700;const m=cur.mode|0;if(m===0)return[-bw,-100];if(m===1)return[100,bw];return[-bw/2,bw/2];}' + @@ -584,6 +585,7 @@ begin // Opus: async decodeFrame, очередь, оверлей для user gesture 'let ws=null,opDec=null,opRdy=false,opQueue=[];' + + 'let txMicStream=null,txMicACtx=null,txMicProc=null,txMicEnc=null,prevTx=false;' + 'async function initOpus(){if(opRdy)return;' + 'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' + 'opDec=new m.OpusDecoder({channels:1,sampleRate:48000});' + @@ -607,6 +609,44 @@ begin 'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' + '}catch(e){console.error("playOpus:",e);}}' + + 'async function startTXMic(){if(txMicStream)return;' + + 'try{' + + 'txMicStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:48000,echoCancellation:true,noiseSuppression:true}});' + + 'txMicACtx=new AudioContext({sampleRate:48000});' + + 'const msrc=txMicACtx.createMediaStreamSource(txMicStream);' + + 'txMicEnc=new AudioEncoder({' + + 'output:(chunk)=>{' + + 'if(!ws||ws.readyState!==1)return;' + + 'const pkt=new Uint8Array(1+chunk.byteLength);pkt[0]=0x4D;' + + 'chunk.copyTo(pkt.subarray(1));ws.send(pkt.buffer);},' + + 'error:(e)=>console.error("txenc:",e)});' + + 'txMicEnc.configure({codec:"opus",sampleRate:48000,numberOfChannels:1,bitrate:32000});' + + // AudioWorklet processor: accumulates render quanta (128 samples) into 960-sample Opus frames + 'const wcSrc=' + + '"class P extends AudioWorkletProcessor{" +' + + '"constructor(){super();this.a=new Float32Array(960);this.p=0;}" +' + + '"process(inp){const d=inp[0]&&inp[0][0];if(!d)return true;" +' + + '"let o=0;while(o{stEl.textContent="connected";initOpus();};' + 'ws.onclose=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' + diff --git a/WebServer.pas b/WebServer.pas index 0dd7666..50f45da 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -103,6 +103,13 @@ type TOpus_encoder_ctl_set = function(st: POpusEncoder; request: Integer; value: Integer): Integer; cdecl; + POpusDecoder = Pointer; + TOpus_decoder_create = function(Fs, channels: Integer; + error: PInteger): POpusDecoder; cdecl; + TOpus_decode_float = function(st: POpusDecoder; data: PByte; len: Integer; + pcm: PSingle; frame_size, decode_fec: Integer): Integer; cdecl; + TOpus_decoder_destroy = procedure(st: POpusDecoder); cdecl; + // ── Callbacks в MainForm ────────────────────────────────────────────────── TWebCmdFreq = procedure(Hz: Double) of object; TWebCmdMode = procedure(Mode: Integer) of object; @@ -129,6 +136,7 @@ type TWebCmdFreqA = procedure(Hz: Double) of object; TWebCmdAttn = procedure(Idx: Integer) of object; TWebCmdTun = procedure(On_: Boolean) of object; + TWebMicCB = procedure(Samples: PSingle; Count: Integer) of object; // ── Главный класс сервера ───────────────────────────────────────────────── TWebServer = class @@ -144,6 +152,12 @@ type FOpusBufPos: Integer; FOpusOut: array[0..3999] of Byte; FOpusReady: Boolean; + // Opus decoder — для RX TX-mic аудио от браузера + FOpusDec: POpusDecoder; + FOpusDecCreate: TOpus_decoder_create; + FOpusDecDecode: TOpus_decode_float; + FOpusDecDestroy: TOpus_decoder_destroy; + FOnWebMic: TWebMicCB; // ── TCP ── FListenSock: TSocket; @@ -292,6 +306,7 @@ type property OnFreqA: TWebCmdFreqA read FOnFreqA write FOnFreqA; property OnAttn: TWebCmdAttn read FOnAttn write FOnAttn; property OnTun: TWebCmdTun read FOnTun write FOnTun; + property OnWebMic: TWebMicCB read FOnWebMic write FOnWebMic; end; implementation @@ -423,6 +438,9 @@ begin FOpusDestroy := TOpus_encoder_destroy(GetProcAddress(FOpusLib, 'opus_encoder_destroy')); FOpusEncode := TOpus_encode_float( GetProcAddress(FOpusLib, 'opus_encode_float')); FOpusCtl := TOpus_encoder_ctl_set(GetProcAddress(FOpusLib, 'opus_encoder_ctl')); + FOpusDecCreate := TOpus_decoder_create( GetProcAddress(FOpusLib, 'opus_decoder_create')); + FOpusDecDecode := TOpus_decode_float( GetProcAddress(FOpusLib, 'opus_decode_float')); + FOpusDecDestroy := TOpus_decoder_destroy(GetProcAddress(FOpusLib, 'opus_decoder_destroy')); if not Assigned(FOpusCreate) or not Assigned(FOpusEncode) then begin @@ -439,6 +457,14 @@ begin if Assigned(FOpusCtl) then FOpusCtl(FOpusEnc, 4002, OPUS_BITRATE); + // Декодер для TX mic (браузер → Opus → WDSP) + if Assigned(FOpusDecCreate) then + begin + Err := 0; + FOpusDec := FOpusDecCreate(OPUS_SAMPLE_RATE, OPUS_CHANNELS, @Err); + if Err <> 0 then FOpusDec := nil; + end; + FOpusBufPos := 0; FOpusReady := True; Result := True; @@ -449,6 +475,9 @@ begin if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then FOpusDestroy(FOpusEnc); FOpusEnc := nil; + if Assigned(FOpusDec) and Assigned(FOpusDecDestroy) then + FOpusDecDestroy(FOpusDec); + FOpusDec := nil; FOpusReady := False; if FOpusLib <> 0 then begin @@ -664,6 +693,8 @@ var Consumed: Integer; Raw: array[0..8191] of Byte; RawLen: Integer; + PcmBuf: array[0..5759] of Single; // 120ms max @ 48kHz (для RX TX-mic) + Decoded: Integer; begin WsHandled := False; RawLen := 0; @@ -813,6 +844,18 @@ begin if PayLen > 0 then Move(Payload[0], Header[1], PayLen); ProcessCommand(Client, Header); end; + $02: // Binary — TX mic Opus frame: byte 'M' + raw Opus packet + begin + if (PayLen > 1) and (Payload[0] = Ord('M')) and + Assigned(FOpusDec) and Assigned(FOpusDecDecode) and + Assigned(FOnWebMic) then + begin + Decoded := FOpusDecDecode(FOpusDec, @Payload[1], PayLen - 1, + @PcmBuf[0], Length(PcmBuf), 0); + if Decoded > 0 then + FOnWebMic(@PcmBuf[0], Decoded); + end; + end; $08: // Close begin Client.State := wsClosed;