Skip to content

Instantly share code, notes, and snippets.

@JayGoldberg
Last active August 5, 2025 04:26
Show Gist options
  • Save JayGoldberg/bfe842d1f6f20b62ad6ee7f66374111c to your computer and use it in GitHub Desktop.
Save JayGoldberg/bfe842d1f6f20b62ad6ee7f66374111c to your computer and use it in GitHub Desktop.
Whelen/Whelan airhorn emulator in a webpage. Modulates a square wave with a sine wave (FM synthesis)
<!DOCTYPE html>
<!-- https://www.youtube.com/watch?v=6JO1F9xD3rU -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FM Synthesis</title>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #0f172a;
color: #e2e8f0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 8px;
background: #4b5563;
outline: none;
opacity: 0.7;
-webkit-transition: .2s;
transition: opacity .2s;
border-radius: 9999px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: #60a5fa;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 5px rgba(96, 165, 250, 0.5);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
background: #60a5fa;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 5px rgba(96, 165, 250, 0.5);
}
canvas {
background-color: #1e293b;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
width: 100%;
height: 150px;
}
.controls-container {
max-width: 600px;
width: 100%;
}
</style>
</head>
<body class="selection:bg-blue-300 selection:text-blue-900">
<div class="controls-container bg-gray-800 p-8 rounded-2xl shadow-2xl space-y-6">
<h1 class="text-3xl font-extrabold text-center text-white mb-6">FM Synthesis, square wave modulated by sine</h1>
<h2>
(Whelen horn emulator)
</h2>
<!-- Carrier Frequency Slider -->
<div class="space-y-2">
<label for="carrierFrequency" class="text-lg font-medium text-gray-300 flex justify-between items-center">
<span>Carrier Frequency</span>
<span id="carrierFrequency-value" class="font-mono text-sm text-blue-300">458 Hz</span>
</label>
<input type="range" id="carrierFrequency" min="200" max="1000" value="480" step="1">
</div>
<!-- Modulation Depth Slider -->
<div class="space-y-2">
<label for="modulationDepth" class="text-lg font-medium text-gray-300 flex justify-between items-center">
<span>Modulation Depth</span>
<span id="modulationDepth-value" class="font-mono text-sm text-blue-300">110 Hz</span>
</label>
<input type="range" id="modulationDepth" min="0" max="700" value="80" step="1">
</div>
<!-- Modulator Rate Slider -->
<div class="space-y-2">
<label for="modulatorRate" class="text-lg font-medium text-gray-300 flex justify-between items-center">
<span>Modulator Rate</span>
<span id="modulatorRate-value" class="font-mono text-sm text-blue-300">1.0 Hz</span>
</label>
<input type="range" id="modulatorRate" min="5" max="120" value="54" step="1.0">
</div>
<!-- Start/Stop Button -->
<div class="flex justify-center">
<button id="toggleButton" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-105 focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-opacity-50">
Start
</button>
</div>
<!-- Waveform Visualizer -->
<div class="pt-6">
<canvas id="waveformCanvas"></canvas>
</div>
</div>
<script>
// --- Global Variables and Setup ---
let audioContext;
let carrierOscillator;
let modulatorOscillator;
let modulationGain;
let masterGainNode;
let isPlaying = false;
// Visualizer variables
let analyser;
let canvas, canvasCtx;
let dataArray;
let bufferLength;
// DOM Elements
const toggleButton = document.getElementById('toggleButton');
const carrierFrequencySlider = document.getElementById('carrierFrequency');
const modulationDepthSlider = document.getElementById('modulationDepth');
const modulatorRateSlider = document.getElementById('modulatorRate');
const carrierFrequencyValue = document.getElementById('carrierFrequency-value');
const modulationDepthValue = document.getElementById('modulationDepth-value');
const modulatorRateValue = document.getElementById('modulatorRate-value');
// --- Helper Functions ---
function initializeAudio() {
// Create a new AudioContext if one doesn't exist
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Create the main carrier oscillator (the one we hear)
carrierOscillator = audioContext.createOscillator();
carrierOscillator.type = 'square';
carrierOscillator.frequency.value = parseFloat(carrierFrequencySlider.value);
// Create the modulator oscillator (the one we don't hear directly)
modulatorOscillator = audioContext.createOscillator();
modulatorOscillator.type = 'sine';
modulatorOscillator.frequency.value = parseFloat(modulatorRateSlider.value);
// Create a gain node for the modulator to control the depth
modulationGain = audioContext.createGain();
modulationGain.gain.value = parseFloat(modulationDepthSlider.value);
// Create a master gain node to control the overall volume
masterGainNode = audioContext.createGain();
masterGainNode.gain.value = 0.3; // Set an initial volume
// Create an analyser node for the visualizer
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
// Connect the signal chain:
// 1. Modulator Oscillator -> Modulation Gain -> Carrier Oscillator Frequency
modulatorOscillator.connect(modulationGain);
modulationGain.connect(carrierOscillator.frequency);
// 2. Carrier Oscillator -> Analyser -> Master Gain -> Speakers
carrierOscillator.connect(analyser);
analyser.connect(masterGainNode);
masterGainNode.connect(audioContext.destination);
// Start both oscillators
carrierOscillator.start();
modulatorOscillator.start();
}
function updateSliderValues() {
carrierFrequencyValue.textContent = `${parseFloat(carrierFrequencySlider.value)} Hz`;
modulationDepthValue.textContent = `${parseFloat(modulationDepthSlider.value)} Hz`;
modulatorRateValue.textContent = `${parseFloat(modulatorRateSlider.value).toFixed(1)} Hz`;
if (isPlaying) {
carrierOscillator.frequency.value = parseFloat(carrierFrequencySlider.value);
modulationGain.gain.value = parseFloat(modulationDepthSlider.value);
modulatorOscillator.frequency.value = parseFloat(modulatorRateSlider.value);
}
}
function startSound() {
// Initialize audio context and oscillators
initializeAudio();
isPlaying = true;
toggleButton.textContent = 'Stop';
toggleButton.classList.remove('bg-blue-600');
toggleButton.classList.add('bg-red-600');
// Start the visualizer
drawWaveform();
}
function stopSound() {
// Disconnect and stop all oscillators
if (carrierOscillator) {
carrierOscillator.disconnect();
carrierOscillator.stop();
carrierOscillator = null;
}
if (modulatorOscillator) {
modulatorOscillator.disconnect();
modulatorOscillator.stop();
modulatorOscillator = null;
}
isPlaying = false;
toggleButton.textContent = 'Start';
toggleButton.classList.remove('bg-red-600');
toggleButton.classList.add('bg-blue-600');
// Clear the canvas when sound stops
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
}
// --- Visualizer Logic ---
function setupCanvas() {
canvas = document.getElementById('waveformCanvas');
canvasCtx = canvas.getContext('2d');
// Set canvas size to match the container
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
canvasCtx.scale(dpr, dpr);
canvasCtx.fillStyle = '#1e293b';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
}
function drawWaveform() {
requestAnimationFrame(drawWaveform);
if (!isPlaying) return;
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#1e293b';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#60a5fa';
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for(let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if(i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
}
// --- Event Listeners ---
window.addEventListener('load', () => {
setupCanvas();
updateSliderValues();
drawWaveform();
});
toggleButton.addEventListener('click', () => {
if (isPlaying) {
stopSound();
} else {
startSound();
}
});
carrierFrequencySlider.addEventListener('input', updateSliderValues);
modulationDepthSlider.addEventListener('input', updateSliderValues);
modulatorRateSlider.addEventListener('input', updateSliderValues);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment