Spaces:
Sleeping
Sleeping
| 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 = ` | |
| <div class="audio-stretch-player"> | |
| <div class="player-header"> | |
| <button class="play-stop-btn" id="playstop"> | |
| <svg width="16" height="16" viewBox="0 0 8 8" fill="currentColor"> | |
| <path d="M1 0L8 4 1 8Z"/> | |
| </svg> | |
| </button> | |
| <div class="playback-slider-container"> | |
| <span class="time-display" id="currentTime">0:00</span> | |
| <input class="playback-slider" id="playback" type="range" value="0" min="0" max="100" step="0.001"> | |
| <span class="time-display" id="duration">0:00</span> | |
| </div> | |
| <input class="file-input" id="upload-file" type="file" accept="audio/*"> | |
| </div> | |
| <div class="controls-panel" id="controls"> | |
| <div class="control-row"> | |
| <label class="control-label">Speed</label> | |
| <div class="control-input-line"> | |
| <input type="range" class="control-slider blue" min="0.5" max="2" step="0.01" value="1" data-key="rate"> | |
| <input type="number" class="control-input blue" min="0.5" max="2" step="0.01" value="1" data-key="rate"> | |
| </div> | |
| </div> | |
| <div class="control-row"> | |
| <label class="control-label">Pitch</label> | |
| <div class="control-input-line"> | |
| <input type="range" class="control-slider red" min="-12" max="12" step="1" value="0" data-key="semitones"> | |
| <input type="number" class="control-input red" min="-12" max="12" step="1" value="0" data-key="semitones"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| // 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 ? | |
| '<path d="M1 1L3 1 3 7 1 7ZM5 1 7 1 7 7 5 7Z"/>' : | |
| '<path d="M1 0L8 4 1 8Z"/>'; | |
| this.elements.playstop.innerHTML = ` | |
| <svg width="20" height="20" viewBox="0 0 8 8" fill="currentColor"> | |
| ${playIcon} | |
| </svg> | |
| `; | |
| // 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 = ''; | |
| } | |
| } |