import {
CTX_DIM_COLOR, CTX_LIT_COLOR, CTX_DIM_PLAYED, CTX_LIT_PLAYED,
MIN_ZOOM, MAX_ZOOM, MIN_VISIBLE_SECS, PEAKS_PER_SECOND,
LARGE_FILE_THRESHOLD_BYTES
} from "../utilities/constants.js"
import { roundRectCorners, formatTime, formatTimeMs, hexToRgb } from "../utilities/tools.js"
import { extractPeaksFromUrl, isAudioFile } from "../utilities/audio.js"
import { ConfirmDialog } from "../components/confirm_dialog.js"
import { ACCENT_STRONG } from "../utilities/colors.js"
/**
* Panel that wraps WaveSurfer to provide audio playback, a canvas-based region
* lane (showing speaker segments), a minimap, a time ruler, and a zoom system.
* Communicates back to the workspace via onRegionHover, onRegionSelect, and
* onRegionActivate callbacks.
*/
export class WaveformPanel {
/**
* @param {object} workspace - the Workspace controller instance
* @param {object} callbacks - callback functions for region interactions
* @param {function} callbacks.onRegionHover - called with region index when a region is hovered
* @param {function} callbacks.onRegionSelect - called with region index when a region is selected
* @param {function} callbacks.onRegionActivate - called with region index when the playhead enters a region
* @param {function} [callbacks.onWordActivate] - called with word index when the playhead enters a word
*/
constructor(workspace, { onRegionHover, onRegionSelect, onRegionActivate, onWordActivate }) {
this.workspace = workspace;
// setup callbacks
this.onRegionHover = onRegionHover ?? (() => {})
this.onRegionSelect = onRegionSelect ?? (() => {})
this.onRegionActivate = onRegionActivate ?? (() => {})
this.onWordActivate = onWordActivate ?? (() => {})
this.onWaveformScroll = this.onWaveformScroll.bind(this);
this.resizeMinimap = this.resizeMinimap.bind(this);
// ── Scroll-to-zoom on waveform ────────────────────────────────────────────
this.zoomRafPending = false; // debounce flag for wheel zoom rAF
this.pendingZoom = null; // accumulates wheel deltas between frames
this.zoomAnchor = 'cursor'; // 'cursor' | 'transport' — what point stays fixed during zoom
this.waveformCursorX = 0; // last known mouse X relative to waveform wrap
this.followPlayhead = true;
// Minimap handle resize state — for resizing the viewport by dragging the left/right edges
this.mmHandleDragging = null; // 'left' | 'right' | null
this.mmHandleStartX = 0;
this._peaksInjected = false; // guard against re-entry when we reload with peaks
this._audioLoadToken = 0; // incremented on clearWaveformPanel to cancel stale loads
// Minimap drag state — for panning the viewport by dragging the thumb
this.mmDragging = false;
this.mmDragStartX = 0;
this.mmDragStartScroll = 0;
// Throttle state for onTimeUpdate — coalesces rapid 'audioprocess' events into
// one rAF callback so we don't thrash the DOM during playback.
this.rafPending = false;
this.pendingTime = 0;
this.currentPlaybackRate = 1.0;
this.pendingScrollFrac = null; // left-edge time fraction to restore after zoom re-render
// TODO: (issue-19) Should ownership of the waveform instance be in the workspace instead of waveform panel?
this.wavesurferInstance = null;
this.activeStreamUrl = null;
this.cachedSegments = [];
this.#getElements();
this.#setupListeners();
// Add btn-active class to the follow button
this.followBtn.classList.add('btn-active');
this.clearWaveformPanel();
}
/** Binds all panel DOM elements to instance properties. */
#getElements() {
this.root = document.getElementById('waveformPanel');
this.playBtn = this.root.querySelector('#playBtn');
this.playDot = this.root.querySelector('#playDot');
this.skipBack = this.root.querySelector('#skipBack');
this.skipFwd = this.root.querySelector('#skipFwd');
this.volumeSlider = this.root.querySelector('#volumeSlider');
this.volumeIconBtn = this.root.querySelector('#volumeIconBtn');
this.volumeCompactPopup = this.root.querySelector('#volumeCompactPopup');
this.volumeSliderCompact = this.root.querySelector('#volumeSliderCompact');
this.speedSelect = this.root.querySelector('#speedSelect');
this.sampleRate = this.root.querySelector('#sampleRate');
this.timeRuler = this.root.querySelector('#timeRuler');
this.gridCanvas = this.root.querySelector('#gridCanvas');
this.followBtn = this.root.querySelector('#followBtn');
this.minimapCanvas = this.root.querySelector('#minimapCanvas');
this.minimapThumb = this.root.querySelector('#minimapThumb');
this.minimapWrap = this.root.querySelector('#minimapWrap');
this.waveformWrapEl = this.root.querySelector('#waveformWrap');
this.waveformHoverLine = this.root.querySelector('#waveformHoverLine');
this.waveformHoverLabel = this.root.querySelector('#waveformHoverLabel');
this.transportLineLabel = this.root.querySelector('#transportLineLabel');
this.zoomAnchorBtn = this.root.querySelector('#zoomAnchorBtn');
this.zoomLevel = this.root.querySelector('#zoomLevel');
this.dropZone = this.root.querySelector('#dropZone');
this.loadingBar = this.root.querySelector('#loadingBar');
this.loadingInfo = this.root.querySelector('#loadingInfo');
this.playerMain = this.root.querySelector('#playerMain');
this.fileInput = this.root.querySelector('#fileInput');
this.deleteAudioBtn = this.root.querySelector('#deleteAudioBtn');
this.downloadWrap = this.root.querySelector('#downloadWrap');
this.downloadAudioBtn = this.root.querySelector('#downloadAudioBtn');
this.downloadPopup = this.root.querySelector('#downloadPopup');
this.downloadOriginalBtn = this.root.querySelector('#downloadOriginalBtn');
this.downloadMp3Btn = this.root.querySelector('#downloadMp3Btn');
this.waveform = this.root.querySelector('#waveform');
this.zoomReset = this.root.querySelector('#zoomReset');
this.mmHandleL = this.root.querySelector('#mmHandleL');
this.mmHandleR = this.root.querySelector('#mmHandleR');
this.trackName = this.root.querySelector('#trackName');
this.currentTime = this.root.querySelector('#currentTime');
this.totalTime = this.root.querySelector('#totalTime');
this.statusLeft = this.root.querySelector('#statusLeft');
this.statusRight = this.root.querySelector('#statusRight');
this.statusText = this.root.querySelector('#statusText');
this.regionCanvas = this.root.querySelector('#regionCanvas');
this.regionHitArea = this.root.querySelector('#regionHitArea');
this.regionLane = this.root.querySelector('#regionLane');
}
/**
* Reads the current waveform-related CSS custom properties.
* @returns {{waveColor: string, progressColor: string, cursorColor: string}}
*/
#waveColors() {
const s = getComputedStyle(document.documentElement);
return {
waveColor: s.getPropertyValue('--waveform').trim() || '#3a3a48',
progressColor: s.getPropertyValue('--waveform-progress').trim() || '#e8ff47',
cursorColor: s.getPropertyValue('--accent').trim() || '#e8ff47',
};
}
/**
* Wires up all event listeners: minimap handle drag, minimap pan, transport
* controls, file drop zone, wheel zoom, region lane interaction, and keyboard shortcuts.
*/
#setupListeners() {
// Re-colour WaveSurfer when the theme changes
window.addEventListener('themechange', () => {
if (this.wavesurferInstance) {
this.wavesurferInstance.setOptions(this.#waveColors());
}
});
// Window Listeners
window.addEventListener('mousemove', (e) => {
if (this.mmHandleDragging) {
const scrollEl = this.getScrollEl();
if (!scrollEl || !this.totalDuration) return;
const W = this.minimapWrap.clientWidth;
const dx = e.clientX - this.mmHandleStartX;
this.mmHandleStartX = e.clientX;
const dFrac = dx / W;
// Use pendingScrollFrac (the intended post-correction left edge) when set,
// because WaveSurfer leaves scrollLeft unchanged after zoom(), so reading
// scrollLeft/scrollWidth between zoom calls gives stale fractions that cause
// both handles to drift. rightFrac is derived from currentZoomLevel which
// is always updated immediately.
const leftFrac = this.pendingScrollFrac !== null
? this.pendingScrollFrac
: scrollEl.scrollLeft / scrollEl.scrollWidth;
const rightFrac = leftFrac + 1 / this.currentZoomLevel;
let newLeftFrac = leftFrac;
let newRightFrac = rightFrac;
if (this.mmHandleDragging === 'right') {
newRightFrac = Math.max(leftFrac + 0.005, Math.min(1, rightFrac + dFrac));
} else {
newLeftFrac = Math.max(0, Math.min(rightFrac - 0.005, leftFrac + dFrac));
}
const newVisibleFrac = newRightFrac - newLeftFrac;
const newZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), 1 / newVisibleFrac));
// applyZoom uses pendingScrollFrac to suppress intermediate scroll
// events WaveSurfer fires during zoom, then restores the correct
// left-edge fraction (newLeftFrac pins the fixed edge for both handles).
this.applyZoom(newZoom, newLeftFrac);
return;
}
if (this.mmDragging) {
const scrollEl = this.getScrollEl();
if (scrollEl) {
const W = this.minimapWrap.clientWidth;
const dx = e.clientX - this.mmDragStartX;
const scrollDelta = (dx / W) * scrollEl.scrollWidth;
const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth;
scrollEl.scrollLeft = Math.max(0, Math.min(maxScroll, this.mmDragStartScroll + scrollDelta));
this.drawMinimap();
}
}
});
window.addEventListener('mouseup', () => {
this.mmDragging = false;
this.mmHandleDragging = null;
});
// Run positioning once after first paint and on resize
window.addEventListener('resize', this.resizeMinimap);
new ResizeObserver(([entry]) => {
this.root.classList.toggle('controls-compact', entry.contentRect.width < 680);
}).observe(this.root);
// setup handle dragging
this.setupHandleDrag(this.mmHandleL, 'left');
this.setupHandleDrag(this.mmHandleR, 'right');
// Zoom reset button
// ── Controls ──────────────────────────────────────────────────────────────
this.playBtn.addEventListener('click', () => { this.togglePlay(); });
this.skipBack.addEventListener('click', () => { this.skipN(-5); });
this.skipFwd.addEventListener('click', () => { this.skipN(5); });
this.zoomReset.addEventListener('click', () => { this.resetZoom(); });
this.volumeIconBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.volumeCompactPopup.classList.toggle('open');
});
this.volumeCompactPopup.addEventListener('click', (e) => { e.stopPropagation(); });
this.volumeSlider.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
this.volumeSliderCompact.value = v;
if (this.wavesurferInstance) this.wavesurferInstance.setVolume(v);
});
this.volumeSliderCompact.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
this.volumeSlider.value = v;
if (this.wavesurferInstance) this.wavesurferInstance.setVolume(v);
});
this.speedSelect.addEventListener('change', (e) => {
this.currentPlaybackRate = parseFloat(e.target.value);
if (this.wavesurferInstance) {
// set the playback rate to the dropdown value
this.wavesurferInstance.setPlaybackRate(this.currentPlaybackRate);
}
});
this.minimapThumb.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// start drag mode for minimap thumb
this.mmDragging = true;
this.mmDragStartX = e.clientX;
this.mmDragStartScroll = this.getScrollEl()?.scrollLeft ?? 0;
});
// Follow playhead toggle
this.followBtn.addEventListener('click', () => {
// toggle following playhead
this.followPlayhead = !this.followPlayhead;
// toggle the btn-active style class in the follow button
this.followBtn.classList.toggle('btn-active', this.followPlayhead);
});
// ── File input / drag drop ────────────────────────────────────────────────
this.fileInput.addEventListener('change', (e) => {
if (e.target.files[0] && !this.workspace.isReadOnly()) {
this.#startLocalAudioLoad(e.target.files[0]);
}
});
this.deleteAudioBtn.addEventListener('click', () => {
new ConfirmDialog('Delete audio?', {
onConfirm: async () => {
const project = this.activeProject;
const server = project?.activeServer;
if (server?.isConnected && project?.projectId) {
await server.deleteAudio(project.projectId);
}
project.hasWaveform = false;
project.local.waveform = null;
project.server.waveform = null;
project.waveformDirty = false;
this.clearWaveformPanel();
},
}, 'This will permanently delete the audio file from the server.');
});
// Download button
this.downloadAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.#handleDownloadClick();
});
this.downloadOriginalBtn.addEventListener('click', () => {
this.downloadPopup.style.display = 'none';
this.#triggerAudioDownload('original');
});
this.downloadMp3Btn.addEventListener('click', () => {
this.downloadPopup.style.display = 'none';
this.#triggerAudioDownload('mp3');
});
document.addEventListener('click', () => {
if (this.downloadPopup) this.downloadPopup.style.display = 'none';
if (this.volumeCompactPopup) this.volumeCompactPopup.classList.remove('open');
});
// Track cursor position over waveform and hit area
this.waveformWrapEl.addEventListener('mousemove', (e) => {
// convert mouse position to waveform position
this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
this.waveformHoverLine.style.display = 'block';
this.#showHoverLabel(e.clientX);
});
this.waveformWrapEl.addEventListener('mouseleave', () => {
this.waveformHoverLine.style.display = 'none';
this.waveformHoverLabel.style.display = 'none';
});
// Track cursor position over the time ruler
this.timeRuler.addEventListener('mousemove', (e) => {
this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
this.waveformHoverLine.style.display = 'block';
this.#showHoverLabel(e.clientX);
});
this.timeRuler.addEventListener('mouseleave', () => {
this.waveformHoverLine.style.display = 'none';
this.waveformHoverLabel.style.display = 'none';
});
this.timeRuler.addEventListener('wheel', (e) => {
e.preventDefault();
this.waveform.dispatchEvent(
new WheelEvent('wheel', {
deltaY: e.deltaY,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
bubbles: true,
cancelable: true
})
);
}, { passive: false });
// Zoom anchor toggle
this.zoomAnchorBtn.classList.toggle('btn-active', this.zoomAnchor === 'cursor');
// toggle zoom anchor button
this.zoomAnchorBtn.addEventListener('click', () => {
// swap between 'transport' and 'cursor' modes
this.zoomAnchor = this.zoomAnchor === 'cursor' ? 'transport' : 'cursor';
this.zoomAnchorBtn.innerHTML = `<span class="icon icon-crosshair" style="width:12px;height:12px;"></span> ${this.zoomAnchor === 'cursor' ? 'CURSOR' : 'TRANSPORT'}`;
// toggle the zoom anchor button style
this.zoomAnchorBtn.classList.toggle('btn-active', this.zoomAnchor === 'cursor');
});
// handle scroll wheel on waveform
this.waveform.addEventListener('wheel', (e) => {
// if there is no instance or no audio, do nothing
if (!this.wavesurferInstance || !this.totalDuration) {
return;
}
e.preventDefault();
// if shift is being held, pan instead of zooming
if (e.shiftKey) {
// Shift+scroll → horizontal scroll
const scrollEl = this.getScrollEl();
if (scrollEl) {
this.#scrollByDelta(scrollEl, e.deltaY);
}
return;
}
// perform zoom
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
this.pendingZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), (this.pendingZoom ?? this.currentZoomLevel) * factor));
// set the zoom level display
this.zoomLevel.textContent =
this.pendingZoom === 1 ? '1×' : `${this.pendingZoom.toFixed(this.pendingZoom < 10 ? 1 : 0)}×`;
// zoom in the next animation frame
if (!this.zoomRafPending) {
this.zoomRafPending = true;
requestAnimationFrame(() => {
if (this.pendingZoom !== null) {
this.doZoom(this.pendingZoom);
this.pendingZoom = null;
}
this.zoomRafPending = false;
});
}
}, { passive: false });
// Wheel over minimap: ctrl = zoom, plain/shift = scroll
this.minimapWrap.addEventListener('wheel', (e) => {
e.preventDefault();
const scrollEl = this.getScrollEl();
// if there is no scroll element, return
if (!scrollEl) {
return;
}
if (e.ctrlKey) {
// Ctrl+scroll → zoom
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
this.#zoomByFactor(factor);
} else {
// Plain or shift → scroll
this.#scrollByDelta(scrollEl, e.deltaY);
}
}, { passive: false });
// ---- FILE DROP ZONE ---- //
// Clicking the drop zone triggers the header file input
this.dropZone.addEventListener('click', () => {
if (!this.workspace.isReadOnly()) this.fileInput.click();
});
// when dragged over, sets the style class
this.dropZone.addEventListener('dragover', (e) => {
if (this.workspace.isReadOnly()) return;
e.preventDefault();
this.dropZone.classList.add('dragover');
});
// when undragged over, reset the style class
this.dropZone.addEventListener('dragleave', () => this.dropZone.classList.remove('dragover'));
// When file is dropped into drop zone, handle the loading of it
this.dropZone.addEventListener('drop', (e) => {
if (this.workspace.isReadOnly()) return;
e.preventDefault();
this.dropZone.classList.remove('dragover');
// load the file
const f = e.dataTransfer.files[0];
if (f) {
this.#startLocalAudioLoad(f);
}
});
// ---- REGIONS ---- //
// When mouse moved over region zone
this.regionHitArea.addEventListener('mousemove', (e) => {
const idx = this.regionIndexAtX(e.clientX);
this.regionHitArea.style.cursor = idx >= 0 ? 'pointer' : 'default';
this.#hoverRegion(idx);
// Keep the hover line and cursor X in sync with the region lane
this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
this.waveformHoverLine.style.display = 'block';
this.#showHoverLabel(e.clientX);
});
// when mouse left region zone
this.regionHitArea.addEventListener('mouseleave', () => {
this.#hoverRegion(-1);
this.waveformHoverLine.style.display = 'none';
this.waveformHoverLabel.style.display = 'none';
});
// when scroll wheel on region area, transfer the event to the waveform
this.regionHitArea.addEventListener('wheel', (e) => {
e.preventDefault();
this.waveform.dispatchEvent(
new WheelEvent('wheel', {
deltaY: e.deltaY,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
bubbles: true,
cancelable: true
})
);
}, { passive: false });
// when clicked in region area
this._regionClickTimer = null;
this.regionHitArea.addEventListener('click', (e) => {
// if there is no waveform instance or audio is empty, return
if (!this.wavesurferInstance || !this.totalDuration) {
return;
}
// Defer single-click handling so a double-click can cancel it
const time = this.clientXToTime(e.clientX);
clearTimeout(this._regionClickTimer);
this._regionClickTimer = setTimeout(() => {
const timeFrac = time / this.totalDuration;
// seek the instance to the beginning of the selected region
this.wavesurferInstance.seekTo(Math.max(0, Math.min(1, timeFrac)));
this.#selectRegion(this.regionIndexAtTime(time));
}, 220);
});
// Double-click on region area: zoom to that region without activating edit mode
this.regionHitArea.addEventListener('dblclick', (e) => {
clearTimeout(this._regionClickTimer);
if (!this.wavesurferInstance || !this.totalDuration) return;
const time = this.clientXToTime(e.clientX);
const idx = this.regionIndexAtTime(time);
if (idx >= 0) {
// Seek to the region start — triggers the 'seeking' event which scrolls
// the transcript panel to this segment via #activateRegion
const region = this.activeProject.transcript().segments[idx];
const timeFrac = region.start / this.totalDuration;
this.wavesurferInstance.seekTo(Math.max(0, Math.min(1, timeFrac)));
this.#selectRegion(idx);
this.zoomToRegion(idx);
}
});
// when activating the context menu in the region area
this.regionHitArea.addEventListener('contextmenu', (e) => {
e.preventDefault();
// select the region and open the segment context menu
const idx = this.regionIndexAtX(e.clientX);
if (idx >= 0) {
this.#selectRegion(idx);
// Anchor split popup just below the region lane at the click x position
const laneRect = this.regionLane.getBoundingClientRect();
const splitAnchor = { left: e.clientX, top: laneRect.bottom, bottom: laneRect.bottom };
this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, idx, splitAnchor);
}
});
// waveform key shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.target.isContentEditable) return;
// Play/pause
if (e.code === 'Space') {
e.preventDefault();
this.togglePlay();
}
// Skip 5 backward
if (e.code === 'ArrowLeft') { this.skipN(-5); }
// Skip 5 forward
if (e.code === 'ArrowRight') { this.skipN(5); }
// Zoom In
if (e.code === 'Equal' || e.code === 'NumpadAdd') { this.zoomIn(1.25); }
// Zoom Out
if (e.code === 'Minus' || e.code === 'NumpadSubtract') { this.zoomOut(1.25); }
// Reset Zoom
if (e.code === 'Digit0' || e.code === 'Numpad0') { this.applyZoom(1); }
});
}
/** Toggles WaveSurfer playback. */
togglePlay() {
if (this.wavesurferInstance) {
this.wavesurferInstance.playPause();
}
}
/**
* Seeks the playhead forward or backward by skipSeconds.
* @param {number} skipSeconds - seconds to skip (negative = backward)
*/
skipN(skipSeconds) {
if (this.wavesurferInstance) {
this.wavesurferInstance.skip(skipSeconds);
}
}
/**
* Decreases the zoom level by dividing by amount.
* @param {number} amount - divisor (e.g. 1.25)
*/
zoomOut(amount) {
this.applyZoom(this.currentZoomLevel / amount);
}
/**
* Increases the zoom level by multiplying by amount.
* @param {number} amount - multiplier (e.g. 1.25)
*/
zoomIn(amount) {
this.applyZoom(this.currentZoomLevel * amount);
}
/** Resets the zoom level to 1× (fit entire track in visible width). */
resetZoom() {
this.applyZoom(1);
}
/**
* Loads waveform data from the given project and draws regions.
* @param {Project} project - the project to load waveform data from
*/
loadFromProject(project) {
this.activeProject = project;
if (this.activeProject.hasWaveform) {
if (!this.activeProject.localOnly) {
this.setStatus('LOADING…');
this.trackName.textContent = this.activeProject.projectName;
this.loadingBar.classList.remove('done');
this.playerMain.classList.remove('active');
this.dropZone.classList.add('collapsed');
}
this.loadWaveform();
this.drawRegions();
}
// In read-only mode with no audio, disable the drop zone
if (this.workspace.isReadOnly() && !this.activeProject.hasWaveform) {
this.dropZone.classList.add('read-only');
}
}
/**
* Initialises WaveSurfer and loads the waveform URL from the active project.
* For server files without pre-computed peaks, checks the remote file size
* via a HEAD request and — if the file is large — extracts peaks in chunks
* using HTTP Range requests before handing off to WaveSurfer, preventing
* the tab from running out of memory on long recordings.
* Logs a warning if the waveform URL is not defined.
*/
async loadWaveform() {
this.dropZone.classList.add('collapsed');
this.initWaveSurfer();
this.trackName.textContent = this.activeProject.waveform()?.filename ?? '';
const wf = this.activeProject.waveform();
if (!wf?.url) {
console.warn("Waveform URL is not defined. Cannot load waveform.");
return;
}
this.activeStreamUrl = wf.url;
let peaks = wf.peaks ?? undefined;
let duration = wf.duration > 0 ? wf.duration : undefined;
// If peaks aren't cached, check whether the remote file is large enough
// that letting WaveSurfer decode it in full would crash the tab.
if (!peaks) {
let isLarge = false;
try {
const head = await fetch(wf.url, { method: 'HEAD' });
const bytes = parseInt(head.headers.get('Content-Length') || '0');
isLarge = bytes > LARGE_FILE_THRESHOLD_BYTES;
} catch { /* network error — proceed with normal WaveSurfer decode */ }
if (isLarge) {
this.loadingBar.classList.add('progress');
this.loadingBar.style.setProperty('--bar-progress', '0%');
this.loadingInfo.classList.add('visible');
this.loadingInfo.textContent = 'DECODING AUDIO — 0%';
const result = await extractPeaksFromUrl(wf.url, {
onProgress: (pct) => {
const p = Math.round(pct * 100);
this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
},
});
if (result) {
peaks = result.peaks;
duration = result.duration;
this.detectedSampleRate = result.sampleRate;
// Cache so a subsequent reload doesn't re-decode
wf.peaks = peaks;
wf.duration = duration;
wf.sampleRate = result.sampleRate;
this._peaksInjected = true; // prevent onReady from re-decoding
// Persist to server so future loads skip this decode
const peakChannel = peaks?.[0];
if (peakChannel && !this.activeProject.localOnly) {
this.activeProject.activeServer.saveWaveform(this.activeProject.projectId, {
peaks: Array.from(peakChannel),
duration,
sampleRate: result.sampleRate,
}).catch(e => console.warn('Could not save waveform peaks:', e));
}
}
// If result is null (Range not supported), fall through to normal WaveSurfer decode
}
}
this.wavesurferInstance.load(this.activeStreamUrl, peaks, duration);
}
/** Destroys the WaveSurfer instance and resets all audio and UI state to defaults. */
clearWaveformPanel() {
this.activeProject = null;
this._audioLoadToken++;
// If the waveserver instance exists, destroy it
if (this.wavesurferInstance) {
try { this.wavesurferInstance.destroy(); } catch(e) {}
this.wavesurferInstance = null;
}
// initialize all the class variables
this.totalDuration = 0;
this.isPlaying = false;
this.playbackProgress = 0;
this.activeStreamUrl = null;
this.cachedWaveformPeaks = null;
this.detectedSampleRate = null;
this.currentZoomLevel = 1;
this.localAudioFile = null;
this.trackName.textContent = '—';
this.currentTime.textContent = '0:00.0';
this.totalTime.textContent = '0:00';
this.loadingBar.className = 'loading-bar done';
this.loadingInfo.classList.remove('visible');
this.loadingInfo.textContent = '';
this.playerMain.classList.remove('active');
if (this.deleteAudioBtn) this.deleteAudioBtn.style.display = 'none';
if (this.downloadWrap) this.downloadWrap.style.display = 'none';
this.dropZone.classList.remove('collapsed');
this.dropZone.classList.remove('read-only');
this.setWaveformHeight(90);
this._peaksInjected = false; // allow onReady to extract peaks again for the next load
// Clear the region canvas so stale regions from the previous project don't persist
const ctx = this.regionCanvas.getContext('2d');
ctx.clearRect(0, 0, this.regionCanvas.width, this.regionCanvas.height);
this.regionLane.style.display = 'none';
}
/**
* Updates the hovered region index and redraws the region canvas.
* @param {number} regionIdx - segment index, or -1 to clear
*/
setHoveredRegion(regionIdx) {
this.drawRegions();
}
/**
* Internal function to set the hovered region and call the associated callback.
* Needed to ensure redraws happen after calling back
* @param {number} regionIdx - segment index being hovered, or -1 to clear
*/
#hoverRegion(regionIdx) {
this.onRegionHover(regionIdx);
this.drawRegions();
}
/**
* Seeks the playhead to the start of the selected segment and redraws.
* @param {number} regionIdx - segment index, or -1 to clear
*/
setSelectedRegion(regionIdx) {
if (regionIdx < 0) {
return;
}
let region = this.activeProject.transcript().segments[regionIdx];
if (this.wavesurferInstance && this.totalDuration)
this.wavesurferInstance.seekTo((region.start + 0.001) / this.totalDuration);
}
/**
* Internal function to set the selected region and call the associated callback.
* Needed to ensure redraws happen after calling back
* @param {number} regionIdx - segment index being selected, or -1 to clear
*/
#selectRegion(regionIdx) {
this.onRegionSelect(regionIdx);
this.drawRegions();
}
/**
* Internal function to set the activated region and call the associated callback.
* Needed to ensure redraws happen after calling back
* @param {number} regionIdx - segment index being activated
*/
#activateRegion(regionIdx) {
this.onRegionActivate(regionIdx);
this.drawRegions();
}
/**
* Zooms the waveform so the specified segment occupies ~50% of the visible
* width, then scrolls to center it. Uses two nested rAF calls to wait for
* WaveSurfer to finish re-rendering at the new zoom level before scrolling.
* @param {number} regionIdx - segment index to zoom to
*/
zoomToRegion(regionIdx) {
const region = this.activeProject.transcript().segments[regionIdx];
if (!region) return;
this.#zoomToTimeRange(region.start, region.end);
}
/** Zooms the waveform to the time range of the paragraph at the given index.
* @param {number} paragraphIdx - index of the paragraph to zoom to
*/
zoomToParagraph(paragraphIdx) {
const paragraph = this.activeProject.transcript().paragraphs[paragraphIdx];
if (!paragraph) return;
const start = paragraph.segments[0].start;
const end = paragraph.segments[paragraph.segments.length - 1].end;
this.#zoomToTimeRange(start, end);
}
/**
* Adjusts the waveform zoom and scroll to display the given time range.
* @param {number} start - start time in seconds
* @param {number} end - end time in seconds
*/
#zoomToTimeRange(start, end) {
// If there is no wavesurfer instance, or there is no audio, do nothing
if (!this.wavesurferInstance || !this.totalDuration) {
return;
}
const duration = end - start;
if (duration <= 0) {
return;
}
// Target: range fills ~50% of visible width
const wrapW = this.waveform.clientWidth;
const targetPPS = wrapW / (duration * 2);
const basePPS = this.basePxPerSec();
const targetZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), targetPPS / basePPS));
this.applyZoom(targetZoom);
// Two rAF frames: first lets WaveSurfer re-render, second lets scrollWidth update
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const scrollEl = this.getScrollEl();
if (!scrollEl) return;
const midFrac = ((start + end) / 2) / this.totalDuration;
const midPx = midFrac * scrollEl.scrollWidth;
scrollEl.scrollLeft = Math.max(0, midPx - scrollEl.clientWidth / 2);
});
});
}
// ── WaveSurfer setup ──────────────────────────────────────────────────────
/**
* Computes the number of pixels per second that makes the entire track
* fit exactly in the waveform container at zoom level 1.
* @returns {number} pixels per second
*/
basePxPerSec() {
if (!this.wavesurferInstance || !this.totalDuration) return 100;
const containerWidth = this.waveform.clientWidth;
return containerWidth / this.totalDuration;
}
/**
* Returns the effective maximum zoom level: at least MAX_ZOOM, but scaled up
* for long files so the user can always zoom in to MIN_VISIBLE_SECS visible.
* @returns {number}
*/
#maxZoom() {
if (!this.totalDuration) return MAX_ZOOM;
return Math.max(MAX_ZOOM, this.totalDuration / MIN_VISIBLE_SECS);
}
/**
* Applies a new zoom level to the WaveSurfer instance, updates the zoom
* label, and redraws the time ruler, minimap, and regions.
* @param {number} newZoom - desired zoom level (clamped to MIN_ZOOM..#maxZoom())
* @param {number|null} [anchorFrac] - if provided, the scroll position will be
* restored to this left-edge time fraction after the zoom re-renders
*/
applyZoom(newZoom, anchorFrac = null) {
if (!this.wavesurferInstance || !this.totalDuration) return;
this.currentZoomLevel = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), newZoom));
const pps = this.basePxPerSec() * this.currentZoomLevel;
if (anchorFrac !== null) this.pendingScrollFrac = anchorFrac;
this.wavesurferInstance.zoom(pps);
// WaveSurfer 7 resets height on zoom — re-apply the stored value
if (this.wavesurferInstance.options) {
this.wavesurferInstance.options.height = this.waveformHeightPx;
}
// Add/remove class that enables horizontal scroll on the inner wave div
this.waveform.classList.toggle('zoomed', this.currentZoomLevel > 1);
// Format: "1×" at base zoom, one decimal < 10×, zero decimals ≥ 10×
this.zoomLevel.textContent =
this.currentZoomLevel === 1 ? '1×' : `${this.currentZoomLevel.toFixed(this.currentZoomLevel < 10 ? 1 : 0)}×`;
this.updateTimeRuler();
requestAnimationFrame(() => {
// Apply the cursor-anchor scroll correction here. We do it in rAF
// (rather than inside onWaveformScroll) so that ALL intermediate
// scroll events WaveSurfer fires during zoom are ignored by
// onWaveformScroll (which returns early while pendingScrollFrac is
// set). Clearing pendingScrollFrac *before* setting scrollLeft
// prevents a synchronous re-entrant scroll event from seeing it set.
if (this.pendingScrollFrac !== null) {
const frac = this.pendingScrollFrac;
this.pendingScrollFrac = null;
const scrollEl = this.getScrollEl();
if (scrollEl) {
scrollEl.scrollLeft = frac * scrollEl.scrollWidth;
}
}
this.drawMinimap();
this.drawRegions();
});
}
/**
* Creates (or re-creates) the WaveSurfer instance and registers all event
* handlers. Called both on initial file load and when switching projects.
* The old instance must be destroyed before calling this (resetWorkspace does it).
*/
initWaveSurfer() {
if (this.wavesurferInstance) { this.wavesurferInstance.destroy(); }
this.wavesurferInstance = WaveSurfer.create({
container: '#waveform',
...this.#waveColors(),
cursorWidth: 1.5,
barWidth: 2,
barGap: 1,
barRadius: 1,
height: this.waveformHeightPx,
normalize: true, // scale peaks relative to the loudest sample
interact: true, // allow clicking the waveform to seek
pixelRatio: 1, // use CSS pixels (not device pixels) for sharper bars at low zoom
});
this.wavesurferInstance.on('ready', this.onReady.bind(this));
this.wavesurferInstance.on('audioprocess', this.onTimeUpdate.bind(this));
this.wavesurferInstance.on('interaction', (newTime) => {
// Fired when the user clicks the waveform to seek
this.playbackProgress = this.totalDuration > 0 ? newTime / this.totalDuration : 0;
this.drawMinimap();
if(this.activeProject.hasTranscript) {
this.drawRegions();
}
});
this.wavesurferInstance.on('seeking', (currentTime) => {
// Fired on programmatic seeks (e.g. from transcript click)
this.playbackProgress = this.totalDuration > 0 ? currentTime / this.totalDuration : 0;
this.currentTime.textContent = formatTimeMs(currentTime);
this.drawMinimap();
this.updateTransportLabel();
if(this.activeProject.hasTranscript) {
const idx = this.regionIndexAtTime(currentTime);
this.#activateRegion(idx)
}
});
this.wavesurferInstance.on('play', this.onPlay.bind(this));
this.wavesurferInstance.on('pause', this.onPause.bind(this));
this.wavesurferInstance.on('finish', this.onFinish.bind(this));
this.wavesurferInstance.on('loading', this.onLoading.bind(this));
}
/**
* WaveSurfer 'ready' handler — fires once the audio is fully decoded and ready.
*
* Two-pass peak extraction strategy:
* 1. First ready (no cachedWaveformPeaks): extract peaks from the decoded buffer
* and reload the file with them. this enables fast zooming without re-decode.
* 2. Second ready (_peaksInjected = true): skip extraction and finish normal setup.
*
* For server-streamed files, peaks weren't pre-computed before loading, so this
* path always runs. For local drag-drop, peaks are pre-computed in the Worker
* inside this.loadAudioFile(), so cachedWaveformPeaks is already set and the first pass
* is skipped.
*/
onReady() {
this.totalDuration = this.activeProject.waveform().duration;
// Fall back to WaveSurfer's decoded duration if the stored value is missing
if (this.totalDuration <= 0) {
this.totalDuration = this.wavesurferInstance.getDuration() || 0;
this.activeProject.waveform().duration = this.totalDuration;
}
// If peaks weren't pre-computed (server URL load), extract them now and
// reload with them so zoom never has to re-decode.
if (!this.activeProject.waveform()?.peaks && !this._peaksInjected) {
try {
const audioBuffer = this.wavesurferInstance.getDecodedData();
if (audioBuffer) {
this.detectedSampleRate = audioBuffer.sampleRate;
const totalSamples = audioBuffer.length;
const peakCount = Math.ceil(this.totalDuration * PEAKS_PER_SECOND);
const channelData = audioBuffer.getChannelData(0);
const blockSize = totalSamples / peakCount;
const peaks = new Float32Array(peakCount);
// For each output peak, find the max absolute sample value in that block
for (let i = 0; i < peakCount; i++) {
let max = 0;
const start = Math.floor(i * blockSize);
const end = Math.min(Math.floor(start + blockSize), totalSamples);
for (let j = start; j < end; j++) {
const v = Math.abs(channelData[j]);
if (v > max) max = v;
}
peaks[i] = max;
}
this.activeProject.waveform().peaks = [peaks];
this._peaksInjected = true;
// Persist to server so future loads skip this decode
if (!this.activeProject.localOnly) {
this.activeProject.activeServer.saveWaveform(this.activeProject.projectId, {
peaks: Array.from(peaks),
duration: this.totalDuration,
sampleRate: this.detectedSampleRate ?? this.activeProject.waveform().sampleRate,
}).catch(e => console.warn('Could not save waveform peaks:', e));
}
// Reload with peaks — fires onReady again, but _peaksInjected prevents re-entry
this.wavesurferInstance.load(this.activeStreamUrl, this.activeProject.waveform().peaks, this.totalDuration);
return; // wait for the second ready event to finish setup
}
} catch(e) {
console.warn('Could not extract peaks:', e);
}
}
// Normal ready setup (runs immediately for local files, after peak injection for server files)
this._peaksInjected = false;
this.loadingBar.classList.add('done');
this.loadingBar.classList.remove('progress');
this.loadingInfo.classList.remove('visible');
this.loadingInfo.textContent = '';
this.playerMain.classList.add('active');
if (this.deleteAudioBtn && !this.workspace.isReadOnly()) this.deleteAudioBtn.style.display = '';
if (this.downloadWrap) this.#updateDownloadButton();
this.totalTime.textContent = formatTime(this.totalDuration);
this.currentZoomLevel = 1;
this.zoomLevel.textContent = '1×';
if (this.activeProject.waveform()?.sampleRate) {
this.sampleRate.textContent =
`${(this.activeProject.waveform().sampleRate / 1000).toFixed(1)} kHz`;
}
this.updateTimeRuler();
if (this.activeProject.hasTranscript) {
this.drawRegions();
}
this.setStatus('READY', `${formatTime(this.totalDuration)} total`);
this.attachScrollListener();
this.resizeMinimap();
this.drawMinimap();
}
/**
* WaveSurfer 'audioprocess' handler — fires frequently during playback.
* Coalesces updates via requestAnimationFrame to avoid redundant DOM writes.
* Handles: timecode display, playback-progress fraction, minimap, region
* highlight, transcript highlight, and follow-playhead auto-scroll.
* @param {number} t - current playback time in seconds
*/
onTimeUpdate(t) {
this.pendingTime = t;
if (this.rafPending) return; // already scheduled; just update the pending time
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
const time = this.pendingTime;
this.currentTime.textContent = formatTimeMs(time);
const pct = this.totalDuration > 0 ? ((time / this.totalDuration) * 100).toFixed(1) : 0;
const speedLabel = this.currentPlaybackRate !== 1.0 ? `${this.currentPlaybackRate}× | ` : '';
this.statusRight.textContent = `${speedLabel}${pct}%`;
this.playbackProgress = this.totalDuration > 0 ? time / this.totalDuration : 0;
this.drawMinimap();
this.updateTransportLabel();
// Redraw regions every frame — needed to track scroll position correctly
const prevActive = this.workspace.activeSegmentIdx;
if(this.activeProject.hasTranscript) {
const regionIdx = this.regionIndexAtTime(time);
this.#activateRegion(regionIdx);
this.onWordActivate(time);
}
if (this.activeProject.hasTranscript &&
(this.workspace.activeSegmentIdx !== prevActive || this.followPlayhead)) {
this.drawRegions();
}
// Auto-scroll: keep playhead centered in the scrollable waveform
if (this.followPlayhead && this.currentZoomLevel > 1) {
const wrapper = this.wavesurferInstance.getWrapper ? this.wavesurferInstance.getWrapper() : null;
const scrollEl = wrapper ? wrapper.parentElement : null;
if (scrollEl) {
const totalWidth = scrollEl.scrollWidth;
const visibleWidth = scrollEl.clientWidth;
const playheadPx = (this.totalDuration > 0 ? time / this.totalDuration : 0) * totalWidth;
const newLeft = Math.max(0, playheadPx - visibleWidth / 2);
// Only scroll if the playhead has drifted more than 1px to avoid jitter
if (Math.abs(scrollEl.scrollLeft - newLeft) > 1) {
scrollEl.scrollLeft = newLeft;
this.drawRegions(); // redraw immediately after scroll
}
}
}
});
}
/** WaveSurfer 'play' handler — updates button icon and status indicators. */
onPlay() {
this.isPlaying = true;
this.playBtn.textContent = '⏸';
this.playDot.classList.remove('paused');
this.statusText.textContent = 'PLAYING';
this.setStatus('PLAYING');
}
/** WaveSurfer 'pause' handler — updates button icon and status indicators. */
onPause() {
this.isPlaying = false;
this.playBtn.textContent = '▶';
this.playDot.classList.add('paused');
this.statusText.textContent = 'PAUSED';
this.setStatus('PAUSED');
}
/** WaveSurfer 'finish' handler — playback reached the end naturally. */
onFinish() {
this.isPlaying = false;
this.playBtn.textContent = '▶';
this.playDot.classList.add('paused');
this.statusText.textContent = 'STOPPED';
this.setStatus('FINISHED');
}
/**
* WaveSurfer 'loading' handler — fires periodically during remote audio fetch.
* @param {number} percent - loading progress 0–100
*/
onLoading(percent) {
this.setStatus("LOADING", `${percent}%`);
this.loadingBar.classList.add('progress');
this.loadingBar.style.setProperty('--bar-progress', `${percent}%`);
this.loadingInfo.classList.add('visible');
this.loadingInfo.textContent = `LOADING AUDIO — ${percent}%`;
}
// ── Time ruler (updates with zoom) ────────────────────────────────────────
/**
* Rebuilds the time ruler tick marks to match the current zoom level and
* scroll position. Tick interval is chosen from a set of "nice" values so
* there are approximately 8 visible ticks at any zoom.
* At very high zoom (≤10s visible) timestamps include decimal seconds.
*/
updateTimeRuler() {
const ruler = this.timeRuler;
ruler.innerHTML = '';
if (!this.totalDuration) return;
const scrollEl = this.getScrollEl();
// How many seconds are currently visible
const visibleSecs = scrollEl
? (scrollEl.clientWidth / scrollEl.scrollWidth) * this.totalDuration
: this.totalDuration;
const scrollLeft = scrollEl ? scrollEl.scrollLeft : 0;
const scrollWidth = scrollEl ? scrollEl.scrollWidth : scrollEl?.clientWidth ?? 1;
const startT = (scrollLeft / scrollWidth) * this.totalDuration;
const endT = startT + visibleSecs;
const useDecimals = visibleSecs <= 10; // show sub-second precision when highly zoomed
// Pick a sensible step size to give ~8 ticks across the visible range
const targetSteps = 8;
const rawStep = visibleSecs / targetSteps;
// Round up to the nearest "nice" interval from this table (extends to multi-hour files)
const niceSteps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800];
const step = niceSteps.find(s => s >= rawStep) ?? niceSteps[niceSteps.length - 1];
// Start at the first grid-aligned tick that is within or just before the visible range
const firstTick = Math.ceil(startT / step) * step;
ruler.style.position = 'relative';
// Minimum pixel gap between label centers to prevent overlap
const rulerWidth = ruler.clientWidth || scrollEl?.clientWidth || 800;
const MIN_LABEL_SPACING = 75; // px
let lastLabelX = -Infinity;
for (let t = firstTick; t <= endT + step * 0.01; t += step) {
if (t < 0 || t > this.totalDuration) continue;
// Convert time → fraction of visible width, accounting for scroll
const frac = scrollEl
? (t / this.totalDuration * scrollWidth - scrollLeft) / scrollEl.clientWidth
: t / this.totalDuration;
const labelX = frac * rulerWidth;
if (labelX - lastLabelX < MIN_LABEL_SPACING) continue;
lastLabelX = labelX;
const el = document.createElement('span');
el.textContent = useDecimals ? formatTimeMs(t) : formatTime(t);
el.style.position = 'absolute';
el.style.left = (frac * 100).toFixed(3) + '%';
el.style.transform = 'translateX(-50%)'; // center label over tick
ruler.appendChild(el);
}
}
/**
* Updates the status bar text. If `right` is omitted, only the left side changes.
* @param {string} left - e.g. 'PLAYING', 'READY'
* @param {string} [right] - e.g. '42.3%'
*/
setStatus(left, right) {
this.statusLeft.textContent = left || '';
if (right !== undefined) this.statusRight.textContent = right;
}
// ── Custom canvas minimap ─────────────────────────────────────────────────
/**
* Redraws the minimap canvas, coloring bars by played/unplayed and
* viewport/non-viewport states, and positions the thumb overlay div.
*/
drawMinimap() {
if (!this.activeProject?.waveform()?.peaks || !this.activeProject.waveform().peaks[0]) return;
const peaks = this.activeProject.waveform().peaks[0];
const dpr = window.devicePixelRatio || 1;
const W = this.minimapCanvas.width / dpr;
const H = this.minimapCanvas.height / dpr;
const ctx = this.minimapCanvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // reset transform to device pixels
ctx.clearRect(0, 0, W, H);
// Compute the viewport fraction (what portion of the full waveform is visible).
// thumbRight is derived from currentZoomLevel (1/zoom) rather than from
// scrollWidth/clientWidth, which can be temporarily out of sync during a
// panel resize before WaveSurfer re-renders at the new container width.
const scrollEl = this.getScrollEl();
const thumbLeft = scrollEl ? scrollEl.scrollLeft / scrollEl.scrollWidth : 0;
const thumbRight = Math.min(1, thumbLeft + 1 / this.currentZoomLevel);
const thumbPxL = Math.round(thumbLeft * W);
const thumbPxR = Math.round(thumbRight * W);
// Draw one bar per pixel — bars grow upward from the bottom, filling the full canvas height
const playX = Math.round(this.playbackProgress * W);
for (let x = 0; x < W; x++) {
const peakIdx = Math.floor((x / W) * peaks.length);
const amp = Math.min((peaks[peakIdx] ?? 0) * H, H / 2);
const inViewport = (x >= thumbPxL && x < thumbPxR);
const played = x < playX;
// Four visual states: played/unplayed × in-viewport/out-of-viewport
ctx.fillStyle = played
? (inViewport ? CTX_LIT_PLAYED : CTX_DIM_PLAYED)
: (inViewport ? CTX_LIT_COLOR : CTX_DIM_COLOR);
ctx.fillRect(x, H / 2 - amp, 1, Math.max(amp * 2, 1));
}
// Transport playhead line
ctx.fillStyle = ACCENT_STRONG;
ctx.fillRect(playX, 0, 1, H);
// Sync the thumb overlay div position to match the viewport fraction
this.minimapThumb.style.left = (thumbLeft * 100) + '%';
this.minimapThumb.style.width = ((thumbRight - thumbLeft) * 100) + '%';
}
/**
* Loads a local audio file: reads it as an ArrayBuffer, decodes it via the
* Web Audio API to extract sample rate and multi-channel buffers, then
* offloads peak extraction to an inline Web Worker so the UI stays responsive
* during processing. When the worker finishes, WaveSurfer is initialised with
* the pre-computed peaks for fast zoom.
* @param {File} file - must have type starting with "audio/"
*/
loadAudioFile(file) {
if (!isAudioFile(file)) {
alert('Please select an audio file.');
return;
}
}
/**
* Shows the loading UI immediately, then delegates to project.loadLocalAudio
* with a progress callback so large files report decode progress.
* @param {File} file - the audio file to load; must have a type starting with "audio/"
*/
#startLocalAudioLoad(file) {
if (!isAudioFile(file)) {
alert('Please select an audio file.');
return;
}
// Check storage limit before spending time decoding (server mode only)
const server = this.activeProject?.activeServer;
if (server?.isConnected && server.backendUser?.subscription_tier) {
const features = server.backendUser.subscription_tier.features || {};
const usage = server.backendUser.usage || {};
const storageGb = features.storage_gb ?? null;
if (storageGb !== null) {
const limitBytes = storageGb * 1024 ** 3;
const usedBytes = parseFloat(usage.storage_bytes || 0);
const remainingBytes = limitBytes - usedBytes;
if (file.size > remainingBytes) {
const fileMb = (file.size / (1024 ** 2)).toFixed(0);
const remainingMb = Math.max(0, remainingBytes / (1024 ** 2)).toFixed(0);
new ConfirmDialog(
'Storage Limit Reached',
{
onConfirm: () => window.openAccountDrawer?.('subscription'),
},
`This file (${fileMb} MB) would exceed your ${storageGb} GB storage limit. You have ${remainingMb} MB available.\n\nYou can free up space by condensing or deleting existing projects, or upgrade your plan for more storage.`,
'Upgrade Plan',
'Dismiss',
);
return;
}
}
}
this.dropZone.classList.add('collapsed');
this.trackName.textContent = file.name;
this.loadingBar.classList.remove('done');
this.playerMain.classList.remove('active');
// Always pass a progress callback. The audio loader decides whether to
// use chunked decoding based on decoded size, not just file size, so a
// file can be small on disk but still go chunked. The callback lazily
// activates the determinate bar on the first progress report; if no
// progress is ever reported (fast in-memory path) the bar stays as an
// indeterminate spinner.
this.loadingBar.classList.remove('progress');
const loadToken = this._audioLoadToken;
this.activeProject.loadLocalAudio(file, (pct) => {
if (this._audioLoadToken !== loadToken) return;
const p = Math.round(pct * 100);
this.loadingBar.classList.add('progress');
this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
this.loadingInfo.classList.add('visible');
this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
});
}
/**
* Sizes the minimap canvas to the wrapper's current CSS dimensions × dpr.
* Called on resize and after WaveSurfer reports 'ready'.
*/
resizeMinimap() {
const dpr = window.devicePixelRatio || 1;
const cssW = this.minimapWrap.clientWidth || this.minimapWrap.offsetWidth;
const cssH = parseInt(getComputedStyle(this.minimapWrap).height) || this.minimapWrap.offsetHeight;
this.minimapCanvas.width = cssW * dpr;
this.minimapCanvas.height = cssH * dpr;
this.minimapCanvas.style.width = cssW + 'px';
this.minimapCanvas.style.height = cssH + 'px';
this.drawMinimap();
}
/**
* Returns the scrollable container element that WaveSurfer renders into.
* this is the parent of WaveSurfer's internal wrapper div.
* Returns null if no WaveSurfer instance exists yet.
* @returns {HTMLElement|null}
*/
getScrollEl() {
if (this.wavesurferInstance && this.wavesurferInstance.getWrapper) {
const wrapper = this.wavesurferInstance.getWrapper();
return wrapper ? wrapper.parentElement : null;
}
return null;
}
/**
* Shows the white hover label at the given clientX, clamped so it stays
* within the waveform wrap bounds.
* @param {number} clientX - viewport X coordinate
*/
#showHoverLabel(clientX) {
if (!this.totalDuration) return;
this.waveformHoverLabel.textContent = formatTimeMs(this.clientXToTime(clientX));
this.waveformHoverLabel.style.display = 'block';
const labelW = this.waveformHoverLabel.offsetWidth;
const wrapW = this.waveformWrapEl.clientWidth;
const clamped = Math.max(labelW / 2, Math.min(wrapW - labelW / 2, this.waveformCursorX));
this.waveformHoverLabel.style.left = clamped + 'px';
}
/**
* Repositions the yellow transport line label to match the current playback position.
* Hides the label when the transport cursor is scrolled out of view.
*/
updateTransportLabel() {
if (!this.totalDuration || !this.wavesurferInstance) {
this.transportLineLabel.style.display = 'none';
return;
}
const scrollEl = this.getScrollEl();
const totalWidth = scrollEl ? scrollEl.scrollWidth : this.waveformWrapEl.clientWidth;
const scrollLeft = scrollEl ? scrollEl.scrollLeft : 0;
const wrapWidth = this.waveformWrapEl.clientWidth;
const x = this.playbackProgress * totalWidth - scrollLeft;
if (x < 0 || x > wrapWidth) {
this.transportLineLabel.style.display = 'none';
return;
}
this.transportLineLabel.textContent = formatTimeMs(this.playbackProgress * this.totalDuration);
this.transportLineLabel.style.display = 'block';
const labelW = this.transportLineLabel.offsetWidth;
const clamped = Math.max(labelW / 2, Math.min(wrapWidth - labelW / 2, x));
this.transportLineLabel.style.left = clamped + 'px';
}
/**
* Scroll handler attached to WaveSurfer's internal scroll container.
* If a pendingScrollFrac is set (from applyZoom), applies the stored scroll
* position immediately and clears it; otherwise defers redraw to the next frame.
*/
onWaveformScroll() {
if (this.pendingScrollFrac !== null) {
// Zoom correction is pending — the rAF in applyZoom will apply it
// and redraw. Suppress all scroll-driven redraws until then so that
// intermediate WaveSurfer-controlled scroll positions never reach
// drawMinimap (that's what causes the thumb bounce).
return;
}
requestAnimationFrame(() => { this.drawMinimap(); this.updateTimeRuler(); this.drawRegions(); this.updateTransportLabel(); });
}
/**
* Attaches the scroll handler to WaveSurfer's scroll container.
* Must be called after WaveSurfer finishes loading (in onReady).
*/
attachScrollListener() {
const scrollEl = this.getScrollEl();
if (scrollEl) scrollEl.addEventListener('scroll', this.onWaveformScroll, { passive: true });
}
/**
* Registers a mousedown listener on a minimap edge handle that initiates a
* resize drag (changes zoom level by adjusting the visible fraction).
* @param {HTMLElement} el - the handle element
* @param {'left'|'right'} side - which edge is being dragged
*/
setupHandleDrag(el, side) {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
this.mmHandleDragging = side;
this.mmHandleStartX = e.clientX;
});
}
/**
* Scrolls the waveform viewport horizontally by deltaY * 2 pixels, clamped
* to the valid scroll range.
* @param {Element} scrollEl - the scrollable container element
* @param {number} deltaY - raw wheel event deltaY
*/
#scrollByDelta(scrollEl, deltaY) {
const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth;
scrollEl.scrollLeft = Math.max(0, Math.min(maxScroll, scrollEl.scrollLeft + deltaY * 2));
}
/**
* Zooms immediately (no rAF batching) by a multiplicative factor, clamped
* to [MIN_ZOOM, MAX_ZOOM]. Used by the minimap wheel handler.
* @param {number} factor - multiplicative zoom factor to apply to the current zoom level
*/
#zoomByFactor(factor) {
this.doZoom(Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), this.currentZoomLevel * factor)));
}
/**
* Zooms to `newZoom` while keeping a fixed time point under the cursor or
* transport position. In cursor-anchor mode, computes the time fraction under
* the mouse and restores it after zoom so that pixel stays in place.
* @param {number} newZoom - desired zoom level
*/
doZoom(newZoom) {
if (!this.wavesurferInstance || !this.totalDuration) return;
const scrollEl = this.getScrollEl();
if (this.zoomAnchor === 'cursor' && scrollEl) {
// Capture the time fraction under the cursor before zoom changes scrollWidth
const oldScrollW = scrollEl.scrollWidth;
const cursorTimeFrac = (scrollEl.scrollLeft + this.waveformCursorX) / oldScrollW;
// Pre-compute the scroll correction as an anchorFrac so that the
// pendingScrollFrac mechanism corrects scrollLeft at the first scroll
// event (before any rAF draw fires). This prevents the minimap thumb
// from briefly jumping to the wrong position before snapping back.
const clampedZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), newZoom));
const estNewScrollW = oldScrollW * clampedZoom / this.currentZoomLevel;
const anchorFrac = Math.max(0, cursorTimeFrac - this.waveformCursorX / estNewScrollW);
this.applyZoom(clampedZoom, anchorFrac);
} else {
this.applyZoom(newZoom); // transport anchor — WaveSurfer keeps playhead centered
}
}
/**
* Sets the waveform panel height (clamped to 40–400px), updates the CSS
* variable, and redraws the minimap and regions.
* @param {number} h - desired height in pixels
*/
setWaveformHeight(h) {
h = Math.max(40, Math.min(400, h));
this.waveformHeightPx = h;
document.documentElement.style.setProperty('--waveform-h', h + 'px');
if (this.wavesurferInstance) this.wavesurferInstance.setOptions({ height: h });
requestAnimationFrame(() => { this.drawMinimap(); this.drawRegions(); });
}
/**
* Entry point for loading an audio file into the player. Validates the file,
* updates all relevant UI and AppState, then delegates the actual data work to
* loadAudioFile. Once loading completes, initialises WaveSurfer with the
* pre-computed peaks for fast zoom.
* @param {File} file - must have type starting with "audio/"
*/
async initAudio(file) {
if (!isAudioFile(file)) {
alert('Please select an audio file.');
return;
}
this.dropZone.classList.add('collapsed');
this.trackName.textContent = file.name;
this.loadingBar.classList.remove('done');
this.playerMain.classList.remove('active');
this.setStatus('LOADING…');
this.detectedSampleRate = null;
if (this.activeProject.waveform()) this.activeProject.waveform().peaks = null;
const { sampleRate, peaks, duration } = await loadAudioFile(file, {
onProgress: (pct) => {
const p = Math.round(pct * 100);
this.loadingBar.classList.add('progress');
this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
this.loadingInfo.classList.add('visible');
this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
},
});
this.detectedSampleRate = sampleRate;
if (this.activeProject.waveform()) this.activeProject.waveform().peaks = peaks;
this.initWaveSurfer();
const audioUrl = URL.createObjectURL(file);
this.activeStreamUrl = audioUrl;
this.wavesurferInstance.load(audioUrl, peaks, duration);
}
/**
* Shows or hides the download button and configures the original-format label.
* Called each time audio becomes ready.
*/
#updateDownloadButton() {
const wf = this.activeProject?.waveform();
if (!wf) {
this.downloadWrap.style.display = 'none';
return;
}
this.downloadWrap.style.display = '';
// Determine the original format from the filename (e.g. "audio.wav" → "wav")
const ext = (wf.filename || '').split('.').pop().toLowerCase();
const isLocalOnly = this.activeProject.localOnly;
const hasMp3 = !isLocalOnly && wf.hasAudioMp3;
const originalIsMp3 = ext === 'mp3';
// Show popup options only when there are two distinct formats to choose from
const showPopup = hasMp3 && !originalIsMp3;
this.downloadOriginalBtn.textContent = ext ? ext.toUpperCase() : 'Original';
this.downloadMp3Btn.style.display = showPopup ? '' : 'none';
this.downloadOriginalBtn.style.display = showPopup ? '' : 'none';
// Store state for click handler
this._downloadShowPopup = showPopup;
}
/**
* Handles a click on the main download button.
* Shows a format picker popup when both original and MP3 are available,
* otherwise triggers an immediate download of the only available format.
*/
#handleDownloadClick() {
if (this._downloadShowPopup) {
const isVisible = this.downloadPopup.style.display !== 'none';
this.downloadPopup.style.display = isVisible ? 'none' : '';
} else {
this.#triggerAudioDownload('original');
}
}
/**
* Triggers a browser download for the audio file.
* @param {'original'|'mp3'} format - Which format to download.
*/
#triggerAudioDownload(format) {
const wf = this.activeProject?.waveform();
if (!wf) return;
const isLocalOnly = this.activeProject.localOnly;
let url, filename;
if (isLocalOnly) {
// Local project — use the blob URL directly
url = wf.url;
filename = wf.filename || 'audio';
} else {
const server = this.activeProject.activeServer;
const projectId = this.activeProject.projectId;
const baseUrl = `${server.audioUrl(projectId)}&download=1`;
if (format === 'mp3') {
url = `${baseUrl}&format=mp3`;
filename = 'audio.mp3';
} else {
url = baseUrl;
filename = wf.filename || 'audio';
}
}
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/**
* Redraws the region lane canvas from scratch using the cached render runs.
* Called on every frame during playback and on zoom/scroll/segment edits.
*
* Visual design:
* - Segments are grouped into "runs" (consecutive same-speaker segments with
* no large gap) and rendered as color bars that grow upward from the bottom.
* - Normal height ≈ 70% of lane; hovered ≈ 88%; selected/active = 100%.
* - Only the first and last segment of a run get rounded outer corners.
* - A speaker name label is drawn left-anchored inside each visible run.
* - If a split popup is open, a dashed vertical line shows the split preview.
*/
drawRegions() {
if (!this.activeProject) {
return;
}
// TODO: (issue-21) Don't redraw all on region style change
this.regionLane.style.display = this.activeProject.hasTranscript ? '' : 'none';
if (!this.activeProject.hasWaveform || !this.activeProject.hasTranscript || this.totalDuration <= 0) {
return;
}
const scrollEl = this.getScrollEl();
const laneEl = this.regionLane;
const dpr = window.devicePixelRatio || 1;
const W = laneEl.clientWidth;
const H = laneEl.clientHeight;
// Size canvas backing store to physical pixels for crisp rendering
this.regionCanvas.width = W * dpr;
this.regionCanvas.height = H * dpr;
this.regionCanvas.style.width = W + 'px';
this.regionCanvas.style.height = H + 'px';
const ctx = this.regionCanvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (!this.activeProject.transcript().segments.length) {
console.warn("No cached segments.")
return;
}
// totalW is the full scrollable width of the waveform; scrollX is current offset
const totalW = scrollEl ? scrollEl.scrollWidth : W;
const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
const currentT = this.totalDuration * this.playbackProgress;
const MIN_W = 3; // minimum pixel width for any segment bar
const GAP = 1; // gap in pixels between the last segment and the next paragraph
const searchActive = this.workspace.searchMatchSet !== null;
// Heights: normal, hovered, selected/active — all bottom-anchored
const H_NORMAL = Math.round(H * 0.70);
const H_HOVER = Math.round(H * 0.88);
const H_BIG = H;
const RAD = 4; // fixed pixel radius — never changes
const LABEL_Y = H - H_NORMAL / 2; // label stays at center of the bottom portion
const _paragraphOp = (paragraph) => {
let speakerHue = this.activeProject.getSpeaker(paragraph.speaker).hue;
if (!speakerHue) {
console.warn("Speaker has no hue.")
speakerHue = "#FFFFFF";
}
const { r, g, b } = hexToRgb(speakerHue);
const numSegs = paragraph.segments.length;
const paragraphFirstSeg = paragraph.segments[0];
const paragraphLastSeg = paragraph.segments[numSegs - 1];
// Pixel extents of the entire paragraph in scroll-space
const paragraphX0 = (paragraphFirstSeg.start / this.totalDuration) * totalW - scrollX;
const paragraphX1 = (paragraphLastSeg.end / this.totalDuration) * totalW - scrollX;
if (paragraphX1 < 0 || paragraphX0 > W) return; // paragraph completely off-screen — skip
const firstIndex = paragraphFirstSeg.index;
const _segOp = (segment, pos) => {
const segIndex = this.activeProject.transcript().segments.indexOf(segment);
const isFirst = pos === 0;
const isLast = pos === numSegs - 1;
const isSelected = segIndex === this.workspace.selectedSegmentIdx;
const isActive = currentT >= segment.start && currentT < segment.end;
// A segment is hovered if the mouse is over its transcript span OR if the user
// is hovering the speaker row in the speakers panel (hoveredSpeakerId)
const hoveredPara = this.workspace.hoveredParagraphIdx >= 0
? this.activeProject.transcript().paragraphs[this.workspace.hoveredParagraphIdx]
: null;
const isHovered = segIndex === this.workspace.hoveredSegmentIdx ||
segment.speaker === this.workspace.hoveredSpeakerId ||
hoveredPara?.segments.includes(segment);
const nextSeg = !isLast ? paragraph.segments[pos + 1] : null;
const rawStart = (segment.start / this.totalDuration) * totalW - scrollX;
// Right edge extends to the start of the next segment (fills any tiny gap between
// segments in the same paragraph) unless this is the last segment of the paragraph
const rawEnd = nextSeg
? (nextSeg.start / this.totalDuration) * totalW - scrollX
: (segment.end / this.totalDuration) * totalW - scrollX;
if (rawEnd < 0 || rawStart > W) {
return; // segment off-screen
}
const xStart = rawStart;
const xEnd = Math.max(rawEnd, rawStart + MIN_W);
const x = Math.max(0, xStart);
const w = Math.min(W, xEnd) - x - (isLast ? GAP : 0);
if (w < 0.5) {
return;
}
// Height and vertical position (bottom-anchored so they grow upward)
const ph = isSelected || isActive ? H_BIG : isHovered ? H_HOVER : H_NORMAL;
const py = H - ph;
// Only the first/last segment of a paragraph gets rounded outer corners
const tl = isFirst ? RAD : 0;
const bl = isFirst ? RAD : 0;
const tr = isLast ? RAD : 0;
const br = isLast ? RAD : 0;
const alpha = isSelected ? 1.0 : isActive ? 0.95 : isHovered ? 0.85 : 0.72;
const isSearchMatch = !searchActive || this.workspace.searchMatchSet.has(segIndex);
ctx.fillStyle = isSearchMatch
? `rgba(${r},${g},${b},${alpha})`
: `rgba(80,80,80,${alpha * 0.4})`; /* --muted equivalent, alpha computed */
roundRectCorners(ctx, x, py, w, ph, [tl, tr, br, bl]);
ctx.fill();
// Accent yellow outline on selected segment
if (isSelected) {
ctx.save();
ctx.strokeStyle = ACCENT_STRONG;
ctx.lineWidth = 1.5;
// Inset by 0.75px so the stroke doesn't bleed outside the fill area
roundRectCorners(ctx, x + 0.75, py + 0.75, w - 1.5, ph - 1.5, [tl, tr, br, bl]);
ctx.stroke();
ctx.restore();
}
}
paragraph.segments.forEach(_segOp);
// Speaker label — left-anchored to visible paragraph start, clipped to visible paragraph width
const visRunX0 = Math.max(0, paragraphX0);
const visRunX1 = Math.min(W, paragraphX1);
const visRunW = visRunX1 - visRunX0;
if (visRunW >= 28) { // skip label if paragraph is too narrow to show any text
const name = this.activeProject.getSpeaker(paragraph.speaker).name;
ctx.font = `500 8px "IBM Plex Mono", monospace`;
const textW = ctx.measureText(name).width;
if (textW + 6 <= visRunW) {
const isRunActive = paragraph.segments.some((seg) => currentT >= seg.start && currentT < seg.end);
const isRunHovered = paragraph.segments.some((seg) => seg.idx === this.workspace.hoveredSegmentIdx || seg.speaker === this.workspace.hoveredSpeakerId) ||
this.workspace.hoveredParagraphIdx >= 0 && this.activeProject.transcript().paragraphs[this.workspace.hoveredParagraphIdx] === paragraph;
const isRunSel = paragraph.segments.some((seg) => seg.idx === this.workspace.selectedSegmentIdx);
const allSegs = this.activeProject.transcript().segments;
const isRunSearchMatch = !searchActive || paragraph.segments.some(seg => this.workspace.searchMatchSet.has(allSegs.indexOf(seg)));
ctx.save();
// Clip text to the visible portion of the paragraph so it doesn't bleed into adjacent paragraphs
ctx.beginPath();
ctx.rect(visRunX0, 0, visRunW, H);
ctx.clip();
ctx.fillStyle = `rgba(255,255,255,${isRunSearchMatch ? ((isRunActive || isRunHovered || isRunSel) ? 0.95 : 0.8) : 0.25})`; /* --text equivalent, alpha computed */
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// Anchor to left edge of visible paragraph (or paragraph start if on-screen), with padding
const labelX = Math.max(visRunX0, paragraphX0) + 5;
ctx.fillText(name, labelX, LABEL_Y);
ctx.restore();
}
}
}
this.activeProject.transcript().paragraphs.forEach(_paragraphOp);
// Split preview line — dashed yellow vertical line at the current split point
if (this.workspace.splitPopup) {
const seg = this.activeProject.transcript().segments[this.workspace.splitPopup.previewFrac.segIdx];
if (seg) {
const splitT = seg.start + this.workspace.splitPopup.previewFrac.timeFrac * (seg.end - seg.start);
const splitX = (splitT / this.totalDuration) * totalW - scrollX;
if (splitX >= 0 && splitX <= W) {
ctx.save();
ctx.strokeStyle = ACCENT_STRONG;
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(splitX, 0);
ctx.lineTo(splitX, H);
ctx.stroke();
ctx.restore();
}
}
}
}
/**
* Converts a time (seconds) to a clientX coordinate over the region lane.
* @param {number} time - seconds
* @returns {number} clientX in viewport pixels
*/
timeToClientX(time) {
const scrollEl = this.getScrollEl();
const laneEl = this.regionLane;
const rect = laneEl.getBoundingClientRect();
const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
const mouseX = (time / this.totalDuration) * totalW - scrollX;
const clientX = mouseX + rect.left;
return clientX;
}
/**
* Converts a clientX coordinate over the region lane to a time in seconds.
* @param {number} clientX - viewport X coordinate
* @returns {number} time in seconds
*/
clientXToTime(clientX) {
const scrollEl = this.getScrollEl();
const laneEl = this.regionLane;
const rect = laneEl.getBoundingClientRect();
const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
const mouseX = clientX - rect.left;
const time = ((mouseX + scrollX) / totalW) * this.totalDuration;
return time;
}
/**
* Given a mouse clientX coordinate over the region lane, returns the index
* of the transcript segment whose time range covers that position.
* @param {number} clientX - mouse X in viewport coordinates
* @returns {number} segment index, or -1 if no segment at that position
*/
regionIndexAtX(clientX) {
const time = this.clientXToTime(clientX);
return this.regionIndexAtTime(time);
//
// if (!this.activeProject.transcript().segments.length || !this.totalDuration) return -1;
// const scrollEl = this.getScrollEl();
// const laneEl = this.regionLane;
// const rect = laneEl.getBoundingClientRect();
// const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
// const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
// const mouseX = clientX - rect.left;
//
// for (let i = 0; i < this.activeProject.transcript().segments.length; i++) {
// const seg = this.activeProject.transcript().segments[i];
// const xStart = (seg.start / this.totalDuration) * totalW - scrollX;
// const xEnd = (seg.end / this.totalDuration) * totalW - scrollX;
// if (mouseX >= xStart && mouseX < xEnd) return i;
// }
// return -1;
}
/**
* Returns the index of the region at the given time
* @param {number} time - time in seconds to look up
* @returns {number} the segment index at the given time, or -1 if none
*/
regionIndexAtTime(time) {
const segAtTime = this.activeProject.segmentAtTime(time);
return segAtTime ?? -1;
}
}