loop_maestro / js /AudioStretchPlayer.js
jorisvaneyghen's picture
Fix: audioplayer glitches
d9f938e
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 = '';
}
}