import SignalsmithStretch from "/js/SignalsmithStretch.mjs"; export default class AudioStretchPlayer { constructor(container, options = {}) { this.container = container; this.options = { initialAudioUrl: options.initialAudioUrl || null, showUpload: options.showUpload !== false, showControls: options.showControls !== false, ...options }; this.audioContext = null; this.stretch = null; this.audioDuration = 1; this.playbackHeld = false; this.configTimeout = null; this.controlValuesInitial = { active: false, rate: 1, semitones: 0, tonalityHz: 8000, formantSemitones: 0, formantCompensation: false, formantBaseHz: 200, loopStart: 0, loopEnd: 0 }; this.controlValues = {...this.controlValuesInitial}; this.configValuesInitial = { blockMs: 120, overlap: 4, splitComputation: true }; this.configValues = {...this.configValuesInitial}; this.elements = {}; this.init(); } async init() { this.createHTML(); await this.initAudio(); this.setupEventListeners(); if (this.options.initialAudioUrl) { await this.loadAudioFromUrl(this.options.initialAudioUrl); } } createHTML() { this.container.innerHTML = `
0:00 0:00
`; // Cache DOM elements this.elements.playstop = this.container.querySelector('#playstop'); this.elements.playback = this.container.querySelector('#playback'); this.elements.currentTime = this.container.querySelector('#currentTime'); this.elements.duration = this.container.querySelector('#duration'); this.elements.uploadFile = this.container.querySelector('#upload-file'); this.elements.upload = this.container.querySelector('#upload'); this.elements.controls = this.container.querySelector('#controls'); } async initAudio() { this.audioContext = new AudioContext(); } setupEventListeners() { // Drag and drop this.container.ondragover = event => event.preventDefault(); this.container.ondrop = event => this.handleDrop(event); // Play/stop button this.elements.playstop.onclick = () => this.togglePlay(); // Control inputs if (this.elements.controls) { this.elements.controls.querySelectorAll('input').forEach(input => { const isCheckbox = input.type === 'checkbox'; const key = input.dataset.key; input.oninput = input.onchange = () => { const value = isCheckbox ? input.checked : parseFloat(input.value); this.updateControlValue(key, value); }; if (!isCheckbox) { input.ondblclick = () => this.resetControlValue(key); } }); } // Upload functionality if (this.options.showUpload) { this.elements.upload.onclick = () => this.elements.uploadFile.click(); this.elements.uploadFile.onchange = async () => { await this.handleFileUpload(); }; } // Playback position this.elements.playback.onmousedown = () => this.playbackHeld = true; this.elements.playback.onmouseup = this.elements.playback.onmousecancel = () => this.playbackHeld = false; this.elements.playback.oninput = this.elements.playback.onchange = () => this.updatePlaybackPosition(); // Update playback position periodically this.startPlaybackUpdate(); } startPlaybackUpdate() { setInterval(() => { if (this.elements.playback && this.stretch) { const currentTime = this.stretch.inputTime || 0; this.elements.playback.max = this.audioDuration; this.elements.playback.value = currentTime; // Update time displays this.elements.currentTime.textContent = this.formatTime(currentTime); this.elements.duration.textContent = this.formatTime(this.audioDuration); } }, 100); } formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } async loadAudioFromUrl(url) { try { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); await this.handleArrayBuffer(arrayBuffer); } catch (error) { console.error('Error loading audio from URL:', error); } } async loadAudioFromFile(file) { await this.handleFile(file); } async handleFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = e => resolve(this.handleArrayBuffer(reader.result)); reader.onerror = reject; reader.readAsArrayBuffer(file); }); } async handleArrayBuffer(arrayBuffer) { const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); this.audioDuration = audioBuffer.duration; const channelBuffers = []; for (let c = 0; c < audioBuffer.numberOfChannels; c++) { channelBuffers.push(audioBuffer.getChannelData(c)); } // Clean up existing stretch node if (this.stretch) { this.audioContext.suspend(); this.stretch.stop(); this.stretch.disconnect(); } this.stretch = await SignalsmithStretch(this.audioContext); this.stretch.connect(this.audioContext.destination); await this.stretch.addBuffers(channelBuffers); this.controlValues.loopEnd = this.audioDuration; // Update duration display this.elements.duration.textContent = this.formatTime(this.audioDuration); if (this.stretch) { const obj = { input: 0, output: this.audioContext.currentTime + 0.15, ...this.controlValues }; this.stretch.schedule(obj); } this.audioContext.resume(); } handleDrop(event) { event.preventDefault(); const dt = event.dataTransfer; const file = dt.items ? dt.items[0].getAsFile() : dt.files[0]; this.handleFile(file); } async handleFileUpload() { if (this.stretch) { this.stretch.stop(); } const file = this.elements.uploadFile.files[0]; if (file) { await this.handleFile(file).catch(e => alert(e.message)); if (this.stretch) { this.controlValues.active = true; this.controlsChanged(); } } } togglePlay() { this.controlValues.active = !this.controlValues.active; this.controlsChanged(0.15); } updateControlValue(key, value) { if (key in this.controlValues) { this.controlValues[key] = value; this.controlsChanged(); } else if (key in this.configValues) { this.configValues[key] = value; this.configChanged(); } } resetControlValue(key) { if (key in this.controlValues) { this.controlValues[key] = this.controlValuesInitial[key]; this.controlsChanged(); } else if (key in this.configValues) { this.configValues[key] = this.configValuesInitial[key]; this.configChanged(); } } controlsChanged(scheduleAhead) { // Update play/stop button const playIcon = this.controlValues.active ? '' : ''; this.elements.playstop.innerHTML = ` ${playIcon} `; // Update control inputs if (this.elements.controls) { this.elements.controls.querySelectorAll('input').forEach(input => { const key = input.dataset.key; if (key in this.controlValues) { const value = this.controlValues[key]; if (value !== parseFloat(input.value)) { input.value = value; } } }); } // Schedule stretch changes if (this.stretch) { const obj = { output: this.audioContext.currentTime + (scheduleAhead || 0), ...this.controlValues }; this.stretch.schedule(obj); } this.audioContext.resume(); } configChanged() { if (this.elements.controls) { this.elements.controls.querySelectorAll('input').forEach(input => { const key = input.dataset.key; if (key in this.configValues) { const value = this.configValues[key]; if (value !== parseFloat(input.value)) { input.value = value; } } }); } if (this.configTimeout === null) { this.configTimeout = setTimeout(() => { this.configTimeout = null; if (this.stretch) { this.stretch.configure({ blockMs: this.configValues.blockMs, intervalMs: this.configValues.blockMs / this.configValues.overlap, splitComputation: this.configValues.splitComputation, }); } }, 50); } this.audioContext.resume(); } updatePlaybackPosition() { if (!this.stretch) return; const inputTime = parseFloat(this.elements.playback.value); const obj = {...this.controlValues}; if (this.playbackHeld) obj.rate = 0; this.stretch.schedule({input: inputTime, ...obj}); } // Public methods play() { this.controlValues.active = true; this.controlsChanged(); } stop() { this.controlValues.active = false; this.controlsChanged(); } setRate(rate) { this.controlValues.rate = rate; this.controlsChanged(); } setPitch(semitones) { this.controlValues.semitones = semitones; this.controlsChanged(); } destroy() { if (this.stretch) { this.stretch.stop(); this.stretch.disconnect(); } if (this.audioContext) { this.audioContext.close(); } this.container.innerHTML = ''; } }