Spaces:
Sleeping
Sleeping
| <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">×</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">𝅘𝅥𝅮</button> | |
| <button class="choice-button note" data-value="1">𝅘𝅥</button> | |
| <button class="choice-button note" data-value="2">𝅗𝅥</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> |