novatx / receiver.html
vsmdvic's picture
Upload 5 files
48278b5 verified
Raw
History Blame Contribute Delete
30.5 kB
<!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 */
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);}
/* BLOCKS */
.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;}
/* FORM */
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;}
/* BUTTONS */
.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;}
/* PROGRESS */
.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 */
.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 */
.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 SCREEN β€” the main visual element
══════════════════════════════════════════ */
.sstv-wrap{
position:relative;background:#000;
/* 4:3 aspect preserved by canvas */
}
#sstvCv{
display:block;width:100%;
image-rendering:pixelated;
background:#000;
}
/* HUD overlaid on canvas */
.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 that moves down as image builds */
.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 indicator bar at very bottom of image */
.sync-bar{height:2px;background:transparent;transition:background .1s;}
.sync-bar.active{background:rgba(255,255,255,.4);}
/* ══════════════════════════════════════════
WATERFALL β€” real FFT like Robot36
══════════════════════════════════════════ */
.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 TEXT TERMINAL */
.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;
/* Subtle scan lines */
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>
<!-- ══ SSTV IMAGE RECEIVER ══ -->
<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>
<!-- IMAGE BUILDS HERE -->
<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>
<!-- ══ WATERFALL ══ -->
<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>
<!-- ══ FSK TEXT DECODER ══ -->
<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>
<!-- FSK TEXT OUTPUT -->
<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>
<!-- STATS -->
<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';
// ═══════════════════════════════════════════════════════════════
// RECEIVER β€” SSTV B&W + FSK Text decoder
// Uses AudioWorklet (modern, no deprecated ScriptProcessor)
// Falls back to ScriptProcessor if AudioWorklet unavailable
// ═══════════════════════════════════════════════════════════════
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;
}
// ── Log ─────────────────────────────────────────────────────────
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(()=>{});
}
// ── Clock ────────────────────────────────────────────────────────
setInterval(()=>{
if(sstvActive||fskActive)
document.getElementById('hudTime').textContent=new Date().toTimeString().slice(0,8);
},1000);
// ── Goertzel β€” precise single-frequency power ────────────────────
// Operates on Float32Array, no heap allocation per call
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;
}
// Detect best frequency across SSTV band (1500–2300Hz) with N probes
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));}
// ══════════════════════════════════════════════════════════════════
// WATERFALL β€” real FFT, Robot36 color scheme
// ══════════════════════════════════════════════════════════════════
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);
// Robot36 color map
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);
// Shift up
const frame=wfCtx.getImageData(0,0,W,WF_H);
frame.data.copyWithin(0,W*4);
wfCtx.putImageData(frame,0,0);
// New bottom row
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);
};
// ══════════════════════════════════════════════════════════════════
// SSTV RECEIVER
// ══════════════════════════════════════════════════════════════════
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);
// Move scan line
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);
// Software gain
const gainNode=ac.createGain();
gainNode.gain.value=+document.getElementById('rxGain').value;
src.connect(gainNode);
// Waterfall analyser
const wfAn=ac.createAnalyser();
wfAn.fftSize=8192;
wfAn.smoothingTimeConstant=0.1;
gainNode.connect(wfAn);
const wfBuf=new Uint8Array(wfAn.frequencyBinCount);
// ScriptProcessor for sample-accurate decode
const proc=ac.createScriptProcessor(4096,1,1);
gainNode.connect(proc);
proc.connect(ac.destination);
// Mode state
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');
}
// Ring buffer 4s
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;};
// Decoder state
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);
// RMS for SNR
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){
// Try VIS decode
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 { // DECODE
const needed=Math.round(spPx);
if(av<needed)break;
const win=rRead(0,needed);
// 32 Goertzel probes for high accuracy
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);
}
};
// Waterfall loop
(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');
};
// ══════════════════════════════════════════════════════════════════
// FSK TEXT RECEIVER
// ══════════════════════════════════════════════════════════════════
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; // samples per bit β€” exact float
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);
// Append samples
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>