Last active
August 5, 2025 04:26
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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