Mirko Pavleski
Published © GPL3+

Simple SDR Receiver Using 2x NE612 - Dual Conversion

This project demonstrates a simple yet highly effective SDR (Software Defined Radio) receiver based on a dual-conversion superheterodyne

IntermediateFull instructions provided3 hours130
Simple SDR Receiver Using 2x NE612 - Dual Conversion

Things used in this project

Hardware components

NE612 double-balanced mixer IC
×2
455KHz filter
×1
465kHz Crystal Resonator
×1
Capacitors , Resistors
×1
VFO 0-30MHz
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic

...

Code

VFO Code with Offset 455KHz

C/C++
...
// By mircemk, June 2026

#include <WiFi.h>
#include <WebServer.h>
#include <si5351.h>
#include <Wire.h>

Si5351 si5351;
unsigned long frequency = 7000000;
bool offsetActive = false;
const unsigned long IF_OFFSET = 455000; // 455 kHz Offset за суперхетеродин

const char* ssid = "Si5351_VFO_Final_Complete";
const char* password = "vfo12345678";

WebServer server(80);

const char VFO_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<style>
  :root { 
    --panel-bg: #E0AB07; 
    --inner-bezel: #E0AB07; 
    --lcd-bg: #0077c2;
    --btn-band: #7f0000; 
    --btn-step: #27ae60; 
    --btn-mode: #2980b9;
    --btn-mem: #8e44ad; 
    --gold-border: #f1c40f; 
    --text-color: #ecf0f1;
  }
  * { -webkit-tap-highlight-color: transparent; box-sizing: border-box; user-select: none; }
  body { background: #000; margin: 0; display: flex; justify-content: center; font-family: 'Arial Black', sans-serif; color: var(--text-color); overflow: hidden; }
  .vfo-main-frame { background: var(--panel-bg); width: 100%; max-width: 400px; height: 100vh; display: flex; flex-direction: column; align-items: center; position: relative; }
  
  .bezel-display { background: var(--inner-bezel); width: 92%; margin-top: 15px; padding: 10px; border-radius: 8px; box-shadow: inset 4px 4px 10px #000; position: relative; }
  .fs-zone { position: absolute; left: 0; top: 0; width: 30%; height: 100%; z-index: 10; cursor: pointer; }
  .mem-zone { position: absolute; left: 30%; top: 0; width: 40%; height: 100%; z-index: 10; cursor: pointer; }
  .reset-zone { position: absolute; right: 0; top: 0; width: 30%; height: 100%; z-index: 10; cursor: pointer; }

  .display { background: var(--lcd-bg); border: 4px solid #111; padding: 10px; box-shadow: inset 0 0 25px #000; height: 125px; display: flex; flex-direction: column; justify-content: space-between; position: relative; transition: background 0.2s; }
  .display.mem-active { background: #e67e22; }
  .display.reset-flash { background: #e74c3c; }

  #f-display { font-size: 55px; font-weight: 900; margin: 0; text-align: right; text-shadow: 2px 2px 4px #000; letter-spacing: -1px; line-height: 1; }
  .display-info { display: flex; justify-content: space-between; font-size: 14px; color: rgba(255,255,255,0.9); font-family: Arial, sans-serif; }
  .display-footer { display: flex; align-items: center; border-top: 1px solid rgba(255,255,255,0.2); padding-top: 5px; margin-bottom: 4px; }
  #mode-label, .sig-text { font-size: 15px; font-weight: bold; color: #fff; }

  .s-meter-container { display: flex; align-items: center; gap: 6px; flex-grow: 1; justify-content: flex-end; margin-left: 25px; }
  .s-grid { display: flex; gap: 1px; height: 10px; width: 115px; background: rgba(0,0,0,0.3); border: 1px solid #111; }
  .s-seg { flex: 1; background: #222; }
  .s-on { background: #ffffff; box-shadow: 0 0 6px #ffffff; }

  /* OFFSET DUGME */
  .offset-btn {
    position: absolute; right: 17px; top: 175px;
    width: 60px; height: 60px; border-radius: 50%;
    background: #3d0000; border: 3px solid #222;
    color: #fff; font-size: 9px; font-weight: bold;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer; box-shadow: 3px 3px 8px #000; z-index: 50;
  }
  .offset-btn.active { background: #ff0000; box-shadow: 0 0 15px #ff0000; border-color: #f1c40f; }

  .bezel-knob { background: var(--inner-bezel); width: 270px; height: 270px; margin: 25px 0; border-radius: 50%; box-shadow: inset 3px 3px 10px #000; display: flex; justify-content: center; align-items: center; }
  #knob { width: 240px; height: 240px; background: conic-gradient(#333, #777, #333); border-radius: 50%; border: 12px solid #1a1a1a; position: relative; will-change: transform; cursor: pointer; }

  .controls-container { width: 94%; display: flex; flex-direction: column; }
  .grid { display: grid; gap: 6px; width: 100%; grid-template-columns: repeat(4, 1fr); }
  .btn { border: 3px solid var(--gold-border); border-radius: 8px; color: #fff; font-weight: 900; font-size: 15px; padding: 11px 0; text-align: center; cursor: pointer; box-shadow: 3px 5px 8px #000; text-transform: uppercase; }
  .b-band { background: var(--btn-band); }
  .b-step { background: var(--btn-step); font-size: 20px; padding: 12px; grid-column: span 4; margin: 12px 0; }
  .b-mode { background: var(--btn-mode); }
  .b-mem { background: var(--btn-mem); font-size: 13px; margin-top: 6px; }
</style>
</head><body>
  <div class="vfo-main-frame">
    <div class="bezel-display">
      <div class="fs-zone" onclick="toggleFS()"></div>
      <div class="mem-zone" onclick="startMem()"></div>
      <div class="reset-zone" onclick="clearAllMem()"></div>
      <div class="display" id="main-display">
        <div class="display-info"><span id="band-label">40M HAM</span><span id="step-label">100Hz</span></div>
        <h1 id="f-display">07.000.000</h1>
        <div class="display-footer">
            <span id="mode-label">USB</span>
            <div class="s-meter-container">
                <span class="sig-text">Sig:</span>
                <div class="s-grid" id="s-grid"></div>
            </div>
        </div>
      </div>
    </div>
    <div id="offset-led" class="offset-btn" onclick="toggleOffset()">OFFSET</div>
    <div class="bezel-knob"><div id="knob"></div></div>
    <div class="controls-container">
      <div class="grid">
        <div class="btn b-band" onclick="setBand(531000)">MW</div>
        <div class="btn b-band" onclick="setBand(1810000)">160</div>
        <div class="btn b-band" onclick="setBand(3500000)">80</div>
        <div class="btn b-band" onclick="setBand(7000000)">40</div>
        <div class="btn b-band" onclick="setBand(14000000)">20</div>
        <div class="btn b-band" onclick="setBand(18068000)">17</div>
        <div class="btn b-band" onclick="setBand(21000000)">15</div>
        <div class="btn b-band" onclick="setBand(24890000)">12</div>
      </div>
      <div class="btn b-step" id="step-btn" onclick="nextStep()">STEP: 100Hz</div>
      <div class="grid">
        <div class="btn b-mode" onclick="setMode('AM')">AM</div>
        <div class="btn b-mode" onclick="setMode('USB')">USB</div>
        <div class="btn b-mode" onclick="setMode('LSB')">LSB</div>
        <div class="btn b-mode" onclick="setMode('FM')">FM</div>
        <div class="btn b-mem" id="m1" onclick="handleMem(1)">M1</div>
        <div class="btn b-mem" id="m2" onclick="handleMem(2)">M2</div>
        <div class="btn b-mem" id="m3" onclick="handleMem(3)">M3</div>
        <div class="btn b-mem" id="m4" onclick="handleMem(4)">M4</div>
      </div>
    </div>
  </div>
  <script>
    var freq = 7000000; var lastAngle = 0; var curRot = 0; var isDrag = false; var lastSent = 0;
    var steps = [10, 100, 1000, 5000, 10000, 100000]; var stepIdx = 1;
    var isMemMode = false; var isOffset = false;

    const sGrid = document.getElementById('s-grid');
    for(let i=0; i<20; i++) { let d=document.createElement('div'); d.className='s-seg'; sGrid.appendChild(d); }

    function toggleOffset() {
      isOffset = !isOffset;
      document.getElementById('offset-led').classList.toggle('active');
      fetch('/setOffset?state=' + (isOffset ? 1 : 0));
    }
    function sendFreq() {
      let now = Date.now();
      if (now - lastSent > 50) { fetch('/set?f=' + freq); lastSent = now; }
    }
    function updateUI() {
      document.getElementById('f-display').innerText = Number(freq).toLocaleString('de-DE').replace(/,/g, '.');
      updateBandLabel();
    }
    function move(e) {
      if (!isDrag) return;
      let ev = e.touches ? e.touches[0] : e;
      let r = document.getElementById('knob').getBoundingClientRect();
      let ang = Math.atan2(ev.clientY - (r.top + r.height/2), ev.clientX - (r.left + r.width/2)) * 180 / Math.PI;
      let d = ang - lastAngle;
      if (d > 180) d -= 360; if (d < -180) d += 360;
      curRot += d;
      freq += Math.round(d) * (steps[stepIdx] / 10);
      if (freq < 100000) freq = 100000;
      updateUI();
      document.getElementById('knob').style.transform = 'rotate(' + curRot + 'deg)';
      sendFreq();
      lastAngle = ang;
    }
    let knob = document.getElementById('knob');
    knob.addEventListener('mousedown', function(e) { isDrag = true; let r = knob.getBoundingClientRect(); lastAngle = Math.atan2(e.clientY - (r.top + r.height/2), e.clientX - (r.left + r.width/2)) * 180 / Math.PI; });
    knob.addEventListener('touchstart', function(e) { isDrag = true; let r = knob.getBoundingClientRect(); lastAngle = Math.atan2(e.touches[0].clientY - (r.top + r.height/2), e.touches[0].clientX - (r.left + r.width/2)) * 180 / Math.PI; e.preventDefault(); }, {passive: false});
    window.addEventListener('mouseup', () => isDrag = false);
    window.addEventListener('touchend', () => isDrag = false);
    window.addEventListener('mousemove', move);
    window.addEventListener('touchmove', move, {passive: false});

    function toggleFS() { if(!document.fullscreenElement) document.documentElement.requestFullscreen().catch(e=>{}); else document.exitFullscreen(); }
    function setBand(f) { freq = f; updateUI(); sendFreq(); }
    function setMode(m) { document.getElementById('mode-label').innerText = m; }
    function nextStep() { stepIdx = (stepIdx + 1) % steps.length; let labels = ["10Hz", "100Hz", "1KHz", "5KHz", "10KHz", "100KHz"]; document.getElementById('step-btn').innerText = "STEP: " + labels[stepIdx]; }
    function startMem() { isMemMode = true; document.getElementById('main-display').classList.add('mem-active'); }
    function clearAllMem() { localStorage.clear(); document.getElementById('main-display').classList.add('reset-flash'); setTimeout(() => location.reload(), 300); }
    function handleMem(id) { 
      if(isMemMode) { localStorage.setItem('m'+id, freq); location.reload(); } 
      else { let s = localStorage.getItem('m'+id); if(s) { freq = parseInt(s); updateUI(); sendFreq(); } } 
    }
    function loadSavedMem() { for(let i=1; i<=4; i++){ let s = localStorage.getItem('m'+i); if(s) document.getElementById('m'+i).innerText = (parseInt(s)/1000000).toFixed(3); } }
    function updateBandLabel() {
      let b = document.getElementById('band-label');
      if (freq >= 7000000 && freq <= 7200000) b.innerText = "40M HAM";
      else if (freq >= 531000 && freq <= 1602000) b.innerText = "MW BROADCAST";
      else b.innerText = "GEN";
    }
    setInterval(() => {
      fetch('/getS').then(r => r.text()).then(v => {
        let segs = document.querySelectorAll('.s-seg');
        let act = Math.floor((v/100)*20);
        segs.forEach((s,i) => { if(i<act) s.classList.add('s-on'); else s.classList.remove('s-on'); });
      });
    }, 250);
    loadSavedMem(); updateUI();
  </script>
</body></html>
)rawliteral";

void updateSi5351() {
  unsigned long outFreq = frequency;
  if (offsetActive) outFreq += IF_OFFSET;
  si5351.set_freq(outFreq * 100ULL, SI5351_CLK0);
}

void setup() {
  Serial.begin(115200); 
  Wire.begin(21, 22);
  
  pinMode(32, INPUT); 
  analogReadResolution(12); 
  analogSetAttenuation(ADC_6db);
  
  si5351.init(SI5351_CRYSTAL_LOAD_8PF, 0, 0);
  updateSi5351();

  WiFi.mode(WIFI_AP);
  WiFi.softAP(ssid, password);
  WiFi.setTxPower(WIFI_POWER_19_5dBm); // Максимално засилување на Wi-Fi

  server.on("/", []() { server.send(200, "text/html", VFO_HTML); });
  server.on("/set", []() { 
    if (server.hasArg("f")) { 
      frequency = server.arg("f").toInt(); 
      updateSi5351(); 
      server.send(200, "text/plain", "OK"); 
    } 
  });
  server.on("/setOffset", []() { 
    if (server.hasArg("state")) { 
      offsetActive = server.arg("state").toInt() == 1; 
      updateSi5351(); 
      server.send(200, "text/plain", "OK"); 
    } 
  });
  server.on("/getS", []() { 
    int val = analogRead(32); 
    int percent = map(val, 0, 1200, 0, 100); 
    if(percent > 100) percent = 100; 
    server.send(200, "text/plain", String(percent)); 
  });
  
  server.begin();
}

void loop() { server.handleClient(); }

Credits

Mirko Pavleski
227 projects • 1605 followers

Comments