mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
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:
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+41
-1
@@ -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<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";' +
|
||||
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
|
||||
'ws.onclose=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user