loop_maestro / index.html
jorisvaneyghen's picture
refactor bars data struct; first and last bar fixing
34bac53
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Upload a song, detect beats and bars, then loop any part">
<title>Loop Maestro</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<link rel="shortcut icon" href="/favicon.ico"/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
<link rel="manifest" href="/site.webmanifest"/>
<link rel="stylesheet" href="css/styles.css"/>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js"></script>
</head>
<body>
<div class="container">
<header>
<div class="header-top">
<h1>Loop Maestro</h1>
<button id="menuButton" class="menu-button" aria-label="About this app">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</button>
</div>
<p class="subtitle">Upload a song, detect beats and bars, then loop any part</p>
</header>
<div id="appMenu" class="app-menu" style="top:0; left:0">
<div class="menu-item" data-action="about">About Loop Maestro</div>
<div class="menu-item" data-action="recent">Recent Songs</div>
</div>
<div id="recentFilesPopup" class="popup-overlay">
<div class="recent-files-content">
<div class="recent-files-header">
<h3>🎵 Recent Songs</h3>
<button id="closeRecentMenu" class="close-recent-menu">×</button>
</div>
<div id="recentFilesBody" class="recent-files-body">
<div style="font-size: 3rem; margin-bottom: 10px;">🎵</div>
<p style="margin: 0; font-size: 1.1rem;">No recent files</p>
<p style="margin: 10px 0 0 0; font-size: 0.9rem; opacity: 0.7;">Files you open will appear here</p>
</div>
</div>
</div>
<!-- Info Popup -->
<div id="infoPopup" class="popup-overlay">
<div class="info-popup-content">
<div class="info-popup-header">
<h2>About Loop Maestro</h2>
<button id="closePopup" class="close-button">&times;</button>
</div>
<div class="info-popup-body">
<p><strong>Loop Maestro</strong> is a Progressive Web App that allows you to:</p>
<ul>
<li>Upload audio files</li>
<li>Automatically detect beats and bars</li>
<li>Loop any section of your audio</li>
<li>Adjust playback parameters in real-time</li>
</ul>
<p>Simply upload a song, wait for beat detection to complete, and start looping!</p>
<div class="tech-details">
<p>Loop Maestro leverages cutting-edge audio processing technology:</p>
<ul>
<li><strong>Beat Detection:</strong> Powered by the AI model <a
href="https://github.com/CPJKU/beat_this">"Beat This!"</a> for precise beat and bar
detection
</li>
<li><strong>Time & Pitch Processing:</strong> Utilizes the <a
href="https://signalsmith-audio.co.uk">Signalsmith Stretch Library</a> for high-quality
time stretching and pitch shifting
</li>
</ul>
</div>
<div class="popup-footer">
<small>Made with ❤️ for musicians</small>
</div>
</div>
</div>
</div>
<div class="app-grid">
<div class="card">
<h2>Upload & Loop any part</h2>
<div id="serverStatus" class="server-status" style="display: none;">
Checking server status...
</div>
<!-- Initialization Progress -->
<div id="initProgress" class="init-progress">
<h3>Initializing Beat Detector</h3>
<div class="progress-bar">
<div id="initProgressFill" class="progress-fill"></div>
</div>
<div class="progress-text">
<span id="initProgressText">0%</span>
<span id="initProgressMessage">Loading models...</span>
</div>
</div>
<!-- Initialization Complete Message -->
<div id="initComplete" class="init-complete">
✓ Beat detector initialized successfully! You can now upload audio files.
</div>
<div id="uploadArea" class="upload-area disabled">
<div class="upload-icon">🎵</div>
<p>Initializing beat detector... Please wait</p>
<input type="file" id="audioFile" class="file-input" accept="audio/*" disabled>
</div>
<div id="fileInfo" class="file-info" style="margin-bottom: 10px"></div>
<div id="loading" class="loading" style="display: none;">
<h3>Detecting Beats</h3>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="progress-text">
<span id="progressText">0%</span>
<span id="progressMessage">Initializing...</span>
</div>
<button id="cancelButton" class="cancel-button">Cancel</button>
</div>
<div id="player-container"></div>
</div>
<div class="card">
<h2>Beat Detection Results</h2>
<div id="results" class="results">
<div class="results-grid">
<div class="result-item">
<div class="result-label">Estimated BPM</div>
<div id="estimatedBPM" class="result-value">--</div>
</div>
<div class="result-item">
<div class="result-label">Detected Beats Per Bar</div>
<div id="detectedBeatsPerBar" class="result-value">--</div>
</div>
</div>
<div id="barsResults" style="display: none; margin-top: 20px;">
<h3>Detected Bars</h3>
<div class="bars-list" id="barsList"></div>
<div class="section-player">
<h3>Play Section</h3>
<div class="bar-selection">
<div class="form-group">
<label for="startBar">Start Bar</label>
<div class="button-input-group">
<button id="prevBar" class="action-button" style="padding: 8px 12px;"></button>
<input type="number" id="startBar" value="0" min="0" style="text-align: center;">
<button id="nextBar" class="action-button" style="padding: 8px 12px;"></button>
<button id="countIn" class="choice-button" data-value="0">
<div class="count-in-container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4.9237001 2"
width="32" height="13">
<rect style="fill:currentColor" ry="0.08" height="2" width="0.2"
y="0" x="0"/>
<rect style="fill:currentColor" ry="0.08" height="2" width="0.2"
y="0" x="4.7237"/>
<rect style="fill:currentColor" ry="0.08" height="0.66"
width="4.5237" y="0.67" x="0.2"/>
</svg>
<span class="count-in-number"></span>
</div>
</button>
</div>
</div>
<div class="form-group">
<label>#Bars to Play</label>
<div class="button-input-group">
<div class="button-group">
<button class="choice-button" data-value="1">1</button>
<button class="choice-button" data-value="2">2</button>
<button class="choice-button active" data-value="4">4</button>
<button class="choice-button" data-value="8">8</button>
<button class="choice-button" data-value="16">16</button>
</div>
<p>...</p>
<input type="number" id="barsToPlay" value="4" min="1" max="1000"
class="custom-input">
</div>
</div>
<div class="form-group">
<label>Step Size (#bars)</label>
<div class="button-input-group">
<div class="button-group">
<button class="choice-button" data-value="1">1</button>
<button class="choice-button active" data-value="2">2</button>
<button class="choice-button" data-value="4">4</button>
<button class="choice-button" data-value="8">8</button>
<button class="choice-button" data-value="16">16</button>
</div>
<p>...</p>
<input type="number" id="stepSize" value="2" min="1" max="32" class="custom-input">
</div>
</div>
<div class="form-group">
<label>Upbeat</label>
<div class="button-input-group">
<div class="button-group">
<button class="choice-button note" data-value="0.5">&#x1D160;</button>
<button class="choice-button note" data-value="1">&#x1D15F;</button>
<button class="choice-button note" data-value="2">&#x1D15E;</button>
</div>
<p>...</p>
<input type="number" id="upbeat" value="0" min="0" max="3.5" step="0.05"
class="custom-input">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module">
// Check if the browser supports service workers
if ('serviceWorker' in navigator) {
// Wait for the window to load before registering
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch((error) => {
// Registration failed
console.log('ServiceWorker registration failed: ', error);
});
});
}
import AudioStretchPlayer from '/js/AudioStretchPlayer.js';
import BeatDetector from '/js/BeatDetector.js';
class BeatDetectionApp {
constructor() {
this.detector = new BeatDetector();
this.isProcessing = false;
this.startTime = null;
this.audioBuffer_22050 = null;
this.audioBuffer = null;
this.claves_low_audio_buffer = null;
this.claves_high_audio_buffer = null;
this.audioContext = null;
this.audioContext_22050 = null;
this.logits = null;
this.bars = null;
this.estimatedBPM = null;
this.detectedBeatsPerBar = null;
// New properties for AudioStretchPlayer and bar navigation
this.audioPlayer = null;
this.currentStartBar = 0;
this.barsToPlay = 4;
this.stepSize = 2;
this.upbeat = 0;
this.countIn = 0;
this.barsMap = {};
// Cache storage key
this.CACHE_STORAGE_KEY = 'beatDetectionCache';
this.cache = null;
// File System Access API and IndexedDB
this.RECENT_FILES_KEY = 'recentAudioFiles';
this.MAX_RECENT_FILES = 10;
this.recentFiles = [];
this.init();
}
async init() {
// Load cache and recent files first
await this.loadCache();
await this.loadRecentFiles();
// Show initialization progress
this.showInitProgress();
let pyodide = await loadPyodide();
// Initialize the detector with progress updates
const success = await this.detector.init(pyodide, this.updateInitProgress.bind(this));
this.audioContext = new AudioContext({sampleRate: 44100});
const response_claves_low = await fetch('/claves_low.mp3');
const arrayBuffer_claves_low = await response_claves_low.arrayBuffer();
this.claves_low_audio_buffer = await this.audioContext.decodeAudioData(arrayBuffer_claves_low);
const response_claves_high = await fetch('/claves_high.mp3');
const arrayBuffer_claves_high = await response_claves_high.arrayBuffer();
this.claves_high_audio_buffer = await this.audioContext.decodeAudioData(arrayBuffer_claves_high);
if (success) {
this.hideInitProgress();
this.enableUploadComponent();
this.setupEventListeners();
console.log("App initialized successfully");
} else {
this.showInitError();
}
}
// IndexedDB for file handles
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('LoopMaestroDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('fileHandles')) {
db.createObjectStore('fileHandles', {keyPath: 'id'});
}
};
});
}
async saveFileHandle(fileHandle, fileName, fileSize) {
try {
const db = await this.openDB();
const transaction = db.transaction(['fileHandles'], 'readwrite');
const store = transaction.objectStore('fileHandles');
const fileRecord = {
id: `${fileName}_${fileSize}_${Date.now()}`,
handle: fileHandle,
fileName: fileName,
fileSize: fileSize,
lastAccessed: Date.now()
};
await store.put(fileRecord);
// Also add to recent files list
await this.addToRecentFiles(fileRecord);
return fileRecord.id;
} catch (error) {
console.error('Error saving file handle:', error);
throw error;
}
}
async getFileHandle(id) {
try {
const db = await this.openDB();
const transaction = db.transaction(['fileHandles'], 'readonly');
const store = transaction.objectStore('fileHandles');
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
} catch (error) {
console.error('Error getting file handle:', error);
throw error;
}
}
async getAllFileHandles() {
try {
const db = await this.openDB();
const transaction = db.transaction(['fileHandles'], 'readonly');
const store = transaction.objectStore('fileHandles');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
} catch (error) {
console.error('Error getting all file handles:', error);
throw error;
}
}
// Recent files management
async loadRecentFiles() {
try {
const recent = localStorage.getItem(this.RECENT_FILES_KEY);
this.recentFiles = recent ? JSON.parse(recent) : [];
} catch (error) {
console.error('Error loading recent files:', error);
this.recentFiles = [];
}
}
async saveRecentFiles() {
try {
localStorage.setItem(this.RECENT_FILES_KEY, JSON.stringify(this.recentFiles));
} catch (error) {
console.error('Error saving recent files:', error);
}
}
async addToRecentFiles(fileRecord) {
// Remove if already exists
this.recentFiles = this.recentFiles.filter(file =>
file.id !== fileRecord.id
);
// Add to beginning
this.recentFiles.unshift({
id: fileRecord.id,
fileName: fileRecord.fileName,
fileSize: fileRecord.fileSize,
lastAccessed: fileRecord.lastAccessed
});
// Keep only recent files
if (this.recentFiles.length > this.MAX_RECENT_FILES) {
this.recentFiles = this.recentFiles.slice(0, this.MAX_RECENT_FILES);
}
await this.saveRecentFiles();
}
async removeFromRecentFiles(fileId) {
this.recentFiles = this.recentFiles.filter(file => file.id !== fileId);
await this.saveRecentFiles();
}
// Cache management methods (existing, keep as is)
async loadCache() {
try {
const cached = localStorage.getItem(this.CACHE_STORAGE_KEY);
this.cache = cached ? JSON.parse(cached) : {};
console.log('Loaded cache with', Object.keys(this.cache).length, 'entries');
} catch (error) {
console.error('Error loading cache:', error);
this.cache = {};
}
}
async saveCache() {
try {
localStorage.setItem(this.CACHE_STORAGE_KEY, JSON.stringify(this.cache));
} catch (error) {
console.error('Error saving cache:', error);
}
}
generateFileKey(file) {
// Create a unique key based on file name, size, and last modified date
return `${file.name}_${file.size}_${file.lastModified}`;
}
getCachedResults(file) {
const fileKey = this.generateFileKey(file);
const cachedResults = this.cache[fileKey]
//check bars is an array:
if (cachedResults && Array.isArray(cachedResults.bars)){
return cachedResults;
} else{
return null;
}
}
async cacheResults(file, results) {
const fileKey = this.generateFileKey(file);
this.cache[fileKey] = {
filename: file.name,
fileSize: file.size,
lastModified: file.lastModified,
bars: results.bars,
estimatedBPM: results.estimated_bpm,
detectedBeatsPerBar: results.detected_beats_per_bar,
timestamp: Date.now()
};
// Clean up old cache entries (keep only last 100 files)
this.cleanupCache();
await this.saveCache();
console.log('Results cached for file:', file.name);
}
cleanupCache() {
const entries = Object.entries(this.cache);
if (entries.length > 100) {
// Sort by timestamp and remove oldest entries
const sorted = entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = sorted.slice(0, sorted.length - 20);
toRemove.forEach(([key]) => {
delete this.cache[key];
});
console.log('Cleaned up cache, removed', toRemove.length, 'old entries');
}
}
showInitProgress() {
const initProgress = document.getElementById('initProgress');
initProgress.style.display = 'block';
const uploadArea = document.getElementById('uploadArea');
uploadArea.classList.add('disabled');
uploadArea.querySelector('p').textContent = 'Initializing beat detector... Please wait';
}
updateInitProgress(percent, message) {
const initProgressFill = document.getElementById('initProgressFill');
const initProgressText = document.getElementById('initProgressText');
const initProgressMessage = document.getElementById('initProgressMessage');
initProgressFill.style.width = `${percent}%`;
initProgressText.textContent = `${percent}%`;
initProgressMessage.textContent = message;
}
hideInitProgress() {
const initProgress = document.getElementById('initProgress');
const initComplete = document.getElementById('initComplete');
initProgress.style.display = 'none';
initComplete.style.display = 'block';
}
showInitError() {
const initProgress = document.getElementById('initProgress');
const initProgressFill = document.getElementById('initProgressFill');
const initProgressMessage = document.getElementById('initProgressMessage');
initProgressFill.style.background = '#f44336';
initProgressMessage.textContent = 'Failed to initialize beat detector. Please refresh the page.';
}
enableUploadComponent() {
const uploadArea = document.getElementById('uploadArea');
uploadArea.classList.remove('disabled');
uploadArea.querySelector('p').textContent = 'Click to upload or drag and drop an audio file';
}
setupEventListeners() {
const uploadArea = document.getElementById('uploadArea');
const cancelButton = document.getElementById('cancelButton');
const prevBarButton = document.getElementById('prevBar');
const nextBarButton = document.getElementById('nextBar');
const barsToPlayInput = document.getElementById('barsToPlay');
const stepSizeInput = document.getElementById('stepSize');
const startBarInput = document.getElementById('startBar');
const upbeatInput = document.getElementById('upbeat');
const countInButton = document.getElementById('countIn');
// File System Access API for file selection
uploadArea.addEventListener('click', async () => {
if ('showOpenFilePicker' in window) {
try {
await this.openFileWithFileSystemAPI();
} catch (error) {
// Check if the error is due to user cancellation (AbortError)
if (error.name === 'AbortError') {
console.log('File selection was canceled by user');
// Don't fall back - just return silently
return;
}
console.warn('File System Access API failed, falling back to traditional file input:', error);
// Fallback to traditional file input
this.openFileWithTraditionalInput();
}
} else {
// Fallback to traditional file input
this.openFileWithTraditionalInput();
}
});
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleAudioFile(files[0]);
}
});
cancelButton.addEventListener('click', () => this.cancelProcessing());
// Count In button
countInButton.addEventListener('click', (e) => {
const button = e.currentTarget;
const value = parseInt(button.dataset.value);
const number = button.querySelector('.count-in-number');
if (value === 0) {
this.countIn = 2;
number.textContent = '²';
button.dataset.value = '2';
} else if (value === 2) {
this.countIn = 1;
number.textContent = '¹';
button.dataset.value = '1';
} else if (value === 1) {
this.countIn = 0;
number.textContent = '';
button.dataset.value = '0';
}
button.classList.toggle('active', this.countIn > 0);
this.updateAudioPlayer();
});
// Bars to play buttons
document.querySelectorAll('.button-group .choice-button[data-value]').forEach(button => {
if (button.closest('.form-group:nth-child(2)')) { // Bars to play group
button.addEventListener('click', (e) => {
const value = parseInt(e.target.dataset.value);
this.barsToPlay = value;
// Update active state
e.target.closest('.button-group').querySelectorAll('.choice-button').forEach(btn => {
btn.classList.remove('active');
});
e.target.classList.add('active');
// Update input field
const barsToPlayInput = document.getElementById('barsToPlay');
barsToPlayInput.value = value;
barsToPlayInput.classList.remove('active');
this.updateAudioPlayer();
});
}
});
// Step size buttons
document.querySelectorAll('.button-group .choice-button[data-value]').forEach(button => {
if (button.closest('.form-group:nth-child(3)')) { // Step size group
button.addEventListener('click', (e) => {
const value = parseInt(e.target.dataset.value);
this.stepSize = value;
// Update active state
e.target.closest('.button-group').querySelectorAll('.choice-button').forEach(btn => {
btn.classList.remove('active');
});
e.target.classList.add('active');
// Update input field
const stepSizeInput = document.getElementById('stepSize');
stepSizeInput.value = value;
stepSizeInput.classList.remove('active');
});
}
});
// Upbeat buttons - multi-select
document.querySelectorAll('.button-group .choice-button[data-value]').forEach(button => {
if (button.closest('.form-group:nth-child(4)')) { // Upbeat group
button.addEventListener('click', (e) => {
const buttonGroup = e.target.closest('.button-group');
const buttons = buttonGroup.querySelectorAll('.choice-button');
// Toggle the clicked button
e.target.classList.toggle('active');
// Get all active buttons and calculate total value
const activeButtons = buttonGroup.querySelectorAll('.choice-button.active');
const totalValue = this.calculateUpbeatValue(activeButtons);
// Update the value and display
this.updateUpbeatDisplay.call(this, totalValue, false);
// Ensure custom input is not active when using buttons
upbeatInput.classList.remove('active');
this.updateAudioPlayer();
});
}
});
// Custom input listeners
barsToPlayInput.addEventListener('change', (e) => {
const value = parseInt(e.target.value);
this.barsToPlay = value;
const buttonGroup = barsToPlayInput.closest('.button-input-group').querySelector('.button-group');
const buttons = buttonGroup.querySelectorAll('.choice-button');
// Check if any button matches the current value
const isCustom = !Array.from(buttons).some(btn =>
parseInt(btn.dataset.value) === value
);
// Update button states
buttons.forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
});
barsToPlayInput.classList.toggle('active', isCustom);
this.updateAudioPlayer();
});
stepSizeInput.addEventListener('change', (e) => {
const value = parseInt(e.target.value);
this.stepSize = value;
// Update button active state
const buttonGroup = stepSizeInput.closest('.button-input-group').querySelector('.button-group');
const buttons = buttonGroup.querySelectorAll('.choice-button');
// Check if any button matches the current value
const isCustom = !Array.from(buttons).some(btn =>
parseInt(btn.dataset.value) === value
);
// Update button states
buttons.forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
});
stepSizeInput.classList.toggle('active', isCustom);
});
upbeatInput.addEventListener('change', (e) => {
const value = parseFloat(e.target.value);
const buttonGroup = upbeatInput.closest('.button-input-group').querySelector('.button-group');
const buttons = buttonGroup.querySelectorAll('.choice-button');
// Check if value matches one of the predefined combinations
const predefinedValues = {
0: [],
0.5: ['0.5'],
1: ['1'],
1.5: ['0.5', '1'],
2: ['2'],
2.5: ['0.5', '2'],
3: ['1', '2'],
3.5: ['0.5', '1', '2']
};
// Check if value is a predefined combination
const isPredefinedValue = Object.keys(predefinedValues).some(key => parseFloat(key) === value);
if (isPredefinedValue) {
// Get the button values that should be active for this predefined value
const activeButtonValues = predefinedValues[value];
// Update button states
buttons.forEach(btn => {
const shouldBeActive = activeButtonValues.includes(btn.dataset.value);
btn.classList.toggle('active', shouldBeActive);
});
// Deactivate custom input since we're using predefined combination
this.updateUpbeatDisplay.call(this, value, false);
upbeatInput.classList.remove('active');
} else {
// Custom value - deselect all buttons and activate custom input
buttons.forEach(btn => {
btn.classList.remove('active');
});
this.updateUpbeatDisplay.call(this, value, true);
}
this.updateAudioPlayer();
});
prevBarButton.addEventListener('click', () => this.previousBar());
nextBarButton.addEventListener('click', () => this.nextBar());
startBarInput.addEventListener('change', (e) => {
let barNum = parseInt(e.target.value)
this.currentStartBar = this.barsMap[barNum];
this.updateAudioPlayer();
});
// Add click handler for bars list
document.addEventListener('click', (e) => {
if (e.target.closest('.bar-item')) {
const barItem = e.target.closest('.bar-item');
const barIndex = Array.from(barItem.parentNode.children).indexOf(barItem);
this.selectBar(barIndex);
}
});
document.addEventListener('dblclick', (e) => {
if (e.target.closest('.bar-item')) {
const barItem = e.target.closest('.bar-item');
const barIndex = Array.from(barItem.parentNode.children).indexOf(barItem);
this.selectBar(barIndex);
this.audioPlayer.play()
}
});
// Enhanced Info Popup functionality with menu
this.menuButton = document.getElementById('menuButton');
this.appMenu = document.getElementById('appMenu');
this.infoPopup = document.getElementById('infoPopup');
const closePopup = document.getElementById('closePopup');
this.recentFilesPopup = document.getElementById('recentFilesPopup');
const closeRecentMenu = document.getElementById('closeRecentMenu');
// Menu item events
const menuItems = this.appMenu.querySelectorAll('.menu-item');
menuItems.forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
this.handleMenuAction(item.dataset.action);
this.hideAppMenu();
});
});
// Backdrop for closing
this.recentFilesPopup.addEventListener('click', () => {
this.hideAppMenu();
});
// Open popup
this.menuButton.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleAppMenu();
});
// Close popup
closePopup.addEventListener('click', function () {
document.getElementById('infoPopup').classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
});
closeRecentMenu.addEventListener('click', function () {
document.getElementById('recentFilesPopup').classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
});
// Close popup when clicking outside content
this.infoPopup.addEventListener('click', function (e) {
const infoPopup = document.getElementById('infoPopup')
if (e.target === infoPopup) {
infoPopup.classList.remove('active');
document.body.style.overflow = '';
}
});
// Close popup when clicking outside content
this.recentFilesPopup.addEventListener('click', function (e) {
const recentFilesPopup = document.getElementById('recentFilesPopup')
if (e.target === recentFilesPopup) {
recentFilesPopup.classList.remove('active');
document.body.style.overflow = '';
}
});
// Close popup and menu with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.infoPopup.classList.remove('active');
document.body.style.overflow = '';
this.hideAppMenu();
}
});
// Close menu when clicking outside
document.addEventListener('click', () => {
this.hideAppMenu();
});
}
toggleAppMenu() {
if (this.appMenu.style.display === 'block') {
this.hideAppMenu();
} else {
this.showAppMenu();
}
}
showAppMenu() {
const rect = this.menuButton.getBoundingClientRect();
const menuWidth = 150;
// Position menu in final location
this.appMenu.style.top = `${rect.bottom + window.scrollY}px`;
this.appMenu.style.left = `${rect.right - menuWidth}px`;
appMenu.classList.add('active');
}
hideAppMenu() {
this.appMenu.classList.remove('active');
}
async handleMenuAction(action) {
this.hideAppMenu();
switch (action) {
case 'about':
this.infoPopup.classList.add('active');
document.body.style.overflow = 'hidden';
break;
case 'recent':
await this.showRecentFilesMenu();
break;
}
}
// Helper function to calculate total value from selected buttons
calculateUpbeatValue(selectedButtons) {
return Array.from(selectedButtons).reduce((total, btn) => {
return total + parseFloat(btn.dataset.value);
}, 0);
}
// Helper function to update the display
updateUpbeatDisplay(value, isCustom = false) {
this.upbeat = value;
const upbeatInput = document.getElementById('upbeat');
upbeatInput.value = value;
// Update custom input active state
upbeatInput.classList.toggle('active', isCustom);
}
async showRecentFilesMenu() {
await this.loadRecentFiles();
const recentFilesBody = document.getElementById('recentFilesBody');
// Clear existing content first
recentFilesBody.innerHTML = '';
if (this.recentFiles.length === 0) {
recentFilesBody.innerHTML = `
<div style="text-align: center; padding: 40px 20px; opacity: 0.8;">
<div style="font-size: 3rem; margin-bottom: 10px;">🎵</div>
<p style="margin: 0; font-size: 1.1rem;">No recent files</p>
<p style="margin: 10px 0 0 0; font-size: 0.9rem; opacity: 0.7;">Files you open will appear here</p>
</div>
`;
} else {
let filesHTML = '<div class="recent-files-list" style="display: flex; flex-direction: column; gap: 10px;">';
for (const file of this.recentFiles) {
filesHTML += `
<div class="recent-file-item" data-file-id="${file.id}">
<div style="flex: 1;">
<div style="font-weight: bold; font-size: 1rem; margin-bottom: 4px;">${file.fileName}</div>
<div style="font-size: 0.8rem; opacity: 0.8;">${this.formatFileSize(file.fileSize)}</div>
</div>
<button class="remove-recent-file">Remove</button>
</div>
`;
}
filesHTML += '</div>';
recentFilesBody.innerHTML = filesHTML;
}
// Load file when clicked
recentFilesBody.querySelectorAll('.recent-file-item').forEach(item => {
item.addEventListener('click', async (e) => {
if (!e.target.classList.contains('remove-recent-file')) {
const fileId = item.dataset.fileId;
await this.loadFileFromHandle(fileId);
this.recentFilesPopup.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
});
});
// Remove file when remove button clicked
recentFilesBody.querySelectorAll('.remove-recent-file').forEach(button => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
const fileId = button.closest('.recent-file-item').dataset.fileId;
await this.removeFromRecentFiles(fileId);
await this.showRecentFilesMenu(); // Refresh the menu
});
});
this.recentFilesPopup.classList.add('active');
document.body.style.overflow = 'hidden';
}
// handle the traditional file input
openFileWithTraditionalInput() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'audio/*';
fileInput.onchange = (e) => {
if (e.target.files.length > 0) {
this.handleAudioFile(e.target.files[0]);
}
};
fileInput.click();
}
async openFileWithFileSystemAPI() {
const [fileHandle] = await window.showOpenFilePicker({
types: [{
description: 'Audio Files',
accept: {
'audio/*': ['.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a']
}
}],
multiple: false
});
const file = await fileHandle.getFile();
const fileId = await this.saveFileHandle(fileHandle, file.name, file.size);
await this.handleAudioFile(file, fileId);
}
async loadFileFromHandle(fileId) {
try {
const fileRecord = await this.getFileHandle(fileId);
if (!fileRecord) {
throw new Error('File not found in database');
}
// Verify we still have permission to read the file
if (await fileRecord.handle.queryPermission({mode: 'read'}) !== 'granted') {
const permission = await fileRecord.handle.requestPermission({mode: 'read'});
if (permission !== 'granted') {
throw new Error('Permission denied to read the file');
}
}
const file = await fileRecord.handle.getFile();
// Update last accessed time
await this.addToRecentFiles(fileRecord);
await this.handleAudioFile(file, fileId);
} catch (error) {
console.error('Error loading file from handle:', error);
alert('Error loading file. It may have been moved or deleted.');
// Remove from recent files if there's an error
await this.removeFromRecentFiles(fileId);
}
}
async handleAudioFile(file, fileId = null) {
if (this.isProcessing) {
alert('Already processing a file. Please wait.');
return;
}
// Check for cached results first
const cachedResults = this.getCachedResults(file);
if (cachedResults) {
console.log('Using cached results for file:', file.name);
await this.loadFromCache(file, cachedResults);
return;
}
this.isProcessing = true;
this.startTime = Date.now();
const loading = document.getElementById('loading');
const results = document.getElementById('results');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const progressMessage = document.getElementById('progressMessage');
const fileInfo = document.getElementById('fileInfo');
// Show loading with initial state
loading.style.display = 'block';
results.style.display = 'none';
progressFill.style.width = '0%';
progressFill.style.background = 'linear-gradient(90deg, #4CAF50, #45a049)';
progressText.textContent = '0%';
progressMessage.textContent = 'Detecting beats ...';
// Show file info
fileInfo.textContent = `File: ${file.name} (${this.formatFileSize(file.size)})`;
try {
const updateProgress = async (percent, message) => {
// Clamp percentage to ensure it stays within valid range
const clampedPercent = Math.max(0, Math.min(100, percent));
const currentTime = Date.now();
const elapsed = (currentTime - this.startTime) / 1000;
// Add time estimation (only when we have meaningful progress)
let enhancedMessage = message;
if (clampedPercent > 5 && clampedPercent < 100 && elapsed > 0.5) {
const estimatedTotal = (elapsed / clampedPercent) * 100;
const remaining = Math.max(0, estimatedTotal - elapsed);
enhancedMessage += ` | Est. ${Math.ceil(remaining)}s remaining`;
}
// Update UI elements atomically
progressFill.style.width = `${clampedPercent}%`;
progressText.textContent = `${Math.round(clampedPercent)}%`;
progressMessage.textContent = enhancedMessage;
// Force synchronous layout and repaint
void progressFill.offsetWidth; // Trigger reflow
// Yield to main thread more effectively
await new Promise(resolve => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(resolve);
} else {
setTimeout(resolve, 16); // ~60fps
}
});
};
const arrayBuffer = await file.arrayBuffer();
this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
//create audioBuffer with SR=22050 for beat detection
const arrayBuffer_22050 = await file.arrayBuffer();
this.audioContext_22050 = new AudioContext({sampleRate: 22050});
this.audioBuffer_22050 = await this.audioContext_22050.decodeAudioData(arrayBuffer_22050);
await updateProgress(5, 'Starting beat detection...');
this.logits = await this.detector.processAudio(
this.audioBuffer_22050,
updateProgress
);
this.audioContext_22050 = null;
this.audioBuffer_22050 = null;
// Automatically run logits_to_bars after preprocessing is complete
await updateProgress(95, 'Running bar detection...');
await this.runAutomaticBarDetection();
// Cache the results for future use
await this.cacheResults(file, {
bars: this.bars,
estimated_bpm: this.estimatedBPM,
detected_beats_per_bar: this.detectedBeatsPerBar
});
// Save file handle if using File System API
if (!fileId && 'showOpenFilePicker' in window) {
// This was a drag/drop or fallback upload, so we can't save a handle
console.log('File uploaded via drag/drop or fallback - no file handle to save');
}
// Update UI with results
this.updateResultsUI();
// Show results section
results.style.display = 'block';
} catch (error) {
console.error("Error processing audio:", error);
if (error.message === "Processing cancelled") {
progressMessage.textContent = "Processing cancelled by user";
} else {
progressMessage.textContent = `Error: ${error.message}`;
}
progressFill.style.background = '#f44336';
// Keep error visible for a bit before hiding
setTimeout(() => {
loading.style.display = 'none';
}, 3000);
} finally {
this.isProcessing = false;
// Don't immediately hide loading - let user see completion for successful processing
if (!this.detector.isCancelled) {
setTimeout(() => {
loading.style.display = 'none';
}, 1000);
}
}
}
async loadFromCache(file, cachedResults) {
const fileInfo = document.getElementById('fileInfo');
const results = document.getElementById('results');
// Show file info
fileInfo.textContent = `File: ${file.name} (${this.formatFileSize(file.size)})`;
// Load the audio file for playback (we still need the audio buffer)
try {
const arrayBuffer = await file.arrayBuffer();
this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
} catch (error) {
console.error("Error loading audio for cached file:", error);
return;
}
// Set the cached results
this.bars = cachedResults.bars;
this.estimatedBPM = cachedResults.estimatedBPM;
this.detectedBeatsPerBar = cachedResults.detectedBeatsPerBar;
// Update UI with cached results
this.updateResultsUI();
// Show results section
results.style.display = 'block';
console.log('Loaded from cache:', cachedResults);
}
async runAutomaticBarDetection() {
const minBPM = 55.0;
const maxBPM = 215.0;
const beatsPerBar = 4;
try {
const result = await this.detector.logits_to_bars(
this.logits.prediction_beat,
this.logits.prediction_downbeat,
minBPM,
maxBPM,
beatsPerBar
);
this.bars = result.bars;
this.estimatedBPM = result.estimated_bpm;
this.detectedBeatsPerBar = result.detected_beats_per_bar;
console.log(`Automatic bar detection: BPM=${this.estimatedBPM}, BeatsPerBar=${this.detectedBeatsPerBar}`);
} catch (error) {
console.error('Error in automatic bar detection:', error);
// Set default values if detection fails
this.estimatedBPM = null;
this.detectedBeatsPerBar = null;
}
}
updateResultsUI() {
// Update estimated BPM
const estimatedBPM = document.getElementById('estimatedBPM');
estimatedBPM.textContent = this.estimatedBPM !== null ? this.estimatedBPM.toFixed(1) : '--';
// Update detected beats per bar
const detectedBeatsPerBar = document.getElementById('detectedBeatsPerBar');
detectedBeatsPerBar.textContent = this.detectedBeatsPerBar !== null ? this.detectedBeatsPerBar : '--';
// Display bars if available and initialize destroy
if (this.bars && this.bars.length > 0) {
this.displayBars();
document.getElementById('barsResults').style.display = 'block';
// Initialize AudioStretchPlayer with the first segment
this.initializeAudioPlayer();
} else {
// If no bars detected but we have audio, still initialize the player with full audio
if (this.audioBuffer) {
this.initializeFullAudioPlayer();
}
}
}
// Add this new method to handle cases where no bars are detected
initializeFullAudioPlayer() {
if (!this.audioPlayer) {
this.audioPlayer = new AudioStretchPlayer(
document.getElementById('player-container'),
{
showUpload: false,
showControls: true
}
);
}
// Convert entire audio buffer to WAV and load into player
const wavBlob = this.audioBufferToWav(this.audioBuffer);
const audioUrl = URL.createObjectURL(wavBlob);
this.audioPlayer.loadAudioFromUrl(audioUrl);
}
displayBars() {
const barsList = document.getElementById('barsList');
barsList.innerHTML = '';
if (!this.bars || this.bars.length === 0) {
barsList.innerHTML = '<div class="bar-item">No bars detected</div>';
// Initialize player with full audio if no bars but audio exists
if (this.audioBuffer) {
this.initializeFullAudioPlayer();
}
return;
}
// Create barsMap that maps nb to idx for easy lookup
this.barsMap = {};
this.bars.forEach(bar => {
this.barsMap[bar.nb] = bar.idx;
});
// Update start bar input max value
const startBarInput = document.getElementById('startBar');
const maxStartBar = Math.max(1, this.bars.length + 1 - this.barsToPlay);
startBarInput.max = maxStartBar;
this.currentStartBar = 0;
startBarInput.value = 1;
// Display bars in list
this.bars.forEach((bar, index) => {
const barItem = document.createElement('div');
barItem.className = 'bar-item';
barItem.style.cursor = 'pointer';
barItem.style.padding = '8px';
barItem.style.borderRadius = '4px';
barItem.style.transition = 'background-color 0.2s';
barItem.innerHTML = `
<span>Bar ${bar.nb}</span>
<span>${this.formatTime(bar.start)}</span>
`;
// Highlight current selected bar
if (index === this.currentStartBar) {
barItem.style.backgroundColor = 'rgba(33, 150, 243, 0.3)';
}
barsList.appendChild(barItem);
});
// Show the bars results section
document.getElementById('barsResults').style.display = 'block';
// Initialize audio player with the first segment
this.initializeAudioPlayer();
}
async calculateBars() {
const minBPM = parseFloat(document.getElementById('minBPM').value);
const maxBPM = parseFloat(document.getElementById('maxBPM').value);
const beatsPerBar = parseInt(document.getElementById('beatsPerBar').value);
if (!this.logits) {
alert('Please process an audio file first');
return;
}
const calculateBarsButton = document.getElementById('calculateBars');
calculateBarsButton.disabled = true;
calculateBarsButton.textContent = 'Calculating...';
try {
const result = await this.detector.logits_to_bars(
this.logits.prediction_beat,
this.logits.prediction_downbeat,
minBPM,
maxBPM,
beatsPerBar
);
this.bars = result.bars;
this.estimatedBPM = result.estimated_bpm;
this.detectedBeatsPerBar = result.detected_beats_per_bar;
this.displayBars();
// Update the displayed BPM and beats per bar
const estimatedBPM = document.getElementById('estimatedBPM');
estimatedBPM.textContent = this.estimatedBPM !== null ? this.estimatedBPM.toFixed(1) : '--';
const detectedBeatsPerBar = document.getElementById('detectedBeatsPerBar');
detectedBeatsPerBar.textContent = this.detectedBeatsPerBar !== null ? this.detectedBeatsPerBar : '--';
// Show bars results section
document.getElementById('barsResults').style.display = 'block';
} catch (error) {
console.error('Error calculating bars:', error);
alert('Error calculating bars: ' + error.message);
} finally {
calculateBarsButton.disabled = false;
calculateBarsButton.textContent = 'Calculate Bars';
}
}
initializeAudioPlayer() {
if (!this.audioBuffer || this.bars.length === 0) return;
// Create or update the AudioStretchPlayer
if (!this.audioPlayer) {
this.audioPlayer = new AudioStretchPlayer(
document.getElementById('player-container'),
{
showUpload: false,
showControls: true
}
);
}
this.updateAudioPlayer();
}
updateAudioPlayer() {
if (!this.audioPlayer || !this.audioBuffer || this.bars.length === 0) return;
const startBar = this.currentStartBar;
const endBar = Math.min(startBar + this.barsToPlay - 1, this.bars.length - 1);
if (startBar >= this.bars.length || endBar >= this.bars.length) {
console.warn('Invalid bar selection');
return;
}
let startTime = this.bars[startBar].start;
let endTime = this.bars[endBar].end;
const duration = endTime - startTime;
// Calculate upbeat duration if upbeat is specified
if (this.upbeat !== 0) {
const durationFirstBar = this.bars[startBar].end - startTime;
const upbeatDuration = durationFirstBar * this.upbeat / this.detectedBeatsPerBar;
// Adjust startTime and endTime by subtracting the upbeatDuration
startTime = Math.max(0, startTime - upbeatDuration);
if (!this.countIn) {
endTime = Math.max(0, endTime - upbeatDuration);
}
console.log(`Upbeat adjustment: ${upbeatDuration.toFixed(2)}s applied`);
}
console.log(`Loading audio from ${startTime.toFixed(2)}s to ${endTime.toFixed(2)}s (${duration.toFixed(2)}s)`);
// Extract audio segment
const overlay = !this.countIn
let audioSegment = this.extractAudioSegment(startTime, endTime, overlay);
// Append countIn
if (this.countIn > 0) {
const nb_beats = (endBar + 1 - startBar) * this.detectedBeatsPerBar;
const beat_duration = duration / nb_beats;
audioSegment = this.appendCountIn(audioSegment, beat_duration, this.countIn);
}
// Convert to WAV and create blob URL
const wavBlob = this.audioBufferToWav(audioSegment);
const audioUrl = URL.createObjectURL(wavBlob);
// Update UI highlights
this.updateBarHighlights();
// Load the segment into the AudioStretchPlayer
this.audioPlayer.loadAudioFromUrl(audioUrl);
}
extractAudioSegment(startTime, endTime, overlay = true) {
const sampleRate = this.audioBuffer.sampleRate;
const startSample = Math.floor(startTime * sampleRate);
const endSample = Math.floor(endTime * sampleRate);
const segmentLength = endSample - startSample;
// 50ms fade length
const fadeMs = 0.050;
const fadeSamples = Math.floor(fadeMs * sampleRate);
const segmentBuffer = this.audioContext.createBuffer(
this.audioBuffer.numberOfChannels,
segmentLength,
sampleRate
);
for (let channel = 0; channel < this.audioBuffer.numberOfChannels; channel++) {
const sourceData = this.audioBuffer.getChannelData(channel);
const seg = segmentBuffer.getChannelData(channel);
// --- COPY BASE SEGMENT ---
for (let i = 0; i < segmentLength; i++) {
seg[i] = sourceData[startSample + i];
}
if (!overlay) continue;
// END overlay:
// segment[end-fade..end] crossfaded with original[startTime-fade..startTime]
for (let i = 0; i < fadeSamples; i++) {
const segIndex = segmentLength - fadeSamples + i;
const fadeIn = i / fadeSamples; // 0→1
const fadeOut = 1 - fadeIn; // 1→0
const originalIndex = startSample - fadeSamples + i;
if (originalIndex >= 0) {
seg[segIndex] =
seg[segIndex] * fadeOut +
sourceData[originalIndex] * fadeIn;
}
}
}
return segmentBuffer;
}
appendCountIn(segmentBuffer, beatDuration, nbCountInBars = 2) {
if (!this.claves_high_audio_buffer || !this.claves_low_audio_buffer) {
console.warn('Claves audio buffers not available for countIn');
return segmentBuffer;
}
const sampleRate = segmentBuffer.sampleRate;
const beatDurationSamples = Math.floor(beatDuration * sampleRate);
// Calculate number of beats to skip for upbeat
const beatsToSkip = this.upbeat > 0 ? parseInt(this.upbeat) : 0;
// Calculate total countIn beats
const totalCountInBeats = nbCountInBars * this.detectedBeatsPerBar - beatsToSkip;
if (totalCountInBeats <= 0) {
console.warn('No countIn beats to add after upbeat adjustment');
return segmentBuffer;
}
// Calculate the position where the original segment should starts
const positionOriginal = (nbCountInBars * this.detectedBeatsPerBar - this.upbeat) * beatDuration;
const positionOriginalSamples = Math.floor(positionOriginal * sampleRate);
// Create new buffer with countIn + original segment
const totalLength = positionOriginalSamples + segmentBuffer.length;
const newBuffer = this.audioContext.createBuffer(
segmentBuffer.numberOfChannels,
totalLength,
sampleRate
);
for (let channel = 0; channel < segmentBuffer.numberOfChannels; channel++) {
const originalData = segmentBuffer.getChannelData(channel);
const newData = newBuffer.getChannelData(channel);
let currentPosition = 0;
// Add countIn beats
for (let beat = 0; beat < totalCountInBeats; beat++) {
const isDownbeat = (beat % this.detectedBeatsPerBar) === 0;
const clavesBuffer = isDownbeat ? this.claves_high_audio_buffer : this.claves_low_audio_buffer;
// Copy claves sound at the beat position
const clavesData = clavesBuffer.getChannelData(Math.min(channel, clavesBuffer.numberOfChannels - 1));
const clavesLength = Math.min(clavesBuffer.length, beatDurationSamples);
for (let i = 0; i < clavesLength; i++) {
if (currentPosition + i < totalLength) {
newData[currentPosition + i] += clavesData[i];
}
}
currentPosition += beatDurationSamples;
}
// Copy original segment after countIn
currentPosition = positionOriginalSamples
for (let i = 0; i < originalData.length; i++) {
if (currentPosition + i < totalLength) {
newData[currentPosition + i] = originalData[i];
}
}
}
console.log(`Added ${totalCountInBeats} countIn beats (${nbCountInBars} bars, skipped ${beatsToSkip} upbeat beats)`);
return newBuffer;
}
selectBar(barIndex) {
if (barIndex >= 0 && barIndex < this.bars.length) {
this.currentStartBar = barIndex;
document.getElementById('startBar').value = this.bars[this.currentStartBar].nb;
this.updateAudioPlayer();
}
}
previousBar() {
const newStartBar = Math.max(0, this.currentStartBar - this.stepSize);
if (newStartBar !== this.currentStartBar) {
this.currentStartBar = newStartBar;
document.getElementById('startBar').value = this.bars[this.currentStartBar].nb;
this.updateAudioPlayer();
}
}
nextBar() {
const newStartBar = Math.min(
this.bars.length - this.barsToPlay,
this.currentStartBar + this.stepSize
);
if (newStartBar !== this.currentStartBar && newStartBar >= 0) {
this.currentStartBar = newStartBar;
document.getElementById('startBar').value = this.bars[this.currentStartBar].nb;
this.updateAudioPlayer();
}
}
updateBarHighlights() {
const barsList = document.getElementById('barsList');
const barItems = barsList.querySelectorAll('.bar-item');
barItems.forEach((item, index) => {
if (index === this.currentStartBar) {
item.style.backgroundColor = 'rgba(33, 150, 243, 0.3)';
// Scroll the highlighted item into view
item.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
} else {
item.style.backgroundColor = 'transparent';
}
});
}
audioBufferToWav(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const length = buffer.length;
const data = new Float32Array(length * numChannels);
for (let channel = 0; channel < numChannels; channel++) {
const channelData = buffer.getChannelData(channel);
for (let i = 0; i < length; i++) {
data[i * numChannels + channel] = channelData[i];
}
}
const wavBuffer = new ArrayBuffer(44 + data.length * 2);
const view = new DataView(wavBuffer);
// Write WAV header
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + data.length * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, data.length * 2, true);
// Convert to 16-bit PCM
let offset = 44;
for (let i = 0; i < data.length; i++) {
const sample = Math.max(-1, Math.min(1, data[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
return new Blob([wavBuffer], {type: 'audio/wav'});
}
formatTime(seconds) {
const absSeconds = Math.abs(seconds);
const mins = Math.floor(absSeconds / 60);
const secs = Math.floor(absSeconds % 60);
const ms = Math.floor((absSeconds * 1000) % 1000);
const sign = seconds < 0 ? '-' : '';
return `${sign}${mins}:${secs.toString().padStart(2, '0')}:${ms.toString().padStart(3, '0')}`;
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
cancelProcessing() {
//todo
return undefined;
}
}
// Initialize the app when page loads
window.addEventListener('load', () => {
new BeatDetectionApp();
});
</script>
</body>
</html>