physics · 3d

DOPPLER EFFECT

Watch wave fronts compress and stretch as a source moves. From blueshift to sonic booms — all in real time.

Source at rest. Wave fronts form perfect concentric circles. Observed = source frequency.
Source freq440 Hz
Observed440 Hz
Shift
Mach №0.00
Waves0
1× speed
Source
Observer
Blueshift (higher f)
Redshift (lower f)
No shift

A — Theoretical  - - - source reference  |  ━━ observed  (Doppler formula, instant)

B — Measured from wave arrivals  ━━ observed  |  tick = ring hits observer  (has travel-time lag; shows real transitions)

The Doppler Formula

fobs = fsrc × vsound / (vsound − vsrc · cos θ)
θ = angle between source velocity and direction toward observer.
When source approaches (θ≈0°): denominator shrinks → blueshift (higher pitch).
When source recedes (θ≈180°): denominator grows → redshift (lower pitch).
At Mach 1 the denominator → 0, fobs → ∞ — wave fronts pile up at source.
Beyond Mach 1 a Mach cone forms with half-angle α = arcsin(1/M).

The ring color encodes the Doppler shift each segment of a wave front would carry: blue half faces the direction of motion (compressed), red half trails behind (stretched).

The oscilloscope draws the waveform using the instantaneous Doppler formula evaluated at each frame — it's a theoretical prediction, not measured from individual wave-ring arrivals. For a more accurate capture you'd track each ring reaching the observer and measure inter-arrival periods; the formula gives the same steady-state result once the source is moving at constant velocity.
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────
const C = 100;          // speed of sound (visual units/sec)
const MAX_R = 90;       // max wave radius before culling
const RING_SEGS = 96;   // segments per wave ring

// ─────────────────────────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────────────────────────
let paused    = false;
let simSpeed  = 1;
let simTime   = 0;
let lastEmit  = -99;
let waves     = [];
let oscTime   = 0;

let srcFreq   = 440;
let srcVel    = new THREE.Vector3(0, 0, 0);
let srcPos    = new THREE.Vector3(0, 0, 0);
let obsPos    = new THREE.Vector3(14, 0, 0);
let currentCase = 0;

// Computed each frame
let fObs = 440;

// ── Signal buffer (wave-arrival based waveform) ──────────────────────────────
const SIG_RATE = 240;               // samples per sim-second
const SIG_BUF  = 720;               // 3 seconds at SIG_RATE
let sigBuf          = new Float32Array(SIG_BUF);
let sigHead         = 0;            // next absolute write index
let sigPhase        = 0;            // continuous phase accumulator
let sigPeriod       = 0.2;          // inter-arrival period (seconds), default 5 Hz
let sigLastArrival  = -1;           // simTime of last ring arrival
let sigArrivalTimes = [];           // rolling list of arrival simTimes
let sigSampleAccum  = 0;            // fractional sample accumulator

// ─────────────────────────────────────────────────────────────────────────────
// THREE.JS SETUP
// ─────────────────────────────────────────────────────────────────────────────
const threeCanvas = document.getElementById('three-canvas');
const renderer = new THREE.WebGLRenderer({ canvas: threeCanvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x050508, 1);
renderer.shadowMap.enabled = false;

const scene  = new THREE.Scene();
scene.fog    = new THREE.FogExp2(0x050508, 0.008);

const camera = new THREE.PerspectiveCamera(52, 2, 0.1, 600);
camera.position.set(0, 42, 32);
camera.lookAt(0, 0, 0);

// Resize handling
function resizeRenderer() {
  const w = threeCanvas.clientWidth;
  const h = threeCanvas.clientHeight;
  if (renderer.domElement.width !== w || renderer.domElement.height !== h) {
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// STARS
// ─────────────────────────────────────────────────────────────────────────────
(function buildStars() {
  const count = 1800;
  const pos   = new Float32Array(count * 3);
  for (let i = 0; i < count * 3; i++) pos[i] = (Math.random() - 0.5) * 600;
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
    color: 0xffffff, size: 0.28, transparent: true, opacity: 0.55
  })));
})();

// ─────────────────────────────────────────────────────────────────────────────
// GRID
// ─────────────────────────────────────────────────────────────────────────────
const grid = new THREE.GridHelper(160, 60, 0x181820, 0x131318);
grid.position.y = -0.05;
scene.add(grid);

// Path line along X
(function buildPath() {
  const pts = [new THREE.Vector3(-50, 0, 0), new THREE.Vector3(50, 0, 0)];
  scene.add(new THREE.Line(
    new THREE.BufferGeometry().setFromPoints(pts),
    new THREE.LineDashedMaterial({ color: 0x2a2a3a, dashSize: 1.5, gapSize: 1 })
  ));
})();

// ─────────────────────────────────────────────────────────────────────────────
// SOURCE SPHERE
// ─────────────────────────────────────────────────────────────────────────────
const srcMat = new THREE.MeshStandardMaterial({
  color: 0xff6600, emissive: 0xff3300, emissiveIntensity: 2.2, roughness: 0.3, metalness: 0.5
});
const srcMesh = new THREE.Mesh(new THREE.SphereGeometry(0.75, 20, 20), srcMat);
scene.add(srcMesh);

// glow halo
const srcHaloMat = new THREE.MeshBasicMaterial({
  color: 0xff4400, transparent: true, opacity: 0.12, side: THREE.BackSide
});
srcMesh.add(new THREE.Mesh(new THREE.SphereGeometry(1.4, 16, 16), srcHaloMat));

// point light that follows source
const srcLight = new THREE.PointLight(0xff5500, 3, 25);
scene.add(srcLight);

// ─────────────────────────────────────────────────────────────────────────────
// OBSERVER SPHERE
// ─────────────────────────────────────────────────────────────────────────────
const obsMat = new THREE.MeshStandardMaterial({
  color: 0x00c8ff, emissive: 0x007acc, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.5
});
const obsMesh = new THREE.Mesh(new THREE.SphereGeometry(0.6, 20, 20), obsMat);
scene.add(obsMesh);

const obsHaloMat = new THREE.MeshBasicMaterial({
  color: 0x00aaff, transparent: true, opacity: 0.1, side: THREE.BackSide
});
obsMesh.add(new THREE.Mesh(new THREE.SphereGeometry(1.1, 16, 16), obsHaloMat));

const obsLight = new THREE.PointLight(0x0088ff, 1.5, 18);
obsMesh.add(obsLight);

// ambient
scene.add(new THREE.AmbientLight(0xffffff, 0.08));

// ─────────────────────────────────────────────────────────────────────────────
// TRAIL SYSTEM
// ─────────────────────────────────────────────────────────────────────────────
const TRAIL_LEN = 120;
const trailPos  = new Float32Array(TRAIL_LEN * 3);
const trailGeo  = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
const trailPts  = new THREE.Points(trailGeo, new THREE.PointsMaterial({
  color: 0xff5500, size: 0.22, transparent: true, opacity: 0.35,
  blending: THREE.AdditiveBlending, depthWrite: false
}));
scene.add(trailPts);
let trailBuf = [];

function updateTrail() {
  trailBuf.unshift({ x: srcPos.x, y: srcPos.y, z: srcPos.z });
  if (trailBuf.length > TRAIL_LEN) trailBuf.pop();
  for (let i = 0; i < TRAIL_LEN; i++) {
    if (i < trailBuf.length) {
      trailPos[i*3]   = trailBuf[i].x;
      trailPos[i*3+1] = trailBuf[i].y;
      trailPos[i*3+2] = trailBuf[i].z;
    } else {
      trailPos[i*3+1] = -999;
    }
  }
  trailGeo.attributes.position.needsUpdate = true;
}

// ─────────────────────────────────────────────────────────────────────────────
// WAVE RINGS — per-vertex coloring
// ─────────────────────────────────────────────────────────────────────────────
function makeWaveRing(origin, velAtEmit) {
  const pts  = [];
  const cols = [];
  const speed = velAtEmit.length();
  const mach  = speed / C;
  const vDir  = speed > 0.001 ? velAtEmit.clone().normalize() : new THREE.Vector3();

  for (let i = 0; i <= RING_SEGS; i++) {
    const a = (i / RING_SEGS) * Math.PI * 2;
    pts.push(new THREE.Vector3(Math.cos(a), 0, Math.sin(a)));

    // outward direction at this point on unit circle
    const dot = vDir.x * Math.cos(a) + vDir.z * Math.sin(a);
    const t   = Math.min(Math.abs(dot) * mach * 1.4, 1);

    let r, g, b;
    if (dot > 0) {             // forward of motion → blueshift
      r = 0.3 + (1-t)*0.5; g = 0.4 + (1-t)*0.5; b = 1.0;
    } else if (dot < 0) {      // trailing → redshift
      r = 1.0; g = 0.3 + (1-t)*0.5; b = 0.2 + (1-t)*0.5;
    } else {                   // perpendicular → neutral green
      r = 0.4; g = 1.0; b = 0.5;
    }
    cols.push(r, g, b);
  }

  const geo = new THREE.BufferGeometry().setFromPoints(pts);
  geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(cols), 3));

  const mat = new THREE.LineBasicMaterial({
    vertexColors: true, transparent: true, opacity: 0.82,
    blending: THREE.AdditiveBlending, depthWrite: false
  });

  const ring = new THREE.LineLoop(geo, mat);
  ring.position.copy(origin);
  ring.position.y = 0;
  scene.add(ring);

  return { ring, origin: origin.clone(), radius: 0.1, observed: false };
}

function clearWaves() {
  for (const w of waves) {
    scene.remove(w.ring);
    w.ring.geometry.dispose();
    w.ring.material.dispose();
  }
  waves = [];
  // Reset arrival signal
  sigBuf.fill(0);
  sigHead = 0; sigPhase = 0;
  sigPeriod = 1 / Math.max(1, srcFreq / 88);
  sigLastArrival = -1; sigArrivalTimes = []; sigSampleAccum = 0;
}

function updateWaves(dt) {
  const toRemove = [];
  for (let i = 0; i < waves.length; i++) {
    const w  = waves[i];
    w.radius += C * dt;
    w.ring.scale.setScalar(w.radius);

    // Observer hit check
    if (!w.observed) {
      const dx = obsPos.x - w.origin.x;
      const dz = obsPos.z - w.origin.z;
      if (w.radius >= Math.sqrt(dx*dx + dz*dz)) {
        w.observed = true;
        // Update signal period from inter-arrival time
        if (sigLastArrival >= 0) {
          sigPeriod = Math.max(0.01, simTime - sigLastArrival);
        }
        sigLastArrival = simTime;
        sigArrivalTimes.push(simTime);
        // Trim arrivals that have scrolled off the visible window
        const winSec = SIG_BUF / SIG_RATE;
        while (sigArrivalTimes.length > 0 && sigArrivalTimes[0] < simTime - winSec - 0.5) {
          sigArrivalTimes.shift();
        }
        // flash observer
        obsMat.emissiveIntensity = 4;
        setTimeout(() => { obsMat.emissiveIntensity = 1.8; }, 120);
      }
    }

    // fade out
    const fadeAt = MAX_R * 0.55;
    if (w.radius > fadeAt) {
      w.ring.material.opacity = 0.82 * Math.max(0, 1 - (w.radius - fadeAt) / (MAX_R - fadeAt));
    }

    if (w.radius > MAX_R) {
      scene.remove(w.ring);
      w.ring.geometry.dispose();
      w.ring.material.dispose();
      toRemove.push(i);
    }
  }
  for (let i = toRemove.length - 1; i >= 0; i--) waves.splice(toRemove[i], 1);
}

// ─────────────────────────────────────────────────────────────────────────────
// DOPPLER FORMULA
// ─────────────────────────────────────────────────────────────────────────────
function computeDopplerFreq() {
  const dx = obsPos.x - srcPos.x;
  const dz = obsPos.z - srcPos.z;
  const d  = Math.sqrt(dx*dx + dz*dz);
  if (d < 0.001) return srcFreq;
  const cosT = (srcVel.x * dx + srcVel.z * dz) / (d * C); // (v_src/C) · cosθ
  const denom = 1 - cosT;
  if (Math.abs(denom) < 0.005) return 9999;
  return Math.max(0.5, Math.min(9999, srcFreq / denom));
}

// ─────────────────────────────────────────────────────────────────────────────
// CASES
// ─────────────────────────────────────────────────────────────────────────────
const CASES = [
  {
    label: 'Stationary',
    desc: 'Source at rest. Wave fronts form perfect concentric circles. Observed = source frequency.',
    setupFn() {
      srcPos.set(0, 0, 0); srcVel.set(0, 0, 0); obsPos.set(14, 0, 0);
    },
    tickFn(_dt) {}
  },
  {
    label: 'Approaching',
    desc: 'Source moves toward observer (Mach 0.5). Waves bunch up → higher pitch (blueshift).',
    setupFn() {
      srcPos.set(-32, 0, 0); srcVel.set(C * 0.5, 0, 0); obsPos.set(14, 0, 0);
    },
    tickFn(dt) {
      srcPos.addScaledVector(srcVel, dt);
      if (srcPos.x > 46) { srcPos.set(-32, 0, 0); clearWaves(); trailBuf = []; }
    }
  },
  {
    label: 'Receding',
    desc: 'Source moves away from observer (Mach 0.5). Waves stretch out → lower pitch (redshift).',
    setupFn() {
      srcPos.set(14, 0, 0); srcVel.set(-C * 0.5, 0, 0); obsPos.set(14, 0, 0);
    },
    tickFn(dt) {
      srcPos.addScaledVector(srcVel, dt);
      if (srcPos.x < -46) { srcPos.set(14, 0, 0); clearWaves(); trailBuf = []; }
    }
  },
  {
    label: 'Supersonic',
    desc: 'Mach 1.5 — source outruns its own waves. Rings pile into a Mach cone. Sonic boom!',
    setupFn() {
      srcPos.set(-40, 0, 0); srcVel.set(C * 1.5, 0, 0); obsPos.set(0, 0, 14);
    },
    tickFn(dt) {
      srcPos.addScaledVector(srcVel, dt);
      if (srcPos.x > 50) { srcPos.set(-40, 0, 0); clearWaves(); trailBuf = []; }
    }
  },
  {
    label: 'Custom',
    desc: 'Free mode — tweak velocity, frequency, and observer position. Source loops at whatever speed you set.',
    setupFn() {
      // Preserve current srcVel; pick a sensible starting position
      if (srcVel.x > 0)       srcPos.set(-40, 0, 0);
      else if (srcVel.x < 0)  srcPos.set(40, 0, 0);
      else                    srcPos.set(0, 0, 0);
    },
    tickFn(dt) {
      if (srcVel.lengthSq() < 0.001) return; // truly stationary — don't loop
      srcPos.addScaledVector(srcVel, dt);
      if (srcVel.x > 0 && srcPos.x >  52) { srcPos.set(-45, 0, 0); clearWaves(); trailBuf = []; }
      if (srcVel.x < 0 && srcPos.x < -52) { srcPos.set( 45, 0, 0); clearWaves(); trailBuf = []; }
    }
  }
];

// ─────────────────────────────────────────────────────────────────────────────
// OSCILLOSCOPE
// ─────────────────────────────────────────────────────────────────────────────
const oscCanvas = document.getElementById('osc-canvas');
const oscCtx    = oscCanvas.getContext('2d');

function resizeOsc() {
  const r = oscCanvas.getBoundingClientRect();
  oscCanvas.width  = Math.round(r.width);
  oscCanvas.height = Math.round(r.height);
}

function drawOscilloscope() {
  const W = oscCanvas.width, H = oscCanvas.height;
  if (W === 0 || H === 0) return;

  // background
  oscCtx.fillStyle = '#06090a';
  oscCtx.fillRect(0, 0, W, H);

  // grid
  oscCtx.strokeStyle = 'rgba(255,255,255,0.04)';
  oscCtx.lineWidth   = 1;
  for (let i = 1; i < 4; i++) {
    oscCtx.beginPath(); oscCtx.moveTo(i*(W/4), 0); oscCtx.lineTo(i*(W/4), H); oscCtx.stroke();
  }
  oscCtx.beginPath(); oscCtx.moveTo(0, H/2); oscCtx.lineTo(W, H/2);
  oscCtx.strokeStyle = 'rgba(255,255,255,0.07)'; oscCtx.stroke();

  // ratio uses raw fObs so color is correct even when f_obs < slider minimum
  const ratio    = fObs / srcFreq;
  const fClamped = Math.max(0.5, Math.min(3000, fObs));
  const timeWin  = 3 / srcFreq;
  const amp      = H * 0.33;

  const N = 300; // fixed sample count for smooth curves

  // Source reference (dashed gray)
  oscCtx.setLineDash([4, 4]);
  oscCtx.strokeStyle = '#444';
  oscCtx.lineWidth   = 1;
  oscCtx.beginPath();
  for (let i = 0; i <= N; i++) {
    const xn = i / N;
    const x  = xn * W;
    const t  = (xn) * timeWin;
    const y  = H/2 - Math.sin(2 * Math.PI * srcFreq * (oscTime - timeWin + t)) * amp;
    i === 0 ? oscCtx.moveTo(x, y) : oscCtx.lineTo(x, y);
  }
  oscCtx.stroke();
  oscCtx.setLineDash([]);

  // Observed wave (solid, colored — ratio drives color, fClamped drives drawing)
  let waveColor;
  if (ratio > 1.05)       waveColor = '#4488ff';
  else if (ratio < 0.95)  waveColor = '#ff4422';
  else                    waveColor = '#00c896';

  // update oscilloscope legend
  document.getElementById('osc-legend').style.color = waveColor;

  oscCtx.strokeStyle = waveColor;
  oscCtx.lineWidth   = 2;
  oscCtx.beginPath();
  for (let i = 0; i <= N; i++) {
    const xn = i / N;
    const x  = xn * W;
    const t  = (xn) * timeWin;
    const y  = H/2 - Math.sin(2 * Math.PI * fClamped * (oscTime - timeWin + t)) * amp;
    i === 0 ? oscCtx.moveTo(x, y) : oscCtx.lineTo(x, y);
  }
  oscCtx.stroke();
}

// ─────────────────────────────────────────────────────────────────────────────
// ARRIVAL-BASED WAVEFORM
// ─────────────────────────────────────────────────────────────────────────────
const arrCanvas = document.getElementById('arr-canvas');
const arrCtx    = arrCanvas.getContext('2d');

function resizeArr() {
  const r = arrCanvas.getBoundingClientRect();
  arrCanvas.width  = Math.round(r.width);
  arrCanvas.height = Math.round(r.height);
}

function drawArrivalWaveform() {
  const W = arrCanvas.width, H = arrCanvas.height;
  if (W === 0 || H === 0) return;
  const amp = H * 0.38;

  // background
  arrCtx.fillStyle = '#04080a';
  arrCtx.fillRect(0, 0, W, H);

  // grid
  arrCtx.strokeStyle = 'rgba(255,255,255,0.04)';
  arrCtx.lineWidth   = 1;
  for (let i = 1; i < 4; i++) {
    arrCtx.beginPath(); arrCtx.moveTo(i*(W/4), 0); arrCtx.lineTo(i*(W/4), H); arrCtx.stroke();
  }
  arrCtx.beginPath(); arrCtx.moveTo(0, H/2); arrCtx.lineTo(W, H/2);
  arrCtx.strokeStyle = 'rgba(255,255,255,0.07)'; arrCtx.stroke();

  const count = Math.min(sigHead, SIG_BUF);
  if (count < 4) {
    arrCtx.fillStyle = 'rgba(255,255,255,0.18)';
    arrCtx.font = '11px DM Mono, monospace';
    arrCtx.textAlign = 'center';
    arrCtx.fillText('Waiting for first wave arrivals…', W / 2, H / 2 + 4);
    return;
  }

  // Color based on measured period vs source emit period
  const srcEmitPeriod = 88 / srcFreq;                               // actual visual ring period
  const measRatio     = srcEmitPeriod / Math.max(sigPeriod, 0.001); // f_obs / f_src
  let arrColor;
  if (measRatio > 1.05)      arrColor = '#4488ff';
  else if (measRatio < 0.95) arrColor = '#ff4422';
  else                       arrColor = '#00c896';
  document.getElementById('arr-legend').style.color = arrColor;

  // Draw waveform (oldest sample left, newest right)
  arrCtx.strokeStyle = arrColor;
  arrCtx.lineWidth   = 2;
  arrCtx.beginPath();
  for (let i = 0; i < count; i++) {
    const bufIdx = ((sigHead - count + i) % SIG_BUF + SIG_BUF) % SIG_BUF;
    const x = (i / (count - 1)) * W;
    const y = H / 2 - sigBuf[bufIdx] * amp;
    i === 0 ? arrCtx.moveTo(x, y) : arrCtx.lineTo(x, y);
  }
  arrCtx.stroke();

  // Arrival tick marks at bottom edge
  const winSec  = count / SIG_RATE;
  const winStart = simTime - winSec;
  arrCtx.strokeStyle = 'rgba(255,255,255,0.55)';
  arrCtx.lineWidth   = 1;
  for (const t of sigArrivalTimes) {
    const x = W * (t - winStart) / winSec;
    if (x >= 0 && x <= W) {
      arrCtx.beginPath();
      arrCtx.moveTo(x, H - 10);
      arrCtx.lineTo(x, H);
      arrCtx.stroke();
    }
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// HUD UPDATE
// ─────────────────────────────────────────────────────────────────────────────
function updateHUD() {
  const mach  = srcVel.length() / C;
  const shift = ((fObs / srcFreq) - 1) * 100;

  document.getElementById('h-fsrc').textContent = srcFreq + ' Hz';
  document.getElementById('h-fobs').textContent = fObs >= 9999 ? '∞' : Math.round(fObs) + ' Hz';
  document.getElementById('h-mach').textContent = mach.toFixed(2);
  document.getElementById('h-waves').textContent = waves.length;

  const shiftEl  = document.getElementById('h-shift');
  const fobsEl   = document.getElementById('h-fobs');
  if (fObs >= 9999) {
    shiftEl.textContent = '∞';
    shiftEl.className   = 'hv-val hv-warn';
    fobsEl.className    = 'hv-val hv-warn';
  } else if (Math.abs(shift) < 2) {
    shiftEl.textContent = '—';
    shiftEl.className   = 'hv-val hv-green';
    fobsEl.className    = 'hv-val hv-green';
  } else if (shift > 0) {
    shiftEl.textContent = '+' + shift.toFixed(1) + '% ↑';
    shiftEl.className   = 'hv-val hv-blue';
    fobsEl.className    = 'hv-val hv-blue';
  } else {
    shiftEl.textContent = shift.toFixed(1) + '% ↓';
    shiftEl.className   = 'hv-val hv-red';
    fobsEl.className    = 'hv-val hv-red';
  }

  // Mach colour
  const machEl = document.getElementById('h-mach');
  machEl.className = mach >= 1 ? 'hv-val hv-warn' : 'hv-val';
}

// ─────────────────────────────────────────────────────────────────────────────
// MAIN SIM TICK
// ─────────────────────────────────────────────────────────────────────────────
function tick(dt) {
  simTime += dt;
  oscTime += dt;

  CASES[currentCase].tickFn(dt);

  // emit waves
  const period = 1 / (srcFreq / 88);  // visual emit rate ≈ 5 Hz at 440 Hz
  if (simTime - lastEmit >= period) {
    waves.push(makeWaveRing(srcPos.clone(), srcVel.clone()));
    lastEmit = simTime;
    // pulse source glow
    srcMat.emissiveIntensity = 5;
    setTimeout(() => { srcMat.emissiveIntensity = 2.2; }, 80);
  }

  updateWaves(dt);
  updateTrail();

  // Sample signal buffer (phase driven by inter-arrival period)
  sigPhase += dt / Math.max(sigPeriod, 0.001);
  sigSampleAccum += dt;
  const sampleInterval = 1 / SIG_RATE;
  while (sigSampleAccum >= sampleInterval) {
    sigSampleAccum -= sampleInterval;
    sigBuf[sigHead % SIG_BUF] = Math.sin(2 * Math.PI * sigPhase);
    sigHead++;
  }

  // sync meshes
  srcMesh.position.copy(srcPos);
  obsMesh.position.copy(obsPos);
  srcLight.position.copy(srcPos).y += 2;

  // subtle source pulse scale
  srcMesh.scale.setScalar(1 + 0.08 * Math.sin(simTime * (srcFreq / 88) * Math.PI * 2));

  fObs = computeDopplerFreq();
  updateHUD();
}

// ─────────────────────────────────────────────────────────────────────────────
// CONTROLS
// ─────────────────────────────────────────────────────────────────────────────
function setCase(idx) {
  currentCase = idx;
  clearWaves(); trailBuf = [];
  simTime = 0; lastEmit = -99; oscTime = 0;
  CASES[idx].setupFn();
  fObs = srcFreq;

  // sync sliders
  const mach = srcVel.length() / C;
  document.getElementById('rng-vel').value  = mach;
  document.getElementById('lbl-vel').textContent = mach.toFixed(2) + ' Mach';
  document.getElementById('rng-obs').value  = obsPos.x;
  document.getElementById('lbl-obs').textContent = 'x = ' + (obsPos.x >= 0 ? '+' : '') + obsPos.x;
  document.getElementById('case-desc').textContent = CASES[idx].desc;

  document.querySelectorAll('.case-btn').forEach((b, i) => b.classList.toggle('active', i === idx));
  updateHUD();
}

function togglePlay() {
  paused = !paused;
  document.getElementById('btn-play').textContent = paused ? '▶ Play' : '⏸ Pause';
}

function setSpeed(s) {
  simSpeed = s;
  document.getElementById('spd-label').textContent = s + '× speed';
  document.querySelectorAll('.spd-btn').forEach(b => {
    b.classList.toggle('active', parseFloat(b.textContent) === s);
  });
}

function resetSim() { setCase(currentCase); }

// Silently activates Custom mode without resetting position/velocity —
// used when the user tweaks a slider while on a preset that doesn't move the source.
function activateCustom() {
  currentCase = 4;
  document.querySelectorAll('.case-btn').forEach((b, i) => b.classList.toggle('active', i === 4));
  document.getElementById('case-desc').textContent = CASES[4].desc;
}

function onVelChange(v) {
  v = parseFloat(v);
  document.getElementById('lbl-vel').textContent = v.toFixed(2) + ' Mach';

  // Case 2 (Receding) moves leftward; everything else moves rightward
  const dir = currentCase === 2 ? -1 : 1;
  srcVel.set(C * v * dir, 0, 0);

  // If on Stationary (Case 0), user clearly wants motion — switch to Custom so the source actually moves
  if (currentCase === 0 && v > 0) activateCustom();
}

function onFreqChange(v) {
  srcFreq = parseInt(v);
  document.getElementById('lbl-freq').textContent = v + ' Hz';
}

function onObsChange(v) {
  v = parseFloat(v);
  obsPos.x = v;
  document.getElementById('lbl-obs').textContent = 'x = ' + (v >= 0 ? '+' : '') + v;
  obsMesh.position.copy(obsPos);
}

// ─────────────────────────────────────────────────────────────────────────────
// SLOW CAMERA DRIFT
// ─────────────────────────────────────────────────────────────────────────────
let camAngle = 0;
function driftCamera(dt) {
  camAngle += dt * 0.012;
  camera.position.x = Math.sin(camAngle) * 5;
  camera.lookAt(0, 0, 0);
}

// ─────────────────────────────────────────────────────────────────────────────
// ANIMATION LOOP
// ─────────────────────────────────────────────────────────────────────────────
let lastTs = null;
function animate(ts) {
  requestAnimationFrame(animate);
  if (lastTs === null) { lastTs = ts; return; }
  let dt = Math.min((ts - lastTs) / 1000, 0.05);
  lastTs = ts;

  resizeRenderer();
  if (!paused) {
    tick(dt * simSpeed);
    driftCamera(dt);
    drawOscilloscope();
    drawArrivalWaveform();
  }
  renderer.render(scene, camera);
}

// ─────────────────────────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────────────────────────
window.addEventListener('resize', () => { resizeRenderer(); resizeOsc(); resizeArr(); });
resizeOsc(); resizeArr();
setCase(0);
animate(0);

// expose handlers for inline HTML
window.setCase     = setCase;
window.togglePlay  = togglePlay;
window.setSpeed    = setSpeed;
window.resetSim    = resetSim;
window.onVelChange = onVelChange;
window.onFreqChange = onFreqChange;
window.onObsChange  = onObsChange;