add browser mic TX: Opus-encoded mic audio from web client to WDSP TX chain

- Browser: AudioWorklet accumulates 960-sample frames, AudioEncoder encodes
  to Opus, sends binary WS frame 'M' + raw Opus packet
- Server: opcode $02 handler decodes Opus via libopus, calls OnWebMic callback
- MainForm: WebOnMic converts PSingle→Double, pushes to PushTXMicSamplesD;
  ApplyMOX switches mic source to txmsWeb when web client active, restores on TX-off
- WDSPEngine: added txmsWeb to TTXMicSource enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:49:51 +03:00
co-authored by Claude Sonnet 4.6
parent 3d25f837af
commit 2ab90e8855
4 changed files with 117 additions and 2 deletions
+32
View File
@@ -255,6 +255,8 @@ type
// --- Settings --- // --- Settings ---
FSettings: TSettingsManager; FSettings: TSettingsManager;
FWebServer: TWebServer; // веб-интерфейс (порт 8080) FWebServer: TWebServer; // веб-интерфейс (порт 8080)
FSavedMicSource: TTXMicSource; // сохраняется при переключении на txmsWeb
FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb
// --- CAT --- // --- CAT ---
FCATEngine: TCATEngine; FCATEngine: TCATEngine;
FCATSerial: TCATSerialManager; FCATSerial: TCATSerialManager;
@@ -493,6 +495,7 @@ type
procedure WebOnFreqA(Hz: Double); procedure WebOnFreqA(Hz: Double);
procedure WebOnAttn(Idx: Integer); procedure WebOnAttn(Idx: Integer);
procedure WebOnTun(On_: Boolean); procedure WebOnTun(On_: Boolean);
procedure WebOnMic(Samples: PSingle; Count: Integer);
// Synchronize-обёртки (выполняются в UI-потоке) // Synchronize-обёртки (выполняются в UI-потоке)
procedure SyncWebFreq; procedure SyncWebFreq;
procedure SyncWebMode; procedure SyncWebMode;
@@ -1101,6 +1104,7 @@ begin
FWebServer.OnFreqA := WebOnFreqA; FWebServer.OnFreqA := WebOnFreqA;
FWebServer.OnAttn := WebOnAttn; FWebServer.OnAttn := WebOnAttn;
FWebServer.OnTun := WebOnTun; FWebServer.OnTun := WebOnTun;
FWebServer.OnWebMic := WebOnMic;
FWebServer.Start; FWebServer.Start;
FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
FCATLastGlobal.CATTcpPort := 19090; FCATLastGlobal.CATTcpPort := 19090;
@@ -4488,6 +4492,19 @@ begin
end; end;
if FWDSPReady then if FWDSPReady then
begin 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, // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX,
// чтобы TX leakage не накапливался в RXA pipeline и FFT-истории // чтобы TX leakage не накапливался в RXA pipeline и FFT-истории
// RX-анализатора. В DUP RX-тракт работает как обычно. // RX-анализатора. В DUP RX-тракт работает как обычно.
@@ -5252,6 +5269,21 @@ begin
ApplyTUN(FWebSyncBool); ApplyTUN(FWebSyncBool);
end; 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); procedure TMainForm.TrkDriveChange(Sender: TObject);
begin begin
FDriveLevel := CalcDriveByte; FDriveLevel := CalcDriveByte;
+1 -1
View File
@@ -140,7 +140,7 @@ type
TPullMicSamplesFunc = function(Max: Integer): Integer of object; TPullMicSamplesFunc = function(Max: Integer): Integer of object;
// TX mic source: откуда получаем сэмплы для модулятора. // TX mic source: откуда получаем сэмплы для модулятора.
TTXMicSource = (txmsRadio, txmsSoundCard); TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb);
{ TWDSPEngine } { TWDSPEngine }
TWDSPEngine = class TWDSPEngine = class
+41 -1
View File
@@ -469,7 +469,8 @@ begin
'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' + 'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
'paintSM(Number(cur.smeter_dbm||-130));' + 'paintSM(Number(cur.smeter_dbm||-130));' +
'const mn=cur.mode_name||(MODE_N[cur.mode||0]||"?");' + '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 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];}' + '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 // Opus: async decodeFrame, очередь, оверлей для user gesture
'let ws=null,opDec=null,opRdy=false,opQueue=[];' + '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;' + 'async function initOpus(){if(opRdy)return;' +
'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' + 'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' +
'opDec=new m.OpusDecoder({channels:1,sampleRate:48000});' + '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";' + 'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' +
'}catch(e){console.error("playOpus:",e);}}' + '}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<d.length){const t=Math.min(960-this.p,d.length-o);" +' +
'"this.a.set(d.subarray(o,o+t),this.p);this.p+=t;o+=t;" +' +
'"if(this.p===960){this.port.postMessage(this.a.slice());this.p=0;}}" +' +
'"return true;}}registerProcessor(\"txmic\",P);";' +
'const burl=URL.createObjectURL(new Blob([wcSrc],{type:"application/javascript"}));' +
'await txMicACtx.audioWorklet.addModule(burl);URL.revokeObjectURL(burl);' +
'let txTs=0;' +
'txMicProc=new AudioWorkletNode(txMicACtx,"txmic");' +
'txMicProc.port.onmessage=function(ev){' +
'if(!txMicEnc||txMicEnc.state!=="configured")return;' +
'const ad=new AudioData({format:"f32-planar",sampleRate:48000,numberOfFrames:960,numberOfChannels:1,timestamp:txTs,data:ev.data});' +
'txTs+=20000;txMicEnc.encode(ad);ad.close();};' +
'msrc.connect(txMicProc);txMicProc.connect(txMicACtx.destination);' +
'}catch(e){console.error("startTXMic:",e);stopTXMic();}}' +
'function stopTXMic(){' +
'if(txMicProc){txMicProc.disconnect();txMicProc=null;}' +
'if(txMicEnc){try{txMicEnc.close();}catch(ex){}txMicEnc=null;}' +
'if(txMicACtx){txMicACtx.close();txMicACtx=null;}' +
'if(txMicStream){txMicStream.getTracks().forEach(function(t){t.stop();});txMicStream=null;}}' +
'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' + 'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' + 'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
'ws.onclose=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' + 'ws.onclose=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
+43
View File
@@ -103,6 +103,13 @@ type
TOpus_encoder_ctl_set = function(st: POpusEncoder; TOpus_encoder_ctl_set = function(st: POpusEncoder;
request: Integer; value: Integer): Integer; cdecl; 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 ────────────────────────────────────────────────── // ── Callbacks в MainForm ──────────────────────────────────────────────────
TWebCmdFreq = procedure(Hz: Double) of object; TWebCmdFreq = procedure(Hz: Double) of object;
TWebCmdMode = procedure(Mode: Integer) of object; TWebCmdMode = procedure(Mode: Integer) of object;
@@ -129,6 +136,7 @@ type
TWebCmdFreqA = procedure(Hz: Double) of object; TWebCmdFreqA = procedure(Hz: Double) of object;
TWebCmdAttn = procedure(Idx: Integer) of object; TWebCmdAttn = procedure(Idx: Integer) of object;
TWebCmdTun = procedure(On_: Boolean) of object; TWebCmdTun = procedure(On_: Boolean) of object;
TWebMicCB = procedure(Samples: PSingle; Count: Integer) of object;
// ── Главный класс сервера ───────────────────────────────────────────────── // ── Главный класс сервера ─────────────────────────────────────────────────
TWebServer = class TWebServer = class
@@ -144,6 +152,12 @@ type
FOpusBufPos: Integer; FOpusBufPos: Integer;
FOpusOut: array[0..3999] of Byte; FOpusOut: array[0..3999] of Byte;
FOpusReady: Boolean; FOpusReady: Boolean;
// Opus decoder — для RX TX-mic аудио от браузера
FOpusDec: POpusDecoder;
FOpusDecCreate: TOpus_decoder_create;
FOpusDecDecode: TOpus_decode_float;
FOpusDecDestroy: TOpus_decoder_destroy;
FOnWebMic: TWebMicCB;
// ── TCP ── // ── TCP ──
FListenSock: TSocket; FListenSock: TSocket;
@@ -292,6 +306,7 @@ type
property OnFreqA: TWebCmdFreqA read FOnFreqA write FOnFreqA; property OnFreqA: TWebCmdFreqA read FOnFreqA write FOnFreqA;
property OnAttn: TWebCmdAttn read FOnAttn write FOnAttn; property OnAttn: TWebCmdAttn read FOnAttn write FOnAttn;
property OnTun: TWebCmdTun read FOnTun write FOnTun; property OnTun: TWebCmdTun read FOnTun write FOnTun;
property OnWebMic: TWebMicCB read FOnWebMic write FOnWebMic;
end; end;
implementation implementation
@@ -423,6 +438,9 @@ begin
FOpusDestroy := TOpus_encoder_destroy(GetProcAddress(FOpusLib, 'opus_encoder_destroy')); FOpusDestroy := TOpus_encoder_destroy(GetProcAddress(FOpusLib, 'opus_encoder_destroy'));
FOpusEncode := TOpus_encode_float( GetProcAddress(FOpusLib, 'opus_encode_float')); FOpusEncode := TOpus_encode_float( GetProcAddress(FOpusLib, 'opus_encode_float'));
FOpusCtl := TOpus_encoder_ctl_set(GetProcAddress(FOpusLib, 'opus_encoder_ctl')); 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 if not Assigned(FOpusCreate) or not Assigned(FOpusEncode) then
begin begin
@@ -439,6 +457,14 @@ begin
if Assigned(FOpusCtl) then if Assigned(FOpusCtl) then
FOpusCtl(FOpusEnc, 4002, OPUS_BITRATE); 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; FOpusBufPos := 0;
FOpusReady := True; FOpusReady := True;
Result := True; Result := True;
@@ -449,6 +475,9 @@ begin
if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then
FOpusDestroy(FOpusEnc); FOpusDestroy(FOpusEnc);
FOpusEnc := nil; FOpusEnc := nil;
if Assigned(FOpusDec) and Assigned(FOpusDecDestroy) then
FOpusDecDestroy(FOpusDec);
FOpusDec := nil;
FOpusReady := False; FOpusReady := False;
if FOpusLib <> 0 then if FOpusLib <> 0 then
begin begin
@@ -664,6 +693,8 @@ var
Consumed: Integer; Consumed: Integer;
Raw: array[0..8191] of Byte; Raw: array[0..8191] of Byte;
RawLen: Integer; RawLen: Integer;
PcmBuf: array[0..5759] of Single; // 120ms max @ 48kHz (для RX TX-mic)
Decoded: Integer;
begin begin
WsHandled := False; WsHandled := False;
RawLen := 0; RawLen := 0;
@@ -813,6 +844,18 @@ begin
if PayLen > 0 then Move(Payload[0], Header[1], PayLen); if PayLen > 0 then Move(Payload[0], Header[1], PayLen);
ProcessCommand(Client, Header); ProcessCommand(Client, Header);
end; 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 $08: // Close
begin begin
Client.State := wsClosed; Client.State := wsClosed;