| <!DOCTYPE html> |
| <html lang="ro"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"> |
| <title>RX β Decoder</title> |
| <link href="https://fonts.googleapis.com/css2?family=Martian+Mono:wght@300;400;600&display=swap" rel="stylesheet"> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} |
| :root{ |
| --bg:#0c0c0c; |
| --ink:#e8e8e8; |
| --ink2:#888; |
| --ink3:#333; |
| --line:#1e1e1e; |
| --line2:#2a2a2a; |
| --surface:#111; |
| --accent:#e8e8e8; |
| } |
| html,body{background:var(--bg);color:var(--ink);font-family:'Martian Mono',monospace;} |
| body{max-width:520px;margin:0 auto;min-height:100vh;padding-bottom:48px;} |
| |
| |
| nav{ |
| display:flex;align-items:center;justify-content:space-between; |
| padding:14px 20px;border-bottom:1px solid var(--line); |
| background:var(--bg);position:sticky;top:0;z-index:99; |
| } |
| .nav-logo{font-size:11px;font-weight:600;letter-spacing:4px;text-transform:uppercase;color:var(--ink);} |
| .nav-switch{display:flex;gap:0;border:1px solid var(--line2);border-radius:6px;overflow:hidden;} |
| .nav-switch a{ |
| font-family:'Martian Mono',monospace; |
| font-size:9px;letter-spacing:2px;text-transform:uppercase; |
| padding:7px 14px;text-decoration:none;color:var(--ink2);transition:.15s;white-space:nowrap; |
| } |
| .nav-switch a:first-child{border-right:1px solid var(--line2);} |
| .nav-switch a.on{background:var(--ink);color:#000;} |
| .nav-switch a:hover:not(.on){background:var(--line);} |
| |
| |
| .block{ |
| margin:16px 16px 0;background:var(--surface); |
| border:1px solid var(--line);border-radius:10px;overflow:hidden; |
| } |
| .block-head{ |
| padding:11px 16px;border-bottom:1px solid var(--line2); |
| display:flex;align-items:center;justify-content:space-between; |
| } |
| .bh-title{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--ink2);} |
| .bh-r{font-size:9px;color:var(--ink3);} |
| .block-body{padding:16px;} |
| |
| |
| label{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--ink3);display:block;margin-bottom:5px;} |
| select{ |
| font-family:'Martian Mono',monospace;font-size:11px;width:100%; |
| background:var(--line);border:1px solid var(--line2);border-radius:6px; |
| color:var(--ink);padding:9px 12px;outline:none;-webkit-appearance:none; |
| } |
| select:focus{border-color:var(--ink3);} |
| .field{margin-bottom:14px;}.field:last-child{margin-bottom:0;} |
| .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;} |
| |
| |
| .btn-row{display:flex;gap:8px;} |
| button{ |
| font-family:'Martian Mono',monospace;font-size:10px;letter-spacing:1px; |
| text-transform:uppercase;padding:10px 16px;border-radius:7px;flex:1; |
| border:1px solid var(--line2);background:transparent;color:var(--ink2); |
| cursor:pointer;transition:.15s; |
| } |
| button:active{opacity:.6;} |
| button.primary{background:var(--ink);color:#000;border-color:var(--ink);font-weight:600;} |
| button:hover:not(.primary){background:var(--line);} |
| button.primary:hover{background:#ccc;} |
| button:disabled{opacity:.2;cursor:not-allowed;} |
| button.sm{flex:none;font-size:9px;padding:7px 12px;} |
| |
| |
| .prog{height:2px;background:var(--line2);border-radius:2px;overflow:hidden;margin-top:0;} |
| .prog-b{height:100%;width:0;background:var(--ink);transition:width .08s;border-radius:2px;} |
| |
| |
| .stats{display:flex;border-top:1px solid var(--line2);} |
| .stat{flex:1;padding:10px 14px;border-right:1px solid var(--line2);} |
| .stat:last-child{border-right:none;} |
| .stat-l{font-size:8px;letter-spacing:1px;text-transform:uppercase;color:var(--ink3);} |
| .stat-v{font-size:14px;color:var(--ink);margin-top:3px;font-weight:600;} |
| |
| |
| .log-wrap{border-top:1px solid var(--line2);} |
| .log-head{ |
| padding:6px 14px;display:flex;align-items:center;justify-content:space-between; |
| border-bottom:1px solid var(--line2); |
| } |
| .log-head span{font-size:8px;letter-spacing:1px;text-transform:uppercase;color:var(--ink3);} |
| .log{padding:10px 14px;font-size:10px;line-height:1.8;color:var(--ink3);max-height:80px;overflow-y:auto;} |
| .log .ok{color:var(--ink);}.log .warn{color:#666;}.log .err{color:#444;} |
| |
| |
| |
| |
| .sstv-wrap{ |
| position:relative;background:#000; |
| |
| } |
| #sstvCv{ |
| display:block;width:100%; |
| image-rendering:pixelated; |
| background:#000; |
| } |
| |
| .sstv-hud{ |
| position:absolute;inset:0;pointer-events:none; |
| display:flex;flex-direction:column; |
| } |
| .hud-top{ |
| padding:6px 10px; |
| display:flex;justify-content:space-between;align-items:center; |
| background:linear-gradient(rgba(0,0,0,.65),transparent); |
| font-size:9px;letter-spacing:1px;color:rgba(255,255,255,.65); |
| } |
| .hud-bot{ |
| margin-top:auto;padding:6px 10px; |
| display:flex;justify-content:space-between;align-items:center; |
| background:linear-gradient(transparent,rgba(0,0,0,.65)); |
| font-size:9px;letter-spacing:1px;color:rgba(255,255,255,.5); |
| } |
| |
| .scan-line{ |
| position:absolute;left:0;right:0;height:1px; |
| background:rgba(255,255,255,.3); |
| box-shadow:0 0 4px rgba(255,255,255,.3); |
| pointer-events:none;display:none; |
| } |
| |
| .sync-bar{height:2px;background:transparent;transition:background .1s;} |
| .sync-bar.active{background:rgba(255,255,255,.4);} |
| |
| |
| |
| |
| .wf-wrap{position:relative;background:#000;} |
| #wfCv{ |
| display:block;width:100%;height:180px; |
| image-rendering:pixelated;background:#000;cursor:crosshair; |
| } |
| .wf-axis{ |
| display:flex;justify-content:space-between;padding:3px 8px; |
| font-size:8px;color:var(--ink3);letter-spacing:.5px;background:#000; |
| } |
| .wf-bottom{ |
| display:flex;justify-content:space-between;align-items:center; |
| padding:6px 12px;border-top:1px solid var(--line);background:var(--surface); |
| } |
| .wf-bottom span{font-size:9px;color:var(--ink3);} |
| |
| |
| .fsk-terminal{ |
| padding:14px; |
| background:#000; |
| font-size:12px;line-height:1.8; |
| color:var(--ink);min-height:120px;max-height:220px; |
| overflow-y:auto;word-break:break-all;white-space:pre-wrap; |
| |
| background-image:repeating-linear-gradient( |
| to bottom,transparent 0,transparent 2px, |
| rgba(255,255,255,.013) 2px,rgba(255,255,255,.013) 3px |
| ); |
| } |
| .cursor{ |
| display:inline-block;width:7px;height:13px; |
| background:var(--ink);vertical-align:text-bottom; |
| animation:cb .75s step-end infinite; |
| } |
| @keyframes cb{0%,100%{opacity:1}50%{opacity:0}} |
| |
| ::-webkit-scrollbar{width:3px;} |
| ::-webkit-scrollbar-thumb{background:var(--line2);border-radius:3px;} |
| </style> |
| </head> |
| <body> |
|
|
| <nav> |
| <div class="nav-logo">RadioΒ·RX</div> |
| <div class="nav-switch"> |
| <a href="encoder.html">TX Encoder</a> |
| <a href="receiver.html" class="on">RX Decoder</a> |
| </div> |
| </nav> |
|
|
| |
| <div class="block" style="margin-top:16px;"> |
| <div class="block-head"> |
| <span class="bh-title">SSTV Image Decoder</span> |
| <span class="bh-r" id="sstvStatus">IDLE</span> |
| </div> |
|
|
| |
| <div class="sstv-wrap"> |
| <canvas id="sstvCv" width="320" height="240"></canvas> |
| <div class="sstv-hud"> |
| <div class="hud-top"> |
| <span id="hudMode">AUTO DETECT</span> |
| <span id="hudSnr">SNR β</span> |
| </div> |
| <div class="hud-bot"> |
| <span id="hudLine">LINE β / β</span> |
| <span id="hudTime">ββ:ββ</span> |
| </div> |
| </div> |
| <div class="scan-line" id="scanLine"></div> |
| </div> |
|
|
| <div class="sync-bar" id="syncBar"></div> |
| <div class="prog"><div class="prog-b" id="sstvProg"></div></div> |
|
|
| <div class="block-body"> |
| <div class="row2"> |
| <div class="field"> |
| <label>Mod implicit</label> |
| <select id="sstvRxModeSelect"> |
| <option value="r8">Robot8 β 8s</option> |
| <option value="r36" selected>Robot36 β 36s</option> |
| <option value="r72">Robot72 β 72s</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label>Amplificare</label> |
| <select id="rxGain"> |
| <option value="1">1Γ normal</option> |
| <option value="2" selected>2Γ amplificat</option> |
| <option value="4">4Γ puternic</option> |
| <option value="8">8Γ maxim</option> |
| </select> |
| </div> |
| </div> |
| <div class="btn-row"> |
| <button class="primary" id="btnSstvStart">⬑ Start SSTV RX</button> |
| <button id="btnSstvStop" disabled>β Stop</button> |
| <button class="sm" id="btnSstvSave" disabled>β¬ Save</button> |
| </div> |
| </div> |
| <div class="log-wrap"> |
| <div class="log-head"> |
| <span>SSTV Log</span> |
| <button class="sm" onclick="copyLog('sstvLog')">Copy</button> |
| </div> |
| <div class="log" id="sstvLog"><div class="ok">> SSTV decoder ready.</div></div> |
| </div> |
| </div> |
|
|
| |
| <div class="block"> |
| <div class="block-head"> |
| <span class="bh-title">Waterfall β SpectrogramΔ FFT</span> |
| <span class="bh-r">0 β 3000 Hz</span> |
| </div> |
| <div class="wf-wrap"> |
| <canvas id="wfCv"></canvas> |
| </div> |
| <div class="wf-axis"> |
| <span>0</span><span>500</span><span>1k</span><span>1.2k</span> |
| <span>1.5k</span><span>2k</span><span>2.3k</span><span>3k</span> |
| </div> |
| <div class="wf-bottom"> |
| <span id="wfCursor">β Hz</span> |
| <div style="display:flex;gap:8px;"> |
| <button class="sm" id="btnWfSave">β¬ PNG</button> |
| <button class="sm" id="btnWfClr">CLR</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="block"> |
| <div class="block-head"> |
| <span class="bh-title">FSK Text Decoder</span> |
| <span class="bh-r" id="fskStatus">IDLE</span> |
| </div> |
| <div class="block-body"> |
| <div class="row2"> |
| <div class="field"> |
| <label>Mod FSK</label> |
| <select id="rxFskBaud"> |
| <option value="100_1200" selected>100 bd / 1200 Hz</option> |
| <option value="45_2125">45 bd / 2125 Hz</option> |
| <option value="300_1275">300 bd / 1275 Hz</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label>SNR live</label> |
| <div id="fskSnrDisp" style="font-size:16px;font-weight:600;color:var(--ink);padding:6px 0;">β</div> |
| </div> |
| </div> |
| <div class="btn-row"> |
| <button class="primary" id="btnFskStart">⬑ Start FSK RX</button> |
| <button id="btnFskStop" disabled>β Stop</button> |
| <button class="sm" id="btnFskClr">CLR</button> |
| </div> |
| </div> |
| <div class="log-wrap"> |
| <div class="log-head"> |
| <span>FSK Log</span> |
| <button class="sm" onclick="copyLog('fskLog')">Copy</button> |
| </div> |
| <div class="log" id="fskLog"><div class="ok">> FSK decoder ready.</div></div> |
| </div> |
| </div> |
|
|
| |
| <div class="block"> |
| <div class="block-head"> |
| <span class="bh-title">Text RecepΘionat</span> |
| <span class="bh-r" id="fskCharCount">0 chars</span> |
| </div> |
| <div class="fsk-terminal" id="fskScreen"><span class="cursor"></span></div> |
| </div> |
|
|
| |
| <div class="block"> |
| <div class="stats"> |
| <div class="stat"><div class="stat-l">SSTV Images</div><div class="stat-v" id="imgCount">0</div></div> |
| <div class="stat"><div class="stat-l">FSK Chars</div><div class="stat-v" id="charCount">0</div></div> |
| <div class="stat"><div class="stat-l">SNR</div><div class="stat-v" id="globalSnr">β</div></div> |
| </div> |
| </div> |
|
|
| <script> |
| 'use strict'; |
| |
| |
| |
| |
| |
| |
| const SS=1200, SB=1500, SW=2300, SyncMs=9; |
| const VIS_MAP={0x02:'r8',0x04:'r8',0x08:'r36',0x0C:'r72',0x0A:'r36'}; |
| const MODES={ |
| r8: {w:160,h:120,msPerLine:60, name:'Robot8'}, |
| r36:{w:320,h:240,msPerLine:88, name:'Robot36'}, |
| r72:{w:320,h:240,msPerLine:176,name:'Robot72'}, |
| }; |
| |
| let AC=null, micStream=null; |
| let sstvActive=false, fskActive=false; |
| let sstvImgData=null, sstvImgCount=0; |
| let fskCharCount=0; |
| |
| function getAC(){ |
| if(!AC) AC=new(window.AudioContext||window.webkitAudioContext)(); |
| if(AC.state==='suspended') AC.resume(); |
| return AC; |
| } |
| |
| |
| function slog(msg,cls=''){ |
| const el=document.getElementById('sstvLog'); |
| const d=document.createElement('div');d.className=cls;d.textContent='> '+msg; |
| el.appendChild(d);el.scrollTop=el.scrollHeight; |
| } |
| function flog(msg,cls=''){ |
| const el=document.getElementById('fskLog'); |
| const d=document.createElement('div');d.className=cls;d.textContent='> '+msg; |
| el.appendChild(d);el.scrollTop=el.scrollHeight; |
| } |
| function copyLog(id){ |
| const txt=[...document.getElementById(id).children].map(n=>n.textContent).join('\n'); |
| navigator.clipboard?.writeText(txt).catch(()=>{}); |
| } |
| |
| |
| setInterval(()=>{ |
| if(sstvActive||fskActive) |
| document.getElementById('hudTime').textContent=new Date().toTimeString().slice(0,8); |
| },1000); |
| |
| |
| |
| function goertzel(buf, start, len, freq, sr){ |
| const k=Math.round(len*freq/sr); |
| const omega=(2*Math.PI*k)/len; |
| const coeff=2*Math.cos(omega); |
| let s0=0,s1=0,s2=0; |
| for(let i=0;i<len;i++){ |
| s0=buf[start+i]+coeff*s1-s2; |
| s2=s1;s1=s0; |
| } |
| const re=s1-s2*Math.cos(omega), im=s2*Math.sin(omega); |
| return Math.sqrt(re*re+im*im)/len; |
| } |
| |
| |
| function detectFreq(buf, start, len, sr, nProbes){ |
| let best=-1, bf=SB; |
| for(let i=0;i<=nProbes;i++){ |
| const f=SB+(i/nProbes)*(SW-SB); |
| const p=goertzel(buf,start,len,f,sr); |
| if(p>best){best=p;bf=f;} |
| } |
| return bf; |
| } |
| |
| function freqToLuma(f){return Math.max(0,Math.min(255,((f-SB)/(SW-SB))*255));} |
| |
| |
| |
| |
| const WF_MAX=3000, WF_H=180; |
| const wfCv=document.getElementById('wfCv'); |
| const wfCtx=wfCv.getContext('2d'); |
| |
| function initWf(){ |
| wfCv.width=wfCv.offsetWidth||480; |
| wfCv.height=WF_H; |
| wfCtx.fillStyle='#000'; |
| wfCtx.fillRect(0,0,wfCv.width,WF_H); |
| } |
| initWf(); |
| window.addEventListener('resize',initWf); |
| |
| |
| function wfColor(v){ |
| if(v<15) return [0,0,0]; |
| if(v<60) {const t=(v-15)/45;return[0,Math.floor(t*160),Math.floor(t*25)];} |
| if(v<130) {const t=(v-60)/70;return[Math.floor(t*180),160+Math.floor(t*55),0];} |
| if(v<200) {const t=(v-130)/70;return[180+Math.floor(t*55),215+Math.floor(t*40),Math.floor(t*90)];} |
| const t=(v-200)/55;return[235+Math.floor(t*20),255,90+Math.floor(t*165)]; |
| } |
| |
| function wfPushRow(freqBuf, bins, sr){ |
| const W=wfCv.width; |
| const nyq=sr/2; |
| const binMax=Math.floor(WF_MAX/nyq*bins); |
| |
| const frame=wfCtx.getImageData(0,0,W,WF_H); |
| frame.data.copyWithin(0,W*4); |
| wfCtx.putImageData(frame,0,0); |
| |
| const row=wfCtx.createImageData(W,1); |
| for(let x=0;x<W;x++){ |
| const bin=Math.floor(x/W*binMax); |
| const v=freqBuf[Math.min(bin,freqBuf.length-1)]; |
| const [r,g,b]=wfColor(v); |
| const hz=(bin/bins)*nyq; |
| const boost=(hz>=1200&&hz<=2300&&v>20)?10:0; |
| row.data[x*4]=Math.min(255,r+boost); |
| row.data[x*4+1]=Math.min(255,g+boost); |
| row.data[x*4+2]=Math.min(255,b); |
| row.data[x*4+3]=255; |
| } |
| wfCtx.putImageData(row,0,WF_H-1); |
| } |
| |
| wfCv.addEventListener('mousemove',e=>{ |
| const r=wfCv.getBoundingClientRect(); |
| document.getElementById('wfCursor').textContent=Math.round((e.clientX-r.left)/r.width*WF_MAX)+' Hz'; |
| }); |
| wfCv.addEventListener('touchmove',e=>{ |
| e.preventDefault(); |
| const r=wfCv.getBoundingClientRect(); |
| document.getElementById('wfCursor').textContent=Math.round((e.touches[0].clientX-r.left)/r.width*WF_MAX)+' Hz'; |
| },{passive:false}); |
| |
| document.getElementById('btnWfSave').onclick=()=>{ |
| wfCv.toBlob(blob=>{ |
| const url=URL.createObjectURL(blob); |
| Object.assign(document.createElement('a'),{href:url,download:'wf-'+Date.now()+'.png'}).click(); |
| setTimeout(()=>URL.revokeObjectURL(url),3000); |
| }); |
| }; |
| document.getElementById('btnWfClr').onclick=()=>{ |
| wfCtx.fillStyle='#000';wfCtx.fillRect(0,0,wfCv.width,WF_H); |
| }; |
| |
| |
| |
| |
| const sstvCv =document.getElementById('sstvCv'); |
| const sstvCtx=sstvCv.getContext('2d'); |
| |
| function initSstvCanvas(m){ |
| sstvCv.width=m.w; sstvCv.height=m.h; |
| sstvCtx.fillStyle='#000'; |
| sstvCtx.fillRect(0,0,m.w,m.h); |
| document.getElementById('hudMode').textContent=m.name; |
| document.getElementById('hudLine').textContent='LINE 0 / '+m.h; |
| document.getElementById('sstvProg').style.width='0%'; |
| } |
| |
| function paintLine(pixels, lineY, mode){ |
| const id=sstvCtx.createImageData(mode.w,1); |
| for(let x=0;x<mode.w;x++){ |
| const v=pixels[x]; |
| id.data[x*4]=v; id.data[x*4+1]=v; |
| id.data[x*4+2]=v; id.data[x*4+3]=255; |
| } |
| sstvCtx.putImageData(id,0,lineY); |
| |
| const sl=document.getElementById('scanLine'); |
| const pct=lineY/mode.h; |
| sl.style.top=Math.floor(pct*(sstvCv.offsetHeight||240))+'px'; |
| sl.style.display='block'; |
| } |
| |
| document.getElementById('btnSstvStart').onclick=async()=>{ |
| try{ |
| micStream=await navigator.mediaDevices.getUserMedia({ |
| audio:{echoCancellation:false,noiseSuppression:false,autoGainControl:false} |
| }); |
| }catch(err){slog('Microfon: '+err.message,'err');return;} |
| |
| const ac=getAC(); |
| const sr=ac.sampleRate; |
| const src=ac.createMediaStreamSource(micStream); |
| |
| |
| const gainNode=ac.createGain(); |
| gainNode.gain.value=+document.getElementById('rxGain').value; |
| src.connect(gainNode); |
| |
| |
| const wfAn=ac.createAnalyser(); |
| wfAn.fftSize=8192; |
| wfAn.smoothingTimeConstant=0.1; |
| gainNode.connect(wfAn); |
| const wfBuf=new Uint8Array(wfAn.frequencyBinCount); |
| |
| |
| const proc=ac.createScriptProcessor(4096,1,1); |
| gainNode.connect(proc); |
| proc.connect(ac.destination); |
| |
| |
| let currentMode=MODES[document.getElementById('sstvRxModeSelect').value]; |
| initSstvCanvas(currentMode); |
| |
| const spVISBit=Math.round(sr*30/1000); |
| let spSync=Math.round(sr*SyncMs/1000); |
| let spPx=Math.round(sr*currentMode.msPerLine/1000)/currentMode.w; |
| |
| function applyMode(key){ |
| const m=MODES[key]; |
| if(!m)return; |
| currentMode=m; |
| spSync=Math.round(sr*SyncMs/1000); |
| spPx=Math.round(sr*m.msPerLine/1000)/m.w; |
| initSstvCanvas(m); |
| slog('VIS β '+m.name,'ok'); |
| } |
| |
| |
| const RING=sr*4; |
| const ring=new Float32Array(RING); |
| let rH=0,rT=0; |
| const rAvail=()=>(rH-rT+RING)%RING; |
| const rPush=arr=>{for(let i=0;i<arr.length;i++){ring[rH]=arr[i];rH=(rH+1)%RING;}}; |
| const rRead=(off,len)=>{const o=new Float32Array(len);for(let i=0;i<len;i++)o[i]=ring[(rT+off+i)%RING];return o;}; |
| const rConsume=n=>{rT=(rT+n+RING)%RING;}; |
| |
| |
| const ST={HUNT:0,VIS:1,DECODE:2}; |
| let state=ST.HUNT, pixelPos=0, curLine=0, syncHold=0; |
| let linePixels=new Uint8Array(currentMode.w); |
| let snrSmooth=0; |
| let wfRunning=true; |
| |
| proc.onaudioprocess=e=>{ |
| if(!sstvActive)return; |
| const chunk=e.inputBuffer.getChannelData(0); |
| rPush(chunk); |
| |
| |
| let rms=0; |
| for(let i=0;i<chunk.length;i++)rms+=chunk[i]*chunk[i]; |
| rms=Math.sqrt(rms/chunk.length); |
| snrSmooth=snrSmooth*.92+rms*.08; |
| const snrDb=Math.min(40,snrSmooth*400).toFixed(0); |
| document.getElementById('hudSnr').textContent='SNR '+snrDb+'dB'; |
| document.getElementById('globalSnr').textContent=snrDb+'dB'; |
| |
| while(true){ |
| const av=rAvail(); |
| |
| if(state===ST.HUNT){ |
| if(av<spSync)break; |
| const win=rRead(0,spSync); |
| const sp=goertzel(win,0,spSync,SS,sr); |
| const thr=Math.max(0.015,snrSmooth*2.5); |
| if(sp>thr){ |
| syncHold++; |
| document.getElementById('syncBar').classList.add('active'); |
| document.getElementById('sstvStatus').textContent='SYNC'; |
| if(syncHold>=2){ |
| |
| const visNeeded=spSync+9*spVISBit; |
| if(av>=visNeeded){ |
| let visCode=0; |
| for(let b=0;b<8;b++){ |
| const voff=spSync+b*spVISBit; |
| const vw=rRead(voff,spVISBit); |
| const p1=goertzel(vw,0,spVISBit,1100,sr); |
| const p0=goertzel(vw,0,spVISBit,1300,sr); |
| if(p1>p0)visCode|=(1<<b); |
| } |
| const mapped=VIS_MAP[visCode&0x7F]||VIS_MAP[visCode]; |
| if(mapped)applyMode(mapped); |
| rConsume(visNeeded); |
| } else { |
| rConsume(spSync); |
| } |
| curLine=0; |
| linePixels=new Uint8Array(currentMode.w); |
| pixelPos=0; |
| syncHold=0; |
| state=ST.DECODE; |
| document.getElementById('sstvStatus').textContent='DECODE'; |
| document.getElementById('sstvProg').style.width='0%'; |
| } else { |
| rConsume(Math.floor(spSync/4)); |
| } |
| } else { |
| syncHold=0; |
| rConsume(1); |
| document.getElementById('syncBar').classList.remove('active'); |
| document.getElementById('sstvStatus').textContent='HUNT'; |
| } |
| |
| } else { |
| const needed=Math.round(spPx); |
| if(av<needed)break; |
| const win=rRead(0,needed); |
| |
| const freq=detectFreq(win,0,needed,sr,32); |
| linePixels[pixelPos]=Math.round(freqToLuma(freq)); |
| pixelPos++; |
| rConsume(needed); |
| |
| if(pixelPos>=currentMode.w){ |
| paintLine(linePixels,curLine,currentMode); |
| curLine++; |
| linePixels=new Uint8Array(currentMode.w); |
| pixelPos=0; |
| document.getElementById('hudLine').textContent='LINE '+curLine+' / '+currentMode.h; |
| document.getElementById('sstvProg').style.width=(curLine/currentMode.h*100)+'%'; |
| |
| if(curLine>=currentMode.h){ |
| sstvImgData=sstvCv.toDataURL('image/png'); |
| sstvImgCount++; |
| document.getElementById('imgCount').textContent=sstvImgCount; |
| document.getElementById('btnSstvSave').disabled=false; |
| document.getElementById('sstvStatus').textContent='DONE #'+sstvImgCount; |
| document.getElementById('scanLine').style.display='none'; |
| slog('β Imagine #'+sstvImgCount+' completΔ ('+currentMode.w+'Γ'+currentMode.h+')','ok'); |
| curLine=0; |
| sstvCtx.fillStyle='#111'; |
| sstvCtx.fillRect(0,0,currentMode.w,currentMode.h); |
| } |
| state=ST.HUNT; |
| } |
| } |
| if(rAvail()>sr*2)rConsume(rAvail()-sr*2); |
| } |
| }; |
| |
| |
| (function wfLoop(){ |
| if(!wfRunning)return; |
| wfAn.getByteFrequencyData(wfBuf); |
| wfPushRow(wfBuf,wfAn.frequencyBinCount,sr); |
| requestAnimationFrame(wfLoop); |
| })(); |
| |
| sstvActive=true; |
| document.getElementById('btnSstvStart').disabled=true; |
| document.getElementById('btnSstvStop').disabled=false; |
| slog('π SSTV RX activ β sr='+sr+' gain='+gainNode.gain.value+'Γ','ok'); |
| slog('spSync='+spSync+' spPx='+spPx.toFixed(1)+' probes=32','ok'); |
| |
| micStream._sstvProc=proc; |
| micStream._wfStop=()=>{wfRunning=false;}; |
| }; |
| |
| document.getElementById('btnSstvStop').onclick=()=>{ |
| sstvActive=false; |
| if(micStream){ |
| if(micStream._wfStop)micStream._wfStop(); |
| if(micStream._sstvProc){try{micStream._sstvProc.disconnect();}catch(e){}} |
| if(!fskActive){micStream.getTracks().forEach(t=>t.stop());micStream=null;} |
| } |
| document.getElementById('btnSstvStart').disabled=false; |
| document.getElementById('btnSstvStop').disabled=true; |
| document.getElementById('sstvStatus').textContent='STOP'; |
| document.getElementById('syncBar').classList.remove('active'); |
| document.getElementById('scanLine').style.display='none'; |
| slog('β SSTV RX oprit.','warn'); |
| }; |
| |
| document.getElementById('btnSstvSave').onclick=()=>{ |
| if(!sstvImgData)return; |
| Object.assign(document.createElement('a'),{href:sstvImgData,download:'sstv-'+Date.now()+'.png'}).click(); |
| slog('β¬ Salvat.','ok'); |
| }; |
| |
| |
| |
| |
| document.getElementById('btnFskStart').onclick=async()=>{ |
| try{ |
| if(!micStream){ |
| micStream=await navigator.mediaDevices.getUserMedia({ |
| audio:{echoCancellation:false,noiseSuppression:false,autoGainControl:false} |
| }); |
| } |
| }catch(err){flog('Microfon: '+err.message,'err');return;} |
| |
| const ac=getAC(); |
| const sr=ac.sampleRate; |
| const src=ac.createMediaStreamSource(micStream); |
| const proc=ac.createScriptProcessor(2048,1,1); |
| src.connect(proc);proc.connect(ac.destination); |
| |
| const [baudStr,markStr]=document.getElementById('rxFskBaud').value.split('_'); |
| const baud=+baudStr, fskMark=+markStr, fskSpace=fskMark-170; |
| const spb=sr/baud; |
| |
| let accumBuf=new Float32Array(sr*2); |
| let accumLen=0, bitPhase=0; |
| let fskSt='IDLE', fskBits=[], fskMsg=''; |
| |
| proc.onaudioprocess=e=>{ |
| if(!fskActive)return; |
| const inp=e.inputBuffer.getChannelData(0); |
| |
| |
| if(accumLen+inp.length>accumBuf.length){ |
| const keep=Math.ceil(spb*20); |
| accumBuf.copyWithin(0,accumLen-keep,accumLen); |
| accumLen=keep; |
| } |
| accumBuf.set(inp,accumLen); |
| accumLen+=inp.length; |
| |
| const bitLen=Math.round(spb); |
| |
| while(bitPhase+bitLen<=accumLen){ |
| const off=Math.floor(bitPhase); |
| if(off+bitLen>accumLen)break; |
| |
| const pm=goertzel(accumBuf,off,bitLen,fskMark,sr); |
| const ps=goertzel(accumBuf,off,bitLen,fskSpace,sr); |
| const bit=pm>ps?1:0; |
| const snr=Math.min(40,Math.abs(pm-ps)*800); |
| document.getElementById('fskSnrDisp').textContent=snr.toFixed(0)+'dB'; |
| document.getElementById('globalSnr').textContent=snr.toFixed(0)+'dB'; |
| |
| if(fskSt==='IDLE'){ |
| if(bit===0){fskSt='DATA';fskBits=[];bitPhase+=bitLen+spb*.5;} |
| else{bitPhase+=1;} |
| } else if(fskSt==='DATA'){ |
| fskBits.push(bit);bitPhase+=bitLen; |
| if(fskBits.length===8)fskSt='STOP'; |
| } else { |
| if(bit===1){ |
| let code=0; |
| for(let b=0;b<8;b++)code|=(fskBits[b]<<b); |
| if(code===4&&fskMsg.trim()){ |
| flog('β '+fskMsg.trim(),'ok');fskMsg=''; |
| } else if(code>=32&&code<127){ |
| fskMsg+=String.fromCharCode(code); |
| appendChar(String.fromCharCode(code)); |
| } |
| } |
| fskSt='IDLE';bitPhase+=bitLen; |
| } |
| if(bitPhase>accumLen-bitLen)break; |
| } |
| const trim=Math.floor(Math.max(0,bitPhase-spb*10)); |
| if(trim>0){accumBuf.copyWithin(0,trim,accumLen);accumLen-=trim;bitPhase-=trim;} |
| }; |
| |
| fskActive=true; |
| document.getElementById('btnFskStart').disabled=true; |
| document.getElementById('btnFskStop').disabled=false; |
| document.getElementById('fskStatus').textContent='RX'; |
| flog('π FSK RX β '+fskMark+'/'+fskSpace+'Hz @ '+baud+'bd Β· spb='+spb.toFixed(1),'ok'); |
| |
| micStream._fskProc=proc; |
| }; |
| |
| document.getElementById('btnFskStop').onclick=()=>{ |
| fskActive=false; |
| if(micStream&&micStream._fskProc){try{micStream._fskProc.disconnect();}catch(e){}} |
| if(!sstvActive&&micStream){micStream.getTracks().forEach(t=>t.stop());micStream=null;} |
| document.getElementById('btnFskStart').disabled=false; |
| document.getElementById('btnFskStop').disabled=true; |
| document.getElementById('fskStatus').textContent='STOP'; |
| document.getElementById('fskSnrDisp').textContent='β'; |
| flog('β FSK RX oprit.','warn'); |
| }; |
| |
| function appendChar(ch){ |
| const screen=document.getElementById('fskScreen'); |
| const cursor=screen.querySelector('.cursor'); |
| screen.insertBefore(document.createTextNode(ch),cursor); |
| screen.scrollTop=screen.scrollHeight; |
| fskCharCount++; |
| document.getElementById('fskCharCount').textContent=fskCharCount+' chars'; |
| document.getElementById('charCount').textContent=fskCharCount; |
| } |
| |
| document.getElementById('btnFskClr').onclick=()=>{ |
| const s=document.getElementById('fskScreen'); |
| [...s.childNodes].forEach(n=>{if(!(n.nodeType===1&&n.classList?.contains('cursor')))s.removeChild(n);}); |
| if(!s.querySelector('.cursor')){const c=document.createElement('span');c.className='cursor';s.appendChild(c);} |
| fskCharCount=0; |
| document.getElementById('fskCharCount').textContent='0 chars'; |
| document.getElementById('charCount').textContent='0'; |
| }; |
| </script> |
| </body> |
| </html> |
|
|