summaryrefslogtreecommitdiff
path: root/tools/mq_editor/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/index.html')
-rw-r--r--tools/mq_editor/index.html832
1 files changed, 100 insertions, 732 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index a2daff5..efbd73d 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -3,252 +3,7 @@
<head>
<meta charset="utf-8">
<title>MQ Spectral Editor</title>
- <style>
- body {
- font-family: monospace;
- font-size: 14px;
- margin: 20px;
- background: #1a1a1a;
- color: #ddd;
- }
- .page-title {
- display: flex;
- align-items: baseline;
- gap: 16px;
- margin-bottom: 10px;
- }
- .page-title h2 { margin: 0; }
- #fileLabel {
- font-size: 13px;
- color: #8af;
- opacity: 0.8;
- }
- .toolbar {
- margin-bottom: 10px;
- padding: 10px;
- background: #2a2a2a;
- border-radius: 4px;
- }
- button {
- background: #3a3a3a;
- color: #ddd;
- border: 1px solid #555;
- padding: 8px 16px;
- margin-right: 8px;
- cursor: pointer;
- border-radius: 4px;
- }
- button:hover { background: #4a4a4a; }
- button:disabled { opacity: 0.5; cursor: not-allowed; }
- #extractBtn { background: #666; color: #fff; font-weight: bold; border-color: #888; }
- input[type="file"] { display: none; }
- .params {
- display: inline-block;
- margin-left: 20px;
- }
- label {
- margin-right: 8px;
- }
- input[type="number"], select {
- width: 80px;
- background: #3a3a3a;
- color: #ddd;
- border: 1px solid #555;
- padding: 4px;
- border-radius: 3px;
- }
- .main-area {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin-top: 10px;
- }
- #canvas {
- border: 1px solid #555;
- background: #000;
- cursor: crosshair;
- display: block;
- flex-shrink: 0;
- }
- .right-panel {
- background: #2a2a2a;
- border: 1px solid #555;
- border-radius: 4px;
- padding: 12px;
- min-width: 260px;
- max-width: 260px;
- max-height: 700px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 6px;
- box-sizing: border-box;
- }
- .panel-title {
- font-size: 12px;
- color: #888;
- text-transform: uppercase;
- letter-spacing: 1px;
- border-bottom: 1px solid #444;
- padding-bottom: 6px;
- margin-bottom: 2px;
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .right-panel label {
- display: flex;
- align-items: center;
- gap: 6px;
- margin: 0;
- cursor: pointer;
- font-size: 14px;
- }
- /* Partial properties */
- .prop-row {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- font-size: 13px;
- padding: 2px 0;
- }
- .prop-label { color: #777; font-size: 12px; }
- .curve-tabs {
- display: flex;
- gap: 2px;
- margin-top: 8px;
- margin-bottom: 4px;
- }
- .tab-btn {
- flex: 1;
- padding: 4px 0;
- font-size: 12px;
- margin: 0;
- background: #222;
- border-color: #444;
- color: #888;
- }
- .tab-btn.active {
- background: #3a3a3a;
- border-color: #666;
- color: #ddd;
- }
- .curve-grid {
- display: grid;
- grid-template-columns: 18px 1fr 1fr;
- gap: 3px 4px;
- align-items: center;
- }
- .curve-grid span { color: #666; font-size: 11px; }
- .curve-grid input[type="number"] {
- width: 100%;
- background: #333;
- color: #ccc;
- border: 1px solid #444;
- padding: 2px 4px;
- border-radius: 2px;
- font-size: 11px;
- font-family: monospace;
- box-sizing: border-box;
- }
- .curve-grid input[type="number"]:focus {
- border-color: #666;
- outline: none;
- }
- .partial-actions {
- display: flex;
- gap: 4px;
- margin-top: 8px;
- }
- .partial-actions button {
- flex: 1;
- padding: 4px 6px;
- font-size: 12px;
- margin: 0;
- }
- .synth-grid {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 4px 8px;
- align-items: center;
- }
- .synth-grid span { color: #888; font-size: 12px; }
- .synth-field-wrap {
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .synth-field-wrap input[type="number"] {
- flex: 1;
- min-width: 0;
- background: #333;
- color: #ccc;
- border: 1px solid #444;
- padding: 2px 4px;
- border-radius: 2px;
- font-size: 11px;
- font-family: monospace;
- box-sizing: border-box;
- }
- .synth-field-wrap input[type="number"]:focus { border-color: #666; outline: none; }
- .jog-slider {
- width: 44px;
- height: 16px;
- background: #1e1e1e;
- border: 1px solid #444;
- border-radius: 3px;
- cursor: ew-resize;
- position: relative;
- flex-shrink: 0;
- user-select: none;
- overflow: hidden;
- }
- .jog-slider::before {
- content: '';
- position: absolute;
- left: 50%; top: 3px; bottom: 3px;
- width: 1px;
- background: #484848;
- transform: translateX(-50%);
- }
- .jog-thumb {
- position: absolute;
- top: 3px; bottom: 3px;
- width: 6px;
- background: #888;
- border-radius: 2px;
- left: calc(50% - 3px);
- transition: left 0.12s ease;
- }
- .jog-slider:hover .jog-thumb { background: #aaa; }
- .synth-grid input[type="number"]:focus { border-color: #666; outline: none; }
- .synth-section {
- border-top: 1px solid #444;
- padding-top: 8px;
- margin-top: auto;
- }
- /* resonator mode badge shown in partial header color swatch area */
- .res-badge {
- font-size: 9px;
- color: #8cf;
- border: 1px solid #8cf;
- border-radius: 2px;
- padding: 0 3px;
- vertical-align: middle;
- margin-left: 4px;
- opacity: 0.8;
- }
- #status {
- margin-top: 10px;
- padding: 8px;
- background: #2a2a2a;
- border-radius: 4px;
- min-height: 20px;
- }
- .info { color: #4af; }
- .warn { color: #fa4; }
- .error { color: #f44; }
- </style>
+ <link rel="stylesheet" href="style.css">
</head>
<body>
<div class="page-title">
@@ -258,53 +13,99 @@
<div class="toolbar">
<input type="file" id="wavFile" accept=".wav">
- <button id="chooseFileBtn">&#x1F4C2; Open WAV</button>
- <button id="testWavBtn">⚗ Test WAV</button>
- <button id="extractBtn" disabled>Extract Partials</button>
- <button id="autoSpreadAllBtn" disabled>Auto Spread All</button>
- <button id="playBtn" disabled>▶ Play</button>
- <button id="stopBtn" disabled>■ Stop</button>
-
- <div class="params">
- <label>Hop:</label>
- <input type="number" id="hopSize" value="256" min="64" max="1024" step="64">
-
- <label>Threshold (dB):</label>
- <input type="number" id="threshold" value="-20" step="any">
-
- <label>Prominence (dB):</label>
- <input type="number" id="prominence" value="1.0" step="0.1" min="0">
-
- <label style="margin-left:16px;" title="Weight spectrum by frequency before peak detection (f * FFT_Power(f)), accentuates high-frequency peaks">
- <input type="checkbox" id="freqWeight"> f·Power
- </label>
-
- <label style="margin-left:16px;">Keep:</label>
- <input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;">
- <span id="keepPctLabel" style="margin-left:4px;">100%</span>
- </div>
+ <span class="toolbar-group">
+ <button id="chooseFileBtn">&#x1F4C2; Open WAV</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-group">
+ <button id="extractBtn" disabled>Extract Partials</button>
+ <button id="autoSpreadAllBtn" disabled>Auto Spread All</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-group">
+ <button id="playBtn" disabled>▶ Play</button>
+ <button id="stopBtn" disabled>■ Stop</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-group">
+ <button id="newPartialBtn" disabled>+ Partial</button>
+ <button id="clearAllBtn" disabled>✕ Clear All</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-group">
+ <button id="exploreBtn" disabled>⊕ Explore</button>
+ <button id="contourBtn" disabled>≋ Contour</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-group">
+ <button id="undoBtn" disabled>↩ Undo</button>
+ <button id="redoBtn" disabled>↪ Redo</button>
+ </span>
+ <span class="toolbar-sep"></span>
+ <span class="toolbar-wrap">
+ <button id="paramsBtn">⚙ Params</button>
+ <div id="paramsPanel">
+ <div class="param-group">
+ <span class="group-label">STFT</span>
+ <label title="STFT hop size in samples. Smaller = finer time resolution, more frames, slower.">Hop</label>
+ <input type="number" id="hopSize" value="256" min="64" max="1024" step="64" style="width:60px;">
+ </div>
+ <div class="param-group">
+ <span class="group-label">Peak Detect</span>
+ <label title="Minimum spectral peak amplitude in dB. Peaks below this are ignored.">Threshold (dB)</label>
+ <input type="number" id="threshold" value="-20" step="any">
+ <label title="Minimum prominence in dB: how much a peak must stand above its surrounding valley. Suppresses weak shoulders.">Prominence (dB)</label>
+ <input type="number" id="prominence" value="1.0" step="0.1" min="0">
+ <label title="Weight spectrum by frequency before peak detection: f × Power(f). Boosts high-frequency peaks relative to low-frequency ones.">
+ <input type="checkbox" id="freqWeight"> f·Power
+ </label>
+ </div>
+ <div class="param-group">
+ <span class="group-label">Tracking</span>
+ <label title="Frames a candidate must persist consecutively before being promoted to a tracked partial. Higher = fewer spurious short bursts.">Birth</label>
+ <input type="number" id="birthPersistence" value="3" min="1" max="10" step="1" style="width:50px;">
+ <label title="Frames a partial can go unmatched before it is terminated. Higher = bridges short gaps; lower = cuts off quickly.">Death</label>
+ <input type="number" id="deathAge" value="5" min="1" max="20" step="1" style="width:50px;">
+ <label title="Weight of phase prediction error in the peak-matching cost function. Higher = stricter phase coherence required to continue a partial.">Phase Wt</label>
+ <input type="number" id="phaseErrorWeight" value="2.0" min="0" max="10" step="0.5" style="width:55px;">
+ <label title="Minimum number of frames a tracked partial must span. Shorter partials are discarded after tracking.">Min Len</label>
+ <input type="number" id="minLength" value="10" min="1" max="50" step="1" style="width:50px;">
+ </div>
+ </div>
+ </span>
</div>
<div class="main-area">
- <div style="display:flex; flex-direction:column; gap:6px; flex-shrink:0;">
- <div style="position: relative;">
+ <div class="canvas-col">
+ <div class="canvas-wrap">
<canvas id="canvas" width="1400" height="600"></canvas>
- <canvas id="cursorCanvas" width="1400" height="600" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas>
+ <canvas id="cursorCanvas" width="1400" height="600"></canvas>
+ <canvas id="playheadCanvas" width="1400" height="600"></canvas>
+ <!-- Partial spectrum viewer (bottom-right overlay, left of main spectrum) -->
+ <div id="partialSpectrumViewer">
+ <canvas id="partialSpectrumCanvas" width="200" height="100"></canvas>
+ </div>
<!-- Mini spectrum viewer (bottom-right overlay) -->
- <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;">
+ <div id="spectrumViewer">
<canvas id="spectrumCanvas" width="400" height="100"></canvas>
</div>
+ <!-- Keep slider (bottom-left overlay) -->
+ <div id="keepOverlay">
+ <span title="Keep only the strongest N% of extracted partials, ranked by peak amplitude.">Keep</span>
+ <input type="range" id="keepPct" min="1" max="100" value="100">
+ <span id="keepPctLabel">100%</span>
+ </div>
</div>
<!-- Amplitude bezier editor (shown when partial selected) -->
- <div id="ampEditPanel" style="display:none;">
- <div style="font-size:10px; color:#555; padding:2px 0 3px 1px; display:flex; align-items:center; gap:10px; text-transform:uppercase; letter-spacing:0.5px;">
+ <div id="ampEditPanel">
+ <div class="amp-edit-header">
<span>Amplitude</span>
- <span id="ampEditTitle" style="color:#777; text-transform:none; letter-spacing:0;"></span>
- <span style="color:#333; text-transform:none; letter-spacing:0;">drag control points · Esc to deselect</span>
+ <span id="ampEditTitle"></span>
+ <span class="amp-edit-hint">drag control points · Esc to deselect</span>
</div>
- <canvas id="ampEditCanvas" width="1400" height="120" style="border:1px solid #2a2a2a; background:#0e0e0e; cursor:crosshair; display:block;"></canvas>
+ <canvas id="ampEditCanvas" width="1400" height="120"></canvas>
</div>
</div>
@@ -313,7 +114,7 @@
<div id="partialProps" style="display:none;">
<div class="panel-title">
<span id="propTitle">Partial #—</span>
- <span id="propSwatch" style="display:inline-block;width:10px;height:10px;border-radius:2px;flex-shrink:0;"></span>
+ <span id="propSwatch"></span>
</div>
<div class="prop-row">
<span class="prop-label">Peak</span>
@@ -339,11 +140,11 @@
</div>
<div class="partial-actions">
<button id="mutePartialBtn">Mute</button>
- <button id="deletePartialBtn">Delete</button>
+ <button id="deletePartialBtn">Delete <kbd>Del</kbd></button>
</div>
</div>
- <div id="noSelMsg" style="color:#555;font-size:13px;padding:2px 0;">Click a partial to select</div>
+ <div id="noSelMsg">Click a partial to select</div>
<!-- Synthesis options (always at bottom) -->
<div class="synth-section">
@@ -352,478 +153,45 @@
<label><input type="checkbox" id="disableJitter"> Disable jitter</label>
<label><input type="checkbox" id="disableSpread"> Disable spread</label>
<label title="Test mode: force resonator synthesis for all partials (ignores per-partial mode setting)"><input type="checkbox" id="forceResonator"> Resonator (all)</label>
- <div id="globalResParams" style="display:none;margin-top:4px;padding:4px 0 2px 12px;border-left:2px solid #555;">
- <label style="display:flex;align-items:center;gap:6px;" title="Global pole radius r in (0,1). Applied to all partials in resonator mode.">
+ <div id="globalResParams" style="display:none;">
+ <label title="Global pole radius r in (0,1). Applied to all partials in resonator mode.">
r (pole)
- <input type="range" id="globalR" min="0.75" max="0.9999" step="0.0001" value="0.995" style="flex:1;min-width:0;">
- <span id="globalRVal" style="width:44px;text-align:right;">0.9950</span>
+ <input type="range" id="globalR" min="0.75" max="0.9999" step="0.0001" value="0.995">
+ <span id="globalRVal" class="slider-val">0.9950</span>
</label>
- <label style="display:flex;align-items:center;gap:6px;" title="Global gain compensation applied to all partials in resonator mode.">
+ <label title="Global gain compensation applied to all partials in resonator mode.">
gain
- <input type="range" id="globalGain" min="0.0" max="4.0" step="0.01" value="1.0" style="flex:1;min-width:0;">
- <span id="globalGainVal" style="width:44px;text-align:right;">1.00</span>
+ <input type="range" id="globalGain" min="0.0" max="4.0" step="0.01" value="1.0">
+ <span id="globalGainVal" class="slider-val">1.00</span>
</label>
<label title="Override per-partial r/gain with global values during playback"><input type="checkbox" id="forceRGain"> force r/gain</label>
</div>
<div style="margin-top:6px;">
- <label style="display:flex;align-items:center;gap:6px;" title="LP filter coefficient k1 in (0,1]. 1.0 = bypass.">
+ <label title="LP filter coefficient k1 in (0,1]. 1.0 = bypass.">
LP k1
- <input type="range" id="lpK1" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;">
- <span id="lpK1Val" style="width:44px;text-align:right;">bypass</span>
+ <input type="range" id="lpK1" min="0.001" max="1.0" step="0.001" value="1.0">
+ <span id="lpK1Val" class="slider-val">bypass</span>
</label>
- <label style="display:flex;align-items:center;gap:6px;" title="HP filter coefficient k2 in (0,1]. 1.0 = bypass.">
+ <label title="HP filter coefficient k2 in (0,1]. 1.0 = bypass.">
HP k2
- <input type="range" id="hpK2" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;">
- <span id="hpK2Val" style="width:44px;text-align:right;">bypass</span>
+ <input type="range" id="hpK2" min="0.001" max="1.0" step="0.001" value="1.0">
+ <span id="hpK2Val" class="slider-val">bypass</span>
</label>
</div>
</div>
</div>
</div>
- <div id="tooltip" style="position: fixed; display: none; background: #2a2a2a; padding: 4px 8px; border: 1px solid #555; border-radius: 3px; pointer-events: none; font-size: 11px; z-index: 1000;"></div>
+ <div id="tooltip"></div>
<div id="status">Load a WAV file to begin...</div>
+ <script src="utils.js"></script>
<script src="fft.js"></script>
<script src="mq_extract.js"></script>
<script src="mq_synth.js"></script>
<script src="viewer.js"></script>
<script src="editor.js"></script>
- <script>
- // LP: y[n] = k*x[n] + (1-k)*y[n-1] => -3dB at cos(w) = (2-2k-k²)/(2(1-k))
- function k1ToHz(k, sr) {
- if (k >= 1.0) return sr / 2;
- const cosW = (2 - 2*k - k*k) / (2*(1 - k));
- return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
- }
- // HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²)
- function k2ToHz(k, sr) {
- if (k >= 1.0) return 0;
- const cosW = 2*k / (1 + k*k);
- return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
- }
- function fmtHz(f) {
- return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz';
- }
- function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; }
-
- // LP/HP slider live display
- document.getElementById('lpK1').addEventListener('input', function() {
- const k = parseFloat(this.value);
- const f = k1ToHz(k, getSR());
- document.getElementById('lpK1Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
- });
- document.getElementById('hpK2').addEventListener('input', function() {
- const k = parseFloat(this.value);
- const f = k2ToHz(k, getSR());
- document.getElementById('hpK2Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
- });
-
- // Show/hide global resonator params when forceResonator toggled
- document.getElementById('forceResonator').addEventListener('change', function() {
- document.getElementById('globalResParams').style.display = this.checked ? '' : 'none';
- });
- document.getElementById('globalR').addEventListener('input', function() {
- document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4);
- });
- document.getElementById('globalGain').addEventListener('input', function() {
- document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2);
- });
- let audioBuffer = null;
- let viewer = null;
- let audioContext = null;
- let currentSource = null;
- let extractedPartials = null;
- let stftCache = null;
-
- const wavFile = document.getElementById('wavFile');
- const chooseFileBtn = document.getElementById('chooseFileBtn');
- const extractBtn = document.getElementById('extractBtn');
- const autoSpreadAllBtn = document.getElementById('autoSpreadAllBtn');
- const playBtn = document.getElementById('playBtn');
- const stopBtn = document.getElementById('stopBtn');
- const canvas = document.getElementById('canvas');
- const status = document.getElementById('status');
- const fileLabel = document.getElementById('fileLabel');
-
- const hopSize = document.getElementById('hopSize');
- const threshold = document.getElementById('threshold');
- const prominence = document.getElementById('prominence');
- const freqWeightCb = document.getElementById('freqWeight');
- const keepPct = document.getElementById('keepPct');
- const keepPctLabel = document.getElementById('keepPctLabel');
- const fftSize = 1024; // Fixed
-
- function getKeepCount() {
- return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100));
- }
-
- keepPct.addEventListener('input', () => {
- keepPctLabel.textContent = keepPct.value + '%';
- if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount());
- });
-
- // --- Editor ---
- const editor = new PartialEditor();
- editor.onPartialDeleted = () => {
- if (viewer && extractedPartials)
- viewer.setKeepCount(extractedPartials.length > 0 ? getKeepCount() : 0);
- };
-
- // Initialize audio context
- function initAudioContext() {
- if (!audioContext) {
- audioContext = new (window.AudioContext || window.webkitAudioContext)();
- }
- }
-
- // Shared: initialize editor from an AudioBuffer
- function loadAudioBuffer(buffer, label) {
- audioBuffer = buffer;
- initAudioContext();
- extractBtn.disabled = false;
- playBtn.disabled = false;
- setStatus('Computing STFT cache...', 'info');
-
- // Reset partials from previous file
- extractedPartials = null;
- editor.setPartials(null);
-
- setTimeout(() => {
- const signal = audioBuffer.getChannelData(0);
- stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, Math.max(64, parseInt(hopSize.value) || 64));
- setStatus(`${label} — ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch (${stftCache.getNumFrames()} frames cached)`, 'info');
- viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache);
- editor.setViewer(viewer);
- viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
- viewer.onRender = () => editor.onRender();
- if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache);
- }, 10);
- }
-
- // File chooser button
- chooseFileBtn.addEventListener('click', () => wavFile.click());
-
- // Load WAV file
- wavFile.addEventListener('change', async (e) => {
- const file = e.target.files[0];
- if (!file) return;
-
- fileLabel.textContent = file.name;
- setStatus('Loading WAV...', 'info');
- try {
- const arrayBuffer = await file.arrayBuffer();
- const ctx = new AudioContext();
- const buf = await ctx.decodeAudioData(arrayBuffer);
- loadAudioBuffer(buf, `Loaded: ${file.name}`);
- } catch (err) {
- setStatus('Error loading WAV: ' + err.message, 'error');
- console.error(err);
- }
- });
-
- // Test WAV: generate synthetic signal (two sine waves) in-memory
- document.getElementById('testWavBtn').addEventListener('click', () => {
- initAudioContext();
- const SR = 32000;
- const duration = 2.0;
- const numSamples = SR * duration;
-
- // Two sine waves: 440 Hz (A4) + 660 Hz (E5, perfect fifth), equal amplitude
- const buf = audioContext.createBuffer(1, numSamples, SR);
- const data = buf.getChannelData(0);
- for (let i = 0; i < numSamples; ++i) {
- data[i] = 0.5 * Math.sin(2 * Math.PI * 440 * i / SR)
- + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR);
- }
-
- fileLabel.textContent = 'test-440+660hz.wav';
- loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)');
- });
-
- // Update cache when hop size changes
- hopSize.addEventListener('change', () => {
- const val = Math.max(64, parseInt(hopSize.value) || 64);
- hopSize.value = val;
- if (stftCache) {
- setStatus('Updating STFT cache...', 'info');
- setTimeout(() => {
- stftCache.setHopSize(val);
- setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info');
- if (viewer) viewer.render();
- }, 10);
- }
- });
-
- function runExtraction() {
- if (!stftCache) return;
-
- setStatus('Extracting partials...', 'info');
- extractBtn.disabled = true;
-
- setTimeout(() => {
- try {
- const params = {
- fftSize: fftSize,
- hopSize: parseInt(hopSize.value),
- threshold: parseFloat(threshold.value),
- prominence: parseFloat(prominence.value),
- freqWeight: freqWeightCb.checked,
- sampleRate: audioBuffer.sampleRate
- };
-
- const result = extractPartials(params, stftCache);
-
- // Sort by decreasing peak amplitude
- result.partials.sort((a, b) => {
- const peakA = a.amps.reduce((m, v) => Math.max(m, v), 0);
- const peakB = b.amps.reduce((m, v) => Math.max(m, v), 0);
- return peakB - peakA;
- });
-
- extractedPartials = result.partials;
- editor.setPartials(result.partials);
- viewer.setFrames(result.frames);
- setStatus(`Extracted ${result.partials.length} partials`, 'info');
- viewer.setPartials(result.partials);
- viewer.setKeepCount(getKeepCount());
- viewer.selectPartial(-1);
-
- } catch (err) {
- setStatus('Extraction error: ' + err.message, 'error');
- console.error(err);
- }
- extractBtn.disabled = false;
- autoSpreadAllBtn.disabled = false;
- }, 50);
- }
-
- extractBtn.addEventListener('click', () => {
- if (!audioBuffer) return;
- runExtraction();
- });
-
- autoSpreadAllBtn.addEventListener('click', () => {
- if (!extractedPartials || !stftCache) return;
- const fs = stftCache.fftSize;
- const sr = audioBuffer.sampleRate;
- const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
- for (const p of extractedPartials) {
- const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr);
- if (!p.replicas) p.replicas = { ...defaults };
- p.replicas.spread_above = spread_above;
- p.replicas.spread_below = spread_below;
- }
- if (viewer) viewer.render();
- const sel = viewer ? viewer.selectedPartial : -1;
- if (sel >= 0) editor.onPartialSelect(sel);
- setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info');
- });
-
- threshold.addEventListener('change', () => {
- if (stftCache) runExtraction();
- });
-
- freqWeightCb.addEventListener('change', () => {
- if (stftCache) runExtraction();
- });
-
- function playAudioBuffer(buffer, statusMsg) {
- const startTime = audioContext.currentTime;
- currentSource = audioContext.createBufferSource();
- currentSource.buffer = buffer;
- currentSource.connect(audioContext.destination);
- currentSource.start();
- currentSource.onended = () => {
- currentSource = null;
- playBtn.disabled = false;
- stopBtn.disabled = true;
- viewer.setPlayheadTime(-1);
- setStatus('Stopped', 'info');
- };
- playBtn.disabled = true;
- stopBtn.disabled = false;
- setStatus(statusMsg, 'info');
- function tick() {
- if (!currentSource) return;
- viewer.setPlayheadTime(audioContext.currentTime - startTime);
- requestAnimationFrame(tick);
- }
- tick();
- }
-
- function stopAudio() {
- if (currentSource) {
- try { currentSource.stop(); } catch (e) {}
- currentSource = null;
- }
- if (viewer) viewer.setPlayheadTime(-1);
- playBtn.disabled = false;
- stopBtn.disabled = true;
- setStatus('Stopped', 'info');
- }
-
- // Play audio
- playBtn.addEventListener('click', () => {
- if (!audioBuffer || !audioContext) return;
- stopAudio();
- playAudioBuffer(audioBuffer, 'Playing...');
- });
-
- // Stop audio
- stopBtn.addEventListener('click', () => {
- stopAudio();
- });
-
- function setStatus(msg, type = '') {
- status.innerHTML = msg;
- status.className = type;
- }
-
- // Play synthesized audio
- function playSynthesized() {
- if (!extractedPartials || extractedPartials.length === 0) {
- setStatus('No partials extracted yet', 'warn');
- return;
- }
- if (!audioBuffer || !audioContext) return;
-
- stopAudio();
-
- setStatus('Synthesizing...', 'info');
-
- const keepCount = getKeepCount();
- const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted);
- setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info');
-
- const integratePhase = document.getElementById('integratePhase').checked;
- const disableJitter = document.getElementById('disableJitter').checked;
- const disableSpread = document.getElementById('disableSpread').checked;
- const forceResonator = document.getElementById('forceResonator').checked;
- const lpK1Raw = parseFloat(document.getElementById('lpK1').value);
- const hpK2Raw = parseFloat(document.getElementById('hpK2').value);
- const k1 = lpK1Raw < 1.0 ? lpK1Raw : null;
- const k2 = hpK2Raw < 1.0 ? hpK2Raw : null;
- const forceRGain = forceResonator && document.getElementById('forceRGain').checked;
- const globalR = parseFloat(document.getElementById('globalR').value);
- const globalGain = parseFloat(document.getElementById('globalGain').value);
- const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration,
- integratePhase, {disableJitter, disableSpread, forceResonator,
- forceRGain, globalR, globalGain, k1, k2});
-
- if (viewer) {
- viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value)));
- }
-
- const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate);
- synthBuffer.getChannelData(0).set(pcm);
- playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`);
- }
-
- // Keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
- if (e.code === 'Digit1') {
- e.preventDefault();
- playSynthesized();
- } else if (e.code === 'Digit2') {
- e.preventDefault();
- if (!playBtn.disabled) {
- playBtn.click();
- }
- } else if (e.code === 'KeyP') {
- e.preventDefault();
- if (viewer) viewer.togglePeaks();
- } else if (e.code === 'KeyA') {
- e.preventDefault();
- if (viewer) {
- viewer.showSynthFFT = !viewer.showSynthFFT;
- viewer.renderSpectrum();
- }
- } else if (e.code === 'KeyE') {
- e.preventDefault();
- if (!extractBtn.disabled) extractBtn.click();
- } else if (e.code === 'Escape') {
- if (viewer) viewer.selectPartial(-1);
- }
- });
-
- // Curve tab switching
- document.querySelectorAll('.tab-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
- document.getElementById('tab' + btn.dataset.tab).style.display = '';
- });
- });
-
- // --- Test WAV peak validation ---
- function validateTestWAVPeaks(cache) {
- const SR = cache.sampleRate;
- const N = cache.fftSize;
- const binWidth = SR / N; // Hz per bin
- const numBins = N / 2;
- const numBars = 100; // mini-spectrum bar count
-
- // Use a mid-signal frame (avoid edge effects)
- const midFrame = cache.frames[Math.floor(cache.frames.length / 2)];
- if (!midFrame) { console.error('[TestWAV] No frames computed'); return; }
- const sq = midFrame.squaredAmplitude;
- const t = midFrame.time;
-
- console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's');
-
- // Top 5 bins by magnitude
- const ranked = Array.from(sq)
- .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) }))
- .sort((a, b) => b.db - a.db);
- console.log('Top 5 FFT bins:');
- ranked.slice(0, 5).forEach(x =>
- console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`));
-
- // Expected bins for 440/660 Hz
- const bin440 = Math.round(440 / binWidth);
- const bin660 = Math.round(660 / binWidth);
- const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20));
- const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20));
- console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`);
- console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`);
-
- // Validate: 440/660 Hz must be in top-10
- const top10Freqs = ranked.slice(0, 10).map(x => x.freq);
- const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2);
- const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2);
- console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') +
- ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗'));
-
- // Mini-spectrum: which bar do these peaks land in?
- const bar440 = Math.floor(bin440 * numBars / numBins);
- const bar660 = Math.floor(bin660 * numBars / numBins);
- const sampledBin440 = Math.floor(bar440 * numBins / numBars);
- const sampledBin660 = Math.floor(bar660 * numBars / numBars);
- console.log('Mini-spectrum (linear scale, 100 bars):');
- console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`);
- console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`);
- if (bar440 < 5 || bar660 < 5) {
- console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 +
- ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' +
- ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.');
- }
-
- // Main spectrogram: confirm bins are in draw range
- const mainFreqStart = 20, mainFreqEnd = 16000;
- const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd;
- const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd;
- const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart));
- const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart));
- console.log('Main spectrogram (log Y-axis, 600px):');
- console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`);
- console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`);
-
- console.groupEnd();
- }
- </script>
+ <script src="app.js"></script>
</body>
</html>