physics

DOPPLER EFFECT

Interactive simulation showing how frequency shifts when a sound source moves. Visualize wavefronts, hear the pitch change, and explore supersonic speeds.

Wave Visualization

440 Hz
Red circles ahead = compressed waves (higher frequency). Blue circles behind = stretched waves (lower frequency).

Parameters

100 Hz 440 Hz 2000 Hz
Stationary 50 m/s Supersonic
M = 0.14
100 m/s 343 m/s 500 m/s
← Moving away 0 m/s Moving towards →
0.1× 1.0×

Calculated Values

Frequency (approaching)
587Hz
Frequency (receding)
329Hz
Wavelength (ahead)
0.59m
Wavelength (behind)
1.04m

How It Works

The Doppler Effect is the change in frequency (and wavelength) of a wave as a source moves relative to an observer. When the source approaches, waves are compressed, raising the frequency. When it recedes, waves are stretched, lowering the frequency.
f' = f₀ × (v + vo) / (v − vs)
Key Parameters:
f₀ = source frequency
v = wave speed (e.g., 343 m/s for sound in air)
vs = source velocity (positive = away from observer)
vo = observer velocity (positive = toward source)
f' = observed frequency
Mach number = vs / v (supersonic when M > 1)
Sonic Boom: When the source travels faster than the wave speed, it creates a Mach cone shock wave. The half-angle is arcsin(v/vs) = arcsin(1/M).
Developer Reference

Core Algorithm & Standalone Script

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

// State
    let state = {
      f0: 440,           // Hz
      vs: 50,            // m/s (source speed)
      v: 343,            // m/s (wave speed)
      vo: 0,             // m/s (observer speed)
      sourceType: 'continuous',
      direction: 'linear',
      showWaves: true,
      showCone: false,
      time: 0,
      simSpeed: 1,
      isPlaying: false,
      audioContext: null,
      oscillators: [],
      gains: []
    };

    const canvas = document.getElementById('doppler-canvas');
    const ctx = canvas.getContext('2d');

    // Resize canvas
    function resizeCanvas() {
      const rect = canvas.parentElement.getBoundingClientRect();
      canvas.width = rect.width - 16; // account for padding
      canvas.height = 400;
    }
    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    // Update UI from state
    function updateUI() {
      document.getElementById('freq-value').textContent = state.f0 + ' Hz';
      document.getElementById('speed-value').textContent = state.vs + ' m/s';
      document.getElementById('wave-speed-value').textContent = state.v + ' m/s';
      document.getElementById('obs-speed-value').textContent = state.vo + ' m/s';

      const mach = (state.vs / state.v).toFixed(2);
      document.getElementById('mach-label').textContent = `M = ${mach}`;

      // Calculate observed frequencies
      const fApproaching = state.f0 * (state.v + state.vo) / (state.v - state.vs);
      const fReceding = state.f0 * (state.v + state.vo) / (state.v + state.vs);

      document.getElementById('freq-approaching').textContent = fApproaching.toFixed(0);
      document.getElementById('freq-receding').textContent = fReceding.toFixed(0);

      const wavelengthAhead = state.v / fApproaching;
      const wavelengthBehind = state.v / fReceding;

      document.getElementById('wavelength-ahead').textContent = wavelengthAhead.toFixed(2);
      document.getElementById('wavelength-behind').textContent = wavelengthBehind.toFixed(2);
    }

    // Sliders
    document.getElementById('freq-slider').addEventListener('input', (e) => {
      state.f0 = parseInt(e.target.value);
      updateUI();
      updateAudio();
    });

    document.getElementById('speed-slider').addEventListener('input', (e) => {
      state.vs = parseInt(e.target.value);
      updateUI();
      updateAudio();
    });

    document.getElementById('wave-speed-slider').addEventListener('input', (e) => {
      state.v = parseInt(e.target.value);
      updateUI();
      updateAudio();
    });

    document.getElementById('obs-speed-slider').addEventListener('input', (e) => {
      state.vo = parseInt(e.target.value);
      updateUI();
      updateAudio();
    });

    // Toggle buttons
    document.querySelectorAll('[data-source-type]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        document.querySelectorAll('[data-source-type]').forEach(b => b.classList.remove('active'));
        e.target.classList.add('active');
        state.sourceType = e.target.dataset.sourceType;
      });
    });

    document.querySelectorAll('[data-direction]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active'));
        e.target.classList.add('active');
        state.direction = e.target.dataset.direction;
      });
    });

    document.getElementById('show-waves-btn').addEventListener('click', (e) => {
      e.target.classList.toggle('active');
      state.showWaves = e.target.classList.contains('active');
    });

    document.getElementById('show-cone-btn').addEventListener('click', (e) => {
      e.target.classList.toggle('active');
      state.showCone = e.target.classList.contains('active');
    });

    // Reset
    document.getElementById('reset-btn').addEventListener('click', () => {
      state = {
        f0: 440,
        vs: 50,
        v: 343,
        vo: 0,
        sourceType: 'continuous',
        direction: 'linear',
        showWaves: true,
        showCone: false,
        time: 0,
        simSpeed: 1,
        isPlaying: state.isPlaying,
        audioContext: state.audioContext,
        oscillators: state.oscillators,
        gains: state.gains
      };
      document.getElementById('sim-speed-slider').value = 1;
      document.getElementById('sim-speed-value').textContent = '1.0×';
      document.getElementById('freq-slider').value = 440;
      document.getElementById('speed-slider').value = 50;
      document.getElementById('wave-speed-slider').value = 343;
      document.getElementById('obs-speed-slider').value = 0;
      document.querySelectorAll('[data-source-type]').forEach(b => b.classList.remove('active'));
      document.querySelector('[data-source-type="continuous"]').classList.add('active');
      document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active'));
      document.querySelector('[data-direction="linear"]').classList.add('active');
      document.getElementById('show-waves-btn').classList.add('active');
      document.getElementById('show-cone-btn').classList.remove('active');
      updateUI();
      updateAudio();
    });

    document.getElementById('sim-speed-slider').addEventListener('input', (e) => {
      state.simSpeed = parseFloat(e.target.value);
      document.getElementById('sim-speed-value').textContent = state.simSpeed.toFixed(1) + '×';
    });

    // Audio
    function initAudio() {
      if (state.audioContext) return;
      state.audioContext = new (window.AudioContext || window.webkitAudioContext)();
    }

    function startAudio() {
      initAudio();
      state.isPlaying = true;
      document.getElementById('play-btn').textContent = '⏸ Pause Sound';
      document.getElementById('play-btn').classList.add('active');

      const now = state.audioContext.currentTime;

      // Create main oscillator
      const osc = state.audioContext.createOscillator();
      const gain = state.audioContext.createGain();

      osc.type = 'sine';
      osc.connect(gain);
      gain.connect(state.audioContext.destination);

      state.oscillators.push(osc);
      state.gains.push(gain);

      osc.start(now);
      gain.gain.setValueAtTime(0.1, now);
    }

    function stopAudio() {
      state.isPlaying = false;
      document.getElementById('play-btn').textContent = '▶ Play Sound';
      document.getElementById('play-btn').classList.remove('active');

      state.oscillators.forEach(osc => {
        try { osc.stop(state.audioContext.currentTime); } catch (e) {}
      });
      state.oscillators = [];
      state.gains = [];
    }

    function updateAudio() {
      if (!state.isPlaying || !state.audioContext) return;

      const now = state.audioContext.currentTime;
      const fCurrent = state.f0 * (state.v + state.vo) / (state.v - state.vs * Math.cos(state.time / 2));

      state.oscillators.forEach((osc, i) => {
        try {
          osc.frequency.setValueAtTime(Math.max(20, Math.min(20000, fCurrent)), now);
        } catch (e) {}
      });
    }

    document.getElementById('play-btn').addEventListener('click', () => {
      if (state.isPlaying) {
        stopAudio();
      } else {
        startAudio();
      }
    });

    document.getElementById('stop-btn').addEventListener('click', stopAudio);

    // Animation loop
    let lastTime = Date.now();

    function animate() {
      const now = Date.now();
      const deltaTime = (now - lastTime) / 1000;
      lastTime = now;

      state.time += deltaTime * state.simSpeed;

      // Draw canvas
      ctx.fillStyle = '#0a0a0a';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      const centerY = canvas.height / 2;
      const maxRadius = Math.max(canvas.width, canvas.height);

      // Calculate source position
      let sourceX, sourceY;
      if (state.direction === 'linear') {
        sourceX = (state.time * 50) % canvas.width;
        sourceY = centerY;
      } else {
        const radius = 100;
        sourceX = canvas.width / 2 + radius * Math.cos(state.time);
        sourceY = canvas.height / 2 + radius * Math.sin(state.time);
      }

      // Draw wavefronts
      if (state.showWaves) {
        const period = 1 / state.f0;
        const waveSpeed = state.v;

        for (let i = 0; i < 20; i++) {
          const emitTime = state.time - i * period;
          if (emitTime < 0) break;

          // Calculate where source was at emit time
          let emitX, emitY;
          if (state.direction === 'linear') {
            emitX = (emitTime * 50) % canvas.width;
            emitY = centerY;
          } else {
            const radius = 100;
            emitX = canvas.width / 2 + radius * Math.cos(emitTime);
            emitY = canvas.height / 2 + radius * Math.sin(emitTime);
          }

          const radius = waveSpeed * (state.time - emitTime);

          // Determine if ahead or behind (for linear motion)
          let isAhead = false;
          if (state.direction === 'linear') {
            isAhead = emitX < sourceX;
          }

          ctx.strokeStyle = isAhead ? '#ff3333' : '#4488ff';
          ctx.lineWidth = 2;
          ctx.globalAlpha = 0.6;
          ctx.beginPath();
          ctx.arc(emitX, emitY, radius, 0, Math.PI * 2);
          ctx.stroke();
          ctx.globalAlpha = 1;
        }
      }

      // Draw Mach cone
      if (state.showCone && state.vs > state.v) {
        const mach = state.vs / state.v;
        const coneAngle = Math.asin(1 / mach);

        ctx.strokeStyle = '#ffcc00';
        ctx.lineWidth = 3;
        ctx.globalAlpha = 0.7;
        ctx.beginPath();
        ctx.moveTo(sourceX, sourceY - 150);
        ctx.lineTo(sourceX + 150 * Math.tan(coneAngle), sourceY + 150);
        ctx.moveTo(sourceX, sourceY + 150);
        ctx.lineTo(sourceX + 150 * Math.tan(coneAngle), sourceY - 150);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      // Draw source
      ctx.fillStyle = '#ff2200';
      ctx.beginPath();
      ctx.arc(sourceX, sourceY, 8, 0, Math.PI * 2);
      ctx.fill();

      // Draw observer
      ctx.fillStyle = '#00c896';
      ctx.fillRect(20, centerY - 12, 20, 24);
      ctx.fillText('O', 25, centerY + 5);

      // Update frequency bar
      const fObserved = state.f0 * (state.v + state.vo) / (state.v - state.vs * Math.cos(state.time / 2));
      const fMin = state.f0 * (state.v + state.vo) / (state.v + state.vs);
      const fMax = state.f0 * (state.v + state.vo) / (state.v - Math.abs(state.vs));

      const barPercent = (fObserved - fMin) / (fMax - fMin);
      document.getElementById('freq-bar').style.height = (barPercent * 100) + '%';
      document.getElementById('freq-label').textContent = fObserved.toFixed(0) + ' Hz';

      updateAudio();
      requestAnimationFrame(animate);
    }

    updateUI();
    animate();