var BirdNETPlayerModule = (function(exports) {
	Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
	//#region \0rolldown/runtime.js
	var __defProp = Object.defineProperty;
	var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
	var __exportAll = (all, no_symbols) => {
		let target = {};
		for (var name in all) __defProp(target, name, {
			get: all[name],
			enumerable: true
		});
		if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
		return target;
	};
	//#endregion
	//#region src/ui/template.ts
	/**
	* Default options for the player UI.
	* Every key corresponds to a visible section that can be toggled off.
	*/
	var DEFAULT_OPTIONS = {
		showFileOpen: true,
		showTransport: true,
		showTime: true,
		showVolume: true,
		showViewToggles: true,
		showZoom: true,
		showFFTControls: true,
		showDisplayGain: true,
		showStatusbar: true,
		showOverview: true,
		viewMode: "both",
		transportStyle: "default",
		transportOverlay: false,
		showWaveformTimeline: true,
		compactToolbar: "auto",
		followGuardLeftRatio: .35,
		followGuardRightRatio: .65,
		followTargetRatio: .5,
		followCatchupDurationMs: 240,
		followCatchupSeekDurationMs: 360,
		smoothLerp: .18,
		smoothSeekLerp: .08,
		smoothMinStepRatio: .03,
		smoothSeekMinStepRatio: .008,
		smoothSeekFocusMs: 1400
	};
	/**
	* @param {Partial<typeof DEFAULT_OPTIONS>} opts
	*/
	function createPlayerHTML(opts = {}) {
		const o = {
			...DEFAULT_OPTIONS,
			...opts
		};
		const hide = (flag) => flag ? "" : " style=\"display:none\"";
		const viewMode = [
			"both",
			"waveform",
			"spectrogram"
		].includes(o.viewMode) ? o.viewMode : "both";
		const transportStyle = o.transportStyle === "hero" ? "hero" : "default";
		const compactToolbar = [
			"auto",
			"on",
			"off"
		].includes(o.compactToolbar) ? o.compactToolbar : "auto";
		return `<div class="${[
			"daw-shell",
			`view-mode-${viewMode}`,
			`transport-style-${transportStyle}`,
			`compact-toolbar-${compactToolbar}`,
			o.transportOverlay ? "transport-overlay" : ""
		].join(" ")}">

    <!-- ═══ Top Toolbar ═══ -->
    <div class="toolbar" data-aw="toolbarRoot">
      <div class="toolbar-primary">
        <button class="toolbar-btn file-btn" data-aw="openFileBtn" title="Load audio file"${hide(o.showFileOpen)}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 15v4a2 2 0 002 2h14a2 2 0 002-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
            Open
        </button>
        <input type="file" data-aw="audioFile" accept="audio/*" hidden>

        <div class="toolbar-sep"${hide(o.showFileOpen)}></div>

        <!-- Transport -->
        <div class="transport"${hide(o.showTransport)}>
            <button class="transport-btn" data-aw="jumpStartBtn" disabled title="Jump to start (Home)">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="5" width="3" height="14"/><polygon points="20 5 10 12 20 19"/></svg>
            </button>
            <button class="transport-btn" data-aw="backwardBtn" disabled title="-5s (J)">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="19 5 9 12 19 19"/><polygon points="12 5 2 12 12 19"/></svg>
            </button>
            <button class="transport-btn play-btn" data-aw="playPauseBtn" disabled title="Play / Pause (Space)">
                <svg class="icon-play" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 3 20 12 6 21"/></svg>
            </button>
            <button class="transport-btn" data-aw="stopBtn" disabled title="Stop">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
            </button>
            <button class="transport-btn" data-aw="forwardBtn" disabled title="+5s (L)">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 5 15 12 5 19"/><polygon points="12 5 22 12 12 19"/></svg>
            </button>
            <button class="transport-btn" data-aw="jumpEndBtn" disabled title="Jump to end (End)">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="4 5 14 12 4 19"/><rect x="18" y="5" width="3" height="14"/></svg>
            </button>
        </div>

        <!-- Time -->
        <div class="time-display" data-aw="timeDisplay" role="status" aria-live="polite"${hide(o.showTime)}>
            <span data-aw="currentTime">00:00.0</span><span class="time-sep">/</span><span data-aw="totalTime">00:00.0</span>
        </div>
        <button class="toolbar-btn compact-more-btn" data-aw="compactMoreBtn" aria-expanded="false" title="Show more controls">More</button>
      </div>

      <div class="toolbar-secondary" data-aw="toolbarSecondary">

        <div class="toolbar-sep"${hide(o.showVolume)}></div>

        <!-- Volume -->
        <button class="toolbar-btn icon-btn" data-aw="volumeToggleBtn" title="Mute / Unmute"${hide(o.showVolume)}>
            <svg data-aw="volumeIcon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
                <polygon points="6 9 2 9 2 15 6 15 11 19 11 5"/>
                <path data-aw="volumeWaves" d="M15 8.5a4 4 0 010 7M18 5a9 9 0 010 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
        </button>
        <input type="range" data-aw="volumeSlider" class="toolbar-range toolbar-range-sm" min="0" max="100" value="80" title="Volume"${hide(o.showVolume)}>

        <div class="toolbar-sep"${hide(o.showViewToggles)}></div>

        <!-- Toggle tools -->
        <span${hide(o.showViewToggles)}>
            <button class="toolbar-btn toggle-btn active" data-aw="followToggleBtn" disabled title="Toggle Free / Follow / Smooth">Follow</button>
            <button class="toolbar-btn toggle-btn" data-aw="loopToggleBtn" disabled title="Loop">Loop</button>
            <button class="toolbar-btn toggle-btn" data-aw="crosshairToggleBtn" disabled title="Toggle crosshair">Crosshair</button>
            <button class="toolbar-btn" data-aw="fitViewBtn" disabled title="Fit to view">Fit</button>
            <button class="toolbar-btn" data-aw="resetViewBtn" disabled title="Reset zoom">Reset</button>
        </span>

        <div class="toolbar-sep"${hide(o.showZoom)}></div>

        <!-- Zoom -->
        <span${hide(o.showZoom)} class="zoom-controls-row">
            <label class="toolbar-label">H</label>
            <input type="range" data-aw="zoomSlider" class="toolbar-range toolbar-range-zoom" min="20" max="450" value="100" step="5">
            <span class="toolbar-value toolbar-value-sm" data-aw="zoomValue">100 px/s</span>
            <label class="toolbar-label freq-zoom-label">V</label>
            <input type="range" data-aw="freqZoomSlider" class="toolbar-range toolbar-range-zoom" min="0" max="100" value="0" step="1" title="Vertical frequency zoom">
        </span>

        <div class="toolbar-sep"${hide(o.showFFTControls)}></div>

        <!-- Settings toggle (opens side panel) -->
        <button class="toolbar-btn settings-toggle-btn" data-aw="settingsToggleBtn" title="Open settings panel"${hide(o.showFFTControls)}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
            Settings
        </button>
      </div>
    </div>

    <!-- ═══ Settings Side-Panel ═══ -->
    <div class="settings-panel" data-aw="settingsPanel">
        <div class="settings-panel-header">
            <span class="settings-panel-title">Settings</span>
            <button class="settings-panel-close" data-aw="settingsPanelClose" title="Close">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
            </button>
        </div>

        <div class="settings-section preset-section">
            <div class="settings-row">
                <label class="settings-label">Preset</label>
                <select data-aw="presetSelect" class="settings-select">
                </select>
                <button class="toolbar-btn mini-btn" data-aw="presetFavBtn" title="Set as default preset" disabled>
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
                </button>
                <button class="toolbar-btn mini-btn" data-aw="presetSaveBtn" title="Save current settings as preset">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.2 3a2 2 0 011.4.6l3.8 3.8a2 2 0 01.6 1.4V19a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z"/><path d="M17 21v-7a1 1 0 00-1-1H8a1 1 0 00-1 1v7"/><path d="M7 3v4a1 1 0 001 1h7"/></svg>
                </button>
                <button class="toolbar-btn mini-btn" data-aw="presetManageBtn" title="Manage presets">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="M3 18h18"/><path d="M3 6h18"/></svg>
                </button>
            </div>
            <div class="preset-save-row" data-aw="presetSaveRow" hidden>
                <input type="text" data-aw="presetSaveInput" class="settings-input" placeholder="Preset name…" maxlength="40">
                <button class="toolbar-btn mini-btn pm-confirm" data-aw="presetSaveConfirm" title="Save">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
                </button>
                <button class="toolbar-btn mini-btn" data-aw="presetSaveCancel" title="Cancel">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
                </button>
            </div>
            <div class="preset-manager-inline" data-aw="presetManagerPanel" hidden>
                <div class="preset-manager-list" data-aw="presetManagerList"></div>
                <div class="pm-actions">
                    <button class="pm-action-btn" data-aw="presetImportBtn" title="Import presets from JSON">
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
                        Import
                    </button>
                    <button class="pm-action-btn" data-aw="presetExportBtn" title="Export user presets as JSON">
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
                        Export
                    </button>
                    <span class="pm-status" data-aw="presetStatus"></span>
                </div>
            </div>
        </div>

        <div class="settings-section quality-slider-section">
            <h3 class="settings-section-title">Quality</h3>
            <div class="settings-row quality-slider-row">
                <span class="quality-end-label">⚡</span>
                <input type="range" data-aw="qualitySlider" class="settings-range quality-range" min="0" max="4" value="2" step="1" list="qualityStops">
                <span class="quality-end-label">🔬</span>
                <datalist id="qualityStops">
                    <option value="0"></option><option value="1"></option><option value="2"></option><option value="3"></option><option value="4"></option>
                </datalist>
            </div>
            <div class="quality-label-bar">
                <span>Performance</span><span>Balanced</span><span>Quality</span><span>High</span><span>Ultra</span>
            </div>
            <div class="quality-level-display" data-aw="qualityLevelDisplay">Quality</div>
        </div>

        <div class="settings-section">
            <h3 class="settings-section-title">Gain</h3>
            <div class="settings-row">
                <label class="settings-label">Mode</label>
                <select data-aw="gainModeSelect" class="settings-select" title="Auto: percentile-based contrast on each file. Fixed: use saved floor/ceil values.">
                    <option value="auto" selected>Auto</option>
                    <option value="fixed">Fixed</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Floor</label>
                <input type="range" data-aw="floorSlider" class="settings-range" min="0" max="100" value="0" title="Spectrogram floor">
            </div>
            <div class="settings-row">
                <label class="settings-label">Ceiling</label>
                <input type="range" data-aw="ceilSlider" class="settings-range" min="0" max="100" value="100" title="Spectrogram ceiling">
            </div>
            <div class="settings-row">
                <button class="toolbar-btn mini-btn" data-aw="autoContrastBtn" disabled title="Auto optimize contrast">Auto Contrast</button>
            </div>
        </div>

        <div class="settings-section">
            <h3 class="settings-section-title">Display</h3>
            <div class="settings-row">
                <label class="settings-label">Max Freq</label>
                <select data-aw="maxFreqModeSelect" class="settings-select" title="Auto: detect from content. Nyquist: full bandwidth. Fixed: use selected value.">
                    <option value="auto" selected>Auto</option>
                    <option value="nyquist">Nyquist</option>
                    <option value="fixed">Fixed</option>
                </select>
                <select data-aw="maxFreqSelect" class="settings-select">
                    <option value="10000" selected>10 kHz</option>
                </select>
                <button class="toolbar-btn mini-btn" data-aw="autoFreqBtn" disabled title="Auto-detect frequency range">AF</button>
            </div>
            <div class="settings-row">
                <label class="settings-label">Color</label>
                <select data-aw="colorSchemeSelect" class="settings-select">
                    <option value="grayscale" selected>B/W</option>
                    <option value="xenocanto">XC</option>
                    <option value="fire">Fire</option>
                    <option value="inferno">Inferno</option>
                    <option value="viridis">Viridis</option>
                    <option value="magma">Magma</option>
                    <option value="plasma">Plasma</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label" title="Median-based spectral noise floor subtraction">
                    <input type="checkbox" data-aw="noiseReductionCheck"> Noise Reduction
                </label>
            </div>
            <div class="settings-row">
                <label class="settings-label" title="Contrast Limited Adaptive Histogram Equalization — enhances local detail">
                    <input type="checkbox" data-aw="claheCheck"> Adaptive Contrast
                </label>
            </div>
        </div>

        <div class="settings-section">
            <h3 class="settings-section-title">Overlay</h3>
            <div class="settings-row">
                <label class="settings-label" title="Weighted mean frequency of the magnitude spectrum per frame">
                    <input type="checkbox" data-aw="showCentroidCheck"> Spectral Centroid
                </label>
            </div>
            <div class="settings-row">
                <label class="settings-label" title="Fundamental frequency (F0) estimated via cepstral analysis">
                    <input type="checkbox" data-aw="showF0Check"> Cepstral Pitch (F0)
                </label>
            </div>
            <div class="settings-row">
                <label class="settings-label" title="Spectral ridges: continuous frequency tracks of local spectral maxima (harmonics and tonal components)">
                    <input type="checkbox" data-aw="showRidgesCheck"> Spectral Ridges
                </label>
            </div>
        </div>

        <div class="settings-section">
            <h3 class="settings-section-title">DSP</h3>
            <div class="settings-row">
                <label class="settings-label">Frequency</label>
                <select data-aw="scaleSelect" class="settings-select">
                    <option value="mel" selected>Mel</option>
                    <option value="linear">Linear</option>
                    <option value="cqt">CQT</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Colour Scale</label>
                <select data-aw="colourScaleSelect" class="settings-select">
                    <option value="dbSquared" selected>dB²</option>
                    <option value="db">dB</option>
                    <option value="linear">Linear</option>
                    <option value="meter">Meter</option>
                    <option value="phase">Phase</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Window</label>
                <select data-aw="windowSize" class="settings-select" title="Window Size (samples)">
                    <option value="256">256</option>
                    <option value="512">512</option>
                    <option value="1024" selected>1024</option>
                    <option value="2048">2048</option>
                    <option value="4096">4096</option>
                    <option value="8192">8192</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Window Fn</label>
                <select data-aw="windowFunction" class="settings-select" title="Window Function">
                    <option value="hann" selected>Hann</option>
                    <option value="hamming">Hamming</option>
                    <option value="blackman">Blackman</option>
                    <option value="blackmanHarris">Blackman-Harris</option>
                    <option value="kaiser">Kaiser (β=6)</option>
                    <option value="flatTop">Flat Top</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label" title="Time-frequency reassignment sharpens spectral peaks">
                    <input type="checkbox" data-aw="reassignedCheck"> Reassigned
                </label>
            </div>
            <div class="settings-row">
                <label class="settings-label">Overlap</label>
                <select data-aw="overlapSelect" class="settings-select" title="Window Overlap — higher = smoother time axis">
                    <option value="0">None</option>
                    <option value="1">25 %</option>
                    <option value="2" selected>50 %</option>
                    <option value="3">75 %</option>
                    <option value="4">87.5 %</option>
                    <option value="5">93.75 %</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Oversampling</label>
                <select data-aw="oversamplingSelect" class="settings-select" title="Zero-pad FFT — higher = finer frequency resolution">
                    <option value="0" selected>1×</option>
                    <option value="1">2×</option>
                    <option value="2">4×</option>
                    <option value="3">8×</option>
                </select>
            </div>
            <div class="settings-row">
                <label class="settings-label">Mel Bins</label>
                <input type="number" data-aw="nMelsInput" class="settings-number" value="160" min="16" max="512" step="16" title="Number of mel bands">
            </div>
        </div>

        <div class="settings-section" data-aw="pcenSection">
            <h3 class="settings-section-title">
                <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
                    <input type="checkbox" data-aw="pcenEnabledCheck" checked>
                    PCEN
                </label>
            </h3>
            <div class="settings-row">
                <label class="settings-label">Gain</label>
                <input type="number" data-aw="pcenGainInput" class="settings-number" value="0.8" min="0" max="2" step="0.05">
            </div>
            <div class="settings-row">
                <label class="settings-label">Bias</label>
                <input type="number" data-aw="pcenBiasInput" class="settings-number" value="0.01" min="0" max="1" step="0.005">
            </div>
            <div class="settings-row">
                <label class="settings-label">Root</label>
                <input type="number" data-aw="pcenRootInput" class="settings-number" value="4.0" min="1" max="10" step="0.5">
            </div>
            <div class="settings-row">
                <label class="settings-label">Smoothing</label>
                <input type="number" data-aw="pcenSmoothingInput" class="settings-number" value="0.025" min="0" max="0.5" step="0.005">
            </div>
        </div>
    </div>

    <!-- ═══ Main Content ═══ -->
    <div class="views-panel">
        <!-- Waveform -->
        <div class="waveform-container" data-aw="waveformContainer">
            <div class="time-aligned-row">
                <div class="axis-spacer">
                    <div class="amplitude-labels" data-aw="amplitudeLabels"></div>
                </div>
                <div class="time-pane">
                    <div class="waveform-wrapper" data-aw="waveformWrapper">
                        <div class="waveform-content" data-aw="waveformContent">
                            <canvas data-aw="amplitudeCanvas"></canvas>
                            <canvas data-aw="waveformTimelineCanvas"></canvas>
                        </div>
                        <div class="playhead playhead-secondary" data-aw="waveformPlayhead"></div>
                    </div>
                </div>
            </div>
            <div data-aw="audioEngineHost" style="position:absolute;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;"></div>
        </div>

        <!-- Split handle -->
        <div class="view-split-handle" data-aw="viewSplitHandle" title="Adjust amplitude/spectrogram ratio"></div>

        <!-- Spectrogram -->
        <div class="spectrogram-container" data-aw="spectrogramContainer">
            <div class="time-aligned-row">
                <div class="axis-spacer freq-axis-spacer" data-aw="freqAxisSpacer">
                    <div class="frequency-labels" data-aw="freqLabels"></div>
                    <button class="freq-zoom-reset-btn" data-aw="freqZoomResetBtn" hidden title="Reset vertical zoom (Shift+DblClick)">⟷</button>
                </div>
                <div class="time-pane">
                    <div class="freq-scrollbar" data-aw="freqScrollbar" hidden>
                        <div class="freq-scrollbar-thumb" data-aw="freqScrollbarThumb"></div>
                    </div>
                    <div class="canvas-wrapper" data-aw="canvasWrapper"
                         role="slider"
                         aria-label="Playback position"
                         aria-valuemin="0"
                         aria-valuemax="0"
                         aria-valuenow="0"
                         aria-valuetext="00:00.0 of 00:00.0"
                         tabindex="0">
                        <div class="canvas-sizer" data-aw="canvasSizer" aria-hidden="true"></div>
                        <canvas data-aw="spectrogramCanvas"></canvas>
                        <canvas class="crosshair-overlay" data-aw="crosshairCanvas"></canvas>
                        <div class="crosshair-readout" data-aw="crosshairReadout"></div>
                        <div class="playhead" data-aw="playhead"></div>
                    </div>
                </div>
            </div>
            <div class="recomputing-overlay" data-aw="recomputingOverlay" aria-live="polite" hidden>
                <span class="recomputing-spinner"></span>
                <span>Computing…</span>
            </div>
            <div class="spectrogram-resize-handle" data-aw="spectrogramResizeHandle" title="Adjust spectrogram height"></div>
        </div>

        <!-- Overview -->
        <div class="overview-container" data-aw="overviewContainer"${hide(o.showOverview)}>
            <canvas data-aw="overviewCanvas"></canvas>
            <div class="overview-window" data-aw="overviewWindow">
                <div class="handle left" data-aw="overviewHandleLeft"></div>
                <div class="handle right" data-aw="overviewHandleRight"></div>
            </div>
        </div>
        <div class="overview-label-section" data-aw="overviewLabelSection"${hide(o.showOverview)}>
            <button class="overview-label-tab" data-aw="overviewLabelToggle" type="button" title="Toggle label overview" aria-expanded="true">
                <svg class="overview-label-tab-chevron" width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
                    <polyline points="1,2.5 4.5,6.5 8,2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
                </svg>
                <span class="overview-label-tab-text">Labels</span>
            </button>
            <div class="overview-label-tracks" data-aw="overviewLabelTracks"></div>
        </div>
    </div>

    <!-- ═══ Status Bar ═══ -->
    <div class="statusbar"${hide(o.showStatusbar)}>
        <div class="statusbar-section" data-aw="fileInfo">
            <span class="statusbar-label">No file</span>
        </div>
        <div class="statusbar-section">
            <span class="statusbar-label" data-aw="sampleRateInfo"></span>
        </div>
        <div class="statusbar-spacer"></div>
        <div class="statusbar-section">
            <span data-aw="viewRange"></span>
        </div>
        <div class="statusbar-section">
            <span data-aw="playState">Idle</span>
        </div>
    </div>
</div>`;
	}
	//#endregion
	//#region src/shared/constants.ts
	var DEFAULT_SAMPLE_RATE = 32e3;
	var SEEK_FINE_SEC = .5;
	var MAX_BASE_SPECTROGRAM_WIDTH = 24e3;
	var MIN_WINDOW_NORM = .02;
	var PERCH_PCEN_GAIN = .8;
	var PERCH_PCEN_BIAS = .01;
	var PERCH_PCEN_SMOOTHING = .025;
	function windowHopFromOverlap(windowSize, overlapLevel) {
		if (overlapLevel <= 0) return windowSize;
		if (overlapLevel === 1) return windowSize * 3 >> 2;
		return windowSize >> overlapLevel - 1;
	}
	function fftSizeFromOversampling(windowSize, oversamplingLevel) {
		return windowSize << oversamplingLevel;
	}
	var DSP_PROFILES = {
		perch: {
			scale: "mel",
			windowSize: 1024,
			overlapLevel: 2,
			oversamplingLevel: 1,
			nMels: 160,
			frameRate: 100,
			usePcen: true,
			pcenGain: PERCH_PCEN_GAIN,
			pcenBias: PERCH_PCEN_BIAS,
			pcenRoot: 4,
			pcenSmoothing: PERCH_PCEN_SMOOTHING,
			windowFunction: "hann",
			colorScheme: "grayscale",
			reassigned: false,
			maxFreqMode: "nyquist"
		},
		classic: {
			scale: "linear",
			windowSize: 2048,
			overlapLevel: 2,
			oversamplingLevel: 0,
			nMels: 160,
			frameRate: 100,
			usePcen: false,
			pcenGain: 0,
			pcenBias: 0,
			pcenRoot: 1,
			pcenSmoothing: 0,
			windowFunction: "hann",
			colorScheme: "xenocanto",
			reassigned: false,
			maxFreqMode: "nyquist"
		},
		birder: {
			scale: "linear",
			colourScale: "dbSquared",
			windowSize: 2048,
			overlapLevel: 4,
			oversamplingLevel: 0,
			nMels: 200,
			usePcen: true,
			pcenGain: .8,
			pcenBias: .01,
			pcenRoot: 4,
			pcenSmoothing: .025,
			windowFunction: "blackmanHarris",
			colorScheme: "inferno",
			reassigned: false,
			noiseReduction: false,
			clahe: true,
			gainMode: "fixed",
			gainFloor: 49,
			gainCeil: 100,
			maxFreqMode: "nyquist"
		}
	};
	var CQT_FMIN = 32.7;
	var QUALITY_LEVELS = [
		{
			label: "Performance",
			windowSize: 256,
			overlapLevel: 1,
			oversamplingLevel: 0,
			nMels: 80
		},
		{
			label: "Balanced",
			windowSize: 512,
			overlapLevel: 2,
			oversamplingLevel: 0,
			nMels: 128
		},
		{
			label: "Quality",
			windowSize: 1024,
			overlapLevel: 2,
			oversamplingLevel: 1,
			nMels: 160
		},
		{
			label: "High",
			windowSize: 2048,
			overlapLevel: 3,
			oversamplingLevel: 1,
			nMels: 200
		},
		{
			label: "Ultra",
			windowSize: 4096,
			overlapLevel: 3,
			oversamplingLevel: 2,
			nMels: 256
		}
	];
	//#endregion
	//#region src/shared/utils.ts
	function clamp(value, min, max) {
		return Math.max(min, Math.min(max, value));
	}
	function clampNumber(value, min, max, fallback) {
		const n = Number(value);
		if (!Number.isFinite(n)) return fallback;
		return Math.max(min, Math.min(max, n));
	}
	function formatTime(seconds) {
		const s = Math.max(0, seconds);
		const mins = Math.floor(s / 60);
		const secs = (s % 60).toFixed(1);
		return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(4, "0")}`;
	}
	function formatSecondsShort(seconds) {
		return `${seconds.toFixed(2)}s`;
	}
	function isTypingContext(target) {
		if (!target || !("tagName" in target)) return false;
		const el = target;
		return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
	}
	function getTimeGridSteps(pixelsPerSecond) {
		const majorStep = pixelsPerSecond >= 320 ? .5 : pixelsPerSecond >= 180 ? 1 : pixelsPerSecond >= 90 ? 2 : pixelsPerSecond >= 45 ? 5 : 10;
		return {
			majorStep,
			minorStep: majorStep / 2
		};
	}
	function hexToRgb(hex) {
		if (!hex) return null;
		let h = String(hex).replace("#", "").trim();
		if (h.length === 3) h = h.split("").map((c) => c + c).join("");
		if (h.length !== 6) return null;
		return [
			parseInt(h.slice(0, 2), 16),
			parseInt(h.slice(2, 4), 16),
			parseInt(h.slice(4, 6), 16)
		];
	}
	function colorWithAlpha(color, alpha = 1) {
		if (!color) return color;
		const c = color.trim();
		if (c.startsWith("rgba")) {
			const inner = c.slice(5, -1).split(",").map((s) => s.trim());
			return `rgba(${inner[0]}, ${inner[1]}, ${inner[2]}, ${alpha})`;
		}
		if (c.startsWith("rgb(")) {
			const inner = c.slice(4, -1).split(",").map((s) => s.trim());
			return `rgba(${inner[0]}, ${inner[1]}, ${inner[2]}, ${alpha})`;
		}
		if (c[0] === "#") {
			const rgb = hexToRgb(c);
			if (rgb) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
		}
		return c;
	}
	function parseNativeSampleRate(buf) {
		if (buf.byteLength < 10) return 0;
		const view = new DataView(buf);
		if (buf.byteLength >= 28 && view.getUint32(0) === 1380533830 && view.getUint32(8) === 1463899717) return view.getUint32(24, true);
		if (buf.byteLength >= 21 && view.getUint32(0) === 1716281667) return view.getUint8(18) << 12 | view.getUint8(19) << 4 | view.getUint8(20) >> 4;
		if (view.getUint32(0) === 1332176723 && buf.byteLength >= 64) {
			const dataOffset = 27 + view.getUint8(26);
			if (buf.byteLength >= dataOffset + 16) {
				if (view.getUint8(dataOffset) === 1 && view.getUint32(dataOffset + 1) === 1987015266) return view.getUint32(dataOffset + 12, true);
				if (view.getUint32(dataOffset) === 1332770163 && view.getUint32(dataOffset + 4) === 1214603620) return view.getUint32(dataOffset + 12, true);
			}
		}
		{
			const MP3_SAMPLE_RATES = [
				[
					11025,
					12e3,
					8e3
				],
				[
					0,
					0,
					0
				],
				[
					22050,
					24e3,
					16e3
				],
				[
					44100,
					48e3,
					32e3
				]
			];
			let scanStart = 0;
			if (view.getUint8(0) === 73 && view.getUint8(1) === 68 && view.getUint8(2) === 51) scanStart = 10 + ((view.getUint8(6) & 127) << 21 | (view.getUint8(7) & 127) << 14 | (view.getUint8(8) & 127) << 7 | view.getUint8(9) & 127);
			const scanEnd = Math.min(buf.byteLength - 3, scanStart + 4096);
			for (let i = scanStart; i < scanEnd; i++) {
				if (view.getUint8(i) !== 255 || (view.getUint8(i + 1) & 224) !== 224) continue;
				const h = view.getUint32(i);
				const version = h >>> 19 & 3;
				const layer = h >>> 17 & 3;
				const srIndex = h >>> 10 & 3;
				if (version === 1 || layer === 0 || srIndex === 3) continue;
				return MP3_SAMPLE_RATES[version][srIndex];
			}
		}
		return 0;
	}
	function escapeHtml(value) {
		return String(value ?? "").split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split("\"").join("&quot;").split("'").join("&#39;");
	}
	//#endregion
	//#region src/infrastructure/audio/AudioEngineBase.ts
	var AudioEngineBase = class extends EventTarget {
		loadFromArrayBuffer(_buf, _name) {
			return this._niAsync("loadFromArrayBuffer");
		}
		loadFromUrl(_url) {
			return this._niAsync("loadFromUrl");
		}
		loadFromFile(_file) {
			return this._niAsync("loadFromFile");
		}
		playPause() {
			this._ni("playPause");
		}
		stop() {
			this._ni("stop");
		}
		seekToTime(_timeSec, _centerView, _options) {
			this._ni("seekToTime");
		}
		seekByDelta(_deltaSec) {
			this._ni("seekByDelta");
		}
		getCurrentTime() {
			this._ni("getCurrentTime");
			return 0;
		}
		isPlaying() {
			this._ni("isPlaying");
			return false;
		}
		playSegment(_startSec, _endSec, _options) {
			this._ni("playSegment");
		}
		playBandpassedSegment(_startSec, _endSec, _freqMinHz, _freqMaxHz, _options) {
			this._ni("playBandpassedSegment");
		}
		stopSegmentPlayback(_reason, _targetTimeSec) {
			this._ni("stopSegmentPlayback");
		}
		endNormalSegment(_targetTimeSec) {
			this._ni("endNormalSegment");
		}
		setVolume(_val) {
			this._ni("setVolume");
		}
		toggleMute() {
			this._ni("toggleMute");
		}
		updateActiveSegmentFromLabel(_label) {
			this._ni("updateActiveSegmentFromLabel");
		}
		destroy() {
			this._ni("destroy");
		}
		_clearPlaybackFilter() {
			this._ni("_clearPlaybackFilter");
		}
		_clearActiveSegment() {
			this._ni("_clearActiveSegment");
		}
		_ni(name) {
			throw new Error(`${this.constructor.name}: '${name}' not implemented`);
		}
		_niAsync(name) {
			return Promise.reject(/* @__PURE__ */ new Error(`${this.constructor.name}: '${name}' not implemented`));
		}
	};
	//#endregion
	//#region src/infrastructure/audio/AudioEngine.ts
	/**
	* @typedef {Object} SegmentFilter
	* @property {'bandpass'} type
	* @property {number} freqMinHz
	* @property {number} freqMaxHz
	*/
	/**
	* @typedef {Object} SegmentPlayback
	* @property {number} token
	* @property {AudioContext} ctx
	* @property {AudioBufferSourceNode|null} source
	* @property {BiquadFilterNode} bandpass
	* @property {GainNode} gain
	* @property {number} startSec
	* @property {number} endSec
	* @property {number} startAtCtx
	* @property {number} runStartSec
	* @property {number} sourceGeneration
	* @property {number} rafId
	* @property {number} currentTimeSec
	*/
	/**
	* Decode an ArrayBuffer into an AudioBuffer, preserving the file's native
	* sample rate by pre-parsing the header before creating the AudioContext.
	* @param {ArrayBuffer} arrayBuffer
	* @returns {Promise<AudioBuffer>}
	*/
	async function decodeArrayBuffer(arrayBuffer) {
		const Ctor = window.AudioContext || window.webkitAudioContext;
		if (!Ctor) throw new Error("AudioContext is not supported by this browser.");
		const nativeSr = parseNativeSampleRate(arrayBuffer);
		const ctx = new Ctor(nativeSr > 0 ? { sampleRate: nativeSr } : void 0);
		try {
			return await ctx.decodeAudioData(arrayBuffer);
		} finally {
			ctx.close?.().catch(() => {});
		}
	}
	/**
	* AudioEngine — Wraps WaveSurfer and audio playback.
	* Extends EventTarget for event emission.
	*/
	var AudioEngine = class extends AudioEngineBase {
		/**
		* @param {Function} WaveSurferCtor - WaveSurfer constructor (loaded at runtime)
		* @param {Object} [options]
		* @param {HTMLElement} [options.container] - Container element for WaveSurfer
		*/
		constructor(WaveSurferCtor, options = {}) {
			super();
			this._WaveSurferCtor = WaveSurferCtor;
			this._container = options.container || null;
			/** @type {AudioBuffer|null} */
			this.audioBuffer = null;
			/** @type {any|null} WaveSurfer instance */
			this.wavesurfer = null;
			/** @type {number} Volume 0-1 */
			this.volume = .8;
			/** @type {boolean} */
			this.muted = false;
			/** @type {number} Volume before mute */
			this.preMuteVolume = .8;
			/** @type {boolean} True while a segment (labelled or unlabelled) is the active playback target. */
			this._segmentMode = false;
			/** @type {string|null} */
			this._activeSegmentLabelId = null;
			/** @type {SegmentFilter|null} */
			this._activeSegmentFilter = null;
			/** @type {number|null} */
			this._activeSegmentStart = null;
			/** @type {number|null} */
			this._activeSegmentEnd = null;
			/** @type {boolean} */
			this._suppressNextPauseHandler = false;
			/** @type {number} */
			this._segmentPlayToken = 0;
			/** @type {SegmentPlayback|null} */
			this._customSegmentPlayback = null;
			/** @type {number} */
			this._lastTimeupdateEmitAt = 0;
			/** @type {number} */
			this.pixelsPerSecond = 100;
			/** @type {boolean} */
			this.loopPlayback = false;
		}
		/** @returns {'normal'|'segment'} */
		get playbackMode() {
			return this._segmentMode ? "segment" : "normal";
		}
		set playbackMode(v) {
			this._segmentMode = v === "segment";
		}
		/**
		* Loads audio from an ArrayBuffer.
		* @param {ArrayBuffer} arrayBuffer
		* @param {string} [name] - Display filename
		* @returns {Promise<{duration: number, sampleRate: number}>}
		*/
		async loadFromArrayBuffer(arrayBuffer, name) {
			const audioBuffer = await decodeArrayBuffer(arrayBuffer);
			this.audioBuffer = audioBuffer;
			const displayName = name || "audio";
			this._setupWaveSurfer(null, displayName);
			return {
				duration: audioBuffer.duration,
				sampleRate: audioBuffer.sampleRate
			};
		}
		/**
		* Loads audio from a URL.
		* @param {string} url
		* @returns {Promise<{duration: number, sampleRate: number}>}
		*/
		async loadFromUrl(url) {
			const response = await fetch(url);
			if (!response.ok) throw new Error(`HTTP ${response.status}`);
			const audioBuffer = await decodeArrayBuffer(await response.arrayBuffer());
			this.audioBuffer = audioBuffer;
			const name = decodeURIComponent(new URL(url, location.href).pathname.split("/").pop() || "audio");
			this._setupWaveSurfer(url, name);
			return {
				duration: audioBuffer.duration,
				sampleRate: audioBuffer.sampleRate
			};
		}
		/**
		* Loads audio from a File object (WaveSurfer loads the file directly via loadBlob).
		* @param {File} file
		* @returns {Promise<{duration: number, sampleRate: number}>}
		*/
		async loadFromFile(file) {
			const audioBuffer = await decodeArrayBuffer(await file.arrayBuffer());
			this.audioBuffer = audioBuffer;
			this._setupWaveSurfer(file, file.name);
			return {
				duration: audioBuffer.duration,
				sampleRate: audioBuffer.sampleRate
			};
		}
		/**
		* Toggles playback (play/pause).
		*/
		playPause() {
			if (this._customSegmentPlayback) {
				this._stopCustomSegmentPlayback("paused", this._customSegmentPlayback.currentTimeSec);
				return;
			}
			this._clearActiveSegment();
			if (this.wavesurfer && this.audioBuffer) this.wavesurfer.playPause();
		}
		/**
		* Stops playback and seeks to the beginning.
		*/
		stop() {
			if (this._customSegmentPlayback) this._stopCustomSegmentPlayback("stopped", 0);
			if (!this.wavesurfer) return;
			this._clearActiveSegment();
			this.wavesurfer.pause();
			this.seekToTime(0);
			this._emit("transportstatechange", {
				state: "stopped",
				reason: "stop-control"
			});
			this._emit("pause", {});
		}
		/**
		* Seeks to a specific time.
		* @param {number} timeSec
		* @param {boolean} [centerView=false]
		* @param {Object} [options]
		*/
		seekToTime(timeSec, centerView = false, options = {}) {
			if (!this.audioBuffer) return;
			if (this._customSegmentPlayback && options.allowCustomPlayback !== true) this._stopCustomSegmentPlayback("paused", this._customSegmentPlayback.currentTimeSec);
			const t = clamp(timeSec, 0, this.audioBuffer.duration);
			if (this.wavesurfer) this.wavesurfer.setTime(t);
			this._emit("uiupdate", {
				time: t,
				fromPlayback: false,
				centerView,
				emitSeek: true,
				immediate: true
			});
		}
		/**
		* Seeks relative to the current time.
		* @param {number} deltaSec
		*/
		seekByDelta(deltaSec) {
			if (!this.audioBuffer) return;
			this.seekToTime(this.getCurrentTime() + deltaSec, false);
		}
		/**
		* Returns the current playback time.
		* @returns {number}
		*/
		getCurrentTime() {
			if (this._customSegmentPlayback) return this._customSegmentPlayback.currentTimeSec;
			return this.wavesurfer ? this.wavesurfer.getCurrentTime() : 0;
		}
		/**
		* Plays a time segment using WaveSurfer's native segment playback.
		* @param {number} startSec
		* @param {number} endSec
		* @param {Object} [options]
		* @param {string} [options.labelId]
		*/
		playSegment(startSec, endSec, options = {}) {
			if (!this.audioBuffer || !this.wavesurfer) return;
			this._clearPlaybackFilter();
			const dur = this.audioBuffer.duration;
			const start = clamp(startSec, 0, dur);
			const end = clamp(endSec, 0, dur);
			if (end - start < .01) return;
			this._clearActiveSegment();
			const token = this._segmentPlayToken;
			this.playbackMode = "segment";
			this._activeSegmentLabelId = options?.labelId || null;
			this._activeSegmentFilter = null;
			this._activeSegmentStart = start;
			this._activeSegmentEnd = end;
			if (this.wavesurfer.isPlaying()) {
				this._suppressNextPauseHandler = true;
				this.wavesurfer.pause();
			}
			this.seekToTime(start, false);
			if (token !== this._segmentPlayToken) return;
			const runPlay = () => {
				if (token !== this._segmentPlayToken) return;
				try {
					if (this.loopPlayback) {
						this.seekToTime(start, false, { allowCustomPlayback: true });
						this.wavesurfer.play();
						this._emit("segmentstart", {
							start,
							end,
							loop: true
						});
						return;
					}
					const maybePromise = this.wavesurfer.play(start, end);
					this._emit("segmentstart", {
						start,
						end
					});
					if (maybePromise && typeof maybePromise.then === "function") maybePromise.catch(() => {
						if (token !== this._segmentPlayToken) return;
						this.seekToTime(start, false);
						this.wavesurfer?.play();
					});
				} catch {
					if (token !== this._segmentPlayToken) return;
					this.seekToTime(start, false);
					this.wavesurfer?.play();
					this._emit("segmentstart", {
						start,
						end
					});
				}
			};
			try {
				window.requestAnimationFrame(runPlay);
			} catch {
				runPlay();
			}
		}
		/**
		* Plays a bandpass-filtered segment via a dedicated AudioContext graph.
		* @param {number} startSec
		* @param {number} endSec
		* @param {number} freqMinHz
		* @param {number} freqMaxHz
		* @param {Object} [options]
		* @param {string} [options.labelId]
		*/
		playBandpassedSegment(startSec, endSec, freqMinHz, freqMaxHz, options = {}) {
			if (!this.audioBuffer) return;
			const dur = this.audioBuffer.duration;
			const start = clamp(startSec, 0, dur);
			const end = clamp(endSec, 0, dur);
			if (end - start < .01) return;
			const nyquist = Math.max(100, this.audioBuffer.sampleRate * .5 - 10);
			const fLo = Math.max(20, Math.min(freqMinHz, freqMaxHz, nyquist - 5));
			const fHi = clamp(Math.max(freqMinHz, freqMaxHz), fLo + 5, nyquist);
			const center = Math.sqrt(fLo * fHi);
			const q = clamp(center / Math.max(10, fHi - fLo), .25, 40);
			this._stopCustomSegmentPlayback("stopped", start);
			this._clearPlaybackFilter();
			if (this.wavesurfer?.isPlaying()) {
				this._suppressNextPauseHandler = true;
				this.wavesurfer.pause();
			}
			this.seekToTime(start, false, { allowCustomPlayback: true });
			const Ctor = window.AudioContext || window.webkitAudioContext;
			if (!Ctor) {
				this.playSegment(start, end, { labelId: options?.labelId });
				return;
			}
			const token = ++this._segmentPlayToken;
			this.playbackMode = "segment";
			this._activeSegmentLabelId = options?.labelId || null;
			this._activeSegmentStart = start;
			this._activeSegmentEnd = end;
			this._activeSegmentFilter = {
				type: "bandpass",
				freqMinHz: fLo,
				freqMaxHz: fHi
			};
			const ctx = new Ctor();
			const bandpass = ctx.createBiquadFilter();
			bandpass.type = "bandpass";
			bandpass.frequency.value = center;
			bandpass.Q.value = q;
			const gain = ctx.createGain();
			gain.gain.value = this.muted ? 0 : this.volume;
			bandpass.connect(gain);
			gain.connect(ctx.destination);
			/** @type {SegmentPlayback} */
			const playback = {
				token,
				ctx,
				source: null,
				bandpass,
				gain,
				startSec: start,
				endSec: end,
				startAtCtx: 0,
				runStartSec: start,
				sourceGeneration: 0,
				rafId: 0,
				currentTimeSec: start
			};
			this._customSegmentPlayback = playback;
			this._startCustomSegmentSource(playback);
			this._emit("transportstatechange", {
				state: "playing_segment",
				reason: "bandpass-segment-start"
			});
			this._emit("segmentstart", {
				start,
				end,
				filter: {
					type: "bandpass",
					freqMinHz: fLo,
					freqMaxHz: fHi
				}
			});
			const onFrame = () => {
				if (!this._customSegmentPlayback || this._customSegmentPlayback.token !== token) return;
				const elapsed = Math.max(0, ctx.currentTime - playback.startAtCtx);
				const t = Math.min(playback.endSec, playback.runStartSec + elapsed);
				playback.currentTimeSec = t;
				this._emit("uiupdate", {
					time: t,
					fromPlayback: true
				});
				if (t >= playback.endSec - .002) {
					if (this.loopPlayback) {
						this._loopCustomSegmentPlayback(playback);
						playback.rafId = requestAnimationFrame(onFrame);
						return;
					}
					this._stopCustomSegmentPlayback("stopped", playback.endSec, { emitEnd: true });
					return;
				}
				playback.rafId = requestAnimationFrame(onFrame);
			};
			playback.rafId = requestAnimationFrame(onFrame);
		}
		/**
		* Stops active segment playback.
		* @param {string} [reason]
		* @param {number|null} [targetTimeSec]
		*/
		stopSegmentPlayback(reason = "stopped", targetTimeSec = null) {
			this._stopCustomSegmentPlayback(reason, targetTimeSec);
		}
		/**
		* Sets the playback volume.
		* @param {number} val - 0 to 1
		*/
		setVolume(val) {
			this.volume = clamp(val, 0, 1);
			if (this.wavesurfer) this.wavesurfer.setVolume(this.volume);
			if (this._customSegmentPlayback?.gain) this._customSegmentPlayback.gain.gain.value = this.muted ? 0 : this.volume;
		}
		/**
		* Toggles mute on/off.
		*/
		toggleMute() {
			if (this.muted) {
				this.muted = false;
				this.setVolume(this.preMuteVolume);
			} else {
				this.preMuteVolume = this.volume;
				this.muted = true;
				if (this.wavesurfer) this.wavesurfer.setVolume(0);
				if (this._customSegmentPlayback?.gain) this._customSegmentPlayback.gain.gain.value = 0;
			}
		}
		/**
		* Returns whether audio is currently playing.
		* @returns {boolean}
		*/
		isPlaying() {
			return this.wavesurfer?.isPlaying() || this._customSegmentPlayback !== null;
		}
		/**
		* Updates the active segment based on a label.
		* @param {Object} label - Label with start, end, freqMin, freqMax
		*/
		updateActiveSegmentFromLabel(label) {
			if (!label || this.playbackMode !== "segment") return;
			const labelId = label.id || null;
			if (this._activeSegmentLabelId && labelId && this._activeSegmentLabelId !== labelId) return;
			const dur = this.audioBuffer?.duration || 0;
			if (dur <= 0) return;
			const start = clamp(Number(label.start ?? 0), 0, dur);
			const end = clamp(Number(label.end ?? start + .01), start + .01, dur);
			this._activeSegmentStart = start;
			this._activeSegmentEnd = end;
			if (this._customSegmentPlayback) {
				this._retargetCustomSegmentPlayback({
					start,
					end,
					freqMinHz: Number(label.freqMin),
					freqMaxHz: Number(label.freqMax)
				});
				return;
			}
			const now = this.getCurrentTime();
			if (now < start || now > end) {
				this.seekToTime(start, false, { allowCustomPlayback: true });
				if (this.loopPlayback && !this.wavesurfer?.isPlaying()) this.wavesurfer?.play();
			}
		}
		/**
		* Ends a normal (non-bandpass) segment, resets segment state, and pauses playback.
		* Called by PlayerState when the segment end is reached during normal WaveSurfer playback.
		* @param {number} targetTimeSec
		*/
		endNormalSegment(targetTimeSec) {
			this._clearActiveSegment();
			this._suppressNextPauseHandler = true;
			if (this.wavesurfer) {
				this.wavesurfer.pause();
				this.seekToTime(Number(targetTimeSec ?? 0), false);
			}
		}
		/**
		* Destroys the engine and releases all resources.
		*/
		destroy() {
			if (this._customSegmentPlayback) this._stopCustomSegmentPlayback("stopped", 0);
			if (this.wavesurfer) {
				this.wavesurfer.destroy();
				this.wavesurfer = null;
			}
			this.audioBuffer = null;
		}
		/**
		* @param {string|File|Blob|null} source - URL string, File/Blob, or null
		* @param {string} [name] - display name
		*/
		_setupWaveSurfer(source, name) {
			if (this.wavesurfer) this.wavesurfer.destroy();
			const WaveSurferCtor = this._WaveSurferCtor;
			const wsOptions = {
				container: this._container,
				height: 1,
				waveColor: "#38bdf8",
				progressColor: "#0ea5e9",
				cursorColor: "#ef4444",
				normalize: true,
				minPxPerSec: this.pixelsPerSecond,
				autoScroll: false,
				autoCenter: false
			};
			const ws = WaveSurferCtor && typeof WaveSurferCtor.create === "function" ? WaveSurferCtor.create(wsOptions) : new WaveSurferCtor(wsOptions);
			if (typeof source === "string") ws.load(source);
			else if (source instanceof Blob) ws.loadBlob(source);
			ws.on("ready", () => {
				ws.zoom(this.pixelsPerSecond);
				ws.setVolume(this.volume);
				this.seekToTime(0, true);
				this._lastTimeupdateEmitAt = 0;
				this._emit("ready", {
					duration: this.audioBuffer?.duration || 0,
					sampleRate: this.audioBuffer?.sampleRate || 0
				});
			});
			ws.on("timeupdate", (t) => {
				this._emit("uiupdate", {
					time: t,
					fromPlayback: true
				});
				const now = performance.now();
				if (now - this._lastTimeupdateEmitAt >= 66) {
					this._lastTimeupdateEmitAt = now;
					this._emit("timeupdate", {
						currentTime: t,
						duration: this.audioBuffer?.duration || 0
					});
				}
			});
			ws.on("play", () => {
				this._emit("play", {});
				if (this.playbackMode === "segment") {
					this._emit("transportstatechange", {
						state: "playing_segment",
						reason: "engine-play"
					});
					return;
				}
				this._emit("transportstatechange", {
					state: this.loopPlayback ? "playing_loop" : "playing",
					reason: "engine-play"
				});
			});
			ws.on("pause", () => {
				if (this._suppressNextPauseHandler) {
					this._suppressNextPauseHandler = false;
					return;
				}
				this._emit("pause", {});
				if (this.playbackMode === "segment" && this._activeSegmentEnd != null) this._emit("transportstatechange", {
					state: "paused_segment",
					reason: "engine-pause"
				});
				else if (this.audioBuffer) {
					const atEnd = ws.getCurrentTime() >= this.audioBuffer.duration - .01;
					this._emit("transportstatechange", {
						state: atEnd ? "stopped" : "paused",
						reason: "engine-pause"
					});
				} else this._emit("transportstatechange", {
					state: "paused",
					reason: "engine-pause"
				});
			});
			ws.on("finish", () => {
				if (this.playbackMode === "segment") this._clearActiveSegment();
				if (this.loopPlayback) {
					this.seekToTime(0, this.loopPlayback);
					ws.play();
					return;
				}
				this._emit("finish", {});
				this._emit("transportstatechange", {
					state: "stopped",
					reason: "engine-finish"
				});
				if (this.audioBuffer) this._emit("uiupdate", {
					time: this.audioBuffer.duration,
					fromPlayback: false,
					immediate: true
				});
			});
			this.wavesurfer = ws;
		}
		/**
		* @param {SegmentPlayback} playback
		* @param {AudioBufferSourceNode|null} [source]
		* @param {number|null} [startAtSec]
		*/
		_startCustomSegmentSource(playback, source = null, startAtSec = null) {
			if (!playback || !this._customSegmentPlayback || this._customSegmentPlayback.token !== playback.token) return;
			playback.sourceGeneration = (playback.sourceGeneration || 0) + 1;
			const generation = playback.sourceGeneration;
			const nextSource = source || playback.ctx.createBufferSource();
			nextSource.buffer = this.audioBuffer;
			nextSource.connect(playback.bandpass);
			nextSource.onended = () => {
				if (!this._customSegmentPlayback || this._customSegmentPlayback.token !== playback.token) return;
				if (playback.sourceGeneration !== generation) return;
				if (this.loopPlayback) {
					this._loopCustomSegmentPlayback(playback);
					return;
				}
				this._stopCustomSegmentPlayback("stopped", playback.endSec, { emitEnd: true });
			};
			playback.source = nextSource;
			playback.runStartSec = startAtSec == null ? playback.startSec : clamp(startAtSec, playback.startSec, playback.endSec - .001);
			playback.startAtCtx = playback.ctx.currentTime + .005;
			nextSource.start(playback.startAtCtx, playback.runStartSec, playback.endSec - playback.runStartSec);
		}
		/**
		* @param {SegmentPlayback} playback
		*/
		_loopCustomSegmentPlayback(playback) {
			if (!playback || !this._customSegmentPlayback || this._customSegmentPlayback.token !== playback.token) return;
			playback.currentTimeSec = playback.startSec;
			this._emit("uiupdate", {
				time: playback.startSec,
				fromPlayback: false,
				immediate: true
			});
			this._emit("segmentloop", {
				start: playback.startSec,
				end: playback.endSec,
				filter: "bandpass"
			});
			this._startCustomSegmentSource(playback);
		}
		/**
		* @param {{ start: number, end: number, freqMinHz?: number, freqMaxHz?: number }} opts
		*/
		_retargetCustomSegmentPlayback({ start, end, freqMinHz, freqMaxHz }) {
			const playback = this._customSegmentPlayback;
			if (!playback || !this.audioBuffer) return;
			playback.startSec = start;
			playback.endSec = end;
			if (Number.isFinite(freqMinHz) && Number.isFinite(freqMaxHz)) {
				const nyquist = Math.max(100, this.audioBuffer.sampleRate * .5 - 10);
				const fMin = Number(freqMinHz);
				const fMax = Number(freqMaxHz);
				const fLo = Math.max(20, Math.min(fMin, fMax, nyquist - 5));
				const fHi = clamp(Math.max(fMin, fMax), fLo + 5, nyquist);
				const center = Math.sqrt(fLo * fHi);
				const q = clamp(center / Math.max(10, fHi - fLo), .25, 40);
				playback.bandpass.frequency.value = center;
				playback.bandpass.Q.value = q;
				this._activeSegmentFilter = {
					type: "bandpass",
					freqMinHz: fLo,
					freqMaxHz: fHi
				};
			}
			const desiredStart = clamp(playback.currentTimeSec || start, start, end - .001);
			this._restartCustomSegmentSource(playback, desiredStart);
		}
		/**
		* @param {SegmentPlayback} playback
		* @param {number} atSec
		*/
		_restartCustomSegmentSource(playback, atSec) {
			if (!playback || !this._customSegmentPlayback || this._customSegmentPlayback.token !== playback.token) return;
			playback.sourceGeneration = (playback.sourceGeneration || 0) + 1;
			if (playback.source) {
				playback.source.onended = null;
				try {
					playback.source.stop();
				} catch {}
				try {
					playback.source.disconnect();
				} catch {}
				playback.source = null;
			}
			playback.currentTimeSec = atSec;
			this._emit("uiupdate", {
				time: atSec,
				fromPlayback: false,
				immediate: true
			});
			this._startCustomSegmentSource(playback, null, atSec);
		}
		/**
		* @param {string} [reason]
		* @param {number|null} [targetTimeSec]
		* @param {Object} [options]
		*/
		_stopCustomSegmentPlayback(reason = "stopped", targetTimeSec = null, options = {}) {
			const active = this._customSegmentPlayback;
			if (!active) return;
			if (active.rafId) cancelAnimationFrame(active.rafId);
			active.rafId = 0;
			if (active.source) {
				active.source.onended = null;
				try {
					active.source.stop();
				} catch {}
				try {
					active.source.disconnect();
				} catch {}
			}
			try {
				active.bandpass?.disconnect();
			} catch {}
			try {
				active.gain.disconnect();
			} catch {}
			try {
				active.ctx.close();
			} catch {}
			this._customSegmentPlayback = null;
			this._clearActiveSegment();
			if (Number.isFinite(targetTimeSec)) this._emit("uiupdate", {
				time: targetTimeSec,
				fromPlayback: false,
				immediate: true
			});
			this._emit("pause", {});
			this._emit("transportstatechange", {
				state: reason === "paused" ? "paused_segment" : "stopped",
				reason: "bandpass-segment-stop"
			});
			if (options && options.emitEnd) this._emit("segmentend", { end: targetTimeSec ?? 0 });
		}
		_clearPlaybackFilter() {
			if (!this.wavesurfer) return;
			if (typeof this.wavesurfer.setFilter === "function") try {
				this.wavesurfer.setFilter(null);
			} catch {}
		}
		/**
		* Reset all active-segment state to idle.
		* Call this from every path that ends or cancels segment playback.
		*/
		_clearActiveSegment() {
			this._activeSegmentLabelId = null;
			this._activeSegmentFilter = null;
			this._activeSegmentStart = null;
			this._activeSegmentEnd = null;
			this.playbackMode = "normal";
			this._segmentPlayToken++;
		}
		/**
		* @param {string} eventName
		* @param {any} detail
		*/
		_emit(eventName, detail) {
			this.dispatchEvent(new CustomEvent(String(eventName), { detail }));
		}
	};
	//#endregion
	//#region src/ui/components/gestures/gestures.ts
	function distance(a, b) {
		const dx = a.clientX - b.clientX;
		const dy = a.clientY - b.clientY;
		return Math.hypot(dx, dy);
	}
	function midpoint(a, b) {
		return {
			x: (a.clientX + b.clientX) * .5,
			y: (a.clientY + b.clientY) * .5
		};
	}
	var GestureRecognizer = class {
		constructor(element) {
			this.element = element;
			this.handlers = /* @__PURE__ */ new Map();
			this.cleanups = [];
			this.lastTapTime = 0;
			this.lastTapX = 0;
			this.lastTapY = 0;
			this.touchMode = null;
			this.swipeStartX = 0;
			this.swipeStartY = 0;
			this.swipeLastX = 0;
			this.swipeLastY = 0;
			this.lastPinchDistance = 0;
			this.lastPinchCenter = null;
			this._bind();
		}
		on(event, callback) {
			const arr = this.handlers.get(event) || [];
			arr.push(callback);
			this.handlers.set(event, arr);
			return () => this.off(event, callback);
		}
		off(event, callback) {
			const arr = this.handlers.get(event);
			if (!arr) return;
			this.handlers.set(event, arr.filter((cb) => cb !== callback));
		}
		emit(event, detail) {
			const arr = this.handlers.get(event);
			if (!arr) return;
			for (const cb of arr) cb(detail);
		}
		dispose() {
			for (const cleanup of this.cleanups) cleanup();
			this.cleanups.length = 0;
			this.handlers.clear();
		}
		_bind() {
			const on = (name, fn, options = { passive: false }) => {
				this.element.addEventListener(name, fn, options);
				this.cleanups.push(() => this.element.removeEventListener(name, fn, options));
			};
			on("touchstart", (e) => this._onTouchStart(e));
			on("touchmove", (e) => this._onTouchMove(e));
			on("touchend", (e) => this._onTouchEnd(e));
			on("touchcancel", () => this._reset());
		}
		_onTouchStart(e) {
			if (e.touches.length === 1) {
				const t = e.touches[0];
				this.touchMode = "swipe";
				this.swipeStartX = t.clientX;
				this.swipeStartY = t.clientY;
				this.swipeLastX = t.clientX;
				this.swipeLastY = t.clientY;
				return;
			}
			if (e.touches.length >= 2) {
				const a = e.touches[0];
				const b = e.touches[1];
				this.touchMode = "pinch";
				this.lastPinchDistance = distance(a, b);
				this.lastPinchCenter = midpoint(a, b);
				e.preventDefault();
			}
		}
		_onTouchMove(e) {
			if (this.touchMode === "pinch" && e.touches.length >= 2) {
				const a = e.touches[0];
				const b = e.touches[1];
				const d = Math.max(1, distance(a, b));
				const center = midpoint(a, b);
				const scale = d / Math.max(1, this.lastPinchDistance);
				this.lastPinchDistance = d;
				this.lastPinchCenter = center;
				this.emit("pinch", {
					scale,
					centerX: center.x,
					centerY: center.y
				});
				e.preventDefault();
				return;
			}
			if (this.touchMode === "swipe" && e.touches.length === 1) {
				this.swipeLastX = e.touches[0].clientX;
				this.swipeLastY = e.touches[0].clientY;
			}
		}
		_onTouchEnd(e) {
			if (this.touchMode === "pinch") {
				if (e.touches.length < 2) this._reset();
				return;
			}
			if (this.touchMode === "swipe" && e.touches.length === 0) {
				const dx = this.swipeLastX - this.swipeStartX;
				const dy = Math.abs(this.swipeLastY - this.swipeStartY);
				if (Math.abs(dx) > 24 && dy < 48) this.emit("swipe", { dx });
				else {
					const now = performance.now();
					const withinTime = now - this.lastTapTime < 280;
					const nearLast = Math.hypot(this.swipeStartX - this.lastTapX, this.swipeStartY - this.lastTapY) < 24;
					if (withinTime && nearLast) {
						this.emit("doubletap", {
							x: this.swipeStartX,
							y: this.swipeStartY
						});
						this.lastTapTime = 0;
					} else {
						this.lastTapTime = now;
						this.lastTapX = this.swipeStartX;
						this.lastTapY = this.swipeStartY;
					}
				}
			}
			this._reset();
		}
		_reset() {
			this.touchMode = null;
			this.swipeStartX = 0;
			this.swipeStartY = 0;
			this.swipeLastX = 0;
			this.swipeLastY = 0;
			this.lastPinchDistance = 0;
			this.lastPinchCenter = null;
		}
	};
	//#endregion
	//#region src/domain/transportState.ts
	var TRANSPORT_STATE_LABELS = {
		"": "",
		idle: "Idle",
		loading: "Loading",
		ready: "Ready",
		rendering: "Rendering...",
		playing: "Playing",
		playing_loop: "Playing (Loop)",
		playing_segment: "Playing (Segment)",
		paused: "Paused",
		paused_segment: "Paused (Segment)",
		stopped: "Stopped",
		error: "Error"
	};
	var ALLOWED_TRANSITIONS$1 = {
		"": new Set([
			"idle",
			"loading",
			"ready",
			"error"
		]),
		idle: new Set([
			"loading",
			"ready",
			"error"
		]),
		loading: new Set([
			"ready",
			"error",
			"idle"
		]),
		ready: new Set([
			"rendering",
			"playing",
			"playing_loop",
			"playing_segment",
			"paused",
			"stopped",
			"loading",
			"error"
		]),
		rendering: new Set([
			"ready",
			"error",
			"loading"
		]),
		playing: new Set([
			"paused",
			"stopped",
			"playing_loop",
			"playing_segment",
			"ready"
		]),
		playing_loop: new Set([
			"paused",
			"stopped",
			"playing",
			"ready"
		]),
		playing_segment: new Set([
			"paused_segment",
			"stopped",
			"ready"
		]),
		paused: new Set([
			"playing",
			"playing_loop",
			"stopped",
			"ready",
			"loading"
		]),
		paused_segment: new Set([
			"playing_segment",
			"stopped",
			"ready",
			"paused"
		]),
		stopped: new Set([
			"playing",
			"playing_loop",
			"playing_segment",
			"ready",
			"loading"
		]),
		error: new Set([
			"loading",
			"idle",
			"ready"
		])
	};
	function canTransitionTransportState(fromState, toState) {
		if (!toState) return false;
		if (fromState === toState) return true;
		return (ALLOWED_TRANSITIONS$1[fromState] ?? ALLOWED_TRANSITIONS$1[""]).has(toState);
	}
	//#endregion
	//#region src/app/interactionState.ts
	/**
	* Per-mode context data, only valid while the corresponding mode is active.
	*
	* @typedef {Object} InteractionContext
	* @property {string | undefined} playheadSource       - 'waveform' | 'spectrogram' | 'overview' (playhead-drag)
	* @property {number}         panStartX               - clientX at pan start (viewport-pan)
	* @property {number}         panStartY               - clientY at pan start (viewport-pan)
	* @property {number}         panStartScroll           - scrollLeft at pan start (viewport-pan)
	* @property {boolean}        panSuppressClick         - true once drag exceeds 3px (viewport-pan)
	* @property {boolean}        panIsMiddle              - true if middle mouse button started the pan
	* @property {string | undefined} panSource            - 'waveform' | 'spectrogram' (viewport-pan)
	* @property {number | null}  panStartFreqViewMin      - freq viewport min at pan start (viewport-pan)
	* @property {number | null}  panStartFreqViewMax      - freq viewport max at pan start (viewport-pan)
	* @property {number}         overviewStartX           - clientX at overview drag start
	* @property {number}         overviewStartNorm        - windowStartNorm at drag start
	* @property {number}         overviewEndNorm          - windowEndNorm at drag start
	* @property {boolean}        overviewMoved            - true once drag exceeds 2px threshold
	* @property {number}         resizeStartY             - clientY at resize start (view-resize)
	* @property {number}         resizeStartWaveformH     - waveformDisplayHeight at resize start
	* @property {number}         resizeStartSpectrogramH  - spectrogramDisplayHeight at resize start
	*/
	/** @type {ReadonlySet<InteractionMode>} */
	var OVERVIEW_MODES = new Set([
		"overview-move",
		"overview-resize-left",
		"overview-resize-right"
	]);
	/** @type {ReadonlySet<InteractionMode>} */
	var VIEW_RESIZE_MODES = new Set(["view-resize-split", "view-resize-spectrogram"]);
	/**
	* Allowed transitions.  Every mode can return to 'idle'.
	* Only 'idle' can transition to a specific mode.
	*
	* @type {Record<InteractionMode, ReadonlySet<InteractionMode>>}
	*/
	var ALLOWED_TRANSITIONS = {
		"idle": new Set([
			"playhead-drag",
			"viewport-pan",
			"overview-move",
			"overview-resize-left",
			"overview-resize-right",
			"view-resize-split",
			"view-resize-spectrogram"
		]),
		"playhead-drag": new Set(["idle"]),
		"viewport-pan": new Set(["idle"]),
		"overview-move": new Set(["idle"]),
		"overview-resize-left": new Set(["idle"]),
		"overview-resize-right": new Set(["idle"]),
		"view-resize-split": new Set(["idle"]),
		"view-resize-spectrogram": new Set(["idle"])
	};
	/**
	* Check whether a transition from `from` to `to` is allowed.
	*
	* @param {InteractionMode} from
	* @param {InteractionMode} to
	* @returns {boolean}
	*/
	function canTransitionInteraction(from, to) {
		return ALLOWED_TRANSITIONS[from]?.has(to) === true;
	}
	/**
	* Create a fresh default context (all fields at neutral/zero values).
	*
	* @returns {InteractionContext}
	*/
	function defaultContext() {
		return {
			playheadSource: void 0,
			panStartX: 0,
			panStartScroll: 0,
			panSuppressClick: false,
			panStartY: 0,
			panIsMiddle: false,
			panSource: void 0,
			panStartFreqViewMin: null,
			panStartFreqViewMax: null,
			overviewStartX: 0,
			overviewStartNorm: 0,
			overviewEndNorm: 1,
			overviewMoved: false,
			resizeStartY: 0,
			resizeStartWaveformH: 0,
			resizeStartSpectrogramH: 0
		};
	}
	var InteractionState = class {
		constructor() {
			/** @type {InteractionMode} */
			this.mode = "idle";
			/** @type {InteractionContext} */
			this.ctx = defaultContext();
			/** @type {number} Timestamp-based click suppression for seek */
			this._blockSeekClickUntil = 0;
			/** @type {number} Timestamp-based click suppression for overview */
			this._overviewSuppressClickUntil = 0;
		}
		/** @returns {boolean} */
		get isIdle() {
			return this.mode === "idle";
		}
		/** @returns {boolean} */
		get isDraggingPlayhead() {
			return this.mode === "playhead-drag";
		}
		/** @returns {boolean} */
		get isDraggingViewport() {
			return this.mode === "viewport-pan";
		}
		/** @returns {boolean} */
		get isOverviewDrag() {
			return OVERVIEW_MODES.has(this.mode);
		}
		/** @returns {boolean} */
		get isViewResize() {
			return VIEW_RESIZE_MODES.has(this.mode);
		}
		/**
		* The overview sub-mode: 'move' | 'left' | 'right' | null.
		* @returns {string | null}
		*/
		get overviewSubMode() {
			switch (this.mode) {
				case "overview-move": return "move";
				case "overview-resize-left": return "left";
				case "overview-resize-right": return "right";
				default: return null;
			}
		}
		/**
		* The view-resize sub-mode: 'split' | 'spectrogram' | null.
		* @returns {string | null}
		*/
		get viewResizeSubMode() {
			switch (this.mode) {
				case "view-resize-split": return "split";
				case "view-resize-spectrogram": return "spectrogram";
				default: return null;
			}
		}
		/**
		* Transition to a new mode.  Returns false if the transition is invalid.
		*
		* @param {InteractionMode} nextMode
		* @returns {boolean}
		*/
		enter(nextMode) {
			if (!canTransitionInteraction(this.mode, nextMode)) return false;
			this.mode = nextMode;
			if (nextMode === "idle") this.ctx = defaultContext();
			return true;
		}
		/**
		* Return to idle, resetting all context.
		*/
		release() {
			this.mode = "idle";
			this.ctx = defaultContext();
		}
		/**
		* Block seek-clicks for `ms` milliseconds (e.g. after a drag).
		* @param {number} [ms=220]
		*/
		blockSeekClicks(ms = 220) {
			this._blockSeekClickUntil = Math.max(this._blockSeekClickUntil, performance.now() + ms);
		}
		/**
		* Returns true if seek-clicks are currently suppressed.
		* Covers both timestamp-based blocking and viewport-pan drag threshold.
		* @returns {boolean}
		*/
		isSeekBlocked() {
			if (performance.now() < this._blockSeekClickUntil) return true;
			if (this.ctx.panSuppressClick) return true;
			return false;
		}
		/**
		* Clear the pan-based seek suppression (called once after a blocked click is consumed).
		*/
		consumeSeekBlock() {
			this.ctx.panSuppressClick = false;
		}
		/**
		* Block overview clicks for `ms` milliseconds.
		* @param {number} [ms=260]
		*/
		blockOverviewClicks(ms = 260) {
			this._overviewSuppressClickUntil = Math.max(this._overviewSuppressClickUntil, performance.now() + ms);
		}
		/**
		* Returns true if overview clicks are currently suppressed.
		* @returns {boolean}
		*/
		isOverviewClickBlocked() {
			return performance.now() < this._overviewSuppressClickUntil;
		}
	};
	//#endregion
	//#region src/domain/dsp.ts
	function hzToMel(hz) {
		return 2595 * Math.log10(1 + hz / 700);
	}
	function melToHz(mel) {
		return 700 * (Math.pow(10, mel / 2595) - 1);
	}
	/**
	* Return an array of `nMels` frequencies (Hz) evenly spaced in mel scale
	* from 0 Hz to sampleRate/2.
	*/
	function buildMelFrequencies(sampleRate, nMels) {
		const melMin = hzToMel(0);
		const melMax = hzToMel(sampleRate / 2);
		const freqs = new Float32Array(nMels);
		for (let i = 0; i < nMels; i++) freqs[i] = melToHz(melMin + i / Math.max(1, nMels - 1) * (melMax - melMin));
		return freqs;
	}
	function createMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax) {
		const nFftBins = Math.floor(fftSize / 2);
		const melMin = hzToMel(fMin);
		const melMax = hzToMel(fMax);
		const melPoints = [];
		for (let i = 0; i < nMels + 2; i++) melPoints.push(melMin + i / (nMels + 1) * (melMax - melMin));
		const binPoints = melPoints.map(melToHz).map((hz) => Math.floor((fftSize + 1) * hz / sampleRate));
		const filterbank = [];
		for (let m = 1; m <= nMels; m++) {
			const filter = new Float32Array(nFftBins);
			const left = clamp(binPoints[m - 1], 0, nFftBins - 1);
			const center = clamp(binPoints[m], 0, nFftBins - 1);
			const right = clamp(binPoints[m + 1], 0, nFftBins - 1);
			for (let k = left; k < center; k++) filter[k] = (k - left) / (center - left || 1);
			for (let k = center; k < right; k++) filter[k] = (right - k) / (right - center || 1);
			let filterSum = 0;
			for (let k = 0; k < nFftBins; k++) filterSum += filter[k];
			if (filterSum === 0) {
				filter[center] = 1;
				filterSum = 1;
			}
			for (let k = 0; k < nFftBins; k++) filter[k] /= filterSum;
			filterbank.push(filter);
		}
		return filterbank;
	}
	/**
	* Build a sparse mel filterbank for fast application.
	* Instead of iterating all nFftBins per filter, stores only
	* the non-zero (startBin, weights[]) ranges.
	*/
	function createSparseMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax) {
		const dense = createMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax);
		const sparse = new Array(nMels);
		for (let m = 0; m < nMels; m++) {
			const f = dense[m];
			let start = -1, end = 0;
			for (let k = 0; k < f.length; k++) if (f[k] !== 0) {
				if (start < 0) start = k;
				end = k + 1;
			}
			if (start < 0) start = 0;
			const weights = f.subarray(start, end);
			sparse[m] = {
				start,
				weights
			};
		}
		return sparse;
	}
	var _twiddleCache = /* @__PURE__ */ new Map();
	function getTwiddleFactors(n) {
		if (_twiddleCache.has(n)) return _twiddleCache.get(n);
		const table = {};
		for (let len = 2; len <= n; len <<= 1) {
			const halfLen = len >> 1;
			const angleStep = -2 * Math.PI / len;
			const cos = new Float64Array(halfLen);
			const sin = new Float64Array(halfLen);
			for (let k = 0; k < halfLen; k++) {
				const angle = angleStep * k;
				cos[k] = Math.cos(angle);
				sin[k] = Math.sin(angle);
			}
			table[len] = {
				cos,
				sin
			};
		}
		_twiddleCache.set(n, table);
		return table;
	}
	function iterativeFFT(real, imag) {
		const n = real.length;
		const twiddle = getTwiddleFactors(n);
		let j = 0;
		for (let i = 1; i < n; i++) {
			let bit = n >> 1;
			while (j & bit) {
				j ^= bit;
				bit >>= 1;
			}
			j ^= bit;
			if (i < j) {
				let tmp = real[i];
				real[i] = real[j];
				real[j] = tmp;
				tmp = imag[i];
				imag[i] = imag[j];
				imag[j] = tmp;
			}
		}
		for (let len = 2; len <= n; len <<= 1) {
			const halfLen = len >> 1;
			const { cos, sin } = twiddle[len];
			for (let i = 0; i < n; i += len) for (let k = 0; k < halfLen; k++) {
				const evenIndex = i + k;
				const oddIndex = evenIndex + halfLen;
				const tr = cos[k] * real[oddIndex] - sin[k] * imag[oddIndex];
				const ti = sin[k] * real[oddIndex] + cos[k] * imag[oddIndex];
				real[oddIndex] = real[evenIndex] - tr;
				imag[oddIndex] = imag[evenIndex] - ti;
				real[evenIndex] += tr;
				imag[evenIndex] += ti;
			}
		}
	}
	/** @param {number} i @param {number} N */
	function hannWindow$1(i, N) {
		return .5 * (1 - Math.cos(2 * Math.PI * i / Math.max(1, N - 1)));
	}
	/** @param {number} i @param {number} N */
	function hammingWindow(i, N) {
		return .54 - .46 * Math.cos(2 * Math.PI * i / Math.max(1, N - 1));
	}
	/** @param {number} i @param {number} N */
	function blackmanWindow(i, N) {
		return .42 - .5 * Math.cos(2 * Math.PI * i / Math.max(1, N - 1)) + .08 * Math.cos(4 * Math.PI * i / Math.max(1, N - 1));
	}
	/** Blackman-Harris 4-term — excellent sidelobe suppression (−92 dB). */
	function blackmanHarrisWindow(i, N) {
		const a0 = .35875, a1 = .48829, a2 = .14128, a3 = .01168;
		const t = 2 * Math.PI * i / Math.max(1, N - 1);
		return a0 - a1 * Math.cos(t) + a2 * Math.cos(2 * t) - a3 * Math.cos(3 * t);
	}
	/** Flat-top — near-unity amplitude accuracy at the cost of wider main lobe. */
	function flatTopWindow(i, N) {
		const a0 = .21557895, a1 = .41663158, a2 = .277263158;
		const a3 = .083578947, a4 = .006947368;
		const t = 2 * Math.PI * i / Math.max(1, N - 1);
		return a0 - a1 * Math.cos(t) + a2 * Math.cos(2 * t) - a3 * Math.cos(3 * t) + a4 * Math.cos(4 * t);
	}
	/**
	* Kaiser window — adjustable via β parameter.
	* Higher β → narrower main lobe, lower sidelobes.
	* β≈5: similar to Hamming, β≈8.6: similar to Blackman-Harris.
	* Uses rational Bessel I₀ approximation (no factorial overflow).
	*/
	function kaiserWindow(i, N, beta = 6) {
		const alpha = Math.max(1, N - 1) / 2;
		return _besselI0(beta * Math.sqrt(1 - ((i - alpha) / alpha) ** 2)) / _besselI0(beta);
	}
	/** Modified Bessel function I₀(x) — polynomial approximation (Abramowitz & Stegun). */
	function _besselI0(x) {
		const ax = Math.abs(x);
		if (ax < 3.75) {
			const t = (ax / 3.75) ** 2;
			return 1 + t * (3.5156229 + t * (3.0899424 + t * (1.2067492 + t * (.2659732 + t * (.0360768 + t * .0045813)))));
		}
		const t = 3.75 / ax;
		return Math.exp(ax) / Math.sqrt(ax) * (.39894228 + t * (.01328592 + t * (.00225319 + t * (-.00157565 + t * (.00916281 + t * (-.02057706 + t * (.02635537 + t * (-.01647633 + t * .00392377))))))));
	}
	var WINDOW_FUNCTIONS = {
		hann: hannWindow$1,
		hamming: hammingWindow,
		blackman: blackmanWindow,
		blackmanHarris: blackmanHarrisWindow,
		flatTop: flatTopWindow,
		kaiser: kaiserWindow
	};
	/**
	* IEC 60268-18 fader law: map dB → meter deflection percentage.
	* Used by the "Meter" colour scale to produce perceptually-even brightness.
	*/
	function iecDbToFader(db) {
		if (db < -70) return 0;
		if (db < -60) return (db + 70) * .25;
		if (db < -50) return (db + 60) * .5 + 2.5;
		if (db < -40) return (db + 50) * .75 + 7.5;
		if (db < -30) return (db + 40) * 1.5 + 15;
		if (db < -20) return (db + 30) * 2 + 30;
		return (db + 20) * 2.5 + 50;
	}
	iecDbToFader(0);
	/**
	* Compute a full spectrogram from raw audio samples.
	*
	* @param {Object} params
	* @param {ArrayBuffer|Float32Array} params.channelData - mono audio samples
	* @param {number} params.fftSize
	* @param {number} params.sampleRate
	* @param {number} params.frameRate      - frames per second
	* @param {number} params.nMels          - number of mel bins (mel scale)
	* @param {number} params.pcenGain
	* @param {number} params.pcenBias
	* @param {number} params.pcenRoot
	* @param {number} params.pcenSmoothing
	* @param {boolean} [params.usePcen=true] - apply PCEN normalisation (false → dB even in mel mode)
	* @param {string} [params.scale='mel'] - 'mel' or 'linear'
	* @param {string} [params.colourScale='dbSquared'] - 'linear'|'meter'|'dbSquared'|'db'|'phase'
	* @param {Float32Array} [params.initialSmooth] - carry-over PCEN smooth state from previous chunk
	* @param {number} [params.windowSize] - window length in samples (0 or omit = auto: 4×hopSize)
	* @param {number} [params.hopSize] - hop size in samples (0 or omit = auto: sampleRate/frameRate)
	* @param {string} [params.windowFunction='hann'] - 'hann' | 'hamming' | 'blackman'
	*
	* @returns {{ data: Float32Array, nFrames: number, nMels: number, hopSize: number, winLength: number, colourScale: string, smoothState?: Float32Array }}
	*/
	function computeSpectrogram(params) {
		const { channelData, fftSize, sampleRate, frameRate, nMels, pcenGain = 2, pcenBias = 1e-6, pcenRoot = 2, pcenSmoothing = .025, usePcen = true, scale = "mel", initialSmooth, colourScale = "dbSquared", windowSize: userWindowSize, hopSize: userHopSize, windowFunction = "hann" } = params;
		const audio = channelData instanceof Float32Array ? channelData : new Float32Array(channelData);
		const autoHop = Math.max(1, Math.floor(sampleRate / frameRate));
		const hopSize = userHopSize && userHopSize > 0 ? userHopSize : autoHop;
		const winLength = userWindowSize && userWindowSize > 0 ? userWindowSize : 4 * hopSize;
		const numFrames = Math.max(1, Math.floor((audio.length - winLength) / hopSize) + 1);
		const nBins = Math.floor(fftSize / 2);
		const useLinear = scale === "linear";
		const useCQT = scale === "cqt";
		const isPhase = colourScale === "phase";
		const outBins = useLinear ? nBins : nMels;
		const output = new Float32Array(numFrames * outBins);
		const wfn = WINDOW_FUNCTIONS[windowFunction] || hannWindow$1;
		const maxCopy = Math.min(winLength, fftSize);
		const windowLUT = new Float32Array(maxCopy);
		for (let i = 0; i < maxCopy; i++) windowLUT[i] = wfn(i, winLength);
		const real = new Float32Array(fftSize);
		const imag = new Float32Array(fftSize);
		const sparseFB = useLinear ? null : useCQT ? createCQTFilterbank(sampleRate, fftSize, nMels) : createSparseMelFilterbank(sampleRate, fftSize, nMels, 0, sampleRate / 2);
		const denseFB = isPhase && !useLinear ? createMelFilterbank(sampleRate, fftSize, nMels, 0, sampleRate / 2) : null;
		const powerBuf = !useLinear && !isPhase ? new Float32Array(nBins) : null;
		/** Apply windowed frame into reusable buffers and run FFT in-place. */
		function runFFT(offset) {
			real.fill(0);
			imag.fill(0);
			for (let i = 0; i < maxCopy; i++) real[i] = (audio[offset + i] || 0) * windowLUT[i];
			iterativeFFT(real, imag);
		}
		let smooth = null;
		if (isPhase) for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {
			runFFT(frameIdx * hopSize);
			const base = frameIdx * outBins;
			if (useLinear) for (let k = 0; k < nBins; k++) output[base + k] = Math.atan2(imag[k], real[k]);
			else for (let m = 0; m < nMels; m++) {
				const f = denseFB[m];
				let sumSin = 0, sumCos = 0;
				for (let k = 0; k < f.length; k++) if (f[k] > 0) {
					const ph = Math.atan2(imag[k], real[k]);
					sumSin += f[k] * Math.sin(ph);
					sumCos += f[k] * Math.cos(ph);
				}
				output[base + m] = Math.atan2(sumSin, sumCos);
			}
		}
		else if (useLinear) {
			const wantRaw = colourScale === "linear" || colourScale === "meter";
			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {
				runFFT(frameIdx * hopSize);
				const base = frameIdx * nBins;
				if (wantRaw) for (let k = 0; k < nBins; k++) output[base + k] = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);
				else for (let k = 0; k < nBins; k++) {
					const p = real[k] * real[k] + imag[k] * imag[k];
					output[base + k] = 10 * Math.log10(Math.max(1e-20, p));
				}
			}
		} else if (!usePcen) {
			const wantRaw = colourScale === "linear" || colourScale === "meter";
			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {
				runFFT(frameIdx * hopSize);
				for (let k = 0; k < nBins; k++) powerBuf[k] = real[k] * real[k] + imag[k] * imag[k];
				const base = frameIdx * nMels;
				for (let m = 0; m < nMels; m++) {
					const { start, weights } = sparseFB[m];
					let sum = 0;
					for (let k = 0; k < weights.length; k++) sum += powerBuf[start + k] * weights[k];
					if (wantRaw) output[base + m] = Math.sqrt(Math.max(0, sum));
					else output[base + m] = 10 * Math.log10(Math.max(1e-10, sum));
				}
			}
		} else {
			smooth = new Float32Array(nMels);
			if (initialSmooth && initialSmooth.length === nMels) smooth.set(initialSmooth);
			const pcenPower = 1 / pcenRoot;
			const pcenBiasOffset = Math.pow(pcenBias, pcenPower);
			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {
				runFFT(frameIdx * hopSize);
				for (let k = 0; k < nBins; k++) powerBuf[k] = real[k] * real[k] + imag[k] * imag[k];
				const base = frameIdx * nMels;
				for (let m = 0; m < nMels; m++) {
					const { start, weights } = sparseFB[m];
					let e = 0;
					for (let k = 0; k < weights.length; k++) e += powerBuf[start + k] * weights[k];
					smooth[m] = (1 - pcenSmoothing) * smooth[m] + pcenSmoothing * e;
					const denom = Math.pow(1e-10 + smooth[m], pcenGain);
					const norm = e / denom;
					output[base + m] = Math.pow(norm + pcenBias, pcenPower) - pcenBiasOffset;
				}
			}
		}
		const result = {
			data: output,
			nFrames: numFrames,
			nMels: outBins,
			hopSize,
			winLength,
			colourScale
		};
		if (smooth) result.smoothState = smooth;
		return result;
	}
	/**
	* Build a CQT (Constant-Q) filterbank that maps STFT bins to
	* log-spaced frequency bins with constant Q factor.
	* Returns a sparse filterbank compatible with applySparseMelFilterbank.
	*
	* @param {number} sampleRate
	* @param {number} fftSize
	* @param {number} nBinsOut        - desired number of output bins
	* @param {number} [fMin=32.7]     - lowest frequency (Hz), default C1
	* @param {number} [binsPerOctave=24]
	*/
	function createCQTFilterbank(sampleRate, fftSize, nBinsOut, fMin = 32.7, binsPerOctave = 24) {
		const nFftBins = Math.floor(fftSize / 2);
		const nyquist = sampleRate / 2;
		const Q = 1 / (Math.pow(2, 1 / binsPerOctave) - 1);
		const sparse = new Array(nBinsOut);
		for (let k = 0; k < nBinsOut; k++) {
			const fCenter = fMin * Math.pow(2, k / binsPerOctave);
			if (fCenter >= nyquist) {
				sparse[k] = {
					start: nFftBins - 1,
					weights: new Float32Array([1])
				};
				continue;
			}
			const bw = fCenter / Q;
			const fLo = Math.max(0, fCenter - bw / 2);
			const fHi = Math.min(nyquist, fCenter + bw / 2);
			const binLo = Math.max(0, Math.floor(fLo * fftSize / sampleRate));
			const len = Math.min(nFftBins - 1, Math.ceil(fHi * fftSize / sampleRate)) - binLo + 1;
			const weights = new Float32Array(len);
			let sum = 0;
			for (let b = 0; b < len; b++) {
				const fBin = (binLo + b) * sampleRate / fftSize;
				const dist = Math.abs(fBin - fCenter) / (bw / 2 + 1e-12);
				weights[b] = Math.max(0, 1 - dist);
				sum += weights[b];
			}
			if (sum > 0) for (let b = 0; b < len; b++) weights[b] /= sum;
			else if (len > 0) weights[0] = 1;
			sparse[k] = {
				start: binLo,
				weights
			};
		}
		return sparse;
	}
	/**
	* Build CQT frequency array (Hz) for each output bin.
	* @param {number} nBins
	* @param {number} [fMin=32.7]
	* @param {number} [binsPerOctave=24]
	* @returns {Float32Array}
	*/
	function buildCQTFrequencies(nBins, fMin = 32.7, binsPerOctave = 24) {
		const freqs = new Float32Array(nBins);
		for (let k = 0; k < nBins; k++) freqs[k] = fMin * Math.pow(2, k / binsPerOctave);
		return freqs;
	}
	/**
	* Compute a reassigned spectrogram for sharper time-frequency localization.
	* Uses three parallel STFTs with:
	*   1. h(n) — standard window
	*   2. (n - N/2) · h(n) — time-ramped window (for time correction)
	*   3. h'(n) — derivative window (for frequency correction)
	*
	* The corrected coordinates allow energy to be placed at its "true"
	* center of gravity in time-frequency space.
	*
	* @param {Object} params  - Same as computeSpectrogram plus:
	* @param {ArrayBuffer|Float32Array} params.channelData
	* @param {number} params.fftSize
	* @param {number} params.sampleRate
	* @param {number} params.frameRate
	* @param {number} params.nMels
	* @param {string} [params.scale='mel']
	* @param {string} [params.colourScale='dbSquared']
	* @param {number} [params.windowSize]
	* @param {number} [params.hopSize]
	* @param {string} [params.windowFunction='hann']
	* @returns {{ data: Float32Array, nFrames: number, nMels: number, hopSize: number, winLength: number, colourScale: string }}
	*/
	function computeReassignedSpectrogram(params) {
		const { channelData, fftSize, sampleRate, frameRate, nMels, scale = "mel", colourScale = "dbSquared", windowSize: userWindowSize, hopSize: userHopSize, windowFunction = "hann" } = params;
		const audio = channelData instanceof Float32Array ? channelData : new Float32Array(channelData);
		const autoHop = Math.max(1, Math.floor(sampleRate / frameRate));
		const hopSize = userHopSize && userHopSize > 0 ? userHopSize : autoHop;
		const winLength = userWindowSize && userWindowSize > 0 ? userWindowSize : 4 * hopSize;
		const numFrames = Math.max(1, Math.floor((audio.length - winLength) / hopSize) + 1);
		const nBins = Math.floor(fftSize / 2);
		const useLinear = scale === "linear";
		const useCQT = scale === "cqt";
		const outBins = useLinear ? nBins : nMels;
		const output = new Float32Array(numFrames * outBins);
		const wfn = WINDOW_FUNCTIONS[windowFunction] || hannWindow$1;
		const maxCopy = Math.min(winLength, fftSize);
		const winH = new Float32Array(maxCopy);
		const winTH = new Float32Array(maxCopy);
		const winDH = new Float32Array(maxCopy);
		for (let i = 0; i < maxCopy; i++) winH[i] = wfn(i, winLength);
		for (let i = 0; i < maxCopy; i++) winTH[i] = (i - winLength / 2) * winH[i];
		for (let i = 1; i < maxCopy - 1; i++) winDH[i] = .5 * (winH[i + 1] - winH[i - 1]);
		winDH[0] = winH[1] - winH[0];
		if (maxCopy > 1) winDH[maxCopy - 1] = winH[maxCopy - 1] - winH[maxCopy - 2];
		const realH = new Float32Array(fftSize), imagH = new Float32Array(fftSize);
		const realTH = new Float32Array(fftSize), imagTH = new Float32Array(fftSize);
		const realDH = new Float32Array(fftSize), imagDH = new Float32Array(fftSize);
		const sparseFB = useLinear ? null : useCQT ? createCQTFilterbank(sampleRate, fftSize, nMels) : createSparseMelFilterbank(sampleRate, fftSize, nMels, 0, sampleRate / 2);
		const counts = new Float32Array(numFrames * outBins);
		for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {
			const offset = frameIdx * hopSize;
			realH.fill(0);
			imagH.fill(0);
			realTH.fill(0);
			imagTH.fill(0);
			realDH.fill(0);
			imagDH.fill(0);
			for (let i = 0; i < maxCopy; i++) {
				const s = audio[offset + i] || 0;
				realH[i] = s * winH[i];
				realTH[i] = s * winTH[i];
				realDH[i] = s * winDH[i];
			}
			iterativeFFT(realH, imagH);
			iterativeFFT(realTH, imagTH);
			iterativeFFT(realDH, imagDH);
			if (useLinear) for (let k = 0; k < nBins; k++) {
				const magSq = realH[k] * realH[k] + imagH[k] * imagH[k];
				if (magSq < 1e-30) continue;
				const dtSamples = (realTH[k] * realH[k] + imagTH[k] * imagH[k]) / magSq;
				const dOmega = -(imagDH[k] * realH[k] - realDH[k] * imagH[k]) / magSq;
				const correctedBin = k + dOmega * fftSize / (2 * Math.PI);
				const correctedFrame = frameIdx + dtSamples / hopSize;
				const cBin = clamp(Math.round(correctedBin), 0, nBins - 1);
				const idx = clamp(Math.round(correctedFrame), 0, numFrames - 1) * nBins + cBin;
				const value = colourScale === "linear" || colourScale === "meter" ? Math.sqrt(magSq) : 10 * Math.log10(Math.max(1e-20, magSq));
				output[idx] += value;
				counts[idx] += 1;
			}
			else for (let k = 0; k < nBins; k++) {
				const magSq = realH[k] * realH[k] + imagH[k] * imagH[k];
				if (magSq < 1e-30) continue;
				const dtSamples = (realTH[k] * realH[k] + imagTH[k] * imagH[k]) / magSq;
				const dOmega = -(imagDH[k] * realH[k] - realDH[k] * imagH[k]) / magSq;
				const correctedBin = k + dOmega * fftSize / (2 * Math.PI);
				const correctedFrame = frameIdx + dtSamples / hopSize;
				const cFrame = clamp(Math.round(correctedFrame), 0, numFrames - 1);
				const cBinInt = clamp(Math.round(correctedBin), 0, nBins - 1);
				for (let m = 0; m < nMels; m++) {
					const { start, weights } = sparseFB[m];
					if (cBinInt < start || cBinInt >= start + weights.length) continue;
					const w = weights[cBinInt - start];
					if (w <= 0) continue;
					const value = colourScale === "linear" || colourScale === "meter" ? Math.sqrt(magSq) * w : 10 * Math.log10(Math.max(1e-20, magSq)) * w;
					const idx = cFrame * nMels + m;
					output[idx] += value;
					counts[idx] += w;
				}
			}
		}
		for (let i = 0; i < output.length; i++) if (counts[i] > 0) output[i] /= counts[i];
		return {
			data: output,
			nFrames: numFrames,
			nMels: outBins,
			hopSize,
			winLength,
			colourScale
		};
	}
	//#endregion
	//#region src/domain/coordinateSystem.ts
	function computeMaxBin({ isLinear, nyquist, spectrogramMels, boundedMaxFreq, melFreqs }) {
		if (isLinear) {
			const binHz = nyquist / spectrogramMels;
			return clamp(Math.floor(boundedMaxFreq / binHz), 1, spectrogramMels - 1);
		}
		let maxBin = spectrogramMels - 1;
		if (!melFreqs) return maxBin;
		const freqs = melFreqs;
		for (let i = 0; i < freqs.length; i++) if (freqs[i] > boundedMaxFreq) {
			maxBin = Math.max(1, i - 1);
			break;
		}
		return maxBin;
	}
	var CoordinateSystem = class {
		/**
		* @param {object} [params]
		* @param {number} [params.duration]           Audio duration in seconds
		* @param {number} [params.sampleRate]         Audio sample rate in Hz
		* @param {number} [params.pixelsPerSecond]    Current zoom level
		* @param {number} [params.canvasWidth]        Spectrogram canvas width in px
		* @param {number} [params.canvasHeight]       Spectrogram canvas height in px (display size)
		* @param {number} [params.maxFreq]            User-selected max frequency in Hz
		* @param {number} [params.spectrogramMels]    Number of mel bins (nMels)
		* @param {string} [params.scale]               'mel' | 'linear' | 'cqt'
		* @param {number} [params.frameRate]          Spectrogram frame rate (default: PERCH_FRAME_RATE)
		* @param {number} [params.hopSize]            Actual hop size in samples (0 = auto from frameRate)
		* @param {number[] | null} [params.freqRange]  [fMin, fMax] in Hz — explicit frequency range (for external images)
		* @param {string | null} [params.freqScale]    Frequency axis mapping: 'mel' | 'linear' | 'log' (for external images)
		* @param {number | null} [params.freqViewMin]  Frequency viewport bottom (Hz) for vertical zoom (null = 0)
		* @param {number | null} [params.freqViewMax]  Frequency viewport top (Hz) for vertical zoom (null = boundedMaxFreq)
		*/
		constructor({ duration = 0, sampleRate = DEFAULT_SAMPLE_RATE, pixelsPerSecond = 100, canvasWidth = 0, canvasHeight = 0, maxFreq = 1e4, spectrogramMels = 128, scale = "mel", frameRate = 100, hopSize = 0, freqRange = null, freqScale = null, freqViewMin = null, freqViewMax = null } = {}) {
			this._melFreqs = null;
			this._maxBin = 0;
			this._hasFreqView = false;
			this._viewMinBin = 0;
			this._viewMaxBin = 0;
			this._viewBinRange = 0;
			this.duration = Math.max(0, duration);
			this.sampleRate = Math.max(1, sampleRate);
			this.pixelsPerSecond = Math.max(.01, pixelsPerSecond);
			this.canvasWidth = Math.max(0, canvasWidth);
			this.canvasHeight = Math.max(1, canvasHeight);
			this.maxFreq = Math.max(1, maxFreq);
			this.spectrogramMels = Math.max(1, spectrogramMels);
			this.scale = scale;
			this.frameRate = Math.max(1, frameRate);
			/** @type {number[] | null} */
			this.freqRange = freqRange;
			/** @type {string | null} */
			this.freqScale = freqScale;
			this.nyquist = this.sampleRate / 2;
			this.boundedMaxFreq = this.freqRange ? Math.max(1, this.freqRange[1]) : Math.min(this.maxFreq, this.nyquist);
			this.isLinear = this.freqScale ? this.freqScale === "linear" : this.scale === "linear";
			this.hopSize = hopSize && hopSize > 0 ? hopSize : Math.max(1, Math.floor(this.sampleRate / this.frameRate));
			/** @type {Float32Array | null} */
			this._melFreqs = null;
			this._maxBin = this._computeMaxBin();
			const hasView = freqViewMin != null && freqViewMax != null && (freqViewMin > 0 || freqViewMax < this.boundedMaxFreq);
			this.freqViewMin = hasView ? Math.max(0, freqViewMin) : null;
			this.freqViewMax = hasView ? Math.min(this.boundedMaxFreq, freqViewMax) : null;
			this._hasFreqView = hasView;
			if (hasView) {
				this._viewMinBin = this._freqToBinFractional(this.freqViewMin);
				this._viewMaxBin = this._freqToBinFractional(this.freqViewMax);
				this._viewBinRange = this._viewMaxBin - this._viewMinBin;
			} else {
				this._viewMinBin = 0;
				this._viewMaxBin = this._maxBin;
				this._viewBinRange = this._maxBin;
			}
		}
		/** @returns {Float32Array} */
		get melFreqs() {
			if (!this._melFreqs) this._melFreqs = this.scale === "cqt" ? buildCQTFrequencies(this.spectrogramMels, CQT_FMIN, 24) : buildMelFrequencies(this.sampleRate, this.spectrogramMels);
			return this._melFreqs;
		}
		get maxBin() {
			return this._maxBin;
		}
		/** Compute maxBin for the current bounded max frequency. */
		_computeMaxBin() {
			return computeMaxBin({
				isLinear: this.isLinear,
				nyquist: this.nyquist,
				spectrogramMels: this.spectrogramMels,
				boundedMaxFreq: this.boundedMaxFreq,
				melFreqs: this.melFreqs
			});
		}
		/** Seconds → canvas pixel X. */
		timeToPixelX(timeSec) {
			if (this.duration <= 0 || this.canvasWidth <= 0) return 0;
			return timeSec / this.duration * this.canvasWidth;
		}
		/** Canvas pixel X → seconds. */
		pixelXToTime(pixelX) {
			if (this.canvasWidth <= 0 || this.duration <= 0) return 0;
			return clamp(pixelX / this.canvasWidth * this.duration, 0, this.duration);
		}
		/** Seconds → scroll‐aware pixel X (accounts for pixelsPerSecond). */
		timeToScrollX(timeSec) {
			return timeSec * this.pixelsPerSecond;
		}
		/** Scroll pixel → seconds. */
		scrollXToTime(scrollX) {
			return scrollX / this.pixelsPerSecond;
		}
		/**
		* Frequency → fractional bin index [0 .. maxBin].
		* Shared helper for both full-range and viewport-aware mappings.
		* @private
		*/
		_freqToBinFractional(freq) {
			const cf = clamp(freq, 0, this.boundedMaxFreq);
			if (this.isLinear) return clamp(cf / (this.nyquist / this.spectrogramMels), 0, this._maxBin);
			const freqs = this.melFreqs;
			if (cf >= freqs[this._maxBin]) return this._maxBin;
			for (let i = 0; i < this._maxBin; i++) if (freqs[i + 1] >= cf) {
				const range = freqs[i + 1] - freqs[i];
				return range > 0 ? i + (cf - freqs[i]) / range : i;
			}
			return 0;
		}
		/**
		* Frequency (Hz) → display pixel Y (0 = top).
		* Correctly handles mel, linear, and log modes.
		* When freqRange is set (external image), uses direct mapping over that range.
		* When freqViewMin/freqViewMax are set (vertical zoom), maps within that viewport.
		*/
		frequencyToPixelY(freq) {
			if (this.freqRange) return this._freqToPixelY_external(freq);
			const bin = this._freqToBinFractional(freq);
			if (this._hasFreqView) return (1 - (this._viewBinRange > 0 ? (bin - this._viewMinBin) / this._viewBinRange : 0)) * this.canvasHeight;
			const clampedBin = clamp(bin, 0, this._maxBin);
			const h = this.spectrogramMels;
			return (h - 1 - clampedBin / this._maxBin * (h - 1)) / h * this.canvasHeight;
		}
		/**
		* Display pixel Y (0 = top) → frequency (Hz).
		*/
		pixelYToFrequency(displayY) {
			if (this.freqRange) return this._pixelYToFreq_external(displayY);
			if (this._hasFreqView) {
				const fraction = 1 - clamp(displayY / this.canvasHeight, 0, 1);
				const bin = this._viewMinBin + fraction * this._viewBinRange;
				const clampedBin = clamp(Math.round(bin), 0, this._maxBin);
				if (this.isLinear) return clampedBin * (this.nyquist / this.spectrogramMels);
				return this.melFreqs[clampedBin] || 0;
			}
			const h = this.spectrogramMels;
			const internalY = displayY / this.canvasHeight * h;
			const clampedBin = clamp(Math.round((h - 1 - internalY) / (h - 1) * this._maxBin), 0, this._maxBin);
			if (this.isLinear) return clampedBin * (this.nyquist / this.spectrogramMels);
			return this.melFreqs[clampedBin] || 0;
		}
		/** @private Map frequency → pixel Y for an external image with known freqRange + freqScale. */
		_freqToPixelY_external(freq) {
			if (!this.freqRange) return 0;
			const [fMin, fMax] = this.freqRange;
			const cf = clamp(freq, fMin, fMax);
			const scale = this.freqScale || "linear";
			let fraction;
			if (scale === "log") {
				const safeMin = Math.max(1, fMin);
				fraction = Math.log(cf / safeMin) / Math.log(fMax / safeMin);
			} else if (scale === "mel") {
				const melMin = hzToMel(fMin);
				const melMax = hzToMel(fMax);
				const melF = hzToMel(cf);
				fraction = melMax > melMin ? (melF - melMin) / (melMax - melMin) : 0;
			} else fraction = fMax > fMin ? (cf - fMin) / (fMax - fMin) : 0;
			fraction = clamp(fraction, 0, 1);
			return (1 - fraction) * this.canvasHeight;
		}
		/** @private Map pixel Y → frequency for an external image with known freqRange + freqScale. */
		_pixelYToFreq_external(displayY) {
			if (!this.freqRange) return 0;
			const [fMin, fMax] = this.freqRange;
			const fraction = 1 - clamp(displayY / this.canvasHeight, 0, 1);
			const scale = this.freqScale || "linear";
			if (scale === "log") {
				const safeMin = Math.max(1, fMin);
				return safeMin * Math.pow(fMax / safeMin, fraction);
			} else if (scale === "mel") {
				const melMin = hzToMel(fMin);
				return melToHz(melMin + fraction * (hzToMel(fMax) - melMin));
			} else return fMin + fraction * (fMax - fMin);
		}
		/**
		* Frequency → normalized Y fraction (0 = top, 1 = bottom).
		* Useful for CSS positioning (e.g. frequency axis labels).
		*/
		frequencyToYFraction(freq) {
			if (this.canvasHeight <= 0) return 0;
			return this.frequencyToPixelY(freq) / this.canvasHeight;
		}
		/**
		* Frequency → Y fraction in the base spectrogram image (full range, viewport-independent).
		* 0 = top (highest freq), 1 = bottom (0 Hz).
		* Used for computing source crop when rendering with vertical zoom.
		*/
		frequencyToBaseYFraction(freq) {
			if (this.freqRange) return this._freqToPixelY_external(freq) / Math.max(1, this.canvasHeight);
			const bin = this._freqToBinFractional(freq);
			const h = this.spectrogramMels;
			return (h - 1 - bin / this._maxBin * (h - 1)) / h;
		}
		/** Frequency (Hz) → nearest bin index. */
		frequencyToBin(freq) {
			if (this.isLinear) {
				const binHz = this.nyquist / this.spectrogramMels;
				return clamp(Math.round(freq / binHz), 0, this.spectrogramMels - 1);
			}
			const freqs = this.melFreqs;
			let best = 0, bestDist = Math.abs(freqs[0] - freq);
			for (let i = 1; i < freqs.length; i++) {
				const d = Math.abs(freqs[i] - freq);
				if (d < bestDist) {
					bestDist = d;
					best = i;
				}
				if (freqs[i] > freq) break;
			}
			return best;
		}
		/** Bin index → frequency (Hz). */
		binToFrequency(bin) {
			const clamped = clamp(bin, 0, this.spectrogramMels - 1);
			if (this.isLinear) return clamped * (this.nyquist / this.spectrogramMels);
			return this.melFreqs[clamped] || 0;
		}
		/** Time (seconds) → spectrogram frame index. */
		timeToFrame(timeSec, nFrames) {
			const frameCenterSec = 2 * this.hopSize / this.sampleRate;
			return clamp(Math.round((timeSec - frameCenterSec) * this.frameRate), 0, nFrames - 1);
		}
		/** Frame index → time (seconds). */
		frameToTime(frame) {
			const frameCenterSec = 2 * this.hopSize / this.sampleRate;
			return frame / this.frameRate + frameCenterSec;
		}
		/** Display pixel Y → bin index (clamped to maxBin). */
		pixelYToBin(displayY) {
			if (this._hasFreqView) {
				const fraction = 1 - clamp(displayY / this.canvasHeight, 0, 1);
				const bin = this._viewMinBin + fraction * this._viewBinRange;
				return clamp(Math.round(bin), 0, this._maxBin);
			}
			const h = this.spectrogramMels;
			const internalY = displayY / this.canvasHeight * h;
			return clamp(Math.round((h - 1 - internalY) / (h - 1) * this._maxBin), 0, this._maxBin);
		}
		/**
		* Convert browser clientX/clientY relative to a wrapper rect
		* into canvas-local pixel coordinates.
		* @param {number} clientX
		* @param {number} clientY
		* @param {DOMRect} wrapperRect
		* @param {number} scrollLeft
		* @returns {{ canvasX: number, canvasY: number, localX: number, localY: number }}
		*/
		clientToCanvas(clientX, clientY, wrapperRect, scrollLeft) {
			const localX = clientX - wrapperRect.left;
			const localY = clientY - wrapperRect.top;
			return {
				canvasX: scrollLeft + localX,
				canvasY: Math.max(0, Math.min(localY, this.canvasHeight)),
				localX,
				localY
			};
		}
		/**
		* Convert browser clientX/clientY to time and frequency.
		* @param {number} clientX
		* @param {number} clientY
		* @param {DOMRect} wrapperRect
		* @param {number} scrollLeft
		* @returns {{ time: number, freq: number, canvasX: number, canvasY: number, localX: number, localY: number }}
		*/
		clientToTimeFreq(clientX, clientY, wrapperRect, scrollLeft) {
			const { canvasX, canvasY, localX, localY } = this.clientToCanvas(clientX, clientY, wrapperRect, scrollLeft);
			return {
				time: this.pixelXToTime(canvasX),
				freq: this.pixelYToFrequency(canvasY),
				canvasX,
				canvasY,
				localX,
				localY
			};
		}
	};
	//#endregion
	//#region src/domain/spectralFeatures.ts
	var _hannCache = /* @__PURE__ */ new Map();
	function hannWindow(n) {
		if (_hannCache.has(n)) return _hannCache.get(n);
		const w = new Float32Array(n);
		for (let i = 0; i < n; i++) w[i] = .5 - .5 * Math.cos(2 * Math.PI * i / (n - 1));
		_hannCache.set(n, w);
		return w;
	}
	function frameCentroid(channelData, offset, windowSize, fftSize, hann, freqPerBin) {
		const real = new Float32Array(fftSize);
		const imag = new Float32Array(fftSize);
		const copy = Math.min(windowSize, fftSize, channelData.length - offset);
		for (let i = 0; i < copy; i++) real[i] = (channelData[offset + i] || 0) * hann[i];
		iterativeFFT(real, imag);
		let sumMag = 0, sumWeighted = 0;
		const nBins = fftSize / 2;
		for (let k = 1; k < nBins; k++) {
			const mag = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);
			sumMag += mag;
			sumWeighted += mag * k * freqPerBin;
		}
		return sumMag > 1e-10 ? sumWeighted / sumMag : 0;
	}
	function frameCepstralF0(channelData, offset, windowSize, fftSize, hann, sampleRate, minF0, maxF0) {
		const real = new Float32Array(fftSize);
		const imag = new Float32Array(fftSize);
		const copy = Math.min(windowSize, fftSize, channelData.length - offset);
		for (let i = 0; i < copy; i++) real[i] = (channelData[offset + i] || 0) * hann[i];
		iterativeFFT(real, imag);
		const nBins = fftSize / 2;
		const logSpec = new Float32Array(fftSize);
		for (let k = 0; k < nBins; k++) {
			const mag = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);
			logSpec[k] = Math.log(mag + 1e-10);
		}
		for (let k = 1; k < nBins; k++) logSpec[fftSize - k] = logSpec[k];
		iterativeFFT(logSpec, new Float32Array(fftSize));
		const minQ = Math.max(1, Math.ceil(sampleRate / maxF0));
		const maxQ = Math.min(nBins - 1, Math.floor(sampleRate / minF0));
		let peakVal = -Infinity, peakQ = -1;
		for (let q = minQ; q <= maxQ; q++) {
			const v = logSpec[q];
			if (v > peakVal) {
				peakVal = v;
				peakQ = q;
			}
		}
		const threshold = logSpec[0] * .05;
		if (peakQ < 0 || peakVal < threshold) return 0;
		return sampleRate / peakQ;
	}
	/**
	* Compute per-frame spectral centroid and cepstral F0 from raw audio.
	*
	* @param channelData  Raw PCM samples (mono)
	* @param sampleRate   Sample rate in Hz
	* @param hopSize      Frame hop in samples (same as used for the spectrogram)
	* @param windowSize   Analysis window length in samples
	* @param nFrames      Number of spectrogram frames to match
	* @param minF0        Minimum expected F0 in Hz (default 50)
	* @param maxF0        Maximum expected F0 in Hz (default 1200)
	*/
	function computeSpectralFeatures(channelData, sampleRate, hopSize, windowSize, nFrames, minF0 = 50, maxF0 = 1200) {
		let fftSize = 1;
		while (fftSize < windowSize) fftSize <<= 1;
		const hann = hannWindow(windowSize);
		const freqPerBin = sampleRate / fftSize;
		const centroid = new Float32Array(nFrames);
		const f0 = new Float32Array(nFrames);
		for (let frame = 0; frame < nFrames; frame++) {
			const offset = frame * hopSize;
			if (offset + windowSize > channelData.length) break;
			centroid[frame] = frameCentroid(channelData, offset, windowSize, fftSize, hann, freqPerBin);
			f0[frame] = frameCepstralF0(channelData, offset, windowSize, fftSize, hann, sampleRate, minF0, maxF0);
		}
		return {
			centroid,
			f0
		};
	}
	function extractPeaks(mag, nBins, freqPerBin, topN, noiseFloor) {
		const peaks = [];
		for (let k = 1; k < nBins - 1; k++) if (mag[k] > mag[k - 1] && mag[k] > mag[k + 1] && mag[k] > noiseFloor) {
			const denom = mag[k - 1] - 2 * mag[k] + mag[k + 1];
			const delta = denom !== 0 ? .5 * (mag[k - 1] - mag[k + 1]) / denom : 0;
			peaks.push({
				freqHz: (k + delta) * freqPerBin,
				strength: mag[k]
			});
		}
		peaks.sort((a, b) => b.strength - a.strength);
		return peaks.slice(0, topN);
	}
	/**
	* Compute spectral ridges from raw audio.
	*
	* @param channelData       Mono PCM samples
	* @param sampleRate        Sample rate in Hz
	* @param hopSize           Frame hop in samples (same as spectrogram)
	* @param windowSize        Analysis window in samples
	* @param nFrames           Number of frames to analyse
	* @param minLengthFrames   Minimum ridge length; shorter ridges are discarded (default 8)
	* @param maxFreqJumpHz     Maximum Hz between consecutive ridge points (default 200)
	* @param maxGapFrames      Maximum silent frames before a ridge is closed (default 2)
	* @param maxPeaksPerFrame  How many local maxima to track per frame (default 20)
	*/
	function computeRidges(channelData, sampleRate, hopSize, windowSize, nFrames, minLengthFrames = 8, maxFreqJumpHz = 200, maxGapFrames = 2, maxPeaksPerFrame = 20) {
		let fftSize = 1;
		while (fftSize < windowSize) fftSize <<= 1;
		const nBins = fftSize / 2;
		const freqPerBin = sampleRate / fftSize;
		const hann = hannWindow(windowSize);
		const framePeaks = [];
		let globalMax = 0;
		const tempMag = new Float32Array(nBins);
		for (let frame = 0; frame < Math.min(nFrames, 200); frame++) {
			const off = frame * hopSize;
			if (off + windowSize > channelData.length) break;
			const real = new Float32Array(fftSize);
			const imag = new Float32Array(fftSize);
			for (let i = 0; i < windowSize; i++) real[i] = (channelData[off + i] || 0) * hann[i];
			iterativeFFT(real, imag);
			for (let k = 0; k < nBins; k++) {
				const m = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);
				if (m > globalMax) globalMax = m;
			}
		}
		const noiseFloor = globalMax * .02;
		for (let frame = 0; frame < nFrames; frame++) {
			const off = frame * hopSize;
			if (off + windowSize > channelData.length) {
				framePeaks.push([]);
				continue;
			}
			const real = new Float32Array(fftSize);
			const imag = new Float32Array(fftSize);
			for (let i = 0; i < windowSize; i++) real[i] = (channelData[off + i] || 0) * hann[i];
			iterativeFFT(real, imag);
			for (let k = 0; k < nBins; k++) tempMag[k] = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);
			framePeaks.push(extractPeaks(tempMag, nBins, freqPerBin, maxPeaksPerFrame, noiseFloor));
		}
		const active = [];
		const finished = [];
		for (let frame = 0; frame < nFrames; frame++) {
			for (let i = active.length - 1; i >= 0; i--) if (frame - active[i].lastFrame > maxGapFrames) finished.push(active.splice(i, 1)[0]);
			const peaks = framePeaks[frame];
			const matched = /* @__PURE__ */ new Set();
			for (const peak of peaks) {
				let bestIdx = -1, bestDist = maxFreqJumpHz;
				for (let i = 0; i < active.length; i++) {
					if (matched.has(i)) continue;
					const lastFreq = active[i].freqHz[active[i].freqHz.length - 1];
					const dist = Math.abs(peak.freqHz - lastFreq);
					if (dist < bestDist) {
						bestDist = dist;
						bestIdx = i;
					}
				}
				if (bestIdx >= 0) {
					matched.add(bestIdx);
					active[bestIdx].frames.push(frame);
					active[bestIdx].freqHz.push(peak.freqHz);
					active[bestIdx].strength.push(peak.strength);
					active[bestIdx].lastFrame = frame;
				} else active.push({
					frames: [frame],
					freqHz: [peak.freqHz],
					strength: [peak.strength],
					lastFrame: frame
				});
			}
		}
		finished.push(...active);
		const long = finished.filter((r) => r.frames.length >= minLengthFrames);
		if (long.length === 0) return [];
		let maxStrength = 1e-10;
		for (const r of long) for (const s of r.strength) if (s > maxStrength) maxStrength = s;
		return long.map((r) => ({
			frames: new Uint32Array(r.frames),
			freqHz: new Float32Array(r.freqHz),
			strength: new Float32Array(r.strength.map((s) => s / maxStrength))
		}));
	}
	//#endregion
	//#region src/infrastructure/spectrogram.worker.js?worker&inline
	var spectrogram_worker_exports = /* @__PURE__ */ __exportAll({ default: () => WorkerWrapper });
	function WorkerWrapper(options) {
		let objURL;
		try {
			objURL = blob && (self.URL || self.webkitURL).createObjectURL(blob);
			if (!objURL) throw "";
			const worker = new Worker(objURL, { name: options?.name });
			worker.addEventListener("error", () => {
				(self.URL || self.webkitURL).revokeObjectURL(objURL);
			});
			return worker;
		} catch (e) {
			return new Worker("data:text/javascript;charset=utf-8," + encodeURIComponent(jsContent), { name: options?.name });
		}
	}
	var jsContent, blob;
	var init_spectrogram_worker = __esmMin((() => {
		jsContent = "(function() {\n	//#region src/shared/utils.ts\n	function clamp(value, min, max) {\n		return Math.max(min, Math.min(max, value));\n	}\n	//#endregion\n	//#region src/domain/dsp.ts\n	function hzToMel(hz) {\n		return 2595 * Math.log10(1 + hz / 700);\n	}\n	function melToHz(mel) {\n		return 700 * (Math.pow(10, mel / 2595) - 1);\n	}\n	function createMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax) {\n		const nFftBins = Math.floor(fftSize / 2);\n		const melMin = hzToMel(fMin);\n		const melMax = hzToMel(fMax);\n		const melPoints = [];\n		for (let i = 0; i < nMels + 2; i++) melPoints.push(melMin + i / (nMels + 1) * (melMax - melMin));\n		const binPoints = melPoints.map(melToHz).map((hz) => Math.floor((fftSize + 1) * hz / sampleRate));\n		const filterbank = [];\n		for (let m = 1; m <= nMels; m++) {\n			const filter = new Float32Array(nFftBins);\n			const left = clamp(binPoints[m - 1], 0, nFftBins - 1);\n			const center = clamp(binPoints[m], 0, nFftBins - 1);\n			const right = clamp(binPoints[m + 1], 0, nFftBins - 1);\n			for (let k = left; k < center; k++) filter[k] = (k - left) / (center - left || 1);\n			for (let k = center; k < right; k++) filter[k] = (right - k) / (right - center || 1);\n			let filterSum = 0;\n			for (let k = 0; k < nFftBins; k++) filterSum += filter[k];\n			if (filterSum === 0) {\n				filter[center] = 1;\n				filterSum = 1;\n			}\n			for (let k = 0; k < nFftBins; k++) filter[k] /= filterSum;\n			filterbank.push(filter);\n		}\n		return filterbank;\n	}\n	/**\n	* Build a sparse mel filterbank for fast application.\n	* Instead of iterating all nFftBins per filter, stores only\n	* the non-zero (startBin, weights[]) ranges.\n	*/\n	function createSparseMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax) {\n		const dense = createMelFilterbank(sampleRate, fftSize, nMels, fMin, fMax);\n		const sparse = new Array(nMels);\n		for (let m = 0; m < nMels; m++) {\n			const f = dense[m];\n			let start = -1, end = 0;\n			for (let k = 0; k < f.length; k++) if (f[k] !== 0) {\n				if (start < 0) start = k;\n				end = k + 1;\n			}\n			if (start < 0) start = 0;\n			const weights = f.subarray(start, end);\n			sparse[m] = {\n				start,\n				weights\n			};\n		}\n		return sparse;\n	}\n	const _twiddleCache = /* @__PURE__ */ new Map();\n	function getTwiddleFactors(n) {\n		if (_twiddleCache.has(n)) return _twiddleCache.get(n);\n		const table = {};\n		for (let len = 2; len <= n; len <<= 1) {\n			const halfLen = len >> 1;\n			const angleStep = -2 * Math.PI / len;\n			const cos = new Float64Array(halfLen);\n			const sin = new Float64Array(halfLen);\n			for (let k = 0; k < halfLen; k++) {\n				const angle = angleStep * k;\n				cos[k] = Math.cos(angle);\n				sin[k] = Math.sin(angle);\n			}\n			table[len] = {\n				cos,\n				sin\n			};\n		}\n		_twiddleCache.set(n, table);\n		return table;\n	}\n	function iterativeFFT(real, imag) {\n		const n = real.length;\n		const twiddle = getTwiddleFactors(n);\n		let j = 0;\n		for (let i = 1; i < n; i++) {\n			let bit = n >> 1;\n			while (j & bit) {\n				j ^= bit;\n				bit >>= 1;\n			}\n			j ^= bit;\n			if (i < j) {\n				let tmp = real[i];\n				real[i] = real[j];\n				real[j] = tmp;\n				tmp = imag[i];\n				imag[i] = imag[j];\n				imag[j] = tmp;\n			}\n		}\n		for (let len = 2; len <= n; len <<= 1) {\n			const halfLen = len >> 1;\n			const { cos, sin } = twiddle[len];\n			for (let i = 0; i < n; i += len) for (let k = 0; k < halfLen; k++) {\n				const evenIndex = i + k;\n				const oddIndex = evenIndex + halfLen;\n				const tr = cos[k] * real[oddIndex] - sin[k] * imag[oddIndex];\n				const ti = sin[k] * real[oddIndex] + cos[k] * imag[oddIndex];\n				real[oddIndex] = real[evenIndex] - tr;\n				imag[oddIndex] = imag[evenIndex] - ti;\n				real[evenIndex] += tr;\n				imag[evenIndex] += ti;\n			}\n		}\n	}\n	/** @param {number} i @param {number} N */\n	function hannWindow$1(i, N) {\n		return .5 * (1 - Math.cos(2 * Math.PI * i / Math.max(1, N - 1)));\n	}\n	/** @param {number} i @param {number} N */\n	function hammingWindow(i, N) {\n		return .54 - .46 * Math.cos(2 * Math.PI * i / Math.max(1, N - 1));\n	}\n	/** @param {number} i @param {number} N */\n	function blackmanWindow(i, N) {\n		return .42 - .5 * Math.cos(2 * Math.PI * i / Math.max(1, N - 1)) + .08 * Math.cos(4 * Math.PI * i / Math.max(1, N - 1));\n	}\n	/** Blackman-Harris 4-term — excellent sidelobe suppression (−92 dB). */\n	function blackmanHarrisWindow(i, N) {\n		const a0 = .35875, a1 = .48829, a2 = .14128, a3 = .01168;\n		const t = 2 * Math.PI * i / Math.max(1, N - 1);\n		return a0 - a1 * Math.cos(t) + a2 * Math.cos(2 * t) - a3 * Math.cos(3 * t);\n	}\n	/** Flat-top — near-unity amplitude accuracy at the cost of wider main lobe. */\n	function flatTopWindow(i, N) {\n		const a0 = .21557895, a1 = .41663158, a2 = .277263158;\n		const a3 = .083578947, a4 = .006947368;\n		const t = 2 * Math.PI * i / Math.max(1, N - 1);\n		return a0 - a1 * Math.cos(t) + a2 * Math.cos(2 * t) - a3 * Math.cos(3 * t) + a4 * Math.cos(4 * t);\n	}\n	/**\n	* Kaiser window — adjustable via β parameter.\n	* Higher β → narrower main lobe, lower sidelobes.\n	* β≈5: similar to Hamming, β≈8.6: similar to Blackman-Harris.\n	* Uses rational Bessel I₀ approximation (no factorial overflow).\n	*/\n	function kaiserWindow(i, N, beta = 6) {\n		const alpha = Math.max(1, N - 1) / 2;\n		return _besselI0(beta * Math.sqrt(1 - ((i - alpha) / alpha) ** 2)) / _besselI0(beta);\n	}\n	/** Modified Bessel function I₀(x) — polynomial approximation (Abramowitz & Stegun). */\n	function _besselI0(x) {\n		const ax = Math.abs(x);\n		if (ax < 3.75) {\n			const t = (ax / 3.75) ** 2;\n			return 1 + t * (3.5156229 + t * (3.0899424 + t * (1.2067492 + t * (.2659732 + t * (.0360768 + t * .0045813)))));\n		}\n		const t = 3.75 / ax;\n		return Math.exp(ax) / Math.sqrt(ax) * (.39894228 + t * (.01328592 + t * (.00225319 + t * (-.00157565 + t * (.00916281 + t * (-.02057706 + t * (.02635537 + t * (-.01647633 + t * .00392377))))))));\n	}\n	const WINDOW_FUNCTIONS = {\n		hann: hannWindow$1,\n		hamming: hammingWindow,\n		blackman: blackmanWindow,\n		blackmanHarris: blackmanHarrisWindow,\n		flatTop: flatTopWindow,\n		kaiser: kaiserWindow\n	};\n	/**\n	* IEC 60268-18 fader law: map dB → meter deflection percentage.\n	* Used by the \"Meter\" colour scale to produce perceptually-even brightness.\n	*/\n	function iecDbToFader(db) {\n		if (db < -70) return 0;\n		if (db < -60) return (db + 70) * .25;\n		if (db < -50) return (db + 60) * .5 + 2.5;\n		if (db < -40) return (db + 50) * .75 + 7.5;\n		if (db < -30) return (db + 40) * 1.5 + 15;\n		if (db < -20) return (db + 30) * 2 + 30;\n		return (db + 20) * 2.5 + 50;\n	}\n	iecDbToFader(0);\n	/**\n	* Compute a full spectrogram from raw audio samples.\n	*\n	* @param {Object} params\n	* @param {ArrayBuffer|Float32Array} params.channelData - mono audio samples\n	* @param {number} params.fftSize\n	* @param {number} params.sampleRate\n	* @param {number} params.frameRate      - frames per second\n	* @param {number} params.nMels          - number of mel bins (mel scale)\n	* @param {number} params.pcenGain\n	* @param {number} params.pcenBias\n	* @param {number} params.pcenRoot\n	* @param {number} params.pcenSmoothing\n	* @param {boolean} [params.usePcen=true] - apply PCEN normalisation (false → dB even in mel mode)\n	* @param {string} [params.scale='mel'] - 'mel' or 'linear'\n	* @param {string} [params.colourScale='dbSquared'] - 'linear'|'meter'|'dbSquared'|'db'|'phase'\n	* @param {Float32Array} [params.initialSmooth] - carry-over PCEN smooth state from previous chunk\n	* @param {number} [params.windowSize] - window length in samples (0 or omit = auto: 4×hopSize)\n	* @param {number} [params.hopSize] - hop size in samples (0 or omit = auto: sampleRate/frameRate)\n	* @param {string} [params.windowFunction='hann'] - 'hann' | 'hamming' | 'blackman'\n	*\n	* @returns {{ data: Float32Array, nFrames: number, nMels: number, hopSize: number, winLength: number, colourScale: string, smoothState?: Float32Array }}\n	*/\n	function computeSpectrogram(params) {\n		const { channelData, fftSize, sampleRate, frameRate, nMels, pcenGain = 2, pcenBias = 1e-6, pcenRoot = 2, pcenSmoothing = .025, usePcen = true, scale = \"mel\", initialSmooth, colourScale = \"dbSquared\", windowSize: userWindowSize, hopSize: userHopSize, windowFunction = \"hann\" } = params;\n		const audio = channelData instanceof Float32Array ? channelData : new Float32Array(channelData);\n		const autoHop = Math.max(1, Math.floor(sampleRate / frameRate));\n		const hopSize = userHopSize && userHopSize > 0 ? userHopSize : autoHop;\n		const winLength = userWindowSize && userWindowSize > 0 ? userWindowSize : 4 * hopSize;\n		const numFrames = Math.max(1, Math.floor((audio.length - winLength) / hopSize) + 1);\n		const nBins = Math.floor(fftSize / 2);\n		const useLinear = scale === \"linear\";\n		const useCQT = scale === \"cqt\";\n		const isPhase = colourScale === \"phase\";\n		const outBins = useLinear ? nBins : nMels;\n		const output = new Float32Array(numFrames * outBins);\n		const wfn = WINDOW_FUNCTIONS[windowFunction] || hannWindow$1;\n		const maxCopy = Math.min(winLength, fftSize);\n		const windowLUT = new Float32Array(maxCopy);\n		for (let i = 0; i < maxCopy; i++) windowLUT[i] = wfn(i, winLength);\n		const real = new Float32Array(fftSize);\n		const imag = new Float32Array(fftSize);\n		const sparseFB = useLinear ? null : useCQT ? createCQTFilterbank(sampleRate, fftSize, nMels) : createSparseMelFilterbank(sampleRate, fftSize, nMels, 0, sampleRate / 2);\n		const denseFB = isPhase && !useLinear ? createMelFilterbank(sampleRate, fftSize, nMels, 0, sampleRate / 2) : null;\n		const powerBuf = !useLinear && !isPhase ? new Float32Array(nBins) : null;\n		/** Apply windowed frame into reusable buffers and run FFT in-place. */\n		function runFFT(offset) {\n			real.fill(0);\n			imag.fill(0);\n			for (let i = 0; i < maxCopy; i++) real[i] = (audio[offset + i] || 0) * windowLUT[i];\n			iterativeFFT(real, imag);\n		}\n		let smooth = null;\n		if (isPhase) for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {\n			runFFT(frameIdx * hopSize);\n			const base = frameIdx * outBins;\n			if (useLinear) for (let k = 0; k < nBins; k++) output[base + k] = Math.atan2(imag[k], real[k]);\n			else for (let m = 0; m < nMels; m++) {\n				const f = denseFB[m];\n				let sumSin = 0, sumCos = 0;\n				for (let k = 0; k < f.length; k++) if (f[k] > 0) {\n					const ph = Math.atan2(imag[k], real[k]);\n					sumSin += f[k] * Math.sin(ph);\n					sumCos += f[k] * Math.cos(ph);\n				}\n				output[base + m] = Math.atan2(sumSin, sumCos);\n			}\n		}\n		else if (useLinear) {\n			const wantRaw = colourScale === \"linear\" || colourScale === \"meter\";\n			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {\n				runFFT(frameIdx * hopSize);\n				const base = frameIdx * nBins;\n				if (wantRaw) for (let k = 0; k < nBins; k++) output[base + k] = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);\n				else for (let k = 0; k < nBins; k++) {\n					const p = real[k] * real[k] + imag[k] * imag[k];\n					output[base + k] = 10 * Math.log10(Math.max(1e-20, p));\n				}\n			}\n		} else if (!usePcen) {\n			const wantRaw = colourScale === \"linear\" || colourScale === \"meter\";\n			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {\n				runFFT(frameIdx * hopSize);\n				for (let k = 0; k < nBins; k++) powerBuf[k] = real[k] * real[k] + imag[k] * imag[k];\n				const base = frameIdx * nMels;\n				for (let m = 0; m < nMels; m++) {\n					const { start, weights } = sparseFB[m];\n					let sum = 0;\n					for (let k = 0; k < weights.length; k++) sum += powerBuf[start + k] * weights[k];\n					if (wantRaw) output[base + m] = Math.sqrt(Math.max(0, sum));\n					else output[base + m] = 10 * Math.log10(Math.max(1e-10, sum));\n				}\n			}\n		} else {\n			smooth = new Float32Array(nMels);\n			if (initialSmooth && initialSmooth.length === nMels) smooth.set(initialSmooth);\n			const pcenPower = 1 / pcenRoot;\n			const pcenBiasOffset = Math.pow(pcenBias, pcenPower);\n			for (let frameIdx = 0; frameIdx < numFrames; frameIdx++) {\n				runFFT(frameIdx * hopSize);\n				for (let k = 0; k < nBins; k++) powerBuf[k] = real[k] * real[k] + imag[k] * imag[k];\n				const base = frameIdx * nMels;\n				for (let m = 0; m < nMels; m++) {\n					const { start, weights } = sparseFB[m];\n					let e = 0;\n					for (let k = 0; k < weights.length; k++) e += powerBuf[start + k] * weights[k];\n					smooth[m] = (1 - pcenSmoothing) * smooth[m] + pcenSmoothing * e;\n					const denom = Math.pow(1e-10 + smooth[m], pcenGain);\n					const norm = e / denom;\n					output[base + m] = Math.pow(norm + pcenBias, pcenPower) - pcenBiasOffset;\n				}\n			}\n		}\n		const result = {\n			data: output,\n			nFrames: numFrames,\n			nMels: outBins,\n			hopSize,\n			winLength,\n			colourScale\n		};\n		if (smooth) result.smoothState = smooth;\n		return result;\n	}\n	/**\n	* Build a CQT (Constant-Q) filterbank that maps STFT bins to\n	* log-spaced frequency bins with constant Q factor.\n	* Returns a sparse filterbank compatible with applySparseMelFilterbank.\n	*\n	* @param {number} sampleRate\n	* @param {number} fftSize\n	* @param {number} nBinsOut        - desired number of output bins\n	* @param {number} [fMin=32.7]     - lowest frequency (Hz), default C1\n	* @param {number} [binsPerOctave=24]\n	*/\n	function createCQTFilterbank(sampleRate, fftSize, nBinsOut, fMin = 32.7, binsPerOctave = 24) {\n		const nFftBins = Math.floor(fftSize / 2);\n		const nyquist = sampleRate / 2;\n		const Q = 1 / (Math.pow(2, 1 / binsPerOctave) - 1);\n		const sparse = new Array(nBinsOut);\n		for (let k = 0; k < nBinsOut; k++) {\n			const fCenter = fMin * Math.pow(2, k / binsPerOctave);\n			if (fCenter >= nyquist) {\n				sparse[k] = {\n					start: nFftBins - 1,\n					weights: new Float32Array([1])\n				};\n				continue;\n			}\n			const bw = fCenter / Q;\n			const fLo = Math.max(0, fCenter - bw / 2);\n			const fHi = Math.min(nyquist, fCenter + bw / 2);\n			const binLo = Math.max(0, Math.floor(fLo * fftSize / sampleRate));\n			const len = Math.min(nFftBins - 1, Math.ceil(fHi * fftSize / sampleRate)) - binLo + 1;\n			const weights = new Float32Array(len);\n			let sum = 0;\n			for (let b = 0; b < len; b++) {\n				const fBin = (binLo + b) * sampleRate / fftSize;\n				const dist = Math.abs(fBin - fCenter) / (bw / 2 + 1e-12);\n				weights[b] = Math.max(0, 1 - dist);\n				sum += weights[b];\n			}\n			if (sum > 0) for (let b = 0; b < len; b++) weights[b] /= sum;\n			else if (len > 0) weights[0] = 1;\n			sparse[k] = {\n				start: binLo,\n				weights\n			};\n		}\n		return sparse;\n	}\n	//#endregion\n	//#region src/domain/spectralFeatures.ts\n	const _hannCache = /* @__PURE__ */ new Map();\n	function hannWindow(n) {\n		if (_hannCache.has(n)) return _hannCache.get(n);\n		const w = new Float32Array(n);\n		for (let i = 0; i < n; i++) w[i] = .5 - .5 * Math.cos(2 * Math.PI * i / (n - 1));\n		_hannCache.set(n, w);\n		return w;\n	}\n	function frameCentroid(channelData, offset, windowSize, fftSize, hann, freqPerBin) {\n		const real = new Float32Array(fftSize);\n		const imag = new Float32Array(fftSize);\n		const copy = Math.min(windowSize, fftSize, channelData.length - offset);\n		for (let i = 0; i < copy; i++) real[i] = (channelData[offset + i] || 0) * hann[i];\n		iterativeFFT(real, imag);\n		let sumMag = 0, sumWeighted = 0;\n		const nBins = fftSize / 2;\n		for (let k = 1; k < nBins; k++) {\n			const mag = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);\n			sumMag += mag;\n			sumWeighted += mag * k * freqPerBin;\n		}\n		return sumMag > 1e-10 ? sumWeighted / sumMag : 0;\n	}\n	function frameCepstralF0(channelData, offset, windowSize, fftSize, hann, sampleRate, minF0, maxF0) {\n		const real = new Float32Array(fftSize);\n		const imag = new Float32Array(fftSize);\n		const copy = Math.min(windowSize, fftSize, channelData.length - offset);\n		for (let i = 0; i < copy; i++) real[i] = (channelData[offset + i] || 0) * hann[i];\n		iterativeFFT(real, imag);\n		const nBins = fftSize / 2;\n		const logSpec = new Float32Array(fftSize);\n		for (let k = 0; k < nBins; k++) {\n			const mag = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);\n			logSpec[k] = Math.log(mag + 1e-10);\n		}\n		for (let k = 1; k < nBins; k++) logSpec[fftSize - k] = logSpec[k];\n		iterativeFFT(logSpec, new Float32Array(fftSize));\n		const minQ = Math.max(1, Math.ceil(sampleRate / maxF0));\n		const maxQ = Math.min(nBins - 1, Math.floor(sampleRate / minF0));\n		let peakVal = -Infinity, peakQ = -1;\n		for (let q = minQ; q <= maxQ; q++) {\n			const v = logSpec[q];\n			if (v > peakVal) {\n				peakVal = v;\n				peakQ = q;\n			}\n		}\n		const threshold = logSpec[0] * .05;\n		if (peakQ < 0 || peakVal < threshold) return 0;\n		return sampleRate / peakQ;\n	}\n	/**\n	* Compute per-frame spectral centroid and cepstral F0 from raw audio.\n	*\n	* @param channelData  Raw PCM samples (mono)\n	* @param sampleRate   Sample rate in Hz\n	* @param hopSize      Frame hop in samples (same as used for the spectrogram)\n	* @param windowSize   Analysis window length in samples\n	* @param nFrames      Number of spectrogram frames to match\n	* @param minF0        Minimum expected F0 in Hz (default 50)\n	* @param maxF0        Maximum expected F0 in Hz (default 1200)\n	*/\n	function computeSpectralFeatures(channelData, sampleRate, hopSize, windowSize, nFrames, minF0 = 50, maxF0 = 1200) {\n		let fftSize = 1;\n		while (fftSize < windowSize) fftSize <<= 1;\n		const hann = hannWindow(windowSize);\n		const freqPerBin = sampleRate / fftSize;\n		const centroid = new Float32Array(nFrames);\n		const f0 = new Float32Array(nFrames);\n		for (let frame = 0; frame < nFrames; frame++) {\n			const offset = frame * hopSize;\n			if (offset + windowSize > channelData.length) break;\n			centroid[frame] = frameCentroid(channelData, offset, windowSize, fftSize, hann, freqPerBin);\n			f0[frame] = frameCepstralF0(channelData, offset, windowSize, fftSize, hann, sampleRate, minF0, maxF0);\n		}\n		return {\n			centroid,\n			f0\n		};\n	}\n	function extractPeaks(mag, nBins, freqPerBin, topN, noiseFloor) {\n		const peaks = [];\n		for (let k = 1; k < nBins - 1; k++) if (mag[k] > mag[k - 1] && mag[k] > mag[k + 1] && mag[k] > noiseFloor) {\n			const denom = mag[k - 1] - 2 * mag[k] + mag[k + 1];\n			const delta = denom !== 0 ? .5 * (mag[k - 1] - mag[k + 1]) / denom : 0;\n			peaks.push({\n				freqHz: (k + delta) * freqPerBin,\n				strength: mag[k]\n			});\n		}\n		peaks.sort((a, b) => b.strength - a.strength);\n		return peaks.slice(0, topN);\n	}\n	/**\n	* Compute spectral ridges from raw audio.\n	*\n	* @param channelData       Mono PCM samples\n	* @param sampleRate        Sample rate in Hz\n	* @param hopSize           Frame hop in samples (same as spectrogram)\n	* @param windowSize        Analysis window in samples\n	* @param nFrames           Number of frames to analyse\n	* @param minLengthFrames   Minimum ridge length; shorter ridges are discarded (default 8)\n	* @param maxFreqJumpHz     Maximum Hz between consecutive ridge points (default 200)\n	* @param maxGapFrames      Maximum silent frames before a ridge is closed (default 2)\n	* @param maxPeaksPerFrame  How many local maxima to track per frame (default 20)\n	*/\n	function computeRidges(channelData, sampleRate, hopSize, windowSize, nFrames, minLengthFrames = 8, maxFreqJumpHz = 200, maxGapFrames = 2, maxPeaksPerFrame = 20) {\n		let fftSize = 1;\n		while (fftSize < windowSize) fftSize <<= 1;\n		const nBins = fftSize / 2;\n		const freqPerBin = sampleRate / fftSize;\n		const hann = hannWindow(windowSize);\n		const framePeaks = [];\n		let globalMax = 0;\n		const tempMag = new Float32Array(nBins);\n		for (let frame = 0; frame < Math.min(nFrames, 200); frame++) {\n			const off = frame * hopSize;\n			if (off + windowSize > channelData.length) break;\n			const real = new Float32Array(fftSize);\n			const imag = new Float32Array(fftSize);\n			for (let i = 0; i < windowSize; i++) real[i] = (channelData[off + i] || 0) * hann[i];\n			iterativeFFT(real, imag);\n			for (let k = 0; k < nBins; k++) {\n				const m = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);\n				if (m > globalMax) globalMax = m;\n			}\n		}\n		const noiseFloor = globalMax * .02;\n		for (let frame = 0; frame < nFrames; frame++) {\n			const off = frame * hopSize;\n			if (off + windowSize > channelData.length) {\n				framePeaks.push([]);\n				continue;\n			}\n			const real = new Float32Array(fftSize);\n			const imag = new Float32Array(fftSize);\n			for (let i = 0; i < windowSize; i++) real[i] = (channelData[off + i] || 0) * hann[i];\n			iterativeFFT(real, imag);\n			for (let k = 0; k < nBins; k++) tempMag[k] = Math.sqrt(real[k] * real[k] + imag[k] * imag[k]);\n			framePeaks.push(extractPeaks(tempMag, nBins, freqPerBin, maxPeaksPerFrame, noiseFloor));\n		}\n		const active = [];\n		const finished = [];\n		for (let frame = 0; frame < nFrames; frame++) {\n			for (let i = active.length - 1; i >= 0; i--) if (frame - active[i].lastFrame > maxGapFrames) finished.push(active.splice(i, 1)[0]);\n			const peaks = framePeaks[frame];\n			const matched = /* @__PURE__ */ new Set();\n			for (const peak of peaks) {\n				let bestIdx = -1, bestDist = maxFreqJumpHz;\n				for (let i = 0; i < active.length; i++) {\n					if (matched.has(i)) continue;\n					const lastFreq = active[i].freqHz[active[i].freqHz.length - 1];\n					const dist = Math.abs(peak.freqHz - lastFreq);\n					if (dist < bestDist) {\n						bestDist = dist;\n						bestIdx = i;\n					}\n				}\n				if (bestIdx >= 0) {\n					matched.add(bestIdx);\n					active[bestIdx].frames.push(frame);\n					active[bestIdx].freqHz.push(peak.freqHz);\n					active[bestIdx].strength.push(peak.strength);\n					active[bestIdx].lastFrame = frame;\n				} else active.push({\n					frames: [frame],\n					freqHz: [peak.freqHz],\n					strength: [peak.strength],\n					lastFrame: frame\n				});\n			}\n		}\n		finished.push(...active);\n		const long = finished.filter((r) => r.frames.length >= minLengthFrames);\n		if (long.length === 0) return [];\n		let maxStrength = 1e-10;\n		for (const r of long) for (const s of r.strength) if (s > maxStrength) maxStrength = s;\n		return long.map((r) => ({\n			frames: new Uint32Array(r.frames),\n			freqHz: new Float32Array(r.freqHz),\n			strength: new Float32Array(r.strength.map((s) => s / maxStrength))\n		}));\n	}\n	//#endregion\n	//#region src/infrastructure/spectrogram.worker.js\n	self.onmessage = (event) => {\n		const msg = event.data;\n		if (msg.type === \"spectralFeatures\") {\n			const { requestId, channelData, sampleRate, hopSize, windowSize, nFrames } = msg;\n			try {\n				const result = computeSpectralFeatures(new Float32Array(channelData), sampleRate, hopSize, windowSize, nFrames);\n				self.postMessage({\n					type: \"spectralFeaturesResult\",\n					requestId,\n					centroid: result.centroid.buffer,\n					f0: result.f0.buffer\n				}, { transfer: [result.centroid.buffer, result.f0.buffer] });\n			} catch (e) {\n				self.postMessage({\n					type: \"spectralFeaturesError\",\n					requestId,\n					message: String(e)\n				});\n			}\n			return;\n		}\n		if (msg.type === \"ridges\") {\n			const { requestId, channelData, sampleRate, hopSize, windowSize, nFrames } = msg;\n			try {\n				const ridges = computeRidges(new Float32Array(channelData), sampleRate, hopSize, windowSize, nFrames);\n				const packed = ridges.map((r) => ({\n					frames: r.frames.buffer,\n					freqHz: r.freqHz.buffer,\n					strength: r.strength.buffer\n				}));\n				const transfers = ridges.flatMap((r) => [\n					r.frames.buffer,\n					r.freqHz.buffer,\n					r.strength.buffer\n				]);\n				self.postMessage({\n					type: \"ridgesResult\",\n					requestId,\n					ridges: packed\n				}, { transfer: transfers });\n			} catch (e) {\n				self.postMessage({\n					type: \"ridgesError\",\n					requestId,\n					message: String(e)\n				});\n			}\n			return;\n		}\n		const { requestId, channelData, ...options } = msg;\n		const result = computeSpectrogram({\n			channelData: new Float32Array(channelData),\n			...options\n		});\n		const outMsg = {\n			requestId,\n			data: result.data.buffer,\n			nFrames: result.nFrames,\n			nMels: result.nMels,\n			hopSize: result.hopSize,\n			winLength: result.winLength,\n			colourScale: result.colourScale\n		};\n		const transfer = [result.data.buffer];\n		if (result.smoothState) {\n			outMsg.smoothState = result.smoothState.buffer;\n			transfer.push(result.smoothState.buffer);\n		}\n		self.postMessage(outMsg, { transfer });\n	};\n	//#endregion\n})();\n\n//# sourceMappingURL=spectrogram.worker-C606Su4Y.js.map";
		blob = typeof self !== "undefined" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", jsContent], { type: "text/javascript;charset=utf-8" });
	}));
	//#endregion
	//#region src/domain/spectrogram.ts
	var _WorkerCtor = null;
	var _workerCtorResolved = false;
	async function getWorkerCtor() {
		if (_workerCtorResolved) return _WorkerCtor;
		_workerCtorResolved = true;
		try {
			_WorkerCtor = (await Promise.resolve().then(() => (init_spectrogram_worker(), spectrogram_worker_exports))).default;
		} catch {
			_WorkerCtor = null;
		}
		return _WorkerCtor;
	}
	function computeAmplitudePeak(channelData) {
		let peak = 0;
		for (let i = 0; i < channelData.length; i++) {
			const abs = Math.abs(channelData[i]);
			if (abs > peak) peak = abs;
		}
		return Math.max(1e-6, peak);
	}
	var COLOR_MAPS = {
		inferno: [
			[
				0,
				0,
				4
			],
			[
				31,
				12,
				72
			],
			[
				85,
				15,
				109
			],
			[
				136,
				34,
				106
			],
			[
				186,
				54,
				85
			],
			[
				227,
				89,
				51
			],
			[
				249,
				140,
				10
			],
			[
				252,
				201,
				40
			],
			[
				252,
				255,
				164
			]
		],
		viridis: [
			[
				68,
				1,
				84
			],
			[
				68,
				2,
				86
			],
			[
				69,
				4,
				87
			],
			[
				69,
				5,
				89
			],
			[
				70,
				7,
				90
			],
			[
				70,
				8,
				92
			],
			[
				70,
				10,
				93
			],
			[
				70,
				11,
				94
			],
			[
				71,
				13,
				96
			],
			[
				71,
				14,
				97
			],
			[
				71,
				16,
				99
			],
			[
				71,
				17,
				100
			],
			[
				71,
				19,
				101
			],
			[
				72,
				20,
				103
			],
			[
				72,
				22,
				104
			],
			[
				72,
				23,
				105
			],
			[
				72,
				24,
				106
			],
			[
				72,
				26,
				108
			],
			[
				72,
				27,
				109
			],
			[
				72,
				28,
				110
			],
			[
				72,
				29,
				111
			],
			[
				72,
				31,
				112
			],
			[
				72,
				32,
				113
			],
			[
				72,
				33,
				115
			],
			[
				72,
				35,
				116
			],
			[
				72,
				36,
				117
			],
			[
				72,
				37,
				118
			],
			[
				72,
				38,
				119
			],
			[
				72,
				40,
				120
			],
			[
				72,
				41,
				121
			],
			[
				71,
				42,
				122
			],
			[
				71,
				44,
				122
			],
			[
				71,
				45,
				123
			],
			[
				71,
				46,
				124
			],
			[
				71,
				47,
				125
			],
			[
				70,
				48,
				126
			],
			[
				70,
				50,
				126
			],
			[
				70,
				51,
				127
			],
			[
				69,
				52,
				128
			],
			[
				69,
				53,
				129
			],
			[
				69,
				55,
				129
			],
			[
				68,
				56,
				130
			],
			[
				68,
				57,
				131
			],
			[
				68,
				58,
				131
			],
			[
				67,
				60,
				132
			],
			[
				67,
				61,
				132
			],
			[
				66,
				62,
				133
			],
			[
				66,
				63,
				133
			],
			[
				66,
				64,
				134
			],
			[
				65,
				66,
				134
			],
			[
				65,
				67,
				135
			],
			[
				64,
				68,
				135
			],
			[
				64,
				69,
				136
			],
			[
				63,
				71,
				136
			],
			[
				63,
				72,
				137
			],
			[
				62,
				73,
				137
			],
			[
				62,
				74,
				137
			],
			[
				62,
				76,
				138
			],
			[
				61,
				77,
				138
			],
			[
				61,
				78,
				138
			],
			[
				60,
				79,
				139
			],
			[
				60,
				80,
				139
			],
			[
				59,
				82,
				139
			],
			[
				59,
				83,
				140
			],
			[
				58,
				84,
				140
			],
			[
				58,
				85,
				140
			],
			[
				57,
				86,
				141
			],
			[
				57,
				88,
				141
			],
			[
				56,
				89,
				141
			],
			[
				56,
				90,
				141
			],
			[
				55,
				91,
				142
			],
			[
				55,
				92,
				142
			],
			[
				54,
				94,
				142
			],
			[
				54,
				95,
				142
			],
			[
				53,
				96,
				142
			],
			[
				53,
				97,
				143
			],
			[
				52,
				98,
				143
			],
			[
				52,
				99,
				143
			],
			[
				51,
				101,
				143
			],
			[
				51,
				102,
				143
			],
			[
				50,
				103,
				144
			],
			[
				50,
				104,
				144
			],
			[
				49,
				105,
				144
			],
			[
				49,
				106,
				144
			],
			[
				49,
				108,
				144
			],
			[
				48,
				109,
				144
			],
			[
				48,
				110,
				144
			],
			[
				47,
				111,
				145
			],
			[
				47,
				112,
				145
			],
			[
				46,
				113,
				145
			],
			[
				46,
				114,
				145
			],
			[
				45,
				116,
				145
			],
			[
				45,
				117,
				145
			],
			[
				44,
				118,
				145
			],
			[
				44,
				119,
				145
			],
			[
				44,
				120,
				146
			],
			[
				43,
				121,
				146
			],
			[
				43,
				122,
				146
			],
			[
				42,
				123,
				146
			],
			[
				42,
				125,
				146
			],
			[
				42,
				126,
				146
			],
			[
				41,
				127,
				146
			],
			[
				41,
				128,
				146
			],
			[
				40,
				129,
				146
			],
			[
				40,
				130,
				146
			],
			[
				40,
				131,
				146
			],
			[
				39,
				132,
				146
			],
			[
				39,
				133,
				146
			],
			[
				38,
				134,
				146
			],
			[
				38,
				136,
				146
			],
			[
				38,
				137,
				146
			],
			[
				37,
				138,
				146
			],
			[
				37,
				139,
				146
			],
			[
				36,
				140,
				146
			],
			[
				36,
				141,
				146
			],
			[
				36,
				142,
				146
			],
			[
				35,
				143,
				146
			],
			[
				35,
				144,
				146
			],
			[
				35,
				145,
				146
			],
			[
				34,
				146,
				146
			],
			[
				34,
				147,
				146
			],
			[
				33,
				148,
				146
			],
			[
				33,
				149,
				146
			],
			[
				33,
				150,
				146
			],
			[
				32,
				151,
				145
			],
			[
				32,
				152,
				145
			],
			[
				32,
				153,
				145
			],
			[
				31,
				154,
				145
			],
			[
				31,
				155,
				145
			],
			[
				31,
				156,
				145
			],
			[
				30,
				157,
				144
			],
			[
				30,
				158,
				144
			],
			[
				30,
				159,
				144
			],
			[
				30,
				160,
				144
			],
			[
				29,
				161,
				143
			],
			[
				29,
				162,
				143
			],
			[
				29,
				163,
				143
			],
			[
				29,
				164,
				142
			],
			[
				28,
				165,
				142
			],
			[
				28,
				166,
				142
			],
			[
				28,
				167,
				141
			],
			[
				28,
				168,
				141
			],
			[
				28,
				169,
				141
			],
			[
				27,
				170,
				140
			],
			[
				27,
				171,
				140
			],
			[
				27,
				172,
				139
			],
			[
				27,
				173,
				139
			],
			[
				27,
				174,
				138
			],
			[
				27,
				175,
				138
			],
			[
				27,
				176,
				137
			],
			[
				27,
				177,
				137
			],
			[
				27,
				178,
				136
			],
			[
				27,
				179,
				136
			],
			[
				27,
				180,
				135
			],
			[
				27,
				181,
				135
			],
			[
				27,
				182,
				134
			],
			[
				27,
				183,
				133
			],
			[
				28,
				184,
				133
			],
			[
				28,
				185,
				132
			],
			[
				28,
				186,
				131
			],
			[
				29,
				187,
				131
			],
			[
				29,
				188,
				130
			],
			[
				29,
				189,
				129
			],
			[
				30,
				190,
				129
			],
			[
				30,
				190,
				128
			],
			[
				31,
				191,
				127
			],
			[
				31,
				192,
				126
			],
			[
				32,
				193,
				126
			],
			[
				33,
				194,
				125
			],
			[
				33,
				195,
				124
			],
			[
				34,
				196,
				123
			],
			[
				35,
				197,
				123
			],
			[
				36,
				198,
				122
			],
			[
				37,
				198,
				121
			],
			[
				37,
				199,
				120
			],
			[
				38,
				200,
				119
			],
			[
				39,
				201,
				118
			],
			[
				40,
				202,
				118
			],
			[
				41,
				203,
				117
			],
			[
				42,
				203,
				116
			],
			[
				44,
				204,
				115
			],
			[
				45,
				205,
				114
			],
			[
				46,
				206,
				113
			],
			[
				47,
				207,
				112
			],
			[
				49,
				207,
				111
			],
			[
				50,
				208,
				110
			],
			[
				51,
				209,
				109
			],
			[
				53,
				210,
				108
			],
			[
				54,
				210,
				107
			],
			[
				56,
				211,
				106
			],
			[
				57,
				212,
				105
			],
			[
				59,
				213,
				104
			],
			[
				60,
				213,
				103
			],
			[
				62,
				214,
				102
			],
			[
				64,
				215,
				101
			],
			[
				65,
				215,
				100
			],
			[
				67,
				216,
				98
			],
			[
				69,
				217,
				97
			],
			[
				70,
				217,
				96
			],
			[
				72,
				218,
				95
			],
			[
				74,
				219,
				94
			],
			[
				76,
				219,
				93
			],
			[
				78,
				220,
				91
			],
			[
				80,
				221,
				90
			],
			[
				82,
				221,
				89
			],
			[
				83,
				222,
				88
			],
			[
				85,
				222,
				86
			],
			[
				87,
				223,
				85
			],
			[
				89,
				224,
				84
			],
			[
				91,
				224,
				83
			],
			[
				94,
				225,
				81
			],
			[
				96,
				225,
				80
			],
			[
				98,
				226,
				79
			],
			[
				100,
				226,
				77
			],
			[
				102,
				227,
				76
			],
			[
				104,
				227,
				75
			],
			[
				106,
				228,
				73
			],
			[
				109,
				228,
				72
			],
			[
				111,
				229,
				71
			],
			[
				113,
				229,
				69
			],
			[
				115,
				230,
				68
			],
			[
				118,
				230,
				66
			],
			[
				120,
				231,
				65
			],
			[
				122,
				231,
				64
			],
			[
				125,
				232,
				62
			],
			[
				127,
				232,
				61
			],
			[
				129,
				232,
				59
			],
			[
				132,
				233,
				58
			],
			[
				134,
				233,
				56
			],
			[
				137,
				234,
				55
			],
			[
				139,
				234,
				53
			],
			[
				141,
				235,
				52
			],
			[
				144,
				235,
				50
			],
			[
				146,
				235,
				49
			],
			[
				149,
				236,
				47
			],
			[
				151,
				236,
				46
			],
			[
				154,
				236,
				45
			],
			[
				156,
				237,
				43
			],
			[
				159,
				237,
				42
			],
			[
				161,
				237,
				40
			],
			[
				163,
				238,
				39
			],
			[
				166,
				238,
				38
			],
			[
				168,
				238,
				36
			],
			[
				171,
				239,
				35
			],
			[
				173,
				239,
				34
			],
			[
				176,
				239,
				32
			],
			[
				178,
				239,
				31
			],
			[
				181,
				240,
				30
			],
			[
				183,
				240,
				29
			],
			[
				186,
				240,
				28
			],
			[
				188,
				240,
				27
			],
			[
				191,
				241,
				26
			],
			[
				193,
				241,
				25
			],
			[
				195,
				241,
				24
			],
			[
				198,
				241,
				23
			],
			[
				200,
				241,
				23
			],
			[
				203,
				241,
				22
			],
			[
				205,
				242,
				22
			],
			[
				207,
				242,
				21
			],
			[
				210,
				242,
				21
			],
			[
				212,
				242,
				21
			],
			[
				214,
				242,
				21
			],
			[
				217,
				242,
				20
			],
			[
				219,
				242,
				20
			],
			[
				221,
				243,
				20
			],
			[
				224,
				243,
				21
			],
			[
				226,
				243,
				21
			],
			[
				228,
				243,
				21
			],
			[
				230,
				243,
				22
			],
			[
				232,
				243,
				22
			],
			[
				235,
				243,
				23
			],
			[
				237,
				244,
				24
			],
			[
				239,
				244,
				25
			],
			[
				241,
				244,
				26
			],
			[
				243,
				244,
				27
			],
			[
				245,
				244,
				28
			],
			[
				247,
				244,
				30
			],
			[
				249,
				244,
				31
			],
			[
				251,
				245,
				33
			],
			[
				253,
				245,
				35
			]
		],
		magma: [
			[
				0,
				0,
				4
			],
			[
				28,
				16,
				68
			],
			[
				79,
				18,
				123
			],
			[
				129,
				37,
				129
			],
			[
				181,
				54,
				122
			],
			[
				229,
				80,
				100
			],
			[
				251,
				135,
				97
			],
			[
				254,
				194,
				135
			],
			[
				252,
				253,
				191
			]
		],
		plasma: [
			[
				13,
				8,
				135
			],
			[
				75,
				3,
				161
			],
			[
				125,
				3,
				168
			],
			[
				168,
				34,
				150
			],
			[
				203,
				70,
				121
			],
			[
				229,
				107,
				93
			],
			[
				248,
				148,
				65
			],
			[
				253,
				195,
				40
			],
			[
				240,
				249,
				33
			]
		],
		xenocanto: [
			[
				0,
				0,
				0
			],
			[
				30,
				10,
				5
			],
			[
				65,
				20,
				10
			],
			[
				110,
				35,
				15
			],
			[
				160,
				55,
				15
			],
			[
				200,
				85,
				20
			],
			[
				230,
				130,
				30
			],
			[
				245,
				180,
				60
			],
			[
				255,
				220,
				110
			],
			[
				255,
				245,
				180
			],
			[
				255,
				255,
				255
			]
		]
	};
	function sampleColorMap(stops, t) {
		if (!stops || stops.length === 0) return {
			r: 0,
			g: 0,
			b: 0
		};
		if (stops.length === 1) return {
			r: stops[0][0],
			g: stops[0][1],
			b: stops[0][2]
		};
		const pos = clamp(t, 0, 1) * (stops.length - 1);
		const idx = Math.floor(pos);
		const frac = pos - idx;
		const a = stops[idx];
		const b = stops[Math.min(stops.length - 1, idx + 1)];
		return {
			r: Math.round(a[0] + (b[0] - a[0]) * frac),
			g: Math.round(a[1] + (b[1] - a[1]) * frac),
			b: Math.round(a[2] + (b[2] - a[2]) * frac)
		};
	}
	function getSpectrogramColor(value, colorScheme) {
		const x = clamp(value, 0, 1);
		if (colorScheme === "grayscale") {
			const v = Math.round((1 - x) * 255);
			return {
				r: v,
				g: v,
				b: v
			};
		}
		if (colorScheme === "viridis") {
			const palette = COLOR_MAPS.viridis;
			const color = palette[Math.min(palette.length - 1, Math.floor(x * (palette.length - 1)))];
			return {
				r: color[0],
				g: color[1],
				b: color[2]
			};
		}
		if (colorScheme === "fire") return {
			r: Math.round(255 * Math.pow(x, .7)),
			g: Math.round(255 * clamp((x - .15) / .85, 0, 1)),
			b: Math.round(255 * clamp((x - .45) / .55, 0, 1))
		};
		return sampleColorMap(COLOR_MAPS[colorScheme] || COLOR_MAPS.inferno, x);
	}
	var _VS = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() {
    v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);
    gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
	var _FS = `#version 300 es
precision mediump float;
uniform sampler2D u_gray;
uniform sampler2D u_lut;
uniform float u_floor;
uniform float u_rcpRange;
in vec2 v_uv;
out vec4 fragColor;
void main() {
    float g = texture(u_gray, v_uv).r;
    float t = clamp((g - u_floor) * u_rcpRange, 0.0, 1.0);
    fragColor = texture(u_lut, vec2(t, 0.5));
}`;
	var GpuColorizer = class {
		constructor() {
			this.length = 0;
			this._gl = null;
			this._maxTex = 0;
			this._prog = null;
			this._uFloor = null;
			this._uRcpRange = null;
			this._uGray = null;
			this._uLut = null;
			this._vao = null;
			this._grayTex = null;
			this._lutTex = null;
			this._w = 0;
			this._h = 0;
			this._lutScheme = null;
			this.gray = null;
			this.width = 0;
			this.height = 0;
			this._canvas = document.createElement("canvas");
			const gl = this._canvas.getContext("webgl2", {
				premultipliedAlpha: false,
				preserveDrawingBuffer: true,
				antialias: false
			});
			if (!gl) {
				this._gl = null;
				return;
			}
			this._gl = gl;
			this._maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
			const vs = this._sh(gl.VERTEX_SHADER, _VS);
			const fs = this._sh(gl.FRAGMENT_SHADER, _FS);
			if (!vs || !fs) {
				this._gl = null;
				return;
			}
			const p = gl.createProgram();
			gl.attachShader(p, vs);
			gl.attachShader(p, fs);
			gl.linkProgram(p);
			gl.deleteShader(vs);
			gl.deleteShader(fs);
			if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
				this._gl = null;
				return;
			}
			/** @type {WebGLProgram | null} */
			this._prog = p;
			/** @type {WebGLUniformLocation | null} */
			this._uFloor = gl.getUniformLocation(p, "u_floor");
			/** @type {WebGLUniformLocation | null} */
			this._uRcpRange = gl.getUniformLocation(p, "u_rcpRange");
			/** @type {WebGLUniformLocation | null} */
			this._uGray = gl.getUniformLocation(p, "u_gray");
			/** @type {WebGLUniformLocation | null} */
			this._uLut = gl.getUniformLocation(p, "u_lut");
			/** @type {WebGLVertexArrayObject | null} */
			const vao = gl.createVertexArray();
			gl.bindVertexArray(vao);
			const buf = gl.createBuffer();
			gl.bindBuffer(gl.ARRAY_BUFFER, buf);
			gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
				-1,
				-1,
				1,
				-1,
				-1,
				1,
				1,
				1
			]), gl.STATIC_DRAW);
			const loc = gl.getAttribLocation(p, "a_pos");
			gl.enableVertexAttribArray(loc);
			gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
			this._vao = vao;
			/** @type {WebGLTexture | null} */
			this._grayTex = gl.createTexture();
			/** @type {WebGLTexture | null} */
			this._lutTex = gl.createTexture();
			/** @type {number} */
			this._w = 0;
			/** @type {number} */
			this._h = 0;
			this._lutScheme = null;
		}
		/** @private */ _sh(type, src) {
			const gl = this._gl;
			if (!gl) return null;
			const s = gl.createShader(type);
			if (!s) return null;
			gl.shaderSource(s, src);
			gl.compileShader(s);
			if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
				gl.deleteShader(s);
				return null;
			}
			return s;
		}
		get ok() {
			return !!this._gl;
		}
		get canvas() {
			return this._canvas;
		}
		/** Upload Float32 grayscale map (values 0-1) as a R32F texture. Returns success. */
		uploadGrayscale(gray, width, height) {
			const gl = this._gl;
			if (!gl || width > this._maxTex || height > this._maxTex) return false;
			this._w = width;
			this._h = height;
			this._canvas.width = width;
			this._canvas.height = height;
			gl.bindTexture(gl.TEXTURE_2D, this._grayTex);
			gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0, gl.RED, gl.FLOAT, gray);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
			return true;
		}
		/** Build 256-entry RGBA color look-up texture from a color scheme. */
		uploadColorLut(scheme) {
			if (scheme === this._lutScheme) return;
			const gl = this._gl;
			if (!gl) return;
			this._lutScheme = scheme;
			const d = new Uint8Array(256 * 4);
			for (let i = 0; i < 256; i++) {
				const c = getSpectrogramColor(i / 255, scheme);
				const o = i * 4;
				d[o] = c.r;
				d[o + 1] = c.g;
				d[o + 2] = c.b;
				d[o + 3] = 255;
			}
			gl.bindTexture(gl.TEXTURE_2D, this._lutTex);
			gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, d);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
		}
		/** Render colorized spectrogram. floor01/ceil01 ∈ [0,1]. ~0.1 ms. */
		render(floor01, ceil01) {
			const gl = this._gl;
			if (!gl || !this._w || !this._prog || !this._vao) return;
			gl.viewport(0, 0, this._w, this._h);
			gl.useProgram(this._prog);
			gl.bindVertexArray(this._vao);
			gl.activeTexture(gl.TEXTURE0);
			gl.bindTexture(gl.TEXTURE_2D, this._grayTex);
			gl.uniform1i(this._uGray, 0);
			gl.activeTexture(gl.TEXTURE1);
			gl.bindTexture(gl.TEXTURE_2D, this._lutTex);
			gl.uniform1i(this._uLut, 1);
			gl.uniform1f(this._uFloor, floor01);
			gl.uniform1f(this._uRcpRange, 1 / Math.max(1e-4, ceil01 - floor01));
			gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
		}
		dispose() {
			const gl = this._gl;
			if (!gl) return;
			if (this._grayTex) gl.deleteTexture(this._grayTex);
			if (this._lutTex) gl.deleteTexture(this._lutTex);
			if (this._prog) gl.deleteProgram(this._prog);
			if (this._vao) gl.deleteVertexArray(this._vao);
			this._gl = null;
		}
	};
	function updateSpectrogramStats(spectrogramData) {
		if (!spectrogramData || spectrogramData.length === 0) return {
			logMin: 0,
			logMax: 1
		};
		let minLog = Number.POSITIVE_INFINITY;
		let maxLog = Number.NEGATIVE_INFINITY;
		const stride = Math.max(1, Math.floor(spectrogramData.length / 12e4));
		for (let i = 0; i < spectrogramData.length; i += stride) {
			const mapped = spectrogramData[i] || 0;
			if (mapped < minLog) minLog = mapped;
			if (mapped > maxLog) maxLog = mapped;
		}
		if (!Number.isFinite(minLog) || !Number.isFinite(maxLog) || maxLog - minLog < 1e-6) return {
			logMin: 0,
			logMax: 1
		};
		return {
			logMin: minLog,
			logMax: maxLog
		};
	}
	/**
	* Computes tighter logMin/logMax from the PCEN data using percentiles,
	* cutting away noise-floor and rare hot-spots for better visual contrast.
	* @param {Float32Array} spectrogramData  - flat PCEN output (nFrames × nMels)
	* @param {number} [loPercentile=2]       - lower percentile (black point)
	* @param {number} [hiPercentile=98]      - upper percentile (white point)
	*/
	function autoContrastStats(spectrogramData, loPercentile = 2, hiPercentile = 98) {
		if (!spectrogramData || spectrogramData.length === 0) return {
			logMin: 0,
			logMax: 1
		};
		const stride = Math.max(1, Math.floor(spectrogramData.length / 2e5));
		const mapped = [];
		for (let i = 0; i < spectrogramData.length; i += stride) mapped.push(spectrogramData[i] || 0);
		mapped.sort((a, b) => a - b);
		const loIdx = Math.floor(mapped.length * loPercentile / 100);
		const hiIdx = Math.min(mapped.length - 1, Math.floor(mapped.length * hiPercentile / 100));
		const logMin = mapped[loIdx];
		const logMax = mapped[hiIdx];
		if (!Number.isFinite(logMin) || !Number.isFinite(logMax) || logMax - logMin < 1e-6) return {
			logMin: 0,
			logMax: 1
		};
		return {
			logMin,
			logMax
		};
	}
	/**
	* Detects the effective upper frequency boundary by finding the highest
	* bin that carries meaningful energy above the noise floor.
	* Returns a frequency in Hz suitable as maxFreq.
	* @param {Float32Array} spectrogramData
	* @param {number} nFrames
	* @param {number} nMels
	* @param {number} sampleRate
	* @param {string} [scale='mel'] - 'mel' or 'linear'
	* @param {number} [energyThreshold=0.08] - fraction of peak-bin energy
	*/
	function detectMaxFrequency(spectrogramData, nFrames, nMels, sampleRate, scale = "mel", energyThreshold = .08) {
		if (!spectrogramData || nFrames <= 0 || nMels <= 0) return sampleRate / 2;
		const binEnergy = new Float64Array(nMels);
		const stride = Math.max(1, Math.floor(nFrames / 2e3));
		let sampledFrames = 0;
		for (let f = 0; f < nFrames; f += stride) {
			const base = f * nMels;
			for (let m = 0; m < nMels; m++) binEnergy[m] += spectrogramData[base + m] || 0;
			sampledFrames++;
		}
		for (let m = 0; m < nMels; m++) binEnergy[m] /= sampledFrames;
		let peakEnergy = -Infinity;
		for (let m = 0; m < nMels; m++) if (binEnergy[m] > peakEnergy) peakEnergy = binEnergy[m];
		if (!isFinite(peakEnergy)) return sampleRate / 2;
		if (peakEnergy >= 0 && peakEnergy < 1e-12) return sampleRate / 2;
		const threshold = peakEnergy < 0 ? peakEnergy + 10 * Math.log10(Math.max(1e-10, energyThreshold)) : peakEnergy * energyThreshold;
		const activeBins = [];
		for (let m = 0; m < nMels; m++) if (binEnergy[m] > threshold) activeBins.push(m);
		if (activeBins.length === 0) return sampleRate / 2;
		const upperBin = activeBins[Math.min(activeBins.length - 1, Math.ceil(activeBins.length * .95) - 1)];
		let detectedHz;
		if (scale === "linear") {
			const binHz = sampleRate / 2 / nMels;
			detectedHz = Math.min(nMels - 1, upperBin) * binHz;
		} else detectedHz = buildMelFrequencies(sampleRate, nMels)[Math.min(nMels - 1, upperBin)] || sampleRate / 2;
		const withMargin = detectedHz * 1.1;
		const steps = [
			2e3,
			3e3,
			4e3,
			5e3,
			6e3,
			8e3,
			1e4,
			12e3,
			16e3,
			2e4,
			22050
		];
		const nyquist = sampleRate / 2;
		let best = nyquist;
		for (const s of steps) if (s >= withMargin && s <= nyquist) {
			best = s;
			break;
		}
		return best;
	}
	function drawTimeGrid({ ctx, width, height, duration, pixelsPerSecond, scrollLeft = 0 }) {
		if (width <= 0) return;
		const majorColor = getComputedStyle(document.documentElement).getPropertyValue("--color-text-secondary").trim() || "#cbd5e1";
		const { majorStep, minorStep } = getTimeGridSteps(pixelsPerSecond);
		ctx.save();
		ctx.font = "11px monospace";
		ctx.textBaseline = "top";
		for (let t = 0; t <= duration; t += minorStep) {
			const x = Math.round(t * pixelsPerSecond) - scrollLeft + .5;
			if (x < 0 || x > width) continue;
			const isMajor = Math.abs(t / majorStep - Math.round(t / majorStep)) < 1e-4;
			ctx.strokeStyle = isMajor ? "rgba(148,163,184,0.35)" : "rgba(148,163,184,0.18)";
			ctx.beginPath();
			ctx.moveTo(x, 0);
			ctx.lineTo(x, height);
			ctx.stroke();
			if (isMajor) {
				ctx.fillStyle = majorColor;
				ctx.fillText(`${t.toFixed(1)}s`, x + 3, 4);
			}
		}
		ctx.restore();
	}
	/**
	* Stage 1 - Expensive, done ONCE per audio / fftSize / maxFreq change.
	* Converts spectrogram data → 8-bit grayscale image (Uint8Array).
	* Frame-averaging and mel→y mapping happens here.
	* The `colourScale` parameter controls how raw values map to pixel brightness:
	*   - 'dbSquared' / 'db' / PCEN: linear range mapping (existing behavior)
	*   - 'linear': proportional magnitude mapping
	*   - 'meter': IEC 60268-18 perceptual meter curve
	*   - 'phase': −π…+π → 0…255
	*/
	function buildSpectrogramGrayscale({ spectrogramData, spectrogramFrames, spectrogramMels, sampleRateHz, maxFreq, spectrogramAbsLogMin, spectrogramAbsLogMax, scale = "mel", colourScale = "dbSquared", noiseReduction = false, clahe = false }) {
		if (!spectrogramData || spectrogramFrames <= 0 || spectrogramMels <= 0) return null;
		const width = clamp(spectrogramFrames, 1, MAX_BASE_SPECTROGRAM_WIDTH);
		const height = spectrogramMels;
		const framesPerPixel = spectrogramFrames / width;
		const isLinear = scale === "linear";
		const boundedMaxFreq = Math.min(maxFreq, sampleRateHz / 2);
		const melFreqs = isLinear ? null : buildMelFrequencies(sampleRateHz, spectrogramMels);
		const maxBin = computeMaxBin({
			isLinear,
			nyquist: sampleRateHz / 2,
			spectrogramMels,
			boundedMaxFreq,
			melFreqs
		});
		const yBinLo = new Int16Array(height);
		const yBinHi = new Int16Array(height);
		const yBinFrac = new Float32Array(height);
		for (let y = 0; y < height; y++) {
			const fracBin = (height - 1 - y) / (height - 1) * maxBin;
			const lo = clamp(Math.floor(fracBin), 0, maxBin);
			const hi = Math.min(maxBin, lo + 1);
			yBinLo[y] = lo;
			yBinHi[y] = hi;
			yBinFrac[y] = fracBin - lo;
		}
		let cleanData = spectrogramData;
		if (noiseReduction && colourScale !== "phase") cleanData = spectralSubtract(spectrogramData, spectrogramFrames, spectrogramMels);
		const dataRange = Math.max(1e-6, spectrogramAbsLogMax - spectrogramAbsLogMin);
		const gray = new Float32Array(width * height);
		const IEC_MAX = colourScale === "meter" ? iecDbToFader(0) : 0;
		for (let x = 0; x < width; x++) {
			const frameStart = Math.round(x * framesPerPixel);
			const frameEnd = Math.max(frameStart + 1, Math.round((x + 1) * framesPerPixel));
			for (let y = 0; y < height; y++) {
				const binLo = yBinLo[y];
				const binHi = yBinHi[y];
				const frac = yBinFrac[y];
				let sumLo = 0, sumHi = 0, count = 0;
				for (let frame = frameStart; frame < frameEnd; frame++) {
					const base = frame * spectrogramMels;
					sumLo += cleanData[base + binLo] || 0;
					sumHi += cleanData[base + binHi] || 0;
					count++;
				}
				const value = (sumLo + (sumHi - sumLo) * frac) / Math.max(1, count);
				let pixel;
				if (colourScale === "phase") pixel = (value + Math.PI) / (2 * Math.PI);
				else if (colourScale === "meter") {
					const raw = clamp((value - spectrogramAbsLogMin) / dataRange, 0, 1);
					const db = raw <= 0 ? -80 : 20 * Math.log10(raw);
					pixel = iecDbToFader(Math.max(-70, db)) / IEC_MAX;
				} else pixel = (value - spectrogramAbsLogMin) / dataRange;
				gray[y * width + x] = clamp(pixel, 0, 1);
			}
		}
		if (clahe && colourScale !== "phase") applyCLAHE(gray, width, height);
		return {
			gray,
			width,
			height
		};
	}
	/**
	* Estimate per-bin noise floor via median across all frames,
	* then subtract it so stationary noise is suppressed.
	* Returns a new Float32Array with the same layout.
	*/
	function spectralSubtract(spectrogramData, nFrames, nMels) {
		const result = new Float32Array(spectrogramData.length);
		for (let m = 0; m < nMels; m++) {
			const stride = Math.max(1, Math.floor(nFrames / 2e3));
			const vals = [];
			for (let f = 0; f < nFrames; f += stride) vals.push(spectrogramData[f * nMels + m]);
			vals.sort((a, b) => a - b);
			const median = vals[Math.floor(vals.length / 2)] || 0;
			for (let f = 0; f < nFrames; f++) {
				const idx = f * nMels + m;
				result[idx] = Math.max(0, spectrogramData[idx] - median);
			}
		}
		return result;
	}
	/**
	* Apply CLAHE in-place on a Float32Array grayscale image (values 0-1).
	* Divides the image into tiles, computes clipped histograms per tile,
	* and bilinearly interpolates between tile mappings for smooth results.
	*
	* @param {Float32Array} gray   - image data [0,1], modified in place
	* @param {number} width
	* @param {number} height
	* @param {number} [nTilesX=8]  - number of horizontal tiles
	* @param {number} [nTilesY=4]  - number of vertical tiles
	* @param {number} [clipLimit=2.5] - contrast limit (higher = more contrast, 1 = no enhancement)
	*/
	function applyCLAHE(gray, width, height, nTilesX = 8, nTilesY = 4, clipLimit = 2.5) {
		const nBins = 256;
		const tilesX = Math.min(nTilesX, width);
		const tilesY = Math.min(nTilesY, height);
		if (tilesX < 1 || tilesY < 1) return;
		const tileW = width / tilesX;
		const tileH = height / tilesY;
		const cdfs = new Array(tilesY * tilesX);
		for (let ty = 0; ty < tilesY; ty++) for (let tx = 0; tx < tilesX; tx++) {
			const x0 = Math.floor(tx * tileW);
			const x1 = Math.min(width, Math.floor((tx + 1) * tileW));
			const y0 = Math.floor(ty * tileH);
			const y1 = Math.min(height, Math.floor((ty + 1) * tileH));
			const nPixels = (x1 - x0) * (y1 - y0);
			if (nPixels === 0) {
				cdfs[ty * tilesX + tx] = new Float32Array(nBins);
				continue;
			}
			const hist = new Float64Array(nBins);
			for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
				const bin = Math.min(nBins - 1, Math.floor(gray[yy * width + xx] * (nBins - 1)));
				hist[bin]++;
			}
			const limit = Math.max(1, Math.floor(clipLimit * nPixels / nBins));
			let excess = 0;
			for (let i = 0; i < nBins; i++) if (hist[i] > limit) {
				excess += hist[i] - limit;
				hist[i] = limit;
			}
			const perBin = excess / nBins;
			for (let i = 0; i < nBins; i++) hist[i] += perBin;
			const cdf = new Float32Array(nBins);
			cdf[0] = hist[0];
			for (let i = 1; i < nBins; i++) cdf[i] = cdf[i - 1] + hist[i];
			const cdfMin = cdf[0];
			const cdfRange = Math.max(1, cdf[nBins - 1] - cdfMin);
			for (let i = 0; i < nBins; i++) cdf[i] = (cdf[i] - cdfMin) / cdfRange;
			cdfs[ty * tilesX + tx] = cdf;
		}
		for (let y = 0; y < height; y++) {
			const tyF = (y + .5) / tileH - .5;
			const ty0 = clamp(Math.floor(tyF), 0, tilesY - 1);
			const ty1 = Math.min(tilesY - 1, ty0 + 1);
			const fy = tyF - ty0;
			for (let x = 0; x < width; x++) {
				const txF = (x + .5) / tileW - .5;
				const tx0 = clamp(Math.floor(txF), 0, tilesX - 1);
				const tx1 = Math.min(tilesX - 1, tx0 + 1);
				const fx = txF - tx0;
				const val = gray[y * width + x];
				const bin = clamp(Math.floor(val * (nBins - 1)), 0, nBins - 1);
				const c00 = cdfs[ty0 * tilesX + tx0][bin];
				const c10 = cdfs[ty0 * tilesX + tx1][bin];
				const c01 = cdfs[ty1 * tilesX + tx0][bin];
				const c11 = cdfs[ty1 * tilesX + tx1][bin];
				const top = c00 + (c10 - c00) * fx;
				const bot = c01 + (c11 - c01) * fx;
				gray[y * width + x] = clamp(top + (bot - top) * fy, 0, 1);
			}
		}
	}
	/**
	* Stage 2 - Cheap JS fallback, called on every floor/ceil/colorScheme change.
	* Builds a 256-entry RGBA look-up table, then paints the Float32 grayscale map.
	*/
	function colorizeSpectrogram(grayInfo, floor01, ceil01, colorScheme) {
		if (!grayInfo) return null;
		const { gray, width, height } = grayInfo;
		const lut = new Uint32Array(256);
		const range = Math.max(1e-6, ceil01 - floor01);
		const view = new DataView(lut.buffer);
		for (let i = 0; i < 256; i++) {
			const c = getSpectrogramColor(clamp((i / 255 - floor01) / range, 0, 1), colorScheme);
			view.setUint32(i * 4, 255 << 24 | c.b << 16 | c.g << 8 | c.r, true);
		}
		const canvas = document.createElement("canvas");
		canvas.width = width;
		canvas.height = height;
		const ctx = canvas.getContext("2d");
		if (!ctx) return null;
		const imageData = ctx.createImageData(width, height);
		const pixels = new Uint32Array(imageData.data.buffer);
		const len = width * height;
		for (let i = 0; i < len; i++) pixels[i] = lut[clamp(Math.round(gray[i] * 255), 0, 255)];
		ctx.putImageData(imageData, 0, 0);
		return canvas;
	}
	function renderSpectrogram({ duration, spectrogramCanvas, pixelsPerSecond, canvasHeight, baseCanvas, freqViewSrcCrop, canvasSizer, scrollLeft = 0, viewportWidth, totalWidth: explicitTotalWidth }) {
		if (!baseCanvas) return;
		const ctx = spectrogramCanvas.getContext("2d");
		if (!ctx) return;
		const totalWidth = (explicitTotalWidth ?? 0) > 0 ? explicitTotalWidth : Math.max(1, Math.round(duration * pixelsPerSecond));
		const height = Math.max(140, Math.floor(canvasHeight));
		if (canvasSizer) canvasSizer.style.width = `${totalWidth}px`;
		const vw = viewportWidth ?? 0;
		const width = vw > 0 ? Math.min(vw, totalWidth) : totalWidth;
		const sl = vw > 0 ? scrollLeft ?? 0 : 0;
		spectrogramCanvas.width = width;
		spectrogramCanvas.height = height;
		ctx.clearRect(0, 0, width, height);
		const x0 = -sl;
		const drawWidth = totalWidth;
		const srcY = freqViewSrcCrop?.srcY ?? 0;
		const srcH = freqViewSrcCrop?.srcH ?? baseCanvas.height;
		const visLeft = Math.max(0, -x0);
		const visRight = Math.min(drawWidth, width - x0);
		if (visRight > visLeft) {
			const srcFrac0 = visLeft / drawWidth;
			const srcFrac1 = visRight / drawWidth;
			const clipSrcX = srcFrac0 * baseCanvas.width;
			const clipSrcW = (srcFrac1 - srcFrac0) * baseCanvas.width;
			const dstX = x0 + visLeft;
			const dstW = visRight - visLeft;
			const wantCrispH = drawWidth >= baseCanvas.width;
			if (wantCrispH && height !== srcH) {
				const oc = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(Math.ceil(clipSrcW), height) : (() => {
					const c = document.createElement("canvas");
					c.width = Math.ceil(clipSrcW);
					c.height = height;
					return c;
				})();
				const octx = oc.getContext("2d");
				if (!octx) return;
				octx.imageSmoothingEnabled = true;
				octx.imageSmoothingQuality = "high";
				octx.drawImage(baseCanvas, clipSrcX, srcY, clipSrcW, srcH, 0, 0, Math.ceil(clipSrcW), height);
				ctx.imageSmoothingEnabled = false;
				ctx.drawImage(oc, 0, 0, Math.ceil(clipSrcW), height, dstX, 0, dstW, height);
			} else {
				ctx.imageSmoothingEnabled = !wantCrispH;
				ctx.drawImage(baseCanvas, clipSrcX, srcY, clipSrcW, srcH, dstX, 0, dstW, height);
			}
		}
		drawTimeGrid({
			ctx,
			width,
			height,
			duration,
			pixelsPerSecond,
			scrollLeft: sl
		});
	}
	function createSpectrogramProcessor() {
		let worker = null;
		let workerFailed = false;
		let requestCounter = 0;
		const pendingRequests = /* @__PURE__ */ new Map();
		const computeMainThread = (channelData, options) => {
			const res = computeSpectrogram({
				channelData,
				...options
			});
			return Promise.resolve(res);
		};
		const ensureWorker = async () => {
			if (worker || workerFailed) return;
			try {
				const Ctor = await getWorkerCtor();
				if (!Ctor) throw new Error("Worker constructor unavailable");
				worker = new Ctor();
				worker.onmessage = (event) => {
					const { requestId, data: bufferData, nFrames, nMels, smoothState, hopSize, winLength, colourScale } = event.data;
					const pending = pendingRequests.get(requestId);
					if (!pending) return;
					pendingRequests.delete(requestId);
					const result = {
						data: new Float32Array(bufferData),
						nFrames,
						nMels,
						hopSize,
						winLength,
						colourScale
					};
					if (smoothState) result.smoothState = new Float32Array(smoothState);
					pending.resolve(result);
				};
				worker.onerror = (error) => {
					console.warn("Spectrogram Worker failed, using main-thread fallback:", error);
					workerFailed = true;
					worker?.terminate();
					worker = null;
					pendingRequests.forEach(({ reject }) => reject(error));
					pendingRequests.clear();
				};
			} catch (e) {
				console.warn("Cannot create Worker, using main-thread fallback:", e);
				workerFailed = true;
				worker = null;
			}
		};
		const compute = async (channelData, options) => {
			if (workerFailed) return computeMainThread(channelData, options);
			await ensureWorker();
			if (workerFailed) return computeMainThread(channelData, options);
			const requestId = ++requestCounter;
			const audioCopy = new Float32Array(channelData);
			const workerPromise = new Promise((resolve, reject) => {
				pendingRequests.set(requestId, {
					resolve,
					reject
				});
			});
			worker.postMessage({
				requestId,
				channelData: audioCopy.buffer,
				...options
			}, [audioCopy.buffer]);
			const durationSec = channelData.length / Math.max(1, options.sampleRate || 44100);
			const timeoutMs = Math.max(5e3, 5e3 + Math.ceil(durationSec / 10) * 2e3);
			try {
				return await Promise.race([workerPromise, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Worker timeout")), timeoutMs))]);
			} catch (e) {
				console.warn("Worker failed/timed out, computing on main thread:", e?.message || e);
				pendingRequests.delete(requestId);
				workerFailed = true;
				worker?.terminate();
				worker = null;
				return computeMainThread(channelData, options);
			}
		};
		const computeProgressive = async function* (channelData, options) {
			const sampleRate = Math.max(1, options.sampleRate || 0);
			const chunkSeconds = Math.max(1, options.chunkSeconds || 10);
			const samplesPerChunk = Math.max(1, Math.floor(chunkSeconds * sampleRate));
			const totalChunks = Math.max(1, Math.ceil(channelData.length / samplesPerChunk));
			const overlapSamples = options.windowSize || options.fftSize || 2048;
			let smoothState = null;
			for (let chunk = 0; chunk < totalChunks; chunk++) {
				const startSample = chunk * samplesPerChunk;
				const endSample = Math.min(channelData.length, startSample + samplesPerChunk);
				const actualStart = chunk === 0 ? startSample : Math.max(0, startSample - overlapSamples);
				const prependedSamples = startSample - actualStart;
				const result = await compute(channelData.subarray(actualStart, endSample), smoothState ? {
					...options,
					initialSmooth: smoothState
				} : options);
				if (result.smoothState) smoothState = result.smoothState;
				let trimmedResult = result;
				if (prependedSamples > 0 && result.nFrames > 0) {
					const hop = result.hopSize || 1;
					const overlapFrames = Math.min(result.nFrames - 1, Math.ceil(prependedSamples / hop));
					if (overlapFrames > 0) {
						const nMels = result.nMels;
						trimmedResult = {
							data: result.data.slice(overlapFrames * nMels),
							nFrames: result.nFrames - overlapFrames,
							nMels: result.nMels,
							hopSize: result.hopSize,
							winLength: result.winLength,
							colourScale: result.colourScale,
							smoothState: result.smoothState
						};
					}
				}
				yield {
					chunk,
					totalChunks,
					percent: (chunk + 1) / totalChunks * 100,
					result: trimmedResult
				};
			}
		};
		const dispose = () => {
			if (worker) {
				worker.terminate();
				worker = null;
			}
			pendingRequests.clear();
		};
		return {
			compute,
			computeProgressive,
			dispose
		};
	}
	function createSpectralFeaturesProcessor() {
		let worker = null;
		let workerFailed = false;
		let requestCounter = 0;
		const pendingFeatures = /* @__PURE__ */ new Map();
		const pendingRidges = /* @__PURE__ */ new Map();
		const ensureWorker = async () => {
			if (worker || workerFailed) return;
			try {
				const Ctor = await getWorkerCtor();
				if (!Ctor) throw new Error("Worker unavailable");
				worker = new Ctor();
				worker.onmessage = (event) => {
					const { type, requestId } = event.data;
					if (type === "spectralFeaturesResult") {
						const p = pendingFeatures.get(requestId);
						pendingFeatures.delete(requestId);
						p?.resolve({
							centroid: new Float32Array(event.data.centroid),
							f0: new Float32Array(event.data.f0)
						});
					} else if (type === "spectralFeaturesError") {
						const p = pendingFeatures.get(requestId);
						pendingFeatures.delete(requestId);
						p?.reject(new Error(event.data.message || "spectralFeatures worker error"));
					} else if (type === "ridgesResult") {
						const p = pendingRidges.get(requestId);
						pendingRidges.delete(requestId);
						p?.resolve(event.data.ridges.map((r) => ({
							frames: new Uint32Array(r.frames),
							freqHz: new Float32Array(r.freqHz),
							strength: new Float32Array(r.strength)
						})));
					} else if (type === "ridgesError") {
						const p = pendingRidges.get(requestId);
						pendingRidges.delete(requestId);
						p?.reject(new Error(event.data.message || "ridges worker error"));
					}
				};
				worker.onerror = (err) => {
					workerFailed = true;
					worker?.terminate();
					worker = null;
					pendingFeatures.forEach(({ reject }) => reject(err));
					pendingFeatures.clear();
					pendingRidges.forEach(({ reject }) => reject(err));
					pendingRidges.clear();
				};
			} catch {
				workerFailed = true;
			}
		};
		const computeFeatures = async (channelData, sampleRate, hopSize, windowSize, nFrames) => {
			if (workerFailed) return computeSpectralFeatures(channelData, sampleRate, hopSize, windowSize, nFrames);
			await ensureWorker();
			if (workerFailed) return computeSpectralFeatures(channelData, sampleRate, hopSize, windowSize, nFrames);
			const requestId = ++requestCounter;
			const copy = channelData.slice();
			return new Promise((resolve, reject) => {
				pendingFeatures.set(requestId, {
					resolve,
					reject
				});
				worker.postMessage({
					type: "spectralFeatures",
					requestId,
					channelData: copy.buffer,
					sampleRate,
					hopSize,
					windowSize,
					nFrames
				}, { transfer: [copy.buffer] });
			});
		};
		const computeRidgesWorker = async (channelData, sampleRate, hopSize, windowSize, nFrames) => {
			if (workerFailed) return computeRidges(channelData, sampleRate, hopSize, windowSize, nFrames);
			await ensureWorker();
			if (workerFailed) return computeRidges(channelData, sampleRate, hopSize, windowSize, nFrames);
			const requestId = ++requestCounter;
			const copy = channelData.slice();
			return new Promise((resolve, reject) => {
				pendingRidges.set(requestId, {
					resolve,
					reject
				});
				worker.postMessage({
					type: "ridges",
					requestId,
					channelData: copy.buffer,
					sampleRate,
					hopSize,
					windowSize,
					nFrames
				}, { transfer: [copy.buffer] });
			});
		};
		const dispose = () => {
			worker?.terminate();
			worker = null;
			pendingFeatures.forEach(({ reject }) => reject(/* @__PURE__ */ new Error("Disposed")));
			pendingFeatures.clear();
			pendingRidges.forEach(({ reject }) => reject(/* @__PURE__ */ new Error("Disposed")));
			pendingRidges.clear();
		};
		return {
			computeFeatures,
			computeRidges: computeRidgesWorker,
			dispose
		};
	}
	//#endregion
	//#region src/infrastructure/storage/IStorage.ts
	var StorageBase = class {
		getItem(_key) {
			throw new Error(`${this.constructor.name}: getItem not implemented`);
		}
		setItem(_key, _value) {
			throw new Error(`${this.constructor.name}: setItem not implemented`);
		}
		removeItem(_key) {
			throw new Error(`${this.constructor.name}: removeItem not implemented`);
		}
		hasItem(key) {
			return this.getItem(key) !== null;
		}
		clear() {
			throw new Error(`${this.constructor.name}: clear not implemented`);
		}
	};
	//#endregion
	//#region src/infrastructure/storage/LocalStorageAdapter.ts
	var LocalStorageAdapter = class extends StorageBase {
		getItem(key) {
			try {
				return localStorage.getItem(key);
			} catch {
				return null;
			}
		}
		setItem(key, value) {
			try {
				localStorage.setItem(key, value);
			} catch {}
		}
		removeItem(key) {
			try {
				localStorage.removeItem(key);
			} catch {}
		}
		hasItem(key) {
			try {
				return localStorage.getItem(key) !== null;
			} catch {
				return false;
			}
		}
		clear() {
			try {
				localStorage.clear();
			} catch {}
		}
	};
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/checkPrivateRedeclaration.js
	function _checkPrivateRedeclaration(e, t) {
		if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object");
	}
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateMethodInitSpec.js
	function _classPrivateMethodInitSpec(e, a) {
		_checkPrivateRedeclaration(e, a), a.add(e);
	}
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldInitSpec.js
	function _classPrivateFieldInitSpec(e, t, a) {
		_checkPrivateRedeclaration(e, t), t.set(e, a);
	}
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/assertClassBrand.js
	function _assertClassBrand(e, t, n) {
		if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n;
		throw new TypeError("Private element is not present on this object");
	}
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldSet2.js
	function _classPrivateFieldSet2(s, a, r) {
		return s.set(_assertClassBrand(s, a), r), r;
	}
	//#endregion
	//#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldGet2.js
	function _classPrivateFieldGet2(s, a) {
		return s.get(_assertClassBrand(s, a));
	}
	//#endregion
	//#region src/domain/PresetManager.ts
	var LS_USER_PRESETS = "aw-user-presets";
	var LS_FAV_PRESET = "aw-favourite-preset";
	var LS_LAST_SETTINGS = "aw-last-settings";
	var _d = /* @__PURE__ */ new WeakMap();
	var _onRegen = /* @__PURE__ */ new WeakMap();
	var _onStage = /* @__PURE__ */ new WeakMap();
	var _statusTimer = /* @__PURE__ */ new WeakMap();
	var _cleanups = /* @__PURE__ */ new WeakMap();
	var _currentColorScheme = /* @__PURE__ */ new WeakMap();
	var _storage = /* @__PURE__ */ new WeakMap();
	var _onDspCommand = /* @__PURE__ */ new WeakMap();
	var _dspBeforeSnapshot = /* @__PURE__ */ new WeakMap();
	var _PresetManager_brand = /* @__PURE__ */ new WeakSet();
	var PresetManager = class {
		/**
		* @param {object} d  Subset of PlayerState DOM-refs (all preset-related elements).
		* @param {object} callbacks
		* @param {(opts?: object) => void} callbacks.onRegenerateSpectrogram
		* @param {() => void}              callbacks.onStage1Rebuild
		* @param {import('../infrastructure/storage/IStorage.ts').IStorage} [callbacks.storage]
		*   Storage adapter — defaults to LocalStorageAdapter. Pass an InMemoryStorageAdapter
		*   for tests or headless environments.
		* @param {((cmd: import('./undoStack.ts').UndoCommand) => void) | null} [callbacks.onDspCommand]
		*   Called after each DSP parameter change with an undo/redo command object.
		*   Pass `undoStack.record.bind(undoStack)` to wire DSP changes into the undo stack.
		*/
		constructor(d, { onRegenerateSpectrogram, onStage1Rebuild, storage, onDspCommand }) {
			_classPrivateMethodInitSpec(this, _PresetManager_brand);
			_classPrivateFieldInitSpec(this, _d, void 0);
			_classPrivateFieldInitSpec(this, _onRegen, void 0);
			_classPrivateFieldInitSpec(this, _onStage, void 0);
			_classPrivateFieldInitSpec(this, _statusTimer, 0);
			_classPrivateFieldInitSpec(this, _cleanups, []);
			_classPrivateFieldInitSpec(this, _currentColorScheme, "grayscale");
			_classPrivateFieldInitSpec(this, _storage, void 0);
			_classPrivateFieldInitSpec(this, _onDspCommand, null);
			_classPrivateFieldInitSpec(this, _dspBeforeSnapshot, null);
			_classPrivateFieldSet2(_d, this, d);
			_classPrivateFieldSet2(_onRegen, this, onRegenerateSpectrogram);
			_classPrivateFieldSet2(_onStage, this, onStage1Rebuild);
			_classPrivateFieldSet2(_storage, this, storage ?? new LocalStorageAdapter());
			_classPrivateFieldSet2(_onDspCommand, this, onDspCommand ?? null);
		}
		/** The current spectrogram colour scheme (kept in sync with the colorSchemeSelect). */
		get currentColorScheme() {
			return _classPrivateFieldGet2(_currentColorScheme, this);
		}
		/**
		* Bind all preset/DSP-control event listeners.
		* The caller is responsible for removing them on dispose — pass the same
		* `on(target, type, fn)` helper used in PlayerState._bindEvents so that
		* cleanup is registered in the central _cleanups array.
		*
		* @param {(target: EventTarget, type: string, fn: EventListener) => void} on
		*/
		bindEvents(on) {
			const d = _classPrivateFieldGet2(_d, this);
			on(d.presetSelect, "change", () => {
				const val = d.presetSelect?.value;
				if (val) this.applyPreset(val);
				this.updatePresetButtons();
				this.persistCurrentSettings();
			});
			on(d.presetSaveBtn, "click", () => _assertClassBrand(_PresetManager_brand, this, _promptSaveUserPreset).call(this));
			on(d.presetFavBtn, "click", () => _assertClassBrand(_PresetManager_brand, this, _toggleFavouritePreset).call(this));
			on(d.presetManageBtn, "click", () => _assertClassBrand(_PresetManager_brand, this, _openPresetManager).call(this));
			on(d.presetSaveConfirm, "click", () => _assertClassBrand(_PresetManager_brand, this, _confirmSaveUserPreset).call(this));
			on(d.presetSaveCancel, "click", () => _assertClassBrand(_PresetManager_brand, this, _cancelSaveUserPreset).call(this));
			on(d.presetSaveInput, "keydown", (e) => {
				const ev = e;
				if (ev.key === "Enter") _assertClassBrand(_PresetManager_brand, this, _confirmSaveUserPreset).call(this);
				if (ev.key === "Escape") _assertClassBrand(_PresetManager_brand, this, _cancelSaveUserPreset).call(this);
			});
			on(d.presetImportBtn, "click", () => _assertClassBrand(_PresetManager_brand, this, _importPresets).call(this));
			on(d.presetExportBtn, "click", () => _assertClassBrand(_PresetManager_brand, this, _exportPresets).call(this));
			on(d.qualitySlider, "input", () => {
				this.applyQualityLevel(parseInt(d.qualitySlider.value, 10));
			});
			on(d.scaleSelect, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
			on(d.scaleSelect, "change", () => {
				this.clearPresetHighlight();
				_classPrivateFieldGet2(_onRegen, this).call(this, { autoAdjust: true });
				_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "Scale change");
			});
			on(d.colourScaleSelect, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
			on(d.colourScaleSelect, "change", () => {
				this.clearPresetHighlight();
				this.updateColourScaleConstraints();
				_classPrivateFieldGet2(_onRegen, this).call(this, { autoAdjust: true });
				_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "Colour scale change");
			});
			on(d.colorSchemeSelect, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
			on(d.colorSchemeSelect, "change", () => {
				_classPrivateFieldSet2(_currentColorScheme, this, d.colorSchemeSelect.value);
				this.persistCurrentSettings();
				_classPrivateFieldGet2(_onStage, this).call(this);
				_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "Colour scheme change");
			});
			for (const { el, syncQuality, desc } of [
				{
					el: "nMelsInput",
					syncQuality: true,
					desc: "Mel bins change"
				},
				{
					el: "windowSizeSelect",
					syncQuality: true,
					desc: "Window size change"
				},
				{
					el: "overlapSelect",
					syncQuality: true,
					desc: "Overlap change"
				},
				{
					el: "oversamplingSelect",
					syncQuality: true,
					desc: "Oversampling change"
				},
				{
					el: "windowFunctionSelect",
					syncQuality: false,
					desc: "Window function change"
				},
				{
					el: "reassignedCheck",
					syncQuality: false,
					desc: "Reassignment change"
				},
				{
					el: "pcenGainInput",
					syncQuality: false,
					desc: "PCEN gain change"
				},
				{
					el: "pcenBiasInput",
					syncQuality: false,
					desc: "PCEN bias change"
				},
				{
					el: "pcenRootInput",
					syncQuality: false,
					desc: "PCEN root change"
				},
				{
					el: "pcenSmoothingInput",
					syncQuality: false,
					desc: "PCEN smoothing change"
				}
			]) {
				if (!d[el]) continue;
				on(d[el], "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
				on(d[el], "focus", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
				on(d[el], "change", () => {
					this.clearPresetHighlight();
					if (syncQuality) this.syncQualitySlider();
					_classPrivateFieldGet2(_onRegen, this).call(this);
					_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, desc);
				});
			}
			if (d.pcenEnabledCheck) {
				on(d.pcenEnabledCheck, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
				on(d.pcenEnabledCheck, "change", () => {
					this.updatePcenSectionDimming();
					this.clearPresetHighlight();
					_classPrivateFieldGet2(_onRegen, this).call(this);
					_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "PCEN enabled change");
				});
			}
			on(d.noiseReductionCheck, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
			on(d.noiseReductionCheck, "change", () => {
				this.persistCurrentSettings();
				_classPrivateFieldGet2(_onStage, this).call(this);
				_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "Noise reduction change");
			});
			on(d.claheCheck, "pointerdown", () => _assertClassBrand(_PresetManager_brand, this, _captureBeforeDsp).call(this));
			on(d.claheCheck, "change", () => {
				this.persistCurrentSettings();
				_classPrivateFieldGet2(_onStage, this).call(this);
				_assertClassBrand(_PresetManager_brand, this, _commitDspCommand).call(this, "CLAHE change");
			});
		}
		/** Populate preset dropdown from built-ins + user presets. */
		populatePresetDropdown() {
			const sel = _classPrivateFieldGet2(_d, this).presetSelect;
			if (!sel) return;
			sel.innerHTML = "";
			const empty = document.createElement("option");
			empty.value = "";
			empty.textContent = "— Custom —";
			sel.appendChild(empty);
			for (const name of Object.keys(DSP_PROFILES)) {
				const opt = document.createElement("option");
				opt.value = name;
				opt.textContent = name.charAt(0).toUpperCase() + name.slice(1);
				sel.appendChild(opt);
			}
			const userPresets = this.loadUserPresets();
			const userNames = Object.keys(userPresets);
			if (userNames.length) {
				const sep = document.createElement("option");
				sep.disabled = true;
				sep.textContent = "──────────";
				sel.appendChild(sep);
				const fav = this.getFavouritePreset();
				for (const name of userNames) {
					const opt = document.createElement("option");
					opt.value = `user:${name}`;
					opt.textContent = (fav === `user:${name}` ? "⭐ " : "") + name;
					sel.appendChild(opt);
				}
			}
			const fav = this.getFavouritePreset();
			if (fav && Array.from(sel.options).some((o) => o.value === fav)) sel.value = fav;
			else sel.value = "birder";
		}
		/** Enable/disable and style the favourite button based on the current selection. */
		updatePresetButtons() {
			const val = _classPrivateFieldGet2(_d, this).presetSelect?.value || "";
			const isAny = val !== "";
			const btn = _classPrivateFieldGet2(_d, this).presetFavBtn;
			if (btn) {
				btn.disabled = !isAny;
				const isFav = isAny && this.getFavouritePreset() === val;
				btn.classList.toggle("active", isFav);
				btn.title = isFav ? "Remove as default preset" : "Set as default preset";
			}
		}
		/**
		* Apply a named preset (built-in or user): writes DOM controls, then calls
		* onRegenerateSpectrogram so the caller decides whether audio is available.
		*/
		applyPreset(name) {
			const p = name.startsWith("user:") ? this.loadUserPresets()[name.slice(5)] : DSP_PROFILES[name];
			if (!p) return;
			_assertClassBrand(_PresetManager_brand, this, _applyControls).call(this, p);
			if (_classPrivateFieldGet2(_d, this).presetSelect) _classPrivateFieldGet2(_d, this).presetSelect.value = name;
			this.updatePresetButtons();
			this.syncQualitySlider();
			this.updatePcenSectionDimming();
			_classPrivateFieldGet2(_onRegen, this).call(this, { autoAdjust: true });
		}
		/**
		* Apply favourite preset or last-used settings to controls on init (no audio yet,
		* so does NOT call onRegenerateSpectrogram).
		*/
		applyFavouritePresetControls() {
			const fav = this.getFavouritePreset();
			let p;
			if (fav) p = fav.startsWith("user:") ? this.loadUserPresets()[fav.slice(5)] : DSP_PROFILES[fav];
			if (!p) p = this.loadLastSettings();
			if (!p) p = DSP_PROFILES[_classPrivateFieldGet2(_d, this).presetSelect?.value || "birder"];
			if (!p) return;
			_assertClassBrand(_PresetManager_brand, this, _applyControls).call(this, p);
			if (!fav && !DSP_PROFILES[_classPrivateFieldGet2(_d, this).presetSelect?.value || ""]) {
				if (_classPrivateFieldGet2(_d, this).presetSelect) _classPrivateFieldGet2(_d, this).presetSelect.value = "";
			}
			this.updatePresetButtons();
		}
		/** Clear preset dropdown selection (set to Custom), persist settings. */
		clearPresetHighlight() {
			if (_classPrivateFieldGet2(_d, this).presetSelect) _classPrivateFieldGet2(_d, this).presetSelect.value = "";
			this.updatePresetButtons();
			this.persistCurrentSettings();
		}
		applyQualityLevel(index) {
			const level = QUALITY_LEVELS[index];
			if (!level) return;
			const d = _classPrivateFieldGet2(_d, this);
			if (d.windowSizeSelect) d.windowSizeSelect.value = String(level.windowSize);
			if (d.overlapSelect) d.overlapSelect.value = String(level.overlapLevel);
			if (d.oversamplingSelect) d.oversamplingSelect.value = String(level.oversamplingLevel);
			if (d.nMelsInput) d.nMelsInput.value = String(level.nMels);
			if (d.qualityLevelDisplay) d.qualityLevelDisplay.textContent = level.label;
			this.clearPresetHighlight();
			_classPrivateFieldGet2(_onRegen, this).call(this);
		}
		syncQualitySlider() {
			const d = _classPrivateFieldGet2(_d, this);
			if (!d.qualitySlider) return;
			const ws = parseInt(d.windowSizeSelect?.value || "0", 10);
			const ol = parseInt(d.overlapSelect?.value || "-1", 10);
			const os = parseInt(d.oversamplingSelect?.value || "-1", 10);
			const nm = parseInt(d.nMelsInput?.value || "0", 10);
			const idx = QUALITY_LEVELS.findIndex((l) => l.windowSize === ws && l.overlapLevel === ol && l.oversamplingLevel === os && l.nMels === nm);
			if (idx >= 0) {
				d.qualitySlider.value = String(idx);
				if (d.qualityLevelDisplay) d.qualityLevelDisplay.textContent = QUALITY_LEVELS[idx].label;
			} else if (d.qualityLevelDisplay) d.qualityLevelDisplay.textContent = "Custom";
		}
		updatePcenSectionDimming() {
			const d = _classPrivateFieldGet2(_d, this);
			if (!d.pcenSection) return;
			const enabled = d.pcenEnabledCheck?.checked ?? true;
			d.pcenSection.style.opacity = enabled ? "" : "0.45";
			for (const el of [
				d.pcenGainInput,
				d.pcenBiasInput,
				d.pcenRootInput,
				d.pcenSmoothingInput
			]) if (el) el.disabled = !enabled;
		}
		updateColourScaleConstraints() {
			const d = _classPrivateFieldGet2(_d, this);
			const cs = d.colourScaleSelect?.value || "dbSquared";
			if (d.pcenEnabledCheck) {
				if (cs === "phase") {
					d.pcenEnabledCheck.checked = false;
					d.pcenEnabledCheck.disabled = true;
				} else d.pcenEnabledCheck.disabled = false;
				this.updatePcenSectionDimming();
			}
		}
		/** Snapshot current DSP controls into a preset-shape object. */
		getCurrentPresetSettings() {
			const d = _classPrivateFieldGet2(_d, this);
			const gainMode = d.gainModeSelect?.value || "auto";
			const preset = {
				scale: d.scaleSelect?.value || "mel",
				colourScale: d.colourScaleSelect?.value || "dbSquared",
				windowSize: parseInt(d.windowSizeSelect?.value || "1024", 10),
				overlapLevel: parseInt(d.overlapSelect?.value || "2", 10),
				oversamplingLevel: parseInt(d.oversamplingSelect?.value || "0", 10),
				windowFunction: d.windowFunctionSelect?.value || "hann",
				nMels: parseInt(d.nMelsInput?.value || "160", 10),
				usePcen: d.pcenEnabledCheck?.checked ?? true,
				pcenGain: parseFloat(d.pcenGainInput?.value || "0.8"),
				pcenBias: parseFloat(d.pcenBiasInput?.value || "0.01"),
				pcenRoot: parseFloat(d.pcenRootInput?.value || "4.0"),
				pcenSmoothing: parseFloat(d.pcenSmoothingInput?.value || "0.025"),
				colorScheme: d.colorSchemeSelect?.value || "grayscale",
				reassigned: d.reassignedCheck?.checked ?? false,
				noiseReduction: d.noiseReductionCheck?.checked ?? false,
				clahe: d.claheCheck?.checked ?? false,
				gainMode,
				maxFreqMode: d.maxFreqModeSelect?.value || "auto"
			};
			if (gainMode === "fixed") {
				preset.gainFloor = parseInt(d.floorSlider?.value || "0", 10);
				preset.gainCeil = parseInt(d.ceilSlider?.value || "100", 10);
			}
			if (preset.maxFreqMode === "fixed") preset.maxFreqHz = parseFloat(d.maxFreqSelect?.value || "10000");
			return preset;
		}
		persistCurrentSettings() {
			_classPrivateFieldGet2(_storage, this).setItem(LS_LAST_SETTINGS, JSON.stringify(this.getCurrentPresetSettings()));
		}
		loadLastSettings() {
			try {
				const raw = _classPrivateFieldGet2(_storage, this).getItem(LS_LAST_SETTINGS);
				if (raw) {
					const p = JSON.parse(raw);
					if (p && typeof p === "object" && !Array.isArray(p)) return p;
				}
			} catch {}
			return null;
		}
		loadUserPresets() {
			try {
				const raw = _classPrivateFieldGet2(_storage, this).getItem(LS_USER_PRESETS);
				if (raw) {
					const p = JSON.parse(raw);
					if (p && typeof p === "object" && !Array.isArray(p)) return p;
				}
			} catch {}
			return {};
		}
		saveUserPresetsToStorage(presets) {
			_classPrivateFieldGet2(_storage, this).setItem(LS_USER_PRESETS, JSON.stringify(presets));
		}
		getFavouritePreset() {
			return _classPrivateFieldGet2(_storage, this).getItem(LS_FAV_PRESET) || "";
		}
		setFavouritePreset(key) {
			_classPrivateFieldGet2(_storage, this).setItem(LS_FAV_PRESET, String(key));
		}
		dispose() {
			clearTimeout(_classPrivateFieldGet2(_statusTimer, this));
		}
	};
	/** Snapshot current DSP settings so we know what "before" was when the user changes something. */
	function _captureBeforeDsp() {
		if (!_classPrivateFieldGet2(_onDspCommand, this)) return;
		if (!_classPrivateFieldGet2(_dspBeforeSnapshot, this)) _classPrivateFieldSet2(_dspBeforeSnapshot, this, this.getCurrentPresetSettings());
	}
	/**
	* Build and record a DspParamChange command after a control change.
	* Compares before/after snapshots; records nothing if settings are identical.
	* @param {string} description
	*/
	function _commitDspCommand(description) {
		if (!_classPrivateFieldGet2(_onDspCommand, this) || !_classPrivateFieldGet2(_dspBeforeSnapshot, this)) return;
		const before = _classPrivateFieldGet2(_dspBeforeSnapshot, this);
		_classPrivateFieldSet2(_dspBeforeSnapshot, this, null);
		const after = this.getCurrentPresetSettings();
		if (JSON.stringify(before) === JSON.stringify(after)) return;
		_classPrivateFieldGet2(_onDspCommand, this)?.call(this, {
			type: "dsp-param",
			description,
			execute: () => {
				_assertClassBrand(_PresetManager_brand, this, _applyControls).call(this, after);
				this.syncQualitySlider();
				this.updatePcenSectionDimming();
				this.updateColourScaleConstraints();
				this.persistCurrentSettings();
				_classPrivateFieldGet2(_onRegen, this).call(this);
			},
			undo: () => {
				_assertClassBrand(_PresetManager_brand, this, _applyControls).call(this, before);
				this.syncQualitySlider();
				this.updatePcenSectionDimming();
				this.updateColourScaleConstraints();
				this.persistCurrentSettings();
				_classPrivateFieldGet2(_onRegen, this).call(this);
			}
		});
	}
	/**
	* Write all DSP control DOM elements from a preset object.
	* Shared by applyPreset() and applyFavouritePresetControls().
	*/
	function _applyControls(p) {
		const d = _classPrivateFieldGet2(_d, this);
		if (d.scaleSelect) d.scaleSelect.value = p.scale || "mel";
		if (d.windowSizeSelect && p.windowSize != null) d.windowSizeSelect.value = String(p.windowSize);
		if (d.overlapSelect && p.overlapLevel != null) d.overlapSelect.value = String(p.overlapLevel);
		if (d.oversamplingSelect && p.oversamplingLevel != null) d.oversamplingSelect.value = String(p.oversamplingLevel);
		if (d.windowFunctionSelect) d.windowFunctionSelect.value = p.windowFunction || "hann";
		if (d.nMelsInput) d.nMelsInput.value = String(p.nMels ?? 160);
		if (d.pcenEnabledCheck) d.pcenEnabledCheck.checked = !!p.usePcen;
		if (d.pcenGainInput) d.pcenGainInput.value = String(p.pcenGain ?? .8);
		if (d.pcenBiasInput) d.pcenBiasInput.value = String(p.pcenBias ?? .01);
		if (d.pcenRootInput) d.pcenRootInput.value = String(p.pcenRoot ?? 4);
		if (d.pcenSmoothingInput) d.pcenSmoothingInput.value = String(p.pcenSmoothing ?? .025);
		if (p.colorScheme && d.colorSchemeSelect) {
			d.colorSchemeSelect.value = p.colorScheme;
			_classPrivateFieldSet2(_currentColorScheme, this, p.colorScheme);
		}
		if (d.reassignedCheck) d.reassignedCheck.checked = !!p.reassigned;
		if (p.colourScale != null && d.colourScaleSelect) d.colourScaleSelect.value = p.colourScale;
		if (p.noiseReduction != null && d.noiseReductionCheck) d.noiseReductionCheck.checked = !!p.noiseReduction;
		if (p.clahe != null && d.claheCheck) d.claheCheck.checked = !!p.clahe;
		const gainMode = p.gainMode || "auto";
		if (d.gainModeSelect) d.gainModeSelect.value = gainMode;
		if (gainMode === "fixed" && p.gainFloor != null && p.gainCeil != null) {
			if (d.floorSlider) d.floorSlider.value = String(p.gainFloor);
			if (d.ceilSlider) d.ceilSlider.value = String(p.gainCeil);
		}
		const maxFreqMode = p.maxFreqMode || "auto";
		if (d.maxFreqModeSelect) d.maxFreqModeSelect.value = maxFreqMode;
		if (maxFreqMode === "fixed" && p.maxFreqHz != null && d.maxFreqSelect) d.maxFreqSelect.value = String(p.maxFreqHz);
	}
	function _showPresetStatus(msg, isError = false) {
		const el = _classPrivateFieldGet2(_d, this).presetStatus;
		if (!el) return;
		el.textContent = msg;
		el.classList.toggle("pm-status-error", isError);
		el.classList.remove("pm-status-visible");
		el.offsetWidth;
		el.classList.add("pm-status-visible");
		clearTimeout(_classPrivateFieldGet2(_statusTimer, this));
		_classPrivateFieldSet2(_statusTimer, this, setTimeout(() => el.classList.remove("pm-status-visible"), 2500));
	}
	function _openPresetManager() {
		const d = _classPrivateFieldGet2(_d, this);
		if (!d.presetManagerPanel) return;
		const isOpen = !d.presetManagerPanel.hidden;
		d.presetManagerPanel.hidden = isOpen;
		d.presetManageBtn?.classList.toggle("active", !isOpen);
		if (!isOpen) _assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
	}
	function _renderPresetManagerList() {
		const list = _classPrivateFieldGet2(_d, this).presetManagerList;
		if (!list) return;
		list.innerHTML = "";
		const fav = this.getFavouritePreset();
		const starSvg = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
		const starFilledSvg = `<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
		const trashSvg = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`;
		const pencilSvg = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 00-3.986-3.987L3.842 16.174a2 2 0 00-.5.83l-1.321 4.352a.5.5 0 00.62.62l4.352-1.321a2 2 0 00.83-.497z"/></svg>`;
		for (const name of Object.keys(DSP_PROFILES)) {
			const key = name;
			const isFav = fav === key;
			const row = document.createElement("div");
			row.className = "pm-item";
			row.innerHTML = `
                <button class="pm-fav-btn${isFav ? " active" : ""}" title="Set as default">${isFav ? starFilledSvg : starSvg}</button>
                <span class="pm-name" title="Click to apply">${name.charAt(0).toUpperCase() + name.slice(1)}</span>
                <span class="pm-badge">built-in</span>`;
			const favBtn = row.querySelector(".pm-fav-btn");
			if (favBtn) favBtn.addEventListener("click", () => {
				this.setFavouritePreset(isFav ? "" : key);
				this.populatePresetDropdown();
				this.updatePresetButtons();
				_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
			});
			const nameEl = row.querySelector(".pm-name");
			if (nameEl) nameEl.addEventListener("click", () => {
				this.applyPreset(key);
				this.persistCurrentSettings();
			});
			list.appendChild(row);
		}
		const userPresets = this.loadUserPresets();
		for (const name of Object.keys(userPresets)) {
			const key = `user:${name}`;
			const isFav = fav === key;
			const row = document.createElement("div");
			row.className = "pm-item";
			row.innerHTML = `
                <button class="pm-fav-btn${isFav ? " active" : ""}" title="Set as default">${isFav ? starFilledSvg : starSvg}</button>
                <span class="pm-name" title="Click to apply"></span>
                <button class="pm-icon-btn pm-rename-btn" title="Rename">${pencilSvg}</button>
                <button class="pm-icon-btn pm-delete-btn" title="Delete">${trashSvg}</button>`;
			const nameSpan = row.querySelector(".pm-name");
			if (nameSpan) nameSpan.textContent = name;
			const favBtnUser = row.querySelector(".pm-fav-btn");
			if (favBtnUser) favBtnUser.addEventListener("click", () => {
				this.setFavouritePreset(isFav ? "" : key);
				this.populatePresetDropdown();
				this.updatePresetButtons();
				_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
			});
			const nameBtnUser = row.querySelector(".pm-name");
			if (nameBtnUser) nameBtnUser.addEventListener("click", () => {
				this.applyPreset(key);
				this.persistCurrentSettings();
			});
			const renameBtn = row.querySelector(".pm-rename-btn");
			if (renameBtn) renameBtn.addEventListener("click", () => _assertClassBrand(_PresetManager_brand, this, _inlineRenamePreset).call(this, name, row));
			const delBtn = row.querySelector(".pm-delete-btn");
			if (delBtn) {
				const db = delBtn;
				db.addEventListener("click", () => {
					if (db.classList.contains("pm-confirm-delete")) _assertClassBrand(_PresetManager_brand, this, _deleteUserPreset).call(this, name);
					else {
						db.classList.add("pm-confirm-delete");
						db.title = "Click again to confirm";
						setTimeout(() => {
							db.classList.remove("pm-confirm-delete");
							db.title = "Delete";
						}, 2e3);
					}
				});
			}
			list.appendChild(row);
		}
		if (!Object.keys(DSP_PROFILES).length && !Object.keys(userPresets).length) {
			const empty = document.createElement("div");
			empty.className = "pm-empty";
			empty.textContent = "No presets yet.";
			list.appendChild(empty);
		}
	}
	function _promptSaveUserPreset() {
		const d = _classPrivateFieldGet2(_d, this);
		if (!d.presetSaveRow) return;
		const isOpen = !d.presetSaveRow.hidden;
		d.presetSaveRow.hidden = isOpen;
		if (!isOpen && d.presetSaveInput) {
			d.presetSaveInput.value = "";
			d.presetSaveInput.focus();
		}
	}
	function _confirmSaveUserPreset() {
		const d = _classPrivateFieldGet2(_d, this);
		const inp = d.presetSaveInput;
		if (!inp) return;
		const clean = (inp.value || "").trim();
		if (!clean) return;
		if (DSP_PROFILES[clean.toLowerCase()]) {
			_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, "Built-in name — choose another", true);
			return;
		}
		const presets = this.loadUserPresets();
		presets[clean] = this.getCurrentPresetSettings();
		this.saveUserPresetsToStorage(presets);
		this.populatePresetDropdown();
		if (d.presetSelect) d.presetSelect.value = `user:${clean}`;
		this.updatePresetButtons();
		d.presetSaveRow.hidden = true;
		_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
		_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, `Saved "${clean}"`);
	}
	function _cancelSaveUserPreset() {
		if (_classPrivateFieldGet2(_d, this).presetSaveRow) _classPrivateFieldGet2(_d, this).presetSaveRow.hidden = true;
	}
	function _toggleFavouritePreset() {
		const val = _classPrivateFieldGet2(_d, this).presetSelect?.value || "";
		if (!val) return;
		const current = this.getFavouritePreset();
		this.setFavouritePreset(current === val ? "" : val);
		this.populatePresetDropdown();
		if (_classPrivateFieldGet2(_d, this).presetSelect) _classPrivateFieldGet2(_d, this).presetSelect.value = val;
		this.updatePresetButtons();
		_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
	}
	function _inlineRenamePreset(oldName, row) {
		const nameSpan = row.querySelector(".pm-name");
		if (!nameSpan || row.querySelector(".pm-rename-input")) return;
		const input = document.createElement("input");
		input.type = "text";
		input.className = "pm-rename-input";
		input.value = oldName;
		input.maxLength = 40;
		nameSpan.replaceWith(input);
		input.focus();
		input.select();
		const commit = () => {
			const clean = (input.value || "").trim();
			if (!clean || clean === oldName) {
				_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
				return;
			}
			if (DSP_PROFILES[clean.toLowerCase()]) {
				_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, "Built-in name — choose another", true);
				_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
				return;
			}
			const presets = this.loadUserPresets();
			if (presets[clean]) {
				_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, `"${clean}" already exists`, true);
				_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
				return;
			}
			presets[clean] = presets[oldName];
			delete presets[oldName];
			this.saveUserPresetsToStorage(presets);
			const oldKey = `user:${oldName}`;
			const newKey = `user:${clean}`;
			if (this.getFavouritePreset() === oldKey) this.setFavouritePreset(newKey);
			this.populatePresetDropdown();
			this.updatePresetButtons();
			_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
		};
		input.addEventListener("keydown", (e) => {
			if (e.key === "Enter") commit();
			if (e.key === "Escape") _assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
		});
		input.addEventListener("blur", commit);
	}
	function _deleteUserPreset(name) {
		const presets = this.loadUserPresets();
		delete presets[name];
		this.saveUserPresetsToStorage(presets);
		const key = `user:${name}`;
		if (this.getFavouritePreset() === key) this.setFavouritePreset("");
		this.populatePresetDropdown();
		this.updatePresetButtons();
		_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
	}
	function _exportPresets() {
		const data = {
			version: 1,
			favourite: this.getFavouritePreset(),
			presets: this.loadUserPresets()
		};
		const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
		const url = URL.createObjectURL(blob);
		const a = document.createElement("a");
		a.href = url;
		a.download = "audio-workbench-presets.json";
		a.click();
		URL.revokeObjectURL(url);
		_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, "Exported");
	}
	function _importPresets() {
		const input = document.createElement("input");
		input.type = "file";
		input.accept = ".json,application/json";
		input.onchange = () => {
			const file = input.files?.[0];
			if (!file) return;
			const reader = new FileReader();
			reader.onload = () => {
				try {
					const data = JSON.parse(String(reader.result));
					if (!data || typeof data !== "object" || typeof data.presets !== "object" || Array.isArray(data.presets)) {
						_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, "Invalid preset file", true);
						return;
					}
					for (const [k, v] of Object.entries(data.presets)) if (!v || typeof v !== "object" || Array.isArray(v)) {
						_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, `Invalid entry: "${k}"`, true);
						return;
					}
					const existing = this.loadUserPresets();
					let imported = 0;
					for (const [k, v] of Object.entries(data.presets)) {
						if (DSP_PROFILES[k.toLowerCase()]) continue;
						existing[k] = v;
						imported++;
					}
					this.saveUserPresetsToStorage(existing);
					if (typeof data.favourite === "string" && data.favourite.startsWith("user:")) {
						if (existing[data.favourite.slice(5)]) this.setFavouritePreset(data.favourite);
					}
					this.populatePresetDropdown();
					this.updatePresetButtons();
					_assertClassBrand(_PresetManager_brand, this, _renderPresetManagerList).call(this);
					_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, `Imported ${imported} preset(s)`);
				} catch {
					_assertClassBrand(_PresetManager_brand, this, _showPresetStatus).call(this, "Failed to parse file", true);
				}
			};
			reader.readAsText(file);
		};
		input.click();
	}
	//#endregion
	//#region src/app/SpectrogramTileManager.ts
	var TILE_PRELOAD_AHEAD = 2;
	var SpectrogramTileManager = class extends EventTarget {
		constructor(params) {
			super();
			this.channelData = params.channelData;
			this.sampleRate = params.sampleRate;
			this.totalDuration = params.totalDuration;
			this.nMels = params.nMels;
			this.dspOptions = params.dspOptions;
			this.colorOptions = params.colorOptions;
			this.totalTiles = Math.ceil(this.totalDuration / 30);
			this.samplesPerTile = Math.floor(30 * this.sampleRate);
			this.tiles = /* @__PURE__ */ new Map();
			this.queue = [];
			this.isComputing = false;
			this.processor = createSpectrogramProcessor();
			this.globalMin = 0;
			this.globalMax = 1;
			this.tilesComputed = 0;
			this.disposed = false;
		}
		get hasTiles() {
			return this.tilesComputed > 0;
		}
		firstReadyTileData() {
			for (const tile of this.tiles.values()) if (tile.state === "ready" && tile.data) return tile.data;
			return null;
		}
		requestViewport(startTime, endTime) {
			if (this.disposed) return;
			const firstTile = Math.max(0, Math.floor(startTime / 30));
			const lastTile = Math.min(this.totalTiles - 1, Math.ceil(endTime / 30));
			const wanted = [];
			for (let i = firstTile; i <= lastTile; i++) wanted.push(i);
			for (let d = 1; d <= TILE_PRELOAD_AHEAD; d++) {
				if (firstTile - d >= 0) wanted.push(firstTile - d);
				if (lastTile + d < this.totalTiles) wanted.push(lastTile + d);
			}
			const inQueue = new Set(this.queue);
			for (const idx of wanted) {
				const tile = this.tiles.get(idx);
				if ((!tile || tile.state === "idle" || tile.state === "error") && !inQueue.has(idx)) {
					this.queue.unshift(idx);
					inQueue.add(idx);
				}
			}
			this._evict(Math.floor((firstTile + lastTile) / 2));
			if (!this.isComputing) this._computeNext();
		}
		updateColorOptions(opts) {
			this.colorOptions = opts;
			for (const tile of this.tiles.values()) if (tile.state === "ready" && tile.data) this._colorize(tile);
			this.dispatchEvent(new CustomEvent("tileready"));
		}
		renderToCanvas(ctx, params) {
			const { duration, totalDisplayWidth, canvasHeight, scrollLeft, viewportWidth, freqViewSrcCrop } = params;
			const tileDisplayWidth = 30 / duration * totalDisplayWidth;
			for (let i = 0; i < this.totalTiles; i++) {
				const tile = this.tiles.get(i);
				if (!tile || tile.state !== "ready" || !tile.canvas) continue;
				const tileDstX0 = i * tileDisplayWidth - scrollLeft;
				const tileDstX1 = Math.min((i + 1) * 30, duration) / duration * totalDisplayWidth - scrollLeft;
				if (tileDstX1 <= 0 || tileDstX0 >= viewportWidth) continue;
				tile.lastUsed = Date.now();
				const visLeft = Math.floor(Math.max(0, tileDstX0));
				const visRight = Math.ceil(Math.min(viewportWidth, tileDstX1));
				if (visRight <= visLeft) continue;
				const tileDispW = tileDstX1 - tileDstX0;
				const frac0 = (visLeft - tileDstX0) / tileDispW;
				const frac1 = (visRight - tileDstX0) / tileDispW;
				const srcX = frac0 * tile.canvasWidth;
				const srcW = Math.max(1, (frac1 - frac0) * tile.canvasWidth);
				const srcY = freqViewSrcCrop?.srcY ?? 0;
				const srcH = freqViewSrcCrop?.srcH ?? tile.canvas.height;
				const dstW = visRight - visLeft;
				ctx.imageSmoothingEnabled = tileDispW < tile.canvasWidth;
				ctx.drawImage(tile.canvas, srcX, srcY, srcW, srcH, visLeft, 0, dstW, canvasHeight);
			}
			drawTimeGrid({
				ctx,
				width: viewportWidth,
				height: canvasHeight,
				duration,
				pixelsPerSecond: totalDisplayWidth / duration,
				scrollLeft
			});
		}
		async computeFirstTile() {
			if (this.tilesComputed > 0 || this.disposed) return;
			await this._computeTile(0);
		}
		queueAllTiles() {
			if (this.disposed) return;
			const inQueue = new Set(this.queue);
			for (let i = 0; i < this.totalTiles; i++) {
				const tile = this.tiles.get(i);
				if ((!tile || tile.state === "idle" || tile.state === "error") && !inQueue.has(i)) {
					this.queue.push(i);
					inQueue.add(i);
				}
			}
			if (!this.isComputing) this._computeNext();
		}
		invalidate() {
			this.tiles.clear();
			this.queue = [];
			this.tilesComputed = 0;
			this.globalMin = 0;
			this.globalMax = 1;
		}
		dispose() {
			this.disposed = true;
			this.processor.dispose();
			this.tiles.clear();
			this.queue = [];
		}
		async _computeNext() {
			if (this.disposed || this.queue.length === 0) {
				this.isComputing = false;
				return;
			}
			let idx;
			while (this.queue.length > 0) {
				const candidate = this.queue.shift();
				const existing = this.tiles.get(candidate);
				if (!existing || existing.state === "idle" || existing.state === "error") {
					idx = candidate;
					break;
				}
			}
			if (idx === void 0) {
				this.isComputing = false;
				return;
			}
			this.isComputing = true;
			await this._computeTile(idx);
			this._computeNext();
		}
		async _computeTile(idx) {
			const tile = {
				idx,
				state: "computing",
				data: null,
				nFrames: 0,
				canvas: null,
				canvasWidth: 0,
				lastUsed: Date.now()
			};
			this.tiles.set(idx, tile);
			try {
				const startSample = idx * this.samplesPerTile;
				const overlapSamples = this.dspOptions.windowSize || 2048;
				const actualStart = idx === 0 ? startSample : Math.max(0, startSample - overlapSamples);
				const endSample = Math.min(this.channelData.length, (idx + 1) * this.samplesPerTile);
				const chunkData = this.channelData.subarray(actualStart, endSample);
				const result = await this.processor.compute(chunkData, this.dspOptions);
				if (this.disposed) return;
				let { data, nFrames } = result;
				if (actualStart < startSample) {
					const hop = result.hopSize || 1;
					const overlapFrames = Math.min(nFrames - 1, Math.ceil((startSample - actualStart) / hop));
					if (overlapFrames > 0) {
						data = data.slice(overlapFrames * result.nMels);
						nFrames -= overlapFrames;
					}
				}
				tile.data = data;
				tile.nFrames = nFrames;
				tile.state = "ready";
				const rangeChanged = this._updateGlobalStats(data);
				this.tilesComputed++;
				if (rangeChanged) {
					for (const t of this.tiles.values()) if (t !== tile && t.state === "ready" && t.data) this._colorize(t);
				}
				this._colorize(tile);
				this.dispatchEvent(new CustomEvent("tileready", { detail: { idx } }));
			} catch (err) {
				if (!this.disposed) {
					tile.state = "error";
					console.warn(`SpectrogramTileManager: tile ${idx} failed:`, err);
				}
			}
		}
		_colorize(tile) {
			if (!tile.data || tile.nFrames <= 0) return;
			const opts = this.colorOptions;
			const grayInfo = buildSpectrogramGrayscale({
				spectrogramData: tile.data,
				spectrogramFrames: tile.nFrames,
				spectrogramMels: this.nMels,
				sampleRateHz: this.sampleRate,
				maxFreq: opts.maxFreq,
				spectrogramAbsLogMin: this.globalMin,
				spectrogramAbsLogMax: this.globalMax,
				scale: opts.scale,
				colourScale: opts.colourScale,
				noiseReduction: opts.noiseReduction,
				clahe: opts.clahe
			});
			if (!grayInfo) return;
			const canvas = colorizeSpectrogram(grayInfo, opts.floor01, opts.ceil01, opts.colorScheme);
			if (!canvas) return;
			tile.canvas = canvas;
			tile.canvasWidth = grayInfo.width;
		}
		_updateGlobalStats(data) {
			const { logMin, logMax } = updateSpectrogramStats(data);
			const prevMin = this.globalMin;
			const prevMax = this.globalMax;
			if (this.tilesComputed === 0) {
				this.globalMin = logMin;
				this.globalMax = logMax;
			} else {
				if (logMin < this.globalMin) this.globalMin = logMin;
				if (logMax > this.globalMax) this.globalMax = logMax;
			}
			return Math.abs(this.globalMin - prevMin) > 1e-4 || Math.abs(this.globalMax - prevMax) > 1e-4;
		}
		_evict(centerIdx) {
			if (this.tiles.size <= 16) return;
			const candidates = [...this.tiles.values()].filter((t) => t.state === "ready").sort((a, b) => Math.abs(b.idx - centerIdx) - Math.abs(a.idx - centerIdx));
			while (this.tiles.size > 16 && candidates.length > 0) this.tiles.delete(candidates.pop().idx);
		}
	};
	//#endregion
	//#region src/infrastructure/SpectrogramCache.ts
	var DB_NAME = "aw-spectrogram-cache";
	var STORE_NAME = "spectrograms";
	var DB_VERSION = 1;
	var DEFAULT_MAX_ENTRIES = 20;
	var DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
	var DEFAULT_MIN_FREE_BYTES = 50 * 1024 * 1024;
	/**
	* @typedef {object} CacheEntry
	* @property {string}       [key]
	* @property {ArrayBuffer}  dataBuffer   - Float32Array data as ArrayBuffer
	* @property {number}       nFrames
	* @property {number}       nMels
	* @property {number}       hopSize
	* @property {number}       winLength
	* @property {string}       colourScale
	* @property {number}       [timestamp]    - Date.now() of last access
	* @property {number}       [byteSize]     - estimated size in bytes (set on write)
	*/
	var SpectrogramCache = class {
		/**
		* @param {object} [opts]
		* @param {number} [opts.maxEntries=20]       - max number of cached spectrograms
		* @param {number} [opts.maxBytes=268435456]  - max total byte size (default 256 MB)
		* @param {number} [opts.minFreeBytes=52428800] - skip write if origin has < this free
		*/
		constructor({ maxEntries = DEFAULT_MAX_ENTRIES, maxBytes = DEFAULT_MAX_BYTES, minFreeBytes = DEFAULT_MIN_FREE_BYTES } = {}) {
			this._db = null;
			this._opening = null;
			/** @type {IDBDatabase|null} */
			this._db = null;
			this._opening = null;
			this._maxEntries = maxEntries;
			this._maxBytes = maxBytes;
			this._minFreeBytes = minFreeBytes;
		}
		/**
		* Compute a cache key from audio channel data + DSP parameters.
		*
		* @param {Float32Array} channelData
		* @param {number}       sampleRate
		* @param {object}       dspParams   — plain serialisable DSP options
		* @returns {Promise<string>}  hex SHA-256 digest
		*/
		async computeKey(channelData, sampleRate, dspParams) {
			const FINGERPRINT_SAMPLES = 4096;
			const cd = channelData instanceof Float32Array ? channelData : Float32Array.from(channelData);
			const len = cd.length;
			const fpLen = Math.min(FINGERPRINT_SAMPLES, len);
			const fp = new Float32Array(fpLen * 2 + 3);
			fp.set(cd.subarray(0, fpLen), 0);
			fp.set(cd.subarray(Math.max(0, len - fpLen)), fpLen);
			fp[fpLen * 2] = sampleRate;
			fp[fpLen * 2 + 1] = len;
			fp[fpLen * 2 + 2] = len > 0 ? cd[Math.floor(len / 2)] : 0;
			const paramsBytes = new TextEncoder().encode(JSON.stringify(dspParams));
			const combined = new Uint8Array(fp.buffer.byteLength + paramsBytes.length);
			combined.set(new Uint8Array(fp.buffer), 0);
			combined.set(paramsBytes, fp.buffer.byteLength);
			if (crypto?.subtle?.digest) {
				const hashBuf = await crypto.subtle.digest("SHA-256", combined);
				return Array.from(new Uint8Array(hashBuf)).map((b) => b.toString(16).padStart(2, "0")).join("");
			}
			let h1 = 2166136261, h2 = 3299399273;
			for (let i = 0; i < combined.length; i++) {
				const b = combined[i];
				h1 = Math.imul(h1 ^ b, 16777619) >>> 0;
				h2 = Math.imul(h2 ^ b, 16777619) >>> 0;
			}
			return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
		}
		/**
		* Look up a cache entry. Returns null on miss.
		* Updates the LRU timestamp of a hit.
		*
		* @param {string} key
		* @returns {Promise<CacheEntry|null>}
		*/
		async get(key) {
			const db = await this._open();
			if (!db) return null;
			return new Promise((resolve) => {
				const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME);
				const req = store.get(key);
				req.onsuccess = () => {
					const entry = req.result;
					if (!entry) {
						resolve(null);
						return;
					}
					entry.timestamp = Date.now();
					store.put(entry);
					resolve(entry);
				};
				req.onerror = () => resolve(null);
			});
		}
		/**
		* Write a cache entry. Evicts LRU entries if limits are exceeded.
		* Skips the write silently if origin storage is critically low.
		*
		* @param {string}      key
		* @param {CacheEntry}  entry
		*/
		async set(key, entry) {
			if (this._minFreeBytes > 0 && await this._isStorageCritical()) return;
			const db = await this._open();
			if (!db) return;
			const byteSize = this._estimateEntryBytes(entry);
			return new Promise((resolve) => {
				const tx = db.transaction(STORE_NAME, "readwrite");
				const store = tx.objectStore(STORE_NAME);
				store.put({
					...entry,
					key,
					timestamp: Date.now(),
					byteSize
				});
				const allReq = store.getAll();
				allReq.onsuccess = () => {
					const all = allReq.result;
					const sorted = all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
					let toEvict = sorted.slice(0, Math.max(0, all.length - this._maxEntries));
					if (this._maxBytes > 0) {
						const remaining = sorted.filter((e) => !toEvict.includes(e));
						let totalBytes = remaining.reduce((s, e) => s + (e.byteSize ?? 0), 0);
						const byteEvict = [];
						for (const e of remaining) {
							if (totalBytes <= this._maxBytes) break;
							byteEvict.push(e);
							totalBytes -= e.byteSize ?? 0;
						}
						toEvict = [...new Set([...toEvict, ...byteEvict])];
					}
					for (const e of toEvict) if (e.key) store.delete(e.key);
					resolve(void 0);
				};
				allReq.onerror = () => resolve(void 0);
				tx.onerror = () => resolve(void 0);
			});
		}
		/**
		* Clear the entire cache.
		*/
		async clear() {
			const db = await this._open();
			if (!db) return;
			return new Promise((resolve) => {
				const req = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).clear();
				req.onsuccess = () => resolve(void 0);
				req.onerror = () => resolve(void 0);
			});
		}
		/**
		* Returns the approximate total cached bytes (sum of stored byteSize fields).
		* @returns {Promise<number>}
		*/
		async estimateBytesUsed() {
			const db = await this._open();
			if (!db) return 0;
			return new Promise((resolve) => {
				const req = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).getAll();
				req.onsuccess = () => {
					resolve(req.result.reduce((s, e) => s + (e.byteSize ?? 0), 0));
				};
				req.onerror = () => resolve(0);
			});
		}
		/**
		* Estimate the byte footprint of a spectrogram entry.
		* Float32Array body + ~128 bytes metadata overhead.
		* @param {CacheEntry} entry
		* @returns {number}
		*/
		_estimateEntryBytes(entry) {
			return ((entry.dataBuffer?.byteLength ?? 0) || (entry.nFrames ?? 0) * (entry.nMels ?? 0) * 4 || 0) + 128;
		}
		/**
		* Query StorageManager.estimate() and return true if available storage
		* is below the configured minimum free threshold.
		* Silently returns false when the API is unavailable.
		* @returns {Promise<boolean>}
		*/
		async _isStorageCritical() {
			try {
				if (!navigator?.storage?.estimate) return false;
				const info = await navigator.storage.estimate();
				return (info.quota ?? 0) - (info.usage ?? 0) < this._minFreeBytes;
			} catch {
				return false;
			}
		}
		_open() {
			if (this._db) return Promise.resolve(this._db);
			if (this._opening) return this._opening;
			this._opening = new Promise((resolve) => {
				if (typeof indexedDB === "undefined") {
					resolve(null);
					return;
				}
				const req = indexedDB.open(DB_NAME, DB_VERSION);
				req.onupgradeneeded = (e) => {
					const rt = e.target;
					if (!rt) return;
					const db = rt.result;
					if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: "key" });
				};
				req.onsuccess = (e) => {
					const rt = e.target;
					if (!rt) {
						this._opening = null;
						resolve(null);
						return;
					}
					this._db = rt.result;
					this._opening = null;
					resolve(this._db);
				};
				req.onerror = () => {
					this._opening = null;
					resolve(null);
				};
			});
			return this._opening;
		}
	};
	/** Singleton shared across the player instance. Uses production defaults. */
	var spectrogramCache = new SpectrogramCache();
	//#endregion
	//#region src/app/SpectrogramController.ts
	/**
	* SpectrogramController — owns spectrogram state and the compute/render pipeline.
	*
	* @param {Object} d  DOM element refs (same shape as PlayerState.d)
	* @param {Object} [opts]
	* @param {boolean} [opts.enableProgressive=false]
	*/
	var SpectrogramController = class extends EventTarget {
		constructor(d, opts = {}) {
			super();
			this._d = d;
			this._enableProgressive = opts.enableProgressive === true;
			this.processor = createSpectrogramProcessor();
			this.colorizer = new GpuColorizer();
			this._sfProcessor = createSpectralFeaturesProcessor();
			this._sfGeneration = 0;
			/** @type {Float32Array|null} */
			this._data = null;
			this._nFrames = 0;
			this._nMels = 0;
			this._hopSize = 0;
			this._winLength = 0;
			/** @type {string} */
			this._activeScale = "mel";
			/** @type {string} */
			this._colourScale = "dbSquared";
			this._absLogMin = 0;
			this._absLogMax = 1;
			/** @type {HTMLCanvasElement|OffscreenCanvas|null} */
			this._baseCanvas = null;
			/** @type {{gray: Uint8Array, width: number, height: number}|null} */
			this._grayInfo = null;
			this._gpuReady = false;
			this._externalMode = false;
			/** @type {{freqRange: [number,number]|null, freqScale: string|null}|null} */
			this._externalImageConfig = null;
			/** @type {AudioBuffer|null} */
			this._audioBuffer = null;
			this._sampleRateHz = 44100;
			this._tileManager = null;
			this._spectralFeatures = null;
			this._ridges = null;
			this._zoomRedrawRafId = 0;
			this._freqAxisRafId = void 0;
			this._lastFreqAxisH = 0;
		}
		get data() {
			return this._data;
		}
		get nFrames() {
			return this._nFrames;
		}
		get nMels() {
			return this._nMels;
		}
		get hopSize() {
			return this._hopSize;
		}
		get winLength() {
			return this._winLength;
		}
		get baseCanvas() {
			return this._baseCanvas;
		}
		get grayInfo() {
			return this._grayInfo;
		}
		get absLogMin() {
			return this._absLogMin;
		}
		get absLogMax() {
			return this._absLogMax;
		}
		get activeScale() {
			return this._activeScale;
		}
		get colourScale() {
			return this._colourScale;
		}
		get externalMode() {
			return this._externalMode;
		}
		get externalImageConfig() {
			return this._externalImageConfig;
		}
		/** True when there is valid spectrogram data to render. */
		get hasData() {
			return this._tileManager ? this._tileManager.hasTiles : this._data !== null && this._nFrames > 0;
		}
		get isTileMode() {
			return this._tileManager !== null;
		}
		/**
		* Called by PlayerState whenever a new audio file is loaded.
		* @param {AudioBuffer} audioBuffer
		* @param {number} sampleRateHz
		*/
		setAudio(audioBuffer, sampleRateHz) {
			this._tileManager?.dispose();
			this._tileManager = null;
			this._spectralFeatures = null;
			this._ridges = null;
			this._audioBuffer = audioBuffer;
			this._sampleRateHz = sampleRateHz;
			this._externalMode = false;
			this._externalImageConfig = null;
			this._data = null;
			this._nFrames = 0;
			this._nMels = 0;
			this._baseCanvas = null;
			this._grayInfo = null;
			this.updateMaxFreqOptions(sampleRateHz);
		}
		/** Reset external mode without clearing audio (used on zoom / reload). */
		clearExternalMode() {
			this._externalMode = false;
			this._externalImageConfig = null;
		}
		/**
		* Full compute pipeline: DSP → store data → optional auto-adjust → Stage 1+2+3.
		* @param {{ autoAdjust?: boolean }} [opts]
		*/
		async generate({ autoAdjust = false } = {}) {
			if (!this._audioBuffer) return;
			if (this._externalMode) return;
			const d = this._d;
			if (d.recomputingOverlay) d.recomputingOverlay.hidden = false;
			this._emit("transportstatechange", {
				state: "rendering",
				reason: "spectrogram-generate"
			});
			await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
			const scale = d.scaleSelect?.value || "mel";
			const colourScale = d.colourScaleSelect?.value || "dbSquared";
			const windowSize = parseInt(d.windowSizeSelect?.value || "1024", 10);
			const overlapLevel = parseInt(d.overlapSelect?.value || "2", 10);
			const oversamplingLevel = parseInt(d.oversamplingSelect?.value || "0", 10);
			const hopSize = windowHopFromOverlap(windowSize, overlapLevel);
			const fftSize = fftSizeFromOversampling(windowSize, oversamplingLevel);
			const windowFunction = d.windowFunctionSelect?.value || "hann";
			const nMels = Math.max(16, Math.min(512, parseInt(d.nMelsInput?.value || "160", 10) || 160));
			const useReassigned = d.reassignedCheck?.checked ?? false;
			const effectiveNMels = scale === "cqt" ? Math.ceil(Math.log2(this._audioBuffer.sampleRate / 2 / CQT_FMIN) * 24) : nMels;
			const options = {
				scale,
				colourScale,
				sampleRate: this._audioBuffer.sampleRate,
				fftSize,
				windowFunction,
				nMels: effectiveNMels,
				frameRate: 100,
				usePcen: d.pcenEnabledCheck?.checked ?? true,
				pcenGain: parseFloat(d.pcenGainInput?.value || "0.8"),
				pcenBias: parseFloat(d.pcenBiasInput?.value || "0.01"),
				pcenRoot: parseFloat(d.pcenRootInput?.value || "4.0"),
				pcenSmoothing: parseFloat(d.pcenSmoothingInput?.value || "0.025"),
				windowSize,
				hopSize
			};
			this._activeScale = scale;
			if (this._audioBuffer.duration >= Infinity) {
				await this._startTileMode(options, autoAdjust);
				return;
			}
			const t0 = performance.now();
			try {
				const channelData = this._audioBuffer.getChannelData(0);
				const cacheKey = await spectrogramCache.computeKey(channelData, this._audioBuffer.sampleRate, options);
				const cached = await spectrogramCache.get(cacheKey);
				if (cached) {
					this._data = new Float32Array(cached.dataBuffer);
					this._nFrames = cached.nFrames;
					this._nMels = cached.nMels;
					this._hopSize = cached.hopSize;
					this._winLength = cached.winLength;
					this._colourScale = cached.colourScale || colourScale;
					this._updateStats();
					this._grayInfo = null;
					this._baseCanvas = null;
					this._gpuReady = false;
					if (autoAdjust) {
						if ((d.gainModeSelect?.value || "auto") === "auto") this.autoContrast(false);
						const freqMode = d.maxFreqModeSelect?.value || "auto";
						if (freqMode === "auto") this.autoFrequency(false);
						else if (freqMode === "nyquist") this.setMaxFreqToNyquist();
					}
					this._computeSpectralFeaturesAsync(channelData, options);
					this._emit("needsredraw");
					if (d.recomputingOverlay) d.recomputingOverlay.hidden = true;
					this._emit("transportstatechange", {
						state: "ready",
						reason: "spectrogram-ready"
					});
					this._emit("computetime", { durationMs: 0 });
					this._emit("ready", {
						duration: this._audioBuffer.duration,
						sampleRate: this._audioBuffer.sampleRate,
						nFrames: this._nFrames,
						nMels: this._nMels,
						fromCache: true
					});
					return;
				}
				let result;
				if (useReassigned) result = computeReassignedSpectrogram({
					channelData,
					...options
				});
				else if (this._enableProgressive && this._audioBuffer.duration >= 60 && typeof this.processor.computeProgressive === "function") {
					const chunkResults = [];
					for await (const progress of this.processor.computeProgressive(channelData, {
						...options,
						chunkSeconds: 10
					})) {
						chunkResults.push(progress.result);
						this._emit("progress", {
							chunk: progress.chunk,
							totalChunks: progress.totalChunks,
							percent: progress.percent
						});
					}
					result = this._mergeProgressiveResults(chunkResults, options.nMels);
				} else result = await this.processor.compute(channelData, options);
				this._data = result.data;
				this._nFrames = result.nFrames;
				this._nMels = result.nMels;
				this._hopSize = result.hopSize || Math.max(1, Math.floor(this._sampleRateHz / 100));
				this._winLength = result.winLength || 4 * this._hopSize;
				this._colourScale = result.colourScale || colourScale;
				spectrogramCache.set(cacheKey, {
					dataBuffer: new Uint8Array(this._data.buffer).slice().buffer,
					nFrames: this._nFrames,
					nMels: this._nMels,
					hopSize: this._hopSize,
					winLength: this._winLength,
					colourScale: this._colourScale
				}).catch(() => {});
				this._updateStats();
				this._grayInfo = null;
				this._baseCanvas = null;
				this._gpuReady = false;
				this._computeSpectralFeaturesAsync(channelData, options);
				if (autoAdjust) {
					if ((d.gainModeSelect?.value || "auto") === "auto") this.autoContrast(false);
					const freqMode = d.maxFreqModeSelect?.value || "auto";
					if (freqMode === "auto") this.autoFrequency(false);
					else if (freqMode === "nyquist") this.setMaxFreqToNyquist();
				}
				this._emit("needsredraw");
				if (d.recomputingOverlay) d.recomputingOverlay.hidden = true;
				this._emit("transportstatechange", {
					state: "ready",
					reason: "spectrogram-ready"
				});
				this._emit("computetime", { durationMs: Math.round(performance.now() - t0) });
				this._emit("ready", {
					duration: this._audioBuffer.duration,
					sampleRate: this._audioBuffer.sampleRate,
					nFrames: this._nFrames,
					nMels: this._nMels
				});
			} catch (error) {
				if (d.recomputingOverlay) d.recomputingOverlay.hidden = true;
				this._emit("transportstatechange", {
					state: "error",
					reason: "spectrogram-error"
				});
				this._emit("error", {
					message: error?.message || String(error),
					source: "spectrogram"
				});
				throw error;
			}
		}
		async _startTileMode(dspOptions, autoAdjust) {
			const d = this._d;
			if (d.recomputingOverlay) d.recomputingOverlay.hidden = false;
			this._emit("transportstatechange", {
				state: "rendering",
				reason: "spectrogram-generate"
			});
			this._tileManager?.dispose();
			this._tileManager = null;
			this._data = null;
			this._nFrames = 0;
			this._grayInfo = null;
			this._baseCanvas = null;
			const tm = new SpectrogramTileManager({
				channelData: this._audioBuffer.getChannelData(0),
				sampleRate: this._audioBuffer.sampleRate,
				totalDuration: this._audioBuffer.duration,
				nMels: dspOptions.nMels,
				dspOptions,
				colorOptions: this._getCurrentColorOptions()
			});
			this._tileManager = tm;
			this._nMels = dspOptions.nMels;
			tm.addEventListener("tileready", () => {
				this._absLogMin = tm.globalMin;
				this._absLogMax = tm.globalMax;
				this._emit("needsredraw");
			});
			await tm.computeFirstTile();
			if (this._tileManager !== tm) return;
			tm.queueAllTiles();
			this._absLogMin = tm.globalMin;
			this._absLogMax = tm.globalMax;
			if (autoAdjust) {
				if ((d.gainModeSelect?.value || "auto") === "auto") this.autoContrast(false);
				const freqMode = d.maxFreqModeSelect?.value || "auto";
				if (freqMode === "auto") this.autoFrequency(false);
				else if (freqMode === "nyquist") this.setMaxFreqToNyquist();
			}
			if (d.recomputingOverlay) d.recomputingOverlay.hidden = true;
			this._emit("transportstatechange", {
				state: "ready",
				reason: "spectrogram-ready"
			});
			this._emit("computetime", { durationMs: 0 });
			this._emit("ready", {
				duration: this._audioBuffer.duration,
				sampleRate: this._audioBuffer.sampleRate,
				nFrames: 0,
				nMels: dspOptions.nMels
			});
			this._emit("needsredraw");
		}
		_getCurrentColorOptions() {
			const d = this._d;
			return {
				colorScheme: d.colorSchemeSelect?.value || "grayscale",
				maxFreq: parseFloat(d.maxFreqSelect?.value || "10000"),
				floor01: parseFloat(d.floorSlider?.value || "0") / 100,
				ceil01: parseFloat(d.ceilSlider?.value || "100") / 100,
				noiseReduction: d.noiseReductionCheck?.checked ?? false,
				clahe: d.claheCheck?.checked ?? false,
				scale: d.scaleSelect?.value || "mel",
				colourScale: d.colourScaleSelect?.value || "dbSquared"
			};
		}
		_computeSpectralFeaturesAsync(channelData, dspOptions) {
			this._spectralFeatures = null;
			this._ridges = null;
			const hopSize = dspOptions.hopSize || this._hopSize || 320;
			const windowSize = dspOptions.windowSize || this._winLength || 1024;
			const nFrames = this._nFrames;
			if (!nFrames || !channelData?.length) return;
			const gen = ++this._sfGeneration;
			this._sfProcessor.computeFeatures(channelData, this._sampleRateHz, hopSize, windowSize, nFrames).then((features) => {
				if (this._sfGeneration !== gen) return;
				this._spectralFeatures = features;
				this._emit("needsredraw");
				return this._sfProcessor.computeRidges(channelData, this._sampleRateHz, hopSize, windowSize, nFrames);
			}).then((ridges) => {
				if (!ridges || this._sfGeneration !== gen) return;
				this._ridges = ridges;
				this._emit("needsredraw");
			}).catch(() => {});
		}
		_drawSpectralOverlay(ctx, p, totalWidth, scrollLeft, viewportWidth, canvasHeight) {
			const d = this._d;
			const sf = this._spectralFeatures;
			const showCentroid = d.showCentroidCheck?.checked && sf?.centroid;
			const showF0 = d.showF0Check?.checked && sf?.f0;
			const showRidges = d.showRidgesCheck?.checked && this._ridges?.length;
			if (!showCentroid && !showF0 && !showRidges) return;
			const nFrames = this._nFrames;
			if (!nFrames) return;
			const frameToX = (i) => i / nFrames * totalWidth - scrollLeft;
			const freqToY = (hz) => {
				if (hz <= 0) return -1;
				const frac = p.coords.frequencyToBaseYFraction(hz);
				if (p.freqViewMin != null && p.freqViewMax != null) {
					const topFrac = p.coords.frequencyToBaseYFraction(p.freqViewMax);
					const span = p.coords.frequencyToBaseYFraction(p.freqViewMin) - topFrac;
					if (span < 1e-6) return -1;
					return (frac - topFrac) / span * canvasHeight;
				}
				return frac * canvasHeight;
			};
			const COLOR_CENTROID = "#f59e0b";
			const COLOR_F0 = "#22d3ee";
			const COLOR_GAP = "rgba(167,139,250,0.25)";
			ctx.save();
			ctx.lineWidth = 1.5;
			ctx.strokeStyle = COLOR_CENTROID;
			const cx = new Float32Array(nFrames);
			const cy = new Float32Array(nFrames);
			const fx = new Float32Array(nFrames);
			const fy = new Float32Array(nFrames);
			for (let i = 0; i < nFrames; i++) {
				const x = frameToX(i);
				cx[i] = x;
				cy[i] = showCentroid ? freqToY(sf.centroid[i]) : -1;
				fx[i] = x;
				fy[i] = showF0 ? freqToY(sf.f0[i]) : -1;
			}
			if (showCentroid && showF0) {
				ctx.fillStyle = COLOR_GAP;
				ctx.beginPath();
				let started = false;
				for (let i = 0; i < nFrames; i++) {
					if (cx[i] < 0 || cx[i] > viewportWidth) continue;
					if (cy[i] < 0 || fy[i] < 0) {
						started = false;
						continue;
					}
					if (!started) {
						ctx.moveTo(cx[i], cy[i]);
						started = true;
					} else ctx.lineTo(cx[i], cy[i]);
				}
				for (let i = nFrames - 1; i >= 0; i--) {
					if (fx[i] < 0 || fx[i] > viewportWidth) continue;
					if (fy[i] < 0 || cy[i] < 0) continue;
					ctx.lineTo(fx[i], fy[i]);
				}
				ctx.closePath();
				ctx.fill();
			}
			if (showCentroid) {
				ctx.strokeStyle = COLOR_CENTROID;
				ctx.beginPath();
				let started = false;
				for (let i = 0; i < nFrames; i++) {
					if (cx[i] < -2 || cx[i] > viewportWidth + 2) {
						started = false;
						continue;
					}
					if (cy[i] < 0) {
						started = false;
						continue;
					}
					if (!started) {
						ctx.moveTo(cx[i], cy[i]);
						started = true;
					} else ctx.lineTo(cx[i], cy[i]);
				}
				ctx.stroke();
			}
			if (showF0) {
				ctx.strokeStyle = COLOR_F0;
				ctx.beginPath();
				let started = false;
				for (let i = 0; i < nFrames; i++) {
					if (fx[i] < -2 || fx[i] > viewportWidth + 2) {
						started = false;
						continue;
					}
					if (fy[i] < 0) {
						started = false;
						continue;
					}
					if (!started) {
						ctx.moveTo(fx[i], fy[i]);
						started = true;
					} else ctx.lineTo(fx[i], fy[i]);
				}
				ctx.stroke();
			}
			if (showRidges && this._ridges) {
				const sorted = [...this._ridges].sort((a, b) => {
					return a.strength.reduce((s, v) => s + v, 0) / a.strength.length - b.strength.reduce((s, v) => s + v, 0) / b.strength.length;
				});
				for (const ridge of sorted) {
					const len = ridge.frames.length;
					let meanStr = 0;
					for (let i = 0; i < len; i++) meanStr += ridge.strength[i];
					meanStr /= len;
					const alpha = .25 + meanStr * .65;
					const lw = .8 + meanStr * 1.2;
					const nyquist = this._sampleRateHz / 2;
					ctx.strokeStyle = `hsla(${(30 + ridge.freqHz[Math.floor(len / 2)] / nyquist * 170).toFixed(0)},100%,75%,${alpha.toFixed(2)})`;
					ctx.lineWidth = lw;
					ctx.beginPath();
					let started = false;
					for (let i = 0; i < len; i++) {
						const x = frameToX(ridge.frames[i]);
						if (x < -2 || x > viewportWidth + 2) {
							started = false;
							continue;
						}
						const y = freqToY(ridge.freqHz[i]);
						if (y < 0) {
							started = false;
							continue;
						}
						if (!started) {
							ctx.moveTo(x, y);
							started = true;
						} else ctx.lineTo(x, y);
					}
					ctx.stroke();
				}
				ctx.lineWidth = 1.5;
			}
			ctx.restore();
		}
		/**
		* Mode 1: inject raw Float32Array data — enters pipeline at Stage 1 (grayscale → colorize → render).
		*/
		setExternalData(data, nFrames, nMels, options = {}) {
			let floats;
			if (typeof data === "string") {
				const binary = atob(data);
				const bytes = new Uint8Array(binary.length);
				for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
				floats = new Float32Array(bytes.buffer);
			} else if (data instanceof ArrayBuffer) floats = new Float32Array(data);
			else if (data instanceof Float32Array) floats = data;
			else throw new Error("setSpectrogramData: data must be Float32Array, ArrayBuffer, or base64 string");
			if (floats.length !== nFrames * nMels) throw new Error(`setSpectrogramData: data.length (${floats.length}) !== nFrames*nMels (${nFrames}*${nMels}=${nFrames * nMels})`);
			this._externalMode = true;
			this._data = floats;
			this._nFrames = nFrames;
			this._nMels = nMels;
			if (options.sampleRate) {
				this._sampleRateHz = options.sampleRate;
				this.updateMaxFreqOptions(options.sampleRate);
			}
			if (options.scale && this._d.scaleSelect) this._d.scaleSelect.value = options.scale;
			this._updateStats();
			if ((this._d.gainModeSelect?.value || "auto") === "auto") this.autoContrast(false);
			const freqMode = this._d.maxFreqModeSelect?.value || "auto";
			if (freqMode === "auto") this.autoFrequency(false);
			else if (freqMode === "nyquist") this.setMaxFreqToNyquist();
			this._emit("needsredraw");
			this._emit("transportstatechange", {
				state: "ready",
				reason: "spectrogram-external-data"
			});
			this._emit("ready", {
				duration: this._audioBuffer?.duration || 0,
				sampleRate: this._sampleRateHz,
				nFrames: this._nFrames,
				nMels: this._nMels,
				external: true
			});
		}
		/**
		* Mode 2: inject a pre-rendered image — bypasses DSP + colorization.
		* @returns {Promise<void>}
		*/
		async setExternalImage(image, options = {}) {
			return new Promise((resolve, reject) => {
				const apply = (img) => {
					const canvas = document.createElement("canvas");
					canvas.width = img.naturalWidth || img.width;
					canvas.height = img.naturalHeight || img.height;
					const ctx = canvas.getContext("2d");
					if (!ctx) {
						reject(/* @__PURE__ */ new Error("Could not get 2d context"));
						return;
					}
					ctx.drawImage(img, 0, 0);
					this._externalMode = true;
					this._baseCanvas = canvas;
					this._data = new Float32Array(0);
					this._nFrames = canvas.width;
					this._nMels = canvas.height;
					this._grayInfo = null;
					this._externalImageConfig = options.freqRange || options.freqScale ? {
						freqRange: options.freqRange || null,
						freqScale: options.freqScale || null
					} : null;
					if (options.sampleRate) {
						this._sampleRateHz = options.sampleRate;
						this.updateMaxFreqOptions(options.sampleRate);
					}
					this.setDspControlsEnabled(false);
					this._emit("needsredraw");
					this._emit("transportstatechange", {
						state: "ready",
						reason: "spectrogram-external-image"
					});
					this._emit("ready", {
						duration: this._audioBuffer?.duration || 0,
						sampleRate: this._sampleRateHz,
						nFrames: this._nFrames,
						nMels: this._nMels,
						external: true,
						externalImage: true,
						freqRange: options.freqRange || null,
						freqScale: options.freqScale || null
					});
					resolve();
				};
				if (image instanceof HTMLCanvasElement || image instanceof HTMLImageElement && image.complete) apply(image);
				else if (image instanceof HTMLImageElement) {
					image.onload = () => apply(image);
					image.onerror = reject;
				} else if (typeof image === "string") {
					const img = new Image();
					img.onload = () => apply(img);
					img.onerror = reject;
					img.src = image;
				} else reject(/* @__PURE__ */ new Error("setSpectrogramImage: unsupported image type"));
			});
		}
		/**
		* Stage 1 — expensive: spectrogram data → float32 grayscale.
		* Run once per audio/FFT/frequency change.
		*/
		buildGrayscale() {
			if (this._tileManager) {
				this._tileManager.updateColorOptions(this._getCurrentColorOptions());
				return;
			}
			if (!this._data) return;
			const d = this._d;
			this._grayInfo = buildSpectrogramGrayscale({
				spectrogramData: this._data,
				spectrogramFrames: this._nFrames,
				spectrogramMels: this._nMels,
				sampleRateHz: this._sampleRateHz,
				maxFreq: parseFloat(d.maxFreqSelect?.value || "10000"),
				spectrogramAbsLogMin: this._absLogMin,
				spectrogramAbsLogMax: this._absLogMax,
				scale: this._activeScale || d.scaleSelect?.value || "mel",
				colourScale: this._colourScale || d.colourScaleSelect?.value || "dbSquared",
				noiseReduction: d.noiseReductionCheck?.checked ?? false,
				clahe: d.claheCheck?.checked ?? false
			});
			if (this._grayInfo && this.colorizer.ok) {
				const { gray, width, height } = this._grayInfo;
				this._gpuReady = this.colorizer.uploadGrayscale(gray, width, height);
			} else this._gpuReady = false;
		}
		/**
		* Stage 2 — fast: grayscale → colored canvas.
		* GPU path: ~0.1 ms. JS fallback: ~20–80 ms.
		* @param {string} colorScheme
		* @returns {HTMLCanvasElement|OffscreenCanvas|null}
		*/
		buildBaseImage(colorScheme) {
			if (this._tileManager) {
				this._tileManager.updateColorOptions(this._getCurrentColorOptions());
				return null;
			}
			if (!this._grayInfo) this.buildGrayscale();
			const d = this._d;
			const floor01 = parseFloat(d.floorSlider?.value || "0") / 100;
			const ceil01 = parseFloat(d.ceilSlider?.value || "100") / 100;
			if (this._gpuReady && this._grayInfo) {
				this.colorizer.uploadColorLut(colorScheme);
				this.colorizer.render(floor01, ceil01);
				this._baseCanvas = this.colorizer.canvas;
			} else this._baseCanvas = colorizeSpectrogram(this._grayInfo, floor01, ceil01, colorScheme);
			return this._baseCanvas;
		}
		/**
		* Stage 3 — render the spectrogram to the visible canvas.
		*
		* @param {Object} p
		* @param {boolean}  p.show              - Whether the spectrogram pane is visible
		* @param {number}   p.pixelsPerSecond
		* @param {number|null} p.freqViewMin
		* @param {number|null} p.freqViewMax
		* @param {import('../domain/coordinateSystem.ts').CoordinateSystem} p.coords
		* @param {number}   p.effectiveHeight
		* @param {number}   p.currentTime       - for the UI-update after draw
		* @param {number}   p.scrollLeft
		* @param {number}   p.viewportWidth
		* @param {string}   p.colorScheme       - needed if baseCanvas is missing
		*/
		draw(p) {
			if (!p.show) return;
			if (!this._audioBuffer) return;
			if (this._tileManager) {
				const tm = this._tileManager;
				const duration = this._audioBuffer.duration;
				const totalWidth = Math.max(1, Math.floor(duration * p.pixelsPerSecond));
				const canvasHeight = Math.max(140, Math.floor(p.effectiveHeight));
				const vw = p.viewportWidth ?? 0;
				const width = vw > 0 ? Math.min(vw, totalWidth) : totalWidth;
				const sl = vw > 0 ? p.scrollLeft ?? 0 : 0;
				if (this._d.canvasSizer) this._d.canvasSizer.style.width = `${totalWidth}px`;
				this._d.spectrogramCanvas.width = width;
				this._d.spectrogramCanvas.height = canvasHeight;
				const ctx = this._d.spectrogramCanvas.getContext("2d");
				if (ctx) {
					ctx.clearRect(0, 0, width, canvasHeight);
					let freqViewSrcCrop = null;
					if (p.freqViewMin != null && p.freqViewMax != null) {
						const baseH = tm.nMels;
						const srcYTop = p.coords.frequencyToBaseYFraction(p.freqViewMax) * baseH;
						const srcYBot = p.coords.frequencyToBaseYFraction(p.freqViewMin) * baseH;
						freqViewSrcCrop = {
							srcY: srcYTop,
							srcH: Math.max(1, srcYBot - srcYTop)
						};
					}
					tm.renderToCanvas(ctx, {
						duration,
						totalDisplayWidth: totalWidth,
						canvasHeight,
						scrollLeft: sl,
						viewportWidth: width,
						freqViewSrcCrop
					});
				}
				const startTime = sl / p.pixelsPerSecond;
				const endTime = (sl + width) / p.pixelsPerSecond;
				tm.requestViewport(startTime, endTime);
				this._syncFreqAxisHeight();
				return;
			}
			if (!this._data || this._nFrames <= 0) return;
			if (!this._baseCanvas) this.buildBaseImage(p.colorScheme);
			if (!this._baseCanvas) return;
			let freqViewSrcCrop = null;
			if (p.freqViewMin != null && p.freqViewMax != null) {
				const baseH = this._baseCanvas.height;
				const srcYTop = p.coords.frequencyToBaseYFraction(p.freqViewMax) * baseH;
				const srcYBottom = p.coords.frequencyToBaseYFraction(p.freqViewMin) * baseH;
				freqViewSrcCrop = {
					srcY: srcYTop,
					srcH: Math.max(1, srcYBottom - srcYTop)
				};
			}
			const totalWidth = Math.max(1, Math.floor(this._audioBuffer.duration * p.pixelsPerSecond));
			renderSpectrogram({
				duration: this._audioBuffer.duration,
				spectrogramCanvas: this._d.spectrogramCanvas,
				pixelsPerSecond: p.pixelsPerSecond,
				canvasHeight: p.effectiveHeight,
				baseCanvas: this._baseCanvas,
				freqViewSrcCrop,
				canvasSizer: this._d.canvasSizer,
				scrollLeft: p.scrollLeft,
				viewportWidth: p.viewportWidth,
				totalWidth
			});
			if (this._spectralFeatures || this._ridges) {
				const ctx = this._d.spectrogramCanvas.getContext("2d");
				if (ctx) {
					const canvasHeight = Math.max(140, Math.floor(p.effectiveHeight));
					const vw = p.viewportWidth ?? 0;
					const sl = vw > 0 ? p.scrollLeft ?? 0 : 0;
					const width = vw > 0 ? Math.min(vw, totalWidth) : totalWidth;
					this._drawSpectralOverlay(ctx, p, totalWidth, sl, width, canvasHeight);
				}
			}
			this._syncFreqAxisHeight();
		}
		/**
		* Request a debounced redraw on the next animation frame.
		* Fires 'needsredraw' so the host can call draw() with current params.
		*/
		requestRedraw() {
			if (this._zoomRedrawRafId) return;
			this._zoomRedrawRafId = requestAnimationFrame(() => {
				this._zoomRedrawRafId = 0;
				this._emit("needsredraw");
			});
		}
		/**
		* Compute optimal floor/ceil percentiles and write them to the sliders.
		* Pass redraw=true when called from a button click to also re-render.
		* @param {boolean} [redraw=false]
		*/
		autoContrast(redraw = false) {
			const data = this._tileManager ? this._tileManager.firstReadyTileData() : this._data;
			if (!data) return;
			const absMin = this._tileManager ? this._tileManager.globalMin : this._absLogMin;
			const absMax = this._tileManager ? this._tileManager.globalMax : this._absLogMax;
			const stats = autoContrastStats(data, 2, 98);
			const range = absMax - absMin;
			if (range < 1e-8) return;
			const floorPct = this._clamp((stats.logMin - absMin) / range * 100, 0, 100);
			const ceilPct = this._clamp((stats.logMax - absMin) / range * 100, 0, 100);
			if (this._d.floorSlider) this._d.floorSlider.value = Math.round(floorPct);
			if (this._d.ceilSlider) this._d.ceilSlider.value = Math.round(ceilPct);
			if (redraw) this._emit("needsredraw");
		}
		/**
		* Detect best max-frequency from signal content and update the select.
		* @param {boolean} [redraw=false]
		*/
		autoFrequency(redraw = false) {
			const data = this._tileManager ? this._tileManager.firstReadyTileData() : this._data;
			const nMels = this._tileManager ? this._tileManager.nMels : this._nMels;
			const nFrames = this._tileManager ? Math.floor((data?.length ?? 0) / nMels) : this._nFrames;
			if (!data) return;
			const hzValue = detectMaxFrequency(data, nFrames, nMels, this._sampleRateHz, this._d.scaleSelect?.value || "mel");
			const opts = Array.from(this._d.maxFreqSelect?.options || []);
			let best = opts[opts.length - 1];
			for (const opt of opts) if (parseFloat(opt.value) >= hzValue) {
				best = opt;
				break;
			}
			if (this._d.maxFreqSelect && best) this._d.maxFreqSelect.value = best.value;
			this._emit("scalechange", { maxFreq: parseFloat(best?.value || "10000") });
			if (redraw) this._emit("needsredraw");
		}
		/** Set maxFreq select to the Nyquist frequency. */
		setMaxFreqToNyquist() {
			const nyquist = this._sampleRateHz / 2;
			const opts = Array.from(this._d.maxFreqSelect?.options || []);
			const last = opts[opts.length - 1];
			if (last && this._d.maxFreqSelect) this._d.maxFreqSelect.value = last.value;
			this._emit("scalechange", { maxFreq: nyquist });
		}
		/**
		* Rebuild the max-frequency <select> options for the current sample rate.
		* @param {number} sampleRateHz
		*/
		updateMaxFreqOptions(sampleRateHz) {
			const nyquist = sampleRateHz / 2;
			const select = this._d.maxFreqSelect;
			if (!select) return;
			const candidates = [
				1e3,
				2e3,
				4e3,
				6e3,
				8e3,
				1e4,
				12e3,
				16e3,
				2e4,
				24e3,
				32e3,
				44100,
				48e3
			];
			const prev = parseFloat(select.value) || 1e4;
			const kept = candidates.filter((f) => f <= nyquist);
			if (!kept.length || kept[kept.length - 1] < nyquist) kept.push(nyquist);
			select.innerHTML = "";
			for (const hz of kept) {
				const opt = document.createElement("option");
				opt.value = String(hz);
				if (hz === nyquist) opt.textContent = `${hz >= 1e3 ? `${(hz / 1e3).toFixed(hz % 1e3 ? 1 : 0)} kHz` : `${hz} Hz`} (Nyquist)`;
				else opt.textContent = hz >= 1e3 ? `${(hz / 1e3).toFixed(hz % 1e3 ? 1 : 0)} kHz` : `${hz} Hz`;
				select.appendChild(opt);
			}
			if (kept.includes(prev)) select.value = String(prev);
			else {
				let best = kept[kept.length - 1];
				for (const v of kept) if (v >= prev) {
					best = v;
					break;
				}
				select.value = String(best);
			}
		}
		/**
		* Enable or disable DSP/display controls that have no effect on
		* pre-rendered spectrogram images.
		* @param {boolean} enabled
		*/
		setDspControlsEnabled(enabled) {
			const d = this._d;
			const settingsEls = [
				d.scaleSelect,
				d.windowSizeSelect,
				d.windowFunctionSelect,
				d.overlapSelect,
				d.oversamplingSelect,
				d.nMelsInput,
				d.floorSlider,
				d.ceilSlider,
				d.colorSchemeSelect,
				d.maxFreqSelect
			];
			for (const el of settingsEls) {
				if (!el) continue;
				el.disabled = !enabled;
				const row = el.closest?.(".settings-row");
				if (row) row.style.opacity = enabled ? "" : "0.35";
			}
			for (const btn of [d.autoContrastBtn, d.autoFreqBtn]) if (btn) btn.disabled = !enabled;
			const pcen = d.pcenSection || d.container?.querySelector("[data-aw=\"pcenSection\"]");
			if (pcen) pcen.style.display = enabled ? "" : "none";
			if (d.presetSelect) d.presetSelect.disabled = !enabled;
		}
		destroy() {
			if (this._zoomRedrawRafId) {
				cancelAnimationFrame(this._zoomRedrawRafId);
				this._zoomRedrawRafId = 0;
			}
			if (typeof this._freqAxisRafId === "number") {
				cancelAnimationFrame(this._freqAxisRafId);
				this._freqAxisRafId = void 0;
			}
			this.processor.dispose();
			this._sfProcessor.dispose();
			this.colorizer.dispose();
		}
		_updateStats() {
			if (!this._data) return;
			const stats = updateSpectrogramStats(this._data);
			this._absLogMin = stats.logMin;
			this._absLogMax = stats.logMax;
		}
		_mergeProgressiveResults(chunkResults, nMels) {
			if (!chunkResults.length) return {
				data: new Float32Array(0),
				nFrames: 0,
				nMels,
				hopSize: 0,
				winLength: 0
			};
			const actualNMels = chunkResults[0].nMels || nMels;
			let totalSize = 0;
			for (const chunk of chunkResults) totalSize += chunk.data.length;
			const totalFrames = Math.floor(totalSize / actualNMels);
			const data = new Float32Array(totalFrames * actualNMels);
			let offset = 0;
			for (const chunk of chunkResults) {
				const toCopy = Math.min(chunk.data.length, data.length - offset);
				if (toCopy > 0) data.set(chunk.data.subarray(0, toCopy), offset);
				offset += toCopy;
			}
			const first = chunkResults[0] || {};
			return {
				data,
				nFrames: totalFrames,
				nMels: actualNMels,
				hopSize: first.hopSize,
				winLength: first.winLength
			};
		}
		/** Keep the freq-axis height element in sync with the rendered canvas height. */
		_syncFreqAxisHeight() {
			const h = this._d.spectrogramCanvas?.height;
			if (!h || !this._d.freqAxisSpacer) return;
			if (this._lastFreqAxisH === h) return;
			this._lastFreqAxisH = h;
			if (typeof this._freqAxisRafId === "number") cancelAnimationFrame(this._freqAxisRafId);
			this._freqAxisRafId = requestAnimationFrame(() => {
				this._freqAxisRafId = void 0;
				const ch = this._d.spectrogramCanvas?.height;
				if (ch > 0) {
					this._d.freqAxisSpacer.style.height = `${ch}px`;
					if (this._d.crosshairCanvas) this._d.crosshairCanvas.style.marginTop = `-${ch}px`;
				}
			});
		}
		_clamp(v, lo, hi) {
			return v < lo ? lo : v > hi ? hi : v;
		}
		/**
		* @param {string} name
		* @param {any} detail
		*/
		_emit(name, detail = {}) {
			this.dispatchEvent(new CustomEvent(name, { detail }));
		}
	};
	//#endregion
	//#region src/app/FrequencyViewport.ts
	var FrequencyViewport = class extends EventTarget {
		constructor(..._args) {
			super(..._args);
			this._min = null;
			this._max = null;
		}
		get min() {
			return this._min;
		}
		get max() {
			return this._max;
		}
		get isZoomed() {
			return this._min != null;
		}
		set(min, max) {
			this._min = min;
			this._max = max;
			this._fire();
		}
		reset() {
			if (!this.isZoomed) return;
			this._min = null;
			this._max = null;
			this._fire();
		}
		resetSilent() {
			this._min = null;
			this._max = null;
		}
		zoom(factor, anchorFreq, boundedMax) {
			const currentMin = this._min ?? 0;
			const currentMax = this._max ?? boundedMax;
			const anchor = clamp(anchorFreq, currentMin, currentMax);
			let newMin = anchor - (anchor - currentMin) / factor;
			let newMax = anchor + (currentMax - anchor) / factor;
			const minRange = Math.max(100, boundedMax * .05);
			if (newMax - newMin < minRange) {
				const mid = (newMin + newMax) / 2;
				newMin = mid - minRange / 2;
				newMax = mid + minRange / 2;
			}
			newMin = Math.max(0, newMin);
			newMax = Math.min(boundedMax, newMax);
			if (newMin <= 1 && newMax >= boundedMax - 1) {
				this._min = null;
				this._max = null;
			} else {
				this._min = newMin;
				this._max = newMax;
			}
			this._fire();
		}
		pan(deltaHz, boundedMax) {
			const currentMin = this._min ?? 0;
			const currentMax = this._max ?? boundedMax;
			if (currentMin <= 0 && currentMax >= boundedMax) return;
			let newMin = currentMin + deltaHz;
			let newMax = currentMax + deltaHz;
			const range = currentMax - currentMin;
			if (newMin < 0) {
				newMin = 0;
				newMax = range;
			}
			if (newMax > boundedMax) {
				newMax = boundedMax;
				newMin = boundedMax - range;
			}
			this._min = Math.max(0, newMin);
			this._max = Math.min(boundedMax, newMax);
			this._fire();
		}
		setFromSlider(sliderValue, boundedMax) {
			if (sliderValue <= 0) {
				this.reset();
				return;
			}
			const range = boundedMax * Math.max(.05, 1 - sliderValue / 100 * .95);
			const currentMid = this._min != null && this._max != null ? (this._min + this._max) / 2 : boundedMax / 2;
			let newMin = currentMid - range / 2;
			let newMax = currentMid + range / 2;
			if (newMin < 0) {
				newMin = 0;
				newMax = range;
			}
			if (newMax > boundedMax) {
				newMax = boundedMax;
				newMin = boundedMax - range;
			}
			this._min = newMin;
			this._max = newMax;
			this._fire();
		}
		_fire() {
			this.dispatchEvent(new CustomEvent("change"));
		}
	};
	//#endregion
	//#region src/app/ViewportManager.ts
	var ViewportManager = class extends EventTarget {
		/**
		* @param {object} opts
		* @param {object}   opts.d              - DOM-refs subset (canvasWrapper, waveformWrapper,
		*                                         overviewContainer, overviewWindow, viewRangeDisplay,
		*                                         zoomSlider, zoomValue, spectrogramCanvas, amplitudeCanvas)
		* @param {import('../domain/coordinateSystem.ts').CoordinateSystem} opts.coords
		* @param {import('./interactionState.ts').InteractionState} opts.interaction
		* @param {object}   opts.layout         - { showSpectrogram, showWaveform, showOverview }
		* @param {import('./PlayerState.ts').PlaybackViewportConfig} opts.playbackViewportConfig
		* @param {() => AudioBuffer | null}     opts.getAudioBuffer
		* @param {() => any}                    opts.getWavesurfer
		* @param {(detail: object) => void}     opts.scheduleUiUpdate
		* @param {() => void}                   opts.onRedrawNeeded  - full redraw (spectrogram + waveform)
		* @param {() => boolean}                opts.getSpectroHasData
		* @param {(event: string, detail: object) => void} opts.emit
		*/
		constructor({ d, coords, interaction, layout, playbackViewportConfig, getAudioBuffer, getWavesurfer, scheduleUiUpdate, onRedrawNeeded, getSpectroHasData, emit }) {
			super();
			this._d = d;
			this._coords = coords;
			this._interaction = interaction;
			this._layout = layout;
			this._cfg = playbackViewportConfig;
			this._getAudioBuffer = getAudioBuffer;
			this._getWavesurfer = getWavesurfer;
			this._scheduleUiUpdate = scheduleUiUpdate;
			this._onRedrawNeeded = onRedrawNeeded;
			this._getSpectroHasData = getSpectroHasData;
			this._emitHost = emit;
			this.pixelsPerSecond = 100;
			this.windowStartNorm = 0;
			this.windowEndNorm = 1;
			this.followMode = "follow";
			this.followPlayback = true;
			this.scrollSyncLock = false;
			this._smoothSeekFocusUntil = 0;
			this._followCatchupRafId = 0;
			this._followCatchupAnim = null;
			this._zoomRedrawRafId = 0;
			this._overviewViewportRafId = 0;
			this._overviewNeedsFinalRedraw = false;
			this._lastSelectionEmitAt = 0;
			this._lastSelectionStart = NaN;
			this._lastSelectionEnd = NaN;
			this._lastViewRangeTextStart = NaN;
			this._lastViewRangeTextEnd = NaN;
		}
		/**
		* Called by PlayerState whenever the playback viewport config is updated.
		* @param {import('./PlayerState.ts').PlaybackViewportConfig} cfg
		*/
		updateConfig(cfg) {
			this._cfg = cfg;
		}
		/**
		* Called by PlayerState when coords are rebuilt.
		* @param {import('../domain/coordinateSystem.ts').CoordinateSystem} coords
		*/
		updateCoords(coords) {
			this._coords = coords;
		}
		/**
		* Called by PlayerState when layout visibility changes.
		* @param {object} layout
		*/
		updateLayout(layout) {
			this._layout = {
				...this._layout,
				...layout
			};
		}
		/**
		* Called when a seek happens during playback — activates the slow-lerp window.
		*/
		markSeekFocus() {
			this._smoothSeekFocusUntil = performance.now() + this._cfg.smoothSeekFocusMs;
		}
		/**
		* Apply follow-mode scrolling for the given playhead pixel position.
		* Called from PlayerState on each `uiupdate` event during playback.
		* @param {number} position  - playhead position in pixels (timeToScrollX result)
		*/
		applyFollowScroll(position) {
			const vw = this._getViewportWidth();
			if (this.followMode === "smooth") this._applySmoothFollow(position, vw);
			else {
				const scrollLeft = this._getPrimaryScrollLeft();
				const guardLeft = scrollLeft + vw * this._cfg.followGuardLeftRatio;
				const guardRight = scrollLeft + vw * this._cfg.followGuardRightRatio;
				if (position < guardLeft || position > guardRight) this._animateFollowCatchupTo(Math.max(0, position - vw * this._cfg.followTargetRatio));
			}
		}
		/**
		* Cycle through follow modes: follow → smooth → free → follow.
		* @returns {string} new follow mode
		*/
		cycleFollowMode() {
			this.followMode = this.followMode === "free" ? "follow" : this.followMode === "follow" ? "smooth" : "free";
			this.followPlayback = this.followMode !== "free";
			if (this.followMode !== "follow") this._cancelFollowCatchupAnimation();
			this.dispatchEvent(new CustomEvent("followchange", { detail: { mode: this.followMode } }));
			this._emitHost("followmodechange", { mode: this.followMode });
			return this.followMode;
		}
		getPrimaryScrollLeft() {
			return this._getPrimaryScrollLeft();
		}
		getViewportWidth() {
			return this._getViewportWidth();
		}
		/**
		* Set zoom level with optional anchor point.
		* @param {number}  nextPps
		* @param {boolean} redraw
		* @param {number}  [anchorTime]   - time in seconds at anchor pixel
		* @param {number}  [anchorPixel]  - pixel X of anchor in viewport
		*/
		setPixelsPerSecond(nextPps, redraw, anchorTime, anchorPixel) {
			this._setPixelsPerSecond(nextPps, redraw, anchorTime, anchorPixel);
		}
		/**
		* Fit the entire audio track into the current viewport.
		*/
		fitEntireTrackInView() {
			const buf = this._getAudioBuffer();
			if (!buf) return;
			const fitPps = this._getViewportWidth() / Math.max(.05, buf.duration);
			this._setPixelsPerSecond(fitPps, true, 0, 0);
		}
		/**
		* Zoom by a multiplicative scale around a client-space X coordinate.
		* @param {number} scale
		* @param {number} centerClientX
		* @param {'spectrogram'|'waveform'} [source]
		*/
		zoomByScale(scale, centerClientX, source = "spectrogram") {
			if (!this._getAudioBuffer()) return;
			const wrapper = source === "waveform" ? this._d.waveformWrapper : this._d.canvasWrapper;
			const rect = wrapper.getBoundingClientRect();
			const localX = clamp(centerClientX - rect.left, 0, rect.width);
			const anchorTime = this._coords.scrollXToTime(wrapper.scrollLeft + localX);
			this._setPixelsPerSecond(this.pixelsPerSecond * scale, true, anchorTime, localX);
		}
		/**
		* Scroll the viewport so that timeSec is horizontally centred.
		* @param {number} timeSec
		*/
		centerViewportAtTime(timeSec) {
			const buf = this._getAudioBuffer();
			if (!buf) return;
			const vw = this._getViewportWidth();
			const viewDur = this._coords.scrollXToTime(vw);
			let start = timeSec - viewDur / 2;
			start = clamp(start, 0, Math.max(0, buf.duration - viewDur));
			this._setLinkedScrollLeft(this._coords.timeToScrollX(start));
		}
		/**
		* Convert a client-space X coordinate to an audio time.
		* @param {number} clientX
		* @param {'spectrogram'|'waveform'} [source]
		* @returns {number}
		*/
		clientXToTime(clientX, source = "spectrogram") {
			const wrapper = source === "waveform" ? this._d.waveformWrapper : this._d.canvasWrapper;
			const scrollX = clientX - wrapper.getBoundingClientRect().left + wrapper.scrollLeft;
			const dur = this._getAudioBuffer()?.duration || 0;
			return clamp(this._coords.scrollXToTime(scrollX), 0, dur);
		}
		/**
		* Reset zoom and scroll state when a new file is loaded.
		* @param {number} [pps]
		*/
		resetZoom(pps = 100) {
			if (this._zoomRedrawRafId) {
				cancelAnimationFrame(this._zoomRedrawRafId);
				this._zoomRedrawRafId = 0;
			}
			if (this._overviewViewportRafId) {
				cancelAnimationFrame(this._overviewViewportRafId);
				this._overviewViewportRafId = 0;
			}
			this._cancelFollowCatchupAnimation();
			this._setPixelsPerSecond(pps, false);
			this._lastSelectionEmitAt = 0;
			this._lastSelectionStart = NaN;
			this._lastSelectionEnd = NaN;
			this._lastViewRangeTextStart = NaN;
			this._lastViewRangeTextEnd = NaN;
		}
		/**
		* Sync the overview window element to the current scroll/zoom position.
		* Call after every playhead update and scroll event.
		*/
		syncOverviewWindowToViewport() {
			this._syncOverviewWindowToViewport();
		}
		updateOverviewWindowElement() {
			this._updateOverviewWindowElement();
		}
		getOverviewSpanConstraints() {
			return this._getOverviewSpanConstraints();
		}
		/**
		* Start an overview navigator drag.
		* @param {'move'|'left'|'right'} mode
		* @param {number} clientX
		*/
		startOverviewDrag(mode, clientX) {
			this._startOverviewDrag(mode, clientX);
		}
		/**
		* Update overview drag in progress.
		* @param {number} clientX
		*/
		updateOverviewDrag(clientX) {
			this._updateOverviewDrag(clientX);
		}
		/**
		* Queue a deferred viewport apply from the overview window position.
		* @param {boolean} [redrawFinal]
		*/
		queueOverviewViewportApply(redrawFinal = false) {
			this._queueOverviewViewportApply(redrawFinal);
		}
		applyOverviewWindowToViewport(redraw = true) {
			this._applyOverviewWindowToViewport(redraw);
		}
		dispose() {
			this._cancelFollowCatchupAnimation();
			if (this._zoomRedrawRafId) {
				cancelAnimationFrame(this._zoomRedrawRafId);
				this._zoomRedrawRafId = 0;
			}
			if (this._overviewViewportRafId) {
				cancelAnimationFrame(this._overviewViewportRafId);
				this._overviewViewportRafId = 0;
			}
		}
		_getPrimaryScrollWrapper() {
			if (!this._layout.showSpectrogram && this._layout.showWaveform) return this._d.waveformWrapper;
			return this._d.canvasWrapper || this._d.waveformWrapper;
		}
		_getSecondaryScrollWrapper() {
			const primary = this._getPrimaryScrollWrapper();
			if (primary === this._d.canvasWrapper) return this._d.waveformWrapper;
			if (primary === this._d.waveformWrapper) return this._d.canvasWrapper;
			return null;
		}
		_getPrimaryScrollLeft() {
			return this._getPrimaryScrollWrapper()?.scrollLeft || 0;
		}
		_getViewportWidth() {
			const primary = this._getPrimaryScrollWrapper();
			const secondary = this._getSecondaryScrollWrapper();
			return Math.max(1, primary?.clientWidth || secondary?.clientWidth || 0);
		}
		_setLinkedScrollLeft(nextLeft) {
			if (this.scrollSyncLock) return;
			this.scrollSyncLock = true;
			const buf = this._getAudioBuffer();
			const vw = this._getViewportWidth();
			const tw = buf ? Math.max(1, Math.floor(this._coords.timeToScrollX(buf.duration))) : 0;
			const bounded = clamp(nextLeft, 0, Math.max(0, tw - vw));
			const primary = this._getPrimaryScrollWrapper();
			const secondary = this._getSecondaryScrollWrapper();
			if (primary) primary.scrollLeft = bounded;
			if (secondary) secondary.scrollLeft = primary?.scrollLeft ?? bounded;
			this.scrollSyncLock = false;
			this._scheduleUiUpdate({
				time: this._getCurrentTime(),
				fromPlayback: false
			});
		}
		_setPixelsPerSecond(nextPps, redraw, anchorTime, anchorPixel) {
			const d = this._d;
			const minPps = Number(d.zoomSlider.min);
			const maxPps = Number(d.zoomSlider.max);
			const sliderStep = Number(d.zoomSlider.step || 1);
			const vw = this._getViewportWidth();
			const buf = this._getAudioBuffer();
			const duration = buf?.duration || 0;
			const clamped = clamp(nextPps, minPps, maxPps);
			const changed = Math.abs(clamped - this.pixelsPerSecond) >= .01;
			const fallbackTime = this._coords.scrollXToTime(this._getPrimaryScrollLeft() + vw / 2);
			const aTime = anchorTime ?? fallbackTime;
			const aPixel = anchorPixel ?? vw / 2;
			const effectivePps = changed ? clamped : this.pixelsPerSecond;
			const estWidth = duration ? Math.max(1, Math.floor(duration * effectivePps)) : 0;
			const maxScroll = Math.max(0, estWidth - vw);
			const bounded = clamp(duration ? aTime * effectivePps - aPixel : 0, 0, maxScroll);
			if (changed) {
				this.pixelsPerSecond = effectivePps;
				d.zoomSlider.value = String(Math.round(effectivePps / sliderStep) * sliderStep);
				d.zoomValue.textContent = `${Math.round(effectivePps)} px/s`;
				const ws = this._getWavesurfer();
				if (ws) ws.zoom(effectivePps);
				if (buf && redraw) {
					this._onRedrawNeeded();
					this._emitHost("zoomchange", { pixelsPerSecond: this.pixelsPerSecond });
					this.dispatchEvent(new CustomEvent("zoomchange", { detail: { pixelsPerSecond: this.pixelsPerSecond } }));
				} else this._requestSpectrogramRedraw();
			}
			this._setLinkedScrollLeft(bounded);
		}
		_requestSpectrogramRedraw() {
			if (this._zoomRedrawRafId) return;
			this._zoomRedrawRafId = requestAnimationFrame(() => {
				this._zoomRedrawRafId = 0;
				if (!this._getAudioBuffer()) return;
				this._onRedrawNeeded();
				this._emitHost("zoomchange", { pixelsPerSecond: this.pixelsPerSecond });
				this.dispatchEvent(new CustomEvent("zoomchange", { detail: { pixelsPerSecond: this.pixelsPerSecond } }));
			});
		}
		_syncOverviewWindowToViewport() {
			const buf = this._getAudioBuffer();
			if (!this._layout.showOverview || !buf) return;
			if (this._interaction.isOverviewDrag) return;
			const d = this._d;
			if (Math.max(d.spectrogramCanvas?.width || 0, d.amplitudeCanvas?.width || 0, Math.floor(this._coords.timeToScrollX(buf.duration))) <= 0) return;
			const vw = this._getViewportWidth();
			const viewTime = this._coords.scrollXToTime(vw);
			const startTime = this._coords.scrollXToTime(this._getPrimaryScrollLeft());
			const endTime = Math.min(buf.duration, startTime + viewTime);
			const nextStartNorm = startTime / buf.duration;
			const nextEndNorm = endTime / buf.duration;
			const moved = Math.abs(nextStartNorm - this.windowStartNorm) > 1e-5 || Math.abs(nextEndNorm - this.windowEndNorm) > 1e-5;
			this.windowStartNorm = nextStartNorm;
			this.windowEndNorm = nextEndNorm;
			if (moved) this._updateOverviewWindowElement();
			if (Math.abs(startTime - this._lastViewRangeTextStart) > .05 || Math.abs(endTime - this._lastViewRangeTextEnd) > .05) {
				this._lastViewRangeTextStart = startTime;
				this._lastViewRangeTextEnd = endTime;
				if (d.viewRangeDisplay) d.viewRangeDisplay.textContent = `${formatSecondsShort(startTime)} – ${formatSecondsShort(endTime)}`;
			}
			const now = performance.now();
			if ((!Number.isFinite(this._lastSelectionStart) || Math.abs(startTime - this._lastSelectionStart) > .03 || Math.abs(endTime - this._lastSelectionEnd) > .03) && now - this._lastSelectionEmitAt >= 80) {
				this._lastSelectionEmitAt = now;
				this._lastSelectionStart = startTime;
				this._lastSelectionEnd = endTime;
				this._emitHost("selection", {
					start: startTime,
					end: endTime
				});
				this.dispatchEvent(new CustomEvent("selection", { detail: {
					start: startTime,
					end: endTime
				} }));
			}
		}
		_updateOverviewWindowElement() {
			if (!this._layout.showOverview) return;
			const cw = this._d.overviewContainer?.clientWidth || 0;
			if (cw <= 0) return;
			const minW = 8;
			let left = this.windowStartNorm * cw;
			let width = Math.max(minW, this.windowEndNorm * cw - left);
			if (left + width > cw) left = Math.max(0, cw - width);
			this._d.overviewWindow.style.left = `${left}px`;
			this._d.overviewWindow.style.width = `${width}px`;
		}
		_getOverviewSpanConstraints() {
			const buf = this._getAudioBuffer();
			const duration = Math.max(.001, buf?.duration || .001);
			const vw = Math.max(1, this._getViewportWidth());
			const d = this._d;
			const minPps = Math.max(1, Number(d.zoomSlider?.min || 20));
			const maxPps = Math.max(minPps, Number(d.zoomSlider?.max || 450));
			const minSpanNorm = Math.max(MIN_WINDOW_NORM, vw / maxPps / duration);
			const maxSpanNorm = Math.min(1, vw / minPps / duration);
			return {
				minSpanNorm: Math.min(minSpanNorm, 1),
				maxSpanNorm: Math.max(minSpanNorm, maxSpanNorm)
			};
		}
		_startOverviewDrag(mode, clientX) {
			if (!this._interaction.enter({
				move: "overview-move",
				left: "overview-resize-left",
				right: "overview-resize-right"
			}[mode])) return;
			this._interaction.ctx.overviewStartX = clientX;
			this._interaction.ctx.overviewStartNorm = this.windowStartNorm;
			this._interaction.ctx.overviewEndNorm = this.windowEndNorm;
		}
		_updateOverviewDrag(clientX) {
			const sub = this._interaction.overviewSubMode;
			const buf = this._getAudioBuffer();
			if (!this._layout.showOverview || !buf || !sub) return;
			const ctx = this._interaction.ctx;
			if (Math.abs(clientX - ctx.overviewStartX) > 2) ctx.overviewMoved = true;
			const cw = this._d.overviewContainer?.clientWidth || 1;
			const deltaNorm = (clientX - ctx.overviewStartX) / cw;
			const { minSpanNorm, maxSpanNorm } = this._getOverviewSpanConstraints();
			const fixedStart = ctx.overviewStartNorm;
			const fixedEnd = ctx.overviewEndNorm;
			if (sub === "move") {
				let s = fixedStart + deltaNorm;
				let e = fixedEnd + deltaNorm;
				const span = e - s;
				if (s < 0) {
					s = 0;
					e = span;
				}
				if (e > 1) {
					e = 1;
					s = 1 - span;
				}
				this.windowStartNorm = s;
				this.windowEndNorm = e;
			} else if (sub === "left") {
				const nextStart = fixedStart + deltaNorm;
				const right = fixedEnd;
				const minStart = Math.max(0, right - maxSpanNorm);
				const maxStart = Math.max(minStart, right - minSpanNorm);
				this.windowStartNorm = clamp(nextStart, minStart, maxStart);
				this.windowEndNorm = right;
			} else if (sub === "right") {
				const nextEnd = fixedEnd + deltaNorm;
				const left = fixedStart;
				const minEnd = Math.min(1, left + minSpanNorm);
				const maxEnd = Math.min(1, left + maxSpanNorm);
				this.windowEndNorm = clamp(nextEnd, minEnd, maxEnd);
				this.windowStartNorm = left;
			}
			this._updateOverviewWindowElement();
			this._queueOverviewViewportApply(false);
		}
		_queueOverviewViewportApply(redrawFinal = false) {
			this._overviewNeedsFinalRedraw = this._overviewNeedsFinalRedraw || redrawFinal;
			if (this._overviewViewportRafId) return;
			this._overviewViewportRafId = requestAnimationFrame(() => {
				this._overviewViewportRafId = 0;
				const redraw = this._overviewNeedsFinalRedraw;
				this._overviewNeedsFinalRedraw = false;
				this._applyOverviewWindowToViewport(redraw);
				if (!redraw) {
					this._onRedrawNeeded();
					this._emitHost("zoomchange", { pixelsPerSecond: this.pixelsPerSecond });
				}
			});
		}
		_applyOverviewWindowToViewport(redraw = true) {
			const buf = this._getAudioBuffer();
			if (!this._layout.showOverview || !buf) return;
			const dur = buf.duration;
			const viewDur = Math.max(.01, (this.windowEndNorm - this.windowStartNorm) * dur);
			const targetPps = this._getViewportWidth() / viewDur;
			this._setPixelsPerSecond(targetPps, redraw, this.windowStartNorm * dur, 0);
		}
		_cancelFollowCatchupAnimation() {
			if (this._followCatchupRafId) {
				cancelAnimationFrame(this._followCatchupRafId);
				this._followCatchupRafId = 0;
			}
			this._followCatchupAnim = null;
		}
		_animateFollowCatchupTo(targetScrollLeft) {
			const buf = this._getAudioBuffer();
			if (!buf) return;
			const vw = this._getViewportWidth();
			const tw = Math.max(1, Math.floor(this._coords.timeToScrollX(buf.duration)));
			const target = clamp(targetScrollLeft, 0, Math.max(0, tw - vw));
			const start = this._getPrimaryScrollLeft();
			const delta = target - start;
			if (Math.abs(delta) < 1) return;
			const now = performance.now();
			const duration = now < this._smoothSeekFocusUntil ? this._cfg.followCatchupSeekDurationMs : this._cfg.followCatchupDurationMs;
			if (this._followCatchupAnim) {
				if (Math.abs(this._followCatchupAnim.target - target) < 6) return;
			}
			this._cancelFollowCatchupAnimation();
			this._followCatchupAnim = {
				start,
				target,
				startedAt: now,
				duration
			};
			const easeOutCubic = (t) => 1 - (1 - t) ** 3;
			const tick = (ts) => {
				const anim = this._followCatchupAnim;
				if (!anim) return;
				const t = clamp((ts - anim.startedAt) / Math.max(1, anim.duration), 0, 1);
				const eased = easeOutCubic(t);
				const next = anim.start + (anim.target - anim.start) * eased;
				this._setLinkedScrollLeft(next);
				if (t >= 1) {
					this._cancelFollowCatchupAnimation();
					return;
				}
				this._followCatchupRafId = requestAnimationFrame(tick);
			};
			this._followCatchupRafId = requestAnimationFrame(tick);
		}
		_applySmoothFollow(position, viewportWidth) {
			const buf = this._getAudioBuffer();
			if (!buf) return;
			const vw = Math.max(1, viewportWidth || this._getViewportWidth());
			const totalWidth = Math.max(1, Math.floor(this._coords.timeToScrollX(buf.duration)));
			const maxScroll = Math.max(0, totalWidth - vw);
			const target = clamp(position - vw * this._cfg.followTargetRatio, 0, maxScroll);
			const current = this._getPrimaryScrollLeft();
			const delta = target - current;
			if (Math.abs(delta) < .6) return;
			const inSeekFocus = performance.now() < this._smoothSeekFocusUntil;
			const lerp = inSeekFocus ? this._cfg.smoothSeekLerp : this._cfg.smoothLerp;
			const minStep = inSeekFocus ? vw * this._cfg.smoothSeekMinStepRatio : vw * this._cfg.smoothMinStepRatio;
			const step = Math.sign(delta) * Math.min(Math.abs(delta), Math.max(minStep, Math.abs(delta) * lerp, 1));
			this._setLinkedScrollLeft(current + step);
		}
		_getCurrentTime() {
			return this._getWavesurfer()?.getCurrentTime?.() ?? 0;
		}
	};
	//#endregion
	//#region src/ui/components/waveform/waveform.ts
	function getCssVar(name, fallback = "") {
		try {
			return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
		} catch {
			return fallback;
		}
	}
	function installThemeObserver(canvas, redrawFn) {
		if (canvas.dataset?.awThemeObserverInstalled) return;
		canvas.dataset.awThemeObserverInstalled = "1";
		const mo = new MutationObserver((mutations) => {
			for (const m of mutations) if (m.attributeName === "data-theme") {
				redrawFn();
				break;
			}
		});
		mo.observe(document.documentElement, {
			attributes: true,
			attributeFilter: ["data-theme"]
		});
		try {
			const mm = window.matchMedia?.("(prefers-color-scheme: light)");
			if (mm?.addEventListener) mm.addEventListener("change", () => redrawFn());
			else if (mm?.addListener) mm.addListener(() => redrawFn());
		} catch {}
		canvas._aw_themeObserver = mo;
		canvas._aw_themeRedraw = redrawFn;
	}
	function drawWaveformTimeline({ ctx, width, height, duration, pixelsPerSecond }) {
		if (width <= 0) return;
		const textColor = getCssVar("--color-text-secondary", "#cbd5e1");
		const { majorStep, minorStep } = getTimeGridSteps(pixelsPerSecond);
		ctx.clearRect(0, 0, width, height);
		ctx.fillStyle = getCssVar("--color-bg-secondary", "#1e293b");
		ctx.fillRect(0, 0, width, height);
		ctx.font = "11px monospace";
		ctx.textBaseline = "middle";
		ctx.fillStyle = textColor;
		ctx.strokeStyle = colorWithAlpha(getCssVar("--color-text-secondary", "#cbd5e1"), .25);
		for (let t = 0; t <= duration; t += minorStep) {
			const x = Math.round(t * pixelsPerSecond) + .5;
			if (x < 0 || x > width) continue;
			const isMajor = Math.abs(t / majorStep - Math.round(t / majorStep)) < 1e-4;
			const lineHeight = isMajor ? 14 : 8;
			ctx.beginPath();
			ctx.moveTo(x, 0);
			ctx.lineTo(x, lineHeight);
			ctx.stroke();
			if (isMajor) ctx.fillText(`${t.toFixed(1)}s`, x + 3, height / 2);
		}
	}
	function renderMainWaveform({ audioBuffer, amplitudeCanvas, waveformTimelineCanvas, waveformContent, pixelsPerSecond, waveformHeight = 100, amplitudePeakAbs, showTimeline = true }) {
		if (!audioBuffer) return;
		const ampCtx = amplitudeCanvas.getContext("2d");
		const timelineCtx = waveformTimelineCanvas.getContext("2d");
		if (!ampCtx || !timelineCtx) return;
		try {
			amplitudeCanvas._aw_lastRender = {
				audioBuffer,
				amplitudeCanvas,
				waveformTimelineCanvas,
				waveformContent,
				pixelsPerSecond,
				waveformHeight,
				amplitudePeakAbs,
				showTimeline
			};
		} catch (e) {}
		const width = Math.max(1, Math.floor(audioBuffer.duration * pixelsPerSecond));
		const clampedWaveformHeight = Math.max(64, Math.floor(waveformHeight));
		const timelineHeight = showTimeline ? clamp(Math.round(clampedWaveformHeight * .22), 18, 32) : 0;
		const ampHeight = Math.max(32, clampedWaveformHeight - timelineHeight);
		amplitudeCanvas.width = width;
		amplitudeCanvas.height = ampHeight;
		waveformTimelineCanvas.width = width;
		waveformTimelineCanvas.height = timelineHeight;
		waveformTimelineCanvas.style.display = showTimeline ? "block" : "none";
		waveformContent.style.width = `${width}px`;
		const channelData = audioBuffer.getChannelData(0);
		const totalSamples = channelData.length;
		const midY = ampHeight / 2;
		const ampScale = 1 / Math.max(1e-6, amplitudePeakAbs);
		const { majorStep, minorStep } = getTimeGridSteps(pixelsPerSecond);
		ampCtx.clearRect(0, 0, width, ampHeight);
		ampCtx.fillStyle = getCssVar("--color-bg-tertiary", "#0f3460");
		ampCtx.fillRect(0, 0, width, ampHeight);
		for (let t = 0; t <= audioBuffer.duration; t += minorStep) {
			const x = Math.round(t * pixelsPerSecond) + .5;
			ampCtx.strokeStyle = Math.abs(t / majorStep - Math.round(t / majorStep)) < 1e-4 ? colorWithAlpha(getCssVar("--color-text-secondary", "#94a3b8"), .22) : colorWithAlpha(getCssVar("--color-text-secondary", "#94a3b8"), .12);
			ampCtx.beginPath();
			ampCtx.moveTo(x, 0);
			ampCtx.lineTo(x, ampHeight);
			ampCtx.stroke();
		}
		ampCtx.strokeStyle = colorWithAlpha(getCssVar("--color-text-secondary", "#94a3b8"), .35);
		ampCtx.beginPath();
		ampCtx.moveTo(0, midY + .5);
		ampCtx.lineTo(width, midY + .5);
		ampCtx.stroke();
		ampCtx.strokeStyle = getCssVar("--color-accent", "#60a5fa");
		ampCtx.lineWidth = 1;
		for (let x = 0; x < width; x++) {
			const start = Math.floor(x * totalSamples / width);
			const end = Math.min(totalSamples, Math.floor((x + 1) * totalSamples / width));
			let min = 1;
			let max = -1;
			for (let i = start; i < end; i++) {
				const v = clamp(channelData[i] * ampScale, -1, 1);
				if (v < min) min = v;
				if (v > max) max = v;
			}
			ampCtx.beginPath();
			ampCtx.moveTo(x + .5, (1 + min) * midY);
			ampCtx.lineTo(x + .5, (1 + max) * midY);
			ampCtx.stroke();
		}
		if (showTimeline && timelineHeight > 0) drawWaveformTimeline({
			ctx: timelineCtx,
			width,
			height: timelineHeight,
			duration: audioBuffer.duration,
			pixelsPerSecond
		});
		installThemeObserver(amplitudeCanvas, () => {
			const s = amplitudeCanvas._aw_lastRender;
			if (s) requestAnimationFrame(() => {
				try {
					renderMainWaveform(s);
				} catch {}
			});
		});
	}
	function renderOverviewWaveform({ audioBuffer, overviewCanvas, overviewContainer, amplitudePeakAbs }) {
		if (!audioBuffer) return;
		const ctx = overviewCanvas.getContext("2d");
		if (!ctx) return;
		try {
			overviewCanvas._aw_lastRender = {
				audioBuffer,
				overviewCanvas,
				overviewContainer,
				amplitudePeakAbs
			};
		} catch (e) {}
		const rect = overviewContainer.getBoundingClientRect();
		overviewCanvas.width = Math.max(1, Math.floor(rect.width));
		overviewCanvas.height = Math.max(1, Math.floor(rect.height));
		ctx.clearRect(0, 0, overviewCanvas.width, overviewCanvas.height);
		ctx.fillStyle = getCssVar("--color-bg-tertiary", "#0f3460");
		ctx.fillRect(0, 0, overviewCanvas.width, overviewCanvas.height);
		const channelData = audioBuffer.getChannelData(0);
		const totalSamples = channelData.length;
		const amp = overviewCanvas.height / 2;
		const ampScale = 1 / Math.max(1e-6, amplitudePeakAbs);
		ctx.strokeStyle = getCssVar("--color-accent", "#60a5fa");
		ctx.lineWidth = 1;
		for (let x = 0; x < overviewCanvas.width; x++) {
			const start = Math.floor(x * totalSamples / overviewCanvas.width);
			const end = Math.min(totalSamples, Math.floor((x + 1) * totalSamples / overviewCanvas.width));
			let min = 1;
			let max = -1;
			for (let i = start; i < end; i++) {
				const v = clamp(channelData[i] * ampScale, -1, 1);
				if (v < min) min = v;
				if (v > max) max = v;
			}
			ctx.beginPath();
			ctx.moveTo(x, (1 + min) * amp);
			ctx.lineTo(x, (1 + max) * amp);
			ctx.stroke();
		}
		installThemeObserver(overviewCanvas, () => {
			const s = overviewCanvas._aw_lastRender;
			if (s) requestAnimationFrame(() => {
				try {
					renderOverviewWaveform(s);
				} catch {}
			});
		});
	}
	function renderFrequencyLabels({ labelsElement, coords }) {
		labelsElement.innerHTML = "";
		const boundedMaxFreq = coords.freqViewMax ?? Math.min(coords.maxFreq, coords.sampleRate / 2);
		const minFreq = coords.freqViewMin ?? (coords.freqRange && coords.freqRange[0] || 0);
		const range = boundedMaxFreq - minFreq;
		const niceSteps = [
			100,
			200,
			500,
			1e3,
			2e3,
			2500,
			5e3,
			1e4,
			2e4
		];
		const rawStep = range / 6;
		const step = niceSteps.reduce((best, s) => Math.abs(s - rawStep) < Math.abs(best - rawStep) ? s : best);
		const frequencies = [];
		const startFreq = Math.ceil(minFreq / step) * step;
		for (let f = startFreq; f <= boundedMaxFreq + 1; f += step) frequencies.push(f);
		if (minFreq === 0 && (frequencies.length === 0 || frequencies[0] !== 0)) frequencies.unshift(0);
		frequencies.forEach((freq) => {
			const span = document.createElement("span");
			span.textContent = freq >= 1e3 ? `${(freq / 1e3).toFixed(freq % 1e3 === 0 ? 0 : 1)} kHz` : `${Math.round(freq)} Hz`;
			const yFrac = coords.frequencyToYFraction(freq);
			span.style.position = "absolute";
			span.style.top = `${yFrac * 100}%`;
			span.style.transform = `translateY(${-yFrac * 100}%)`;
			span.style.setProperty("--tick-pos", `${yFrac * 100}%`);
			labelsElement.appendChild(span);
		});
	}
	//#endregion
	//#region src/ui/components/transport/transport-controller.ts
	var TransportController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(this.d.openFileBtn, "click", () => h.d.audioFile.click());
			on(this.d.compactMoreBtn, "click", (e) => {
				e.preventDefault();
				e.stopPropagation();
				h._setCompactToolbarOpen(!h._compactToolbarOpen);
			});
			on(this.d.settingsToggleBtn, "click", () => h._toggleSettingsPanel());
			on(this.d.settingsPanelClose, "click", () => h._setSettingsPanelOpen(false));
			on(this.d.audioFile, "change", (e) => h._handleFileSelect(e));
			on(this.d.playPauseBtn, "click", () => h._togglePlayPause());
			on(this.d.stopBtn, "click", () => h._stopPlayback());
			on(this.d.jumpStartBtn, "click", () => h._seekToTime(0, true));
			on(this.d.jumpEndBtn, "click", () => h._seekToTime(h.audioBuffer?.duration ?? 0, true));
			on(this.d.backwardBtn, "click", () => h._seekByDelta(-5));
			on(this.d.forwardBtn, "click", () => h._seekByDelta(5));
			on(this.d.followToggleBtn, "click", () => h._cycleFollowMode());
			on(this.d.loopToggleBtn, "click", () => {
				h.loopPlayback = !h.loopPlayback;
				h._updateToggleButtons();
			});
			on(this.d.crosshairToggleBtn, "click", () => h._toggleCrosshair());
			on(this.d.freqZoomResetBtn, "click", () => h._freqView.reset());
			on(this.d.fitViewBtn, "click", () => h._fitEntireTrackInView());
			on(this.d.resetViewBtn, "click", () => {
				h._setPixelsPerSecond(100, true);
				h._setLinkedScrollLeft(0);
				h._freqView.reset();
				h._syncOverviewWindowToViewport();
			});
		}
	};
	//#endregion
	//#region src/ui/components/settings-panel/settings-panel-controller.ts
	var SettingsPanelController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			h._presets.bindEvents(on);
			on(this.d.maxFreqSelect, "change", () => {
				if (h.audioBuffer && h._spectro.hasData) {
					h._freqView.resetSilent();
					h._emit("spectrogramscalechange", { maxFreq: parseFloat(this.d.maxFreqSelect.value) });
					h._updateCoords();
					h._createFrequencyLabels();
					h._spectro.buildGrayscale();
					h._spectro.buildBaseImage(h._presets.currentColorScheme);
					h._drawSpectrogram();
					if (this.d.freqZoomResetBtn) this.d.freqZoomResetBtn.hidden = true;
				}
			});
			on(this.d.zoomSlider, "input", (e) => {
				h._setPixelsPerSecond(parseFloat(e.target.value), false);
				h._requestSpectrogramRedraw();
			});
			on(this.d.zoomSlider, "change", () => {
				if (h._spectro.hasData) h._drawSpectrogram();
			});
			for (const key of [
				"showCentroidCheck",
				"showF0Check",
				"showRidgesCheck"
			]) on(this.d[key], "change", () => {
				if (h._spectro.hasData) h._drawSpectrogram();
			});
		}
	};
	//#endregion
	//#region src/ui/components/volume/volume-controller.ts
	var VolumeController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			on(this.d.volumeSlider, "input", (e) => {
				this.host.muted = false;
				this.host._setVolume(parseFloat(e.target.value) / 100);
			});
			on(this.d.volumeToggleBtn, "click", () => this.host._toggleMute());
		}
	};
	//#endregion
	//#region src/ui/components/display-gain/display-gain-controller.ts
	var DisplayGainController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			const rebuildDisplay = () => {
				if (!h._spectro.hasData) return;
				h._spectro.buildBaseImage(h._presets.currentColorScheme);
				h._drawSpectrogram();
			};
			let debounceId = 0;
			const scheduleRebuild = () => {
				clearTimeout(debounceId);
				debounceId = window.setTimeout(() => {
					debounceId = 0;
					rebuildDisplay();
				}, 150);
			};
			on(this.d.gainModeSelect, "change", () => {
				h._presets.persistCurrentSettings();
				if (this.d.gainModeSelect.value === "auto" && h._spectro.data) h._spectro.autoContrast(true);
			});
			on(this.d.maxFreqModeSelect, "change", () => {
				h._presets.persistCurrentSettings();
				if (!h.audioBuffer) return;
				const mode = this.d.maxFreqModeSelect.value;
				if (mode === "auto") h._spectro.autoFrequency(true);
				else if (mode === "nyquist") {
					h._spectro.setMaxFreqToNyquist();
					h._spectro.generate();
				}
			});
			on(this.d.floorSlider, "input", () => {
				h._presets.persistCurrentSettings();
				scheduleRebuild();
			});
			on(this.d.ceilSlider, "input", () => {
				h._presets.persistCurrentSettings();
				scheduleRebuild();
			});
			on(this.d.autoContrastBtn, "click", () => h._spectro.autoContrast(true));
			on(this.d.autoFreqBtn, "click", () => h._spectro.autoFrequency(true));
		}
	};
	//#endregion
	//#region src/ui/components/playhead/playhead-controller.ts
	var PlayheadController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(this.d.playhead, "pointerdown", (e) => h._startPlayheadDrag(e, "spectrogram"));
			on(this.d.waveformPlayhead, "pointerdown", (e) => h._startPlayheadDrag(e, "waveform"));
			on(this.d.viewSplitHandle, "pointerdown", (e) => {
				if (!h._showWaveform || !h._showSpectrogram) return;
				e.preventDefault();
				h._startViewResize("split", e.clientY);
			});
			on(this.d.spectrogramResizeHandle, "pointerdown", (e) => {
				if (!h._showSpectrogram) return;
				e.preventDefault();
				h._startViewResize("spectrogram", e.clientY);
			});
		}
	};
	//#endregion
	//#region src/ui/components/freq-viewport/freq-viewport-controller.ts
	var FreqViewportController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			{
				let dragging = false;
				let startY = 0;
				let startMin = 0;
				let startMax = 0;
				const onMove = (e) => {
					if (!dragging) return;
					const spacerH = this.d.freqAxisSpacer.getBoundingClientRect().height;
					const dy = e.clientY - startY;
					const boundedMax = h.coords.boundedMaxFreq;
					const range = startMax - startMin;
					const deltaHz = dy / spacerH * range;
					let newMin = startMin + deltaHz;
					let newMax = startMax + deltaHz;
					if (newMin < 0) {
						newMin = 0;
						newMax = range;
					}
					if (newMax > boundedMax) {
						newMax = boundedMax;
						newMin = boundedMax - range;
					}
					h._freqView.set(Math.max(0, newMin), Math.min(boundedMax, newMax));
				};
				const onUp = () => {
					if (!dragging) return;
					dragging = false;
					document.body.style.cursor = "";
					document.removeEventListener("pointermove", onMove);
					document.removeEventListener("pointerup", onUp);
				};
				on(this.d.freqAxisSpacer, "pointerdown", (e) => {
					if (e.button !== 0 || !h._showSpectrogram) return;
					if (!h._freqView.isZoomed) return;
					e.preventDefault();
					dragging = true;
					startY = e.clientY;
					startMin = h._freqView.min ?? 0;
					startMax = h._freqView.max ?? h.coords.boundedMaxFreq;
					document.body.style.cursor = "ns-resize";
					document.addEventListener("pointermove", onMove);
					document.addEventListener("pointerup", onUp);
				});
			}
			on(this.d.freqAxisSpacer, "wheel", (e) => {
				if (!h.audioBuffer || !h._showSpectrogram) return;
				e.preventDefault();
				const rect = this.d.freqAxisSpacer.getBoundingClientRect();
				const localY = e.clientY - rect.top;
				const canvasH = this.d.spectrogramCanvas?.height || rect.height;
				const canvasY = localY / Math.max(1, rect.height) * canvasH;
				const freqAtCursor = h.coords.pixelYToFrequency(canvasY);
				const zoomIn = e.deltaY < 0;
				h._freqView.zoom(zoomIn ? 1.15 : 1 / 1.15, freqAtCursor, h.coords.boundedMaxFreq);
			}, { passive: false });
			on(this.d.freqZoomSlider, "input", (e) => {
				h._freqView.setFromSlider(parseInt(e.target.value, 10), h.coords.boundedMaxFreq);
			});
			{
				let dragging = false;
				let startY = 0;
				let startMin = 0;
				let startMax = 0;
				const onMove = (e) => {
					if (!dragging) return;
					const barH = this.d.freqScrollbar.getBoundingClientRect().height;
					const dy = e.clientY - startY;
					const boundedMax = h.coords.boundedMaxFreq;
					const range = startMax - startMin;
					const deltaHz = dy / barH * boundedMax;
					let newMin = startMin - deltaHz;
					let newMax = startMax - deltaHz;
					if (newMin < 0) {
						newMin = 0;
						newMax = range;
					}
					if (newMax > boundedMax) {
						newMax = boundedMax;
						newMin = boundedMax - range;
					}
					h._freqView.set(Math.max(0, newMin), Math.min(boundedMax, newMax));
				};
				const onUp = () => {
					if (!dragging) return;
					dragging = false;
					this.d.freqScrollbar?.classList.remove("active");
					document.removeEventListener("pointermove", onMove);
					document.removeEventListener("pointerup", onUp);
				};
				on(this.d.freqScrollbarThumb, "pointerdown", (e) => {
					if (!h._freqView.isZoomed) return;
					e.preventDefault();
					e.stopPropagation();
					dragging = true;
					startY = e.clientY;
					startMin = h._freqView.min ?? 0;
					startMax = h._freqView.max ?? h.coords.boundedMaxFreq;
					this.d.freqScrollbar?.classList.add("active");
					document.addEventListener("pointermove", onMove);
					document.addEventListener("pointerup", onUp);
				});
			}
		}
	};
	//#endregion
	//#region src/ui/components/canvas-interaction/canvas-interaction-controller.ts
	var CanvasInteractionController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(this.d.canvasWrapper, "click", (e) => h._handleCanvasClick(e));
			on(this.d.canvasWrapper, "dblclick", (e) => {
				if (e.shiftKey) {
					e.preventDefault();
					h._freqView.reset();
				}
			});
			on(this.d.canvasWrapper, "mousemove", (e) => h._updateCrosshair(e));
			on(this.d.canvasWrapper, "mouseleave", () => h._hideCrosshair());
			on(this.d.waveformWrapper, "click", (e) => h._handleWaveformClick(e));
			on(this.d.canvasWrapper, "scroll", () => {
				if (h.scrollSyncLock) return;
				if (h._getPrimaryScrollWrapper() !== this.d.canvasWrapper) return;
				h._setLinkedScrollLeft(this.d.canvasWrapper.scrollLeft);
				if (h._spectro.hasData) h._drawSpectrogram();
			});
			on(this.d.waveformWrapper, "scroll", () => {
				if (h.scrollSyncLock) return;
				if (h._getPrimaryScrollWrapper() !== this.d.waveformWrapper) return;
				h._setLinkedScrollLeft(this.d.waveformWrapper.scrollLeft);
				if (h._spectro.hasData) h._drawSpectrogram();
			});
			on(this.d.canvasWrapper, "wheel", (e) => h._handleWheel(e, "spectrogram"), { passive: false });
			on(this.d.waveformWrapper, "wheel", (e) => h._handleWheel(e, "waveform"), { passive: false });
			on(this.d.canvasWrapper, "keydown", (e) => {
				if (!h.audioBuffer) return;
				if (isTypingContext(e.target)) return;
				switch (e.key) {
					case "ArrowLeft":
						e.preventDefault();
						h._seekByDelta(-SEEK_FINE_SEC);
						break;
					case "ArrowRight":
						e.preventDefault();
						h._seekByDelta(SEEK_FINE_SEC);
						break;
					case "Home":
						e.preventDefault();
						h._seekToTime(0, true);
						break;
					case "End":
						e.preventDefault();
						h._seekToTime(h.audioBuffer.duration, true);
						break;
				}
			});
			on(this.d.canvasWrapper, "pointerdown", (e) => h._startViewportPan(e, "spectrogram"));
			on(this.d.waveformWrapper, "pointerdown", (e) => h._startViewportPan(e, "waveform"));
		}
	};
	//#endregion
	//#region src/ui/components/overview/overview-controller.ts
	var OverviewController = class {
		constructor(d, host) {
			this.d = d;
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(this.d.overviewHandleLeft, "pointerdown", (e) => {
				if (!h._showOverview) return;
				e.preventDefault();
				h._startOverviewDrag("left", e.clientX);
			});
			on(this.d.overviewHandleRight, "pointerdown", (e) => {
				if (!h._showOverview) return;
				e.preventDefault();
				h._startOverviewDrag("right", e.clientX);
			});
			on(this.d.overviewWindow, "pointerdown", (e) => {
				if (!h._showOverview) return;
				if (e.target === this.d.overviewHandleLeft || e.target === this.d.overviewHandleRight) return;
				e.preventDefault();
				h._startOverviewDrag("move", e.clientX);
			});
			on(this.d.overviewCanvas, "click", (e) => {
				if (h.interaction.isOverviewClickBlocked()) return;
				if (!h._showOverview || !h.audioBuffer) return;
				const rect = this.d.overviewCanvas.getBoundingClientRect();
				const xNorm = clamp((e.clientX - rect.left) / rect.width, 0, 1);
				h._seekToTime(xNorm * h.audioBuffer.duration, true);
			});
			on(this.d.overviewLabelToggle, "click", () => h._toggleOverviewLabelSection());
		}
	};
	//#endregion
	//#region src/ui/components/document-events/document-events-controller.ts
	var DocumentEventsController = class {
		constructor(host) {
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(document, "pointermove", (e) => {
				if (h.interaction.isViewResize) {
					h._updateViewResize(e.clientY);
					return;
				}
				if (h.interaction.isDraggingViewport) h._updateViewportPan(e.clientX, e.clientY);
				if (h.interaction.isDraggingPlayhead) h._seekFromClientX(e.clientX, h.interaction.ctx.playheadSource);
				if (h.interaction.isOverviewDrag) h._updateOverviewDrag(e.clientX);
			});
			const releaseAll = () => {
				h._stopViewResize();
				if (h.interaction.isDraggingViewport) {
					if (h.interaction.ctx.panSuppressClick) h.interaction.blockSeekClicks(50);
					document.body.style.cursor = "";
				}
				if (h.interaction.isOverviewDrag) {
					h._queueOverviewViewportApply(true);
					if (h.interaction.ctx.overviewMoved) h.interaction.blockOverviewClicks(260);
				}
				h.interaction.release();
			};
			on(document, "pointerup", releaseAll);
			on(document, "pointercancel", releaseAll);
			on(document, "keydown", (e) => h._handleKeyboardShortcuts(e));
			on(document, "pointerdown", (e) => {
				if (!h._compactToolbarOpen) return;
				if (h.d.toolbarRoot?.contains(e.target)) return;
				h._setCompactToolbarOpen(false);
			});
		}
	};
	//#endregion
	//#region src/ui/components/window-events/window-events-controller.ts
	var WindowEventsController = class {
		constructor(host) {
			this.host = host;
		}
		bind(on) {
			const h = this.host;
			on(window, "resize", () => {
				h._queueCompactToolbarLayoutRefresh();
				if (!h._shouldCompactToolbarBeActive()) h._setCompactToolbarOpen(false);
				if (!h.audioBuffer) return;
				h._invalidateSpectrogramHeightCache();
				h._drawSpectrogram();
				h._drawMainWaveform();
				h._drawOverviewWaveform();
				h._syncOverviewWindowToViewport();
				h._emit("viewresize", {
					waveformHeight: h.waveformDisplayHeight,
					spectrogramHeight: h.spectrogramDisplayHeight
				});
			});
			on(window, "beforeunload", () => h.dispose());
		}
	};
	//#endregion
	//#region src/app/PlayerState.ts
	/**
	* Sanitize and clamp a partial playback viewport config object.
	* All fields are optional; missing or invalid values fall back to `current` or built-in defaults.
	* Pure function — no side effects, no DOM required.
	*
	* @param {Partial<PlaybackViewportConfig>} partial
	* @param {Partial<PlaybackViewportConfig>} [current={}]
	* @returns {PlaybackViewportConfig}
	*/
	function sanitizePlaybackViewportConfig(partial = {}, current = {}) {
		return {
			followGuardLeftRatio: clampNumber(partial.followGuardLeftRatio, .05, .95, current.followGuardLeftRatio ?? .35),
			followGuardRightRatio: clampNumber(partial.followGuardRightRatio, .05, .95, current.followGuardRightRatio ?? .65),
			followTargetRatio: clampNumber(partial.followTargetRatio, .1, .9, current.followTargetRatio ?? .5),
			followCatchupDurationMs: clampNumber(partial.followCatchupDurationMs, 80, 2500, current.followCatchupDurationMs ?? 240),
			followCatchupSeekDurationMs: clampNumber(partial.followCatchupSeekDurationMs, 100, 3e3, current.followCatchupSeekDurationMs ?? 360),
			smoothLerp: clampNumber(partial.smoothLerp, .02, .95, current.smoothLerp ?? .18),
			smoothSeekLerp: clampNumber(partial.smoothSeekLerp, .01, .9, current.smoothSeekLerp ?? .08),
			smoothMinStepRatio: clampNumber(partial.smoothMinStepRatio, .001, .25, current.smoothMinStepRatio ?? .03),
			smoothSeekMinStepRatio: clampNumber(partial.smoothSeekMinStepRatio, .001, .2, current.smoothSeekMinStepRatio ?? .008),
			smoothSeekFocusMs: clampNumber(partial.smoothSeekFocusMs, 150, 5e3, current.smoothSeekFocusMs ?? 1400)
		};
	}
	var PlayerState = class {
		/**
		* @param {HTMLElement} container
		* @param {any} WaveSurfer
		* @param {((event: string, detail: any) => void) | null} [emitHostEvent]
		* @param {PlayerOptions} [options]
		*/
		constructor(container, WaveSurfer, emitHostEvent = null, options = {}) {
			if (!container) throw new Error("PlayerState: container element required");
			if (!WaveSurfer && !options.engine) throw new Error("PlayerState: WaveSurfer reference or options.engine required");
			this.container = container;
			this.d = this._queryDom(container);
			/** @type {import('../infrastructure/storage/IStorage.ts').IStorage} */
			this._storage = options.storage ?? new LocalStorageAdapter();
			this._presets = new PresetManager(this.d, {
				onRegenerateSpectrogram: (opts) => {
					if (this.audioBuffer) this._spectro.generate(opts);
				},
				onStage1Rebuild: () => {
					if (this._spectro.hasData) {
						this._spectro.buildGrayscale();
						this._spectro.buildBaseImage(this._presets.currentColorScheme);
						this._drawSpectrogram();
					}
				},
				storage: this._storage,
				onDspCommand: options.onDspCommand ?? null
			});
			this._presets.populatePresetDropdown();
			this._presets.applyFavouritePresetControls();
			this.WaveSurfer = WaveSurfer;
			/** @type {AudioEngine} */
			this._engine = options.engine instanceof AudioEngineBase ? options.engine : new AudioEngine(WaveSurfer, { container: this.d.audioEngineHost });
			this._engine.addEventListener("uiupdate", (e) => this._scheduleUiUpdate(e.detail));
			this._engine.addEventListener("transportstatechange", (e) => {
				const { state, reason } = e.detail;
				this._setTransportState(state, reason);
			});
			this._engine.addEventListener("ready", () => {
				this._viewport._lastSelectionEmitAt = 0;
				this._viewport._lastSelectionStart = NaN;
				this._viewport._lastSelectionEnd = NaN;
			});
			this._engine.addEventListener("timeupdate", (e) => {
				this._perf.timeupdateEvents += 1;
				this._emit("timeupdate", e.detail);
			});
			this._engine.addEventListener("segmentstart", (e) => this._emit("segmentplaystart", e.detail));
			this._engine.addEventListener("segmentend", (e) => this._emit("segmentplayend", e.detail));
			this._engine.addEventListener("segmentloop", (e) => this._emit("segmentloop", e.detail));
			this._emitHostEvent = typeof emitHostEvent === "function" ? emitHostEvent : null;
			this.options = options || {};
			this._viewMode = this.options.viewMode === "waveform" || this.options.viewMode === "spectrogram" ? this.options.viewMode : "both";
			this._showWaveform = this._viewMode !== "spectrogram";
			this._showSpectrogram = this._viewMode !== "waveform";
			this._showOverview = this.options.showOverview !== false;
			this._transportOverlay = this.options.transportOverlay === true;
			this._compactToolbarMode = this.options.compactToolbar && [
				"auto",
				"on",
				"off"
			].includes(this.options.compactToolbar) ? this.options.compactToolbar : "auto";
			this._compactToolbarOpen = false;
			this._settingsPanelOpen = false;
			this._compactToolbarLayoutRaf = 0;
			this._showWaveformTimeline = this.options.showWaveformTimeline !== false && !(this.options.transportOverlay && this._viewMode === "waveform");
			this._playbackViewportConfig = this._sanitizePlaybackViewportConfig(this.options || {});
			this._spectro = new SpectrogramController(this.d, { enableProgressive: this.options.enableProgressiveSpectrogram === true });
			this._spectro.addEventListener("transportstatechange", (e) => {
				const { state, reason } = e.detail;
				this._setTransportState(state, reason);
			});
			this._spectro.addEventListener("progress", (e) => this._emit("progress", e.detail));
			this._spectro.addEventListener("computetime", (e) => this._emit("computeTime", e.detail));
			this._spectro.addEventListener("ready", (e) => this._emit("ready", e.detail));
			this._spectro.addEventListener("error", (e) => this._emit("error", e.detail));
			this._spectro.addEventListener("scalechange", (e) => {
				this._emit("spectrogramscalechange", e.detail);
				this._updateCoords();
				this._createFrequencyLabels();
			});
			this._spectro.addEventListener("needsredraw", () => {
				this._updateCoords();
				this._createFrequencyLabels();
				this._drawSpectrogram();
				this._syncOverviewWindowToViewport();
			});
			this.sampleRateHz = DEFAULT_SAMPLE_RATE;
			this.amplitudePeakAbs = 1;
			this._freqView = new FrequencyViewport();
			this._freqView.addEventListener("change", () => this._applyFreqViewChange());
			this.interaction = new InteractionState();
			this.coords = new CoordinateSystem();
			this.transportState = "";
			this._lastTimeReadoutText = "";
			this._uiFrameId = 0;
			this._uiPending = null;
			this._perf = {
				enabled: false,
				/** @type {HTMLDivElement | null} */
				panel: null,
				intervalId: 0,
				frames: 0,
				fps: 0,
				lastFrameTs: 0,
				longFrames: 0,
				maxFrameMs: 0,
				uiFlushes: 0,
				timeupdateEvents: 0,
				selectionEvents: 0,
				seekEvents: 0,
				transitionEvents: 0,
				blockedTransitions: 0,
				lastTransition: ""
			};
			const vLayout = {
				showSpectrogram: this._showSpectrogram,
				showWaveform: this._showWaveform,
				showOverview: this._showOverview,
				spectrogramHeight: 200,
				waveformHeight: 100
			};
			this._viewport = new ViewportManager({
				d: this.d,
				coords: this.coords,
				interaction: this.interaction,
				layout: vLayout,
				playbackViewportConfig: this._playbackViewportConfig,
				getAudioBuffer: () => this.audioBuffer,
				getWavesurfer: () => this.wavesurfer,
				scheduleUiUpdate: (detail) => this._scheduleUiUpdate(detail),
				onRedrawNeeded: () => {
					if (this._spectro.hasData) this._drawSpectrogram();
					this._drawMainWaveform();
				},
				getSpectroHasData: () => this._spectro.hasData,
				emit: (event, detail) => this._emit(event, detail)
			});
			this._crosshairEnabled = false;
			this._crosshairRafId = 0;
			this.waveformDisplayHeight = 100;
			this.spectrogramDisplayHeight = 200;
			this._viewResizeFrameId = 0;
			this._viewResizeNeedsWaveformRedraw = false;
			this._viewResizeNeedsSpectrogramRedraw = false;
			this._applyLocalViewHeights();
			this._updateAmplitudeLabels();
			this._setInitialPlayheadPositions();
			this._updateToggleButtons();
			this._updateAriaPlaybackPosition(0);
			this._setCompactToolbarOpen(false);
			this._setTransportState("idle", "init");
			this._initPerfOverlay();
			this._cleanups = [];
			this._bindEvents();
			if (this._storage.getItem("aw-label-section-collapsed") === "1") this._toggleOverviewLabelSection(true);
			if (this.options.enableTouchGestures !== false) this._bindTouchGestures();
			this._refreshCompactToolbarLayout();
			this._presets.updatePcenSectionDimming();
			requestAnimationFrame(() => this._refreshCompactToolbarLayout());
		}
		_emit(event, detail = {}) {
			if (!this._emitHostEvent) return;
			this._emitHostEvent(event, detail);
		}
		get audioBuffer() {
			return this._engine.audioBuffer;
		}
		get wavesurfer() {
			return this._engine.wavesurfer;
		}
		get volume() {
			return this._engine.volume;
		}
		get muted() {
			return this._engine.muted;
		}
		get preMuteVolume() {
			return this._engine.preMuteVolume;
		}
		get playbackMode() {
			return this._engine.playbackMode;
		}
		get loopPlayback() {
			return this._engine.loopPlayback;
		}
		set muted(v) {
			this._engine.muted = v;
		}
		set loopPlayback(v) {
			this._engine.loopPlayback = v;
		}
		get pixelsPerSecond() {
			return this._viewport.pixelsPerSecond;
		}
		set pixelsPerSecond(v) {
			this._viewport.pixelsPerSecond = v;
		}
		get windowStartNorm() {
			return this._viewport.windowStartNorm;
		}
		set windowStartNorm(v) {
			this._viewport.windowStartNorm = v;
		}
		get windowEndNorm() {
			return this._viewport.windowEndNorm;
		}
		set windowEndNorm(v) {
			this._viewport.windowEndNorm = v;
		}
		get followMode() {
			return this._viewport.followMode;
		}
		set followMode(v) {
			this._viewport.followMode = v;
		}
		get followPlayback() {
			return this._viewport.followPlayback;
		}
		set followPlayback(v) {
			this._viewport.followPlayback = v;
		}
		get scrollSyncLock() {
			return this._viewport.scrollSyncLock;
		}
		set scrollSyncLock(v) {
			this._viewport.scrollSyncLock = v;
		}
		_sanitizePlaybackViewportConfig(partial = {}) {
			return sanitizePlaybackViewportConfig(partial, this._playbackViewportConfig || {});
		}
		updatePlaybackViewportConfig(partial = {}) {
			this._playbackViewportConfig = this._sanitizePlaybackViewportConfig(partial);
			if (this._playbackViewportConfig.followGuardLeftRatio >= this._playbackViewportConfig.followGuardRightRatio) {
				this._playbackViewportConfig.followGuardLeftRatio = .35;
				this._playbackViewportConfig.followGuardRightRatio = .65;
			}
			this._viewport?.updateConfig(this._playbackViewportConfig);
			this._emit("followconfigchange", { ...this._playbackViewportConfig });
			return { ...this._playbackViewportConfig };
		}
		getPlaybackViewportConfig() {
			return { ...this._playbackViewportConfig };
		}
		_initPerfOverlay() {
			const byOption = this.options?.enablePerfOverlay === true;
			let byQuery = false;
			try {
				byQuery = new URLSearchParams(window.location.search || "").get("perf") === "1";
			} catch {
				byQuery = false;
			}
			if (!byOption && !byQuery) return;
			this._perf.enabled = true;
			const panel = document.createElement("div");
			panel.className = "abp-perf-overlay";
			panel.style.position = "absolute";
			panel.style.top = "8px";
			panel.style.right = "8px";
			panel.style.left = "auto";
			panel.style.bottom = "auto";
			panel.style.transform = "none";
			panel.style.zIndex = "60";
			panel.innerHTML = `
            <div class="abp-perf-title">PERF</div>
            <div class="abp-perf-body">Initializing...</div>
        `;
			this.container.appendChild(panel);
			this._perf.panel = panel;
			this._perf.intervalId = window.setInterval(() => {
				this._renderPerfOverlay();
			}, 500);
		}
		_perfOnFrame(ts) {
			if (!this._perf.enabled) return;
			this._perf.frames += 1;
			if (this._perf.lastFrameTs > 0) {
				const frameMs = ts - this._perf.lastFrameTs;
				if (frameMs > 0) {
					const fps = 1e3 / frameMs;
					this._perf.fps = this._perf.fps <= 0 ? fps : this._perf.fps * .85 + fps * .15;
				}
				this._perf.maxFrameMs = Math.max(this._perf.maxFrameMs, frameMs);
				if (frameMs > 32) this._perf.longFrames += 1;
			}
			this._perf.lastFrameTs = ts;
		}
		_renderPerfOverlay() {
			if (!this._perf.enabled || !this._perf.panel) return;
			const body = this._perf.panel.querySelector(".abp-perf-body");
			if (!body) return;
			body.innerHTML = [
				`state: ${this.transportState || "n/a"}`,
				`fps: ${this._perf.fps.toFixed(1)} | long>32ms: ${this._perf.longFrames}`,
				`max frame: ${this._perf.maxFrameMs.toFixed(1)}ms | ui flushes: ${this._perf.uiFlushes}`,
				`timeupdate: ${this._perf.timeupdateEvents} | selection: ${this._perf.selectionEvents} | seek: ${this._perf.seekEvents}`,
				`transitions: ${this._perf.transitionEvents} | blocked: ${this._perf.blockedTransitions}`,
				`last: ${this._perf.lastTransition || "-"}`
			].join("<br>");
			this._perf.uiFlushes = 0;
			this._perf.timeupdateEvents = 0;
			this._perf.selectionEvents = 0;
			this._perf.seekEvents = 0;
			this._perf.maxFrameMs = 0;
		}
		_setTransportState(nextState, reason = "") {
			if (!nextState || this.transportState === nextState) return;
			const fromState = this.transportState || "";
			if (!canTransitionTransportState(fromState, nextState)) {
				this._perf.blockedTransitions += 1;
				this._emit("transporttransitionblocked", {
					from: fromState,
					to: nextState,
					reason
				});
				return;
			}
			this.transportState = nextState;
			this._updatePlayPauseButton();
			this._perf.transitionEvents += 1;
			this._perf.lastTransition = `${fromState || "∅"} → ${nextState}${reason ? ` (${reason})` : ""}`;
			this._setPlayState(TRANSPORT_STATE_LABELS[nextState] || nextState);
			this._emit("transportstatechange", {
				state: nextState,
				reason
			});
		}
		_updatePlayPauseButton() {
			const isPlaying = this.transportState === "playing" || this.transportState === "playing_loop" || this.transportState === "playing_segment";
			this.d.playPauseBtn?.classList.toggle("playing", isPlaying);
		}
		_scheduleUiUpdate({ time = this._getCurrentTime(), fromPlayback = false, centerView = false, emitSeek = false, immediate = false } = {}) {
			this._uiPending = this._uiPending || {
				time: 0,
				fromPlayback: false,
				centerView: false,
				emitSeek: false
			};
			this._uiPending.time = time;
			this._uiPending.fromPlayback = fromPlayback;
			this._uiPending.centerView = this._uiPending.centerView || centerView;
			this._uiPending.emitSeek = this._uiPending.emitSeek || emitSeek;
			if (immediate) {
				if (this._uiFrameId) {
					cancelAnimationFrame(this._uiFrameId);
					this._uiFrameId = 0;
				}
				this._flushUiUpdate(performance.now());
				return;
			}
			if (this._uiFrameId) return;
			this._uiFrameId = requestAnimationFrame((ts) => this._flushUiUpdate(ts));
		}
		_flushUiUpdate(_ts) {
			this._uiFrameId = 0;
			const pending = this._uiPending;
			this._uiPending = null;
			if (!pending || !this.audioBuffer) return;
			this._perfOnFrame(_ts);
			this._perf.uiFlushes += 1;
			const duration = Math.max(0, this.audioBuffer.duration || 0);
			const t = clamp(pending.time || 0, 0, duration || pending.time || 0);
			this._updateTimeReadout(t);
			this._updatePlayhead(t, pending.fromPlayback);
			if (pending.centerView) this._centerViewportAtTime(t);
			if (pending.emitSeek) {
				this._perf.seekEvents += 1;
				this._emit("seek", {
					currentTime: t,
					duration: this.audioBuffer?.duration || 0
				});
			}
		}
		_queryDom(root) {
			const q = (id) => root.querySelector(`[data-aw="${id}"]`);
			return {
				openFileBtn: q("openFileBtn"),
				toolbarRoot: q("toolbarRoot"),
				compactMoreBtn: q("compactMoreBtn"),
				toolbarSecondary: q("toolbarSecondary"),
				audioFile: q("audioFile"),
				playPauseBtn: q("playPauseBtn"),
				stopBtn: q("stopBtn"),
				jumpStartBtn: q("jumpStartBtn"),
				jumpEndBtn: q("jumpEndBtn"),
				backwardBtn: q("backwardBtn"),
				forwardBtn: q("forwardBtn"),
				followToggleBtn: q("followToggleBtn"),
				loopToggleBtn: q("loopToggleBtn"),
				fitViewBtn: q("fitViewBtn"),
				resetViewBtn: q("resetViewBtn"),
				currentTimeDisplay: q("currentTime"),
				totalTimeDisplay: q("totalTime"),
				playStateDisplay: q("playState"),
				viewRangeDisplay: q("viewRange"),
				spectrogramCanvas: q("spectrogramCanvas"),
				spectrogramContainer: q("spectrogramContainer"),
				waveformContainer: q("waveformContainer"),
				waveformWrapper: q("waveformWrapper"),
				waveformContent: q("waveformContent"),
				amplitudeLabels: q("amplitudeLabels"),
				amplitudeCanvas: q("amplitudeCanvas"),
				waveformTimelineCanvas: q("waveformTimelineCanvas"),
				waveformPlayhead: q("waveformPlayhead"),
				audioEngineHost: q("audioEngineHost"),
				playhead: q("playhead"),
				canvasWrapper: q("canvasWrapper"),
				canvasSizer: q("canvasSizer"),
				viewSplitHandle: q("viewSplitHandle"),
				spectrogramResizeHandle: q("spectrogramResizeHandle"),
				overviewCanvas: q("overviewCanvas"),
				overviewContainer: q("overviewContainer"),
				overviewWindow: q("overviewWindow"),
				overviewHandleLeft: q("overviewHandleLeft"),
				overviewHandleRight: q("overviewHandleRight"),
				overviewLabelTracks: q("overviewLabelTracks"),
				overviewLabelSection: q("overviewLabelSection"),
				overviewLabelToggle: q("overviewLabelToggle"),
				fileInfo: q("fileInfo"),
				sampleRateInfo: q("sampleRateInfo"),
				scaleSelect: q("scaleSelect"),
				colourScaleSelect: q("colourScaleSelect"),
				presetSelect: q("presetSelect"),
				presetSaveBtn: q("presetSaveBtn"),
				presetFavBtn: q("presetFavBtn"),
				presetManageBtn: q("presetManageBtn"),
				presetSaveRow: q("presetSaveRow"),
				presetSaveInput: q("presetSaveInput"),
				presetSaveConfirm: q("presetSaveConfirm"),
				presetSaveCancel: q("presetSaveCancel"),
				presetManagerPanel: q("presetManagerPanel"),
				presetManagerList: q("presetManagerList"),
				presetImportBtn: q("presetImportBtn"),
				presetExportBtn: q("presetExportBtn"),
				presetStatus: q("presetStatus"),
				nMelsInput: q("nMelsInput"),
				pcenGainInput: q("pcenGainInput"),
				pcenBiasInput: q("pcenBiasInput"),
				pcenRootInput: q("pcenRootInput"),
				pcenSmoothingInput: q("pcenSmoothingInput"),
				pcenEnabledCheck: q("pcenEnabledCheck"),
				pcenSection: q("pcenSection"),
				windowSizeSelect: q("windowSize"),
				windowFunctionSelect: q("windowFunction"),
				overlapSelect: q("overlapSelect"),
				oversamplingSelect: q("oversamplingSelect"),
				reassignedCheck: q("reassignedCheck"),
				noiseReductionCheck: q("noiseReductionCheck"),
				claheCheck: q("claheCheck"),
				showCentroidCheck: q("showCentroidCheck"),
				showF0Check: q("showF0Check"),
				showRidgesCheck: q("showRidgesCheck"),
				qualitySlider: q("qualitySlider"),
				qualityLevelDisplay: q("qualityLevelDisplay"),
				zoomSlider: q("zoomSlider"),
				zoomValue: q("zoomValue"),
				maxFreqModeSelect: q("maxFreqModeSelect"),
				maxFreqSelect: q("maxFreqSelect"),
				colorSchemeSelect: q("colorSchemeSelect"),
				freqLabels: q("freqLabels"),
				freqZoomResetBtn: q("freqZoomResetBtn"),
				freqAxisSpacer: q("freqAxisSpacer"),
				freqZoomSlider: q("freqZoomSlider"),
				freqScrollbar: q("freqScrollbar"),
				freqScrollbarThumb: q("freqScrollbarThumb"),
				volumeToggleBtn: q("volumeToggleBtn"),
				volumeIcon: q("volumeIcon"),
				volumeWaves: q("volumeWaves"),
				volumeSlider: q("volumeSlider"),
				gainModeSelect: q("gainModeSelect"),
				floorSlider: q("floorSlider"),
				ceilSlider: q("ceilSlider"),
				autoContrastBtn: q("autoContrastBtn"),
				autoFreqBtn: q("autoFreqBtn"),
				crosshairToggleBtn: q("crosshairToggleBtn"),
				crosshairCanvas: q("crosshairCanvas"),
				crosshairReadout: q("crosshairReadout"),
				recomputingOverlay: q("recomputingOverlay"),
				settingsToggleBtn: q("settingsToggleBtn"),
				settingsPanel: q("settingsPanel"),
				settingsPanelClose: q("settingsPanelClose")
			};
		}
		dispose() {
			this._stopCustomSegmentPlayback("stopped", this._getCurrentTime());
			this._viewport.dispose();
			this._hideCrosshair();
			if (this._viewResizeFrameId) {
				cancelAnimationFrame(this._viewResizeFrameId);
				this._viewResizeFrameId = 0;
			}
			if (this._uiFrameId) {
				cancelAnimationFrame(this._uiFrameId);
				this._uiFrameId = 0;
			}
			if (this._compactToolbarLayoutRaf) {
				cancelAnimationFrame(this._compactToolbarLayoutRaf);
				this._compactToolbarLayoutRaf = 0;
			}
			if (this._perf.intervalId) {
				clearInterval(this._perf.intervalId);
				this._perf.intervalId = 0;
			}
			if (this._perf.panel?.parentNode) {
				this._perf.panel.parentNode.removeChild(this._perf.panel);
				this._perf.panel = null;
			}
			for (let i = this._cleanups.length - 1; i >= 0; i--) this._cleanups[i]();
			this._cleanups.length = 0;
			this._presets.dispose();
			this._spectro.destroy();
			this._engine.destroy();
		}
		async _handleFileSelect(e) {
			const file = e.target?.files?.[0] ?? null;
			if (!file) return;
			await this.loadFile(file);
		}
		async loadFile(file) {
			if (!file) return;
			this.d.fileInfo.innerHTML = `<span class="statusbar-label">${escapeHtml(file.name)}</span>`;
			this.d.fileInfo.classList.add("loading");
			this._setTransportState("loading", "file-load");
			try {
				const result = await this._engine.loadFromFile(file);
				await this._onAudioLoaded(result, file.name, "file-loaded");
			} catch (error) {
				this._onAudioLoadError(error, "file");
			}
		}
		async loadUrl(url) {
			this.d.fileInfo.innerHTML = `<span class="statusbar-label">Loading…</span>`;
			this.d.fileInfo.classList.add("loading");
			this._setTransportState("loading", "url-load");
			try {
				const result = await this._engine.loadFromUrl(url);
				const name = decodeURIComponent(new URL(url, location.href).pathname.split("/").pop() || "audio");
				await this._onAudioLoaded(result, name, "url-loaded");
			} catch (error) {
				this._onAudioLoadError(error, "url");
				throw error;
			}
		}
		async _onAudioLoaded({ duration, sampleRate }, displayName, readyReason) {
			this.sampleRateHz = sampleRate;
			this.amplitudePeakAbs = this._engine.audioBuffer ? computeAmplitudePeak(this._engine.audioBuffer.getChannelData(0)) : 0;
			this._updateAmplitudeLabels();
			if (this._engine.audioBuffer) this._spectro.setAudio(this._engine.audioBuffer, sampleRate);
			this._spectro.updateMaxFreqOptions(sampleRate);
			this.d.fileInfo.innerHTML = `<span class="statusbar-label">${escapeHtml(displayName)}</span> <span>${formatTime(duration)}</span>`;
			this.d.sampleRateInfo.textContent = `${sampleRate} Hz`;
			this.d.totalTimeDisplay.textContent = formatTime(duration);
			this.d.currentTimeDisplay.textContent = formatTime(0);
			this._setPixelsPerSecond(100, false);
			this._setTransportEnabled(true);
			this._updateToggleButtons();
			this._setTransportState("ready", readyReason);
			this.d.fileInfo.classList.remove("loading");
			await this._spectro.generate({ autoAdjust: true });
			this._drawMainWaveform();
			this._drawOverviewWaveform();
			this._createFrequencyLabels();
			this._seekToTime(0, true);
		}
		_onAudioLoadError(error, source) {
			console.error(`Error loading audio (${source}):`, error);
			this._setTransportState("error", `${source}-load-failed`);
			this.d.fileInfo.classList.remove("loading");
			this._emit("error", {
				message: error?.message || String(error),
				source
			});
		}
		_togglePlayPause() {
			this._engine.playPause();
		}
		_stopPlayback() {
			this._engine.stop();
		}
		playSegment(startSec, endSec, options = {}) {
			this._engine.playSegment(startSec, endSec, options);
		}
		playBandpassedSegment(startSec, endSec, freqMinHz, freqMaxHz, options = {}) {
			this._engine.playBandpassedSegment(startSec, endSec, freqMinHz, freqMaxHz, options);
		}
		updateActiveSegmentFromLabel(label) {
			this._engine.updateActiveSegmentFromLabel(label);
		}
		/**
		* Stop any custom segment playback.
		* @param {string} [reason]
		* @param {number|null} [targetTimeSec]
		*/
		_stopCustomSegmentPlayback(reason = "stopped", targetTimeSec = null) {
			this._engine.stopSegmentPlayback(reason, targetTimeSec);
		}
		_clearPlaybackFilter() {
			this._engine._clearPlaybackFilter();
		}
		_seekToTime(timeSec, centerView = false, options = {}) {
			if (!this.audioBuffer) return;
			if (options.userInitiated) this._viewport.markSeekFocus();
			this._engine.seekToTime(timeSec, centerView, options);
		}
		_seekByDelta(deltaSec) {
			if (!this.audioBuffer) return;
			this._seekToTime(this._getCurrentTime() + deltaSec, false);
		}
		_getCurrentTime() {
			return this._engine.getCurrentTime();
		}
		_updateTimeReadout(t) {
			const nextText = formatTime(t);
			if (nextText !== this._lastTimeReadoutText) {
				this._lastTimeReadoutText = nextText;
				this.d.currentTimeDisplay.textContent = nextText;
			}
			this._updateAriaPlaybackPosition(t);
		}
		_updateAriaPlaybackPosition(currentTimeSec) {
			const slider = this.d.canvasWrapper;
			if (!slider) return;
			const duration = this.audioBuffer?.duration || 0;
			const now = clamp(currentTimeSec || 0, 0, duration || currentTimeSec || 0);
			slider.setAttribute("aria-valuemin", "0");
			slider.setAttribute("aria-valuemax", String(duration.toFixed(3)));
			slider.setAttribute("aria-valuenow", String(now.toFixed(3)));
			slider.setAttribute("aria-valuetext", `${formatTime(now)} of ${formatTime(duration)}`);
		}
		_updatePlayhead(currentTime, fromPlayback) {
			if (!this.audioBuffer) return;
			const position = this.coords.timeToScrollX(currentTime);
			this.d.playhead.style.transform = `translateX(${position}px)`;
			this.d.waveformPlayhead.style.transform = `translateX(${position}px)`;
			if (fromPlayback && this.followPlayback && this.wavesurfer?.isPlaying()) this._viewport.applyFollowScroll(position);
			this._syncOverviewWindowToViewport();
			if (!this._engine._customSegmentPlayback && this._engine._activeSegmentEnd != null && currentTime >= this._engine._activeSegmentEnd - .005) {
				const start = this._engine._activeSegmentStart ?? 0;
				const end = this._engine._activeSegmentEnd;
				if (this.loopPlayback && this.wavesurfer?.isPlaying()) {
					this._seekToTime(start, false, { allowCustomPlayback: true });
					this._emit("segmentloop", {
						start,
						end,
						filter: "none"
					});
					return;
				}
				this._engine.endNormalSegment(end);
				this._setTransportState("stopped", "segment-end");
				this._emit("segmentplayend", { end });
			}
		}
		/** Build draw-params object from current viewport/layout state. */
		_getSpectrogramDrawParams() {
			return {
				show: this._showSpectrogram,
				pixelsPerSecond: this.pixelsPerSecond,
				freqViewMin: this._freqView.min,
				freqViewMax: this._freqView.max,
				coords: this.coords,
				effectiveHeight: this._getEffectiveSpectrogramHeight(),
				colorScheme: this._presets.currentColorScheme,
				currentTime: this._getCurrentTime(),
				scrollLeft: this.d.canvasWrapper?.scrollLeft ?? 0,
				viewportWidth: this.d.canvasWrapper?.clientWidth ?? 0
			};
		}
		/** Render the spectrogram with current viewport state. */
		_drawSpectrogram() {
			if (!this._showSpectrogram) return;
			if (!this._spectro.hasData) return;
			this._spectro.draw(this._getSpectrogramDrawParams());
			this._updateCoords();
			this._scheduleUiUpdate({
				time: this._getCurrentTime(),
				fromPlayback: false,
				immediate: true
			});
		}
		_setVolume(val) {
			this._engine.setVolume(val);
			this._updateVolumeIcon();
		}
		_toggleMute() {
			this._engine.toggleMute();
			if (!this.muted) this.d.volumeSlider.value = Math.round(this.volume * 100);
			this._updateVolumeIcon();
		}
		_updateVolumeIcon() {
			const waves = this.d.volumeWaves;
			const btn = this.d.volumeToggleBtn;
			if (!waves || !btn) return;
			const vol = this.muted ? 0 : this.volume;
			waves.style.display = vol < .01 ? "none" : "";
			waves.setAttribute("d", vol < .4 ? "M15 8.5a4 4 0 010 7" : "M15 8.5a4 4 0 010 7M18 5a9 9 0 010 14");
			btn.classList.toggle("muted", vol < .01);
		}
		_requestSpectrogramRedraw() {
			this._viewport._requestSpectrogramRedraw();
		}
		_drawMainWaveform() {
			if (!this._showWaveform) return;
			const effectiveWaveformHeight = this._getEffectiveWaveformHeight();
			renderMainWaveform({
				audioBuffer: this.audioBuffer,
				amplitudeCanvas: this.d.amplitudeCanvas,
				waveformTimelineCanvas: this.d.waveformTimelineCanvas,
				waveformContent: this.d.waveformContent,
				pixelsPerSecond: this.pixelsPerSecond,
				waveformHeight: effectiveWaveformHeight,
				amplitudePeakAbs: this.amplitudePeakAbs,
				showTimeline: this._showWaveformTimeline
			});
			this._scheduleUiUpdate({
				time: this._getCurrentTime(),
				fromPlayback: false,
				immediate: true
			});
		}
		_drawOverviewWaveform() {
			if (!this._showOverview) return;
			renderOverviewWaveform({
				audioBuffer: this.audioBuffer,
				overviewCanvas: this.d.overviewCanvas,
				overviewContainer: this.d.overviewContainer,
				amplitudePeakAbs: this.amplitudePeakAbs
			});
			this._scheduleUiUpdate({
				time: this._getCurrentTime(),
				fromPlayback: false,
				immediate: true
			});
		}
		_createFrequencyLabels() {
			renderFrequencyLabels({
				labelsElement: this.d.freqLabels,
				coords: this.coords
			});
		}
		_updateAmplitudeLabels() {
			const el = this.d.amplitudeLabels;
			if (!el) return;
			el.innerHTML = "";
			const peak = Math.max(1e-6, this.amplitudePeakAbs || 1);
			const clampedH = this._getEffectiveWaveformHeight();
			const timelineH = this._showWaveformTimeline ? clamp(Math.round(clampedH * .22), 18, 32) : 0;
			const ampH = Math.max(32, clampedH - timelineH);
			const fmt = (v) => {
				const a = Math.abs(v);
				return a >= 1 ? v.toFixed(1) : a >= .01 ? v.toFixed(2) : v.toFixed(3);
			};
			const values = [
				peak,
				peak / 2,
				0,
				-peak / 2,
				-peak
			];
			values.forEach((value, i) => {
				const frac = i / (values.length - 1);
				const span = document.createElement("span");
				span.textContent = value === 0 ? "0" : `${value > 0 ? "+" : "−"}${fmt(Math.abs(value))}`;
				span.style.top = `${frac * ampH}px`;
				span.style.transform = `translateY(${-frac * 100}%)`;
				span.style.setProperty("--tick-pos", `${frac * 100}%`);
				el.appendChild(span);
			});
		}
		_getPrimaryScrollWrapper() {
			return this._viewport._getPrimaryScrollWrapper();
		}
		_getSecondaryScrollWrapper() {
			return this._viewport._getSecondaryScrollWrapper();
		}
		_getPrimaryScrollLeft() {
			return this._viewport.getPrimaryScrollLeft();
		}
		_getViewportWidth() {
			return this._viewport.getViewportWidth();
		}
		_setLinkedScrollLeft(nextLeft) {
			return this._viewport._setLinkedScrollLeft(nextLeft);
		}
		_setPixelsPerSecond(nextPps, redraw, anchorTime, anchorPixel) {
			return this._viewport.setPixelsPerSecond(nextPps, redraw, anchorTime, anchorPixel);
		}
		_fitEntireTrackInView() {
			this._viewport.fitEntireTrackInView();
		}
		_zoomByScale(scale, centerClientX, source = "spectrogram") {
			this._viewport.zoomByScale(
				scale,
				centerClientX,
				/** @type {'spectrogram'|'waveform'} */
				source
			);
		}
		_centerViewportAtTime(timeSec) {
			this._viewport.centerViewportAtTime(timeSec);
		}
		_clientXToTime(clientX, source = "spectrogram") {
			return this._viewport.clientXToTime(
				clientX,
				/** @type {'spectrogram'|'waveform'} */
				source
			);
		}
		_syncOverviewWindowToViewport() {
			this._viewport.syncOverviewWindowToViewport();
		}
		_updateOverviewWindowElement() {
			this._viewport.updateOverviewWindowElement();
		}
		_getOverviewSpanConstraints() {
			return this._viewport.getOverviewSpanConstraints();
		}
		_startOverviewDrag(mode, clientX) {
			this._viewport.startOverviewDrag(mode, clientX);
		}
		_updateOverviewDrag(clientX) {
			this._viewport.updateOverviewDrag(clientX);
		}
		_queueOverviewViewportApply(redrawFinal = false) {
			this._viewport.queueOverviewViewportApply(redrawFinal);
		}
		_toggleOverviewLabelSection(force) {
			const section = this.d.overviewLabelSection;
			const btn = this.d.overviewLabelToggle;
			if (!section) return;
			const collapsed = force !== void 0 ? force : !section.classList.contains("collapsed");
			section.classList.toggle("collapsed", collapsed);
			if (btn) btn.setAttribute("aria-expanded", String(!collapsed));
			this._storage.setItem("aw-label-section-collapsed", collapsed ? "1" : "0");
			requestAnimationFrame(() => {
				this._invalidateSpectrogramHeightCache?.();
				if (this.audioBuffer) {
					if (this._spectro.hasData) this._drawSpectrogram();
					this._drawMainWaveform();
				}
			});
		}
		_applyOverviewWindowToViewport(redraw = true) {
			this._viewport.applyOverviewWindowToViewport(redraw);
		}
		_handleCanvasClick(e) {
			if (this.interaction.isSeekBlocked()) return;
			if (!this.audioBuffer) return;
			this._cancelFollowCatchupAnimation();
			this._seekToTime(this._clientXToTime(e.clientX, "spectrogram"), false, { userInitiated: true });
		}
		_handleWaveformClick(e) {
			if (this.interaction.isSeekBlocked()) return;
			if (!this.audioBuffer) return;
			this._cancelFollowCatchupAnimation();
			this._seekToTime(this._clientXToTime(e.clientX, "waveform"), false, { userInitiated: true });
		}
		_blockSeekClicks(ms = 220) {
			this.interaction.blockSeekClicks(ms);
		}
		_startPlayheadDrag(event, source) {
			if (!this.audioBuffer) return;
			event.preventDefault();
			if (!this.interaction.enter("playhead-drag")) return;
			this.interaction.ctx.playheadSource = source;
			this._seekFromClientX(event.clientX, source);
		}
		_seekFromClientX(clientX, source = "spectrogram") {
			if (!this.audioBuffer) return;
			this._seekToTime(this._clientXToTime(clientX, source), false);
		}
		_startViewportPan(event, source) {
			if (!this.audioBuffer) return;
			this._cancelFollowCatchupAnimation();
			if (event.target === this.d.playhead || event.target === this.d.waveformPlayhead) return;
			if (event.button !== 0 && event.button !== 1) return;
			if (event.button === 1) event.preventDefault();
			if (!this.interaction.enter("viewport-pan")) return;
			this.interaction.ctx.panStartX = event.clientX;
			this.interaction.ctx.panStartY = event.clientY;
			this.interaction.ctx.panStartScroll = source === "waveform" ? this.d.waveformWrapper.scrollLeft : this.d.canvasWrapper.scrollLeft;
			this.interaction.ctx.panStartFreqViewMin = this._freqView.min;
			this.interaction.ctx.panStartFreqViewMax = this._freqView.max;
			this.interaction.ctx.panIsMiddle = event.button === 1;
			this.interaction.ctx.panSource = source;
			document.body.style.cursor = "grabbing";
		}
		_updateViewportPan(clientX, clientY) {
			const dx = clientX - (this.interaction.ctx.panStartX ?? 0);
			const dy = clientY - (this.interaction.ctx.panStartY ?? 0);
			this.interaction.ctx.panSuppressClick = Math.abs(dx) > 3 || Math.abs(dy) > 3;
			this._setLinkedScrollLeft((this.interaction.ctx.panStartScroll ?? 0) - dx);
			if (this.interaction.ctx.panIsMiddle && this.interaction.ctx.panSource !== "waveform" && this._showSpectrogram && this._freqView.isZoomed) {
				const height = this.d.canvasWrapper?.getBoundingClientRect().height || 1;
				const boundedMax = this.coords.boundedMaxFreq;
				const startMin = this.interaction.ctx.panStartFreqViewMin ?? 0;
				const startMax = this.interaction.ctx.panStartFreqViewMax ?? boundedMax;
				const range = startMax - startMin;
				const deltaHz = dy / height * range;
				let newMin = startMin + deltaHz;
				let newMax = startMax + deltaHz;
				if (newMin < 0) {
					newMin = 0;
					newMax = range;
				}
				if (newMax > boundedMax) {
					newMax = boundedMax;
					newMin = boundedMax - range;
				}
				this._freqView.set(Math.max(0, newMin), Math.min(boundedMax, newMax));
			}
		}
		_handleWheel(event, source) {
			if (!this.audioBuffer) return;
			this._cancelFollowCatchupAnimation();
			const wrapper = source === "waveform" ? this.d.waveformWrapper : this.d.canvasWrapper;
			const rect = wrapper.getBoundingClientRect();
			const localX = event.clientX - rect.left;
			const timeAtCursor = this.coords.scrollXToTime(wrapper.scrollLeft + localX);
			if (event.ctrlKey || event.metaKey) {
				event.preventDefault();
				const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
				this._setPixelsPerSecond(this.pixelsPerSecond * factor, true, timeAtCursor, localX);
				return;
			}
			if (event.shiftKey && source !== "waveform" && this._showSpectrogram) {
				event.preventDefault();
				const canvasY = (event.clientY - rect.top) / Math.max(1, rect.height) * (this.d.spectrogramCanvas?.height || rect.height);
				const freqAtCursor = this.coords.pixelYToFrequency(canvasY);
				const zoomIn = event.deltaY < 0;
				this._freqView.zoom(zoomIn ? 1.15 : 1 / 1.15, freqAtCursor, this.coords.boundedMaxFreq);
				return;
			}
			if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
				event.preventDefault();
				this._setLinkedScrollLeft(Math.max(0, wrapper.scrollLeft + event.deltaY));
			}
		}
		_applyFreqViewChange() {
			this._updateCoords();
			this._drawSpectrogram();
			this._createFrequencyLabels();
			this._scheduleUiUpdate({
				time: this._getCurrentTime(),
				fromPlayback: false,
				immediate: true
			});
			const btn = this.d.freqZoomResetBtn;
			if (btn) btn.hidden = !this._freqView.isZoomed;
			this._updateFreqScrollbar();
			this._syncFreqZoomSlider();
			this._emit("zoomchange", { pixelsPerSecond: this.pixelsPerSecond });
		}
		_updateFreqScrollbar() {
			const bar = this.d.freqScrollbar;
			const thumb = this.d.freqScrollbarThumb;
			if (!bar || !thumb) return;
			if (!this._freqView.isZoomed) {
				bar.hidden = true;
				return;
			}
			bar.hidden = false;
			const boundedMax = this.coords.boundedMaxFreq;
			const viewRange = (this._freqView.max ?? boundedMax) - (this._freqView.min ?? 0);
			const thumbFrac = Math.min(1, viewRange / boundedMax);
			const topFrac = 1 - (this._freqView.max ?? boundedMax) / boundedMax;
			thumb.style.height = `${Math.max(8, thumbFrac * 100)}%`;
			thumb.style.top = `${topFrac * 100}%`;
		}
		_syncFreqZoomSlider() {
			const slider = this.d.freqZoomSlider;
			if (!slider) return;
			if (!this._freqView.isZoomed) {
				slider.value = "0";
				return;
			}
			const boundedMax = this.coords.boundedMaxFreq;
			const val = (1 - ((this._freqView.max ?? boundedMax) - (this._freqView.min ?? 0)) / boundedMax) / .95 * 100;
			slider.value = String(clamp(Math.round(val), 0, 100));
		}
		_applyLocalViewHeights() {
			const overlaySingleWaveform = this._transportOverlay && this._showWaveform && !this._showSpectrogram;
			const overlaySingleSpectrogram = this._transportOverlay && this._showSpectrogram && !this._showWaveform;
			const waveformFlexes = this._showWaveform && !this._showSpectrogram;
			if (this._showWaveform) {
				if (overlaySingleWaveform || waveformFlexes) {
					if (this.d.waveformContainer) {
						this.d.waveformContainer.style.height = "";
						this.d.waveformContainer.style.minHeight = waveformFlexes ? `${Math.round(this.waveformDisplayHeight)}px` : "0";
					}
				} else if (this.d.waveformContainer) {
					this.d.waveformContainer.style.minHeight = "";
					this.d.waveformContainer.style.height = `${Math.round(this.waveformDisplayHeight)}px`;
				}
			}
			if (this._showSpectrogram) {
				if (overlaySingleSpectrogram) {
					if (this.d.spectrogramContainer) {
						this.d.spectrogramContainer.style.height = "";
						this.d.spectrogramContainer.style.minHeight = "0";
					}
				} else if (this.d.spectrogramContainer) {
					this.d.spectrogramContainer.style.height = "";
					this.d.spectrogramContainer.style.minHeight = `${Math.round(this.spectrogramDisplayHeight)}px`;
				}
			}
		}
		_getEffectiveWaveformHeight() {
			if (this._showWaveform && !this._showSpectrogram) {
				const h = this.d.waveformContainer?.clientHeight;
				if (h > 0) return Math.max(64, h);
			}
			return Math.max(64, Math.floor(this.waveformDisplayHeight));
		}
		_getEffectiveSpectrogramHeight() {
			if (typeof this._cachedSpectrogramHeight === "number" && this._cachedSpectrogramHeight > 0) return this._cachedSpectrogramHeight;
			const h = this.d.canvasWrapper?.clientHeight ?? this.d.spectrogramContainer?.clientHeight;
			const result = h > 0 ? Math.max(140, h) : Math.max(140, Math.floor(this.spectrogramDisplayHeight));
			this._cachedSpectrogramHeight = result;
			return result;
		}
		_invalidateSpectrogramHeightCache() {
			this._cachedSpectrogramHeight = 0;
		}
		/** Rebuild the shared CoordinateSystem whenever any mapping parameter changes. */
		_updateCoords() {
			const extCfg = this._spectro.externalImageConfig;
			const totalSpectrogramWidth = this.audioBuffer ? Math.max(1, Math.floor(this.audioBuffer.duration * this.pixelsPerSecond)) : this.d.spectrogramCanvas?.width || 0;
			this.coords = new CoordinateSystem({
				duration: this.audioBuffer?.duration || 0,
				sampleRate: this.sampleRateHz,
				pixelsPerSecond: this.pixelsPerSecond,
				canvasWidth: totalSpectrogramWidth,
				canvasHeight: this.d.spectrogramCanvas?.height || 0,
				maxFreq: parseFloat(this.d.maxFreqSelect?.value || "10000"),
				spectrogramMels: this._spectro.nMels,
				scale: this.d.scaleSelect?.value || "mel",
				frameRate: 100,
				hopSize: this._spectro.hopSize || 0,
				freqRange: extCfg?.freqRange || null,
				freqScale: extCfg?.freqScale || null,
				freqViewMin: this._freqView.min,
				freqViewMax: this._freqView.max
			});
			this._viewport?.updateCoords(this.coords);
		}
		_startViewResize(mode, clientY) {
			if (!this.interaction.enter({
				split: "view-resize-split",
				spectrogram: "view-resize-spectrogram"
			}[mode])) return;
			this.interaction.ctx.resizeStartY = clientY;
			this.interaction.ctx.resizeStartWaveformH = this.waveformDisplayHeight;
			this.interaction.ctx.resizeStartSpectrogramH = this.spectrogramDisplayHeight;
			document.body.style.cursor = "row-resize";
		}
		_updateViewResize(clientY) {
			const sub = this.interaction.viewResizeSubMode;
			if (!sub) return;
			if (sub === "split" && (!this._showWaveform || !this._showSpectrogram) || sub === "spectrogram" && !this._showSpectrogram) return;
			const ctx = this.interaction.ctx;
			const dy = clientY - (ctx.resizeStartY ?? 0);
			let redrawWav = false;
			if (sub === "split") {
				const total = (ctx.resizeStartWaveformH ?? 0) + (ctx.resizeStartSpectrogramH ?? 0);
				let nextWav = (ctx.resizeStartWaveformH ?? 0) + dy;
				nextWav = clamp(nextWav, 64, total - 140);
				this.waveformDisplayHeight = nextWav;
				this.spectrogramDisplayHeight = total - nextWav;
				redrawWav = true;
			} else this.spectrogramDisplayHeight = Math.max(140, (ctx.resizeStartSpectrogramH ?? 0) + dy);
			this._applyLocalViewHeights();
			if (redrawWav) this._updateAmplitudeLabels();
			if (!this.audioBuffer) return;
			this._queueResizeRedraw({
				redrawWaveform: redrawWav,
				redrawSpectrogram: this._spectro.hasData
			});
		}
		_stopViewResize() {
			if (!this.interaction.isViewResize) return;
			this._flushResizeRedraw(true);
			this.interaction.release();
			document.body.style.cursor = "";
		}
		_queueResizeRedraw({ redrawWaveform = false, redrawSpectrogram = false } = {}) {
			this._viewResizeNeedsWaveformRedraw = this._viewResizeNeedsWaveformRedraw || redrawWaveform;
			this._viewResizeNeedsSpectrogramRedraw = this._viewResizeNeedsSpectrogramRedraw || redrawSpectrogram;
			if (this._viewResizeFrameId) return;
			this._viewResizeFrameId = requestAnimationFrame(() => this._flushResizeRedraw(false));
		}
		_flushResizeRedraw(force) {
			if (!this.audioBuffer) return;
			if (this._viewResizeFrameId) {
				cancelAnimationFrame(this._viewResizeFrameId);
				this._viewResizeFrameId = 0;
			}
			this._invalidateSpectrogramHeightCache();
			const redrawWaveform = force || this._viewResizeNeedsWaveformRedraw;
			const redrawSpectrogram = force || this._viewResizeNeedsSpectrogramRedraw;
			this._viewResizeNeedsWaveformRedraw = false;
			this._viewResizeNeedsSpectrogramRedraw = false;
			const savedScroll = this._getPrimaryScrollLeft();
			if (redrawWaveform) this._drawMainWaveform();
			if (redrawSpectrogram) this._drawSpectrogram();
			this._setLinkedScrollLeft(savedScroll);
			this._emit("viewresize", {
				waveformHeight: this.waveformDisplayHeight,
				spectrogramHeight: this.spectrogramDisplayHeight
			});
		}
		_setPlayState(text) {
			this.d.playStateDisplay.textContent = text;
		}
		_shouldCompactToolbarBeActive() {
			if (this._transportOverlay) return false;
			if (this._compactToolbarMode === "off") return false;
			if (this._compactToolbarMode === "on") return true;
			const root = this.d.toolbarRoot;
			if (!root) return false;
			const hadActive = this.container.classList.contains("compact-toolbar-active");
			const hadOpen = this.container.classList.contains("compact-toolbar-open");
			if (hadActive) this.container.classList.remove("compact-toolbar-active");
			if (hadOpen) this.container.classList.remove("compact-toolbar-open");
			const needsCompact = root.scrollWidth > root.clientWidth + 4;
			if (hadActive) this.container.classList.add("compact-toolbar-active");
			if (hadOpen) this.container.classList.add("compact-toolbar-open");
			return needsCompact;
		}
		_isCompactToolbarActive() {
			return this.container.classList.contains("compact-toolbar-active");
		}
		_queueCompactToolbarLayoutRefresh() {
			if (this._compactToolbarLayoutRaf) return;
			this._compactToolbarLayoutRaf = requestAnimationFrame(() => {
				this._compactToolbarLayoutRaf = 0;
				this._refreshCompactToolbarLayout();
			});
		}
		_refreshCompactToolbarLayout() {
			const active = this._shouldCompactToolbarBeActive();
			this.container.classList.toggle("compact-toolbar-active", active);
			if (!active && this._compactToolbarOpen) this._setCompactToolbarOpen(false);
			if (this.d.compactMoreBtn) {
				this.d.compactMoreBtn.disabled = !active;
				this.d.compactMoreBtn.setAttribute("aria-hidden", active ? "false" : "true");
			}
		}
		_setCompactToolbarOpen(nextOpen) {
			const open = this._isCompactToolbarActive() && !!nextOpen;
			this._compactToolbarOpen = open;
			this.container.classList.toggle("compact-toolbar-open", open);
			if (this.d.compactMoreBtn) this.d.compactMoreBtn.setAttribute("aria-expanded", open ? "true" : "false");
		}
		_toggleSettingsPanel() {
			this._setSettingsPanelOpen(!this._settingsPanelOpen);
		}
		_setSettingsPanelOpen(open) {
			this._settingsPanelOpen = !!open;
			this.container.classList.toggle("settings-panel-open", this._settingsPanelOpen);
			if (this.d.settingsToggleBtn) {
				this.d.settingsToggleBtn.classList.toggle("active", this._settingsPanelOpen);
				this.d.settingsToggleBtn.setAttribute("aria-expanded", this._settingsPanelOpen ? "true" : "false");
			}
		}
		_setTransportEnabled(enabled) {
			[
				this.d.playPauseBtn,
				this.d.stopBtn,
				this.d.jumpStartBtn,
				this.d.jumpEndBtn,
				this.d.backwardBtn,
				this.d.forwardBtn,
				this.d.followToggleBtn,
				this.d.loopToggleBtn,
				this.d.crosshairToggleBtn,
				this.d.fitViewBtn,
				this.d.resetViewBtn,
				this.d.autoContrastBtn,
				this.d.autoFreqBtn
			].forEach((btn) => {
				btn.disabled = !enabled;
			});
			this._queueCompactToolbarLayoutRefresh();
		}
		_updateToggleButtons() {
			this.followPlayback = this.followMode !== "free";
			if (this.d.followToggleBtn) {
				this.d.followToggleBtn.classList.toggle("active", this.followPlayback);
				this.d.followToggleBtn.textContent = this.followMode === "smooth" ? "Smooth" : this.followPlayback ? "Follow" : "Free";
				this.d.followToggleBtn.title = this.followMode === "smooth" ? "Smooth follow (continuous)" : this.followPlayback ? "Follow playhead" : "Free navigation";
			}
			if (this.d.loopToggleBtn) {
				this.d.loopToggleBtn.classList.toggle("active", this.loopPlayback);
				this.d.loopToggleBtn.textContent = this.loopPlayback ? "Loop On" : "Loop";
			}
			this._queueCompactToolbarLayoutRefresh();
		}
		_cycleFollowMode() {
			this.followMode = this.followMode === "free" ? "follow" : this.followMode === "follow" ? "smooth" : "free";
			if (this.followMode !== "follow") this._cancelFollowCatchupAnimation();
			this._updateToggleButtons();
			this._emit("followmodechange", { mode: this.followMode });
		}
		_toggleCrosshair() {
			this._crosshairEnabled = !this._crosshairEnabled;
			if (this.d.crosshairToggleBtn) this.d.crosshairToggleBtn.classList.toggle("active", this._crosshairEnabled);
			if (!this._crosshairEnabled) this._hideCrosshair();
		}
		/** @param {MouseEvent|PointerEvent} e */
		_updateCrosshair(e) {
			if (!this._crosshairEnabled || !this.audioBuffer || !this._spectro.data) return;
			const wrapper = this.d.canvasWrapper;
			const overlay = this.d.crosshairCanvas;
			const readout = this.d.crosshairReadout;
			if (!wrapper || !overlay || !readout) return;
			const rect = wrapper.getBoundingClientRect();
			const c = this.coords;
			const { time, freq, canvasX, canvasY, localX, localY } = c.clientToTimeFreq(e.clientX, e.clientY, rect, wrapper.scrollLeft);
			if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) {
				this._hideCrosshair();
				return;
			}
			const frame = c.timeToFrame(time, this._spectro.nFrames);
			const bin = c.pixelYToBin(canvasY);
			const amplitude = this._spectro.data[frame * this._spectro.nMels + bin] || 0;
			if (this._crosshairRafId) cancelAnimationFrame(this._crosshairRafId);
			this._crosshairRafId = requestAnimationFrame(() => {
				this._crosshairRafId = 0;
				const vw = wrapper.clientWidth || c.canvasWidth;
				this._drawCrosshairLines(overlay, localX, canvasY, vw, c.canvasHeight);
			});
			readout.textContent = `${time.toFixed(3) + " s"}  |  ${freq >= 1e3 ? (freq / 1e3).toFixed(2) + " kHz" : Math.round(freq) + " Hz"}  |  ${(this.d.scaleSelect?.value || "mel") === "linear" ? amplitude.toFixed(1) + " dB" : amplitude.toFixed(4)}`;
			readout.classList.add("visible");
			const rw = readout.offsetWidth || 160;
			const rh = readout.offsetHeight || 20;
			let rx = localX + 14;
			let ry = localY - rh - 8;
			if (rx + rw > rect.width) rx = localX - rw - 10;
			if (ry < 0) ry = localY + 18;
			readout.style.left = wrapper.scrollLeft + rx + "px";
			readout.style.top = ry + "px";
		}
		/**
		* @param {HTMLCanvasElement} overlay
		* @param {number} cx - x in canvas coords
		* @param {number} cy - y in canvas coords
		* @param {number} w
		* @param {number} h
		*/
		_drawCrosshairLines(overlay, cx, cy, w, h) {
			if (overlay.width !== w || overlay.height !== h) {
				overlay.width = w;
				overlay.height = h;
			}
			const ctx = overlay.getContext("2d");
			if (!ctx) return;
			ctx.clearRect(0, 0, w, h);
			ctx.strokeStyle = "rgba(255, 255, 255, 0.55)";
			ctx.lineWidth = 1;
			ctx.setLineDash([4, 4]);
			const x = Math.round(cx) + .5;
			ctx.beginPath();
			ctx.moveTo(x, 0);
			ctx.lineTo(x, h);
			ctx.stroke();
			const y = Math.round(cy) + .5;
			ctx.beginPath();
			ctx.moveTo(0, y);
			ctx.lineTo(w, y);
			ctx.stroke();
			ctx.setLineDash([]);
		}
		_hideCrosshair() {
			const overlay = this.d.crosshairCanvas;
			const readout = this.d.crosshairReadout;
			if (overlay) {
				const ctx = overlay.getContext("2d");
				if (ctx) ctx.clearRect(0, 0, overlay.width, overlay.height);
			}
			if (readout) readout.classList.remove("visible");
			if (this._crosshairRafId) {
				cancelAnimationFrame(this._crosshairRafId);
				this._crosshairRafId = 0;
			}
		}
		_cancelFollowCatchupAnimation() {
			this._viewport._cancelFollowCatchupAnimation();
		}
		_animateFollowCatchupTo(targetScrollLeft) {
			this._viewport._animateFollowCatchupTo(targetScrollLeft);
		}
		_applySmoothFollow(position, viewportWidth) {
			this._viewport._applySmoothFollow(position, viewportWidth);
		}
		_setInitialPlayheadPositions() {
			if (this.d.playhead) {
				this.d.playhead.style.left = "0px";
				this.d.playhead.style.transform = "translateX(0px)";
			}
			if (this.d.waveformPlayhead) {
				this.d.waveformPlayhead.style.left = "0px";
				this.d.waveformPlayhead.style.transform = "translateX(0px)";
			}
		}
		_handleKeyboardShortcuts(event) {
			if (event.key === "Escape" && this._compactToolbarOpen) {
				this._setCompactToolbarOpen(false);
				return;
			}
			if (!this.audioBuffer || isTypingContext(event.target)) return;
			switch (event.code) {
				case "Space":
					event.preventDefault();
					this._togglePlayPause();
					break;
				case "Home":
					event.preventDefault();
					this._seekToTime(0, true);
					break;
				case "End":
					event.preventDefault();
					this._seekToTime(this.audioBuffer.duration, true);
					break;
				case "KeyJ":
					event.preventDefault();
					this._seekByDelta(-5);
					break;
				case "KeyL":
					event.preventDefault();
					this._seekByDelta(5);
					break;
				case "ArrowLeft":
					event.preventDefault();
					this._seekByDelta(-SEEK_FINE_SEC);
					break;
				case "ArrowRight":
					event.preventDefault();
					this._seekByDelta(SEEK_FINE_SEC);
					break;
			}
		}
		_bindEvents() {
			const on = (target, type, fn, opts = void 0) => {
				target?.addEventListener(type, fn, opts);
				this._cleanups.push(() => target?.removeEventListener(type, fn, opts));
			};
			new TransportController(this.d, this).bind(on);
			new FreqViewportController(this.d, this).bind(on);
			new SettingsPanelController(this.d, this).bind(on);
			new VolumeController(this.d, this).bind(on);
			new DisplayGainController(this.d, this).bind(on);
			new CanvasInteractionController(this.d, this).bind(on);
			new PlayheadController(this.d, this).bind(on);
			new DocumentEventsController(this).bind(on);
			new OverviewController(this.d, this).bind(on);
			new WindowEventsController(this).bind(on);
		}
		_bindTouchGestures() {
			const bindRecognizer = (element, source) => {
				if (!element) return;
				const rec = new GestureRecognizer(element);
				const offSwipe = rec.on("swipe", ({ dx }) => {
					if (!this.audioBuffer) return;
					this._seekByDelta(dx / Math.max(1, this.pixelsPerSecond));
				});
				const offPinch = rec.on("pinch", ({ scale, centerX }) => {
					if (!this.audioBuffer) return;
					const clampedScale = clamp(scale, .85, 1.15);
					this._zoomByScale(clampedScale, centerX, source);
				});
				const offDoubleTap = rec.on("doubletap", () => {
					if (!this.audioBuffer) return;
					this._fitEntireTrackInView();
				});
				this._cleanups.push(() => {
					offSwipe();
					offPinch();
					offDoubleTap();
					rec.dispose();
				});
			};
			bindRecognizer(this.d.waveformWrapper, "waveform");
			bindRecognizer(this.d.canvasWrapper, "spectrogram");
		}
	};
	//#endregion
	//#region src/ui/components/modal/modal-manager.ts
	/**
	* ModalManager — backdrop + focus trap + keyboard handling for dialogs.
	*
	* Close lifecycle
	* ───────────────
	* External code should call `modal.close()` for programmatic close (e.g. a
	* "Save" button that dismisses and processes data).
	*
	* For Escape / backdrop-click / injected ×-button the manager first invokes
	* `options.onClose` if provided, giving the host a chance to run its own
	* cleanup (e.g. remove the backdrop from the DOM, dispose).  If no `onClose`
	* is supplied the manager falls back to calling `this.close()` itself.
	*
	* This means the host's `close` function can safely call `modal.close()` as
	* part of cleanup without creating a circular call: `ModalManager.close()`
	* only hides + unbinds, it never calls `onClose`.
	*/
	/**
	* @typedef {Object} ModalManagerOptions
	* @property {HTMLElement|null|undefined} [backdrop]
	* @property {HTMLElement|null|undefined} [dialog]
	* @property {(() => void)|null|undefined} [onClose]
	*/
	var ModalManager = class {
		/**
		* @param {ModalManagerOptions} [opts]
		*/
		constructor({ backdrop = null, dialog = null, onClose = null } = {}) {
			/** @type {HTMLElement|null} */
			this.backdrop = backdrop || null;
			/** @type {HTMLElement|null} */
			const candidateDialog = dialog ?? (backdrop ? backdrop.querySelector(".modal, .xc-nokey-dialog, [role=\"dialog\"], dialog") : null);
			this.dialog = candidateDialog;
			/** @type {(() => void)|null} Called instead of close() for user-initiated dismiss (Escape, backdrop, ×) */
			this._onClose = onClose || null;
			this._onKeydown = this._onKeydown.bind(this);
			this._onBackdropClick = this._onBackdropClick.bind(this);
			this._onCloseClick = this._onCloseClick.bind(this);
			this._lastActive = null;
			this._closeBtn = null;
		}
		open() {
			this._lastActive = document.activeElement;
			if (this.backdrop) {
				try {
					this.backdrop.hidden = false;
				} catch (e) {}
				this.backdrop.classList.add("show");
				this.backdrop.setAttribute("aria-hidden", "false");
			}
			if (this.dialog) {
				if (!this.dialog.getAttribute("role")) this.dialog.setAttribute("role", "dialog");
				this.dialog.setAttribute("aria-modal", "true");
				const title = this.dialog.querySelector(".modal-title, h1, h2, h3, .title");
				if (title && !title.id) title.id = this._generateId("modal-title");
				if (title) this.dialog.setAttribute("aria-labelledby", title.id);
				let btn = this.dialog.querySelector(".modal-close");
				if (!btn) {
					const newBtn = document.createElement("button");
					newBtn.type = "button";
					newBtn.className = "modal-close modal-close--injected";
					newBtn.setAttribute("aria-label", "Close");
					newBtn.textContent = "×";
					newBtn.dataset.modalManaged = "1";
					this.dialog.appendChild(newBtn);
					this._closeBtn = newBtn;
					newBtn.addEventListener("click", this._onCloseClick);
				} else {
					this._closeBtn = btn;
					if (!btn.dataset.modalHandlerBound) {
						btn.addEventListener("click", this._onCloseClick);
						btn.dataset.modalHandlerBound = "1";
					}
				}
			}
			this._bind();
			setTimeout(() => {
				const focusTarget = this._getFocusable()[0] || this.dialog || this.backdrop;
				try {
					if (focusTarget instanceof HTMLElement) focusTarget.focus();
				} catch (e) {}
			}, 0);
		}
		/** Hide + unbind. Does NOT call onClose. */
		close() {
			if (this.backdrop) {
				this.backdrop.classList.remove("show");
				this.backdrop.setAttribute("aria-hidden", "true");
				try {
					this.backdrop.hidden = true;
				} catch (e) {}
			}
			this._unbind();
			setTimeout(() => {
				try {
					if (this._lastActive instanceof HTMLElement) this._lastActive.focus();
				} catch (e) {}
			}, 0);
		}
		dispose() {
			this._unbind();
			if (this._closeBtn) try {
				if (this._closeBtn.dataset.modalManaged === "1") {
					this._closeBtn.removeEventListener("click", this._onCloseClick);
					this._closeBtn.parentNode && this._closeBtn.parentNode.removeChild(this._closeBtn);
				} else this._closeBtn.removeEventListener("click", this._onCloseClick);
			} catch (e) {}
			this._closeBtn = null;
			this.backdrop = null;
			this.dialog = null;
		}
		/**
		* Central dismiss handler: called by Escape, backdrop click, and the ×
		* button.  Delegates to `onClose` if supplied, otherwise closes directly.
		*/
		_handleCloseRequest() {
			if (this._onClose) this._onClose();
			else this.close();
		}
		_bind() {
			document.addEventListener("keydown", this._onKeydown);
			this.backdrop?.addEventListener("pointerdown", this._onBackdropClick);
		}
		_unbind() {
			document.removeEventListener("keydown", this._onKeydown);
			this.backdrop?.removeEventListener("pointerdown", this._onBackdropClick);
		}
		_onKeydown(e) {
			if (e.key === "Escape" && this.backdrop?.classList.contains("show")) {
				e.preventDefault();
				this._handleCloseRequest();
				return;
			}
			if (e.key === "Tab" && this.backdrop?.classList.contains("show")) this._trapTab(e);
		}
		_onBackdropClick(e) {
			if (e.target === this.backdrop) this._handleCloseRequest();
		}
		_onCloseClick() {
			this._handleCloseRequest();
		}
		_getFocusable() {
			const nodes = (this.dialog || this.backdrop || document.body).querySelectorAll("a[href], area[href], input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex=\"-1\"]), [contenteditable]");
			return Array.prototype.slice.call(nodes).filter((n) => n.offsetWidth || n.offsetHeight || n.getClientRects().length);
		}
		_trapTab(e) {
			const focusable = this._getFocusable();
			if (!focusable.length) {
				e.preventDefault();
				return;
			}
			const first = focusable[0];
			const last = focusable[focusable.length - 1];
			if (e.shiftKey) {
				if (document.activeElement === first) {
					e.preventDefault();
					last.focus();
				}
			} else if (document.activeElement === last) {
				e.preventDefault();
				first.focus();
			}
		}
		_generateId(prefix = "id") {
			return `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
		}
	};
	//#endregion
	//#region src/ui/components/editable-select/editable-select.ts
	/**
	* Editable select component — a modern combobox dropdown with search,
	* preset options, custom (user-added) options, and add/edit/delete for custom entries.
	*/
	function esc(s) {
		return String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
	}
	function createEditableSelect(opts) {
		let currentValue = opts?.value || "";
		let items = (opts?.items || []).slice();
		let activeIndex = -1;
		let rows = [];
		let open = false;
		let editingValue = null;
		const root = document.createElement("div");
		root.className = "esel" + (opts.className ? " " + opts.className : "");
		const trigger = document.createElement("button");
		trigger.type = "button";
		trigger.className = "esel-trigger";
		updateTriggerText();
		root.appendChild(trigger);
		const dropdown = document.createElement("div");
		dropdown.className = "esel-dropdown hidden";
		const searchInput = document.createElement("input");
		searchInput.type = "text";
		searchInput.className = "esel-search";
		searchInput.placeholder = "Search / add…";
		searchInput.autocomplete = "off";
		searchInput.spellcheck = false;
		const listEl = document.createElement("div");
		listEl.className = "esel-list";
		dropdown.appendChild(searchInput);
		dropdown.appendChild(listEl);
		function updateTriggerText() {
			trigger.textContent = currentValue || opts.placeholder || "–";
			trigger.classList.toggle("esel-has-value", !!currentValue);
		}
		function ensurePortal() {
			if (!dropdown.parentElement) document.body.appendChild(dropdown);
		}
		function show() {
			if (open) return;
			open = true;
			searchInput.value = "";
			editingValue = null;
			ensurePortal();
			dropdown.classList.remove("hidden");
			renderList();
			positionDropdown();
			searchInput.focus();
		}
		function hide() {
			if (!open) return;
			open = false;
			dropdown.classList.add("hidden");
			editingValue = null;
			activeIndex = -1;
		}
		function positionDropdown() {
			const rect = trigger.getBoundingClientRect();
			const spaceBelow = window.innerHeight - rect.bottom;
			const spaceAbove = rect.top;
			dropdown.style.position = "fixed";
			dropdown.style.left = rect.left + "px";
			dropdown.style.minWidth = rect.width + "px";
			if (spaceBelow < 180 && spaceAbove > spaceBelow) {
				dropdown.style.bottom = window.innerHeight - rect.top + "px";
				dropdown.style.top = "auto";
			} else {
				dropdown.style.top = rect.bottom + "px";
				dropdown.style.bottom = "auto";
			}
		}
		function renderList() {
			const query = searchInput.value.trim().toLowerCase();
			listEl.innerHTML = "";
			rows = [];
			activeIndex = -1;
			const filtered = query ? items.filter((it) => String(it.value).toLowerCase().includes(query)) : items;
			if (currentValue) {
				const clearRow = makeRow("–", false, true);
				clearRow.addEventListener("click", (e) => {
					e.stopPropagation();
					selectValue("");
				});
				listEl.appendChild(clearRow);
				rows.push(clearRow);
			}
			for (const it of filtered) {
				if (editingValue != null && it.value === editingValue && it.custom) {
					const row = document.createElement("div");
					row.className = "esel-row esel-editing";
					const editInput = document.createElement("input");
					editInput.type = "text";
					editInput.className = "esel-edit-input";
					editInput.value = it.value;
					editInput.addEventListener("keydown", (e) => {
						if (e.key === "Enter") {
							e.preventDefault();
							finishEdit(it.value, editInput.value);
						} else if (e.key === "Escape") {
							e.preventDefault();
							editingValue = null;
							renderList();
							searchInput.focus();
						}
						e.stopPropagation();
					});
					editInput.addEventListener("click", (e) => e.stopPropagation());
					row.appendChild(editInput);
					listEl.appendChild(row);
					rows.push(row);
					requestAnimationFrame(() => {
						editInput.focus();
						editInput.select();
					});
					continue;
				}
				const row = makeRow(it.value, it.custom, it.value === currentValue);
				row.addEventListener("click", (e) => {
					e.stopPropagation();
					selectValue(it.value);
				});
				row.addEventListener("pointerenter", () => {
					activeIndex = rows.indexOf(row);
					highlightActive();
				});
				listEl.appendChild(row);
				rows.push(row);
			}
			if (query && !items.some((it) => it.value.toLowerCase() === query)) {
				const addRow = document.createElement("div");
				addRow.className = "esel-row esel-add-row";
				addRow.innerHTML = `<span class="esel-add-label">+ ${esc(searchInput.value.trim())}</span>`;
				addRow.addEventListener("click", (e) => {
					e.stopPropagation();
					addCustom(searchInput.value.trim());
				});
				addRow.addEventListener("pointerenter", () => {
					activeIndex = rows.indexOf(addRow);
					highlightActive();
				});
				listEl.appendChild(addRow);
				rows.push(addRow);
			}
		}
		function makeRow(value, isCustom, isSelected) {
			const row = document.createElement("div");
			row.className = "esel-row" + (isSelected ? " esel-selected" : "");
			const label = document.createElement("span");
			label.className = "esel-label";
			label.textContent = String(value);
			row.appendChild(label);
			if (isCustom) {
				const actions = document.createElement("span");
				actions.className = "esel-actions";
				const editBtn = document.createElement("button");
				editBtn.type = "button";
				editBtn.className = "esel-act-btn";
				editBtn.title = "Rename";
				editBtn.textContent = "✎";
				editBtn.addEventListener("click", (e) => {
					e.stopPropagation();
					editingValue = value;
					renderList();
				});
				const delBtn = document.createElement("button");
				delBtn.type = "button";
				delBtn.className = "esel-act-btn esel-del";
				delBtn.title = "Remove";
				delBtn.textContent = "×";
				delBtn.addEventListener("click", (e) => {
					e.stopPropagation();
					removeCustom(value);
				});
				actions.append(editBtn, delBtn);
				row.appendChild(actions);
			}
			return row;
		}
		function highlightActive() {
			for (let i = 0; i < rows.length; i++) rows[i].classList.toggle("esel-active", i === activeIndex);
			if (activeIndex >= 0 && rows[activeIndex]) rows[activeIndex].scrollIntoView({ block: "nearest" });
		}
		function selectValue(val) {
			currentValue = val;
			updateTriggerText();
			hide();
			opts.onChange?.(val);
		}
		function addCustom(val) {
			const trimmed = String(val || "").trim();
			if (!trimmed) return;
			opts.onAdd?.(trimmed);
			items.push({
				value: trimmed,
				custom: true
			});
			selectValue(trimmed);
		}
		function removeCustom(val) {
			opts.onRemove?.(val);
			items = items.filter((it) => !(it.custom && it.value === val));
			if (currentValue === val) {
				currentValue = "";
				updateTriggerText();
				opts.onChange?.("");
			}
			renderList();
		}
		function finishEdit(oldVal, newVal) {
			const trimmed = String(newVal || "").trim();
			editingValue = null;
			if (!trimmed || trimmed === oldVal) {
				renderList();
				searchInput.focus();
				return;
			}
			opts.onRename?.(oldVal, trimmed);
			for (const it of items) if (it.custom && it.value === oldVal) it.value = trimmed;
			if (currentValue === oldVal) {
				currentValue = trimmed;
				updateTriggerText();
				opts.onChange?.(trimmed);
			}
			renderList();
			searchInput.focus();
		}
		let destroyed = false;
		trigger.addEventListener("click", (e) => {
			e.stopPropagation();
			if (open) hide();
			else show();
		});
		searchInput.addEventListener("input", () => renderList());
		searchInput.addEventListener("keydown", (e) => {
			if (e.key === "ArrowDown") {
				e.preventDefault();
				if (rows.length) {
					activeIndex = (activeIndex + 1) % rows.length;
					highlightActive();
				}
			} else if (e.key === "ArrowUp") {
				e.preventDefault();
				if (rows.length) {
					activeIndex = activeIndex <= 0 ? rows.length - 1 : activeIndex - 1;
					highlightActive();
				}
			} else if (e.key === "Enter") {
				e.preventDefault();
				if (activeIndex >= 0 && rows[activeIndex]) rows[activeIndex].click();
				else if (searchInput.value.trim()) {
					const lq = searchInput.value.trim().toLowerCase();
					const match = items.find((it) => it.value.toLowerCase() === lq);
					if (match) selectValue(match.value);
					else addCustom(searchInput.value.trim());
				}
			} else if (e.key === "Escape") {
				e.preventDefault();
				hide();
				trigger.focus();
			}
		});
		function onPointerDown(e) {
			if (destroyed) return;
			const target = e.target;
			if (open && !root.contains(target) && !dropdown.contains(target)) hide();
		}
		document.addEventListener("pointerdown", onPointerDown);
		return {
			el: root,
			getValue() {
				return currentValue;
			},
			setValue(val) {
				currentValue = val || "";
				updateTriggerText();
			},
			setItems(newItems) {
				items = (newItems || []).slice();
				if (open) renderList();
			},
			destroy() {
				destroyed = true;
				hide();
				document.removeEventListener("pointerdown", onPointerDown);
				if (dropdown.parentElement) dropdown.remove();
				root.remove();
			}
		};
	}
	//#endregion
	//#region src/domain/annotations/LabelEditorModal.ts
	var DEFAULT_TAG_PRESETS = [
		{
			key: "sex",
			label: "Sex",
			options: [
				"male",
				"female",
				"unknown"
			]
		},
		{
			key: "lifeStage",
			label: "Life stage",
			options: [
				"adult",
				"juvenile",
				"immature",
				"subadult"
			]
		},
		{
			key: "soundType",
			label: "Sound type",
			options: [
				"song",
				"call",
				"alarm call",
				"flight call",
				"begging call",
				"drumming",
				"nocturnal flight call"
			]
		}
	];
	var LabelEditorModal = class {
		/**
		* @param {object} opts
		* @param {object}   opts.player          - BirdNETPlayer instance
		* @param {Element | null} [opts.anchorEl] - element to position the panel near (unused, reserved)
		* @param {string | null} [opts.initialValue]
		* @param {string | null} [opts.initialColor]
		* @param {object | null} [opts.initialTags]
		* @param {Array | null}  [opts.existingLabels]
		* @param {string | null} [opts.initialScientificName]
		* @param {string | null} [opts.title]
		* @param {Function} opts.onSubmit
		* @param {Function | null} [opts.onDelete]
		* @param {((event: string, detail: object) => void) | null} [opts.onLayerEmit]
		*   Optional emit callback for layer-originating events (tagcustom*).
		*   When omitted, these events are silently dropped (no player dependency needed).
		*/
		constructor({ player, anchorEl, initialValue, initialColor, initialTags, existingLabels, initialScientificName, title, onSubmit, onDelete, onLayerEmit }) {
			this._player = player;
			this._anchorEl = anchorEl;
			this._onSubmit = onSubmit;
			this._onDelete = onDelete;
			this._title = title;
			this._existingLabels = existingLabels;
			this._onLayerEmit = onLayerEmit ?? null;
			const initialStyle = getOverlayColorStyle(initialColor);
			this._initialValueTrim = String(initialValue || "").trim();
			this._initialStyleHex = initialStyle?.hex || "";
			this._initialTagsNorm = initialTags && typeof initialTags === "object" ? { ...initialTags } : {};
			this._initialScientific = String(initialScientificName || "").trim();
			this._currentTags = { ...initialTags || {} };
			this._selectedSci = this._initialScientific;
			this._colorTouched = false;
			this._tagsTouched = false;
			this._sciTouched = false;
			this._activeIndex = -1;
			/** @type {HTMLElement[]} */
			this._resultItems = [];
			this._esInstances = [];
			this._modal = null;
			this._backdrop = null;
			this._panel = null;
			this._input = null;
			this._colorInput = null;
			this._tagsRow = null;
			this._results = null;
			this._confirmBtn = null;
			this._tagPresets = player?.getTagPresets?.() || DEFAULT_TAG_PRESETS;
			this._initialColor = initialColor;
			this._initialValue = initialValue;
		}
		open() {
			const host = this._player?.root || this._player?.container || document.body;
			if (!host || typeof this._onSubmit !== "function") return;
			this._buildDOM();
			host.appendChild(this._backdrop);
			this._renderTags();
			this._renderResults();
			const _mm_opts = {
				backdrop: this._backdrop,
				dialog: this._panel,
				onClose: () => this.close()
			};
			this._modal = new ModalManager(_mm_opts);
			this._modal.open();
			setTimeout(() => {
				this._input.focus();
				this._input.select();
			}, 0);
		}
		close() {
			for (const es of this._esInstances) try {
				es.destroy();
			} catch {}
			this._esInstances = [];
			try {
				this._modal?.close();
			} catch {}
			if (this._backdrop?.parentNode) this._backdrop.parentNode.removeChild(this._backdrop);
			try {
				this._modal?.dispose();
			} catch {}
			this._modal = null;
		}
		_buildDOM() {
			const initialStyle = getOverlayColorStyle(this._initialColor);
			this._backdrop = document.createElement("div");
			this._backdrop.className = "label-editor-backdrop";
			this._panel = document.createElement("div");
			this._panel.className = "label-name-editor";
			const header = document.createElement("div");
			header.className = "label-editor-header";
			const titleEl = document.createElement("span");
			titleEl.className = "label-editor-title";
			titleEl.textContent = this._title || "Edit label";
			const xBtn = document.createElement("button");
			xBtn.type = "button";
			xBtn.className = "modal-close";
			xBtn.setAttribute("aria-label", "Close");
			xBtn.textContent = "×";
			xBtn.dataset.modalHandlerBound = "1";
			xBtn.addEventListener("click", () => this.close());
			header.append(titleEl, xBtn);
			const searchRow = document.createElement("div");
			searchRow.className = "label-search-row";
			this._input = document.createElement("input");
			this._input.type = "text";
			this._input.maxLength = 96;
			this._input.className = "label-search-input";
			this._input.placeholder = this._title || "Search species or label…";
			this._input.value = this._initialValueTrim;
			this._colorInput = document.createElement("input");
			this._colorInput.type = "color";
			this._colorInput.className = "label-search-color";
			this._colorInput.value = initialStyle?.hex || "#0ea5e9";
			this._colorInput.addEventListener("input", () => {
				this._colorTouched = true;
			});
			searchRow.append(this._input, this._colorInput);
			this._tagsRow = document.createElement("div");
			this._tagsRow.className = "label-tags-row";
			this._results = document.createElement("div");
			this._results.className = "label-search-results";
			const footer = document.createElement("div");
			footer.className = "label-search-footer";
			const hints = document.createElement("span");
			hints.className = "label-search-hints";
			hints.textContent = "↑↓ navigate · click select · dblclick confirm · esc close";
			footer.appendChild(hints);
			if (this._onDelete) {
				const delBtn = document.createElement("button");
				delBtn.type = "button";
				delBtn.className = "label-search-delete";
				delBtn.textContent = "Delete";
				delBtn.addEventListener("click", () => {
					this.close();
					this._onDelete?.();
				});
				footer.appendChild(delBtn);
			}
			this._confirmBtn = document.createElement("button");
			this._confirmBtn.type = "button";
			this._confirmBtn.className = "label-search-confirm";
			this._confirmBtn.textContent = "Save";
			this._confirmBtn.addEventListener("click", () => this._submit(this._input.value));
			footer.appendChild(this._confirmBtn);
			this._panel.append(header, searchRow, this._tagsRow, this._results, footer);
			this._backdrop.appendChild(this._panel);
			this._input.addEventListener("input", () => {
				this._selectedSci = "";
				this._sciTouched = true;
				this._renderResults();
			});
			this._input.addEventListener("keydown", (e) => this._handleKeydown(e));
		}
		_renderTags() {
			this._tagsRow.innerHTML = "";
			for (const es of this._esInstances) try {
				es.destroy();
			} catch {}
			this._esInstances = [];
			for (const preset of this._tagPresets) this._tagsRow.appendChild(this._makeTagCombobox(preset));
			const customKeys = Object.keys(this._currentTags).filter((k) => !this._tagPresets.some((p) => p.key === k));
			for (const key of customKeys) {
				const badge = document.createElement("span");
				badge.className = "label-tag-badge";
				badge.textContent = `${key}: ${this._currentTags[key]}`;
				const delBtn = document.createElement("span");
				delBtn.className = "label-tag-badge-del";
				delBtn.textContent = "×";
				delBtn.addEventListener("click", () => {
					delete this._currentTags[key];
					this._renderTags();
				});
				badge.appendChild(delBtn);
				this._tagsRow.appendChild(badge);
			}
			const addBtn = document.createElement("button");
			addBtn.type = "button";
			addBtn.className = "label-tag-add";
			addBtn.textContent = "+ Tag";
			addBtn.addEventListener("click", () => {
				addBtn.hidden = true;
				const form = document.createElement("span");
				form.className = "label-tag-inline-form";
				const keyInput = document.createElement("input");
				keyInput.type = "text";
				keyInput.placeholder = "key";
				keyInput.className = "label-tag-inline-input";
				const valInput = document.createElement("input");
				valInput.type = "text";
				valInput.placeholder = "value";
				valInput.className = "label-tag-inline-input";
				const okBtn = document.createElement("button");
				okBtn.type = "button";
				okBtn.textContent = "✓";
				okBtn.className = "label-tag-inline-confirm";
				const noBtn = document.createElement("button");
				noBtn.type = "button";
				noBtn.textContent = "✕";
				noBtn.className = "label-tag-inline-cancel";
				const commit = () => {
					const k = keyInput.value.trim();
					const v = valInput.value.trim();
					if (k) this._currentTags[k] = v;
					this._renderTags();
				};
				okBtn.addEventListener("click", commit);
				noBtn.addEventListener("click", () => this._renderTags());
				valInput.addEventListener("keydown", (e) => {
					if (e.key === "Enter") {
						e.preventDefault();
						commit();
					}
					if (e.key === "Escape") this._renderTags();
				});
				form.append(keyInput, valInput, okBtn, noBtn);
				this._tagsRow.appendChild(form);
				keyInput.focus();
			});
			this._tagsRow.appendChild(addBtn);
		}
		_makeTagCombobox(preset) {
			const presetDef = this._tagPresets.find((p) => p.key === preset.key) || preset;
			const defaultDef = DEFAULT_TAG_PRESETS.find((p) => p.key === preset.key) || preset;
			const presetOptions = Array.isArray(presetDef.options) ? presetDef.options.slice() : preset.options || [];
			const baseOptions = Array.isArray(defaultDef.options) ? defaultDef.options.slice() : preset.options || [];
			const curVal = this._currentTags[preset.key] || "";
			const items = presetOptions.map((v) => ({
				value: v,
				custom: !baseOptions.includes(v)
			}));
			if (curVal && !items.some((it) => it.value === curVal)) items.push({
				value: curVal,
				custom: !baseOptions.includes(curVal)
			});
			const es = createEditableSelect({
				placeholder: preset.label || "–",
				value: curVal,
				items,
				onChange: (val) => {
					this._tagsTouched = true;
					if (val) this._currentTags[preset.key] = val;
					else delete this._currentTags[preset.key];
				},
				onAdd: (val) => {
					this._tagsTouched = true;
					this._onLayerEmit?.("tagcustomadd", {
						key: preset.key,
						value: val
					});
				},
				onRemove: (val) => {
					this._tagsTouched = true;
					this._onLayerEmit?.("tagcustomremove", {
						key: preset.key,
						value: val
					});
				},
				onRename: (oldV, newV) => {
					this._tagsTouched = true;
					this._onLayerEmit?.("tagcustomrename", {
						key: preset.key,
						oldValue: oldV,
						newValue: newV
					});
				}
			});
			es.el.classList.add("label-tag-combo");
			const trig = es.el.querySelector(".esel-trigger");
			if (trig) trig.setAttribute("title", preset.label || preset.key);
			this._esInstances.push(es);
			return es.el;
		}
		_renderResults() {
			const player = this._player;
			const customOnly = (player?.getLabelEditorSuggestionMode?.() || "merge") === "custom-only";
			const taxonomy = player?.getLabelTaxonomy?.() || [];
			const recent = player?.getLabelSuggestions?.("", 8) || [];
			const filtered = player?.getLabelSuggestions?.(this._input.value, 8) || [];
			const custom = player?.getLabelEditorSuggestions?.(this._input.value, 14) || [];
			this._results.innerHTML = "";
			this._resultItems = [];
			this._activeIndex = -1;
			const seen = /* @__PURE__ */ new Set();
			const addResult = ({ name, scientificName = "", color = "", detail = "", tags = {} }) => {
				const label = String(name || "").trim();
				if (!label) return;
				const key = label.toLowerCase();
				if (seen.has(key)) return;
				seen.add(key);
				const row = document.createElement("button");
				row.type = "button";
				row.className = "label-search-item";
				if (color) {
					const dot = document.createElement("span");
					dot.className = "label-search-dot";
					dot.style.background = getOverlayColorStyle(color)?.hex || color;
					row.appendChild(dot);
				}
				const nameSpan = document.createElement("span");
				nameSpan.className = "label-search-name";
				nameSpan.textContent = label;
				row.appendChild(nameSpan);
				if (scientificName) {
					const sub = document.createElement("span");
					sub.className = "label-search-sci";
					sub.textContent = scientificName;
					row.appendChild(sub);
				}
				if (detail) {
					const detailSpan = document.createElement("span");
					detailSpan.className = "label-search-detail";
					detailSpan.textContent = detail;
					row.appendChild(detailSpan);
				}
				const select = () => {
					this._input.value = label;
					if (color) {
						this._colorInput.value = getOverlayColorStyle(color)?.hex || this._colorInput.value;
						this._colorTouched = true;
					}
					this._selectedSci = String(scientificName || "").trim();
					this._sciTouched = true;
					if (tags && typeof tags === "object") {
						for (const [k, v] of Object.entries(tags)) if (v) this._currentTags[k] = v;
						this._tagsTouched = true;
						this._renderTags();
					}
					for (const item of this._resultItems) item.classList.remove("selected");
					row.classList.add("selected");
					this._confirmBtn.disabled = false;
				};
				row.addEventListener("click", select);
				row.addEventListener("dblclick", () => {
					select();
					this._submit(label);
				});
				row.addEventListener("pointerenter", () => {
					this._activeIndex = this._resultItems.indexOf(row);
					this._updateHighlight();
				});
				this._results.appendChild(row);
				this._resultItems.push(row);
			};
			if (this._existingLabels?.length) for (const item of this._existingLabels) if (typeof item === "string") addResult({ name: item });
			else addResult({
				name: item.name,
				color: item.color || "",
				scientificName: item.scientificName || "",
				tags: item.tags || {}
			});
			for (const item of custom) addResult({
				name: item?.name,
				scientificName: item?.scientificName || "",
				color: item?.color || "",
				detail: item?.detail || ""
			});
			if (!customOnly) {
				for (const item of taxonomy) addResult({
					name: item?.shortcut ? `${item.shortcut}: ${item.name}` : item?.name,
					color: item?.color || ""
				});
				for (const name of recent) addResult({ name });
				for (const name of filtered) addResult({ name });
			}
		}
		_updateHighlight() {
			for (let i = 0; i < this._resultItems.length; i++) this._resultItems[i].classList.toggle("active", i === this._activeIndex);
			if (this._activeIndex >= 0 && this._resultItems[this._activeIndex]) this._resultItems[this._activeIndex].scrollIntoView({ block: "nearest" });
		}
		_handleKeydown(e) {
			const key = e.key;
			if (key === "ArrowDown") {
				e.preventDefault();
				if (this._resultItems.length) {
					this._activeIndex = (this._activeIndex + 1) % this._resultItems.length;
					this._updateHighlight();
				}
			} else if (key === "ArrowUp") {
				e.preventDefault();
				if (this._resultItems.length) {
					this._activeIndex = this._activeIndex <= 0 ? this._resultItems.length - 1 : this._activeIndex - 1;
					this._updateHighlight();
				}
			} else if (key === "Enter") {
				e.preventDefault();
				const activeItem = this._resultItems[this._activeIndex];
				if (activeItem && !activeItem.classList.contains("selected")) activeItem.click();
				else this._submit(this._input.value);
			} else if (key === "Escape") {
				e.preventDefault();
				this.close();
			}
		}
		_submit(value, opts = {}) {
			const trimmed = String(value || "").trim();
			if (!trimmed) return;
			const scientificName = String(opts?.scientificName || this._selectedSci || "").trim();
			const tags = {
				...this._currentTags,
				...opts?.tags || {}
			};
			for (const k of Object.keys(tags)) if (!tags[k]) delete tags[k];
			const nameChanged = trimmed !== this._initialValueTrim;
			const colorChanged = this._colorTouched || String(this._colorInput.value || "") !== String(this._initialStyleHex || "");
			const tagsChanged = this._tagsTouched || JSON.stringify(tags) !== JSON.stringify(this._initialTagsNorm);
			const sciChanged = this._sciTouched || String(scientificName || "") !== String(this._initialScientific || "");
			this._onSubmit({
				name: trimmed,
				color: this._colorInput.value,
				scientificName,
				tags,
				__changed: {
					name: nameChanged,
					color: colorChanged,
					scientificName: sciChanged,
					tags: tagsChanged
				}
			});
			this.close();
		}
	};
	//#endregion
	//#region src/domain/annotations.ts
	var _colorCtx = (() => {
		try {
			return document.createElement("canvas").getContext("2d");
		} catch {
			return null;
		}
	})();
	function parseColorToRgb(color) {
		const raw = String(color || "").trim();
		if (!raw || !_colorCtx) return null;
		try {
			_colorCtx.fillStyle = "#000000";
			_colorCtx.fillStyle = raw;
			const normalized = _colorCtx.fillStyle;
			if (!normalized) return null;
			if (normalized.startsWith("#")) {
				const hex = normalized.slice(1);
				if (hex.length === 3) return {
					r: parseInt(hex[0] + hex[0], 16),
					g: parseInt(hex[1] + hex[1], 16),
					b: parseInt(hex[2] + hex[2], 16)
				};
				if (hex.length === 6) return {
					r: parseInt(hex.slice(0, 2), 16),
					g: parseInt(hex.slice(2, 4), 16),
					b: parseInt(hex.slice(4, 6), 16)
				};
			}
			const m = normalized.match(/rgba?\(([^)]+)\)/i);
			if (!m) return null;
			const parts = m[1].split(",").map((x) => Number(x.trim()));
			if (parts.length < 3 || parts.some((n, i) => i < 3 && !Number.isFinite(n))) return null;
			return {
				r: parts[0],
				g: parts[1],
				b: parts[2]
			};
		} catch {
			return null;
		}
	}
	function _rgbToHex({ r, g, b }) {
		const toHex = (n) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
		return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
	}
	function getOverlayColorStyle(color) {
		const rgb = parseColorToRgb(color);
		if (!rgb) return null;
		return {
			fill: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
			edge: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.95)`,
			soft: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.55)`,
			hex: _rgbToHex(rgb)
		};
	}
	/**
	* Deterministic color for a label name — same name always produces the same
	* color.  Uses a simple string hash mapped onto a golden-angle hue wheel.
	* @param {string} name
	* @returns {string} hex color
	*/
	function colorForName(name) {
		const key = String(name || "").trim().toLowerCase();
		if (!key) return _hslToHex(0, 0, 55);
		let h = 0;
		for (let i = 0; i < key.length; i++) h = (h << 5) - h + key.charCodeAt(i) | 0;
		return _hslToHex((h % 360 + 360) % 360, 65, 58);
	}
	/**
	* Generate a perceptually distinct color using golden-angle hue rotation.
	* Avoids hues already used by existing labels (min distance in hue space).
	* @param {Array<{color?:string}>} existingLabels
	* @returns {string} hex color
	*/
	function _autoAssignColor(existingLabels) {
		const usedHues = [];
		for (const lbl of existingLabels) {
			const rgb = parseColorToRgb(lbl.color);
			if (!rgb) continue;
			const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
			const max = Math.max(r, g, b), min = Math.min(r, g, b);
			if (max === min) continue;
			let h;
			const d = max - min;
			if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
			else if (max === g) h = ((b - r) / d + 2) / 6;
			else h = ((r - g) / d + 4) / 6;
			usedHues.push(h * 360);
		}
		const GOLDEN_ANGLE = 137.508;
		const S = 65, L = 58;
		const n = existingLabels.length;
		if (usedHues.length === 0) return _hslToHex(n * GOLDEN_ANGLE % 360, S, L);
		let bestHue = 0, bestDist = -1;
		for (let i = 0; i < 32; i++) {
			const candidateHue = (n + i) * GOLDEN_ANGLE % 360;
			let minDist = 360;
			for (const used of usedHues) {
				const diff = Math.abs(candidateHue - used);
				minDist = Math.min(minDist, diff, 360 - diff);
			}
			if (minDist > bestDist) {
				bestDist = minDist;
				bestHue = candidateHue;
			}
		}
		return _hslToHex(bestHue, S, L);
	}
	function _hslToHex(h, s, l) {
		s /= 100;
		l /= 100;
		const a = s * Math.min(l, 1 - l);
		const f = (n) => {
			const k = (n + h / 30) % 12;
			const color = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
			return Math.round(255 * clamp(color, 0, 1));
		};
		return _rgbToHex({
			r: f(0),
			g: f(8),
			b: f(4)
		});
	}
	/**
	* @param {Object} opts
	* @param {*} opts.player
	* @param {Element|null} [opts.anchorEl]
	* @param {string} opts.initialValue
	* @param {string|null} opts.initialColor
	* @param {Record<string,string>|null} [opts.initialTags]
	* @param {(string|{name:string, color?:string, scientificName?:string, tags?:Record<string,string>})[]|null} [opts.existingLabels]
	* @param {string|null} [opts.initialScientificName]
	* @param {string|null} [opts.title]
	* @param {function({name:string, color:string, scientificName?:string, tags?:Record<string,string>, __changed?:{name?:boolean, color?:boolean, scientificName?:boolean, tags?:boolean}}):void} opts.onSubmit
	* @param {(function():void)|null} [opts.onDelete]
	*/
	/**
	* Open the label editor modal. If `opts.layer` is provided (an AnnotationLayerBase
	* instance), tag-management events (tagcustom*) are dispatched on that layer
	* instead of calling player._emit() directly — eliminating the circular dependency.
	* @param {any} opts
	*/
	function openLabelNameEditor(opts) {
		const layer = opts?.layer;
		const rest = { ...opts };
		delete rest.layer;
		const onLayerEmit = layer ? (event, detail) => layer.dispatchEvent(new CustomEvent(String(event), { detail })) : (event, detail) => opts.player?._emit?.(event, detail);
		const modal = new LabelEditorModal({
			...rest,
			onLayerEmit
		});
		modal.open();
		return modal;
	}
	var AnnotationLayerBase = class extends EventTarget {
		constructor() {
			super();
			this.player = null;
			this.overlay = null;
			/** @type {any[]} */
			this._items = [];
			this._liveLinkedId = null;
			this._unsubs = [];
			this._domCleanups = [];
			/** @type {any} */
			this._editing = null;
			this._suppressClickUntil = 0;
			/** @type {Set<string>} */
			this._multiSelectedIds = /* @__PURE__ */ new Set();
			this._lastPointerX = 0;
			this._lastPointerY = 0;
			this._grabbing = false;
		}
		/** CSS class name for the created overlay element. */
		get _overlayClassName() {
			return "annotation-layer-base";
		}
		/** CSS selector matching all item elements within the overlay. */
		get _itemElSelector() {
			return ".annotation-item";
		}
		/** Return the DOM root element to mount the overlay into. */
		_getRoot() {
			return null;
		}
		/** Subscribe player events. Called inside attach(), after overlay creation. */
		_subscribePlayerEvents() {}
		/** Bind mouse/pointer interactions. Called inside attach(), after overlay creation. */
		_bindInteractions(_root) {}
		/**
		* Normalize a raw item object (must return object with .id).
		* @param {*} _item
		* @returns {{id: string}}
		* @abstract
		*/
		_normalize(_item) {
			throw new Error(`${this.constructor.name}._normalize not implemented`);
		}
		/** Emit a player event when a new item is created via add(). */
		_emitCreate(_item) {}
		/** Emit a player event when an item is updated after drag/resize. */
		_emitUpdate(_item) {}
		/** Render the overlay. Override in subclasses; default no-op for base typing. */
		render() {}
		attach(player) {
			this.detach();
			this.player = player;
			const root = this._getRoot();
			if (!root) return;
			this.overlay = document.createElement("div");
			this.overlay.className = this._overlayClassName;
			root.appendChild(this.overlay);
			this._subscribePlayerEvents();
			this._bindInteractions(root);
			this.render();
		}
		detach() {
			for (const unsub of this._unsubs) unsub();
			this._unsubs = [];
			for (const cleanup of this._domCleanups) cleanup();
			this._domCleanups = [];
			if (this.overlay?.parentNode) this.overlay.parentNode.removeChild(this.overlay);
			this.overlay = null;
			this.player = null;
			this._editing = null;
		}
		add(item) {
			const region = this._normalize(item);
			this._items.push(region);
			this.render();
			this._emitCreate(region);
			return region.id;
		}
		set(items = []) {
			this._items = items.map((i) => this._normalize(i));
			this.render();
		}
		clear() {
			this._items = [];
			this.render();
		}
		remove(id) {
			this._items = this._items.filter((i) => i.id !== id);
			this.render();
		}
		getAll() {
			return [...this._items];
		}
		setLiveLinkedId(id = null) {
			this._liveLinkedId = id || null;
		}
		setMultiSelected(ids) {
			this._multiSelectedIds = new Set(ids);
			this._updateMultiSelectedVisual();
		}
		toggleMultiSelected(id) {
			if (this._multiSelectedIds.has(id)) this._multiSelectedIds.delete(id);
			else this._multiSelectedIds.add(id);
			this._updateMultiSelectedVisual();
		}
		_updateMultiSelectedVisual() {
			if (!this.overlay) return;
			for (const el of this.overlay.querySelectorAll(this._itemElSelector)) {
				const h = el;
				h.classList.toggle("multi-selected", this._multiSelectedIds.has(h.dataset?.id || ""));
			}
		}
		_finishEditInteraction() {
			if (!this._editing) return;
			const editing = this._editing;
			const shouldSuppressClick = editing.forceSuppressClick || editing.moved;
			editing.element?.classList?.remove("editing");
			const item = this._items.find((i) => i.id === editing.id);
			if (item && editing.moved) this._emitUpdate(item);
			this._editing = null;
			if (shouldSuppressClick) {
				this._suppressClickUntil = performance.now() + 250;
				this.render();
			}
		}
	};
	var AnnotationLayer = class extends AnnotationLayerBase {
		get _overlayClassName() {
			return "annotation-layer";
		}
		get _itemElSelector() {
			return ".annotation-region";
		}
		_getRoot() {
			return this.player?._state?.d?.waveformContent || this.player?.root?.querySelector(".waveform-content");
		}
		_subscribePlayerEvents() {
			this._unsubs.push(this.player.on("ready", () => this.render()));
			this._unsubs.push(this.player.on("zoomchange", () => this.render()));
			this._unsubs.push(this.player.on("viewresize", () => this.render()));
			this._unsubs.push(this.player.on("seek", (e) => this.highlightActiveRegion(e.detail.currentTime)));
			this._unsubs.push(this.player.on("timeupdate", (e) => this.highlightActiveRegion(e.detail.currentTime)));
		}
		_bindInteractions(root) {
			this._bindEditingInteractions(root);
		}
		_emitCreate(region) {
			this.player?._emit?.("annotationcreate", { annotation: { ...region } });
		}
		_emitUpdate(item) {
			this.player?._emit?.("annotationupdate", { annotation: { ...item } });
		}
		/** Public alias for the internal items array (backward compat). */
		get annotations() {
			return this._items;
		}
		highlightActiveRegion(currentTime) {
			if (!this.overlay) return;
			for (const el of this.overlay.querySelectorAll(this._itemElSelector)) {
				const h = el;
				const start = parseFloat(h.dataset.start || "0");
				const end = parseFloat(h.dataset.end || "0");
				el.classList.toggle("active", currentTime >= start && currentTime <= end);
			}
		}
		exportRavenFormat(regions = this._items) {
			return regions.map((r) => `${r.start}\t${r.end}\t${r.species || ""}\t${r.confidence ?? ""}`).join("\n");
		}
		render() {
			if (!this.overlay || !this.player) return;
			if (this._editing) return;
			const coords = this.player._state?.coords;
			const pps = this.player._state?.pixelsPerSecond || 100;
			const duration = this.player.duration || this.player._state?.audioBuffer?.duration || 0;
			const width = Math.max(1, Math.floor(coords ? coords.timeToScrollX(duration) : duration * pps));
			this.overlay.style.width = `${width}px`;
			this.overlay.innerHTML = "";
			const sorted = [...this._items].sort((a, b) => a.start - b.start || a.end - b.end);
			const rowEnds = [];
			const rowMap = /* @__PURE__ */ new Map();
			for (const region of sorted) {
				let row = -1;
				for (let r = 0; r < rowEnds.length; r++) if (region.start >= rowEnds[r]) {
					row = r;
					rowEnds[r] = region.end;
					break;
				}
				if (row < 0) {
					row = rowEnds.length;
					rowEnds.push(region.end);
				}
				rowMap.set(region.id, row);
			}
			const totalRows = Math.max(1, rowEnds.length);
			const localLayout = /* @__PURE__ */ new Map();
			if (totalRows > 1) {
				const par = /* @__PURE__ */ new Map();
				const find = (x) => {
					while (par.get(x) !== x) {
						par.set(x, par.get(par.get(x) || x) || x);
						x = String(par.get(x));
					}
					return x;
				};
				for (const r of sorted) par.set(r.id, r.id);
				for (let i = 0; i < sorted.length; i++) for (let j = i + 1; j < sorted.length; j++) {
					if (sorted[j].start >= sorted[i].end) break;
					const ra = find(sorted[i].id), rb = find(sorted[j].id);
					if (ra !== rb) par.set(ra, rb);
				}
				const clusters = /* @__PURE__ */ new Map();
				for (const r of sorted) {
					const root = find(r.id);
					if (!clusters.has(root)) clusters.set(root, []);
					clusters.get(root).push({
						id: r.id,
						row: rowMap.get(r.id) ?? 0
					});
				}
				for (const [, members] of clusters) {
					if (members.length < 2) continue;
					const rowsSorted = [...new Set(members.map((m) => m.row))].sort((a, b) => a - b);
					const depth = rowsSorted.length;
					if (depth < 2) continue;
					for (const m of members) localLayout.set(m.id, {
						depth,
						localRow: rowsSorted.indexOf(m.row)
					});
				}
			}
			for (const region of this._items) {
				const el = this._createRegionElement(region, pps);
				const layout = localLayout.get(region.id);
				if (layout && layout.depth > 1) {
					const pct = 100 / layout.depth;
					el.style.top = `${layout.localRow * pct}%`;
					el.style.height = `${pct}%`;
					el.style.bottom = "auto";
				}
				this.overlay.appendChild(el);
			}
			if (this._editing) {
				const editing = this._editing;
				const freshEl = this.overlay.querySelector(`.annotation-region[data-id="${editing.id}"]`);
				if (freshEl) {
					editing.element = freshEl;
					freshEl.classList.add("editing");
				}
			}
			this._updateMultiSelectedVisual();
		}
		_createRegionElement(region, pixelsPerSecond) {
			const coords = this.player?._state?.coords;
			const el = document.createElement("div");
			el.className = "annotation-region";
			if (region.readonly) el.classList.add("annotation-region--readonly");
			if (this._liveLinkedId && region.id === this._liveLinkedId) el.classList.add("linked-live");
			el.setAttribute("role", "button");
			el.setAttribute("tabindex", "0");
			const left = coords ? coords.timeToScrollX(region.start) : region.start * pixelsPerSecond;
			const right = coords ? coords.timeToScrollX(region.end) : region.end * pixelsPerSecond;
			el.style.left = `${Math.max(0, left)}px`;
			el.style.width = `${Math.max(1, right - left)}px`;
			const colorStyle = getOverlayColorStyle(region.color);
			if (colorStyle) {
				el.style.setProperty("--annotation-color-fill", colorStyle.fill);
				el.style.setProperty("--annotation-color-edge", colorStyle.edge);
				el.style.setProperty("--annotation-color-soft", colorStyle.soft);
			}
			el.dataset.id = region.id;
			el.dataset.start = String(region.start);
			el.dataset.end = String(region.end);
			const readonlyNote = region.readonly ? " [read-only]" : "";
			el.title = `${region.species || "Annotation"} (${region.start.toFixed(2)}s–${region.end.toFixed(2)}s)${readonlyNote}`;
			el.setAttribute("aria-label", el.title);
			const confidenceStr = region.confidence != null ? `${Math.round(region.confidence * 100)}%` : "";
			const aiTag = region.aiSuggested ? `<span class="annotation-ai-badge" title="AI: ${escapeHtml(region.aiSuggested.model || "")} ${escapeHtml(region.aiSuggested.version || "")}">AI</span>` : "";
			const lockIcon = region.readonly ? `<span class="annotation-lock" title="Read-only (imported from XC)">🔒</span>` : "";
			el.innerHTML = `
            <span class="annotation-label">${escapeHtml(region.species || "Annotation")}</span>
            <span class="annotation-confidence">${confidenceStr}</span>
            ${aiTag}${lockIcon}
            ${region.readonly ? "" : `
            <span class="annotation-handle handle-l" data-mode="resize-l"></span>
            <span class="annotation-handle handle-r" data-mode="resize-r"></span>
            `}
        `;
			el.addEventListener("click", (event) => {
				if (performance.now() < this._suppressClickUntil) return;
				event.preventDefault();
				event.stopPropagation();
				if (event.ctrlKey || event.metaKey) {
					this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
						id: region.id,
						source: "waveform",
						interaction: "ctrl-click"
					} }));
					return;
				}
				this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: region.id,
					source: "waveform",
					interaction: "click"
				} }));
				this.player?._state?._blockSeekClicks?.(260);
				this.player?.playSegment?.(region.start, region.end, { labelId: region.id });
			});
			el.addEventListener("dblclick", (event) => {
				event.preventDefault();
				event.stopPropagation();
				if (region.readonly) return;
				this._suppressClickUntil = performance.now() + 250;
				this._renameRegionPrompt(region.id);
			});
			el.addEventListener("pointerdown", (event) => {
				if (event.button !== 0) return;
				if (region.readonly) {
					event.preventDefault();
					event.stopPropagation();
					return;
				}
				const mode = (event.target?.closest?.(".annotation-handle"))?.dataset?.mode || "move";
				this._startEditInteraction(region.id, mode, event.clientX, el);
				event.preventDefault();
				event.stopPropagation();
				this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: region.id,
					source: "waveform",
					interaction: "click"
				} }));
			});
			el.addEventListener("pointerenter", (event) => {
				this._lastPointerX = event.clientX;
				this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: region.id,
					source: "waveform",
					interaction: "hover"
				} }));
			});
			el.addEventListener("pointerleave", () => {
				if (!this._grabbing && !this._editing) this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: null,
					source: "waveform",
					interaction: "hover"
				} }));
			});
			el.addEventListener("pointermove", (event) => {
				this._lastPointerX = event.clientX;
			});
			return el;
		}
		_bindEditingInteractions(root) {
			const onPointerMove = (e) => {
				if (!this._editing) return;
				this._updateEditInteraction(e.clientX);
				e.preventDefault();
				e.stopPropagation();
			};
			const onPointerUp = (e) => {
				if (!this._editing) return;
				this._finishEditInteraction();
				e.preventDefault();
				e.stopPropagation();
			};
			root.addEventListener("pointermove", onPointerMove, true);
			document.addEventListener("pointerup", onPointerUp, true);
			document.addEventListener("pointercancel", onPointerUp, true);
			this._domCleanups.push(() => root.removeEventListener("pointermove", onPointerMove, true));
			this._domCleanups.push(() => document.removeEventListener("pointerup", onPointerUp, true));
			this._domCleanups.push(() => document.removeEventListener("pointercancel", onPointerUp, true));
		}
		_startEditInteraction(id, mode, clientX, element) {
			const region = this._items.find((a) => a.id === id);
			if (!region || region.readonly) return;
			this._editing = {
				id,
				mode,
				startX: clientX,
				startRegion: { ...region },
				element,
				pending: mode === "move",
				moved: mode !== "move",
				forceSuppressClick: mode !== "move"
			};
			if (mode !== "move") element.classList.add("editing");
		}
		_updateEditInteraction(clientX) {
			if (!this._editing) return;
			const editing = this._editing;
			const region = this._items.find((a) => a.id === editing.id);
			if (!region) return;
			const pps = this.player?._state?.pixelsPerSecond || 100;
			const duration = Math.max(.001, this.player?.duration || this.player?._state?.audioBuffer?.duration || .001);
			const dt = (clientX - editing.startX) / Math.max(1, pps);
			const src = editing.startRegion;
			let next = { ...region };
			if (editing.pending) {
				if (Math.abs(clientX - editing.startX) < 4) return;
				editing.pending = false;
				editing.moved = true;
				editing.element?.classList?.add("editing");
			}
			if (editing.mode === "move") {
				const span = src.end - src.start;
				next.start = clamp(src.start + dt, 0, Math.max(0, duration - span));
				next.end = next.start + span;
			} else if (editing.mode === "resize-l") next.start = clamp(src.start + dt, 0, src.end - .01);
			else if (editing.mode === "resize-r") next.end = clamp(src.end + dt, src.start + .01, duration);
			Object.assign(region, this._normalize({
				...src,
				...next,
				id: src.id
			}));
			this.player?._state?.updateActiveSegmentFromLabel?.(region);
			this.dispatchEvent(new CustomEvent("annotationpreview", { detail: { annotation: { ...region } } }));
			const el = editing.element;
			if (el) {
				el.dataset.start = String(region.start);
				el.dataset.end = String(region.end);
				const coords = this.player?._state?.coords;
				const left = coords ? coords.timeToScrollX(region.start) : region.start * pps;
				const right = coords ? coords.timeToScrollX(region.end) : region.end * pps;
				el.style.left = `${Math.max(0, left)}px`;
				el.style.width = `${Math.max(1, right - left)}px`;
			}
		}
		/**
		* Blender-style grab for waveform annotations (horizontal only).
		*/
		startGrab(annotationId) {
			const region = this._items.find((a) => a.id === annotationId);
			if (!region || this._grabbing) return;
			const el = this.overlay?.querySelector?.(`.annotation-region[data-id="${region.id}"]`);
			if (!el) return;
			const snapshot = { ...region };
			el.classList.add("editing");
			const startX = this._lastPointerX ?? 0;
			this._editing = {
				id: annotationId,
				mode: "move",
				startX,
				startRegion: snapshot,
				element: el,
				pending: false,
				moved: true,
				forceSuppressClick: true
			};
			this._grabbing = true;
			const onMove = (e) => {
				this._updateEditInteraction(e.clientX);
			};
			const confirm = (e) => {
				e?.preventDefault?.();
				e?.stopPropagation?.();
				cleanup();
				this._finishEditInteraction();
			};
			const cancel = (e) => {
				e?.preventDefault?.();
				e?.stopPropagation?.();
				cleanup();
				Object.assign(region, snapshot);
				el.classList.remove("editing");
				this._editing = null;
				this._suppressClickUntil = performance.now() + 250;
				this.render();
			};
			const onKey = (e) => {
				if (e.key === "Escape") cancel(e);
				if (e.key === "g") confirm(e);
			};
			const cleanup = () => {
				this._grabbing = false;
				document.removeEventListener("pointermove", onMove, true);
				document.removeEventListener("pointerdown", confirm, true);
				document.removeEventListener("keydown", onKey, true);
			};
			document.addEventListener("pointermove", onMove, true);
			document.addEventListener("pointerdown", confirm, true);
			document.addEventListener("keydown", onKey, true);
		}
		_renameRegionPrompt(id) {
			const region = this._items.find((a) => a.id === id);
			if (!region) return;
			const current = region.species || "Annotation";
			const el = this.overlay?.querySelector?.(`.annotation-region[data-id="${region.id}"]`);
			openLabelNameEditor({
				layer: this,
				player: this.player,
				anchorEl: el || this.overlay,
				initialValue: current,
				initialColor: region.color,
				initialScientificName: region.scientificName || "",
				onSubmit: ({ name, color }) => {
					const currentHex = getOverlayColorStyle(region.color)?.hex || "";
					if (name === current && color === currentHex) return;
					region.species = name;
					region.color = color;
					this.dispatchEvent(new CustomEvent("annotationupdate", { detail: { annotation: { ...region } } }));
					this.render();
				}
			});
		}
		_normalize(annotation) {
			const start = Number(annotation?.start ?? 0);
			const end = Number(annotation?.end ?? start);
			if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error("AnnotationLayer: start/end must be finite numbers");
			const s = Math.max(0, Math.min(start, end));
			const e = Math.max(0, Math.max(start, end));
			return {
				id: annotation?.id || `ann_${Math.random().toString(36).slice(2, 10)}`,
				start: s,
				end: Math.max(s + .01, e),
				species: annotation?.species || "",
				confidence: annotation?.confidence,
				color: String(annotation?.color || "").trim(),
				readonly: annotation?.readonly === true,
				aiSuggested: annotation?.aiSuggested ?? null,
				recordingId: annotation?.recordingId ?? null
			};
		}
	};
	var SpectrogramLabelLayer = class extends AnnotationLayerBase {
		constructor() {
			super();
			this.overlay = null;
			this._items = [];
			this._liveLinkedId = null;
			this._unsubs = [];
			this._domCleanups = [];
			this._editing = null;
			this._suppressClickUntil = 0;
			this._multiSelectedIds = /* @__PURE__ */ new Set();
			this._lastPointerX = null;
			this._lastPointerY = null;
			this._grabbing = false;
			this.drawMode = true;
			this.stampMode = false;
			this._stampGhostEl = null;
			this._stampAxisLock = false;
			this._stampRefLabelId = null;
			this._axisConstraint = null;
			this._draftEl = null;
			this._drawing = null;
			this._counter = 0;
			this._suppressContextMenuUntil = 0;
			this._focusedLabelId = null;
			this._selectedLabelId = null;
			this._lockedIds = /* @__PURE__ */ new Set();
			this._clipboard = null;
			this.drawMode = true;
			this.stampMode = false;
			this._stampGhostEl = null;
			this._stampAxisLock = false;
			this._stampRefLabelId = null;
			this._axisConstraint = null;
			this._draftEl = null;
			this._drawing = null;
			this._counter = 1;
			this._suppressContextMenuUntil = 0;
			this._focusedLabelId = null;
			this._selectedLabelId = null;
			/** @type {Set<string>} ids of labels whose set is locked — no drag/resize/edit */
			this._lockedIds = /* @__PURE__ */ new Set();
			this._clipboard = null;
		}
		get _overlayClassName() {
			return "spectrogram-label-layer";
		}
		get _itemElSelector() {
			return ".spectrogram-label-region";
		}
		_getRoot() {
			return this.player?._state?.d?.canvasWrapper || this.player?.root?.querySelector(".canvas-wrapper");
		}
		_subscribePlayerEvents() {
			this._unsubs.push(this.player.on("ready", () => this.render()));
			this._unsubs.push(this.player.on("zoomchange", () => this.render()));
			this._unsubs.push(this.player.on("viewresize", () => this.render()));
			this._unsubs.push(this.player.on("spectrogramscalechange", () => this.render()));
			this._unsubs.push(this.player.on("timeupdate", (e) => this.highlightActiveLabel(e.detail.currentTime)));
			this._unsubs.push(this.player.on("labelfocus", (e) => {
				const id = e?.detail?.id || null;
				if (e?.detail?.interaction === "click") {
					this._selectedLabelId = id;
					this._updateSelectedVisual();
				} else {
					this._focusedLabelId = id;
					this._updateFocusedVisual();
				}
			}));
		}
		_bindInteractions(root) {
			this._bindDrawingInteractions(root);
		}
		_emitCreate(region) {
			this.player?._emit?.("spectrogramlabelcreate", { label: { ...region } });
		}
		_emitUpdate(item) {
			this.player?._emit?.("spectrogramlabelupdate", { label: { ...item } });
		}
		/** Public alias for the internal items array (backward compat). */
		get labels() {
			return this._items;
		}
		set labels(v) {
			if (!Array.isArray(v)) {
				this._items = [];
				return;
			}
			this._items = v.map((i) => this._normalize(i));
		}
		detach() {
			super.detach();
			this._draftEl = null;
			this._drawing = null;
			this._stampGhostEl = null;
			this._stampAxisLock = false;
		}
		/** Accept a suggestion label — change its origin to 'manual'. */
		_acceptSuggestion(id) {
			const label = this._items.find((l) => l.id === id);
			if (!label) return;
			label.origin = "manual";
			this.render();
			this.dispatchEvent(new CustomEvent("spectrogramlabelupdate", { detail: { label: { ...label } } }));
		}
		/**
		* Mark a set of label ids as locked (no drag, resize, edit, delete).
		* @param {Set<string>|string[]} ids
		*/
		setLockedIds(ids = []) {
			this._lockedIds = new Set(ids);
			this.render();
		}
		copyLabel(id) {
			const label = this._items.find((l) => l.id === id);
			if (!label) return;
			this._clipboard = { ...label };
		}
		pasteLabel(atTime = null) {
			if (!this._clipboard) return null;
			const src = this._clipboard;
			const duration = src.end - src.start;
			const t = atTime ?? this.player?.currentTime ?? src.start;
			const region = this._normalize({
				start: t,
				end: t + duration,
				freqMin: src.freqMin,
				freqMax: src.freqMax,
				label: src.label,
				color: src.color,
				scientificName: src.scientificName,
				commonName: src.commonName,
				origin: src.origin,
				author: src.author,
				tags: src.tags ? { ...src.tags } : {}
			});
			this.add(region);
			return region;
		}
		/**
		* Returns the reference label for stamp/paste defaults.
		* Priority: 1) explicit stamp ref  2) click-focused  3) last label
		*/
		_getReferenceLabelForDefaults() {
			if (this._stampRefLabelId) {
				const ref = this._items.find((l) => l.id === this._stampRefLabelId);
				if (ref) return ref;
			}
			if (this._focusedLabelId) {
				const focused = this._items.find((l) => l.id === this._focusedLabelId);
				if (focused) return focused;
			}
			return this._items.length ? this._items[this._items.length - 1] : null;
		}
		highlightActiveLabel(currentTime) {
			if (!this.overlay) return;
			for (const el of this.overlay.querySelectorAll(".spectrogram-label-region")) {
				const h = el;
				const start = parseFloat(h.dataset.start || "0");
				const end = parseFloat(h.dataset.end || "0");
				el.classList.toggle("active", currentTime >= start && currentTime <= end);
			}
		}
		/** Set .focused class on the currently hovered label element (transient). */
		_updateFocusedVisual() {
			if (!this.overlay) return;
			for (const el of this.overlay.querySelectorAll(".spectrogram-label-region")) {
				const h = el;
				h.classList.toggle("focused", !!this._focusedLabelId && h.dataset?.id === this._focusedLabelId);
			}
		}
		/** Set .selected class on the explicitly selected label element (sticky). */
		_updateSelectedVisual() {
			if (!this.overlay) return;
			for (const el of this.overlay.querySelectorAll(".spectrogram-label-region")) {
				const h = el;
				h.classList.toggle("selected", !!this._selectedLabelId && h.dataset?.id === this._selectedLabelId);
			}
		}
		render() {
			if (!this.overlay || !this.player) return;
			if (this._editing) return;
			const state = this.player._state;
			const c = state?.coords;
			const duration = this.player.duration || state?.audioBuffer?.duration || 0;
			const pps = state?.pixelsPerSecond || 100;
			const width = Math.max(1, Math.floor(c ? c.timeToScrollX(duration) : duration * pps));
			const height = Math.max(1, state?.d?.spectrogramCanvas?.height || 1);
			this.overlay.style.width = `${width}px`;
			this.overlay.style.height = `${height}px`;
			this.overlay.innerHTML = "";
			const elements = [];
			const geometries = [];
			for (const label of this._items) {
				const el = this._createLabelElement(label, width, height);
				const geo = this._toGeometry(label, width, height);
				this.overlay.appendChild(el);
				elements.push(el);
				geometries.push(geo);
			}
			this._resolveTextCollisions(elements, geometries);
			if (this._editing) {
				const editing = this._editing;
				const freshEl = this.overlay.querySelector(`.spectrogram-label-region[data-id="${editing.id}"]`);
				if (freshEl) {
					editing.element = freshEl;
					freshEl.classList.add("editing");
				}
			}
			this._updateFocusedVisual();
			this._updateSelectedVisual();
			this._updateMultiSelectedVisual();
			if (this._stampGhostEl && this.stampMode) this.overlay.appendChild(this._stampGhostEl);
		}
		/**
		* Detect overlapping text badges and nudge colliding ones to bottom-left.
		* Uses a simple greedy approach: first label keeps top-left, subsequent
		* labels that would collide get moved to bottom-left of their box.
		* @param {HTMLElement[]} elements
		* @param {Array<{left: number, top: number, width: number, height: number}>} geometries
		*/
		_resolveTextCollisions(elements, geometries) {
			const TEXT_H = 16;
			const occupiedRects = [];
			for (let i = 0; i < elements.length; i++) {
				const geo = geometries[i];
				const textEl = elements[i].querySelector(".spectrogram-label-text");
				if (!textEl) continue;
				const textWidth = clamp(geo.width * .7, 100, 200);
				let rect = {
					left: geo.left,
					top: geo.top,
					right: geo.left + textWidth,
					bottom: geo.top + TEXT_H
				};
				if (occupiedRects.some((r) => rect.left < r.right && rect.right > r.left && rect.top < r.bottom && rect.bottom > r.top)) {
					textEl.classList.add("label-text-bottom");
					rect = {
						left: geo.left,
						top: geo.top + geo.height - TEXT_H,
						right: geo.left + textWidth,
						bottom: geo.top + geo.height
					};
				}
				occupiedRects.push(rect);
			}
		}
		_createLabelElement(label, canvasWidth, canvasHeight) {
			const el = document.createElement("div");
			el.className = "spectrogram-label-region";
			const isLocked = this._lockedIds.has(label.id);
			const isReadonly = label.readonly === true;
			const isBlocked = isLocked || isReadonly;
			if (isLocked) el.classList.add("spectrogram-label-region--locked");
			if (isReadonly) el.classList.add("spectrogram-label-region--readonly");
			const isSuggestion = label.origin && label.origin !== "manual" && label.origin !== "xeno-canto";
			if (isSuggestion) el.classList.add("suggestion");
			if (this._liveLinkedId && label.id === this._liveLinkedId) el.classList.add("linked-live");
			el.setAttribute("role", "button");
			el.setAttribute("tabindex", "0");
			this._applyGeometryToElement(el, this._toGeometry(label, canvasWidth, canvasHeight));
			const colorStyle = getOverlayColorStyle(label.color);
			if (colorStyle) {
				el.style.setProperty("--spectrogram-label-color", colorStyle.fill);
				el.style.setProperty("--spectrogram-label-edge", colorStyle.edge);
				el.style.setProperty("--spectrogram-label-soft", colorStyle.soft);
			}
			el.dataset.id = label.id;
			el.dataset.start = String(label.start);
			el.dataset.end = String(label.end);
			const readonlyNote = isReadonly ? " [read-only]" : "";
			el.title = `${label.label || "Label"} ${label.start.toFixed(2)}s–${label.end.toFixed(2)}s / ${Math.round(label.freqMin ?? 0)}-${Math.round(label.freqMax ?? 0)} Hz${readonlyNote}`;
			el.setAttribute("aria-label", el.title);
			const aiTagHtml = label.aiSuggested ? `<span class="spectrogram-label-ai-badge" title="AI: ${escapeHtml(label.aiSuggested.model || "")} ${escapeHtml(label.aiSuggested.version || "")}">AI</span>` : "";
			const lockIconHtml = isReadonly ? `<span class="spectrogram-label-lock" title="Read-only (imported from XC)">🔒</span>` : "";
			el.innerHTML = `
            <span class="spectrogram-label-text">${escapeHtml(label.label || "Label")}</span>
            <span class="spectrogram-label-meta">${Math.round(label.freqMin ?? 0)}-${Math.round(label.freqMax ?? 0)} Hz</span>
            ${aiTagHtml}${lockIconHtml}
            ${!isBlocked ? `
            <button class="label-edit-btn" type="button" title="Edit label">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
                </svg>
            </button>
            <button class="label-delete-btn" type="button" title="Delete label">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                    <polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
                </svg>
            </button>
            <span class="label-handle handle-tl" data-mode="resize-tl"></span>
            <span class="label-handle handle-tr" data-mode="resize-tr"></span>
            <span class="label-handle handle-bl" data-mode="resize-bl"></span>
            <span class="label-handle handle-br" data-mode="resize-br"></span>
            <span class="label-handle handle-l" data-mode="resize-l"></span>
            <span class="label-handle handle-r" data-mode="resize-r"></span>
            <span class="label-handle handle-t" data-mode="resize-t"></span>
            <span class="label-handle handle-b" data-mode="resize-b"></span>
            ` : ""}
            ${isSuggestion ? `
            <button class="label-accept-btn" type="button" title="Accept label">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
                    <polyline points="20 6 9 17 4 12"/>
                </svg>
            </button>
            <button class="label-discard-btn" type="button" title="Discard label">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
                    <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
                </svg>
            </button>
            ` : ""}
        `;
			const editBtn = el.querySelector(".label-edit-btn");
			if (editBtn && !isBlocked) {
				editBtn.addEventListener("click", (event) => {
					event.preventDefault();
					event.stopPropagation();
					this._suppressClickUntil = performance.now() + 250;
					this._renameSpectrogramLabelPrompt(label.id);
				});
				editBtn.addEventListener("pointerdown", (event) => {
					event.stopPropagation();
				});
			}
			const deleteBtn = el.querySelector(".label-delete-btn");
			if (deleteBtn && !isBlocked) {
				deleteBtn.addEventListener("click", (event) => {
					event.preventDefault();
					event.stopPropagation();
					this._suppressClickUntil = performance.now() + 250;
					this.remove(label.id);
					this.dispatchEvent(new CustomEvent("spectrogramlabelremove", { detail: { label: { ...label } } }));
				});
				deleteBtn.addEventListener("pointerdown", (event) => {
					event.stopPropagation();
				});
			}
			const acceptBtn = el.querySelector(".label-accept-btn");
			if (acceptBtn) {
				acceptBtn.addEventListener("click", (event) => {
					event.preventDefault();
					event.stopPropagation();
					this._suppressClickUntil = performance.now() + 250;
					this._acceptSuggestion(label.id);
				});
				acceptBtn.addEventListener("pointerdown", (event) => {
					event.stopPropagation();
				});
			}
			const discardBtn = el.querySelector(".label-discard-btn");
			if (discardBtn) {
				discardBtn.addEventListener("click", (event) => {
					event.preventDefault();
					event.stopPropagation();
					this._suppressClickUntil = performance.now() + 250;
					this.remove(label.id);
					this.dispatchEvent(new CustomEvent("spectrogramlabelremove", { detail: { label: { ...label } } }));
				});
				discardBtn.addEventListener("pointerdown", (event) => {
					event.stopPropagation();
				});
			}
			el.addEventListener("click", (event) => {
				if (performance.now() < this._suppressClickUntil) return;
				event.stopPropagation();
				event.preventDefault();
				if (event.ctrlKey || event.metaKey) {
					this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
						id: label.id,
						source: "spectrogram",
						interaction: "ctrl-click"
					} }));
					return;
				}
				this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: label.id,
					source: "spectrogram",
					interaction: "click",
					seekMode: "none"
				} }));
				this.player?._state?._blockSeekClicks?.(260);
				this.player?.playBandpassedSegment?.(label.start, label.end, label.freqMin ?? 0, label.freqMax ?? 0, { labelId: label.id });
			});
			el.addEventListener("dblclick", (event) => {
				event.preventDefault();
				event.stopPropagation();
				if (isBlocked) return;
				this._suppressClickUntil = performance.now() + 250;
				this._renameSpectrogramLabelPrompt(label.id);
			});
			el.addEventListener("pointerdown", (event) => {
				if (event.button !== 0) return;
				if (isBlocked) {
					event.preventDefault();
					event.stopPropagation();
					return;
				}
				if (this.stampMode) {
					this._stampRefLabelId = label.id;
					event.preventDefault();
					event.stopPropagation();
					return;
				}
				const mode = (event.target?.closest?.(".label-handle"))?.dataset?.mode || "move";
				this._startEditInteraction(label.id, mode, event.clientX, event.clientY, el);
				event.preventDefault();
				event.stopPropagation();
			});
			el.addEventListener("pointerenter", (event) => {
				this._lastPointerX = event.clientX;
				this._lastPointerY = event.clientY;
				this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: label.id,
					source: "spectrogram",
					interaction: "hover"
				} }));
			});
			el.addEventListener("pointerleave", () => {
				if (!this._grabbing && !this._editing) this.dispatchEvent(new CustomEvent("labelfocus", { detail: {
					id: null,
					source: "spectrogram",
					interaction: "hover"
				} }));
			});
			el.addEventListener("pointermove", (event) => {
				this._lastPointerX = event.clientX;
				this._lastPointerY = event.clientY;
			});
			return el;
		}
		_applyGeometryToElement(el, geometry) {
			el.style.left = `${geometry.left}px`;
			el.style.top = `${geometry.top}px`;
			el.style.width = `${geometry.width}px`;
			el.style.height = `${geometry.height}px`;
		}
		_toGeometry(label, canvasWidth, canvasHeight) {
			const c = this.player?._state?.coords;
			const duration = c?.duration || Math.max(.001, this.player?.duration || this.player?._state?.audioBuffer?.duration || .001);
			const x1 = c ? clamp(c.timeToPixelX(label.start), 0, canvasWidth) : clamp(label.start / duration * canvasWidth, 0, canvasWidth);
			const x2 = c ? clamp(c.timeToPixelX(label.end), 0, canvasWidth) : clamp(label.end / duration * canvasWidth, 0, canvasWidth);
			const yHigh = c ? clamp(c.frequencyToPixelY(label.freqMax ?? 0), 0, canvasHeight) : 0;
			const yLow = c ? clamp(c.frequencyToPixelY(label.freqMin ?? 0), 0, canvasHeight) : canvasHeight;
			return {
				left: Math.min(x1, x2),
				top: Math.min(yHigh, yLow),
				width: Math.max(1, Math.abs(x2 - x1)),
				height: Math.max(1, Math.abs(yLow - yHigh))
			};
		}
		_ensureStampGhost() {
			if (this._stampGhostEl || !this.overlay) return;
			this._stampGhostEl = document.createElement("div");
			this._stampGhostEl.className = "spectrogram-label-ghost";
			this.overlay.appendChild(this._stampGhostEl);
		}
		_removeStampGhost() {
			if (this._stampGhostEl?.parentNode) this._stampGhostEl.parentNode.removeChild(this._stampGhostEl);
			this._stampGhostEl = null;
		}
		_updateStampGhost(clientX, clientY) {
			if (!this.stampMode || !this.overlay) {
				this._removeStampGhost();
				return;
			}
			const ref = this._getReferenceLabelForDefaults();
			if (!ref) {
				this._removeStampGhost();
				return;
			}
			this._ensureStampGhost();
			const t = this._clientXToTime(clientX);
			const duration = Math.max(.01, ref.end - ref.start || .01);
			const freq = this._stampAxisLock ? null : this._clientYToFreq(clientY);
			const freqSpan = (ref.freqMax ?? 0) - (ref.freqMin ?? 0);
			const freqMin = freq != null ? freq - freqSpan / 2 : ref.freqMin ?? 0;
			const freqMax = freq != null ? freq + freqSpan / 2 : ref.freqMax ?? 0;
			const width = parseFloat(this.overlay.style.width) || 1;
			const height = parseFloat(this.overlay.style.height) || 1;
			const ghost = {
				start: t,
				end: t + duration,
				freqMin,
				freqMax,
				label: ref.label
			};
			const geo = this._toGeometry(ghost, width, height);
			if (!this._stampGhostEl) return;
			const el = this._stampGhostEl;
			el.style.left = `${geo.left}px`;
			el.style.top = `${geo.top}px`;
			el.style.width = `${geo.width}px`;
			el.style.height = `${geo.height}px`;
			const colorStyle = getOverlayColorStyle(ref.color);
			if (colorStyle) {
				el.style.setProperty("--ghost-color", colorStyle.edge);
				el.style.setProperty("--ghost-fill", colorStyle.fill);
			}
			el.textContent = ref.label || "Label";
			el.classList.toggle("axis-locked", this._stampAxisLock);
		}
		/** Exit stamp mode and clean up ghost. */
		exitStampMode() {
			this.stampMode = false;
			this._stampAxisLock = false;
			this._stampRefLabelId = null;
			this._removeStampGhost();
			this.player?.root?.classList.remove("stamp-mode-active");
			this.dispatchEvent(new CustomEvent("stampmodechange", { detail: { active: false } }));
		}
		_bindDrawingInteractions(wrapper) {
			const onPointerDown = (e) => {
				if (this.stampMode) {
					if (e.button === 2) {
						e.preventDefault();
						e.stopPropagation();
						this._suppressContextMenuUntil = performance.now() + 400;
						this.exitStampMode();
						return;
					}
					if (e.button === 0) {
						if (!this.player?._state?.audioBuffer) return;
						const ref = this._getReferenceLabelForDefaults();
						if (!ref) return;
						const t = this._clientXToTime(e.clientX);
						const duration = Math.max(.01, ref.end - ref.start || .01);
						const freq = this._stampAxisLock ? null : this._clientYToFreq(e.clientY);
						const freqSpan = (ref.freqMax ?? 0) - (ref.freqMin ?? 0);
						const isXc = ref.origin === "xeno-canto";
						this.add({
							start: t,
							end: t + duration,
							freqMin: freq != null ? freq - freqSpan / 2 : ref.freqMin,
							freqMax: freq != null ? freq + freqSpan / 2 : ref.freqMax,
							label: ref.label,
							color: ref.color,
							scientificName: ref.scientificName,
							commonName: ref.commonName,
							origin: "manual",
							author: isXc ? "" : ref.author,
							tags: isXc ? {} : ref.tags ? { ...ref.tags } : {}
						});
						e.preventDefault();
						e.stopPropagation();
						return;
					}
				}
				if (e.target?.closest?.(".spectrogram-label-region")) return;
				if (!e.shiftKey && !this.drawMode || e.button !== 0) return;
				if (!this.player?._state?.audioBuffer) return;
				const start = this._clientXToTime(e.clientX);
				const freq = this._clientYToFreq(e.clientY);
				this._drawing = {
					startTime: start,
					startFreq: freq,
					endTime: start,
					endFreq: freq
				};
				this._ensureDraft();
				this._updateDraft();
				e.preventDefault();
				e.stopPropagation();
			};
			const onPointerMove = (e) => {
				if (this.stampMode) this._updateStampGhost(e.clientX, e.clientY);
				if (this._editing) {
					this._updateEditInteraction(e.clientX, e.clientY);
					e.preventDefault();
					e.stopPropagation();
					return;
				}
				if (this._drawing) {
					this._drawing.endTime = this._clientXToTime(e.clientX);
					this._drawing.endFreq = this._clientYToFreq(e.clientY);
					this._updateDraft();
					e.preventDefault();
					e.stopPropagation();
				}
			};
			const onPointerUp = (e) => {
				if (this._editing) {
					this._finishEditInteraction();
					e.preventDefault();
					e.stopPropagation();
					return;
				}
				if (this._drawing) {
					const region = this._finalizeDraft();
					this._clearDraft();
					if (region) this._openNewLabelPicker(region);
					e.preventDefault();
					e.stopPropagation();
				}
			};
			const onContextMenu = (e) => {
				if (this.stampMode || this._suppressContextMenuUntil > performance.now()) {
					e.preventDefault();
					e.stopPropagation();
				}
			};
			const onKeyDown = (e) => {
				const tag = (e.target?.tagName || "").toLowerCase();
				if (tag === "input" || tag === "textarea" || e.target?.isContentEditable) return;
				if (this.stampMode && e.key === "Escape") {
					e.preventDefault();
					this.exitStampMode();
					return;
				}
				if (this.stampMode) {
					if (e.key === "x" || e.key === "X") {
						e.preventDefault();
						this._stampAxisLock = !this._stampAxisLock;
						if (this._stampGhostEl) this._stampGhostEl.classList.toggle("axis-locked", this._stampAxisLock);
						return;
					}
				}
				if (this._editing && !this._grabbing) {
					if (e.key === "x" || e.key === "X") {
						e.preventDefault();
						this._axisConstraint = this._axisConstraint === "x" ? null : "x";
						return;
					}
					if (e.key === "y" || e.key === "Y") {
						e.preventDefault();
						this._axisConstraint = this._axisConstraint === "y" ? null : "y";
						return;
					}
				}
			};
			wrapper.addEventListener("pointerdown", onPointerDown, true);
			document.addEventListener("pointermove", onPointerMove, true);
			document.addEventListener("pointerup", onPointerUp, true);
			document.addEventListener("pointercancel", onPointerUp, true);
			wrapper.addEventListener("contextmenu", onContextMenu, true);
			document.addEventListener("keydown", onKeyDown, true);
			this._domCleanups.push(() => wrapper.removeEventListener("pointerdown", onPointerDown, true));
			this._domCleanups.push(() => document.removeEventListener("pointermove", onPointerMove, true));
			this._domCleanups.push(() => document.removeEventListener("pointerup", onPointerUp, true));
			this._domCleanups.push(() => document.removeEventListener("pointercancel", onPointerUp, true));
			this._domCleanups.push(() => wrapper.removeEventListener("contextmenu", onContextMenu, true));
			this._domCleanups.push(() => document.removeEventListener("keydown", onKeyDown, true));
		}
		_ensureDraft() {
			if (!this.overlay || this._draftEl) return;
			this._draftEl = document.createElement("div");
			this._draftEl.className = "spectrogram-label-draft";
			this.overlay.appendChild(this._draftEl);
		}
		_updateDraft() {
			if (!this._drawing || !this._draftEl || !this.overlay) return;
			const width = parseFloat(this.overlay.style.width) || 1;
			const height = parseFloat(this.overlay.style.height) || 1;
			const preview = this._normalize({
				start: this._drawing.startTime,
				end: this._drawing.endTime,
				freqMin: Math.min(this._drawing.startFreq, this._drawing.endFreq),
				freqMax: Math.max(this._drawing.startFreq, this._drawing.endFreq),
				label: "New label"
			});
			const g = this._toGeometry(preview, width, height);
			this._draftEl.style.left = `${g.left}px`;
			this._draftEl.style.top = `${g.top}px`;
			this._draftEl.style.width = `${g.width}px`;
			this._draftEl.style.height = `${g.height}px`;
		}
		_finalizeDraft() {
			if (!this._drawing) return null;
			const region = this._normalize({
				start: this._drawing.startTime,
				end: this._drawing.endTime,
				freqMin: Math.min(this._drawing.startFreq, this._drawing.endFreq),
				freqMax: Math.max(this._drawing.startFreq, this._drawing.endFreq),
				label: `Label ${this._counter++}`
			});
			const duration = Math.abs(region.end - region.start);
			const freqSpan = Math.abs(region.freqMax - region.freqMin);
			if (duration < .02 || freqSpan < 20) return null;
			return region;
		}
		_clearDraft() {
			this._drawing = null;
			if (this._draftEl?.parentNode) this._draftEl.parentNode.removeChild(this._draftEl);
			this._draftEl = null;
		}
		_openNewLabelPicker(region) {
			const barSel = this.player?.getSpeciesBarSelection?.();
			if (barSel && barSel.name) {
				region.label = barSel.name;
				region.color = barSel.color || colorForName(barSel.name);
				region.scientificName = barSel.scientificName || "";
				region.tags = {};
				this.add(region);
				return;
			}
			const seen = /* @__PURE__ */ new Set();
			const existingLabels = [];
			for (const l of this._items) {
				const name = (l.label || "").trim();
				if (!name) continue;
				const key = name.toLowerCase();
				if (seen.has(key)) continue;
				seen.add(key);
				existingLabels.push({
					name,
					color: l.color || "",
					scientificName: l.scientificName || "",
					tags: l.tags || {}
				});
			}
			const ref = this._getReferenceLabelForDefaults();
			const isXcRef = ref?.origin === "xeno-canto";
			const initialColor = ref?.color || _autoAssignColor(this._items);
			const refName = (ref?.label || "").trim().toLowerCase();
			const initialHex = (getOverlayColorStyle(initialColor)?.hex || "").toLowerCase();
			openLabelNameEditor({
				layer: this,
				player: this.player,
				initialValue: ref?.label || "",
				initialColor,
				initialTags: isXcRef ? null : ref?.tags || null,
				initialScientificName: ref?.scientificName || "",
				existingLabels,
				title: "New Label",
				onSubmit: ({ name, color, scientificName = "", tags = {} }) => {
					const nameChanged = name.trim().toLowerCase() !== refName;
					const colorUntouched = (color || "").toLowerCase() === initialHex;
					region.label = name;
					region.color = nameChanged && colorUntouched ? colorForName(name) : color;
					region.scientificName = String(scientificName || "").trim();
					region.tags = tags;
					this.add(region);
				}
			});
		}
		_startEditInteraction(labelId, mode, clientX, clientY, element) {
			const label = this._items.find((l) => l.id === labelId);
			if (!label) return;
			this._editing = {
				id: labelId,
				mode,
				startX: clientX,
				startY: clientY,
				startTime: this._clientXToTime(clientX),
				startFreq: this._clientYToFreq(clientY),
				startCanvasY: this._clientYToCanvasY(clientY),
				startLabel: { ...label },
				element,
				pending: mode === "move",
				moved: mode !== "move",
				forceSuppressClick: mode !== "move"
			};
			if (mode !== "move") element.classList.add("editing");
		}
		_updateEditInteraction(clientX, clientY) {
			if (!this._editing) return;
			const editing = this._editing;
			const label = this._items.find((l) => l.id === editing.id);
			if (!label) return;
			const duration = Math.max(.001, this.player?.duration || this.player?._state?.audioBuffer?.duration || .001);
			const maxFreq = this._getMaxFreq();
			const c = this.player?._state?.coords;
			const pps = this.player?._state?.pixelsPerSecond || 100;
			const width = Math.max(1, c ? Math.floor(c.timeToScrollX(duration)) : Math.floor(duration * pps));
			const height = Math.max(1, this.player?._state?.d?.spectrogramCanvas?.height || 1);
			const dt = this._clientXToTime(clientX) - editing.startTime;
			const deltaCanvasY = this._clientYToCanvasY(clientY) - editing.startCanvasY;
			const src = editing.startLabel;
			const srcMaxPy = c ? c.frequencyToPixelY(src.freqMax) : 0;
			const srcMinPy = c ? c.frequencyToPixelY(src.freqMin) : height;
			/** Shift a frequency edge by deltaCanvasY in pixel space, then convert back to Hz. */
			const shiftedFreq = (origFreq) => {
				if (!c) return origFreq;
				const origPy = c.frequencyToPixelY(origFreq);
				return c.pixelYToFrequency(origPy + deltaCanvasY);
			};
			if (editing.pending) {
				if (Math.abs(clientX - editing.startX) < 4 && Math.abs(clientY - editing.startY) < 4) return;
				editing.pending = false;
				editing.moved = true;
				editing.element?.classList?.add("editing");
			}
			let next = { ...label };
			const ax = this._axisConstraint;
			switch (editing.mode) {
				case "move":
					if (ax !== "y") {
						next.start = src.start + dt;
						next.end = src.end + dt;
					}
					if (ax !== "x") {
						next.freqMin = shiftedFreq(src.freqMin);
						next.freqMax = shiftedFreq(src.freqMax);
					}
					break;
				case "resize-l":
					next.start = src.start + dt;
					break;
				case "resize-r":
					next.end = src.end + dt;
					break;
				case "resize-t":
					next.freqMax = shiftedFreq(src.freqMax);
					break;
				case "resize-b":
					next.freqMin = shiftedFreq(src.freqMin);
					break;
				case "resize-tl":
					next.start = src.start + dt;
					next.freqMax = shiftedFreq(src.freqMax);
					break;
				case "resize-tr":
					next.end = src.end + dt;
					next.freqMax = shiftedFreq(src.freqMax);
					break;
				case "resize-bl":
					next.start = src.start + dt;
					next.freqMin = shiftedFreq(src.freqMin);
					break;
				case "resize-br":
					next.end = src.end + dt;
					next.freqMin = shiftedFreq(src.freqMin);
					break;
				default: break;
			}
			next = this._normalize({
				...src,
				...next,
				id: src.id,
				label: src.label,
				color: src.color
			});
			if (editing.mode === "move") {
				const timeSpan = Math.max(.01, src.end - src.start);
				next.end = next.start + timeSpan;
				const origPixelSpan = Math.abs(srcMinPy - srcMaxPy);
				const newMaxPy = (c ? c.frequencyToPixelY(next.freqMin ?? 0) : height) - origPixelSpan;
				next.freqMax = c ? c.pixelYToFrequency(Math.max(0, newMaxPy)) : next.freqMax ?? 0;
				if (next.end > duration) {
					const shift = next.end - duration;
					next.start = Math.max(0, next.start - shift);
					next.end = duration;
				}
				if ((next.freqMax ?? 0) > maxFreq) {
					const shift = (next.freqMax ?? 0) - maxFreq;
					next.freqMin = Math.max(0, (next.freqMin ?? 0) - shift);
					next.freqMax = maxFreq;
				}
			}
			Object.assign(label, next);
			this.player?._state?.updateActiveSegmentFromLabel?.(label);
			this.dispatchEvent(new CustomEvent("spectrogramlabelpreview", { detail: { label: { ...label } } }));
			if (editing.element) {
				editing.element.dataset.start = String(label.start);
				editing.element.dataset.end = String(label.end);
				editing.element.title = `${label.label || "Label"} ${label.start.toFixed(2)}s–${label.end.toFixed(2)}s / ${Math.round(label.freqMin ?? 0)}-${Math.round(label.freqMax ?? 0)} Hz`;
				const geometry = this._toGeometry(label, width, height);
				this._applyGeometryToElement(editing.element, geometry);
				const meta = editing.element.querySelector(".spectrogram-label-meta");
				if (meta) meta.textContent = `${Math.round(label.freqMin ?? 0)}-${Math.round(label.freqMax ?? 0)} Hz`;
			}
		}
		/**
		* Blender-style grab: label follows the mouse until click (confirm) or Escape (cancel).
		*/
		startGrab(labelId) {
			const label = this._items.find((l) => l.id === labelId);
			if (!label || this._grabbing) return;
			const el = this.overlay?.querySelector?.(`.spectrogram-label-region[data-id="${label.id}"]`);
			if (!el) return;
			const snapshot = { ...label };
			el.classList.add("editing");
			const startX = this._lastPointerX ?? 0;
			const startY = this._lastPointerY ?? 0;
			this._editing = {
				id: labelId,
				mode: "move",
				startX,
				startY,
				startTime: this._clientXToTime(startX),
				startFreq: this._clientYToFreq(startY),
				startCanvasY: this._clientYToCanvasY(startY),
				startLabel: snapshot,
				element: el,
				pending: false,
				moved: true,
				forceSuppressClick: true
			};
			this._grabbing = true;
			this._axisConstraint = null;
			const onMove = (e) => {
				this._updateEditInteraction(e.clientX, e.clientY);
			};
			const confirm = (e) => {
				e?.preventDefault?.();
				e?.stopPropagation?.();
				cleanup();
				this._finishEditInteraction();
			};
			const cancel = (e) => {
				e?.preventDefault?.();
				e?.stopPropagation?.();
				cleanup();
				Object.assign(label, snapshot);
				el.classList.remove("editing");
				this._editing = null;
				this._suppressClickUntil = performance.now() + 250;
				this.render();
			};
			const onKey = (e) => {
				if (e.key === "Escape") cancel(e);
				if (e.key === "g") confirm(e);
				if (e.key === "x" || e.key === "X") {
					e.preventDefault();
					this._axisConstraint = this._axisConstraint === "x" ? null : "x";
				}
				if (e.key === "y" || e.key === "Y") {
					e.preventDefault();
					this._axisConstraint = this._axisConstraint === "y" ? null : "y";
				}
			};
			const cleanup = () => {
				this._grabbing = false;
				this._axisConstraint = null;
				document.removeEventListener("pointermove", onMove, true);
				document.removeEventListener("pointerdown", confirm, true);
				document.removeEventListener("keydown", onKey, true);
			};
			document.addEventListener("pointermove", onMove, true);
			document.addEventListener("pointerdown", confirm, true);
			document.addEventListener("keydown", onKey, true);
		}
		_renameSpectrogramLabelPrompt(id) {
			const label = this._items.find((l) => l.id === id);
			if (!label) return;
			const current = label.label || "Label";
			const el = this.overlay?.querySelector?.(`.spectrogram-label-region[data-id="${label.id}"]`);
			openLabelNameEditor({
				layer: this,
				player: this.player,
				anchorEl: el || this.overlay,
				initialValue: current,
				initialColor: label.color,
				initialTags: label.tags || {},
				initialScientificName: label.scientificName || "",
				onSubmit: ({ name, color, scientificName = "", tags = {} }) => {
					const currentHex = getOverlayColorStyle(label.color)?.hex || "";
					const nextSci = String(scientificName || "").trim();
					const prevSci = String(label.scientificName || "").trim();
					if (name === current && color === currentHex && nextSci === prevSci && JSON.stringify(tags) === JSON.stringify(label.tags || {})) return;
					label.label = name;
					label.color = color;
					label.tags = tags;
					if (nextSci) label.scientificName = nextSci;
					const labelKey = name.toLowerCase();
					for (const other of this._items) if (other.id !== label.id && (other.label || "").toLowerCase() === labelKey) {
						other.color = color;
						this.dispatchEvent(new CustomEvent("spectrogramlabelupdate", { detail: { label: { ...other } } }));
					}
					this.dispatchEvent(new CustomEvent("spectrogramlabelupdate", { detail: { label: { ...label } } }));
					this.render();
				},
				onDelete: () => {
					this.remove(id);
					this.dispatchEvent(new CustomEvent("spectrogramlabelremove", { detail: { label: { ...label } } }));
				}
			});
		}
		_renameBulkPrompt(ids) {
			if (!ids || ids.length === 0) return;
			const labels = ids.map((id) => this._items.find((l) => l.id === id)).filter(Boolean);
			if (labels.length === 0) return;
			const first = labels[0];
			const anchorEl = this.overlay || null;
			openLabelNameEditor({
				layer: this,
				player: this.player,
				anchorEl,
				initialValue: first.label || "",
				initialColor: first.color,
				initialTags: first.tags || {},
				initialScientificName: first.scientificName || "",
				title: `Rename ${labels.length} label${labels.length > 1 ? "s" : ""}`,
				onSubmit: ({ name, color, scientificName = "", tags = {}, __changed = {} }) => {
					const changed = { ...__changed || {} };
					if (!changed.name && !changed.scientificName && !changed.tags) {
						changed.name = true;
						changed.tags = true;
					}
					for (const lbl of labels) {
						if (changed.name) lbl.label = name;
						if (changed.color) lbl.color = color;
						if (changed.tags) lbl.tags = tags && typeof tags === "object" ? { ...tags } : {};
						if (changed.scientificName) if (String(scientificName || "").trim()) lbl.scientificName = String(scientificName || "").trim();
						else {
							lbl.scientificName = "";
							lbl.commonName = "";
						}
						this.dispatchEvent(new CustomEvent("spectrogramlabelupdate", { detail: { label: { ...lbl } } }));
					}
					this._multiSelectedIds.clear();
					this._updateMultiSelectedVisual();
					this.render();
				}
			});
		}
		_clientXToTime(clientX) {
			return this.player?._state?._clientXToTime?.(clientX, "spectrogram") || 0;
		}
		_clientYToCanvasY(clientY) {
			const state = this.player?._state;
			const c = state?.coords;
			const wrapper = state?.d?.canvasWrapper;
			if (!wrapper || !c) return 0;
			const rect = wrapper.getBoundingClientRect();
			return clamp(clientY - rect.top, 0, rect.height) / Math.max(1, rect.height) * c.canvasHeight;
		}
		_clientYToFreq(clientY) {
			const state = this.player?._state;
			const c = state?.coords;
			const wrapper = state?.d?.canvasWrapper;
			if (!wrapper || !c) return 0;
			const rect = wrapper.getBoundingClientRect();
			const canvasY = clamp(clientY - rect.top, 0, rect.height) / Math.max(1, rect.height) * c.canvasHeight;
			return c.pixelYToFrequency(canvasY);
		}
		_getMaxFreq() {
			const state = this.player?._state;
			return clamp(parseFloat(state?.d?.maxFreqSelect?.value || "10000"), 1, (state?.sampleRateHz || 32e3) / 2);
		}
		_normalize(label) {
			const start = Number(label?.start ?? 0);
			const end = Number(label?.end ?? start);
			const freqMin = Number(label?.freqMin ?? 0);
			const freqMax = Number(label?.freqMax ?? freqMin);
			if (![
				start,
				end,
				freqMin,
				freqMax
			].every(Number.isFinite)) throw new Error("SpectrogramLabelLayer: numeric start/end/freqMin/freqMax required");
			const maxFreq = this._getMaxFreq();
			const s = Math.max(0, Math.min(start, end));
			const duration = Math.max(.001, this.player?.duration || this.player?._state?.audioBuffer?.duration || Math.max(start, end, .001));
			const e = clamp(Math.max(start, end), 0, duration);
			const f0 = clamp(Math.min(freqMin, freqMax), 0, maxFreq);
			const f1 = clamp(Math.max(freqMin, freqMax), 0, maxFreq);
			const labelName = label?.label || "";
			const explicitColor = String(label?.color || "").trim();
			return {
				id: label?.id || `slabel_${Math.random().toString(36).slice(2, 10)}`,
				start: s,
				end: Math.max(s + .01, e),
				freqMin: f0,
				freqMax: Math.max(f0 + 1, f1),
				label: labelName,
				color: explicitColor || colorForName(labelName),
				scientificName: String(label?.scientificName || "").trim(),
				commonName: String(label?.commonName || "").trim(),
				origin: String(label?.origin || "").trim(),
				author: String(label?.author || "").trim(),
				tags: label?.tags && typeof label.tags === "object" ? { ...label.tags } : {},
				readonly: label?.readonly === true,
				aiSuggested: label?.aiSuggested ?? null,
				recordingId: label?.recordingId ?? null
			};
		}
	};
	//#endregion
	//#region src/domain/undoStack.ts
	var UndoStack = class {
		constructor(maxSize = 100) {
			this._stack = [];
			this._index = -1;
			this._maxSize = maxSize;
		}
		push(snapshot) {
			this._commit({
				kind: "snapshot",
				data: snapshot
			});
		}
		record(command) {
			this._commit({
				kind: "command",
				cmd: command
			});
		}
		undo() {
			if (this._index <= 0) return null;
			const current = this._stack[this._index];
			this._index--;
			if (current.kind === "command") {
				try {
					current.cmd.undo();
				} catch (e) {
					console.error("UndoStack: command.undo() failed", e);
				}
				return null;
			}
			const prev = this._stack[this._index];
			return prev.kind === "snapshot" ? prev.data : null;
		}
		redo() {
			if (this._index >= this._stack.length - 1) return null;
			this._index++;
			const entry = this._stack[this._index];
			if (entry.kind === "command") {
				try {
					entry.cmd.execute();
				} catch (e) {
					console.error("UndoStack: command.execute() failed", e);
				}
				return null;
			}
			return entry.data;
		}
		get canUndo() {
			return this._index > 0;
		}
		get canRedo() {
			return this._index < this._stack.length - 1;
		}
		get size() {
			return this._stack.length;
		}
		peekUndoKind() {
			if (this._index <= 0) return null;
			return this._stack[this._index].kind;
		}
		peekUndoDescription() {
			if (this._index <= 0) return null;
			const entry = this._stack[this._index];
			return entry.kind === "command" ? entry.cmd.description ?? null : null;
		}
		clear() {
			this._stack.length = 0;
			this._index = -1;
		}
		_commit(entry) {
			this._stack.length = this._index + 1;
			this._stack.push(entry);
			if (this._stack.length > this._maxSize) this._stack.splice(0, this._stack.length - this._maxSize);
			this._index = this._stack.length - 1;
		}
	};
	//#endregion
	//#region src/shared/EventBus.ts
	var EventBus = class {
		constructor() {
			this._target = new EventTarget();
		}
		emit(event, detail) {
			this._target.dispatchEvent(new CustomEvent(event, { detail }));
		}
		on(event, handler, options) {
			const listener = (e) => handler(e.detail);
			this._target.addEventListener(event, listener, options);
			return () => this._target.removeEventListener(event, listener, options);
		}
		off(event, listener, options) {
			this._target.removeEventListener(event, listener, options);
		}
		/** Forward all events from an EventTarget's single event type onto this bus. */
		forward(source, event, as = event) {
			source.addEventListener(event, (e) => this.emit(as, e.detail));
		}
	};
	//#endregion
	//#region src/infrastructure/xeno-canto/xcHelpers.ts
	function sleep(ms) {
		return new Promise((resolve) => setTimeout(resolve, ms));
	}
	function safeArray(value) {
		return Array.isArray(value) ? value : [];
	}
	function safeString(value) {
		if (value === 0 || value === false) return String(value);
		return value == null ? "" : String(value).trim();
	}
	function safeField(value) {
		if (value === 0 || value === false) return value;
		return value ?? "";
	}
	function firstNonEmpty(values) {
		for (const v of values) {
			const s = String(v ?? "").trim();
			if (s) return s;
		}
		return "";
	}
	function toFiniteNumber(value) {
		if (value == null) return NaN;
		const n = Number(String(value).replace(",", ".").trim());
		return Number.isFinite(n) ? n : NaN;
	}
	function parseJsonSafe(text) {
		if (!text) return null;
		try {
			return JSON.parse(text);
		} catch {
			return null;
		}
	}
	function normalizeXcId(raw) {
		const digits = String(raw || "").replace(/\D+/g, "");
		return digits ? String(Number(digits)) : "";
	}
	function resolveFetch(fetchImpl) {
		if (typeof fetchImpl === "function") return fetchImpl;
		if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
		return null;
	}
	//#endregion
	//#region src/infrastructure/xeno-canto/xenoCantoApi.ts
	var DEFAULT_XC_ENDPOINT = "https://xeno-canto.org/api/3/upload/annotation-set";
	/**
	* @typedef {Object} XenoCantoClientOptions
	* @property {string} [apiKey]
	* @property {string} [endpoint]
	* @property {number} [timeoutMs]
	* @property {number} [retries]
	* @property {number} [retryDelayMs]
	* @property {string} [keyHeaderName]
	* @property {string} [basicAuthCredential]
	* @property {boolean} [sendBasicAuth]
	* @property {typeof fetch} [fetchImpl]
	*/
	/**
	* @typedef {Object} XenoCantoUploadResult
	* @property {boolean} ok
	* @property {number} status
	* @property {string} message
	* @property {string[]} warnings
	* @property {string[]} errors
	* @property {any} response
	* @property {string} rawText
	*/
	/**
	* @typedef {Object} BuildAnnotationSetParams
	* @property {Object} [metadata]
	* @property {Object[]} [annotations]
	* @property {string} [apiVersion]
	*/
	/**
	* @typedef {Object} BuildAnnotationSetResult
	* @property {boolean} ok
	* @property {Object|null} payload
	* @property {string[]} warnings
	* @property {string[]} errors
	* @property {number} [xcUploadEligible]
	*/
	function isRetryableStatus(status) {
		return status === 408 || status === 425 || status === 429 || status >= 500 && status <= 599;
	}
	function encodeBasicAuth(credential) {
		const raw = String(credential || "");
		if (typeof btoa === "function") return btoa(raw);
		throw new Error("No base64 encoder available for Basic auth header.");
	}
	function extractMessage(body, fallback = "") {
		if (!body || typeof body !== "object") return fallback;
		if (typeof body.message === "string" && body.message) return body.message;
		if (typeof body.status === "string" && body.status) return body.status;
		return fallback;
	}
	function collectWarnings(body) {
		return safeArray(body?.warnings).map((v) => safeString(v)).filter(Boolean);
	}
	function collectErrors(body) {
		return safeArray(body?.errors).map((v) => safeString(v)).filter(Boolean);
	}
	function normalizeMetadata(metadata) {
		const m = metadata || {};
		return {
			xcFileNo: safeString(m.xcfileno ?? m["Xeno-canto file no"] ?? m["Xeno-canto file no:"] ?? m["meta-xcfileno"]),
			projectName: safeString(m.project ?? m.Project ?? m["meta-project"] ?? m.project_name),
			annotatorName: safeString(m.annname ?? m["Name of the Annotator"] ?? m["meta-annname"] ?? m.annotator),
			taxonCoverage: safeString(m.taxon_coverage ?? m.taxonCoverage),
			completeness: safeString(m.completeness ?? m.set_completeness),
			setName: safeString(m.setname),
			setCreator: safeString(m.setcreator),
			setLicense: safeString(m.set_license)
		};
	}
	function mapAnnotationRow(row, normalizedMeta) {
		const r = row || {};
		return {
			annotation_source_id: safeField(r.Selection ?? r.selection ?? r["Selection"]),
			sound_file: "",
			xc_nr: safeField(normalizedMeta.xcFileNo),
			annotator: safeField(normalizedMeta.annotatorName),
			annotator_xc_id: "",
			frequency_high: safeField(r.highFreq ?? r.highfreq ?? r["High Freq (Hz)"]),
			frequency_low: safeField(r.lowFreq ?? r.lowfreq ?? r["Low Freq (Hz)"]),
			start_time: (r.beginTime ?? r["Begin Time (s)"]) === 0 ? 0 : safeField(r.beginTime ?? r["Begin Time (s)"]),
			end_time: (r.endTime ?? r["End Time (s)"]) === 0 ? 0 : safeField(r.endTime ?? r["End Time (s)"]),
			scientific_name: safeField(r.scientificName ?? r["Scientific Name"]),
			sound_type: safeField(r.soundType ?? r["Sound type(s)"]),
			date_identified: "",
			sex: safeField(r.sex ?? r["Sex"]),
			life_stage: safeField(r.lifeStage ?? r["Life stage"]),
			animal_seen: "",
			playback_used: "",
			collection_date: "",
			collection_specimen: "",
			temperature: "",
			annotation_remarks: (() => {
				const base = safeField(r.notes ?? r["Notes"]);
				if (r.aiSuggested?.model) {
					const ai = r.aiSuggested;
					const conf = ai.confidence != null ? ` (${(ai.confidence * 100).toFixed(0)}%)` : "";
					const aiNote = `AI: ${ai.model}${ai.version ? " " + ai.version : ""}${conf}`;
					return base ? `${base}; ${aiNote}` : aiNote;
				}
				return base;
			})(),
			overlap: ""
		};
	}
	var XenoCantoApiError = class extends Error {
		/**
		* @param {string} message
		* @param {Object} [details]
		*/
		constructor(message, details = {}) {
			super(String(message));
			this.name = "XenoCantoApiError";
			this.status = Number.isFinite(details.status) ? details.status : 0;
			this.retryable = details.retryable === true;
			this.response = details.response ?? null;
			this.rawText = details.rawText ?? "";
			this.warnings = safeArray(details.warnings);
			this.errors = safeArray(details.errors);
			this.cause = details.cause;
		}
	};
	var XenoCantoApiClient = class {
		/**
		* @param {XenoCantoClientOptions} [options]
		*/
		constructor(options = {}) {
			this._apiKey = safeString(options.apiKey);
			this._endpoint = safeString(options.endpoint) || "https://xeno-canto.org/api/3/upload/annotation-set";
			this._timeoutMs = Math.max(1e3, Number(options.timeoutMs) || 2e4);
			this._retries = Math.max(0, Number(options.retries) || 2);
			this._retryDelayMs = Math.max(0, Number(options.retryDelayMs) || 500);
			this._keyHeaderName = safeString(options.keyHeaderName) || "key";
			this._basicAuthCredential = safeString(options.basicAuthCredential) || "xc:xc";
			this._sendBasicAuth = options.sendBasicAuth !== false;
			this._fetch = resolveFetch(options.fetchImpl);
			if (!this._fetch) throw new XenoCantoApiError("No fetch implementation available.");
		}
		get endpoint() {
			return this._endpoint;
		}
		get timeoutMs() {
			return this._timeoutMs;
		}
		get retries() {
			return this._retries;
		}
		/**
		* @param {string} apiKey
		*/
		setApiKey(apiKey) {
			this._apiKey = safeString(apiKey);
		}
		/**
		* @param {string} endpoint
		*/
		setEndpoint(endpoint) {
			this._endpoint = safeString(endpoint) || "https://xeno-canto.org/api/3/upload/annotation-set";
		}
		/**
		* @param {number} timeoutMs
		*/
		setTimeout(timeoutMs) {
			this._timeoutMs = Math.max(1e3, Number(timeoutMs) || this._timeoutMs);
		}
		/**
		* @param {number} retries
		* @param {number} [retryDelayMs]
		*/
		setRetryPolicy(retries, retryDelayMs = this._retryDelayMs) {
			this._retries = Math.max(0, Number(retries) || 0);
			this._retryDelayMs = Math.max(0, Number(retryDelayMs) || 0);
		}
		/**
		* @param {Object} payload
		* @param {{ signal?: AbortSignal }} [options]
		* @returns {Promise<XenoCantoUploadResult>}
		*/
		async uploadAnnotationSet(payload, options = {}) {
			if (!this._apiKey) throw new XenoCantoApiError("Xeno-canto API key is required.");
			if (!payload || typeof payload !== "object") throw new XenoCantoApiError("Annotation set payload must be an object.");
			const body = JSON.stringify(payload);
			const maxAttempts = this._retries + 1;
			let lastError = null;
			for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
				return await this._uploadOnce(body, options.signal);
			} catch (error) {
				lastError = error;
				const isLastAttempt = attempt >= maxAttempts;
				if (!(error instanceof XenoCantoApiError) || !error.retryable || isLastAttempt) throw error;
				await sleep(this._retryDelayMs * Math.pow(2, attempt - 1));
			}
			throw lastError || new XenoCantoApiError("Xeno-canto upload failed.");
		}
		/**
		* @param {Object} payload
		* @param {{ signal?: AbortSignal }} [options]
		*/
		async uploadAnnotationSetSafe(payload, options = {}) {
			try {
				return {
					ok: true,
					result: await this.uploadAnnotationSet(payload, options),
					error: null
				};
			} catch (error) {
				return {
					ok: false,
					result: null,
					error
				};
			}
		}
		async _uploadOnce(body, externalSignal) {
			const controller = new AbortController();
			const timeoutId = setTimeout(() => controller.abort("timeout"), this._timeoutMs);
			const signal = controller.signal;
			const abortExternal = () => controller.abort("aborted");
			if (externalSignal) if (externalSignal.aborted) controller.abort("aborted");
			else externalSignal.addEventListener("abort", abortExternal, { once: true });
			try {
				const headers = {
					"Content-Type": "application/json",
					Accept: "application/json",
					[this._keyHeaderName]: this._apiKey
				};
				if (this._sendBasicAuth) headers.Authorization = `Basic ${encodeBasicAuth(this._basicAuthCredential)}`;
				const response = await this._fetch(this._endpoint, {
					method: "POST",
					headers,
					body,
					signal
				});
				const rawText = await response.text();
				const responseJson = parseJsonSafe(rawText);
				const warnings = collectWarnings(responseJson);
				const errors = collectErrors(responseJson);
				const message = extractMessage(responseJson, response.statusText || "");
				if (!response.ok || errors.length) throw new XenoCantoApiError(message || `Xeno-canto upload failed with status ${response.status}.`, {
					status: response.status,
					retryable: isRetryableStatus(response.status),
					response: responseJson,
					rawText,
					warnings,
					errors: errors.length ? errors : [message || response.statusText || "Upload failed"]
				});
				return {
					ok: true,
					status: response.status,
					message,
					warnings,
					errors: [],
					response: responseJson,
					rawText
				};
			} catch (error) {
				if (error instanceof XenoCantoApiError) throw error;
				throw new XenoCantoApiError(signal.aborted && signal.reason === "timeout" ? "Xeno-canto upload timed out." : "Network error while uploading to Xeno-canto.", {
					status: 0,
					retryable: true,
					errors: [error?.message || String(error)],
					cause: error
				});
			} finally {
				clearTimeout(timeoutId);
				if (externalSignal) externalSignal.removeEventListener("abort", abortExternal);
			}
		}
	};
	/**
	* Build and validate a Xeno-canto annotation-set payload.
	* @param {BuildAnnotationSetParams} params
	* @returns {BuildAnnotationSetResult}
	*/
	function buildXenoCantoAnnotationSet(params = {}) {
		const metadata = params.metadata || {};
		const annotations = safeArray(params.annotations);
		const warnings = [];
		const errors = [];
		const meta = normalizeMetadata(metadata);
		if (!meta.xcFileNo) errors.push("Missing metadata field 'xcfileno' (Xeno-canto file number).");
		const uploadable = annotations.filter((a) => !a.readonly);
		if (uploadable.length < annotations.length) {
			const skipped = annotations.length - uploadable.length;
			warnings.push(`${skipped} read-only annotation(s) excluded from upload.`);
		}
		if (!uploadable.length) errors.push("No annotations provided.");
		if (errors.length) return {
			ok: false,
			payload: null,
			warnings,
			errors,
			xcUploadEligible: 0
		};
		const mappedAnnotations = uploadable.map((row) => mapAnnotationRow(row, meta));
		const missingTimeRows = mappedAnnotations.filter((a) => a.start_time === "" || a.end_time === "");
		if (missingTimeRows.length) warnings.push(`${missingTimeRows.length} annotation(s) have missing start/end times.`);
		return {
			ok: true,
			payload: {
				set_source: "",
				set_uri: "",
				set_name: meta.setName,
				annotation_software_name_and_version: params.apiVersion || "Audio Workbench",
				set_creator: meta.setCreator,
				set_creator_id: "",
				set_owner: "",
				set_license: meta.setLicense,
				project_uri: "",
				project_name: meta.projectName,
				funding: "",
				scope: [{
					taxon_coverage: meta.taxonCoverage,
					completeness: meta.completeness
				}],
				annotations: mappedAnnotations
			},
			warnings,
			errors: [],
			xcUploadEligible: uploadable.length
		};
	}
	//#endregion
	//#region src/infrastructure/xeno-canto/xenoCantoRecordingsApi.ts
	var DEFAULT_XC_RECORDINGS_ENDPOINT = "https://xeno-canto.org/api/3/recordings";
	function getRecordingScientificName(rec) {
		if (!rec || typeof rec !== "object") return "";
		const sci = firstNonEmpty([
			rec.scientific_name,
			rec.scientificName,
			rec.sci,
			rec.species
		]);
		if (sci) return sci;
		const gen = firstNonEmpty([rec.gen, rec.genus]);
		const sp = firstNonEmpty([rec.sp, rec.species_epithet]);
		return gen && sp ? `${gen} ${sp}` : "";
	}
	async function fetchXenoCantoRecording(xcId, options = {}) {
		const clean = normalizeXcId(xcId);
		if (!clean) throw new Error("Invalid Xeno-canto ID.");
		const fetchFn = resolveFetch(options.fetchImpl);
		if (!fetchFn) throw new Error("No fetch implementation available.");
		const url = `${String(options.endpoint || "https://xeno-canto.org/api/3/recordings").trim()}?query=nr:${clean}`;
		const apiKey = String(options.apiKey || "").trim();
		if (!apiKey) throw new Error("XC API key missing. Open \"XC API\" and paste your key.");
		const res = await fetchFn(`${url}&key=${encodeURIComponent(apiKey)}`);
		if (!res.ok) {
			let msg = "";
			try {
				const body = await res.json();
				msg = firstNonEmpty([body?.message, body?.error]);
			} catch {}
			const status = Number(res.status) || 0;
			if (status === 401 || status === 403) throw new Error(msg || "Invalid or expired API key. Please check your XC API key.");
			throw new Error(msg || `XC API HTTP ${status}`);
		}
		const data = await res.json();
		const recording = (Array.isArray(data?.recordings) ? data.recordings[0] : null) || (Array.isArray(data?.results) ? data.results[0] : null) || (Array.isArray(data) ? data[0] : null) || null;
		if (!recording) throw new Error(`No XC recording found for ${clean}.`);
		return {
			xcId: clean,
			recording,
			raw: data
		};
	}
	function extractXenoCantoRawLabels(recording) {
		if (!recording || typeof recording !== "object") return [];
		const annotationSet = recording["annotation-set"] ?? recording.annotationSet ?? null;
		if (annotationSet && Array.isArray(annotationSet.annotations)) return annotationSet.annotations;
		const candidate = recording.labels ?? recording.label_annotations ?? recording.labelAnnotations ?? recording.annotations ?? recording.annotation ?? recording.sound_labels ?? recording.soundLabels ?? null;
		if (Array.isArray(candidate)) return candidate;
		if (candidate && typeof candidate === "object") {
			if (Array.isArray(candidate.items)) return candidate.items;
			if (Array.isArray(candidate.labels)) return candidate.labels;
			if (Array.isArray(candidate.annotations)) return candidate.annotations;
		}
		return [];
	}
	function _buildXcLabel(src, index, meta) {
		const { xcId, idPrefix, scientificName, recordingCommonName, recordist, nyquist, recSex, recType, recStage, recAnimalSeen, recPlaybackUsed } = meta;
		const start = toFiniteNumber(src.start ?? src.begin ?? src.from ?? src.t0 ?? src.start_time ?? src.startTime);
		const end = toFiniteNumber(src.end ?? src.stop ?? src.to ?? src.t1 ?? src.end_time ?? src.endTime);
		if (!(Number.isFinite(start) && Number.isFinite(end) && end > start)) return null;
		const freqMinRaw = toFiniteNumber(src.freq_min ?? src.low_freq ?? src.f_low ?? src.min_freq ?? src.frequency_low);
		const freqMaxRaw = toFiniteNumber(src.freq_max ?? src.high_freq ?? src.f_high ?? src.max_freq ?? src.frequency_high);
		const freqMin = Number.isFinite(freqMinRaw) ? Math.max(0, freqMinRaw) : 0;
		const freqMax = Number.isFinite(freqMaxRaw) ? Math.min(nyquist, freqMaxRaw) : nyquist;
		if (!(freqMax > freqMin)) return null;
		const label = firstNonEmpty([
			src.scientific_name,
			src.scientificName,
			src.sound_type,
			src.soundType,
			src.label,
			src.name,
			src.value,
			src.type,
			src.event,
			src.comment,
			src.description,
			recordingCommonName,
			"XC label"
		]);
		const annotationId = firstNonEmpty([
			src.annotation_xc_id,
			src.id,
			index + 1
		]);
		const tags = {};
		const sex = firstNonEmpty([
			src.sex,
			src.Sex,
			recSex
		]);
		const soundType = firstNonEmpty([
			src.sound_type,
			src.soundType,
			src.type,
			recType
		]);
		const lifeStage = firstNonEmpty([
			src.stage,
			src.life_stage,
			src.lifeStage,
			src.age,
			recStage
		]);
		if (sex) tags.sex = sex;
		if (soundType) tags.soundType = soundType;
		if (lifeStage) tags.lifeStage = lifeStage;
		const annSciName = firstNonEmpty([src.scientific_name, src.scientificName]) || "";
		const annotator = firstNonEmpty([src.annotator, src.annotator_name]) || "";
		const animalSeen = firstNonEmpty([
			src.animal_seen,
			src.animalSeen,
			recAnimalSeen
		]) || "";
		const playbackUsed = firstNonEmpty([
			src.playback_used,
			src.playbackUsed,
			recPlaybackUsed
		]) || "";
		const remarks = firstNonEmpty([
			src.annotation_remarks,
			src.remarks,
			src.notes
		]) || "";
		if (annotator) tags.annotator = annotator;
		if (animalSeen) tags.animalSeen = animalSeen;
		if (playbackUsed) tags.playbackUsed = playbackUsed;
		if (remarks) tags.remarks = remarks;
		const setMeta = src.original_set_metadata || {};
		const setLicense = firstNonEmpty([setMeta.set_license, setMeta.license]) || "";
		const setName = firstNonEmpty([setMeta.set_name, setMeta.name]) || "";
		const setCreator = firstNonEmpty([setMeta.set_creator, setMeta.creator]) || "";
		const setUri = firstNonEmpty([setMeta.set_uri, setMeta.uri]) || "";
		const setDate = firstNonEmpty([setMeta.set_creation_date, setMeta.creation_date]) || "";
		const annotationSet = setName || setLicense || setUri ? {
			name: setName,
			license: setLicense,
			creator: setCreator,
			uri: setUri,
			date: setDate
		} : null;
		const resolvedSciName = annSciName || scientificName;
		const resolvedCommonName = annSciName ? "" : recordingCommonName;
		return {
			id: `${idPrefix}${xcId}_lbl_${annotationId}`,
			start,
			end,
			freqMin,
			freqMax,
			label,
			scientificName: resolvedSciName,
			commonName: resolvedCommonName,
			origin: "xeno-canto",
			readonly: true,
			author: annotator || recordist || "",
			tags,
			...annotationSet ? { annotationSet } : {}
		};
	}
	function mapXenoCantoLabelsToSpectrogram(rawLabels, options = {}) {
		const arr = Array.isArray(rawLabels) ? rawLabels : [];
		const xcId = normalizeXcId(options.xcId || "");
		const scientificName = getRecordingScientificName(options.recording || {});
		const recordingCommonName = firstNonEmpty([
			options?.recording?.en,
			options?.recording?.common_name,
			options?.recording?.commonName
		]);
		const recordist = firstNonEmpty([
			options?.recording?.rec,
			options?.recording?.recorder,
			options?.recording?.recordist
		]);
		const sampleRate = Number(options.sampleRate);
		const nyquist = Math.max(1e3, Math.floor((Number.isFinite(sampleRate) ? sampleRate : DEFAULT_SAMPLE_RATE) / 2));
		const idPrefix = String(options.idPrefix || "xc").trim() || "xc";
		const rec = options.recording || {};
		const recSex = firstNonEmpty([rec.sex, rec.Sex]) || "";
		const recType = firstNonEmpty([
			rec.type,
			rec.sound_type,
			rec.soundType
		]) || "";
		const recStage = firstNonEmpty([
			rec.stage,
			rec.life_stage,
			rec.lifeStage,
			rec.age
		]) || "";
		const recCountry = firstNonEmpty([rec.cnt, rec.country]) || "";
		const recLocality = firstNonEmpty([
			rec.loc,
			rec.locality,
			rec.location
		]) || "";
		const recDate = firstNonEmpty([rec.date, rec.recording_date]) || "";
		const recTime = firstNonEmpty([rec.time, rec.recording_time]) || "";
		const recQuality = firstNonEmpty([rec.q, rec.quality]) || "";
		const recLicense = firstNonEmpty([rec.lic, rec.license]) || "";
		const recMethod = firstNonEmpty([rec.method]) || "";
		const recRemarks = firstNonEmpty([rec.rmk, rec.remarks]) || "";
		const recLat = firstNonEmpty([rec.lat, rec.latitude]) || "";
		const recLng = firstNonEmpty([
			rec.lon,
			rec.lng,
			rec.longitude
		]) || "";
		const recAlt = firstNonEmpty([rec.alt, rec.altitude]) || "";
		const recAnimalSeen = firstNonEmpty([
			rec["animal-seen"],
			rec.animal_seen,
			rec.animalSeen
		]) || "";
		const recPlaybackUsed = firstNonEmpty([
			rec["playback-used"],
			rec.playback_used,
			rec.playbackUsed
		]) || "";
		const recAlso = Array.isArray(rec.also) ? rec.also.filter(Boolean).join(", ") : "";
		const labels = [];
		for (let i = 0; i < arr.length; i += 1) {
			const label = _buildXcLabel(arr[i] || {}, i, {
				xcId,
				idPrefix,
				scientificName,
				recordingCommonName,
				recordist,
				nyquist,
				recSex,
				recType,
				recStage,
				recAnimalSeen,
				recPlaybackUsed
			});
			if (label) labels.push(label);
		}
		const recordingMeta = {};
		if (recCountry) recordingMeta.country = recCountry;
		if (recLocality) recordingMeta.locality = recLocality;
		if (recDate) recordingMeta.date = recDate;
		if (recTime) recordingMeta.time = recTime;
		if (recQuality) recordingMeta.quality = recQuality;
		if (recLicense) recordingMeta.license = recLicense;
		if (recMethod) recordingMeta.method = recMethod;
		if (recRemarks) recordingMeta.remarks = recRemarks;
		if (recLat) recordingMeta.lat = recLat;
		if (recLng) recordingMeta.lng = recLng;
		if (recAlt) recordingMeta.alt = recAlt;
		if (recAnimalSeen) recordingMeta.animalSeen = recAnimalSeen;
		if (recPlaybackUsed) recordingMeta.playbackUsed = recPlaybackUsed;
		if (recAlso) recordingMeta.backgroundSpecies = recAlso;
		if (recordist) recordingMeta.recordist = recordist;
		return {
			labels,
			recordingMeta
		};
	}
	async function importXenoCantoSpectrogramLabels(xcId, options = {}) {
		const { recording, xcId: clean } = await fetchXenoCantoRecording(xcId, options);
		const rawLabels = extractXenoCantoRawLabels(recording);
		const { labels, recordingMeta } = mapXenoCantoLabelsToSpectrogram(rawLabels, {
			xcId: clean,
			recording,
			sampleRate: options.sampleRate,
			idPrefix: options.idPrefix
		});
		return {
			xcId: clean,
			recording,
			rawLabels,
			labels,
			recordingMeta
		};
	}
	//#endregion
	//#region src/infrastructure/taxonomyResolver.ts
	function normalizeScientificName(raw) {
		const s = String(raw || "").trim().toLowerCase().replace(/[()]/g, " ").replace(/[^a-z\s-]/g, " ").replace(/\s+/g, " ").trim();
		if (!s) return "";
		const parts = s.split(" ");
		return parts.length >= 2 ? `${parts[0]} ${parts[1]}` : parts[0];
	}
	function getGenusAndEpithet(raw) {
		const n = normalizeScientificName(raw);
		if (!n) return {
			genus: "",
			epithet: ""
		};
		const parts = n.split(" ");
		return {
			genus: parts[0] || "",
			epithet: parts[1] || ""
		};
	}
	function levenshtein(a, b) {
		const s = String(a || "");
		const t = String(b || "");
		if (s === t) return 0;
		if (!s) return t.length;
		if (!t) return s.length;
		const m = s.length;
		const n = t.length;
		const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
		for (let i = 0; i <= m; i += 1) dp[i][0] = i;
		for (let j = 0; j <= n; j += 1) dp[0][j] = j;
		for (let i = 1; i <= m; i += 1) for (let j = 1; j <= n; j += 1) {
			const cost = s[i - 1] === t[j - 1] ? 0 : 1;
			dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
		}
		return dp[m][n];
	}
	var TaxonomyResolver = class {
		constructor() {
			this._data = null;
			this._byScientific = /* @__PURE__ */ new Map();
			this._byScientificNorm = /* @__PURE__ */ new Map();
			this._byGenus = /* @__PURE__ */ new Map();
			this._cache = /* @__PURE__ */ new Map();
			this._data = null;
			this._byScientific = /* @__PURE__ */ new Map();
			this._byScientificNorm = /* @__PURE__ */ new Map();
			this._byGenus = /* @__PURE__ */ new Map();
			this._cache = /* @__PURE__ */ new Map();
		}
		get data() {
			return this._data;
		}
		get languages() {
			return this._data?.languages ?? [];
		}
		get speciesCount() {
			return this._data?.speciesCount ?? 0;
		}
		get modelVersion() {
			return this._data?.modelVersion ?? "";
		}
		get records() {
			return this._data?.records ?? [];
		}
		load(data) {
			if (!data || !Array.isArray(data.records) || !Array.isArray(data.languages)) throw new Error("Invalid taxonomy format.");
			this._data = data;
			this._byScientific = new Map(data.records.map((r) => [r.s, r]));
			this._byScientificNorm = /* @__PURE__ */ new Map();
			this._byGenus = /* @__PURE__ */ new Map();
			this._cache = /* @__PURE__ */ new Map();
			for (const rec of data.records) {
				const norm = normalizeScientificName(rec?.s || "");
				if (norm && !this._byScientificNorm.has(norm)) this._byScientificNorm.set(norm, rec);
				const { genus } = getGenusAndEpithet(rec?.s || "");
				if (!genus) continue;
				if (!this._byGenus.has(genus)) this._byGenus.set(genus, []);
				this._byGenus.get(genus).push(rec);
			}
		}
		clear() {
			this._data = null;
			this._byScientific = /* @__PURE__ */ new Map();
			this._byScientificNorm = /* @__PURE__ */ new Map();
			this._byGenus = /* @__PURE__ */ new Map();
			this._cache = /* @__PURE__ */ new Map();
		}
		resolveCommonName(record, lang) {
			if (!record || !record.n) return "";
			return record.n[lang] || record.n.en_uk || Object.values(record.n)[0] || "";
		}
		resolve(scientificName) {
			const raw = String(scientificName || "").trim();
			if (!raw) return null;
			if (this._cache.has(raw)) return this._cache.get(raw);
			let rec = this._byScientific.get(raw) || null;
			if (!rec) {
				const norm = normalizeScientificName(raw);
				if (norm) rec = this._byScientificNorm.get(norm) || null;
			}
			if (!rec) {
				const { genus, epithet } = getGenusAndEpithet(raw);
				const genusCandidates = this._byGenus.get(genus) || [];
				if (epithet && genusCandidates.length) {
					let best = null;
					let bestDist = Number.POSITIVE_INFINITY;
					for (const c of genusCandidates) {
						const { epithet: candidateEpithet } = getGenusAndEpithet(c?.s || "");
						if (!candidateEpithet) continue;
						const d = levenshtein(epithet, candidateEpithet);
						if (d < bestDist) {
							bestDist = d;
							best = c;
						}
					}
					const threshold = Math.max(1, Math.floor(epithet.length * .34));
					if (best && bestDist <= threshold) rec = best;
				}
			}
			this._cache.set(raw, rec || null);
			return rec || null;
		}
	};
	//#endregion
	//#region src/infrastructure/birdnetInference.ts
	/**
	* BirdNET browser inference via TensorFlow.js Web Worker.
	*
	* - TF.js is loaded from CDN at runtime (not bundled).
	* - The BirdNET model (TF.js Layers format) is fetched from a user-supplied URL
	*   or from the bundled model shipped with the GitHub Pages demo.
	* - Audio is resampled to 48 kHz and split into 3-second chunks for prediction.
	*/
	var TFJS_CDN = "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4";
	var TARGET_SR = 48e3;
	var CHUNK_SECONDS = 3;
	var CHUNK_SAMPLES = TARGET_SR * CHUNK_SECONDS;
	/**
	* Default model URL pointing to the bundled BirdNET v2.4 TF.js model
	* on the GitHub Pages demo site.  npm users can use this as a convenient
	* fallback or host the model themselves.
	*/
	var BIRDNET_MODEL_URL = "https://limitlessgreen.github.io/Audio-Workbench/models/birdnet-v2.4/";
	var WORKER_SOURCE = `
importScripts('${TFJS_CDN}');

/* ── MelSpecLayerSimple — custom Keras layer expected by BirdNET v2.4 ── */
class MelSpecLayerSimple extends tf.layers.Layer {
    declare date: unknown;
    declare declare overlap: unknown;
    declare declare minConfidence: unknown;
    declare declare geoThreshold: unknown;
    declare declare onProgress: unknown;
    declare declare sampleRate: unknown;
    declare declare terminate: unknown;
    declare declare postMessage: unknown;
  constructor(config) {
    super(config);
    this.sampleRate  = config.sampleRate;
    this.specShape   = config.specShape;
    this.frameStep   = config.frameStep;
    this.frameLength = config.frameLength;
    this.fmin        = config.fmin;
    this.fmax        = config.fmax;
    this.melFilterbank = tf.tensor2d(config.melFilterbank);
  }

  build(inputShape) {
    this.magScale = this.addWeight(
      'magnitude_scaling', [], 'float32',
      tf.initializers.constant({ value: 1.23 })
    );
    super.build(inputShape);
  }

  computeOutputShape(inputShape) {
    return [inputShape[0], this.specShape[0], this.specShape[1], 1];
  }

  call(inputs) {
    return tf.tidy(() => {
      let input = inputs[0];
      if (input.shape.length === 1) input = input.expandDims(0);
      return tf.stack(tf.split(input, input.shape[0]).map(t => {
        let spec = t.squeeze();
        spec = tf.sub(spec, tf.min(spec, -1, true));
        spec = tf.div(spec, tf.max(spec, -1, true).add(1e-6));
        spec = tf.sub(spec, 0.5);
        spec = tf.mul(spec, 2.0);
        spec = tf.signal.stft(
          spec, this.frameLength, this.frameStep,
          this.frameLength, tf.signal.hannWindow
        );
        spec = tf.cast(spec, 'float32');
        spec = tf.matMul(spec, this.melFilterbank);
        spec = spec.pow(2.0);
        spec = spec.pow(
          tf.div(1.0, tf.add(1.0, tf.exp(this.magScale.read())))
        );
        spec = tf.reverse(spec, -1);
        spec = tf.transpose(spec);
        spec = spec.expandDims(-1);
        return spec;
      }));
    });
  }

  static get className() { return 'MelSpecLayerSimple'; }
}
tf.serialization.registerClass(MelSpecLayerSimple);

/* ── Worker message handler ── */
let model     = null;
let areaModel = null;
let labels    = [];
let geoscores = null; // Float32Array, same length as labels; null = no geo filter

self.onmessage = async (e) => {
  var type = e.data.type;
  var id   = e.data.id;

  if (type === 'load') {
    try {
      await tf.setBackend('webgl');
      self.postMessage({ id: id, type: 'progress', message: 'Loading model\\u2026', percent: 10 });

      var base = e.data.modelUrl;
      if (!base.endsWith('/')) base += '/';

      model = await tf.loadLayersModel(base + 'model.json');

      self.postMessage({ id: id, type: 'progress', message: 'Warming up\\u2026', percent: 50 });
      model.predict(tf.zeros([1, ${CHUNK_SAMPLES}])).dispose();

      self.postMessage({ id: id, type: 'progress', message: 'Loading labels\\u2026', percent: 80 });
      var raw = await fetch(base + 'labels.json').then(function (r) { return r.json(); });
      labels = raw.map(function (l) {
        var s = String(l);
        var idx = s.indexOf('_');
        return idx >= 0
          ? { scientific: s.slice(0, idx), common: s.slice(idx + 1) }
          : { scientific: s, common: '' };
      });

      self.postMessage({ id: id, type: 'progress', message: 'Loading area model\\u2026', percent: 92 });
      try {
        areaModel = await tf.loadGraphModel(base + 'mdata/model.json');
        self.postMessage({ id: id, type: 'loaded', labelCount: labels.length, hasAreaModel: true });
      } catch (_) {
        // Area model is optional — continue without it
        self.postMessage({ id: id, type: 'loaded', labelCount: labels.length, hasAreaModel: false });
      }
    } catch (err) {
      self.postMessage({ id: id, type: 'error', message: String(err && err.message || err) });
    }
  }

  if (type === 'set-location') {
    if (!areaModel) {
      self.postMessage({ id: id, type: 'no-area-model' });
      return;
    }
    try {
      // Use provided date or fall back to today
      var refDate = e.data.date ? new Date(e.data.date) : new Date();
      if (isNaN(refDate.getTime())) refDate = new Date();
      // Calculate ISO week number
      var now = refDate;
      var jan4 = new Date(now.getFullYear(), 0, 4);
      var weekStart = new Date(jan4);
      weekStart.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
      var week = Math.round((now - weekStart) / 604800000) + 1;
      week = Math.max(1, Math.min(52, week));

      var input = tf.tensor2d([[e.data.latitude, e.data.longitude, week]]);
      var scores = await areaModel.predict(input).data();
      input.dispose();
      geoscores = scores;
      self.postMessage({ id: id, type: 'area-scores-set', count: scores.length, week: week });
    } catch (err) {
      self.postMessage({ id: id, type: 'error', message: String(err && err.message || err) });
    }
  }

  if (type === 'clear-location') {
    geoscores = null;
    self.postMessage({ id: id, type: 'location-cleared' });
  }

  if (type === 'get-species-list') {
    var specList = [];
    for (var i = 0; i < labels.length; i++) {
      specList.push({
        scientific: labels[i].scientific,
        common:     labels[i].common,
        geoscore:   geoscores ? geoscores[i] : null,
      });
    }
    self.postMessage({ id: id, type: 'species-list', species: specList });
    return;
  }

  if (type === 'predict') {
    try {
      var pcm  = tf.tensor(e.data.samples, [1, ${CHUNK_SAMPLES}]);
      var res  = model.predict(pcm);
      var probs = await res.data();
      pcm.dispose();
      res.dispose();

      var predictions = [];
      for (var i = 0; i < probs.length; i++) {
        if (probs[i] > 0.01 && i < labels.length) {
          predictions.push({
            scientific: labels[i].scientific,
            common:     labels[i].common,
            confidence: probs[i],
            geoscore:   geoscores ? geoscores[i] : 1,
          });
        }
      }
      self.postMessage({ id: id, type: 'predictions', predictions: predictions });
    } catch (err) {
      self.postMessage({ id: id, type: 'error', message: String(err && err.message || err) });
    }
  }
};
`;
	async function resampleTo48k(channelData, sourceSampleRate) {
		if (sourceSampleRate === TARGET_SR) return channelData instanceof Float32Array ? channelData : Float32Array.from(channelData);
		const duration = channelData.length / sourceSampleRate;
		const targetLength = Math.ceil(duration * TARGET_SR);
		const offCtx = new OfflineAudioContext(1, targetLength, TARGET_SR);
		const buf = offCtx.createBuffer(1, channelData.length, sourceSampleRate);
		buf.getChannelData(0).set(channelData);
		const src = offCtx.createBufferSource();
		src.buffer = buf;
		src.connect(offCtx.destination);
		src.start();
		return (await offCtx.startRendering()).getChannelData(0);
	}
	var _worker = /* @__PURE__ */ new WeakMap();
	var _pending = /* @__PURE__ */ new WeakMap();
	var _nextId = /* @__PURE__ */ new WeakMap();
	var _loaded = /* @__PURE__ */ new WeakMap();
	var _hasAreaModel = /* @__PURE__ */ new WeakMap();
	var _onLoadProgress = /* @__PURE__ */ new WeakMap();
	var _BirdNETInference_brand = /* @__PURE__ */ new WeakSet();
	var BirdNETInference = class {
		constructor() {
			_classPrivateMethodInitSpec(this, _BirdNETInference_brand);
			_classPrivateFieldInitSpec(this, _worker, null);
			_classPrivateFieldInitSpec(this, _pending, /* @__PURE__ */ new Map());
			_classPrivateFieldInitSpec(this, _nextId, 0);
			_classPrivateFieldInitSpec(this, _loaded, false);
			_classPrivateFieldInitSpec(this, _hasAreaModel, false);
			_classPrivateFieldInitSpec(this, _onLoadProgress, null);
		}
		/** Whether the model has been loaded successfully. */
		get loaded() {
			return _classPrivateFieldGet2(_loaded, this);
		}
		/** Whether the area (geo) model was found and loaded alongside the main model. */
		get hasAreaModel() {
			return _classPrivateFieldGet2(_hasAreaModel, this);
		}
		/**
		* Load the BirdNET model into a Web Worker.
		*
		* @param {object}   opts
		* @param {string}   opts.modelUrl    Base URL of the TF.js model directory
		*                                    (must contain model.json + shards + labels.json).
		* @param {function} [opts.onProgress] Called with (message, percent) during loading.
		* @returns {Promise<{ labelCount: number, hasAreaModel: boolean }>}
		*/
		async load({ modelUrl, onProgress }) {
			if (!modelUrl) throw new Error("modelUrl is required");
			const absoluteModelUrl = new URL(modelUrl, globalThis.location?.href).href;
			this.dispose();
			const blob = new Blob([WORKER_SOURCE], { type: "text/javascript" });
			const blobUrl = URL.createObjectURL(blob);
			_classPrivateFieldSet2(_worker, this, new Worker(blobUrl));
			URL.revokeObjectURL(blobUrl);
			_classPrivateFieldSet2(_onLoadProgress, this, onProgress || null);
			_classPrivateFieldGet2(_worker, this).onmessage = (e) => {
				const { id, type } = e.data;
				if (type === "progress") {
					if (_classPrivateFieldGet2(_onLoadProgress, this)) _classPrivateFieldGet2(_onLoadProgress, this).call(this, e.data.message, e.data.percent);
					return;
				}
				const resolve = _classPrivateFieldGet2(_pending, this).get(id);
				if (resolve) {
					_classPrivateFieldGet2(_pending, this).delete(id);
					resolve(e.data);
				}
			};
			const result = await _assertClassBrand(_BirdNETInference_brand, this, _send).call(this, "load", { modelUrl: absoluteModelUrl });
			_classPrivateFieldSet2(_onLoadProgress, this, null);
			if (result.type === "error") throw new Error(result.message);
			_classPrivateFieldSet2(_loaded, this, true);
			_classPrivateFieldSet2(_hasAreaModel, this, result.hasAreaModel === true);
			return {
				labelCount: result.labelCount,
				hasAreaModel: _classPrivateFieldGet2(_hasAreaModel, this)
			};
		}
		/**
		* Update the geographic location used to weight species occurrence probabilities.
		* Requires the area model to be present alongside the main model.
		*
		* @param {number} latitude
		* @param {number} longitude
		* @param {object}  [opts]
		* @param {string|Date} [opts.date]  Recording date — used to determine the season week.
		*                                   Falls back to today if not provided or invalid.
		* @returns {Promise<{ok:boolean, week?:number}>}
		*/
		async setLocation(latitude, longitude, { date } = {}) {
			if (!_classPrivateFieldGet2(_loaded, this)) return { ok: false };
			const dateStr = date instanceof Date ? date.toISOString() : date ?? null;
			const result = await _assertClassBrand(_BirdNETInference_brand, this, _send).call(this, "set-location", {
				latitude,
				longitude,
				date: dateStr
			});
			if (result.type === "area-scores-set") return {
				ok: true,
				week: result.week
			};
			return { ok: false };
		}
		/**
		* Return all loaded species with their current geoscores (occurrence probabilities).
		* Geoscore is null for each species if no location has been set yet.
		*
		* @returns {Promise<Array<{ scientific: string, common: string, geoscore: number|null }>>}
		*/
		async getAllSpecies() {
			if (!_classPrivateFieldGet2(_loaded, this)) return [];
			const result = await _assertClassBrand(_BirdNETInference_brand, this, _send).call(this, "get-species-list", {});
			return result.type === "species-list" ? result.species : [];
		}
		/**
		* Clear the geographic location, disabling geo-filtering.
		* @returns {Promise<void>}
		*/
		async clearLocation() {
			if (!_classPrivateFieldGet2(_loaded, this)) return;
			await _assertClassBrand(_BirdNETInference_brand, this, _send).call(this, "clear-location", {});
		}
		/**
		* Run BirdNET inference on the given mono PCM audio.
		*
		* @param {Float32Array} channelData   Mono PCM samples.
		* @param {object}       [opts]
		* @param {number}       [opts.sampleRate]     Source sample rate in Hz.
		* @param {number}       [opts.overlap]        Overlap between chunks in seconds (0–2.5).
		* @param {number}       [opts.minConfidence]  Minimum confidence to keep a detection (0–1).
		* @param {number}       [opts.geoThreshold]   Minimum geoscore to keep a detection (0 = disabled).
		* @param {function}     [opts.onProgress]     Called with a number 0–100 representing %.
		* @returns {Promise<Array<{ start: number, end: number, scientific: string, common: string, confidence: number, geoscore: number }>>}
		*/
		async analyze(channelData, opts = {}) {
			const { overlap = 0, minConfidence = .25, geoThreshold = 0, onProgress } = opts;
			const sampleRate = opts.sampleRate || TARGET_SR;
			if (!_classPrivateFieldGet2(_loaded, this)) throw new Error("Model not loaded — call load() first.");
			const pcm = await resampleTo48k(channelData, sampleRate);
			const step = CHUNK_SAMPLES - Math.round(overlap * TARGET_SR);
			const totalChunks = Math.max(1, Math.ceil((pcm.length - CHUNK_SAMPLES) / step) + 1);
			const detections = [];
			for (let i = 0; i < totalChunks; i++) {
				const offset = i * step;
				let chunk;
				if (offset + CHUNK_SAMPLES <= pcm.length) chunk = pcm.slice(offset, offset + CHUNK_SAMPLES);
				else {
					chunk = new Float32Array(CHUNK_SAMPLES);
					chunk.set(pcm.subarray(offset));
				}
				if (onProgress) onProgress(Math.round(i / totalChunks * 100));
				const result = await _assertClassBrand(_BirdNETInference_brand, this, _send).call(this, "predict", { samples: chunk }, [chunk.buffer]);
				if (result.type === "error") throw new Error(result.message);
				const startSec = offset / TARGET_SR;
				const endSec = startSec + CHUNK_SECONDS;
				for (const pred of result.predictions) if (pred.confidence >= minConfidence && pred.geoscore >= geoThreshold) detections.push({
					start: startSec,
					end: Math.min(endSec, pcm.length / TARGET_SR),
					scientific: pred.scientific,
					common: pred.common,
					confidence: pred.confidence,
					geoscore: pred.geoscore
				});
			}
			if (onProgress) onProgress(100);
			return detections;
		}
		/** Terminate the worker and release resources. */
		dispose() {
			if (_classPrivateFieldGet2(_worker, this)) {
				_classPrivateFieldGet2(_worker, this).terminate();
				_classPrivateFieldSet2(_worker, this, null);
			}
			_classPrivateFieldGet2(_pending, this).clear();
			_classPrivateFieldSet2(_loaded, this, false);
			_classPrivateFieldSet2(_hasAreaModel, this, false);
			_classPrivateFieldSet2(_onLoadProgress, this, null);
		}
	};
	function _send(type, data = {}, transferables = []) {
		return new Promise((resolve) => {
			var _this$nextId, _this$nextId2;
			const id = (_classPrivateFieldSet2(_nextId, this, (_this$nextId = _classPrivateFieldGet2(_nextId, this), _this$nextId2 = _this$nextId++, _this$nextId)), _this$nextId2);
			_classPrivateFieldGet2(_pending, this).set(id, resolve);
			if (_classPrivateFieldGet2(_worker, this)) _classPrivateFieldGet2(_worker, this).postMessage({
				type,
				id,
				...data
			}, transferables);
		});
	}
	//#endregion
	//#region src/app/BirdNETPlayer.ts
	var WAVESURFER_CDN = "https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.esm.js";
	var DEFAULT_LABEL_TAXONOMY = [
		{
			name: "Bird Call",
			color: "#0ea5e9",
			shortcut: "1"
		},
		{
			name: "Song",
			color: "#22c55e",
			shortcut: "2"
		},
		{
			name: "Chirp",
			color: "#f59e0b",
			shortcut: "3"
		},
		{
			name: "Noise",
			color: "#ef4444",
			shortcut: "4"
		}
	];
	var BirdNETPlayer = class {
		/**
		* @param {HTMLElement} container - the DOM element to mount the player into
		* @param {Object}      [options]
		* @param {Object}      [options.WaveSurfer]     - pre-loaded WaveSurfer constructor
		* @param {boolean}     [options.showFileOpen]    - show Open button (default: true)
		* @param {boolean}     [options.showTransport]   - show transport controls (default: true)
		* @param {boolean}     [options.showTime]        - show time display (default: true)
		* @param {boolean}     [options.showVolume]      - show volume controls (default: true)
		* @param {boolean}     [options.showViewToggles] - show Follow/Loop/Fit/Reset (default: true)
		* @param {boolean}     [options.showZoom]        - show zoom slider (default: true)
		* @param {boolean}     [options.showFFTControls] - show FFT/Freq/Color selects (default: true)
		* @param {boolean}     [options.showDisplayGain] - show Floor/Ceil sliders (default: true)
		* @param {boolean}     [options.showStatusbar]   - show bottom status bar (default: true)
		* @param {boolean}     [options.showOverview]    - show overview navigator (default: true)
		* @param {'both'|'waveform'|'spectrogram'} [options.viewMode] - visible analysis view(s) (default: both)
		* @param {'default'|'hero'} [options.transportStyle] - transport button style (default: default)
		* @param {boolean}     [options.transportOverlay] - centered play overlay without toolbar height (default: false)
		* @param {boolean}     [options.showWaveformTimeline] - show bottom waveform timeline (default: true)
		* @param {'auto'|'on'|'off'} [options.compactToolbar] - responsive toolbar compaction mode (default: auto)
		* @param {number}      [options.followGuardLeftRatio] - left follow guard ratio (default: 0.35)
		* @param {number}      [options.followGuardRightRatio] - right follow guard ratio (default: 0.65)
		* @param {number}      [options.followTargetRatio] - target ratio for viewport centering (default: 0.5)
		* @param {number}      [options.followCatchupDurationMs] - follow catchup tween duration (default: 240)
		* @param {number}      [options.followCatchupSeekDurationMs] - slower follow tween after manual seek (default: 360)
		* @param {number}      [options.smoothLerp] - smooth mode lerp factor (default: 0.18)
		* @param {number}      [options.smoothSeekLerp] - smooth mode lerp after manual seek (default: 0.08)
		* @param {number}      [options.smoothMinStepRatio] - smooth min step ratio (default: 0.03)
		* @param {number}      [options.smoothSeekMinStepRatio] - smooth min step ratio after seek (default: 0.008)
		* @param {number}      [options.smoothSeekFocusMs] - slow-follow window after manual seek (default: 1400)
		* @param {Array<{name: string, color?: string, shortcut?: string}>} [options.labelTaxonomy] - label taxonomy
		*/
		constructor(container, options = {}) {
			if (!container) throw new Error("BirdNETPlayer: container element required");
			this.container = container;
			this.options = options;
			/** @type {PlayerState | null} */
			this._state = null;
			this._events = new EventBus();
			this.annotations = new AnnotationLayer();
			this.spectrogramLabels = new SpectrogramLabelLayer();
			this._linkedLabels = /* @__PURE__ */ new Map();
			this._isSyncingLabels = false;
			this._undoStack = new UndoStack(100);
			this._isRestoring = false;
			this._labelLibrary = /* @__PURE__ */ new Map();
			this._labelSuggestionProvider = null;
			this._labelEditorSuggestionMode = "merge";
			this._labelTaxonomy = this._normalizeTaxonomy(options.labelTaxonomy || DEFAULT_LABEL_TAXONOMY);
			this._activeLabelId = null;
			this._globalKeyHandler = null;
			/** @type {Array<{ name: string, scientificName?: string }>} */
			this._backgroundSpecies = [];
			/** @type {{ name: string, color: string, scientificName: string } | null} */
			this._speciesBarSelection = null;
			this.on = (event, callback, options) => this._events.on(event, (detail) => callback({ detail }), options);
			this.off = (event, listener, options) => this._events.off(event, listener, options);
			this.ready = this._init();
		}
		async _init() {
			this.container.innerHTML = createPlayerHTML(this.options);
			this.root = this.container.querySelector(".daw-shell");
			const WaveSurfer = this.options.WaveSurfer || window.WaveSurfer || (await import(
				/* @vite-ignore */
				WAVESURFER_CDN
)).default;
			this._state = new PlayerState(this.root, WaveSurfer, (event, detail) => this._emit(event, detail), {
				...this.options,
				onDspCommand: (cmd) => {
					this._undoStack.record(cmd);
					this._emit("undochange", {
						canUndo: this._undoStack.canUndo,
						canRedo: this._undoStack.canRedo
					});
				}
			});
			this.annotations.attach(this);
			this.spectrogramLabels.attach(this);
			this._bindLinkedLabelSync();
			this._bindGlobalHotkeys();
			this._injectAnnotationToolbar();
			this._emit("ready", { phase: "init" });
			return this;
		}
		_emit(event, detail = {}) {
			this._events.emit(event, detail);
		}
		/**
		* Show the spectrogram loading overlay with a custom message.
		* Useful for displaying feedback during network fetches before DSP starts.
		* Pass no argument (or call hideLoadingOverlay) to dismiss it.
		* @param {string} [text]
		*/
		showLoadingOverlay(text = "Loading…") {
			const overlay = this._state?.d?.recomputingOverlay;
			if (!overlay) return;
			const textEl = overlay.querySelector("span:last-child");
			if (textEl) textEl.textContent = text;
			overlay.hidden = false;
		}
		/** Hide the loading overlay that was shown via showLoadingOverlay(). */
		hideLoadingOverlay() {
			const overlay = this._state?.d?.recomputingOverlay;
			if (overlay) overlay.hidden = true;
		}
		/**
		* Load audio from a URL (http, blob:, data: URLs all supported).
		* @param {string} url
		* @returns {Promise<void>}
		*/
		async loadUrl(url) {
			await this.ready;
			return this._state?.loadUrl(url);
		}
		/**
		* Load audio from a File object (e.g. from an <input type="file">).
		* @param {File} file
		* @returns {Promise<void>}
		*/
		async loadFile(file) {
			await this.ready;
			return this._state?.loadFile(file);
		}
		/** Current playback time in seconds */
		get currentTime() {
			return this._state?._getCurrentTime() || 0;
		}
		/** Duration of loaded audio in seconds */
		get duration() {
			return this._state?.audioBuffer?.duration || 0;
		}
		play() {
			this._state?.wavesurfer?.play();
		}
		pause() {
			this._state?.wavesurfer?.pause();
		}
		stop() {
			this._state?._stopPlayback();
		}
		togglePlayPause() {
			this._state?._togglePlayPause();
		}
		/**
		* Play a time-bounded segment, optionally looping.
		* @param {number} startSec
		* @param {number} endSec
		* @param {PlaySegmentOptions} [options]
		*/
		playSegment(startSec, endSec, options) {
			this._state?.playSegment(startSec, endSec, options);
		}
		/**
		* Play a segment through a bandpass filter centered on the given frequency range.
		* @param {number} startSec
		* @param {number} endSec
		* @param {number} freqMinHz
		* @param {number} freqMaxHz
		* @param {PlaySegmentOptions} [options]
		*/
		playBandpassedSegment(startSec, endSec, freqMinHz, freqMaxHz, options) {
			this._state?.playBandpassedSegment(startSec, endSec, freqMinHz, freqMaxHz, options);
		}
		/**
		* Add or update a single waveform annotation. Returns the annotation id.
		* @param {AnnotationInput} annotation
		* @returns {string} id
		*/
		addAnnotation(annotation) {
			const id = annotation?.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
			const existing = this._linkedLabels.get(id);
			const merged = this._normalizeLinkedLabel({
				...existing,
				...annotation,
				id,
				label: annotation?.label ?? annotation?.species ?? existing?.label ?? "Label"
			});
			this._linkedLabels.set(id, merged);
			this._syncLinkedLabelsToLayers();
			return id;
		}
		/**
		* Replace all waveform annotations at once.
		* @param {AnnotationInput[]} annotations
		*/
		setAnnotations(annotations) {
			const next = /* @__PURE__ */ new Map();
			for (const ann of annotations || []) {
				const id = ann?.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
				const existing = this._linkedLabels.get(id);
				next.set(id, this._normalizeLinkedLabel({
					...existing,
					...ann,
					id,
					label: ann?.label ?? ann?.species ?? existing?.label ?? "Label"
				}));
			}
			this._linkedLabels = next;
			this._syncLinkedLabelsToLayers();
		}
		clearAnnotations() {
			this._linkedLabels.clear();
			this._syncLinkedLabelsToLayers();
		}
		/** @returns {string} Tab-separated Raven selection table */
		exportAnnotationsRaven() {
			return this.annotations.exportRavenFormat(this._toAnnotationList());
		}
		/**
		* Add or update a single spectrogram label. Returns the label id.
		* @param {SpectrogramLabelInput} label
		* @returns {string} id
		*/
		addSpectrogramLabel(label) {
			const id = label?.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
			const existing = this._linkedLabels.get(id);
			const merged = this._normalizeLinkedLabel({
				...existing,
				...label,
				id,
				species: label?.species ?? label?.label ?? existing?.species ?? "",
				label: label?.label ?? existing?.label ?? label?.species ?? "Label"
			});
			this._linkedLabels.set(id, merged);
			this._syncLinkedLabelsToLayers();
			return id;
		}
		/**
		* Replace all spectrogram labels at once.
		* @param {SpectrogramLabelInput[]} labels
		*/
		setSpectrogramLabels(labels) {
			const next = /* @__PURE__ */ new Map();
			for (const lbl of labels || []) {
				const id = lbl?.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
				const existing = this._linkedLabels.get(id);
				next.set(id, this._normalizeLinkedLabel({
					...existing,
					...lbl,
					id,
					species: lbl?.species ?? lbl?.label ?? existing?.species ?? "",
					label: lbl?.label ?? existing?.label ?? lbl?.species ?? "Label"
				}));
			}
			this._linkedLabels = next;
			this._syncLinkedLabelsToLayers();
		}
		clearSpectrogramLabels() {
			this._linkedLabels.clear();
			this._backgroundSpecies = [];
			this.setSpeciesBar("");
			this._syncLinkedLabelsToLayers();
		}
		/**
		* Rename an existing label by id. Returns false if the id is not found.
		* @param {string} id
		* @param {string} name
		* @returns {boolean}
		*/
		renameLabel(id, name) {
			const key = String(id || "").trim();
			const value = String(name || "").trim();
			if (!key || !value) return false;
			const current = this._linkedLabels.get(key);
			if (!current) return false;
			this._linkedLabels.set(key, this._normalizeLinkedLabel({
				...current,
				id: key,
				label: value,
				species: value
			}));
			this._syncLinkedLabelsToLayers();
			return true;
		}
		/**
		* Return label names from the internal usage library, ranked by frequency.
		* @param {string} [prefix]
		* @param {number} [limit]
		* @returns {string[]}
		*/
		getLabelSuggestions(prefix = "", limit = 10) {
			const q = String(prefix || "").trim().toLowerCase();
			return Array.from(this._labelLibrary.entries()).filter(([name]) => !q || name.toLowerCase().includes(q)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, Math.max(1, limit)).map(([name]) => name);
		}
		/**
		* Register a custom suggestion provider called when the label editor is open.
		* @param {((query: string, limit: number) => (string | LabelSuggestionItem)[]) | null} provider
		*/
		setLabelSuggestionProvider(provider = null) {
			this._labelSuggestionProvider = typeof provider === "function" ? provider : null;
		}
		/**
		* @param {'merge'|'custom-only'} [mode]
		*/
		setLabelEditorSuggestionMode(mode = "merge") {
			const m = String(mode || "").trim().toLowerCase();
			this._labelEditorSuggestionMode = m === "custom-only" ? "custom-only" : "merge";
		}
		getLabelEditorSuggestionMode() {
			return this._labelEditorSuggestionMode || "merge";
		}
		/**
		* Invoke the registered suggestion provider and return normalized results.
		* @param {string} [query]
		* @param {number} [limit]
		* @returns {LabelSuggestionItem[]}
		*/
		getLabelEditorSuggestions(query = "", limit = 12) {
			if (!this._labelSuggestionProvider) return [];
			try {
				const out = this._labelSuggestionProvider(String(query || ""), Math.max(1, Number(limit) || 12));
				if (!Array.isArray(out)) return [];
				return out.map((item) => {
					if (typeof item === "string") {
						const name = item.trim();
						return name ? { name } : null;
					}
					const name = String(item?.name || "").trim();
					if (!name) return null;
					const color = String(item?.color || "").trim();
					const scientificName = String(item?.scientificName || "").trim();
					const detail = String(item?.detail || "").trim();
					return {
						name,
						color: color || "",
						scientificName: scientificName || "",
						detail: detail || ""
					};
				}).filter((x) => x !== null);
			} catch {
				return [];
			}
		}
		/** @returns {Array<{name: string, color: string, shortcut: string}>} */
		getLabelTaxonomy() {
			return this._labelTaxonomy.map((item) => ({ ...item }));
		}
		/**
		* @param {Array<{name: string, color?: string, shortcut?: string}>} [taxonomy]
		*/
		setLabelTaxonomy(taxonomy = []) {
			this._labelTaxonomy = this._normalizeTaxonomy(taxonomy);
			this._syncLinkedLabelsToLayers();
		}
		/**
		* Override or extend the tag presets shown in the label editor dialog.
		* Each entry: { key, label, options: string[] }
		* @param {Array<{ key: string, label?: string, options: string[] }>} presets
		*/
		setTagPresets(presets) {
			this._tagPresets = (presets || []).map((p) => ({
				key: String(p.key || ""),
				label: String(p.label || p.key || ""),
				options: Array.isArray(p.options) ? p.options.map(String) : []
			})).filter((p) => p.key);
		}
		/** @returns {Array<{key: string, label: string, options: string[]}> | null} */
		getTagPresets() {
			return this._tagPresets ? this._tagPresets.map((p) => ({
				...p,
				options: p.options.slice()
			})) : null;
		}
		/**
		* Apply a taxonomy entry to an existing label by shortcut key or array index.
		* @param {string} id
		* @param {number | string} shortcutOrIndex
		* @returns {boolean}
		*/
		applyTaxonomyToLabel(id, shortcutOrIndex) {
			const key = String(id || "").trim();
			const current = this._linkedLabels.get(key);
			if (!current) return false;
			const index = typeof shortcutOrIndex === "number" ? shortcutOrIndex : this._labelTaxonomy.findIndex((t) => t.shortcut === String(shortcutOrIndex));
			if (index < 0 || index >= this._labelTaxonomy.length) return false;
			const tax = this._labelTaxonomy[index];
			const next = this._normalizeLinkedLabel({
				...current,
				id: key,
				label: tax.name,
				species: tax.name,
				color: tax.color || current.color
			});
			this._linkedLabels.set(key, next);
			this._activeLabelId = key;
			this._syncLinkedLabelsToLayers();
			this._emit("labeltaxonomyapply", {
				id: key,
				taxonomy: { ...tax }
			});
			return true;
		}
		/**
		* Inject a pre-computed spectrogram as raw data (Float32Array or base64-encoded).
		* The player applies its own colorization pipeline (contrast, color map).
		*
		* @param {Float32Array|ArrayBuffer|string} data - spectrogram values.
		*   If string, decoded as base64 → Float32 (little-endian).
		* @param {number} nFrames - number of time frames
		* @param {number} nMels   - number of frequency bins
		* @param {Object} [options]
		* @param {string} [options.mode='mel'] - 'mel'|'linear' (affects freq axis labels)
		* @param {number} [options.sampleRate]   - sample rate for freq labels (default: from audio)
		*/
		async setSpectrogramData(data, nFrames, nMels, options = {}) {
			await this.ready;
			return this._state?._spectro.setExternalData(data, nFrames, nMels, options);
		}
		/**
		* Inject a pre-rendered spectrogram image (bypasses all DSP + colorization).
		*
		* @param {string|HTMLImageElement|HTMLCanvasElement} image - base64 data-URL,
		*   regular URL, or an already-loaded Image/Canvas element.
		* @param {Object} [options]
		* @param {number}   [options.sampleRate]  - sample rate for frequency axis labels
		* @param {number[]} [options.freqRange]   - [fMin, fMax] in Hz the image covers
		* @param {string}   [options.freqScale]   - frequency axis mapping: 'linear' | 'mel' | 'log'
		*/
		async setSpectrogramImage(image, options = {}) {
			await this.ready;
			return this._state?._spectro.setExternalImage(image, options);
		}
		/**
		* Clear any externally-injected spectrogram and re-enable auto-compute.
		*/
		async clearExternalSpectrogram() {
			await this.ready;
			if (!this._state) return;
			this._state._spectro.clearExternalMode();
			this._state._spectro.setDspControlsEnabled(true);
			if (this._state.audioBuffer) this._state._spectro.generate();
		}
		/**
		* Update follow-mode / viewport config at runtime (same keys as constructor options).
		* @param {Partial<{followGuardLeftRatio: number, followGuardRightRatio: number, followTargetRatio: number,
		*   followCatchupDurationMs: number, followCatchupSeekDurationMs: number,
		*   smoothLerp: number, smoothSeekLerp: number, smoothMinStepRatio: number,
		*   smoothSeekMinStepRatio: number, smoothSeekFocusMs: number}>} [config]
		* @returns {object | null}
		*/
		setPlaybackViewportConfig(config = {}) {
			return this._state?.updatePlaybackViewportConfig?.(config) || null;
		}
		/** @returns {object | null} */
		getPlaybackViewportConfig() {
			return this._state?.getPlaybackViewportConfig?.() || null;
		}
		/** Notify the player that its container was resized externally. */
		resize() {
			const s = this._state;
			if (!s) return;
			s._queueCompactToolbarLayoutRefresh?.();
			if (!s.audioBuffer) return;
			s._drawMainWaveform();
			s._drawOverviewWaveform();
			s._syncOverviewWindowToViewport();
			if (s._spectro.hasData) s._drawSpectrogram();
			s._emit("viewresize", {
				waveformHeight: s.waveformDisplayHeight,
				spectrogramHeight: s.spectrogramDisplayHeight
			});
		}
		/** Tear down the player and free resources */
		destroy() {
			if (this._globalKeyHandler) {
				document.removeEventListener("keydown", this._globalKeyHandler, true);
				this._globalKeyHandler = null;
			}
			this.annotations.detach();
			this.spectrogramLabels.detach();
			this._state?.dispose();
			this._state = null;
			this.container.innerHTML = "";
		}
		_bindLinkedLabelSync() {
			const fwd = (layer, event, handler) => {
				const cb = (e) => {
					const ce = e;
					handler(ce);
					this._emit(event, ce.detail);
				};
				layer.addEventListener(event, cb);
			};
			const onLabelFocus = (e) => {
				const ce = e;
				const id = String(ce.detail?.id || "").trim() || null;
				if (ce.detail?.interaction === "click") this._activeLabelId = id;
				else if (!this._activeLabelId || id === null) this._activeLabelId = id;
				this._emit("labelfocus", ce.detail);
			};
			this.annotations.addEventListener("labelfocus", onLabelFocus);
			this.spectrogramLabels.addEventListener("labelfocus", onLabelFocus);
			fwd(this.annotations, "annotationpreview", (ce) => this._previewFromLayer("annotation", ce.detail.annotation));
			fwd(this.annotations, "annotationcreate", (ce) => this._upsertFromLayer("annotation", ce.detail.annotation));
			fwd(this.annotations, "annotationupdate", (ce) => this._upsertFromLayer("annotation", ce.detail.annotation));
			fwd(this.annotations, "annotationremove", (ce) => this._removeFromLinkedLabels(ce.detail.annotation));
			fwd(this.spectrogramLabels, "spectrogramlabelpreview", (ce) => this._previewFromLayer("spectrogram", ce.detail.label));
			fwd(this.spectrogramLabels, "spectrogramlabelcreate", (ce) => this._upsertFromLayer("spectrogram", ce.detail.label));
			fwd(this.spectrogramLabels, "spectrogramlabelupdate", (ce) => this._upsertFromLayer("spectrogram", ce.detail.label));
			fwd(this.spectrogramLabels, "spectrogramlabelremove", (ce) => this._removeFromLinkedLabels(ce.detail.label));
			this.spectrogramLabels.addEventListener("stampmodechange", (e) => {
				const ce = e;
				const on = ce.detail?.active ?? false;
				if (this._stampBtn) this._stampBtn.classList.toggle("active", on);
				this.root?.classList.toggle("stamp-mode-active", on);
				if (!on) this.spectrogramLabels._stampAxisLock = false;
				this._emit("stampmodechange", ce.detail);
			});
			const fwdTag = (layer) => {
				for (const ev of [
					"tagcustomadd",
					"tagcustomremove",
					"tagcustomrename"
				]) layer.addEventListener(ev, (e) => this._emit(
					ev,
					/** @type {CustomEvent} */
					e.detail
				));
			};
			fwdTag(this.annotations);
			fwdTag(this.spectrogramLabels);
		}
		_bindGlobalHotkeys() {
			this._globalKeyHandler = (event) => {
				const targetEl = event.target;
				const tag = targetEl?.tagName?.toLowerCase?.() || "";
				if (tag === "input" || tag === "textarea" || targetEl?.isContentEditable) return;
				const key = String(event.key || "");
				const ctrl = event.ctrlKey || event.metaKey;
				if (ctrl && key === "c" && this._activeLabelId) {
					event.preventDefault();
					this.spectrogramLabels?.copyLabel(this._activeLabelId);
					return;
				}
				if (ctrl && key === "z") {
					event.preventDefault();
					this.undo();
					return;
				}
				if (ctrl && (key === "y" || key === "Z" && event.shiftKey)) {
					event.preventDefault();
					this.redo();
					return;
				}
				if (ctrl && key === "v") {
					event.preventDefault();
					const pasted = this.spectrogramLabels?.pasteLabel();
					if (pasted) this._emit?.("spectrogramlabelcreate", { label: { ...pasted } });
					return;
				}
				if (ctrl && key === "d") {
					event.preventDefault();
					if (this.spectrogramLabels?.stampMode) this.spectrogramLabels.exitStampMode();
					else {
						if (this.spectrogramLabels) {
							this.spectrogramLabels.stampMode = true;
							if (this._activeLabelId) this.spectrogramLabels._stampRefLabelId = this._activeLabelId;
							this.spectrogramLabels.drawMode = false;
							this.root?.classList.remove("draw-mode-active");
							if (this._drawBtn) this._drawBtn.classList.remove("active");
						}
						this.root?.classList.add("stamp-mode-active");
						if (this._stampBtn) this._stampBtn.classList.add("active");
					}
					return;
				}
				if (!this._activeLabelId) return;
				const inAxisMode = this.spectrogramLabels?.stampMode || this.spectrogramLabels?._grabbing || this.spectrogramLabels?._editing;
				if (key === "Delete" || key === "Backspace" || key === "x" && !inAxisMode) {
					event.preventDefault();
					const id = this._activeLabelId;
					const sLabel = this.spectrogramLabels?.labels?.find((l) => l.id === id);
					if (sLabel) {
						this.spectrogramLabels.remove(id);
						this._emit?.("spectrogramlabelremove", { label: { ...sLabel } });
					} else {
						const ann = this.annotations?.annotations?.find((a) => a.id === id);
						if (ann) {
							this.annotations.remove(id);
							this._emit?.("annotationremove", { annotation: { ...ann } });
						}
					}
					this._activeLabelId = null;
					return;
				}
				if (key === "g") {
					event.preventDefault();
					if (this.spectrogramLabels?.labels?.find((l) => l.id === this._activeLabelId)) this.spectrogramLabels.startGrab(this._activeLabelId);
					else this.annotations?.startGrab(this._activeLabelId);
					return;
				}
				if (!/^[1-9]$/.test(key)) return;
				const idx = Number(key) - 1;
				if (idx >= this._labelTaxonomy.length) return;
				event.preventDefault();
				this.applyTaxonomyToLabel(this._activeLabelId, idx);
			};
			document.addEventListener("keydown", this._globalKeyHandler, true);
		}
		_injectAnnotationToolbar() {
			const secondary = this.root?.querySelector("[data-aw=\"toolbarSecondary\"]");
			if (!secondary) return;
			const sep = document.createElement("div");
			sep.className = "toolbar-sep";
			secondary.appendChild(sep);
			const drawBtn = document.createElement("button");
			drawBtn.className = "toolbar-btn toggle-btn active";
			drawBtn.title = "Draw label (Shift+Drag)";
			drawBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.375 2.625a1 1 0 013 3l-9.013 9.014a2 2 0 01-.853.505l-2.873.84a.5.5 0 01-.62-.62l.84-2.873a2 2 0 01.506-.852z"/></svg> Draw`;
			this._drawBtn = drawBtn;
			this.root?.classList.add("draw-mode-active");
			drawBtn.addEventListener("click", () => {
				const on = !this.spectrogramLabels.drawMode;
				this.spectrogramLabels.drawMode = on;
				if (on && this.spectrogramLabels) this.spectrogramLabels.exitStampMode();
				drawBtn.classList.toggle("active", on);
				this.root?.classList.toggle("draw-mode-active", on);
			});
			secondary.appendChild(drawBtn);
			const stampBtn = document.createElement("button");
			stampBtn.className = "toolbar-btn toggle-btn";
			stampBtn.title = "Stamp mode (Ctrl+D)";
			stampBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg> Stamp`;
			this._stampBtn = stampBtn;
			stampBtn.addEventListener("click", () => {
				if (this.spectrogramLabels.stampMode) this.spectrogramLabels.exitStampMode();
				else {
					this.spectrogramLabels.stampMode = true;
					if (this._activeLabelId) this.spectrogramLabels._stampRefLabelId = this._activeLabelId;
					this.spectrogramLabels.drawMode = false;
					if (this._drawBtn) this._drawBtn.classList.remove("active");
					this.root?.classList.remove("draw-mode-active");
					stampBtn.classList.add("active");
					this.root?.classList.add("stamp-mode-active");
				}
			});
			secondary.appendChild(stampBtn);
		}
		/** Public getter for current species bar selection. */
		getSpeciesBarSelection() {
			return this._speciesBarSelection ? { ...this._speciesBarSelection } : null;
		}
		/**
		* Programmatically set the species search bar (e.g. after loading XC recording).
		* @param {string} name
		* @param {{ color?: string, scientificName?: string }} [opts]
		*/
		setSpeciesBar(name, opts = {}) {
			const trimmed = (name || "").trim();
			this._speciesBarSelection = trimmed ? {
				name: trimmed,
				color: opts.color || colorForName(trimmed),
				scientificName: opts.scientificName || ""
			} : null;
			this._emit("speciesbarchange", { selection: this._speciesBarSelection ? { ...this._speciesBarSelection } : null });
		}
		/**
		* Set background species (XC "also" field) to appear in search suggestions.
		* @param {Array<string | { name: string, scientificName?: string }>} species
		*/
		setBackgroundSpecies(species) {
			this._backgroundSpecies = (species || []).map((s) => typeof s === "string" ? { name: s } : {
				name: s.name || "",
				scientificName: s.scientificName || ""
			}).filter((s) => s.name);
		}
		/** @returns {Array<{name: string, scientificName?: string}>} */
		getBackgroundSpecies() {
			return this._backgroundSpecies.map((s) => ({ ...s }));
		}
		/**
		* Live preview of an in-progress drag/resize — update _linkedLabels and
		* mirror the change into the OTHER layer so both views stay in sync.
		* @param {'annotation'|'spectrogram'} source
		* @param {object} item
		*/
		_previewFromLayer(source, item) {
			if (this._isSyncingLabels || !item) return;
			const id = item.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
			const existing = this._linkedLabels.get(id);
			const next = this._normalizeItemFromSource(source, {
				...item,
				id
			}, existing ?? void 0);
			this._linkedLabels.set(id, next);
			this._state?.updateActiveSegmentFromLabel?.(next);
			if (source === "annotation") {
				this.spectrogramLabels.setLiveLinkedId(id);
				this.spectrogramLabels.set(this._toSpectrogramLabelList());
			} else {
				this.annotations.setLiveLinkedId(id);
				this.annotations.set(this._toAnnotationList());
			}
		}
		/**
		* Commit a create/update event — persist into _linkedLabels and sync both layers.
		* @param {'annotation'|'spectrogram'} source
		* @param {object} item
		*/
		_upsertFromLayer(source, item) {
			if (this._isSyncingLabels || !item) return;
			const id = item.id || `lbl_${Math.random().toString(36).slice(2, 10)}`;
			const existing = this._linkedLabels.get(id);
			const next = this._normalizeItemFromSource(source, {
				...item,
				id
			}, existing ?? void 0);
			this._linkedLabels.set(id, next);
			this._state?.updateActiveSegmentFromLabel?.(next);
			this.annotations.setLiveLinkedId(null);
			this.spectrogramLabels.setLiveLinkedId(null);
			this._syncLinkedLabelsToLayers();
		}
		/**
		* Build the normalized label name from the source layer's field conventions.
		* - annotation layer: canonical name field is `species`
		* - spectrogram layer: canonical name field is `label`
		* @param {'annotation'|'spectrogram'} source
		* @param {object} item   item with .id already set
		* @param {object} [existing]
		*/
		_normalizeItemFromSource(source, item, existing) {
			if (source === "spectrogram") {
				const nextName = String(item?.label || item?.species || existing?.label || existing?.species || "Label").trim();
				return this._normalizeLinkedLabel({
					...existing,
					...item,
					species: nextName,
					label: nextName
				});
			}
			return this._normalizeLinkedLabel({
				...existing,
				...item,
				label: item?.species ?? existing?.label ?? "Label"
			});
		}
		_removeFromLinkedLabels(label) {
			if (this._isSyncingLabels || !label?.id) return;
			this._linkedLabels.delete(label.id);
			if (this._activeLabelId === label.id) this._activeLabelId = null;
			this.annotations.setLiveLinkedId(null);
			this.spectrogramLabels.setLiveLinkedId(null);
			this._syncLinkedLabelsToLayers();
		}
		_syncLinkedLabelsToLayers() {
			if (!this._isRestoring) this._undoStack.push(this._snapshotLabels());
			this._isSyncingLabels = true;
			try {
				this.annotations.set(this._toAnnotationList());
				this.spectrogramLabels.set(this._toSpectrogramLabelList());
				this._rebuildLabelLibrary();
				this._renderOverviewLabelTracks();
			} finally {
				this._isSyncingLabels = false;
			}
			this._emit("labelsync", {});
		}
		/** Create a deep snapshot of the current _linkedLabels for undo. */
		_snapshotLabels() {
			return Array.from(this._linkedLabels.values()).map((l) => ({
				...l,
				tags: { ...l.tags || {} }
			}));
		}
		/** Restore a snapshot from the undo stack. */
		_restoreSnapshot(snapshot) {
			if (!snapshot) return;
			this._isRestoring = true;
			this._linkedLabels.clear();
			for (const label of snapshot) this._linkedLabels.set(label.id, {
				...label,
				tags: { ...label.tags || {} }
			});
			this._syncLinkedLabelsToLayers();
			this._isRestoring = false;
			this._emit?.("undochange", {
				canUndo: this._undoStack.canUndo,
				canRedo: this._undoStack.canRedo
			});
		}
		/**
		* Undo the last operation (label mutation or DSP command).
		* @returns {boolean} true if anything was undone
		*/
		undo() {
			if (!this._undoStack.canUndo) return false;
			const snapshot = this._undoStack.undo();
			if (snapshot !== null) this._restoreSnapshot(snapshot);
			this._emit?.("undochange", {
				canUndo: this._undoStack.canUndo,
				canRedo: this._undoStack.canRedo
			});
			return true;
		}
		/**
		* Redo the last undone operation (label mutation or DSP command).
		* @returns {boolean} true if anything was redone
		*/
		redo() {
			if (!this._undoStack.canRedo) return false;
			const snapshot = this._undoStack.redo();
			if (snapshot !== null) this._restoreSnapshot(snapshot);
			this._emit?.("undochange", {
				canUndo: this._undoStack.canUndo,
				canRedo: this._undoStack.canRedo
			});
			return true;
		}
		_rebuildLabelLibrary() {
			const next = /* @__PURE__ */ new Map();
			for (const item of this._linkedLabels.values()) {
				const label = String(item?.label || item?.species || "").trim();
				if (!label) continue;
				next.set(label, (next.get(label) || 0) + 1);
			}
			this._labelLibrary = next;
		}
		/**
		* Highlight specific label segments in the overview tracks by id.
		* @param {string[]} ids
		*/
		setMultiSelectedOverview(ids) {
			const container = this._state?.d?.overviewLabelTracks;
			if (!container) return;
			const set = new Set(ids);
			for (const el of container.querySelectorAll(".overview-label-segment")) {
				const h = el;
				h.classList.toggle("multi-selected", set.has(h.dataset?.id || ""));
			}
		}
		/**
		* Render one row per unique label name below the overview bar,
		* grouped under compact origin headers (manual, BirdNET, xeno-canto).
		*/
		_renderOverviewLabelTracks() {
			const container = this._state?.d?.overviewLabelTracks;
			if (!container) return;
			const duration = this._state?.audioBuffer?.duration || 0;
			const prevRowCount = container.childElementCount;
			if (duration <= 0) {
				container.innerHTML = "";
				this._afterOverviewRowChange(prevRowCount, 0);
				return;
			}
			const originGroups = /* @__PURE__ */ new Map();
			for (const item of this._linkedLabels.values()) {
				const origin = String(item?.origin || "manual").trim() || "manual";
				const name = String(item?.label || item?.species || "").trim();
				if (!name) continue;
				if (!originGroups.has(origin)) originGroups.set(origin, /* @__PURE__ */ new Map());
				const nameMap = originGroups.get(origin);
				if (!nameMap.has(name)) nameMap.set(name, {
					color: item.color || "",
					segments: []
				});
				const g = nameMap.get(name);
				if (!g.color && item.color) g.color = item.color;
				g.segments.push({
					id: item.id || "",
					start: item.start,
					end: item.end
				});
			}
			if (originGroups.size === 0) {
				container.innerHTML = "";
				this._afterOverviewRowChange(prevRowCount, 0);
				return;
			}
			const ORDER = {
				manual: 0,
				BirdNET: 1,
				"xeno-canto": 2
			};
			const sortedOrigins = [...originGroups.entries()].sort((a, b) => (ORDER[a[0]] ?? 99) - (ORDER[b[0]] ?? 99) || a[0].localeCompare(b[0]));
			container.innerHTML = "";
			for (const [origin, nameMap] of sortedOrigins) {
				const header = document.createElement("div");
				header.className = "overview-label-group-header";
				header.textContent = origin;
				container.appendChild(header);
				const sortedNames = [...nameMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
				for (const [name, { color, segments }] of sortedNames) {
					const row = document.createElement("div");
					row.className = "overview-label-row";
					row.title = name;
					if (color) {
						const rgb = parseColorToRgb(color);
						if (rgb) row.style.setProperty("--label-tint", `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.35)`);
					}
					const nameEl = document.createElement("span");
					nameEl.className = "overview-label-row-name";
					nameEl.textContent = name;
					row.appendChild(nameEl);
					const track = document.createElement("div");
					track.className = "overview-label-row-track";
					for (const seg of segments) {
						const s = document.createElement("span");
						s.className = "overview-label-segment";
						s.dataset.start = String(seg.start);
						s.dataset.end = String(seg.end);
						s.dataset.id = seg.id || "";
						const leftPct = seg.start / duration * 100;
						const widthPct = (seg.end - seg.start) / duration * 100;
						s.style.left = `${leftPct}%`;
						s.style.width = `${Math.max(.3, widthPct)}%`;
						s.addEventListener("pointerenter", () => {
							this._emit?.("labelfocus", {
								id: seg.id || null,
								source: "overview",
								interaction: "hover"
							});
						});
						s.addEventListener("pointerleave", () => {
							this._emit?.("labelfocus", {
								id: null,
								source: "overview",
								interaction: "hover"
							});
						});
						s.addEventListener("click", (e) => {
							e.stopPropagation();
							if (e.ctrlKey || e.metaKey) {
								this._emit?.("labelfocus", {
									id: seg.id || null,
									source: "overview",
									interaction: "ctrl-click"
								});
								return;
							}
							const midTime = (seg.start + seg.end) / 2;
							this._state?._seekToTime(midTime, true);
							this._emit?.("labelfocus", {
								id: seg.id || null,
								source: "overview",
								interaction: "click"
							});
						});
						track.appendChild(s);
					}
					row.appendChild(track);
					container.appendChild(row);
				}
			}
			this._afterOverviewRowChange(prevRowCount, container.childElementCount);
		}
		/**
		* When the overview label track row count changes, the spectrogram container
		* shrinks/grows (flexbox). Schedule a resize-aware redraw so canvas, coords,
		* AND label overlay layers all stay in sync.
		*/
		_afterOverviewRowChange(prevCount, nextCount) {
			if (prevCount !== nextCount) this._state?._queueResizeRedraw({ redrawSpectrogram: true });
		}
		/**
		* Normalize taxonomy array into internal compact shape.
		* @param {Array<{name: string, color?: string, shortcut?: string}>} taxonomy
		* @returns {Array<{name: string, color: string, shortcut: string}>}
		*/
		_normalizeTaxonomy(taxonomy) {
			const used = /* @__PURE__ */ new Set();
			const list = [];
			for (const item of taxonomy || []) {
				const name = String(item?.name || "").trim();
				if (!name) continue;
				const shortcut = String(item?.shortcut || "").trim();
				const normalizedShortcut = /^[1-9]$/.test(shortcut) && !used.has(shortcut) ? shortcut : "";
				if (normalizedShortcut) used.add(normalizedShortcut);
				list.push({
					name,
					color: item?.color ? String(item.color) : "",
					shortcut: normalizedShortcut
				});
				if (list.length >= 9) break;
			}
			return list;
		}
		_toAnnotationList() {
			return Array.from(this._linkedLabels.values()).map((l) => ({
				id: l.id,
				start: l.start,
				end: l.end,
				species: l.species || l.label || "",
				confidence: l.confidence,
				color: l.color,
				scientificName: l.scientificName || "",
				commonName: l.commonName || "",
				origin: l.origin || "",
				author: l.author || "",
				tags: l.tags || {}
			}));
		}
		_toSpectrogramLabelList() {
			return Array.from(this._linkedLabels.values()).map((l) => ({
				id: l.id,
				start: l.start,
				end: l.end,
				freqMin: l.freqMin,
				freqMax: l.freqMax,
				label: l.label || l.species || "",
				color: l.color,
				scientificName: l.scientificName || "",
				commonName: l.commonName || "",
				origin: l.origin || "",
				author: l.author || "",
				tags: l.tags || {}
			}));
		}
		_normalizeLinkedLabel(label) {
			const duration = Math.max(.001, this.duration || this._state?.audioBuffer?.duration || .001);
			const nyquist = (this._state?.sampleRateHz || 32e3) / 2;
			const maxFreq = clamp(parseFloat(this._state?.d?.maxFreqSelect?.value || `${nyquist}`), 1, nyquist);
			const start = clamp(Number(label?.start ?? 0), 0, duration);
			const end = clamp(Number(label?.end ?? start + .01), start + .01, duration);
			const freqMinRaw = Number(label?.freqMin ?? 0);
			const freqMaxRaw = Number(label?.freqMax ?? maxFreq);
			const freqMin = clamp(freqMinRaw, 0, maxFreq);
			const freqMax = clamp(freqMaxRaw, freqMin + 1, maxFreq);
			const labelName = String(label?.label || label?.species || "").trim();
			const tax = labelName ? this._labelTaxonomy.find((t) => t.name.toLowerCase() === labelName.toLowerCase()) : null;
			const explicitColor = String(label?.color || "").trim();
			return {
				id: label?.id || `lbl_${Math.random().toString(36).slice(2, 10)}`,
				start,
				end,
				freqMin,
				freqMax,
				species: label?.species || "",
				label: label?.label || label?.species || "",
				confidence: label?.confidence,
				color: explicitColor || tax?.color || colorForName(labelName),
				scientificName: label?.scientificName || "",
				commonName: label?.commonName || "",
				origin: label?.origin || "",
				author: label?.author || "",
				tags: label?.tags && typeof label.tags === "object" ? { ...label.tags } : {}
			};
		}
	};
	//#endregion
	exports.BIRDNET_MODEL_URL = BIRDNET_MODEL_URL;
	exports.BirdNETInference = BirdNETInference;
	exports.BirdNETPlayer = BirdNETPlayer;
	exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
	exports.DEFAULT_XC_ENDPOINT = DEFAULT_XC_ENDPOINT;
	exports.DEFAULT_XC_RECORDINGS_ENDPOINT = DEFAULT_XC_RECORDINGS_ENDPOINT;
	exports.TaxonomyResolver = TaxonomyResolver;
	exports.XenoCantoApiClient = XenoCantoApiClient;
	exports.XenoCantoApiError = XenoCantoApiError;
	exports.buildXenoCantoAnnotationSet = buildXenoCantoAnnotationSet;
	exports.extractXenoCantoRawLabels = extractXenoCantoRawLabels;
	exports.fetchXenoCantoRecording = fetchXenoCantoRecording;
	exports.getRecordingScientificName = getRecordingScientificName;
	exports.importXenoCantoSpectrogramLabels = importXenoCantoSpectrogramLabels;
	exports.mapXenoCantoLabelsToSpectrogram = mapXenoCantoLabelsToSpectrogram;
	exports.normalizeXcId = normalizeXcId;
	return exports;
})({});

//# sourceMappingURL=birdnet-player.iife.js.map