novatx / encoder.html
vsmdvic's picture
Upload 5 files
48278b5 verified
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>TX โ€” Encoder</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:#f5f4f0;
--ink:#0a0a0a;
--ink2:#555;
--ink3:#aaa;
--line:#ddd;
--line2:#e8e8e8;
--surface:#fff;
--accent:#0a0a0a;
}
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(--line);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(--line);}
.nav-switch a.on{background:var(--ink);color:#fff;}
.nav-switch a:hover:not(.on){background:var(--line2);}
/* 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,textarea{
font-family:'Martian Mono',monospace;font-size:11px;
width:100%;background:var(--bg);
border:1px solid var(--line);border-radius:6px;
color:var(--ink);padding:9px 12px;outline:none;
-webkit-appearance:none;transition:.15s;
}
select:focus,textarea:focus{border-color:var(--ink3);}
textarea{resize:none;min-height:72px;line-height:1.7;}
.field{margin-bottom:14px;}
.field:last-child{margin-bottom:0;}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.range-wrap{display:flex;align-items:center;gap:10px;}
input[type=range]{
-webkit-appearance:none;flex:1;height:2px;
background:var(--line);border-radius:2px;cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:16px;height:16px;
border-radius:50%;background:var(--ink);cursor:pointer;
}
.range-val{font-size:10px;color:var(--ink2);min-width:28px;text-align:right;}
input[type=file]{display:none;}
/* 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(--line);background:transparent;
color:var(--ink2);cursor:pointer;transition:.15s;
}
button:active{opacity:.6;}
button.primary{background:var(--ink);color:#fff;border-color:var(--ink);}
button.primary:hover{background:#222;}
button:hover:not(.primary){background:var(--line2);}
button:disabled{opacity:.25;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:12px;}
.prog-b{height:100%;width:0;background:var(--ink);transition:width .1s;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:#888;}
.log .err{color:#bbb;}
/* UPLOAD */
.upload{
border:1px dashed var(--line);border-radius:8px;padding:20px;
text-align:center;color:var(--ink3);font-size:10px;
letter-spacing:1px;cursor:pointer;transition:.2s;text-transform:uppercase;
}
.upload:hover{border-color:var(--ink3);}
/* CANVAS */
#txCv{display:block;width:100%;background:var(--bg);image-rendering:pixelated;}
.canvas-wrap{background:var(--bg);border:1px solid var(--line2);border-radius:6px;overflow:hidden;}
/* SPECTRUM */
#specCv{display:block;width:100%;height:52px;background:var(--ink);image-rendering:pixelated;}
/* WAV badge */
.wav-badge{font-size:9px;color:var(--ink3);margin-top:8px;}
/* Tabs inside encoder */
.enc-tabs{display:flex;gap:0;border-bottom:1px solid var(--line2);}
.enc-tab{
font-family:'Martian Mono',monospace;
font-size:9px;letter-spacing:2px;text-transform:uppercase;
padding:10px 18px;border:none;background:transparent;
color:var(--ink3);cursor:pointer;border-bottom:2px solid transparent;transition:.15s;
}
.enc-tab.on{color:var(--ink);border-bottom-color:var(--ink);}
.enc-page{display:none;padding:16px;}
.enc-page.on{display:block;}
::-webkit-scrollbar{width:3px;}
::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
</style>
</head>
<body>
<nav>
<div class="nav-logo">RadioยทTX</div>
<div class="nav-switch">
<a href="encoder.html" class="on">TX Encoder</a>
<a href="receiver.html">RX Decoder</a>
</div>
</nav>
<!-- ENCODER TABS -->
<div class="block" style="margin-top:16px;margin-left:16px;margin-right:16px;">
<div class="enc-tabs">
<button class="enc-tab on" data-enc="fsk">FSK Text</button>
<button class="enc-tab" data-enc="sstv">SSTV Image</button>
</div>
<!-- FSK PAGE -->
<div class="enc-page on" id="enc-fsk">
<div class="field">
<label>Mesaj de transmis</label>
<textarea id="fskText" placeholder="Scrie mesajul tฤƒu...">CQ CQ CQ DE RADIO-TX 73</textarea>
</div>
<div class="row2">
<div class="field">
<label>Baud rate</label>
<select id="fskBaud">
<option value="45">45 bd โ€” RTTY</option>
<option value="100" selected>100 bd</option>
<option value="300">300 bd</option>
</select>
</div>
<div class="field">
<label>Mark freq</label>
<select id="fskMark">
<option value="1200" selected>1200 Hz</option>
<option value="1275">1275 Hz</option>
<option value="2125">2125 Hz</option>
</select>
</div>
</div>
<div class="row2">
<div class="field">
<label>Repetฤƒri</label>
<select id="fskRep">
<option value="1">1ร—</option>
<option value="3" selected>3ร—</option>
<option value="5">5ร—</option>
</select>
</div>
<div class="field">
<label>Volum โ€” <span id="fskVolV">80</span>%</label>
<div class="range-wrap" style="margin-top:8px;">
<input type="range" id="fskVol" min="10" max="100" value="80">
</div>
</div>
</div>
<div class="btn-row">
<button class="primary" id="btnFskTx">โ–ถ Transmit</button>
<button id="btnFskStop" disabled>โ–  Stop</button>
<button class="sm" id="btnFskWav" disabled>โฌ‡ WAV</button>
</div>
<div class="prog"><div class="prog-b" id="fskProg"></div></div>
<div class="wav-badge" id="fskWavBadge"></div>
</div>
<!-- SSTV PAGE -->
<div class="enc-page" id="enc-sstv">
<div class="field">
<div class="upload" id="uploadZone" onclick="document.getElementById('imgIn').click()">
โ—ˆ Apasฤƒ pentru imagine<br>
<span style="font-size:9px;opacity:.5;margin-top:4px;display:block">PNG / JPG / WEBP</span>
</div>
<input type="file" id="imgIn" accept="image/*">
</div>
<div id="txPrevWrap" style="display:none;" class="field">
<div class="canvas-wrap"><canvas id="txCv"></canvas></div>
</div>
<div class="row2">
<div class="field">
<label>Mod SSTV</label>
<select id="sstvMode">
<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>Volum โ€” <span id="sstvVolV">80</span>%</label>
<div class="range-wrap" style="margin-top:8px;">
<input type="range" id="sstvVol" min="10" max="100" value="80">
</div>
</div>
</div>
<div class="btn-row">
<button class="primary" id="btnSstvTx" disabled>โ–ถ Transmit</button>
<button id="btnSstvStop" disabled>โ–  Stop</button>
<button class="sm" id="btnSstvWav" disabled>โฌ‡ WAV</button>
</div>
<div class="prog"><div class="prog-b" id="sstvProg"></div></div>
<div class="wav-badge" id="sstvWavBadge"></div>
</div>
</div>
<!-- STATS -->
<div class="block">
<div class="block-head">
<span class="bh-title">Stats</span>
<span class="bh-r" id="txStatusBadge">IDLE</span>
</div>
<div class="stats">
<div class="stat"><div class="stat-l">Mark</div><div class="stat-v" id="sMark">1200</div></div>
<div class="stat"><div class="stat-l">Space</div><div class="stat-v" id="sSpace">1030</div></div>
<div class="stat"><div class="stat-l">Duratฤƒ</div><div class="stat-v" id="sDur">โ€”</div></div>
</div>
</div>
<!-- SPECTRUM -->
<div class="block">
<div class="block-head"><span class="bh-title">Spectrum</span></div>
<canvas id="specCv"></canvas>
</div>
<!-- LOG -->
<div class="block">
<div class="log-wrap">
<div class="log-head">
<span>Log</span>
<button class="sm" onclick="copyLog('txLog')" style="font-size:8px;padding:4px 10px;">Copy</button>
</div>
<div class="log" id="txLog"><div class="ok">> Encoder ready.</div></div>
</div>
</div>
<!-- GUIDE -->
<div class="block">
<div class="block-head"><span class="bh-title">HT Guide</span></div>
<div class="block-body" style="font-size:10px;color:var(--ink2);line-height:2;">
1. Volum boxe <strong>60โ€“75%</strong> โ€” evitฤƒ clipping<br>
2. Microfon HT la <strong>5โ€“10 cm</strong><br>
3. Apasฤƒ <strong>PTT</strong> pe HT<br>
4. Apasฤƒ <strong>โ–ถ Transmit</strong><br>
5. Elibereazฤƒ PTT dupฤƒ bara 100%<br><br>
FSK: Mark <strong>1200Hz</strong> ยท Space <strong>1030Hz</strong> ยท Shift <strong>170Hz</strong><br>
SSTV: Sync <strong>1200Hz</strong> ยท Negru <strong>1500Hz</strong> ยท Alb <strong>2300Hz</strong>
</div>
</div>
<script>
'use strict';
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ENCODER โ€” FSK + SSTV B&W Transmitter
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
const SS=1200,SB=1500,SW=2300,SyncMs=9;
const MODES={
r8: {w:160,h:120,msPerLine:60, name:'Robot8 B&W'},
r36:{w:320,h:240,msPerLine:88, name:'Robot36 B&W'},
r72:{w:320,h:240,msPerLine:176,name:'Robot72 B&W'},
};
let AC=null,fskNode=null,sstvNode=null,txActive=false;
let fskPcm=null,fskSr=48000,sstvPcm=null,sstvSr=48000;
function getAC(){
if(!AC) AC=new(window.AudioContext||window.webkitAudioContext)();
if(AC.state==='suspended') AC.resume();
return AC;
}
// โ”€โ”€ Tabs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
document.querySelectorAll('.enc-tab').forEach(t=>{
t.onclick=()=>{
document.querySelectorAll('.enc-tab').forEach(x=>x.classList.remove('on'));
document.querySelectorAll('.enc-page').forEach(x=>x.classList.remove('on'));
t.classList.add('on');
document.getElementById('enc-'+t.dataset.enc).classList.add('on');
updateStats();
};
});
// โ”€โ”€ Sliders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
document.getElementById('fskVol').oninput=function(){document.getElementById('fskVolV').textContent=this.value;};
document.getElementById('sstvVol').oninput=function(){document.getElementById('sstvVolV').textContent=this.value;};
// โ”€โ”€ Stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function updateStats(){
const baud=+document.getElementById('fskBaud').value;
const mark=+document.getElementById('fskMark').value;
const space=mark-170;
const txt=document.getElementById('fskText').value;
const reps=+document.getElementById('fskRep').value;
document.getElementById('sMark').textContent=mark;
document.getElementById('sSpace').textContent=space;
document.getElementById('sDur').textContent=((txt.length*reps*10)/baud).toFixed(1)+'s';
}
['fskBaud','fskMark','fskRep'].forEach(id=>document.getElementById(id).onchange=updateStats);
document.getElementById('fskText').oninput=updateStats;
updateStats();
// โ”€โ”€ Log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function log(msg,cls=''){
const el=document.getElementById('txLog');
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(()=>{});
}
// โ”€โ”€ WAV Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function dlWav(f32,sr,name){
const n=f32.length,ab=new ArrayBuffer(44+n*2),v=new DataView(ab);
const s=(o,str)=>{for(let i=0;i<str.length;i++)v.setUint8(o+i,str.charCodeAt(i));};
s(0,'RIFF');v.setUint32(4,36+n*2,true);s(8,'WAVE');s(12,'fmt ');
v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);
v.setUint32(24,sr,true);v.setUint32(28,sr*2,true);v.setUint16(32,2,true);v.setUint16(34,16,true);
s(36,'data');v.setUint32(40,n*2,true);
for(let i=0;i<n;i++) v.setInt16(44+i*2,Math.max(-32768,Math.min(32767,f32[i]*32767)),true);
const url=URL.createObjectURL(new Blob([ab],{type:'audio/wav'}));
Object.assign(document.createElement('a'),{href:url,download:name}).click();
setTimeout(()=>URL.revokeObjectURL(url),5000);
}
document.getElementById('btnFskWav').onclick=()=>{
if(fskPcm){dlWav(fskPcm,fskSr,'fsk-'+Date.now()+'.wav');log('โฌ‡ WAV FSK descฤƒrcat.','ok');}
};
document.getElementById('btnSstvWav').onclick=()=>{
if(sstvPcm){dlWav(sstvPcm,sstvSr,'sstv-'+Date.now()+'.wav');log('โฌ‡ WAV SSTV descฤƒrcat.','ok');}
};
// โ”€โ”€ Spectrum โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function drawSpec(an){
const cv=document.getElementById('specCv');
cv.width=cv.offsetWidth||480;
const ctx=cv.getContext('2d'),W=cv.width,H=cv.height;
const fr=new Float32Array(an.frequencyBinCount);
an.getFloatFrequencyData(fr);
ctx.fillStyle='#0a0a0a';ctx.fillRect(0,0,W,H);
const bw=W/fr.length;
for(let i=0;i<fr.length;i++){
const v=Math.max(0,(fr[i]+100)/100);
const h=v*H;
const g=Math.floor(v*220);
ctx.fillStyle=`rgb(${g},${g},${g})`;
ctx.fillRect(Math.floor(i*bw),H-h,Math.max(1,Math.ceil(bw)),h);
}
}
// โ”€โ”€ FSK TX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
document.getElementById('btnFskTx').onclick=()=>{
const txt=document.getElementById('fskText').value; if(!txt.trim())return;
const baud=+document.getElementById('fskBaud').value;
const mark=+document.getElementById('fskMark').value;
const space=mark-170;
const vol=document.getElementById('fskVol').value/100;
const reps=+document.getElementById('fskRep').value;
const ac=getAC(),sr=ac.sampleRate;
const spb=sr/baud;
const msg=('\x05\x05\x05'+txt+' \x04').repeat(reps);
const total=Math.ceil(msg.length*10*spb+sr*1.5);
const buf=ac.createBuffer(1,total,sr);
const data=buf.getChannelData(0);
let off=0,ph=0;
const tone=(f,n)=>{
const ns=Math.round(n);
for(let i=0;i<ns&&off<data.length;i++,off++){
data[off]=Math.sin(ph)*vol;
ph=(ph+2*Math.PI*f/sr)%(2*Math.PI);
}
};
tone(mark,sr*.15);
for(const ch of msg){
const code=ch.charCodeAt(0)&0xFF;
tone(space,spb);
for(let b=0;b<8;b++) tone((code>>b)&1?mark:space,spb);
tone(mark,spb);
}
tone(mark,sr*.3);
fskPcm=data.slice(0,off); fskSr=sr;
document.getElementById('btnFskWav').disabled=false;
document.getElementById('fskWavBadge').textContent='WAV: '+(fskPcm.length/sr).toFixed(1)+'s ยท '+(fskPcm.length*2/1024|0)+'KB';
const src=ac.createBufferSource(); src.buffer=buf;
const an=ac.createAnalyser(); an.fftSize=1024;
src.connect(an); an.connect(ac.destination);
fskNode=src; txActive=true; src.start();
document.getElementById('btnFskTx').disabled=true;
document.getElementById('btnFskStop').disabled=false;
document.getElementById('txStatusBadge').textContent='TX FSK';
log('โ–ถ FSK '+txt.length+' chars ร— '+reps+' @ '+baud+'bd','ok');
const t0=ac.currentTime,dur=off/sr;
(function anim(){
if(!txActive)return;
document.getElementById('fskProg').style.width=Math.min(100,(ac.currentTime-t0)/dur*100)+'%';
drawSpec(an);
requestAnimationFrame(anim);
})();
src.onended=()=>{
txActive=false;
document.getElementById('btnFskTx').disabled=false;
document.getElementById('btnFskStop').disabled=true;
document.getElementById('txStatusBadge').textContent='DONE';
document.getElementById('fskProg').style.width='100%';
log('โ–  Transmisie completฤƒ.','ok');
};
};
document.getElementById('btnFskStop').onclick=()=>{
if(fskNode){try{fskNode.stop();}catch(e){}}
txActive=false;
document.getElementById('btnFskTx').disabled=false;
document.getElementById('btnFskStop').disabled=true;
document.getElementById('txStatusBadge').textContent='STOP';
};
// โ”€โ”€ SSTV TX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
document.getElementById('imgIn').onchange=function(e){
const f=e.target.files[0]; if(!f)return;
const url=URL.createObjectURL(f);
const img=new Image();
img.onload=()=>{
const m=MODES[document.getElementById('sstvMode').value];
const cv=document.getElementById('txCv');
cv.width=m.w; cv.height=m.h;
cv.getContext('2d').drawImage(img,0,0,m.w,m.h);
document.getElementById('txPrevWrap').style.display='block';
document.getElementById('uploadZone').textContent='โ—ˆ '+f.name;
document.getElementById('btnSstvTx').disabled=false;
URL.revokeObjectURL(url);
log('Imagine รฎncฤƒrcatฤƒ: '+m.w+'ร—'+m.h,'ok');
};
img.src=url;
};
document.getElementById('btnSstvTx').onclick=()=>{
const k=document.getElementById('sstvMode').value;
const m=MODES[k];
const vol=document.getElementById('sstvVol').value/100;
const ac=getAC(),sr=ac.sampleRate;
const cv=document.getElementById('txCv');
const px=cv.getContext('2d').getImageData(0,0,m.w,m.h);
const lineMs=SyncMs+m.msPerLine;
const totalMs=m.h*lineMs+600;
const totalS=Math.ceil(sr*totalMs/1000)+sr;
const buf=ac.createBuffer(1,totalS,sr);
const data=buf.getChannelData(0);
let off=0,ph=0;
const tone=(f,ms)=>{
const n=Math.round(sr*ms/1000);
for(let i=0;i<n&&off<data.length;i++,off++){
data[off]=Math.sin(ph)*vol;
ph=(ph+2*Math.PI*f/sr)%(2*Math.PI);
}
};
// VIS header Robot8 B&W (0x02) โ€” compatible with all B&W decoders
tone(1900,300);tone(1200,10);tone(1900,300);
tone(1200,30);
const vis=k==='r8'?0x02:k==='r36'?0x08:0x0C;
for(let b=0;b<8;b++) tone((vis>>b)&1?1100:1300,30);
tone(1200,30);
const mspp=m.msPerLine/m.w;
for(let y=0;y<m.h;y++){
tone(SS,SyncMs);
for(let x=0;x<m.w;x++){
const i=(y*m.w+x)*4;
const luma=0.299*px.data[i]+0.587*px.data[i+1]+0.114*px.data[i+2];
tone(SB+(luma/255)*(SW-SB),mspp);
}
}
tone(SS,50);
sstvPcm=data.slice(0,off); sstvSr=sr;
document.getElementById('btnSstvWav').disabled=false;
document.getElementById('sstvWavBadge').textContent='WAV: '+(sstvPcm.length/sr|0)+'s ยท '+(sstvPcm.length*2/1048576).toFixed(1)+'MB';
const src=ac.createBufferSource(); src.buffer=buf;
const an=ac.createAnalyser(); an.fftSize=512;
src.connect(an); an.connect(ac.destination);
sstvNode=src; txActive=true; src.start();
document.getElementById('btnSstvTx').disabled=true;
document.getElementById('btnSstvStop').disabled=false;
document.getElementById('txStatusBadge').textContent='TX SSTV';
log('โ–ถ '+m.name+' โ€” '+m.w+'ร—'+m.h,'ok');
const t0=ac.currentTime;
(function anim(){
if(!txActive)return;
const p=Math.min(100,(ac.currentTime-t0)*1000/totalMs*100);
document.getElementById('sstvProg').style.width=p+'%';
drawSpec(an);
requestAnimationFrame(anim);
})();
src.onended=()=>{
txActive=false;
document.getElementById('btnSstvTx').disabled=false;
document.getElementById('btnSstvStop').disabled=true;
document.getElementById('txStatusBadge').textContent='DONE';
document.getElementById('sstvProg').style.width='100%';
log('โ–  SSTV complet. WAV disponibil.','ok');
};
};
document.getElementById('btnSstvStop').onclick=()=>{
if(sstvNode){try{sstvNode.stop();}catch(e){}}
txActive=false;
document.getElementById('btnSstvTx').disabled=false;
document.getElementById('btnSstvStop').disabled=true;
document.getElementById('txStatusBadge').textContent='STOP';
};
</script>
</body>
</html>