Custom Code Experiment •
Web Audio API: Junkies for the 808
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Audio Visualiser | Junkies for the 808 V1</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
body, html {
overflow: hidden;
width: 100%;
height: 100%;
background: #000;
color: #fff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
touch-action: manipulation;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
position: absolute;
top: 0;
left: 0;
}
#controls {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
background: rgba(0, 0, 0, 0.9);
padding: 20px;
border-radius: 15px;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
}
/* Mobile-specific controls styling - COMPACT VERSION */
@media (max-width: 768px) {
#controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
transform: translateY(100%);
border-radius: 15px 15px 0 0;
padding: 15px 15px 80px 15px; /* Reduced padding */
max-height: 85vh; /* Increased slightly but still compact */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
display: none; /* Hidden by default on mobile */
flex-direction: column;
align-items: stretch;
gap: 12px; /* Reduced gap */
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.7);
}
#controls.show {
display: flex;
transform: translateY(0);
}
.control-group {
width: 100%;
margin-bottom: 5px; /* Reduced margin */
}
label {
font-size: 12px; /* Smaller font */
margin-bottom: 5px; /* Reduced margin */
display: block;
color: #8af;
text-transform: uppercase;
letter-spacing: 0.5px; /* Reduced letter spacing */
}
select, input[type="range"], button {
width: 100%;
min-width: auto;
padding: 8px 10px; /* Reduced padding */
font-size: 14px; /* Slightly smaller font */
border-radius: 8px;
}
select {
background: rgba(0, 20, 40, 0.8);
border: 1px solid rgba(100, 150, 255, 0.3);
color: white;
height: 36px; /* Fixed height */
}
input[type="range"] {
height: 36px; /* Reduced height */
-webkit-appearance: none;
appearance: none;
background: transparent;
margin: 8px 0;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px; /* Smaller thumb */
height: 20px;
border-radius: 50%;
background: #0066ff;
cursor: pointer;
margin-top: -8px;
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #0066ff;
cursor: pointer;
border: none;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
/* Compact button grid */
.button-group {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 2 columns */
grid-template-rows: repeat(2, auto); /* 2 rows */
gap: 8px; /* Smaller gap */
width: 100%;
margin-top: 5px;
}
.button-group button {
padding: 10px 5px; /* Reduced padding */
font-size: 13px; /* Smaller font */
border-radius: 8px;
min-height: 40px; /* Reduced height */
min-width: 0; /* Allow buttons to shrink */
margin: 0;
}
/* Specific button layout */
#playButton {
grid-column: 1;
grid-row: 1;
}
#pauseButton {
grid-column: 2;
grid-row: 1;
}
#stopButton {
grid-column: 1;
grid-row: 2;
}
#uploadButton {
grid-column: 2;
grid-row: 2;
}
.colour-picker {
display: flex;
justify-content: space-between; /* Evenly space */
gap: 5px; /* Smaller gap */
margin-top: 5px;
}
.colour-swatch {
width: 32px; /* Smaller swatches */
height: 32px;
flex: 1; /* Equal width */
border-radius: 6px;
border: 2px solid rgba(255, 255, 255, 0.3);
}
/* Group sliders more closely */
.slider-group {
margin-bottom: 8px;
}
.slider-group label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.slider-value {
font-size: 11px;
color: #8af;
background: rgba(0, 20, 40, 0.5);
padding: 2px 6px;
border-radius: 10px;
min-width: 30px;
text-align: center;
}
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 12px;
color: #8af;
text-transform: uppercase;
letter-spacing: 1px;
}
select, input[type="range"] {
background: rgba(0, 20, 40, 0.8);
border: 1px solid rgba(100, 150, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 5px;
min-width: 150px;
font-size: 14px;
}
input[type="range"] {
width: 200px;
}
button {
background: linear-gradient(45deg, #0066ff, #00ccff);
border: none;
color: white;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.3s;
min-width: 120px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 102, 255, 0.4);
}
button:active {
transform: translateY(0);
}
.button-group {
display: flex;
gap: 10px;
}
/* Mini Player for Mobile */
#miniPlayer {
display: none;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1001;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(15px);
border-radius: 50px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 5px 25px rgba(0, 102, 255, 0.3);
min-width: 180px;
justify-content: space-between;
align-items: center;
gap: 12px;
animation: float 3s ease-in-out infinite;
}
@media (max-width: 768px) {
#miniPlayer {
display: flex;
}
@keyframes float {
0%, 100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-5px);
}
}
}
.mini-player-btn {
background: transparent;
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s;
}
.mini-player-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.mini-player-btn.play {
background: linear-gradient(45deg, #0066ff, #00ccff);
width: 44px;
height: 44px;
font-size: 18px;
}
.mini-player-btn.play:hover {
transform: scale(1.1);
}
#miniTimeDisplay {
font-size: 12px;
color: #8af;
min-width: 50px;
text-align: center;
font-family: monospace;
font-weight: bold;
}
#fileInput {
display: none;
}
#songInfo {
position: absolute;
top: 20px;
left: 20px;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
padding: 12px 15px; /* Slightly reduced padding */
border-radius: 10px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 300px;
}
@media (max-width: 768px) {
#songInfo {
top: 10px;
left: 10px;
right: 10px;
max-width: none;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.85);
}
}
#songTitle {
font-size: 16px;
font-weight: bold;
margin-bottom: 3px; /* Reduced margin */
color: #8af;
}
#songStats {
font-size: 12px;
color: #aaa;
margin-bottom: 3px; /* Reduced margin */
}
#progressDisplay {
margin-top: 3px;
font-size: 11px;
color: #8af;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
padding: 25px; /* Reduced padding */
border-radius: 15px;
border: 1px solid rgba(0, 100, 255, 0.5);
}
.spinner {
width: 40px; /* Smaller spinner */
height: 40px;
border: 3px solid rgba(0, 100, 255, 0.3);
border-top: 3px solid #0066ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#progressBar {
width: 180px; /* Reduced width */
height: 3px;
background: rgba(255, 255, 255, 0.1);
margin: 15px auto; /* Reduced margin */
border-radius: 2px;
overflow: hidden;
}
#progress {
height: 100%;
background: linear-gradient(90deg, #0066ff, #00ccff);
width: 0%;
transition: width 0.3s;
}
#fpsCounter {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
color: #0f0;
font-family: monospace;
font-size: 14px;
}
@media (max-width: 768px) {
#fpsCounter {
top: 10px;
right: 10px;
font-size: 12px;
}
}
.colour-picker {
display: flex;
gap: 5px;
align-items: centre;
}
.colour-swatch {
width: 30px;
height: 30px;
border-radius: 5px;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
transition: transform 0.2s;
}
.colour-swatch:hover {
transform: scale(1.1);
}
.colour-swatch.active {
border-color: #fff;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
}
.hidden {
display: none !important;
}
#playbackControls {
display: flex;
align-items: centre;
gap: 10px;
margin-top: 10px;
}
@media (max-width: 768px) {
#playbackControls {
margin-top: 8px;
gap: 8px;
}
}
#timeSlider {
flex-grow: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
position: relative;
cursor: pointer;
}
@media (max-width: 768px) {
#timeSlider {
height: 5px;
}
}
#timeProgress {
position: absolute;
height: 100%;
background: linear-gradient(90deg, #0066ff, #00ccff);
border-radius: 2px;
width: 0%;
}
#timeDisplay {
font-size: 11px;
color: #8af;
min-width: 80px;
text-align: centre;
}
@media (max-width: 768px) {
#timeDisplay {
font-size: 11px;
min-width: 70px;
}
}
.artist-credit {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
padding: 8px 12px; /* Reduced padding */
border-radius: 10px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px; /* Smaller font */
color: #8af;
max-width: 280px; /* Slightly smaller */
}
@media (max-width: 768px) {
.artist-credit {
bottom: 80px; /* Adjusted to avoid overlap with mini player */
right: 10px;
left: 10px;
text-align: center;
max-width: none;
background: rgba(0, 0, 0, 0.8);
padding: 8px 10px;
font-size: 11px;
}
}
.artist-credit a {
color: #00ccff;
text-decoration: none;
transition: colour 0.3s;
}
.artist-credit a:hover {
colour: #fff;
text-decoration: underline;
}
#errorMessage {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: centre;
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
padding: 25px; /* Reduced padding */
border-radius: 15px;
border: 1px solid rgba(255, 50, 50, 0.5);
max-width: 350px; /* Slightly smaller */
}
#errorMessage h3 {
colour: #ff5555;
margin-bottom: 12px; /* Reduced margin */
font-size: 16px; /* Smaller font */
}
#errorMessage p {
margin-bottom: 12px;
colour: #ccc;
font-size: 14px;
}
#errorMessage button {
background: linear-gradient(45deg, #ff5555, #ff8888);
margin-top: 8px;
padding: 8px 16px; /* Reduced padding */
font-size: 14px;
}
/* Mobile overlay to close menu when tapping outside */
#mobileOverlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
backdrop-filter: blur(2px);
}
@media (max-width: 768px) {
#mobileOverlay.show {
display: block;
}
}
</style>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="songInfo" class="hidden">
<div id="songTitle">Junkies for the 808 V1</div>
<div id="songStats">Loading your track...</div>
<div id="progressDisplay">00:00 / 00:00</div>
<div id="playbackControls" class="hidden">
<div id="timeSlider">
<div id="timeProgress"></div>
</div>
<div id="timeDisplay">00:00</div>
</div>
</div>
<div class="artist-credit">
Music: <a href="https://scrapbook.mikehiggins.me.uk/" target="_blank" rel="noopener">Mike Higgins</a> - Junkies for the 808 V1
</div>
<div id="fpsCounter">FPS: 0</div>
<div id="loading" class="hidden">
<div class="spinner"></div>
<div>Loading Your Track...</div>
<div id="progressBar">
<div id="progress"></div>
</div>
</div>
<div id="errorMessage" class="hidden">
<h3>Audio Loading Error</h3>
<p id="errorText">There was an issue loading the audio file. Please upload your own MP3 file to visualise.</p>
<button id="closeError">Close</button>
</div>
<!-- Hidden audio element -->
<audio id="audioElement" style="display: none;" crossorigin="anonymous"></audio>
<!-- Mini Player for Mobile -->
<div id="miniPlayer">
<button class="mini-player-btn" id="miniPlayBtn">▶</button>
<div id="miniTimeDisplay">00:00</div>
<button class="mini-player-btn" id="miniMenuBtn">⚙</button>
</div>
<!-- Mobile Overlay for closing menu -->
<div id="mobileOverlay"></div>
<div id="controls">
<div class="control-group">
<label>Visualisation Mode</label>
<select id="modeSelect">
<option value="waveform">Waveform</option>
<option value="circle">Circular Spectrum</option>
<option value="tunnel">3D Tunnel</option>
<option value="particleSpectrum">Particle Spectrum</option>
<option value="liquid">Liquid Visualisation</option>
</select>
</div>
<div class="control-group">
<label>Colour Scheme</label>
<div class="colour-picker">
<div class="colour-swatch active" style="background: linear-gradient(45deg, #0066ff, #00ccff);" data-scheme="blue"></div>
<div class="colour-swatch" style="background: linear-gradient(45deg, #ff0066, #ff00cc);" data-scheme="pink"></div>
<div class="colour-swatch" style="background: linear-gradient(45deg, #00ff66, #00ffcc);" data-scheme="green"></div>
<div class="colour-swatch" style="background: linear-gradient(45deg, #ff6600, #ffcc00);" data-scheme="orange"></div>
<div class="colour-swatch" style="background: linear-gradient(45deg, #6600ff, #cc00ff);" data-scheme="purple"></div>
</div>
</div>
<div class="control-group">
<label for="bassBoost">Bass Boost: <span id="bassBoostValue" class="slider-value">1.0</span></label>
<input type="range" id="bassBoost" min="0.5" max="3" step="0.1" value="1">
</div>
<div class="control-group">
<label for="trebleBoost">Treble Boost: <span id="trebleBoostValue" class="slider-value">1.0</span></label>
<input type="range" id="trebleBoost" min="0.5" max="3" step="0.1" value="1">
</div>
<div class="control-group">
<label for="intensity">Intensity: <span id="intensityValue" class="slider-value">1.0</span></label>
<input type="range" id="intensity" min="0.5" max="2" step="0.1" value="1">
</div>
<div class="button-group">
<button id="playButton" disabled>Play</button>
<button id="pauseButton" disabled>Pause</button>
<button id="stopButton" disabled>Stop</button>
<button id="uploadButton">Upload Audio</button>
<input type="file" id="fileInput" accept="audio/*">
</div>
</div>
</div>
<script>
class EnhancedAudioVisualiser {
constructor() {
this.init();
}
async init() {
// DOM Elements
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.loading = document.getElementById('loading');
this.progress = document.getElementById('progress');
this.controls = document.getElementById('controls');
this.songInfo = document.getElementById('songInfo');
this.songTitle = document.getElementById('songTitle');
this.songStats = document.getElementById('songStats');
this.progressDisplay = document.getElementById('progressDisplay');
this.playbackControls = document.getElementById('playbackControls');
this.timeSlider = document.getElementById('timeSlider');
this.timeProgress = document.getElementById('timeProgress');
this.timeDisplay = document.getElementById('timeDisplay');
this.fpsCounter = document.getElementById('fpsCounter');
this.errorMessage = document.getElementById('errorMessage');
this.errorText = document.getElementById('errorText');
this.audioElement = document.getElementById('audioElement');
this.mobileOverlay = document.getElementById('mobileOverlay');
// Mini player elements
this.miniPlayer = document.getElementById('miniPlayer');
this.miniPlayBtn = document.getElementById('miniPlayBtn');
this.miniMenuBtn = document.getElementById('miniMenuBtn');
this.miniTimeDisplay = document.getElementById('miniTimeDisplay');
// Slider value displays
this.bassBoostValue = document.getElementById('bassBoostValue');
this.trebleBoostValue = document.getElementById('trebleBoostValue');
this.intensityValue = document.getElementById('intensityValue');
// Check if mobile
this.isMobile = window.innerWidth <= 768;
// Audio Context and nodes
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create audio processing chain
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 4096;
this.analyser.smoothingTimeConstant = 0.5;
// Create gain node for master volume
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 0.7;
// Create EQ filters
this.bassFilter = this.audioContext.createBiquadFilter();
this.bassFilter.type = 'lowshelf';
this.bassFilter.frequency.value = 200;
this.midFilter = this.audioContext.createBiquadFilter();
this.midFilter.type = 'peaking';
this.midFilter.frequency.value = 1000;
this.midFilter.Q.value = 1;
this.trebleFilter = this.audioContext.createBiquadFilter();
this.trebleFilter.type = 'highshelf';
this.trebleFilter.frequency.value = 3000;
// Connect the audio chain
this.bassFilter.connect(this.midFilter);
this.midFilter.connect(this.trebleFilter);
this.trebleFilter.connect(this.gainNode);
this.gainNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
// Create media element source
this.mediaElementSource = null;
// Audio data arrays
this.bufferLength = this.analyser.frequencyBinCount;
this.frequencyData = new Uint8Array(this.bufferLength);
this.timeData = new Uint8Array(this.bufferLength);
// Frequency bands
this.bassBand = { start: 20, end: 250 };
this.midBand = { start: 250, end: 2000 };
this.trebleBand = { start: 2000, end: 20000 };
// Visualisation settings
this.mode = 'waveform';
this.colourScheme = 'blue';
this.bassBoost = 1;
this.trebleBoost = 1;
this.intensity = 1;
// State
this.isPlaying = false;
this.currentTime = 0;
this.startTime = 0;
this.fps = 0;
this.lastTime = 0;
this.frameCount = 0;
this.animationFrameId = null;
// Visualisation-specific data
this.particles = [];
this.liquidPoints = [];
// Colours
this.colours = {
blue: { primary: '#0066ff', secondary: '#00ccff', accent: '#8af' },
pink: { primary: '#ff0066', secondary: '#ff00cc', accent: '#f8a' },
green: { primary: '#00ff66', secondary: '#00ffcc', accent: '#8fa' },
orange: { primary: '#ff6600', secondary: '#ffcc00', accent: '#fa8' },
purple: { primary: '#6600ff', secondary: '#cc00ff', accent: '#c8f' }
};
this.currentColours = this.colours.blue;
this.setupEventListeners();
this.resize();
this.createLiquidPoints();
this.createParticles();
// Start with demo visualisation while loading
this.startDemoVisualisation();
// Try to load your MP3 from GitHub via jsDelivr
setTimeout(() => this.loadDefaultAudio(), 100);
}
setupEventListeners() {
window.addEventListener('resize', () => {
this.resize();
this.isMobile = window.innerWidth <= 768;
// Update controls visibility based on screen size
if (!this.isMobile && this.controls.classList.contains('show')) {
this.hideMobileMenu();
}
});
// Mini player controls
this.miniPlayBtn.addEventListener('click', () => {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
});
this.miniMenuBtn.addEventListener('click', () => this.toggleMobileMenu());
// Mobile overlay
this.mobileOverlay.addEventListener('click', () => this.hideMobileMenu());
// Controls
document.getElementById('modeSelect').addEventListener('change', (e) => {
this.mode = e.target.value;
if (this.mode === 'particleSpectrum') {
this.createParticles();
}
// Auto-close menu on mobile after selection
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
document.querySelectorAll('.colour-swatch').forEach(swatch => {
swatch.addEventListener('click', (e) => {
document.querySelectorAll('.colour-swatch').forEach(s => s.classList.remove('active'));
e.target.classList.add('active');
this.colourScheme = e.target.dataset.scheme;
this.currentColours = this.colours[this.colourScheme];
// Auto-close menu on mobile after selection
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
});
document.getElementById('bassBoost').addEventListener('input', (e) => {
this.bassBoost = parseFloat(e.target.value);
this.bassFilter.gain.value = (this.bassBoost - 1) * 20;
this.bassBoostValue.textContent = this.bassBoost.toFixed(1);
});
document.getElementById('trebleBoost').addEventListener('input', (e) => {
this.trebleBoost = parseFloat(e.target.value);
this.trebleFilter.gain.value = (this.trebleBoost - 1) * 20;
this.trebleBoostValue.textContent = this.trebleBoost.toFixed(1);
});
document.getElementById('intensity').addEventListener('input', (e) => {
this.intensity = parseFloat(e.target.value);
this.intensityValue.textContent = this.intensity.toFixed(1);
});
document.getElementById('playButton').addEventListener('click', () => {
this.play();
// Auto-close menu on mobile
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
document.getElementById('pauseButton').addEventListener('click', () => {
this.pause();
// Auto-close menu on mobile
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
document.getElementById('stopButton').addEventListener('click', () => {
this.stop();
// Auto-close menu on mobile
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
document.getElementById('uploadButton').addEventListener('click', () => {
document.getElementById('fileInput').click();
// Auto-close menu on mobile
if (this.isMobile) {
setTimeout(() => this.hideMobileMenu(), 300);
}
});
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
this.loadAudioFromFile(file);
}
});
document.getElementById('closeError').addEventListener('click', () => {
this.errorMessage.classList.add('hidden');
});
// Audio element event listeners
this.audioElement.addEventListener('timeupdate', () => {
this.currentTime = this.audioElement.currentTime;
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
});
this.audioElement.addEventListener('loadedmetadata', () => {
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
});
this.audioElement.addEventListener('error', (e) => {
console.error('Audio element error:', e);
});
// Time slider for seeking
this.timeSlider.addEventListener('click', (e) => {
if (!this.audioElement.src || this.audioElement.duration === 0) return;
const rect = this.timeSlider.getBoundingClientRect();
const clickPosition = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickPosition / rect.width));
this.audioElement.currentTime = percentage * this.audioElement.duration;
this.currentTime = this.audioElement.currentTime;
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
});
// Better touch support for time slider on mobile
this.timeSlider.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = this.timeSlider.getBoundingClientRect();
const clickPosition = touch.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickPosition / rect.width));
if (!this.audioElement.src || this.audioElement.duration === 0) return;
this.audioElement.currentTime = percentage * this.audioElement.duration;
this.currentTime = this.audioElement.currentTime;
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
}, { passive: false });
}
toggleMobileMenu() {
if (this.controls.classList.contains('show')) {
this.hideMobileMenu();
} else {
this.showMobileMenu();
}
}
showMobileMenu() {
this.controls.classList.add('show');
this.mobileOverlay.classList.add('show');
// Hide mini player when menu is open
this.miniPlayer.style.display = 'none';
}
hideMobileMenu() {
this.controls.classList.remove('show');
this.mobileOverlay.classList.remove('show');
// Show mini player when menu is closed
if (this.isMobile) {
this.miniPlayer.style.display = 'flex';
}
}
resize() {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
this.centreX = this.width / 2;
this.centreY = this.height / 2;
if (this.mode === 'particleSpectrum') {
this.createParticles();
}
this.createLiquidPoints();
}
getFrequencyValue(startHz, endHz) {
const sampleRate = this.audioContext.sampleRate;
const startIndex = Math.floor((startHz / (sampleRate / 2)) * this.bufferLength);
const endIndex = Math.floor((endHz / (sampleRate / 2)) * this.bufferLength);
let sum = 0;
let count = 0;
for (let i = Math.max(0, startIndex); i < Math.min(this.bufferLength, endIndex); i++) {
sum += this.frequencyData[i];
count++;
}
return count > 0 ? sum / count / 255 : 0;
}
getBassLevel() {
return this.getFrequencyValue(this.bassBand.start, this.bassBand.end) * this.bassBoost;
}
getMidLevel() {
return this.getFrequencyValue(this.midBand.start, this.midBand.end);
}
getTrebleLevel() {
return this.getFrequencyValue(this.trebleBand.start, this.trebleBand.end) * this.trebleBoost;
}
createParticles() {
this.particles = [];
const particleCount = Math.min(1000, Math.floor(this.width * this.height / 1000));
for (let i = 0; i < particleCount; i++) {
this.particles.push({
x: Math.random() * this.width,
y: Math.random() * this.height,
baseY: Math.random() * this.height,
size: Math.random() * 3 + 1,
speed: Math.random() * 0.5 + 0.1,
colour: this.currentColours.primary,
frequencyIndex: Math.floor(Math.random() * this.bufferLength),
phase: Math.random() * Math.PI * 2
});
}
}
createLiquidPoints() {
this.liquidPoints = [];
const pointCount = 100;
for (let i = 0; i <= pointCount; i++) {
this.liquidPoints.push({
x: (i / pointCount) * this.width,
baseY: this.height * 0.5,
y: this.height * 0.5,
vy: 0
});
}
}
formatTime(seconds) {
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
updateProgressDisplay() {
if (!this.audioElement.src) return;
const current = this.audioElement.currentTime;
const total = this.audioElement.duration;
if (isNaN(current) || isNaN(total) || total === 0) return;
const percentage = (current / total) * 100;
this.timeProgress.style.width = `${percentage}%`;
this.timeDisplay.textContent = this.formatTime(current);
this.progressDisplay.textContent = `${this.formatTime(current)} / ${this.formatTime(total)}`;
}
updateMiniPlayerDisplay() {
if (!this.audioElement.src) return;
const current = this.audioElement.currentTime;
if (isNaN(current)) return;
this.miniTimeDisplay.textContent = this.formatTime(current);
// Update play/pause button
if (this.isPlaying) {
this.miniPlayBtn.textContent = '❚❚'; // Pause symbol
} else {
this.miniPlayBtn.textContent = '▶'; // Play symbol
}
}
showError(message) {
this.errorText.textContent = message;
this.errorMessage.classList.remove('hidden');
}
loadDefaultAudio() {
this.showLoading();
this.progress.style.width = '30%';
// Reset audio element
this.audioElement.pause();
this.audioElement.currentTime = 0;
// Using jsDelivr CDN for GitHub files (supports CORS)
const audioUrl = 'https://cdn.jsdelivr.net/gh/alaric-visi/music@main/junkies-for-the-808/junkies-for-the-808.mp3';
console.log('Loading MP3 from jsDelivr:', audioUrl);
// Set up event listeners for loading
const onLoaded = () => {
console.log('Audio loaded successfully from jsDelivr');
this.progress.style.width = '100%';
// Create media element source if not already created
if (!this.mediaElementSource) {
this.mediaElementSource = this.audioContext.createMediaElementSource(this.audioElement);
this.mediaElementSource.connect(this.bassFilter);
}
this.songTitle.textContent = 'Junkies for the 808 V1';
this.songStats.textContent = `by Mike Higgins | ${Math.floor(this.audioElement.duration)} seconds`;
this.songInfo.classList.remove('hidden');
this.playbackControls.classList.remove('hidden');
// Enable playback controls
document.getElementById('playButton').disabled = false;
document.getElementById('pauseButton').disabled = false;
document.getElementById('stopButton').disabled = false;
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
setTimeout(() => {
this.hideLoading();
// Try to auto-play - but don't show error if it fails
this.autoPlay();
}, 500);
};
const onError = (e) => {
console.error('Failed to load audio from jsDelivr:', e);
this.hideLoading();
// Keep demo visualisation running silently
this.startDemoVisualisation();
};
// Remove existing event listeners
this.audioElement.removeEventListener('canplaythrough', onLoaded);
this.audioElement.removeEventListener('error', onError);
// Add new event listeners
this.audioElement.addEventListener('canplaythrough', onLoaded, { once: true });
this.audioElement.addEventListener('error', onError, { once: true });
// Set the source
this.audioElement.src = audioUrl;
this.audioElement.load();
// Timeout in case loading takes too long
setTimeout(() => {
if (this.audioElement.readyState < 2) {
this.progress.style.width = '60%';
}
}, 1000);
}
autoPlay() {
if (!this.audioElement.src) return;
// Resume audio context if suspended
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
const playPromise = this.audioElement.play();
if (playPromise !== undefined) {
playPromise.then(() => {
this.isPlaying = true;
this.startTime = this.audioContext.currentTime - this.audioElement.currentTime;
// Update button states
document.getElementById('playButton').disabled = true;
document.getElementById('pauseButton').disabled = false;
document.getElementById('stopButton').disabled = false;
this.updateMiniPlayerDisplay();
// Start animation loop
this.animate();
}).catch(error => {
console.log('Auto-play failed (normal for browsers):', error);
// Don't show error - just enable play button
document.getElementById('playButton').disabled = false;
this.updateMiniPlayerDisplay();
});
}
}
async loadAudioFromFile(file) {
this.showLoading();
this.progress.style.width = '30%';
try {
// Stop any current playback
this.stop();
// Create object URL from file
const objectUrl = URL.createObjectURL(file);
// Reset audio element
this.audioElement.pause();
this.audioElement.currentTime = 0;
// Set up loading handlers
const onLoaded = () => {
this.progress.style.width = '100%';
// Create media element source if not already created
if (!this.mediaElementSource) {
this.mediaElementSource = this.audioContext.createMediaElementSource(this.audioElement);
this.mediaElementSource.connect(this.bassFilter);
}
this.songTitle.textContent = file.name;
this.songStats.textContent = `${Math.floor(this.audioElement.duration)} seconds`;
this.songInfo.classList.remove('hidden');
this.playbackControls.classList.remove('hidden');
// Enable playback controls
document.getElementById('playButton').disabled = false;
document.getElementById('pauseButton').disabled = false;
document.getElementById('stopButton').disabled = false;
this.updateProgressDisplay();
this.updateMiniPlayerDisplay();
setTimeout(() => {
this.hideLoading();
// Auto-play after loading
this.play();
}, 500);
// Clean up object URL after loading
URL.revokeObjectURL(objectUrl);
};
const onError = (e) => {
console.error('Failed to load audio file:', e);
this.hideLoading();
this.showError('Could not load the audio file. Please try a different file.');
};
// Remove existing event listeners
this.audioElement.removeEventListener('canplaythrough', onLoaded);
this.audioElement.removeEventListener('error', onError);
// Add new event listeners
this.audioElement.addEventListener('canplaythrough', onLoaded, { once: true });
this.audioElement.addEventListener('error', onError, { once: true });
// Set the source
this.audioElement.src = objectUrl;
this.audioElement.load();
} catch (error) {
console.error('Error loading audio file:', error);
this.hideLoading();
this.showError('Failed to load audio file. Please try again.');
}
}
showLoading() {
this.loading.classList.remove('hidden');
}
hideLoading() {
this.loading.classList.add('hidden');
this.progress.style.width = '0%';
}
play() {
if (!this.audioElement.src) return;
// Resume audio context if suspended
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
if (this.isPlaying) return;
const playPromise = this.audioElement.play();
if (playPromise !== undefined) {
playPromise.then(() => {
this.isPlaying = true;
this.startTime = this.audioContext.currentTime - this.audioElement.currentTime;
// Update button states
document.getElementById('playButton').disabled = true;
document.getElementById('pauseButton').disabled = false;
document.getElementById('stopButton').disabled = false;
this.updateMiniPlayerDisplay();
// Start animation loop
this.animate();
}).catch(error => {
console.error('Playback failed:', error);
// Don't show error message - just enable the play button
document.getElementById('playButton').disabled = false;
this.updateMiniPlayerDisplay();
});
}
}
pause() {
if (!this.isPlaying) return;
this.audioElement.pause();
this.isPlaying = false;
// Update button states
document.getElementById('playButton').disabled = false;
document.getElementById('pauseButton').disabled = true;
this.updateMiniPlayerDisplay();
// Stop animation loop
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Update display
this.updateProgressDisplay();
}
stop() {
this.audioElement.pause();
this.audioElement.currentTime = 0;
this.currentTime = 0;
this.isPlaying = false;
// Update button states
document.getElementById('playButton').disabled = false;
document.getElementById('pauseButton').disabled = true;
document.getElementById('stopButton').disabled = true;
this.updateMiniPlayerDisplay();
// Stop animation loop
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Update display
this.updateProgressDisplay();
}
startDemoVisualisation() {
// Stop any existing animation
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
// Generate demo frequency data
this.frequencyData = new Uint8Array(this.bufferLength);
this.timeData = new Uint8Array(this.bufferLength);
// Start demo animation
this.animateDemo();
}
animateDemo() {
const now = performance.now();
this.frameCount++;
if (now - this.lastTime >= 1000) {
this.fps = Math.round((this.frameCount * 1000) / (now - this.lastTime));
this.fpsCounter.textContent = `FPS: ${this.fps}`;
this.frameCount = 0;
this.lastTime = now;
}
// Generate interesting demo patterns
const time = Date.now() * 0.001;
for (let i = 0; i < this.bufferLength; i++) {
// Create pulsing bass frequencies
if (i < this.bufferLength * 0.1) {
this.frequencyData[i] = Math.sin(time * 2 + i * 0.01) * 60 + 80;
}
// Create mid frequencies
else if (i < this.bufferLength * 0.5) {
this.frequencyData[i] = Math.sin(time * 5 + i * 0.005) * 40 + 60;
}
// Create treble frequencies
else {
this.frequencyData[i] = Math.sin(time * 10 + i * 0.002) * 30 + 40;
}
// Create waveform pattern
this.timeData[i] = Math.sin(time * 8 + i * 0.05) * 64 + 128;
}
this.clearCanvas();
this.drawVisualisation();
this.animationFrameId = requestAnimationFrame(() => this.animateDemo());
}
animate() {
if (!this.isPlaying) return;
const now = performance.now();
this.frameCount++;
if (now - this.lastTime >= 1000) {
this.fps = Math.round((this.frameCount * 1000) / (now - this.lastTime));
this.fpsCounter.textContent = `FPS: ${this.fps}`;
this.frameCount = 0;
this.lastTime = now;
}
// Get audio data
this.analyser.getByteFrequencyData(this.frequencyData);
this.analyser.getByteTimeDomainData(this.timeData);
this.clearCanvas();
this.drawVisualisation();
this.animationFrameId = requestAnimationFrame(() => this.animate());
}
clearCanvas() {
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
this.ctx.fillRect(0, 0, this.width, this.height);
}
drawVisualisation() {
switch (this.mode) {
case 'waveform':
this.drawWaveform();
break;
case 'circle':
this.drawCircularSpectrum();
break;
case 'tunnel':
this.drawTunnel();
break;
case 'particleSpectrum':
this.drawParticleSpectrum();
break;
case 'liquid':
this.drawLiquidVisualisation();
break;
}
}
drawWaveform() {
this.ctx.beginPath();
const sliceWidth = this.width / this.bufferLength;
let x = 0;
const bassLevel = this.getBassLevel();
const lineWidth = 2 + bassLevel * 5;
// Create gradient
const gradient = this.ctx.createLinearGradient(0, 0, this.width, 0);
gradient.addColorStop(0, this.currentColours.primary);
gradient.addColorStop(0.5, this.currentColours.secondary);
gradient.addColorStop(1, this.currentColours.primary);
this.ctx.strokeStyle = gradient;
this.ctx.lineWidth = lineWidth;
this.ctx.lineJoin = 'round';
this.ctx.lineCap = 'round';
// Draw waveform
for (let i = 0; i < this.bufferLength; i++) {
const v = this.timeData[i] / 128.0;
const y = v * this.height / 2;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
x += sliceWidth;
}
this.ctx.stroke();
}
drawCircularSpectrum() {
const angleStep = (Math.PI * 2) / this.bufferLength;
const baseRadius = Math.min(this.width, this.height) / 4;
const bassLevel = this.getBassLevel();
const radiusPulse = Math.sin(Date.now() * 0.002) * bassLevel * 20;
this.ctx.save();
this.ctx.translate(this.centreX, this.centreY);
for (let i = 0; i < this.bufferLength; i += 2) {
const angle = i * angleStep;
const value = this.frequencyData[i] / 255;
// Apply frequency-specific intensity
let intensity = this.intensity;
if (i < this.bufferLength * 0.3) {
intensity *= this.bassBoost;
} else if (i > this.bufferLength * 0.7) {
intensity *= this.trebleBoost;
}
const barLength = value * 200 * intensity;
const x1 = Math.cos(angle) * (baseRadius + radiusPulse);
const y1 = Math.sin(angle) * (baseRadius + radiusPulse);
const x2 = Math.cos(angle) * (baseRadius + barLength + radiusPulse);
const y2 = Math.sin(angle) * (baseRadius + barLength + radiusPulse);
// Draw bar
this.ctx.beginPath();
this.ctx.moveTo(x1, y1);
this.ctx.lineTo(x2, y2);
// Colour based on frequency
const hue = (i / this.bufferLength) * 360;
const lineWidth = 2 + value * 3;
this.ctx.strokeStyle = `hsla(${hue}, 80%, 60%, ${0.5 + value * 0.5})`;
this.ctx.lineWidth = lineWidth;
this.ctx.lineCap = 'round';
this.ctx.stroke();
// Draw dot for strong frequencies
if (value > 0.7) {
this.ctx.beginPath();
this.ctx.arc(x2, y2, 3 + value * 5, 0, Math.PI * 2);
this.ctx.fillStyle = this.currentColours.accent;
this.ctx.fill();
}
}
this.ctx.restore();
}
drawTunnel() {
const segments = 40;
const time = Date.now() * 0.001;
const bassLevel = this.getBassLevel();
const trebleLevel = this.getTrebleLevel();
this.ctx.save();
this.ctx.translate(this.centreX, this.centreY);
// Tunnel rotation
const rotationSpeed = 0.002 + bassLevel * 0.003;
const rotation = time * rotationSpeed;
for (let i = 0; i < segments; i++) {
const depth = i / segments;
const radius = 50 + i * 8;
// Distortion based on frequencies
const bassDistortion = Math.sin(time * 2 + i * 0.3) * bassLevel * 30;
const trebleDistortion = Math.sin(time * 5 + i * 0.1) * trebleLevel * 10;
const distortedRadius = radius + bassDistortion + trebleDistortion;
this.ctx.beginPath();
// Draw segment
const points = 32;
for (let j = 0; j <= points; j++) {
const angle = (j / points) * Math.PI * 2 + rotation;
const freqIndex = Math.floor((j / points) * this.bufferLength);
const freqValue = this.frequencyData[freqIndex] / 255;
const distortion = Math.sin(time * 3 + angle * 2) * freqValue * 20 * this.intensity;
const r = distortedRadius + distortion;
const x = Math.cos(angle) * r;
const y = Math.sin(angle) * r;
if (j === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
}
// Colour based on depth
const hue = (i * 10 + time * 50) % 360;
const alpha = 1 - depth * 0.7;
this.ctx.strokeStyle = `hsla(${hue}, 80%, 60%, ${alpha})`;
this.ctx.lineWidth = 2 + bassLevel * 3;
this.ctx.stroke();
}
this.ctx.restore();
}
drawParticleSpectrum() {
if (this.particles.length === 0) {
this.createParticles();
}
const bassLevel = this.getBassLevel();
const trebleLevel = this.getTrebleLevel();
this.particles.forEach(particle => {
const freqValue = this.frequencyData[particle.frequencyIndex] / 255;
// Movement based on frequency
particle.y = particle.baseY + Math.sin(Date.now() * 0.001 * particle.speed + particle.phase) * freqValue * 100 * this.intensity;
// React to bass
if (particle.frequencyIndex < this.bufferLength * 0.3) {
particle.x += Math.sin(Date.now() * 0.001 + particle.phase) * bassLevel * 2;
particle.size = 2 + bassLevel * 3;
}
// React to treble
if (particle.frequencyIndex > this.bufferLength * 0.7) {
particle.size = 1 + trebleLevel * 2;
}
// Wrap around
if (particle.x < 0) particle.x = this.width;
if (particle.x > this.width) particle.x = 0;
if (particle.y < 0) particle.y = this.height;
if (particle.y > this.height) particle.y = 0;
// Draw particle
this.ctx.beginPath();
this.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
const hue = (particle.frequencyIndex / this.bufferLength) * 360;
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, ${0.7 + freqValue * 0.3})`;
this.ctx.fill();
});
}
drawLiquidVisualisation() {
const bassLevel = this.getBassLevel();
const midLevel = this.getMidLevel();
// Update liquid points
this.liquidPoints.forEach((point, i) => {
const freqIndex = Math.floor((i / this.liquidPoints.length) * this.bufferLength);
const targetY = this.height * 0.5 - (this.frequencyData[freqIndex] / 255) * 200 * this.intensity;
// Add bass vibration
const bassVibration = Math.sin(Date.now() * 0.003 + i * 0.1) * bassLevel * 50;
const targetYWithBass = targetY + bassVibration;
// Smooth movement
point.vy += (targetYWithBass - point.y) * 0.1;
point.vy *= 0.9;
point.y += point.vy;
});
// Draw liquid surface
this.ctx.beginPath();
this.ctx.moveTo(0, this.height);
for (let i = 0; i < this.liquidPoints.length; i++) {
const point = this.liquidPoints[i];
if (i === 0) {
this.ctx.lineTo(point.x, point.y);
} else {
const prevPoint = this.liquidPoints[i-1];
const cpX = (prevPoint.x + point.x) / 2;
const cpY = (prevPoint.y + point.y) / 2;
this.ctx.quadraticCurveTo(prevPoint.x, prevPoint.y, cpX, cpY);
}
}
this.ctx.lineTo(this.width, this.height);
this.ctx.closePath();
// Fill with gradient
const gradient = this.ctx.createLinearGradient(0, 0, 0, this.height);
gradient.addColorStop(0, this.currentColours.primary + '80');
gradient.addColorStop(1, this.currentColours.secondary + '40');
this.ctx.fillStyle = gradient;
this.ctx.fill();
// Draw surface line
this.ctx.beginPath();
this.ctx.moveTo(0, this.liquidPoints[0].y);
for (let i = 1; i < this.liquidPoints.length; i++) {
const point = this.liquidPoints[i];
this.ctx.lineTo(point.x, point.y);
}
this.ctx.strokeStyle = this.currentColours.accent;
this.ctx.lineWidth = 2 + midLevel * 3;
this.ctx.stroke();
}
}
// Initialise when page loads
window.addEventListener('load', () => {
new EnhancedAudioVisualiser();
});
</script>
</body>
</html>