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
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×
2×
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)
• 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();