mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +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 ---
|
// --- 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
@@ -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
@@ -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);};' +
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user