From 8c1a9ac42e6aabbdaad4cafc552c2a47ee91a22d Mon Sep 17 00:00:00 2001 From: taniguchi shoichi Date: Tue, 15 Sep 2026 22:50:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(spectrum):=20ASTC=20=E5=B8=AF=E3=81=A8?= =?UTF-8?q?=E3=80=8C=E6=9D=9F=E3=81=AE=E6=AF=8D=E9=9F=B3=E3=80=8D( --- docs/dev/validate.mjs | 122 ++++++++++++++++++ docs/dsp-core.js | 190 ++++++++++++++++++++++++++++ docs/index.html | 28 +++++ docs/main.js | 287 +++++++++++++++++++++++++++++++++++++++++- docs/mobile.css | 116 +++++++++++++++++ docs/mobile.html | 28 +++++ docs/style.css | 117 +++++++++++++++++ docs/sw.js | 2 +- 8 files changed, 888 insertions(+), 2 deletions(-) diff --git a/docs/dev/validate.mjs b/docs/dev/validate.mjs index 7158bc8..e3e16f5 100644 --- a/docs/dev/validate.mjs +++ b/docs/dev/validate.mjs @@ -445,6 +445,128 @@ console.log('\n\x1b[1m10. Vibrato analysis — rate (Hz) & extent (¢) measureme } } +// --------------------------------------------------------------------------- +console.log('\n\x1b[1m11. ASTC (Howell 2016) — absolute tone-color scale & spectral bundles\x1b[0m'); +{ + const fmtB = (b) => `${b.label}${b.weakBridge ? '(wb)' : ''} ×${b.complexity} c=${b.centroid.toFixed(1)}Hz`; + const fmtAll = (bs) => bs.map(fmtB).join(' · '); + + // (a) Figure 15 scale — note names → Hz, band edges = geometric mean of neighbours + { + const scale = [[440, 'u'], [659.3, 'o'], [880, 'ɔ'], [830.6, 'ɔ'], [1319, 'ɑ'], + [1760, 'a'], [2637, 'æ'], [4186, 'i'], [8000, 'iB']]; + const wrong = scale.filter(([f, k]) => (DSP.astcOf(f) || {}).key !== k) + .map(([f, k]) => `${f}→${(DSP.astcOf(f) || {}).key}≠${k}`); + const line = `scale: ${scale.map(([f, k]) => `${f}→${k}`).join(', ')}`; + wrong.length === 0 ? pass(line) : fail(`${line} [${wrong.join(' ')}]`); + + const edgeOk = DSP.astcOf(538.6).key === 'o' && DSP.astcOf(538.5).key === 'u' && + DSP.astcIndex(440) === 0 && DSP.astcIndex(8000) === 7; + const guardOk = DSP.astcOf(0) === null && DSP.astcOf(-5) === null && + DSP.astcOf(NaN) === null && DSP.astcIndex(0) === -1; + (edgeOk && guardOk) ? pass('band edges inclusive-low, and 0 / negative / NaN → null') + : fail(`edges ${edgeOk} guards ${guardOk}`); + } + + // (b) Figure 17 — one bundle of H2,H3,H4 over f0=220; the percept follows the + // amplitude-weighted centroid as the balance inside the bundle shifts. + { + const tri = (d2, d3, d4) => DSP.spectralBundles( + [{ n: 2, freq: 440, db: d2 }, { n: 3, freq: 660, db: d3 }, { n: 4, freq: 880, db: d4 }], + { f0: 220 }); + + let bs = tri(0, 0, 0); + let ok = bs.length === 1 && bs[0].label === ' ({ n: +n, freq: 110 * +n, db: prof[n] })); + const bs = DSP.spectralBundles(hs, { f0: 110 }); + const k1 = bs[0] && bs[0].astc ? bs[0].astc.key : '?'; + const k2 = bs[1] && bs[1].astc ? bs[1].astc.key : '?'; + const ok = bs.length >= 2 && (k1 === 'o' || k1 === 'ɔ') && (k2 === 'ɑ' || k2 === 'a'); + const line = `f0=110 two-hump (H5–H7 / H10–H12, H9 −20 dB): ${bs.length} bundles → ${fmtAll(bs)}`; + ok ? pass(line) : fail(line + ' (expect ≥2, first o|ɔ, second ɑ|a)'); + } + + // (e) End-to-end: synthesized vowel → FFT → harmonic dB → bundles + { + const sr = 44100, f0 = 110, N = 8192; + const sig = DSP.synthVowel({ sr, dur: 0.5, f0, formants: [700, 1220, 2600, 3400, 4500] }); + const w = DSP.hann(N); + const re = new Float64Array(N), im = new Float64Array(N); + const start = Math.round(0.25 * sr) - (N >> 1); + for (let i = 0; i < N; i++) { + const idx = start + i; + re[i] = (idx >= 0 && idx < sig.length ? sig[idx] : 0) * w[i]; + } + DSP.fftRadix2(re, im); + const bins = N >> 1, binHz = sr / N; + const harmonics = []; + for (let n = 1; n * f0 <= 6000; n++) { + const lo = Math.max(0, Math.ceil((n * f0 * 0.9) / binHz)); + const hi = Math.min(bins - 1, Math.floor((n * f0 * 1.1) / binHz)); + let best = -Infinity, bf = n * f0; + for (let i = lo; i <= hi; i++) { + const db = 20 * Math.log10(Math.hypot(re[i], im[i]) + 1e-12); + if (db > best) { best = db; bf = i * binHz; } + } + if (isFinite(best)) harmonics.push({ n, freq: bf, db: best }); + } + const bs = DSP.spectralBundles(harmonics, { f0 }); + // The loudest bundle is the F1 peak (F1 = 700 Hz → H6/H7 region). NOTE: the + // literal FIRST bundle here is H1–H3 (~200 Hz, loud.peakDb) loud = b; + const ok = bs.length >= 2 && loud.centroid > 500 && loud.centroid < 1000 && + (loud.label === ' 0) || !isFinite(f)) return -1; + for (let i = 0; i < ASTC_BANDS.length; i++) { + if (f >= ASTC_BANDS[i].lo && f < ASTC_BANDS[i].hi) return i; + } + return -1; + } + + // The (frozen) band object for this frequency, or null. + function astcOf(freqHz) { + const i = astcIndex(freqHz); + return i < 0 ? null : ASTC_BANDS[i]; + } + + // Bundle label: "~o" → " a.freq - b.freq); + if (hs.length > maxHarmonics) hs = hs.slice(0, maxHarmonics); + let maxDb = -Infinity; + for (let i = 0; i < hs.length; i++) if (hs[i].db > maxDb) maxDb = hs[i].db; + hs = hs.filter((h) => h.db >= maxDb - floorDb); + if (!hs.length) return []; + + // (2) linear amplitude weights + for (let i = 0; i < hs.length; i++) hs[i].amp = Math.pow(10, hs[i].db / 20); + + // (3) trough detection — a local minimum whose NEAREST local maxima on both + // sides stand at least minTroughDb above it splits the series. Shallow + // dips inside one broad peak do not split. + const N = hs.length; + const splitAfter = new Array(N).fill(false); + for (let k = 1; k < N - 1; k++) { + const v = hs[k].db; + if (!(v <= hs[k - 1].db && v <= hs[k + 1].db)) continue; + if (v === hs[k - 1].db && v === hs[k + 1].db) continue; // flat run, no trough + let left = -Infinity; + for (let i = k - 1; i >= 0; i--) { + if (hs[i].db >= left) left = hs[i].db; else break; // stop past the nearest peak + } + let right = -Infinity; + for (let i = k + 1; i < N; i++) { + if (hs[i].db >= right) right = hs[i].db; else break; + } + if (left >= v + minTroughDb && right >= v + minTroughDb) splitAfter[k] = true; + } + + // Build the contiguous groups (the trough harmonic closes the lower bundle). + let groups = []; + let cur = [hs[0]]; + for (let k = 1; k < N; k++) { + if (splitAfter[k - 1]) { groups.push(cur); cur = []; } + cur.push(hs[k]); + } + groups.push(cur); + + // (4) a lone harmonic that is not H1 cannot be a bundle → merge it into the + // louder neighbour (tie → the upper one). H1 may stand alone (Howell's + // "obvious true fundamental"). + const hasFundamental = (g) => g.some((h) => h.n === 1); + const groupPeakDb = (g) => g.reduce((m, h) => Math.max(m, h.db), -Infinity); + let merged = true; + while (merged && groups.length > 1) { + merged = false; + for (let gi = 0; gi < groups.length; gi++) { + if (groups[gi].length !== 1 || hasFundamental(groups[gi])) continue; + const lo = gi > 0 ? groups[gi - 1] : null; + const hi = gi < groups.length - 1 ? groups[gi + 1] : null; + let target = -1; + if (lo && hi) target = groupPeakDb(hi) >= groupPeakDb(lo) ? gi + 1 : gi - 1; + else if (hi) target = gi + 1; + else if (lo) target = gi - 1; + if (target < 0) continue; + const a = Math.min(gi, target), b = Math.max(gi, target); + groups[a] = groups[a].concat(groups[b]); + groups.splice(b, 1); + merged = true; + break; + } + } + + // (5) describe every bundle + const out = []; + for (let gi = 0; gi < groups.length; gi++) { + const g = groups[gi]; + let sw = 0, swf = 0, peakDb = -Infinity, peak = g[0]; + for (let i = 0; i < g.length; i++) { + sw += g[i].amp; + swf += g[i].amp * g[i].freq; + if (g[i].db > peakDb) { peakDb = g[i].db; peak = g[i]; } + } + const centroid = sw > 0 ? swf / sw : g[0].freq; + const astc = astcOf(centroid); + let inBand = 0, rough = 0; + for (let i = 0; i < g.length; i++) { + if (astc && g[i].freq >= astc.lo && g[i].freq < astc.hi) inBand++; + if (f0 > 0 && f0 < erbHz(g[i].freq)) rough++; + } + out.push({ + astc, + label: astcBundleLabel(astc), + centroid, + weakBridge: inBand === 0, + complexity: g.length, + rough, + isFundamental: hasFundamental(g), + peakDb, + peakN: peak.n, + peakFreq: peak.freq, + nFrom: g[0].n, + nTo: g[g.length - 1].n, + freqFrom: g[0].freq, + freqTo: g[g.length - 1].freq, + harmonics: g.map((h) => ({ n: h.n, freq: h.freq, db: h.db })), + }); + } + out.sort((a, b) => a.freqFrom - b.freqFrom); + return out; + } + const api = { fftRadix2, nextPow2, hann, burgLPC, durandKerner, lpcFormants, decimate, @@ -1578,6 +1767,7 @@ envelopeBeat, analyzeVibrato, synthVowel, + ASTC_BANDS, astcOf, astcIndex, astcBundleLabel, erbHz, spectralBundles, }; if (typeof module !== 'undefined' && module.exports) { diff --git a/docs/index.html b/docs/index.html index 3c4296a..9e5f6f8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -520,6 +520,10 @@

Power Spectrum

+
+ +
@@ -1085,6 +1089,30 @@

ERB = Equivalent Rectangular Bandwidth

+ + +
diff --git a/docs/main.js b/docs/main.js index 75d7b32..c16fc3f 100644 --- a/docs/main.js +++ b/docs/main.js @@ -226,6 +226,8 @@ const state = { rdManual: null, // null = auto from P/R/mechanism, number = manual Rd override logScale: false, // Toggle for logarithmic frequency axis roughnessVisible: false, // Toggle for roughness zone overlay (ERB + 5kHz ceiling) + astcVisible: false, // Toggle for the ASTC overlay (Howell tone-color bands + bundle vowels) + astcBundles: null, // Last DSP.spectralBundles() result (recomputed at most every 100 ms) selectionActive: false, selectionMinFreq: 0, selectionMaxFreq: 0, @@ -360,6 +362,8 @@ const els = { btnLogScale: document.getElementById('log-scale-toggle'), btnRoughness: document.getElementById('roughness-toggle'), roughnessLegend: document.getElementById('roughness-legend'), + btnAstc: document.getElementById('astc-toggle'), // PC + mobile + astcLegend: document.getElementById('astc-legend'), // PC + mobile pitchMirror: document.getElementById('rl-pitch-mirror'), btnFullscreen: document.getElementById('spectrum-fullscreen-btn'), micMethodSelect: document.getElementById('mic-formant-method'), @@ -839,6 +843,229 @@ const ROUGH_ZONE_NAME = { 'ceiling': 'Ceiling (>5kHz)' }; +// --- ASTC (Absolute Spectral Tone Color) overlay --- +// Howell (2016), Figure 15: a simple tone carries a vowel quality fixed by its +// frequency alone. A spectral peak — a "bundle" of adjacent harmonics bounded by +// lower-amplitude neighbours — is heard as the ASTC of its amplitude-weighted +// centroid (" 0) || !(nyquist > 0)) return out; + const bins = dataArrayDb.length; + if (!bins) return out; + const binHz = nyquist / bins; + const searchRangeHz = f0 * 0.1; + const cap = Math.min(maxN != null ? maxN : ASTC_MAX_HARMONICS, Math.floor(maxFreq / f0)); + for (let n = 1; n <= cap; n++) { + const expected = n * f0; + if (expected > maxFreq) break; + const lo = Math.max(0, Math.ceil((expected - searchRangeHz) / binHz)); + const hi = Math.min(bins - 1, Math.floor((expected + searchRangeHz) / binHz)); + let peakVal = -Infinity, peakFreq = expected; + for (let i = lo; i <= hi; i++) { + const v = dataArrayDb[i]; + if (v > peakVal) { peakVal = v; peakFreq = i * binHz; } + } + if (!isFinite(peakVal)) continue; + out.push({ n, freq: peakFreq, db: peakVal }); + } + return out; +} + +// Recompute the bundles from one spectrum frame (throttled to 10 Hz). +function updateAstcBundles(dataArrayDb, nyquist, f0, minDb, dbRange, boost, nowT) { + if (!state.astcVisible) return; + if (nowT - _astcCalcAt < ASTC_BUNDLE_INTERVAL_MS) return; + _astcCalcAt = nowT; + if (typeof DSP === 'undefined' || !DSP.spectralBundles) return; + const harmonics = sampleHarmonicsDb(dataArrayDb, nyquist, f0, MAX_FREQ_DISPLAY, ASTC_MAX_HARMONICS); + // Silence guard: with nothing above the analyser floor the "harmonics" are + // just noise, and the 40 dB relative floor inside spectralBundles would still + // happily bundle them. Show nothing instead. + let peakDb = -Infinity; + for (const h of harmonics) if (h.db > peakDb) peakDb = h.db; + const bundles = (harmonics.length && peakDb > minDb) ? DSP.spectralBundles(harmonics, { f0 }) : []; + state.astcBundles = bundles.length ? bundles : null; + _astcScale = { minDb, dbRange, boost }; + _astcVoicedAt = nowT; + updateAstcReadout(); +} + +// Unvoiced / no usable pitch: hold the last bundles briefly, then drop them. +function holdOrClearAstcBundles(nowT) { + if (!state.astcBundles) return; + if (nowT - _astcVoicedAt <= ASTC_HOLD_MS) return; + state.astcBundles = null; + _astcScale = null; + updateAstcReadout(); +} + +// Background: one faint full-height stripe per ASTC band (+ dashed edges). +function drawAstcBands(width, height) { + const bands = astcBandList(); + if (!bands) return; + canvasCtx.save(); + for (const b of bands) { + const x0 = Math.max(0, Math.min(width, freqToX(b.lo, width))); + const x1 = Math.max(0, Math.min(width, freqToX(Math.min(b.hi, MAX_FREQ_DISPLAY), width))); + if (x1 - x0 < 0.5) continue; + canvasCtx.fillStyle = astcRgba(b.key, 0.05); + canvasCtx.fillRect(x0, 0, x1 - x0, height); + } + canvasCtx.setLineDash([3, 4]); + canvasCtx.lineWidth = 1; + canvasCtx.strokeStyle = 'rgba(0, 0, 0, 0.12)'; + canvasCtx.beginPath(); + for (const b of bands) { + if (b.lo <= 0) continue; + const x = freqToX(b.lo, width); + if (x < 1 || x > width - 1) continue; + canvasCtx.moveTo(Math.round(x) + 0.5, 0); + canvasCtx.lineTo(Math.round(x) + 0.5, height); + } + canvasCtx.stroke(); + canvasCtx.setLineDash([]); + canvasCtx.restore(); +} + +// Vertical geometry of the ASTC band ruler. The canvas backing store is sized to +// its CSS box (resizeCanvas), so these offsets are CSS pixels: +// - the HTML x-axis labels overlay the bottom ~24px, +// - on PC the spec-zoom chip (− Full +, .wave-zoom: 22px tall, bottom:22px) +// floats over the bottom-left, i.e. the band height-44 … height-22. +// The ruler is lifted clear of whichever of those is present, so it never shares +// a row with them (and never has to shove its ~u / ~o labels sideways). +const ASTC_AXIS_LABEL_H = 24; +const ASTC_ZOOM_CTRL_H = 44; +function astcStripGeom(width, height) { + const stripH = (height < 240 || width < 400) ? 10 : 14; + let bottom = height - ASTC_AXIS_LABEL_H; + if (els.specZoom && els.specZoom.offsetParent !== null) { + bottom = Math.min(bottom, height - ASTC_ZOOM_CTRL_H - 4); + } + return { stripH, top: bottom - stripH, bottom }; +} + +// Foreground: the opaque band ruler above the frequency axis labels. +function drawAstcLabelStrip(width, height) { + const bands = astcBandList(); + if (!bands || height < 80) return; + const { stripH, top: stripTop } = astcStripGeom(width, height); + canvasCtx.save(); + canvasCtx.textAlign = 'center'; + canvasCtx.textBaseline = 'middle'; + canvasCtx.font = `${stripH <= 10 ? 8 : 9}px Inter, sans-serif`; + for (const b of bands) { + const x0 = Math.max(0, Math.min(width, freqToX(b.lo, width))); + const x1 = Math.max(0, Math.min(width, freqToX(Math.min(b.hi, MAX_FREQ_DISPLAY), width))); + const w = x1 - x0; + if (w < 0.5) continue; + canvasCtx.fillStyle = astcRgba(b.key, 0.35); + canvasCtx.fillRect(x0, stripTop, w, stripH); + if (w >= 22) { + canvasCtx.fillStyle = 'rgba(30, 30, 30, 0.85)'; + canvasCtx.fillText(b.label, x0 + w / 2, stripTop + stripH / 2 + 0.5); + } + } + canvasCtx.strokeStyle = 'rgba(0, 0, 0, 0.18)'; + canvasCtx.lineWidth = 1; + canvasCtx.strokeRect(0.5, stripTop + 0.5, width - 1, stripH - 1); + canvasCtx.restore(); +} + +// Foreground: a bracket + vowel label over every spectral bundle. +function drawAstcBundles(width, height) { + const bundles = state.astcBundles; + if (!bundles || !bundles.length || !_astcScale || height < 80) return; + const { minDb, dbRange, boost } = _astcScale; + const small = width < 400; + // Lowest bracket line allowed: clear of the ASTC ruler (its 5px end ticks + // included) and never below the previous fixed floor. + const yMax = Math.min(height - 62, astcStripGeom(width, height).top - 8); + canvasCtx.save(); + canvasCtx.textAlign = 'center'; + canvasCtx.textBaseline = 'alphabetic'; + canvasCtx.font = `bold ${small ? 9 : 11}px Inter, sans-serif`; + for (const bd of bundles) { + const x0 = freqToX(bd.freqFrom, width); + const x1 = freqToX(bd.freqTo, width); + if (x1 < -24 || x0 > width + 24) continue; + const norm = dbRange > 0 ? Math.max(0, (bd.peakDb - minDb) / dbRange) : 0; + const peakY = height - Math.pow(norm, boost) * height * 0.9; + // 24 px above the tallest harmonic of the bundle (the Hn labels sit at y-8), + // pushed below the roughness strips when those occupy the top of the canvas. + let y = peakY - 24; + if (state.roughnessVisible && y < 64) y = 64; + if (y < 14) y = 14; + if (y > yMax) y = yMax; + const color = bd.astc ? astcRgba(bd.astc.key, 0.95) : 'rgba(90, 90, 90, 0.95)'; + const xa = Math.max(0, Math.min(width, x0)); + const xb = Math.max(0, Math.min(width, x1)); + + // Bracket ⌐───¬ + canvasCtx.strokeStyle = color; + canvasCtx.lineWidth = 1.5; + canvasCtx.beginPath(); + canvasCtx.moveTo(xa, y + 5); + canvasCtx.lineTo(xa, y); + canvasCtx.lineTo(xb, y); + canvasCtx.lineTo(xb, y + 5); + canvasCtx.stroke(); + + // Centroid tick ▼ + const xc = Math.max(0, Math.min(width, freqToX(bd.centroid, width))); + canvasCtx.fillStyle = color; + canvasCtx.beginPath(); + canvasCtx.moveTo(xc - 3.5, y + 1); + canvasCtx.lineTo(xc + 3.5, y + 1); + canvasCtx.lineTo(xc, y + 6); + canvasCtx.closePath(); + canvasCtx.fill(); + + // Label: 0 && _lastPitchClarity > 0.5) { + updateAstcBundles(state.cachedMicData, audioCtx.sampleRate / 2, state.cachedMicPitch, + micAnalyser.minDecibels, micAnalyser.maxDecibels - micAnalyser.minDecibels, 1.2, nowT); + } else { + holdOrClearAstcBundles(nowT); + } + } + // --- Mic spectrum peak Hz labels --- // Find local maxima in the mic spectrum and label each with its Hz. // Independent of pitch detection so it works even on weak signals. @@ -6918,6 +7168,12 @@ function drawVisualizer() { } } + // ASTC foreground: the band ruler above the frequency axis + the bundle vowels + if (state.astcVisible) { + drawAstcLabelStrip(width, height); + drawAstcBundles(width, height); + } + // Parametric playback EQ overlay (desktop, recording playback only) — drawn on top. drawRecEqOverlay(canvasCtx, width, height); @@ -9382,7 +9638,7 @@ if (window.RecordingsDB) { })(); // App version — bottom-right corner + faint header suffix (bump on each release) -const APP_VERSION = 'v1.56.0'; +const APP_VERSION = 'v1.57.0'; (() => { // The #app-version element is parsed AFTER this script tag, so on first run // getElementById returns null. Defer to DOMContentLoaded if the DOM isn't ready. @@ -9899,6 +10155,35 @@ if (els.btnRoughness) { }); } +// ASTC overlay toggle: show/hide the tone-color legend + the live bundle readout. +// Both elements are optional (PC and mobile markup), so every access is guarded. +function updateAstcReadout() { + const visible = state.astcVisible; + if (els.astcLegend) { + els.astcLegend.style.display = visible ? '' : 'none'; + } + if (!visible || !els.astcLegend) return; + const out = els.astcLegend.querySelector('#astc-bundle-readout'); + if (!out) return; + const bundles = state.astcBundles; + const txt = (bundles && bundles.length) + ? bundles.map(b => `${b.label}${b.weakBridge ? '(wb)' : ''} ×${b.complexity}`).join(' · ') + : '— ▶ Play または マイク入力中に表示されます'; + setTextIfChanged(out, txt); +} + +if (els.btnAstc) { + els.btnAstc.addEventListener('click', () => { + state.astcVisible = !state.astcVisible; + els.btnAstc.classList.toggle('astc-active', state.astcVisible); + if (!state.astcVisible) { + state.astcBundles = null; + _astcScale = null; + } + updateAstcReadout(); + }); +} + // View tabs: Spectrum / Spectrogram if (els.viewTabs) { els.viewTabs.addEventListener('click', (e) => { diff --git a/docs/mobile.css b/docs/mobile.css index aec0eba..8d966de 100644 --- a/docs/mobile.css +++ b/docs/mobile.css @@ -2919,3 +2919,119 @@ body.spectrum-collapsed .spectrum-slope-row { border-bottom-right-radius: 10px; } + +/* ============================================================================ + ASTC overlay (Absolute Spectral Tone Color — Howell 2016) + Compact port of the PC .astc-legend, derived from .roughness-legend. + ============================================================================ */ +.inline-toggle-btn.astc-active { + background: #7b4bd8; + border-color: #7b4bd8; + color: #fff; +} + +/* The spectrum toolbar now carries 4 controls (Vol / Slope / Rough / ASTC). + Let that one row wrap instead of squeezing the Volume slider down to ~36px + on a 390px phone. Scoped with :has() so the mic / slope rows are untouched; + browsers without :has() simply keep the previous single-row behaviour. */ +.spectrum-toolbar:has(#astc-toggle) { + flex-wrap: wrap; + row-gap: 8px; +} +/* .toolbar-item is flex:1 1 0, so it would shrink to nothing before the row ever + wrapped — give the Volume item a real basis so the ASTC pill wraps instead. */ +.spectrum-toolbar:has(#astc-toggle) .toolbar-item:not(.toolbar-item-fit) { + flex: 1 1 150px; +} + +.astc-legend { + margin: 4px 0 2px; + padding: 10px 12px; + background: var(--bg-soft); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + font-size: 0.74rem; + color: var(--text-primary); +} +.al-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 8px; + margin-bottom: 6px; + padding-bottom: 5px; + border-bottom: 1px dashed var(--glass-border); +} +.al-title { + font-weight: 700; + font-size: 0.78rem; +} +.al-src { + font-size: 0.64rem; + color: var(--text-secondary); +} +.al-bands { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px 8px; + margin-bottom: 7px; +} +.al-band { + display: flex; + align-items: center; + gap: 5px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.72rem; + font-weight: 700; + min-width: 0; +} +.al-band em { + font-style: normal; + font-weight: 400; + font-size: 0.64rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.al-chip { + width: 9px; + height: 9px; + border-radius: 2px; + display: inline-block; + flex: 0 0 auto; + background: #888; +} +.al-band[data-astc="u"] .al-chip { background: rgb(156, 60, 217); } +.al-band[data-astc="o"] .al-chip { background: rgb(33, 150, 243); } +.al-band[data-astc="open-o"] .al-chip { background: rgb(0, 172, 193); } +.al-band[data-astc="script-a"] .al-chip { background: rgb(46, 156, 100); } +.al-band[data-astc="a"] .al-chip { background: rgb(139, 195, 74); } +.al-band[data-astc="ae"] .al-chip { background: rgb(222, 178, 32); } +.al-band[data-astc="i"] .al-chip { background: rgb(230, 139, 48); } +.al-band[data-astc="iB"] .al-chip { background: rgb(210, 69, 69); } +.al-readout-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} +.al-readout-label { + font-size: 0.68rem; + color: var(--text-secondary); + white-space: nowrap; +} +.al-readout { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-weight: 700; + font-size: 0.8rem; + color: #7b4bd8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.al-note { + font-size: 0.66rem; + line-height: 1.5; + color: var(--text-secondary); +} diff --git a/docs/mobile.html b/docs/mobile.html index adf0206..20c3280 100644 --- a/docs/mobile.html +++ b/docs/mobile.html @@ -121,6 +121,10 @@

Vocal Simulator

+
+ +
@@ -170,6 +174,30 @@

Vocal Simulator

+ + diff --git a/docs/style.css b/docs/style.css index dc17e7a..04f4e81 100644 --- a/docs/style.css +++ b/docs/style.css @@ -197,6 +197,7 @@ header p { top and buries the Source/Filter controls below, making them hard to scroll to and see. Triggers: Roughness legend open, or the Recordings / Mic Analysis panels expanded. */ .visualizer-panel:not(.is-fullscreen):has(#roughness-toggle.roughness-active), +.visualizer-panel:not(.is-fullscreen):has(#astc-toggle.astc-active), .visualizer-panel:not(.is-fullscreen):has(#recordings-panel[open]), .visualizer-panel:not(.is-fullscreen):has(#mic-analysis-group[open]) { position: relative; @@ -4599,3 +4600,119 @@ body.learn-drawer-open { overflow: hidden; } background: linear-gradient(135deg, transparent 55%, rgba(255, 255, 255, 0.55) 55%); border-bottom-right-radius: 10px; } + +/* ============================================================================ + ASTC overlay (Absolute Spectral Tone Color — Howell 2016) + Toggle pill + legend below the spectrum. Derived from the Roughness + (.roughness-legend / .roughness-active) styles, in the band palette. + ============================================================================ */ +.inline-toggle-btn.astc-active { + background-color: #7b4bd8; + color: #fff; + border-color: #7b4bd8; +} + +/* ASTC: standalone pill, same metrics as the Roughness pill */ +#astc-toggle { + padding: 8px 22px; + font-size: 0.78rem; + line-height: 1.4; + min-width: 62px; + text-align: center; +} + +.canvas-control-group:has(#astc-toggle) { + background: transparent; + border-color: transparent; + padding: 0; +} + +.astc-legend { + margin-top: 12px; + padding: 12px 14px; + background: var(--bg-soft); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + font-size: 0.78rem; + color: var(--text-primary); +} +.al-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px dashed var(--glass-border); +} +.al-title { + font-weight: 700; + font-size: 0.82rem; +} +.al-src { + font-size: 0.68rem; + color: var(--text-secondary); + margin-left: auto; +} +.al-bands { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-bottom: 8px; +} +.al-band { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.74rem; + font-weight: 700; + white-space: nowrap; +} +.al-band em { + font-style: normal; + font-weight: 400; + font-size: 0.68rem; + color: var(--text-secondary); +} +.al-chip { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; + flex: 0 0 auto; + background: #888; +} +.al-band[data-astc="u"] .al-chip { background: rgb(156, 60, 217); } +.al-band[data-astc="o"] .al-chip { background: rgb(33, 150, 243); } +.al-band[data-astc="open-o"] .al-chip { background: rgb(0, 172, 193); } +.al-band[data-astc="script-a"] .al-chip { background: rgb(46, 156, 100); } +.al-band[data-astc="a"] .al-chip { background: rgb(139, 195, 74); } +.al-band[data-astc="ae"] .al-chip { background: rgb(222, 178, 32); } +.al-band[data-astc="i"] .al-chip { background: rgb(230, 139, 48); } +.al-band[data-astc="iB"] .al-chip { background: rgb(210, 69, 69); } +.al-readout-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.al-readout-label { + font-size: 0.72rem; + color: var(--text-secondary); + white-space: nowrap; +} +.al-readout { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-weight: 700; + font-size: 0.86rem; + color: #7b4bd8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.al-note { + font-size: 0.7rem; + line-height: 1.5; + color: var(--text-secondary); +} diff --git a/docs/sw.js b/docs/sw.js index e79ca34..40b4b03 100644 --- a/docs/sw.js +++ b/docs/sw.js @@ -4,7 +4,7 @@ * offline fallback). Cross-origin (CDN): stale-while-revalidate. * Bump VERSION on release to purge old caches. */ -const VERSION = 'v1.56.0'; +const VERSION = 'v1.57.0'; const CACHE = `sf-voice-studio-${VERSION}`; const SHELL = [ './',