From 60afddaa6cd57fe04a6a50f3337bbbf899059364 Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Thu, 29 Oct 2026 11:38:45 +0800 Subject: [PATCH 01/33] fix(ui): calibrate VU meter reference and dynamic ballistics to prevent needle pinning at 100% volume --- public/music/js/visualizer.js | 104 +++++++++++++++++---------- test/vu-meter.test.cjs | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 38 deletions(-) create mode 100644 test/vu-meter.test.cjs diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index dc38be14..da6925f0 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -23,7 +23,10 @@ class LXStudioVUMeterAnimation { this.isCompact = !!options.isCompact; this.needleNormL = 0.0; this.needleNormR = 0.0; + this.rollingPeakRMS = -8.0; this.lastTime = performance.now(); + this.timeBufL = null; + this.timeBufR = null; } isLightMode() { @@ -49,20 +52,21 @@ class LXStudioVUMeterAnimation { } vuToNorm(vu) { - if (vu <= -60) return 0.0; - if (vu >= 3) return 1.0; + if (vu <= -30) return 0.0; + if (vu >= 3.0) { + return Math.min(1.02, 1.0 + (vu - 3.0) * 0.01); + } const table = [ - { vu: -60, norm: 0.00 }, - { vu: -50, norm: 0.12 }, - { vu: -40, norm: 0.24 }, - { vu: -30, norm: 0.36 }, - { vu: -20, norm: 0.48 }, - { vu: -10, norm: 0.62 }, - { vu: -5, norm: 0.74 }, - { vu: -3, norm: 0.78 }, + { vu: -30, norm: 0.00 }, + { vu: -20, norm: 0.08 }, + { vu: -15, norm: 0.18 }, + { vu: -10, norm: 0.32 }, + { vu: -7, norm: 0.48 }, + { vu: -5, norm: 0.60 }, + { vu: -3, norm: 0.73 }, { vu: -1, norm: 0.82 }, - { vu: 0, norm: 0.85 }, - { vu: 1, norm: 0.90 }, + { vu: 0, norm: 0.86 }, + { vu: 1, norm: 0.91 }, { vu: 2, norm: 0.95 }, { vu: 3, norm: 1.00 } ]; @@ -72,7 +76,7 @@ class LXStudioVUMeterAnimation { return table[i].norm + frac * (table[i + 1].norm - table[i].norm); } } - return 0.0; + return vu < -30 ? 0.0 : 1.0; } draw(data, ctx) { @@ -86,7 +90,8 @@ class LXStudioVUMeterAnimation { this.lastTime = now; const audio = document.getElementById('audio-player'); - const isPlaying = audio && !audio.paused && (audio.currentTime > 0 || audio.readyState > 1); + const isMutedOrZero = audio && (audio.muted || audio.volume === 0); + const isPlaying = audio && !audio.paused && !isMutedOrZero && (audio.currentTime > 0 || audio.readyState > 1); let targetNormL = 0.0; let targetNormR = 0.0; @@ -122,23 +127,44 @@ class LXStudioVUMeterAnimation { // Fallback to frequency data if time domain is flat if (rmsL < 1e-4 && rmsR < 1e-4 && data && data.length > 0) { - let sumSq = 0; + let sumEnergy = 0; + let count = 0; for (let i = 0; i < data.length; i++) { - const v = data[i] / 255; - sumSq += v * v; + const byteVal = data[i]; + if (byteVal > 0) { + const db = -100 + (byteVal / 255) * 70; + const lin = Math.pow(10, db / 20); + sumEnergy += lin * lin; + count++; + } + } + if (count > 0) { + const rms = Math.sqrt(sumEnergy); + rmsL = rms; + rmsR = rms; } - const rms = Math.sqrt(sumSq / data.length); - rmsL = rms; - rmsR = rms; } - // 2. Calibrated 0 VU = -18 dBFS Reference (When PCM is available) + // 2. Calibrated Master Reference with Dynamic Headroom Tracking if (rmsL > 1e-4 || rmsR > 1e-4) { const dBFS_L = rmsL > 1e-5 ? 20 * Math.log10(rmsL) : -100; const dBFS_R = rmsR > 1e-5 ? 20 * Math.log10(rmsR) : -100; + const maxDbfs = Math.max(dBFS_L, dBFS_R); + + // Dynamically track the material's peak loudness (mastered music vs dialogue/video) + // so the VU needle always dances in its expressive sweet spot (-10 VU to 0 VU / +1 VU) + // without getting stuck at +3 max or pinned at 0. + if (maxDbfs > -45) { + if (maxDbfs > this.rollingPeakRMS) { + this.rollingPeakRMS += (maxDbfs - this.rollingPeakRMS) * Math.min(1.0, dt * 4.0); + } else { + this.rollingPeakRMS += (maxDbfs - this.rollingPeakRMS) * Math.min(1.0, dt * 0.25); + } + } + const effectiveRef = Math.max(-18.0, Math.min(-5.5, this.rollingPeakRMS + 0.5)); - const vuL = dBFS_L - (-18); - const vuR = dBFS_R - (-18); + const vuL = dBFS_L - effectiveRef; + const vuR = dBFS_R - effectiveRef; targetNormL = this.vuToNorm(vuL); targetNormR = this.vuToNorm(vuR); @@ -149,23 +175,25 @@ class LXStudioVUMeterAnimation { const vol = typeof audio.volume === 'number' ? audio.volume : 1.0; // Typical musical rhythm harmonics (~126 BPM) const beat1 = Math.pow(Math.max(0, Math.sin(t * Math.PI * 2.1)), 2.6); - const beat2 = Math.pow(Math.max(0, Math.sin((t + 0.22) * Math.PI * 1.05)), 2.0) * 0.65; - const swingL = Math.sin(t * 3.4) * 0.07 + Math.cos(t * 1.1) * 0.11; - const swingR = Math.cos(t * 3.4 + 0.4) * 0.07 + Math.sin(t * 1.1 + 0.45) * 0.11; - const microJitter = (Math.sin(t * 16.7) + Math.cos(t * 28.3)) * 0.03; - - // Base level around -10VU ~ -3VU, rhythmic punch up to 0VU / +1VU - const dynamicBase = 0.58 + beat1 * 0.25 + beat2 * 0.13; - targetNormL = Math.max(0.08, Math.min(0.96, (dynamicBase + swingL + microJitter) * (0.35 + 0.65 * vol))); - targetNormR = Math.max(0.08, Math.min(0.96, (dynamicBase + swingR - microJitter) * (0.35 + 0.65 * vol))); + const beat2 = Math.pow(Math.max(0, Math.sin((t + 0.22) * Math.PI * 1.05)), 2.0) * 0.45; + const swingL = Math.sin(t * 3.4) * 0.05 + Math.cos(t * 1.1) * 0.07; + const swingR = Math.cos(t * 3.4 + 0.4) * 0.05 + Math.sin(t * 1.1 + 0.45) * 0.07; + const microJitter = (Math.sin(t * 16.7) + Math.cos(t * 28.3)) * 0.02; + + // Base level around -10VU ~ -5VU (norm 0.35 ~ 0.55), rhythmic punch up to 0VU (norm 0.86) + const dynamicBase = 0.42 + beat1 * 0.35 + beat2 * 0.15; + targetNormL = Math.max(0.08, Math.min(0.92, (dynamicBase + swingL + microJitter) * (0.4 + 0.6 * vol))); + targetNormR = Math.max(0.08, Math.min(0.92, (dynamicBase + swingR - microJitter) * (0.4 + 0.6 * vol))); } } - // 3. ANSI C16.5 Standard VU Ballistics (~300 ms rise and fall integration) - const tau = 0.300; - const alpha = 1.0 - Math.exp(-dt / tau); - this.needleNormL += alpha * (targetNormL - this.needleNormL); - this.needleNormR += alpha * (targetNormR - this.needleNormR); + // 3. ANSI C16.5 Standard VU Ballistics (~300 ms rise and fall integration with responsive attack) + const tauRise = 0.120; // 120ms: snappy transient punch + const tauFall = 0.280; // 280ms: organic mechanical decay + const alphaL = 1.0 - Math.exp(-dt / (targetNormL > this.needleNormL ? tauRise : tauFall)); + const alphaR = 1.0 - Math.exp(-dt / (targetNormR > this.needleNormR ? tauRise : tauFall)); + this.needleNormL += alphaL * (targetNormL - this.needleNormL); + this.needleNormR += alphaR * (targetNormR - this.needleNormR); this.renderConsole(ctx, width, height, this.needleNormL, this.needleNormR, this.isCompact); } @@ -785,7 +813,7 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = isLight ? '#64748b' : 'rgba(148, 163, 184, 0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText('0VU=-18dBFS', cx, isCompact ? (cy + 7.5 * scale) : (cy + 10 * scale)); + ctx.fillText('0VU REF', cx, isCompact ? (cy + 7.5 * scale) : (cy + 10 * scale)); if (!isCompact) { ctx.font = `600 ${Math.max(5.5, Math.round(6.5 * scale))}px -apple-system, BlinkMacSystemFont, Arial, sans-serif`; diff --git a/test/vu-meter.test.cjs b/test/vu-meter.test.cjs new file mode 100644 index 00000000..460face6 --- /dev/null +++ b/test/vu-meter.test.cjs @@ -0,0 +1,131 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { readFileSync, existsSync } = require('node:fs') +const vm = require('node:vm') + +function createVisualizerEnvironment() { + const filePath = existsSync('public/music/js/visualizer.js') ? 'public/music/js/visualizer.js' : 'public/music/js/player/visualizer.js' + const code = readFileSync(filePath, 'utf8') + const listeners = {} + let currentTime = 1000 + let audioPlayer = { + paused: false, + muted: false, + volume: 1.0, + currentTime: 10, + readyState: 4, + addEventListener: () => {}, + removeEventListener: () => {} + } + const context = { + window: { + addEventListener: (evt, fn) => { listeners[evt] = fn }, + removeEventListener: () => {} + }, + document: { + documentElement: { classList: { contains: () => false }, getAttribute: () => null }, + getElementById: (id) => id === 'audio-player' ? audioPlayer : null, + addEventListener: () => {} + }, + performance: { now: () => currentTime }, + Math, + Uint8Array, + console + } + context.window.window = context.window + context.window.document = context.document + vm.createContext(context) + vm.runInContext(code, context) + return { + LXStudioVUMeterAnimation: context.window.LXStudioVUMeterAnimation, + setSimulatedTime: (t) => { currentTime = t }, + audioPlayer + } +} + +test('vuToNorm accurately maps dial scale points to dial tick norms', () => { + const { LXStudioVUMeterAnimation } = createVisualizerEnvironment() + assert.ok(LXStudioVUMeterAnimation, 'LXStudioVUMeterAnimation must be exported on window') + + const vu = new LXStudioVUMeterAnimation() + + // Test scale bounds and tick alignments matching canvas masterTicks + assert.equal(vu.vuToNorm(-30), 0.0) + assert.ok(Math.abs(vu.vuToNorm(-20) - 0.08) < 1e-3, '-20 VU must map to norm 0.08') + assert.ok(Math.abs(vu.vuToNorm(-10) - 0.32) < 1e-3, '-10 VU must map to norm 0.32') + assert.ok(Math.abs(vu.vuToNorm(-7) - 0.48) < 1e-3, '-7 VU must map to norm 0.48') + assert.ok(Math.abs(vu.vuToNorm(-5) - 0.60) < 1e-3, '-5 VU must map to norm 0.60') + assert.ok(Math.abs(vu.vuToNorm(-3) - 0.73) < 1e-3, '-3 VU must map to norm 0.73') + assert.ok(Math.abs(vu.vuToNorm(0) - 0.86) < 1e-3, '0 VU must map to norm 0.86') + assert.ok(Math.abs(vu.vuToNorm(1) - 0.91) < 1e-3, '+1 VU must map to norm 0.91') + assert.ok(Math.abs(vu.vuToNorm(2) - 0.95) < 1e-3, '+2 VU must map to norm 0.95') + assert.ok(Math.abs(vu.vuToNorm(3) - 1.00) < 1e-3, '+3 VU must map to norm 1.00') +}) + +test('VU meter at 100% player volume oscillates and does not stay pinned at max level (1.0)', () => { + const { LXStudioVUMeterAnimation, setSimulatedTime } = createVisualizerEnvironment() + + let simulatedTime = 1000 + let renderedLevels = [] + const mockCtx = { + canvas: { width: 400, height: 60 }, + save: () => {}, + restore: () => {}, + clearRect: () => {}, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + fill: () => {}, + arcTo: () => {}, + arc: () => {}, + clip: () => {}, + fillText: () => {}, + createLinearGradient: () => ({ addColorStop: () => {} }), + createRadialGradient: () => ({ addColorStop: () => {} }), + roundRect: () => {} + } + + const vu = new LXStudioVUMeterAnimation({ isCompact: true }) + + vu.renderConsole = (ctx, w, h, levelL, levelR) => { + renderedLevels.push({ levelL, levelR }) + } + + function makeTimeDomain(targetRms) { + const buf = new Uint8Array(512) + const amp = Math.min(127, Math.round(targetRms * Math.SQRT2 * 128)) + for (let i = 0; i < 512; i++) { + buf[i] = Math.round(128 + amp * Math.sin((i / 512) * Math.PI * 8)) + } + return buf + } + + const rmsValues = [ + 0.30, 0.30, 0.28, 0.14, 0.12, 0.12, 0.13, 0.32, 0.30, 0.15, 0.12, 0.11 + ] + + vu.options.analyserL = { + fftSize: 512, + getByteTimeDomainData: (buf) => { + const curRms = rmsValues[Math.floor((simulatedTime - 1000) / 100) % rmsValues.length] + buf.set(makeTimeDomain(curRms)) + } + } + vu.options.analyserR = vu.options.analyserL + + for (let frame = 0; frame < 60; frame++) { + simulatedTime += 33 + setSimulatedTime(simulatedTime) + vu.draw(null, mockCtx) + } + + const levels = renderedLevels.map(r => r.levelL) + const minLevel = Math.min(...levels) + const maxLevel = Math.max(...levels) + + assert.ok(maxLevel < 1.0, `Max needle level should not be pegged at 1.0 (got ${maxLevel})`) + assert.ok(maxLevel > 0.60, `Needle should rise into active zone on loud beats (got ${maxLevel})`) + assert.ok(minLevel < 0.60, `Needle should dip down between beats (got ${minLevel})`) + assert.ok((maxLevel - minLevel) > 0.15, `Needle should oscillate with significant dynamic range (swing was ${maxLevel - minLevel})`) +}) From b3dc308e1ad4c9d0de0f09e372c5c9cf447953d2 Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Thu, 29 Oct 2026 14:27:36 +0800 Subject: [PATCH 02/33] feat(vu-meter): per-profile dial and pointer designs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/js/visualizer.js | 1435 ++++++++++++++++----------------- 1 file changed, 717 insertions(+), 718 deletions(-) diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index da6925f0..d5fe379f 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -17,6 +17,136 @@ * calibrated 0 VU = -18 dBFS ANSI C16.5 300ms ballistics, dedicated digital clip indicators, * and WCAG-compliant legible typography with zero channel/needle collisions. */ +/** + * Per-profile dial & pointer designs. + * Every colour profile (vuMeterTheme) owns a complete design: dial geometry, + * scale style, tick style, typography, decorations, pointer shape and pivot cap. + * Add a new profile with LXStudioVUMeterAnimation.registerDesign(id, design). + * The measurement scale (norm positions of every tick) is shared by all designs. + */ +const VU_NORM_ZERO = 0.86; +const VU_TICKS_COMPACT = [ + { label: '-20', norm: 0.08, major: true }, + { norm: 0.20, type: 'min' }, + { label: '-10', norm: 0.32, major: true }, + { norm: 0.46, type: 'med' }, + { label: '-5', norm: 0.60, major: true }, + { norm: 0.73, type: 'med' }, + { label: '0', norm: 0.86, major: true, isRed: true }, + { norm: 0.93, type: 'min', isRed: true }, + { label: '+3', norm: 1.00, major: true, isRed: true } +]; +const VU_TICKS_FULL = [ + { label: '-20', norm: 0.08, major: true }, + { norm: 0.16, type: 'min' }, + { norm: 0.24, type: 'min' }, + { label: '-10', norm: 0.32, major: true }, + { norm: 0.40, type: 'min' }, + { label: '-7', norm: 0.48, major: false, type: 'med' }, + { label: '-5', norm: 0.60, major: true }, + { norm: 0.67, type: 'min' }, + { label: '-3', norm: 0.73, major: false, type: 'med' }, + { norm: 0.80, type: 'min' }, + { label: '0', norm: 0.86, major: true, isRed: true }, + { label: '+1', norm: 0.91, major: false, type: 'med', isRed: true }, + { label: '+2', norm: 0.95, major: false, type: 'min', isRed: true }, + { label: '+3', norm: 1.00, major: true, isRed: true } +]; + +const LX_VU_DIAL_DESIGNS = { + // Classic broadcast meter: warm lamp-lit plate, serif numerals, double scale rail, + // tapered black blade with a red painted tip, brass corner screws, domed pivot hub. + 'vintage-amber': { + name: 'Vintage Amber', + geom: { span: 0.07, baseY: { c: 0.66, f: 0.61 }, sag: { c: 0.07, f: 0.08 }, curve: 2, pivot: { c: 1.95, f: 2.25 } }, + font: { family: 'Georgia, "Times New Roman", serif', weight: '700', style: 'normal', scale: 1.0 }, + tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'italic', + face: { + kind: 'radial', cy: 0.45, + light: [[0, '#fffdfa'], [0.40, '#fef8ea'], [0.80, '#fdecc4'], [1, '#fbd38d']], + dark: [[0, '#ffe8a3'], [0.35, '#f6c343'], [0.75, '#d99b1a'], [1, '#9c6600']], + vig: { light: ['rgba(180, 120, 20, 0.08)', 'rgba(160, 100, 10, 0.16)'], dark: ['rgba(80, 40, 10, 0.25)', 'rgba(60, 30, 5, 0.38)'], clear: 'rgba(80, 40, 10, 0)' } + }, + arc: 'double', tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, + needle: { type: 'blade', reach: 1.2, width: 1 }, cap: 'screw', peak: null, sheen: false, deco: 'vintage', + light: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, + dark: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.45)', rim: '#bfa15f' } + }, + // Marine instrument: deep smiling ellipse-like arc, translucent water band, + // round dot ticks, drifting wave lines and a luminous pointer ending in a glowing bead. + 'ocean-blue': { + name: 'Ocean Blue', + geom: { span: 0.055, baseY: { c: 0.60, f: 0.57 }, sag: { c: 0.12, f: 0.14 }, curve: 1.6, pivot: { c: 1.75, f: 2.0 } }, + font: { family: '"Avenir Next", "Inter", "Segoe UI", Roboto, sans-serif', weight: '600', style: 'normal', scale: 1.0 }, + tag: ch => 'CH · ' + ch, tagAlign: 'center', mark: 'VU', markStyle: 'normal', + face: { + kind: 'radial', cy: 0.40, + light: [[0, '#f8fafc'], [0.45, '#e0f2fe'], [0.85, '#bae6fd'], [1, '#7dd3fc']], + dark: [[0, '#42d2ff'], [0.35, '#12a4df'], [0.75, '#0369a1'], [1, '#073b64']], + vig: { light: ['rgba(3, 105, 161, 0.08)', 'rgba(2, 132, 199, 0.15)'], dark: ['rgba(0, 30, 65, 0.30)', 'rgba(0, 20, 50, 0.45)'], clear: 'rgba(0, 30, 65, 0)' } + }, + arc: 'band', arcBand: 3.6, tick: { style: 'dots', len: 1, lwMajor: 1.5, lwMinor: 0.8 }, + needle: { type: 'lumen', reach: 1.0, width: 1 }, cap: 'jewel', peak: null, sheen: true, deco: 'ocean', + light: { ink: '#0c4a6e', muted: 'rgba(12, 74, 110, 0.65)', arc: '#0369a1', red: '#e11d48', needle: '#075985', tip: '#0ea5e9', glow: 'rgba(14, 165, 233, 0.55)', wave: 'rgba(3, 105, 161, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, + dark: { ink: '#f0f9ff', muted: 'rgba(224, 242, 254, 0.75)', arc: 'rgba(240, 249, 255, 0.9)', red: '#ff7a93', needle: '#f0f9ff', tip: '#ffffff', glow: 'rgba(186, 230, 253, 0.95)', wave: 'rgba(255, 255, 255, 0.45)', rim: '#0e5c8f' } + }, + // HUD / sci-fi readout: flat wide arc, segmented bar-graph rail that lights with the level, + // monospace type, corner brackets, scan line and a neon hairline pointer with a diamond head. + 'cyber-cyan': { + name: 'Cyber Cyan', + geom: { span: 0.045, baseY: { c: 0.68, f: 0.64 }, sag: { c: 0.05, f: 0.06 }, curve: 4, pivot: { c: 2.2, f: 2.6 } }, + font: { family: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace', weight: '700', style: 'normal', scale: 0.95 }, + tag: ch => 'CH_' + ch, tagAlign: 'left', mark: 'VU/dB', markStyle: 'normal', + face: { + kind: 'linear', + light: [[0, '#ffffff'], [0.60, '#f0fdfa'], [1, '#ccfbf1']], + dark: [[0, '#0c1015'], [0.5, '#070a0e'], [1, '#030508']], + glow: { color: '6, 182, 212', a0: 0.14, a1: 0.03 } + }, + arc: 'segments', tick: { style: 'bar', len: 0.85, lwMajor: 1.8, lwMinor: 0.9 }, + needle: { type: 'neon', reach: 1.0, width: 1 }, cap: 'ring', peak: 'segment', sheen: false, deco: 'cyber', + light: { ink: '#115e59', muted: 'rgba(17, 94, 89, 0.65)', arc: '#0f766e', red: '#e11d48', needle: '#0f766e', tip: '#06b6d4', glow: 'rgba(6, 182, 212, 0.5)', grid: 'rgba(15, 118, 110, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, + dark: { ink: '#22d3ee', muted: 'rgba(34, 211, 238, 0.6)', arc: 'rgba(34, 211, 238, 0.9)', red: '#ff2d6f', needle: '#7df9ff', tip: '#00f5ff', glow: '#00f5ff', grid: 'rgba(34, 211, 238, 0.5)', rim: '#1e3040' } + }, + // Art-deco luxury: sunburst rays radiating from the pivot, double gold frame with corner lozenges, + // fine wedge ticks capped with pearls, radial serif numerals and a spear pointer with a lozenge. + 'champagne-gold': { + name: 'Champagne Gold', + geom: { span: 0.09, baseY: { c: 0.64, f: 0.60 }, sag: { c: 0.10, f: 0.11 }, curve: 2.6, pivot: { c: 1.9, f: 2.2 } }, + font: { family: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif', weight: '700', style: 'normal', scale: 1.0 }, + tag: ch => '— ' + ch + ' —', tagAlign: 'center', mark: 'VU', markStyle: 'italic', rotateLabels: true, + face: { + kind: 'radial', cy: 0.45, + light: [[0, '#ffffff'], [0.40, '#fefce8'], [0.80, '#fef08a'], [1, '#fde047']], + dark: [[0, '#fff4cc'], [0.35, '#fae39b'], [0.70, '#dfba63'], [1, '#9b7625']], + vig: { light: ['rgba(113, 63, 18, 0.06)', 'rgba(113, 63, 18, 0.14)'], dark: ['rgba(60, 45, 20, 0.25)', 'rgba(50, 35, 10, 0.38)'], clear: 'rgba(60, 45, 20, 0)' } + }, + arc: 'double', arcInset: 2.4, tick: { style: 'wedge', len: 1, lwMajor: 1.6, lwMinor: 0.9 }, + needle: { type: 'deco', reach: 1.0, width: 1 }, cap: 'rosette', peak: null, sheen: true, deco: 'champagne', + light: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#b8860b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 230, 150, 0.22)', frame: '#9c7a2a', rim: 'rgba(180, 140, 60, 0.6)' }, + dark: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#b8860b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 255, 255, 0.16)', frame: '#8a6a1f', rim: '#dab978' } + }, + // Bauhaus mastering meter: no scale rail, left-aligned channel letter, hairline rule, + // oversize watermark letter, long thin ticks and a spear-tipped hairline pointer with peak marker. + 'studio-monochrome': { + name: 'Studio Monochrome', + geom: { span: 0.10, baseY: { c: 0.67, f: 0.63 }, sag: { c: 0.035, f: 0.04 }, curve: 2, pivot: { c: 2.1, f: 2.4 } }, + font: { family: '"Helvetica Neue", "Inter", Arial, sans-serif', weight: '600', style: 'normal', scale: 1.05 }, + tag: ch => ch, tagAlign: 'left', mark: 'VU', markStyle: 'normal', + face: { + kind: 'radial', cy: 0.38, + light: [[0, '#ffffff'], [0.65, '#f8fafc'], [1, '#edf2f7']], + dark: [[0, '#191b22'], [0.60, '#101217'], [1, '#08090d']], + vig: { light: ['rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.08)'], dark: ['rgba(0, 0, 0, 0.35)', 'rgba(0, 0, 0, 0.50)'], clear: 'rgba(0, 0, 0, 0)' } + }, + arc: 'none', tick: { style: 'bar', len: 1.15, lwMajor: 1.1, lwMinor: 0.7 }, + needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', peak: 'tick', sheen: false, deco: 'mono', + light: { ink: '#0f172a', muted: 'rgba(15, 23, 42, 0.55)', arc: '#334155', red: '#f43f5e', needle: '#0f172a', tip: '#f43f5e', rule: 'rgba(15, 23, 42, 0.25)', rim: 'rgba(148, 163, 184, 0.55)' }, + dark: { ink: '#f8fafc', muted: 'rgba(226, 232, 240, 0.6)', arc: 'rgba(241, 245, 249, 0.85)', red: '#f43f5e', needle: '#e2e8f0', tip: '#f43f5e', rule: 'rgba(226, 232, 240, 0.25)', rim: '#334155' } + } +}; + + class LXStudioVUMeterAnimation { constructor(options = {}) { this.options = options; @@ -320,405 +450,654 @@ class LXStudioVUMeterAnimation { ctx.restore(); } - drawMeterPod(ctx, x, y, w, h, level, scale, channelTitle, isCompact, theme, isLight) { - ctx.save(); - const podR = (isCompact ? 8 : 8) * scale; - - // 1. 视窗边界裁剪与防溢出 - ctx.save(); - this.roundRect(ctx, x, y, w, h, podR); - ctx.clip(); - - // 2. 现代专业母带表盘表面 (Ultra-Clean Audiophile Plate) - if (theme === 'vintage-amber') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.45, 12 * scale, x + w / 2, y + h * 0.45, Math.max(w * 0.65, h * 1.1)); - if (isLight) { - bgGrad.addColorStop(0, '#fffdfa'); - bgGrad.addColorStop(0.40, '#fef8ea'); - bgGrad.addColorStop(0.80, '#fdecc4'); - bgGrad.addColorStop(1, '#fbd38d'); - } else { - bgGrad.addColorStop(0, '#ffe8a3'); - bgGrad.addColorStop(0.35, '#f6c343'); - bgGrad.addColorStop(0.75, '#d99b1a'); - bgGrad.addColorStop(1, '#9c6600'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(180, 120, 20, 0.08)' : 'rgba(80, 40, 10, 0.25)'); - vigGrad.addColorStop(0.3, 'rgba(80, 40, 10, 0)'); - vigGrad.addColorStop(0.7, 'rgba(80, 40, 10, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(160, 100, 10, 0.16)' : 'rgba(60, 30, 5, 0.38)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - - } else if (theme === 'ocean-blue') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.40, 12 * scale, x + w / 2, y + h * 0.40, Math.max(w * 0.65, h * 1.1)); - if (isLight) { - bgGrad.addColorStop(0, '#f8fafc'); - bgGrad.addColorStop(0.45, '#e0f2fe'); - bgGrad.addColorStop(0.85, '#bae6fd'); - bgGrad.addColorStop(1, '#7dd3fc'); - } else { - bgGrad.addColorStop(0, '#42d2ff'); - bgGrad.addColorStop(0.35, '#12a4df'); - bgGrad.addColorStop(0.75, '#0369a1'); - bgGrad.addColorStop(1, '#073b64'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(3, 105, 161, 0.08)' : 'rgba(0, 30, 65, 0.30)'); - vigGrad.addColorStop(0.3, 'rgba(0, 30, 65, 0)'); - vigGrad.addColorStop(0.7, 'rgba(0, 30, 65, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(2, 132, 199, 0.15)' : 'rgba(0, 20, 50, 0.45)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - - } else if (theme === 'cyber-cyan') { - // 现代极客赛博:深邃纯净黑白,绝不艳俗糊绿! - if (isLight) { - const bgGrad = ctx.createLinearGradient(x, y, x, y + h); - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.60, '#f0fdfa'); - bgGrad.addColorStop(1, '#ccfbf1'); - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - } else { - const bgGrad = ctx.createLinearGradient(x, y, x, y + h); - bgGrad.addColorStop(0, '#0c1015'); - bgGrad.addColorStop(0.5, '#070a0e'); - bgGrad.addColorStop(1, '#030508'); - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const glow = ctx.createRadialGradient(x + w / 2, y + h * 0.5, 10 * scale, x + w / 2, y + h * 0.5, w * 0.65); - glow.addColorStop(0, 'rgba(6, 182, 212, 0.14)'); - glow.addColorStop(0.6, 'rgba(6, 182, 212, 0.03)'); - glow.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = glow; - ctx.fillRect(x, y, w, h); - } - - } else if (theme === 'champagne-gold') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.45, 12 * scale, x + w / 2, y + h * 0.45, Math.max(w * 0.65, h * 1.1)); - if (isLight) { - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.40, '#fefce8'); - bgGrad.addColorStop(0.80, '#fef08a'); - bgGrad.addColorStop(1, '#fde047'); - } else { - bgGrad.addColorStop(0, '#fff4cc'); - bgGrad.addColorStop(0.35, '#fae39b'); - bgGrad.addColorStop(0.70, '#dfba63'); - bgGrad.addColorStop(1, '#9b7625'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(113, 63, 18, 0.06)' : 'rgba(60, 45, 20, 0.25)'); - vigGrad.addColorStop(0.3, 'rgba(60, 45, 20, 0)'); - vigGrad.addColorStop(0.7, 'rgba(60, 45, 20, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(113, 63, 18, 0.14)' : 'rgba(50, 35, 10, 0.38)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - - } else if (theme === 'studio-monochrome') { - // 现代包豪斯极简母带黑白 - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.38, 12 * scale, x + w / 2, y + h * 0.38, Math.max(w * 0.65, h * 1.1)); - if (isLight) { - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.65, '#f8fafc'); - bgGrad.addColorStop(1, '#edf2f7'); - } else { - bgGrad.addColorStop(0, '#191b22'); - bgGrad.addColorStop(0.60, '#101217'); - bgGrad.addColorStop(1, '#08090d'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); + getDesign(theme) { + return LX_VU_DIAL_DESIGNS[theme] || LX_VU_DIAL_DESIGNS['vintage-amber']; + } - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(0, 0, 0, 0.03)' : 'rgba(0, 0, 0, 0.35)'); - vigGrad.addColorStop(0.3, 'rgba(0, 0, 0, 0)'); - vigGrad.addColorStop(0.7, 'rgba(0, 0, 0, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.50)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - } + static registerDesign(id, design) { + if (id && design) LX_VU_DIAL_DESIGNS[id] = design; + } - // 3. 几何浅弧线参数 - const spanMarginX = w * 0.075; - const scaleStartX = x + spanMarginX; - const scaleEndX = x + w - spanMarginX; - const scaleW = scaleEndX - scaleStartX; + // Single-channel pod (desktop / detail view) + drawMeterPod(ctx, x, y, w, h, level, scale, channelTitle, isCompact, theme, isLight) { + this.drawDialPod(ctx, x, y, w, h, { + needles: [{ level, key: channelTitle }], + scale, isCompact, theme, isLight, title: channelTitle, stereo: false + }); + } - const baseCenterY = isCompact ? (y + h * 0.65) : (y + h * 0.60); - const arcSag = isCompact ? (h * 0.065) : (h * 0.075); + // Mobile single dial with coaxial L/R needles + drawSingleStereoPod(ctx, x, y, w, h, levelL, levelR, scale, theme, isLight) { + this.drawDialPod(ctx, x, y, w, h, { + needles: [{ level: levelL, key: 'L', channel: 'L' }, { level: levelR, key: 'R', channel: 'R' }], + scale, isCompact: true, theme, isLight, title: '', stereo: true + }); + } + // Shared dial renderer: geometry, scale, ticks, pointer and decorations all come from the profile's design + drawDialPod(ctx, x, y, w, h, o) { + const D = this.getDesign(o.theme); + const P = o.isLight ? D.light : D.dark; + const { scale, isCompact, isLight, stereo } = o; + const compactLike = isCompact || stereo; + const k = compactLike ? 'c' : 'f'; + const G = D.geom; + const podR = 8 * scale; + const nowMs = performance.now(); + const t = nowMs / 1000; + const sz = stereo ? { maj: 5.5, med: 3.8, min: 2.2, font: 9.2, gap: 4.5, cap: 3.6, tag: 8.5, mark: 10 } + : isCompact ? { maj: 6, med: 4, min: 2.5, font: 9.8, gap: 5, cap: 3, tag: 8.5, mark: 10.5 } + : { maj: 11, med: 7, min: 4.5, font: 13, gap: 8, cap: 5, tag: 11, mark: 18.5 }; + const tl = D.tick.len; + const majLen = sz.maj * scale * tl; + const medLen = sz.med * scale * tl; + const minLen = sz.min * scale * tl; + const fam = D.font.family; + + // Scale geometry (arc shape differs per design) + const spanX = w * G.span; + const sx = x + spanX; + const ex = x + w - spanX; + const sw = ex - sx; + const baseY = y + h * G.baseY[k]; + const sag = h * G.sag[k]; const pivotX = x + w / 2; - const pivotDepth = isCompact ? (h * 1.95) : (h * 2.25); - const pivotY = y + pivotDepth; - - const getScalePoint = (norm) => { - const bx = scaleStartX + norm * scaleW; - const u = (norm - 0.5) * 2; - const by = baseCenterY + (u * u) * arcSag; - + const pivotY = y + h * G.pivot[k]; + const getScalePoint = (norm, off = 0) => { + const bx = sx + norm * sw; + const u = Math.abs((norm - 0.5) * 2); + const by = baseY + Math.pow(u, G.curve) * sag; const dx = bx - pivotX; const dy = by - pivotY; const dist = Math.sqrt(dx * dx + dy * dy); const ux = dx / dist; const uy = dy / dist; - - return { bx, by, ux, uy }; + return { bx: bx + ux * off, by: by + uy * off, ux, uy }; }; - // 配色定义 - const isDarkText = theme === 'vintage-amber' || theme === 'champagne-gold' || (isLight && (theme === 'ocean-blue' || theme === 'cyber-cyan' || theme === 'studio-monochrome')); - const textMain = isDarkText ? '#1e293b' : (theme === 'studio-monochrome' ? '#f8fafc' : (theme === 'cyber-cyan' ? '#22d3ee' : '#f1f5f9')); - const textMuted = isDarkText ? 'rgba(51, 65, 85, 0.65)' : (theme === 'studio-monochrome' ? 'rgba(226, 232, 240, 0.65)' : 'rgba(34, 211, 238, 0.65)'); - const redColor = '#f43f5e'; // 现代活力珊瑚红 - const arcColor = isDarkText ? '#334155' : (theme === 'studio-monochrome' ? 'rgba(241, 245, 249, 0.85)' : (theme === 'cyber-cyan' ? 'rgba(34, 211, 238, 0.85)' : '#94a3b8')); + ctx.save(); + ctx.save(); + this.roundRect(ctx, x, y, w, h, podR); + ctx.clip(); + + // 1. Dial face + const stops = isLight ? D.face.light : D.face.dark; + let faceFill; + if (D.face.kind === 'radial') { + const cy = y + h * D.face.cy; + faceFill = ctx.createRadialGradient(x + w / 2, cy, 12 * scale, x + w / 2, cy, Math.max(w * 0.65, h * 1.1)); + } else { + faceFill = ctx.createLinearGradient(x, y, x, y + h); + } + stops.forEach(s => faceFill.addColorStop(s[0], s[1])); + ctx.fillStyle = faceFill; + ctx.fillRect(x, y, w, h); + + if (D.face.vig) { + const v = isLight ? D.face.vig.light : D.face.vig.dark; + const vg = ctx.createLinearGradient(x, y, x, y + h); + vg.addColorStop(0, v[0]); + vg.addColorStop(0.3, D.face.vig.clear); + vg.addColorStop(0.7, D.face.vig.clear); + vg.addColorStop(1, v[1]); + ctx.fillStyle = vg; + ctx.fillRect(x, y, w, h); + } + if (D.face.glow && !isLight) { + const gl = ctx.createRadialGradient(x + w / 2, y + h * 0.5, 10 * scale, x + w / 2, y + h * 0.5, w * 0.65); + gl.addColorStop(0, `rgba(${D.face.glow.color}, ${D.face.glow.a0})`); + gl.addColorStop(0.6, `rgba(${D.face.glow.color}, ${D.face.glow.a1})`); + gl.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = gl; + ctx.fillRect(x, y, w, h); + } + + // 2. Profile-specific decorations + this.drawDialDeco(ctx, D, P, { x, y, w, h, scale, t, isLight, isCompact, stereo, pivotX, pivotY, sx, ex, o }); - // 4. 现代极简通道徽标 (Top Channel Pill Badge) + // 3. Channel tag / stereo legend ctx.save(); - const tagText = channelTitle === 'L' ? 'CH · L' : 'CH · R'; - const tagFontSize = Math.max(7.5, Math.round((isCompact ? 8.5 : 11.0) * scale)); - ctx.font = `700 ${tagFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif`; - const tagY = y + (isCompact ? 4.5 : 7.5) * scale; - - ctx.fillStyle = textMuted; - ctx.textAlign = 'center'; + const tagSize = Math.max(7.5, Math.round(sz.tag * scale * D.font.scale)); + const tagY = y + (stereo ? 4.5 : (isCompact ? 4.5 : 7.5)) * scale; + ctx.font = `700 ${tagSize}px ${fam}`; + ctx.fillStyle = P.muted; ctx.textBaseline = 'top'; ctx.letterSpacing = '1px'; - ctx.fillText(tagText, x + w / 2, tagY); + if (stereo) { + ctx.textAlign = 'center'; + ctx.fillText('DUAL VU · STEREO', x + w / 2, tagY); + ctx.textAlign = 'left'; + ctx.fillStyle = '#10b981'; + ctx.beginPath(); + ctx.arc(sx, tagY + 4 * scale, 2 * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = P.muted; + ctx.fillText('L', sx + 5 * scale, tagY); + ctx.textAlign = 'right'; + ctx.fillText('R', ex - 5 * scale, tagY); + ctx.fillStyle = P.red; + ctx.beginPath(); + ctx.arc(ex, tagY + 4 * scale, 2 * scale, 0, Math.PI * 2); + ctx.fill(); + } else if (D.tagAlign === 'left') { + ctx.textAlign = 'left'; + ctx.fillText(D.tag(o.title), sx, tagY); + } else { + ctx.textAlign = 'center'; + ctx.fillText(D.tag(o.title), x + w / 2, tagY); + } ctx.restore(); - // 5. 现代双段能量弧带 (Ambient Glow Track Ribbon) - const normZero = 0.86; - const majorTickLen = isCompact ? (6.0 * scale) : (11.0 * scale); - const medTickLen = isCompact ? (4.0 * scale) : (7.0 * scale); - const minorTickLen = isCompact ? (2.5 * scale) : (4.5 * scale); - - // 主安全区间浅光轨 - ctx.beginPath(); - for (let i = 0; i <= 40; i++) { - const n = (i / 40) * normZero; - const p = getScalePoint(n); - if (i === 0) ctx.moveTo(p.bx, p.by); - else ctx.lineTo(p.bx, p.by); - } - ctx.strokeStyle = arcColor; - ctx.lineWidth = (isCompact ? 1.0 : 1.4) * scale; - ctx.stroke(); + // 4. Scale rail + const normZero = VU_NORM_ZERO; + const maxLevel = Math.max(0, ...o.needles.map(n => Math.min(1, n.level || 0))); + const strokeRail = (n0, n1, off, color, lw, steps) => { + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const p = getScalePoint(n0 + (n1 - n0) * (i / steps), off); + if (i === 0) ctx.moveTo(p.bx, p.by); + else ctx.lineTo(p.bx, p.by); + } + ctx.strokeStyle = color; + ctx.lineWidth = lw; + ctx.stroke(); + }; + const railW = (compactLike ? 1.0 : 1.4) * scale; + const redW = (compactLike ? 1.6 : 2.2) * scale; + const peaks = this.peaks || (this.peaks = {}); + const peakInfo = o.needles.map(n => { + const key = (stereo ? 'S' : 'P') + n.key + (compactLike ? 'c' : 'f'); + const pk = peaks[key] || (peaks[key] = { v: 0, t: nowMs, last: nowMs }); + const norm = Math.max(0, Math.min(1, n.level || 0)); + const dt = Math.min(0.1, (nowMs - pk.last) / 1000); + pk.last = nowMs; + if (norm >= pk.v) { pk.v = norm; pk.t = nowMs; } + else if (nowMs - pk.t > 700) pk.v = Math.max(norm, pk.v - dt * 0.5); + return pk.v; + }); - // 0dB 过载红区能量条 - ctx.beginPath(); - for (let i = 0; i <= 15; i++) { - const n = normZero + (i / 15) * (1.0 - normZero); - const p = getScalePoint(n); - if (i === 0) ctx.moveTo(p.bx, p.by); - else ctx.lineTo(p.bx, p.by); + if (D.arc === 'double') { + const off = (D.arcInset || 2.0) * scale; + strokeRail(0, normZero, 0, P.arc, railW, 40); + strokeRail(0, normZero, -off, P.arc, railW * 0.55, 40); + strokeRail(normZero, 1, 0, P.red, redW, 15); + strokeRail(normZero, 1, -off, P.red, railW * 0.55, 15); + } else if (D.arc === 'band') { + const bw = D.arcBand * scale; + const band = (n0, n1, color, alpha, steps) => { + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const p = getScalePoint(n0 + (n1 - n0) * (i / steps), 0); + if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); + } + for (let i = steps; i >= 0; i--) { + const p = getScalePoint(n0 + (n1 - n0) * (i / steps), -bw); + ctx.lineTo(p.bx, p.by); + } + ctx.closePath(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.fill(); + ctx.globalAlpha = 1; + }; + band(0, normZero, P.arc, isLight ? 0.22 : 0.20, 40); + band(normZero, 1, P.red, 0.5, 15); + strokeRail(0, normZero, 0, P.arc, railW * 0.8, 40); + strokeRail(normZero, 1, 0, P.red, redW * 0.8, 15); + } else if (D.arc === 'segments') { + const N = compactLike ? 24 : 32; + const segW = (compactLike ? 1.9 : 2.6) * scale; + const peakSeg = Math.min(N - 1, Math.floor(Math.max(...peakInfo) * N)); + ctx.lineCap = 'butt'; + for (let i = 0; i < N; i++) { + const c = (i + 0.5) / N; + const lit = c <= maxLevel + 0.5 / N; + const hot = c > normZero; + ctx.save(); + if (lit || i === peakSeg) { + ctx.globalAlpha = 1; + if (!isLight) { ctx.shadowColor = hot ? P.red : P.glow; ctx.shadowBlur = 4 * scale; } + } else { + ctx.globalAlpha = 0.2; + } + const col = (i === peakSeg && maxLevel < c) ? P.needle : (hot ? P.red : P.tip); + strokeRail(i / N + 0.004, (i + 0.66) / N, 0, col, segW, 3); + ctx.restore(); + } + } else if (D.arc === 'none') { + strokeRail(normZero, 1, 0, P.red, redW, 15); } - ctx.strokeStyle = redColor; - ctx.lineWidth = (isCompact ? 1.6 : 2.2) * scale; - ctx.stroke(); - - // 6. 现代专业母带级 dB 刻度 (等宽工整,高清晰度) - const masterTicks = isCompact ? [ - { label: '-20', norm: 0.08, major: true }, - { norm: 0.20, type: 'min' }, - { label: '-10', norm: 0.32, major: true }, - { norm: 0.46, type: 'med' }, - { label: '-5', norm: 0.60, major: true }, - { norm: 0.73, type: 'med' }, - { label: '0', norm: 0.86, major: true, isRed: true }, - { norm: 0.93, type: 'min', isRed: true }, - { label: '+3', norm: 1.00, major: true, isRed: true } - ] : [ - { label: '-20', norm: 0.08, major: true }, - { norm: 0.16, type: 'min' }, - { norm: 0.24, type: 'min' }, - { label: '-10', norm: 0.32, major: true }, - { norm: 0.40, type: 'min' }, - { label: '-7', norm: 0.48, major: false, type: 'med' }, - { label: '-5', norm: 0.60, major: true }, - { norm: 0.67, type: 'min' }, - { label: '-3', norm: 0.73, major: false, type: 'med' }, - { norm: 0.80, type: 'min' }, - { label: '0', norm: 0.86, major: true, isRed: true }, - { label: '+1', norm: 0.91, major: false, type: 'med', isRed: true }, - { label: '+2', norm: 0.95, major: false, type: 'min', isRed: true }, - { label: '+3', norm: 1.00, major: true, isRed: true } - ]; - // 绘制刻度线 - masterTicks.forEach(t => { - const p = getScalePoint(t.norm); - const len = t.major ? majorTickLen : (t.type === 'med' ? medTickLen : minorTickLen); - ctx.strokeStyle = t.isRed ? redColor : arcColor; - ctx.lineWidth = (t.major ? (t.isRed ? 1.6 : 1.2) : 0.8) * scale; - ctx.beginPath(); - ctx.moveTo(p.bx, p.by); - ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); - ctx.stroke(); + // 5. Ticks + const ticks = compactLike ? VU_TICKS_COMPACT : VU_TICKS_FULL; + ticks.forEach(tk => { + const p = getScalePoint(tk.norm); + const len = tk.major ? majLen : (tk.type === 'med' ? medLen : minLen); + const col = tk.isRed ? P.red : P.arc; + const lw = (tk.major ? (tk.isRed ? D.tick.lwMajor * 1.15 : D.tick.lwMajor) : D.tick.lwMinor) * scale; + ctx.fillStyle = col; + ctx.strokeStyle = col; + if (D.tick.style === 'dots') { + const r = (tk.major ? 1.5 : 0.85) * scale * (compactLike ? 0.85 : 1); + const d = tk.major ? len : len * 0.6; + ctx.beginPath(); + ctx.arc(p.bx + p.ux * d, p.by + p.uy * d, r, 0, Math.PI * 2); + ctx.fill(); + } else if (D.tick.style === 'wedge') { + const hw = lw * (tk.major ? 0.9 : 0.6); + ctx.beginPath(); + ctx.moveTo(p.bx - p.uy * hw, p.by + p.ux * hw); + ctx.lineTo(p.bx + p.uy * hw, p.by - p.ux * hw); + ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); + ctx.closePath(); + ctx.fill(); + if (tk.major) { + ctx.beginPath(); + ctx.arc(p.bx + p.ux * (len + 1.6 * scale), p.by + p.uy * (len + 1.6 * scale), 0.9 * scale, 0, Math.PI * 2); + ctx.fill(); + } + } else { + ctx.lineWidth = lw; + ctx.beginPath(); + ctx.moveTo(p.bx, p.by); + ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); + ctx.stroke(); + } }); - // 绘制主刻度文字 (字号加大,粗体 700,高清晰度) - const fontSize = Math.max(8.5, Math.round((isCompact ? 9.8 : 13.0) * scale)); - ctx.font = `700 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif`; + // 6. Scale labels + const fontSize = Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)); + ctx.font = `${D.font.style === 'italic' ? 'italic ' : ''}${D.font.weight} ${fontSize}px ${fam}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const labelGap = (isCompact ? 5.0 : 8.0) * scale; - - masterTicks.forEach(t => { - if (!t.label) return; - const p = getScalePoint(t.norm); - const lx = p.bx + p.ux * (majorTickLen + labelGap); - const ly = p.by + p.uy * (majorTickLen + labelGap); - ctx.fillStyle = t.isRed ? redColor : textMain; - ctx.fillText(t.label, lx, ly); + const labelGap = sz.gap * scale + (D.tick.style === 'wedge' ? 2 * scale : 0); + ticks.forEach(tk => { + if (!tk.label) return; + const p = getScalePoint(tk.norm); + const lx = p.bx + p.ux * (majLen + labelGap); + const ly = p.by + p.uy * (majLen + labelGap); + ctx.fillStyle = tk.isRed ? P.red : P.ink; + if (D.rotateLabels) { + ctx.save(); + ctx.translate(lx, ly); + ctx.rotate(Math.atan2(p.uy, p.ux) + Math.PI / 2); + ctx.fillText(tk.label, 0, 0); + ctx.restore(); + } else { + ctx.fillText(tk.label, lx, ly); + } }); - // 7. 优雅中心 VU 徽标 (字号加大) + // 7. Centre mark ctx.save(); - const vuY = baseCenterY + (isCompact ? 10.5 : 18.5) * scale; - const vuFontSize = Math.max(8.0, Math.round((isCompact ? 9.2 : 13.0) * scale)); - ctx.font = `800 ${vuFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; - ctx.fillStyle = textMuted; + const markSize = Math.max(7.5, Math.round((compactLike ? 9.2 : 13.0) * scale * D.font.scale)); + ctx.font = `${D.markStyle === 'italic' ? 'italic ' : ''}800 ${markSize}px ${fam}`; + ctx.fillStyle = P.muted; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.letterSpacing = '2px'; - ctx.fillText('VU', x + w / 2, vuY); + ctx.fillText(D.mark, x + w / 2, baseY + sz.mark * scale); ctx.restore(); - // 8. 现代高精度光纤碳针与珊瑚红荧光针尖 (Fiber-Optic Needle with Coral Tip) - const norm = Math.max(0, Math.min(1.0, level)); - const targetPoint = getScalePoint(norm); - - const ndx = targetPoint.bx - pivotX; - const ndy = targetPoint.by - pivotY; - const nDist = Math.sqrt(ndx * ndx + ndy * ndy); - const nux = ndx / nDist; - const nuy = ndy / nDist; - - const needleTipX = targetPoint.bx + nux * (majorTickLen + 1.2 * scale); - const needleTipY = targetPoint.by + nuy * (majorTickLen + 1.2 * scale); - - const needleBaseY = y + h + 2 * scale; - const tBase = (needleBaseY - pivotY) / nuy; - const needleBaseX = pivotX + tBase * nux; - - const npX = -nuy; - const npY = nux; - - const baseW = (isCompact ? 0.9 : 1.3) * scale; - const tipW = (isCompact ? 0.25 : 0.35) * scale; + // 8. Peak-hold markers (designs that opt in) + if (D.peak === 'tick') { + o.needles.forEach((n, i) => { + const norm = Math.max(0, Math.min(1, n.level || 0)); + if (peakInfo[i] <= norm + 0.02) return; + const p = getScalePoint(Math.min(1, peakInfo[i])); + ctx.strokeStyle = P.tip; + ctx.lineWidth = 1.8 * scale; + ctx.beginPath(); + ctx.moveTo(p.bx - p.ux * 1.5 * scale, p.by - p.uy * 1.5 * scale); + ctx.lineTo(p.bx + p.ux * (majLen + 2 * scale), p.by + p.uy * (majLen + 2 * scale)); + ctx.stroke(); + }); + } - // 悬浮立体柔和阴影 - ctx.save(); - ctx.shadowColor = isLight ? 'rgba(0, 0, 0, 0.25)' : 'rgba(0, 0, 0, 0.50)'; - ctx.shadowBlur = (isCompact ? 2.5 : 4.5) * scale; - ctx.shadowOffsetX = 1.0 * scale; - ctx.shadowOffsetY = 1.5 * scale; + // 9. Pointers + const baseNY = y + h + 2 * scale; + const drawNeedle = (norm, tipColor, shadowSide) => { + const tp = getScalePoint(norm); + const dx = tp.bx - pivotX; + const dy = tp.by - pivotY; + const dist = Math.sqrt(dx * dx + dy * dy); + const nux = dx / dist; + const nuy = dy / dist; + const npX = -nuy; + const npY = nux; + const reach = majLen + 1.2 * scale * D.needle.reach; + const tx = tp.bx + nux * reach; + const ty = tp.by + nuy * reach; + const tB = (baseNY - pivotY) / nuy; + const bx = pivotX + tB * nux; + const by = baseNY; + const pt = (r) => ({ x: bx + (tx - bx) * r, y: by + (ty - by) * r }); + const poly = (r0, w0, r1, w1, fill) => { + const a = pt(r0); + const b = pt(r1); + ctx.beginPath(); + ctx.moveTo(a.x - npX * w0, a.y - npY * w0); + ctx.lineTo(a.x + npX * w0, a.y + npY * w0); + ctx.lineTo(b.x + npX * w1, b.y + npY * w1); + ctx.lineTo(b.x - npX * w1, b.y - npY * w1); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + }; + const bw = (compactLike ? 0.9 : 1.3) * scale * D.needle.width; + const tw = (compactLike ? 0.25 : 0.35) * scale; + const wAt = (r, b0, t0) => b0 + (t0 - b0) * r; + const type = D.needle.type; - // 碳素针体 (Bottom 65%) - const midRatio = 0.65; - const needleMidX = needleBaseX + (needleTipX - needleBaseX) * midRatio; - const needleMidY = needleBaseY + (needleTipY - needleBaseY) * midRatio; - const midW = baseW + (tipW - baseW) * midRatio; + ctx.save(); + if (type === 'lumen' || type === 'neon') { + ctx.shadowColor = P.glow; + ctx.shadowBlur = (type === 'neon' ? 8 : 6) * scale; + } else { + ctx.shadowColor = isLight ? 'rgba(0, 0, 0, 0.25)' : 'rgba(0, 0, 0, 0.50)'; + ctx.shadowBlur = (compactLike ? 2.5 : 4.5) * scale; + ctx.shadowOffsetX = (stereo ? shadowSide * 0.8 : 1.0) * scale; + ctx.shadowOffsetY = 1.5 * scale; + } - ctx.beginPath(); - ctx.moveTo(needleBaseX - npX * baseW, needleBaseY - npY * baseW); - ctx.lineTo(needleBaseX + npX * baseW, needleBaseY + npY * baseW); - ctx.lineTo(needleMidX + npX * midW, needleMidY + npY * midW); - ctx.lineTo(needleMidX - npX * midW, needleMidY - npY * midW); - ctx.closePath(); - ctx.fillStyle = isLight ? '#0f172a' : (theme === 'studio-monochrome' ? '#cbd5e1' : '#111318'); - ctx.fill(); + if (type === 'blade') { + const b0 = bw * 1.15; + poly(0, b0, 0.68, wAt(0.68, b0, tw), P.needle); + poly(0.68, wAt(0.68, b0, tw), 1, tw, tipColor); + } else if (type === 'lumen') { + const a = pt(0); + const b = pt(0.94); + ctx.lineCap = 'round'; + ctx.strokeStyle = P.needle; + ctx.lineWidth = (compactLike ? 1.3 : 1.9) * scale; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = isLight ? 'rgba(255,255,255,0.75)' : 'rgba(14,116,144,0.55)'; + ctx.lineWidth = 0.5 * scale; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + ctx.shadowColor = P.glow; + ctx.shadowBlur = 7 * scale; + ctx.fillStyle = tipColor; + ctx.beginPath(); + ctx.arc(b.x, b.y, (compactLike ? 1.9 : 2.8) * scale, 0, Math.PI * 2); + ctx.fill(); + } else if (type === 'neon') { + const a = pt(0); + const b = pt(0.92); + ctx.lineCap = 'butt'; + ctx.strokeStyle = P.needle; + ctx.lineWidth = (compactLike ? 0.9 : 1.2) * scale; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + const dz = (compactLike ? 2.4 : 3.4) * scale; + const c = pt(0.96); + ctx.fillStyle = tipColor; + ctx.beginPath(); + ctx.moveTo(c.x + nux * dz, c.y + nuy * dz); + ctx.lineTo(c.x + npX * dz * 0.55, c.y + npY * dz * 0.55); + ctx.lineTo(c.x - nux * dz, c.y - nuy * dz); + ctx.lineTo(c.x - npX * dz * 0.55, c.y - npY * dz * 0.55); + ctx.closePath(); + ctx.fill(); + } else if (type === 'deco') { + const b0 = bw * 1.5; + poly(0, b0, 0.8, b0 * 0.32, P.needle); + poly(0.8, b0 * 0.32, 1, tw * 0.6, P.needle); + const c = pt(0.8); + const L = (compactLike ? 3.0 : 4.4) * scale; + const W = (compactLike ? 1.5 : 2.1) * scale; + ctx.beginPath(); + ctx.moveTo(c.x + nux * L, c.y + nuy * L); + ctx.lineTo(c.x + npX * W, c.y + npY * W); + ctx.lineTo(c.x - nux * L, c.y - nuy * L); + ctx.lineTo(c.x - npX * W, c.y - npY * W); + ctx.closePath(); + ctx.fillStyle = tipColor; + ctx.fill(); + ctx.strokeStyle = P.edge; + ctx.lineWidth = 0.6 * scale; + ctx.stroke(); + } else { + const hw = (compactLike ? 0.5 : 0.7) * scale; + poly(0, hw, 0.78, hw * 0.75, P.needle); + poly(0.78, (compactLike ? 1.0 : 1.4) * scale, 1, tw * 0.7, tipColor); + } + ctx.restore(); + return { bx, by }; + }; - // 现代珊瑚红荧光针尖 (Top 35%) - ctx.beginPath(); - ctx.moveTo(needleMidX - npX * midW, needleMidY - npY * midW); - ctx.lineTo(needleMidX + npX * midW, needleMidY + npY * midW); - ctx.lineTo(needleTipX + npX * tipW, needleTipY + npY * tipW); - ctx.lineTo(needleTipX - npX * tipW, needleTipY - npY * tipW); - ctx.closePath(); - ctx.fillStyle = theme === 'cyber-cyan' && !isLight ? '#00f5ff' : redColor; - ctx.fill(); - ctx.restore(); + let lastBase = { bx: pivotX, by: baseNY }; + o.needles.forEach((n) => { + const norm = Math.max(0, Math.min(1.0, n.level || 0)); + const color = stereo ? (n.channel === 'L' ? '#10b981' : P.red) : P.tip; + lastBase = drawNeedle(norm, color, n.channel === 'L' ? -1 : 1); + }); - // 9. 精工双环金属轴心螺帽 (Machined Pivot Cap) + // 10. Pivot cap + const capX = stereo ? pivotX : lastBase.bx; + const capY = baseNY; + const capR = sz.cap * scale; ctx.save(); - const capR = (isCompact ? 3.0 : 5.0) * scale; - const capGrad = ctx.createRadialGradient(needleBaseX - 0.5, needleBaseY - 0.5, 0.5, needleBaseX, needleBaseY, capR); - if (isLight) { - capGrad.addColorStop(0, '#ffffff'); - capGrad.addColorStop(0.5, '#cbd5e1'); - capGrad.addColorStop(1, '#64748b'); - } else { - capGrad.addColorStop(0, '#64748b'); - capGrad.addColorStop(0.6, '#334155'); - capGrad.addColorStop(1, '#0f172a'); - } - ctx.fillStyle = capGrad; - ctx.beginPath(); - if (typeof ctx.arc === 'function') { - ctx.arc(needleBaseX, needleBaseY, capR, 0, Math.PI * 2); + const disc = (r) => { ctx.beginPath(); ctx.arc(capX, capY, r, 0, Math.PI * 2); }; + if (D.cap === 'screw' || D.cap === 'rosette' || D.cap === 'jewel') { + const cg = ctx.createRadialGradient(capX - 0.5, capY - 0.5, 0.5, capX, capY, capR); + if (D.cap === 'rosette') { + cg.addColorStop(0, '#fff3c4'); cg.addColorStop(0.5, '#d4a93c'); cg.addColorStop(1, '#6b4a0c'); + } else if (D.cap === 'jewel') { + cg.addColorStop(0, '#e0f7ff'); cg.addColorStop(0.45, isLight ? '#38bdf8' : '#7dd3fc'); cg.addColorStop(1, '#075985'); + } else if (isLight) { + cg.addColorStop(0, '#ffffff'); cg.addColorStop(0.5, '#cbd5e1'); cg.addColorStop(1, '#64748b'); + } else { + cg.addColorStop(0, '#d9c28a'); cg.addColorStop(0.55, '#8a6b2c'); cg.addColorStop(1, '#3b2a0c'); + } + ctx.fillStyle = cg; + disc(capR); + ctx.fill(); + if (D.cap === 'screw') { + ctx.strokeStyle = 'rgba(30, 20, 5, 0.6)'; + ctx.lineWidth = 0.7 * scale; + ctx.beginPath(); + ctx.moveTo(capX - capR * 0.6, capY); + ctx.lineTo(capX + capR * 0.6, capY); + ctx.stroke(); + } else if (D.cap === 'rosette') { + ctx.strokeStyle = P.edge; + ctx.lineWidth = 0.6 * scale; + disc(capR); + ctx.stroke(); + ctx.fillStyle = P.edge; + disc(capR * 0.3); + ctx.fill(); + } + } else if (D.cap === 'ring') { + ctx.shadowColor = P.glow; + ctx.shadowBlur = isLight ? 0 : 5 * scale; + ctx.strokeStyle = P.needle; + ctx.lineWidth = 1.1 * scale; + disc(capR); + ctx.stroke(); + ctx.fillStyle = P.tip; + disc(capR * 0.35); + ctx.fill(); } else { - this.roundRect(ctx, needleBaseX - capR, needleBaseY - capR, capR * 2, capR * 2, capR); + ctx.fillStyle = P.needle; + disc(capR * 0.8); + ctx.fill(); } - ctx.fill(); ctx.restore(); - // 10. 防眩光蓝宝石双曲面玻璃反光 - const glassGrad = ctx.createLinearGradient(x, y, x, y + h * 0.45); - glassGrad.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'); - glassGrad.addColorStop(0.5, isLight ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.02)'); - glassGrad.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = glassGrad; + // 11. Glass reflection + const glass = ctx.createLinearGradient(x, y, x, y + h * 0.45); + glass.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'); + glass.addColorStop(0.5, isLight ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.02)'); + glass.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = glass; ctx.fillRect(x, y, w, h * 0.45); + if (D.sheen) { + const sg = ctx.createLinearGradient(x, y, x + w, y + h); + sg.addColorStop(0.28, 'rgba(255, 255, 255, 0)'); + sg.addColorStop(0.42, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.10)'); + sg.addColorStop(0.55, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = sg; + ctx.fillRect(x, y, w, h); + } + ctx.restore(); // end clip - ctx.restore(); // 解除裁剪 - - // 11. 现代细致拉丝视窗内嵌倒角框 - if (isCompact) { - // 紧凑模式:单条柔和描边,降低视觉重量 + // 12. Bezel + if (isCompact || stereo) { ctx.strokeStyle = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); ctx.stroke(); - } else if (isLight) { - ctx.strokeStyle = 'rgba(148, 163, 184, 0.55)'; - ctx.lineWidth = 1.0 * scale; + } else { + ctx.strokeStyle = P.rim; + ctx.lineWidth = 1.1 * scale; this.roundRect(ctx, x - 0.5, y - 0.5, w + 1, h + 1, podR); ctx.stroke(); - - ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.strokeStyle = isLight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.8)'; ctx.lineWidth = 0.8 * scale; this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); ctx.stroke(); - } else { - const rimColor = theme === 'vintage-amber' ? '#bfa15f' : (theme === 'champagne-gold' ? '#dab978' : (theme === 'cyber-cyan' ? '#1e3040' : '#334155')); - ctx.strokeStyle = rimColor; - ctx.lineWidth = 1.2 * scale; - this.roundRect(ctx, x - 0.5, y - 0.5, w + 1, h + 1, podR); - ctx.stroke(); + } + ctx.restore(); + } - ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)'; + // Decorative layer unique to each profile (drawn on the face, under scale and pointer) + drawDialDeco(ctx, D, P, c) { + const { x, y, w, h, scale, t, isLight } = c; + ctx.save(); + if (D.deco === 'vintage') { + const g = ctx.createRadialGradient(x + w / 2, y + h * 0.35, 4 * scale, x + w / 2, y + h * 0.35, w * 0.5); + g.addColorStop(0, P.glow); + g.addColorStop(1, 'rgba(255, 200, 90, 0)'); + ctx.globalAlpha = isLight ? 0.35 : 0.55; + ctx.fillStyle = g; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + const inset = 2.5 * scale; + ctx.strokeStyle = P.frame; + ctx.lineWidth = 0.9 * scale; + this.roundRect(ctx, x + inset, y + inset, w - inset * 2, h - inset * 2, 5 * scale); + ctx.stroke(); + const r = 1.4 * scale; + [[x + 6 * scale, y + 6 * scale], [x + w - 6 * scale, y + 6 * scale], [x + 6 * scale, y + h - 6 * scale], [x + w - 6 * scale, y + h - 6 * scale]].forEach(([px, py]) => { + ctx.fillStyle = '#b08d57'; + ctx.beginPath(); + ctx.arc(px, py, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(40, 25, 5, 0.7)'; + ctx.lineWidth = 0.5 * scale; + ctx.beginPath(); + ctx.moveTo(px - r * 0.7, py - r * 0.7); + ctx.lineTo(px + r * 0.7, py + r * 0.7); + ctx.stroke(); + }); + } else if (D.deco === 'ocean') { + ctx.strokeStyle = P.wave; ctx.lineWidth = 0.8 * scale; - this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); + const amp = 1.6 * scale; + const wl = w / 5; + for (let i = 0; i < 3; i++) { + const wy = y + h * (0.80 + i * 0.07); + ctx.beginPath(); + for (let px = 0; px <= w; px += Math.max(2, w / 48)) { + const yy = wy + Math.sin((px / wl) * Math.PI * 2 + t * (0.5 + i * 0.15) + i) * amp; + if (px === 0) ctx.moveTo(x + px, yy); else ctx.lineTo(x + px, yy); + } + ctx.stroke(); + } + } else if (D.deco === 'cyber') { + const b = 3 * scale; + const L = 7 * scale; + ctx.strokeStyle = P.grid; + ctx.lineWidth = 1 * scale; + [[x + b, y + b, 1, 1], [x + w - b, y + b, -1, 1], [x + b, y + h - b, 1, -1], [x + w - b, y + h - b, -1, -1]].forEach(([px, py, sx2, sy2]) => { + ctx.beginPath(); + ctx.moveTo(px + sx2 * L, py); + ctx.lineTo(px, py); + ctx.lineTo(px, py + sy2 * L); + ctx.stroke(); + }); + ctx.globalAlpha = 0.25; + ctx.lineWidth = 0.5 * scale; + for (let i = 1; i < 4; i++) { + const gy = y + h * (0.74 + i * 0.06); + ctx.beginPath(); + ctx.moveTo(x + b, gy); + ctx.lineTo(x + w - b, gy); + ctx.stroke(); + } + ctx.globalAlpha = isLight ? 0.18 : 0.22; + const scanX = x + ((t * 0.12) % 1) * w; + const sg = ctx.createLinearGradient(scanX - 14 * scale, 0, scanX + 2 * scale, 0); + sg.addColorStop(0, 'rgba(0, 245, 255, 0)'); + sg.addColorStop(1, P.glow); + ctx.fillStyle = sg; + ctx.fillRect(scanX - 14 * scale, y, 16 * scale, h); + } else if (D.deco === 'champagne') { + const R = (c.pivotY - y) + h; + const half = Math.atan2(w * 0.6, c.pivotY - y); + const n = 18; + const step = (half * 2) / n; + ctx.fillStyle = P.ray; + for (let i = 0; i < n; i += 2) { + const a0 = -Math.PI / 2 - half + i * step; + const a1 = a0 + step; + ctx.beginPath(); + ctx.moveTo(c.pivotX, c.pivotY); + ctx.lineTo(c.pivotX + R * Math.cos(a0), c.pivotY + R * Math.sin(a0)); + ctx.lineTo(c.pivotX + R * Math.cos(a1), c.pivotY + R * Math.sin(a1)); + ctx.closePath(); + ctx.fill(); + } + ctx.strokeStyle = P.frame; + ctx.lineWidth = 0.9 * scale; + this.roundRect(ctx, x + 2.2 * scale, y + 2.2 * scale, w - 4.4 * scale, h - 4.4 * scale, 5 * scale); + ctx.stroke(); + ctx.lineWidth = 0.5 * scale; + this.roundRect(ctx, x + 4.2 * scale, y + 4.2 * scale, w - 8.4 * scale, h - 8.4 * scale, 4 * scale); + ctx.stroke(); + const d = 2.6 * scale; + ctx.fillStyle = P.frame; + [[x + 8 * scale, y + 8 * scale], [x + w - 8 * scale, y + 8 * scale], [x + 8 * scale, y + h - 8 * scale], [x + w - 8 * scale, y + h - 8 * scale]].forEach(([px, py]) => { + ctx.beginPath(); + ctx.moveTo(px, py - d); + ctx.lineTo(px + d, py); + ctx.lineTo(px, py + d); + ctx.lineTo(px - d, py); + ctx.closePath(); + ctx.fill(); + }); + } else if (D.deco === 'mono') { + if (!c.stereo) { + ctx.globalAlpha = isLight ? 0.06 : 0.07; + ctx.fillStyle = P.ink; + ctx.font = `800 ${Math.round(h * 0.7)}px ${D.font.family}`; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(c.o.title, x + w - 6 * scale, y + h * 0.5); + ctx.globalAlpha = 1; + } + const ry = y + (c.isCompact || c.stereo ? 15 : 24) * scale; + ctx.strokeStyle = P.rule; + ctx.lineWidth = 0.6 * scale; + ctx.beginPath(); + ctx.moveTo(c.sx, ry); + ctx.lineTo(c.ex, ry); ctx.stroke(); } - ctx.restore(); } + drawCenterConsole(ctx, x, y, w, h, scale, isCompact, theme, isLight, levelL, levelR) { if (isCompact) { this.drawCompactBridge(ctx, x, y, w, h, scale, isLight, levelL, levelR); @@ -869,386 +1248,6 @@ class LXStudioVUMeterAnimation { ctx.restore(); } - // 手机移动端自适应:单表盘·同轴双指针 (Dual-Needle Studio Mastering Pod) - drawSingleStereoPod(ctx, x, y, w, h, levelL, levelR, scale, theme, isLight) { - ctx.save(); - const podR = 8 * scale; - - // 1. 视窗边界裁剪与防溢出 - ctx.save(); - this.roundRect(ctx, x, y, w, h, podR); - ctx.clip(); - - // 2. 专业母带表盘底色表面 (依主题定制) - if (theme === 'vintage-amber') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.45, 12 * scale, x + w / 2, y + h * 0.45, Math.max(w * 0.6, h * 1.2)); - if (isLight) { - bgGrad.addColorStop(0, '#fffdfa'); - bgGrad.addColorStop(0.40, '#fef8ea'); - bgGrad.addColorStop(0.80, '#fdecc4'); - bgGrad.addColorStop(1, '#fbd38d'); - } else { - bgGrad.addColorStop(0, '#ffe8a3'); - bgGrad.addColorStop(0.35, '#f6c343'); - bgGrad.addColorStop(0.75, '#d99b1a'); - bgGrad.addColorStop(1, '#9c6600'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(180, 120, 20, 0.08)' : 'rgba(80, 40, 10, 0.25)'); - vigGrad.addColorStop(0.3, 'rgba(80, 40, 10, 0)'); - vigGrad.addColorStop(0.7, 'rgba(80, 40, 10, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(160, 100, 10, 0.16)' : 'rgba(60, 30, 5, 0.38)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - } else if (theme === 'ocean-blue') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.40, 12 * scale, x + w / 2, y + h * 0.40, Math.max(w * 0.6, h * 1.2)); - if (isLight) { - bgGrad.addColorStop(0, '#f8fafc'); - bgGrad.addColorStop(0.45, '#e0f2fe'); - bgGrad.addColorStop(0.85, '#bae6fd'); - bgGrad.addColorStop(1, '#7dd3fc'); - } else { - bgGrad.addColorStop(0, '#42d2ff'); - bgGrad.addColorStop(0.35, '#12a4df'); - bgGrad.addColorStop(0.75, '#0369a1'); - bgGrad.addColorStop(1, '#073b64'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(3, 105, 161, 0.08)' : 'rgba(0, 30, 65, 0.30)'); - vigGrad.addColorStop(0.3, 'rgba(0, 30, 65, 0)'); - vigGrad.addColorStop(0.7, 'rgba(0, 30, 65, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(2, 132, 199, 0.15)' : 'rgba(0, 20, 50, 0.45)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - } else if (theme === 'cyber-cyan') { - if (isLight) { - const bgGrad = ctx.createLinearGradient(x, y, x, y + h); - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.60, '#f0fdfa'); - bgGrad.addColorStop(1, '#ccfbf1'); - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - } else { - const bgGrad = ctx.createLinearGradient(x, y, x, y + h); - bgGrad.addColorStop(0, '#0c1015'); - bgGrad.addColorStop(0.5, '#070a0e'); - bgGrad.addColorStop(1, '#030508'); - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const glow = ctx.createRadialGradient(x + w / 2, y + h * 0.5, 10 * scale, x + w / 2, y + h * 0.5, w * 0.5); - glow.addColorStop(0, 'rgba(6, 182, 212, 0.14)'); - glow.addColorStop(0.6, 'rgba(6, 182, 212, 0.03)'); - glow.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = glow; - ctx.fillRect(x, y, w, h); - } - } else if (theme === 'champagne-gold') { - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.45, 12 * scale, x + w / 2, y + h * 0.45, Math.max(w * 0.6, h * 1.2)); - if (isLight) { - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.40, '#fefce8'); - bgGrad.addColorStop(0.80, '#fef08a'); - bgGrad.addColorStop(1, '#fde047'); - } else { - bgGrad.addColorStop(0, '#fff4cc'); - bgGrad.addColorStop(0.35, '#fae39b'); - bgGrad.addColorStop(0.70, '#dfba63'); - bgGrad.addColorStop(1, '#9b7625'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(113, 63, 18, 0.06)' : 'rgba(60, 45, 20, 0.25)'); - vigGrad.addColorStop(0.3, 'rgba(60, 45, 20, 0)'); - vigGrad.addColorStop(0.7, 'rgba(60, 45, 20, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(113, 63, 18, 0.14)' : 'rgba(50, 35, 10, 0.38)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - } else { - // studio-monochrome - const bgGrad = ctx.createRadialGradient(x + w / 2, y + h * 0.38, 12 * scale, x + w / 2, y + h * 0.38, Math.max(w * 0.6, h * 1.2)); - if (isLight) { - bgGrad.addColorStop(0, '#ffffff'); - bgGrad.addColorStop(0.65, '#f8fafc'); - bgGrad.addColorStop(1, '#edf2f7'); - } else { - bgGrad.addColorStop(0, '#191b22'); - bgGrad.addColorStop(0.60, '#101217'); - bgGrad.addColorStop(1, '#08090d'); - } - ctx.fillStyle = bgGrad; - ctx.fillRect(x, y, w, h); - - const vigGrad = ctx.createLinearGradient(x, y, x, y + h); - vigGrad.addColorStop(0, isLight ? 'rgba(0, 0, 0, 0.03)' : 'rgba(0, 0, 0, 0.35)'); - vigGrad.addColorStop(0.3, 'rgba(0, 0, 0, 0)'); - vigGrad.addColorStop(0.7, 'rgba(0, 0, 0, 0)'); - vigGrad.addColorStop(1, isLight ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.50)'); - ctx.fillStyle = vigGrad; - ctx.fillRect(x, y, w, h); - } - - // 3. 几何浅弧线参数 (宽幅单表盘,平缓舒展大气) - const spanMarginX = w * 0.08; - const scaleStartX = x + spanMarginX; - const scaleEndX = x + w - spanMarginX; - const scaleW = scaleEndX - scaleStartX; - - const baseCenterY = y + h * 0.66; - const arcSag = h * 0.07; - - const pivotX = x + w / 2; - const pivotDepth = h * 2.1; - const pivotY = y + pivotDepth; - - const getScalePoint = (norm) => { - const bx = scaleStartX + norm * scaleW; - const u = (norm - 0.5) * 2; - const by = baseCenterY + (u * u) * arcSag; - - const dx = bx - pivotX; - const dy = by - pivotY; - const dist = Math.sqrt(dx * dx + dy * dy); - const ux = dx / dist; - const uy = dy / dist; - - return { bx, by, ux, uy }; - }; - - // 配色定义 - const isDarkText = theme === 'vintage-amber' || theme === 'champagne-gold' || (isLight && (theme === 'ocean-blue' || theme === 'cyber-cyan' || theme === 'studio-monochrome')); - const textMain = isDarkText ? '#1e293b' : (theme === 'studio-monochrome' ? '#f8fafc' : (theme === 'cyber-cyan' ? '#22d3ee' : '#f1f5f9')); - const textMuted = isDarkText ? 'rgba(51, 65, 85, 0.65)' : (theme === 'studio-monochrome' ? 'rgba(226, 232, 240, 0.65)' : 'rgba(34, 211, 238, 0.65)'); - const redColor = '#f43f5e'; // 活力珊瑚红(R通道与过载红区) - const greenColor = '#10b981'; // 经典翠绿(L通道) - const arcColor = isDarkText ? '#334155' : (theme === 'studio-monochrome' ? 'rgba(241, 245, 249, 0.85)' : (theme === 'cyber-cyan' ? 'rgba(34, 211, 238, 0.85)' : '#94a3b8')); - - // 4. 顶部立体声声道指示与指示图例 - // 左边:● L (绿点);中间:DUAL VU · STEREO;右边:● R (红点) - ctx.save(); - const topY = y + 4.5 * scale; - const tagFontSize = Math.max(7.5, Math.round(8.5 * scale)); - ctx.font = `700 ${tagFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; - ctx.textBaseline = 'top'; - - // 中间徽标 - ctx.fillStyle = textMuted; - ctx.textAlign = 'center'; - ctx.letterSpacing = '1px'; - ctx.fillText('DUAL VU · STEREO', x + w / 2, topY); - - // 左通道指示 (绿点 + L) - ctx.textAlign = 'left'; - ctx.fillStyle = greenColor; - ctx.beginPath(); - ctx.arc(scaleStartX, topY + 4 * scale, 2 * scale, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = textMuted; - ctx.fillText('L', scaleStartX + 5 * scale, topY); - - // 右通道指示 (红点 + R) - ctx.textAlign = 'right'; - ctx.fillText('R', scaleEndX - 5 * scale, topY); - ctx.fillStyle = redColor; - ctx.beginPath(); - ctx.arc(scaleEndX, topY + 4 * scale, 2 * scale, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - // 5. 现代双段能量弧带 - const normZero = 0.86; - const majorTickLen = 5.5 * scale; - const medTickLen = 3.8 * scale; - const minorTickLen = 2.2 * scale; - - ctx.beginPath(); - for (let i = 0; i <= 50; i++) { - const n = (i / 50) * normZero; - const p = getScalePoint(n); - if (i === 0) ctx.moveTo(p.bx, p.by); - else ctx.lineTo(p.bx, p.by); - } - ctx.strokeStyle = arcColor; - ctx.lineWidth = 1.0 * scale; - ctx.stroke(); - - ctx.beginPath(); - for (let i = 0; i <= 20; i++) { - const n = normZero + (i / 20) * (1.0 - normZero); - const p = getScalePoint(n); - if (i === 0) ctx.moveTo(p.bx, p.by); - else ctx.lineTo(p.bx, p.by); - } - ctx.strokeStyle = redColor; - ctx.lineWidth = 1.5 * scale; - ctx.stroke(); - - // 6. 宽幅工整刻度 - const ticks = [ - { label: '-20', norm: 0.08, major: true }, - { norm: 0.20, type: 'min' }, - { label: '-10', norm: 0.32, major: true }, - { norm: 0.46, type: 'med' }, - { label: '-5', norm: 0.60, major: true }, - { norm: 0.73, type: 'med' }, - { label: '0', norm: 0.86, major: true, isRed: true }, - { norm: 0.93, type: 'min', isRed: true }, - { label: '+3', norm: 1.00, major: true, isRed: true } - ]; - - ticks.forEach(t => { - const p = getScalePoint(t.norm); - const len = t.major ? majorTickLen : (t.type === 'med' ? medTickLen : minorTickLen); - ctx.strokeStyle = t.isRed ? redColor : arcColor; - ctx.lineWidth = (t.major ? (t.isRed ? 1.5 : 1.1) : 0.8) * scale; - ctx.beginPath(); - ctx.moveTo(p.bx, p.by); - ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); - ctx.stroke(); - }); - - // 刻度文字 - const fontSize = Math.max(8.0, Math.round(9.2 * scale)); - ctx.font = `700 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const labelGap = 4.5 * scale; - - ticks.forEach(t => { - if (!t.label) return; - const p = getScalePoint(t.norm); - const lx = p.bx + p.ux * (majorTickLen + labelGap); - const ly = p.by + p.uy * (majorTickLen + labelGap); - ctx.fillStyle = t.isRed ? redColor : textMain; - ctx.fillText(t.label, lx, ly); - }); - - // 7. 优雅中心 VU 徽标 - ctx.save(); - const vuY = baseCenterY + 10.0 * scale; - const vuFontSize = Math.max(7.5, Math.round(8.5 * scale)); - ctx.font = `800 ${vuFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; - ctx.fillStyle = textMuted; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '2px'; - ctx.fillText('VU', x + w / 2, vuY); - ctx.restore(); - - // 8. 同轴双指针 (Coaxial Dual-Needle) - const needleBaseY = y + h + 2 * scale; - const baseW = 0.85 * scale; - const tipW = 0.25 * scale; - - const drawNeedle = (levelVal, tipColor, isLeftChannel) => { - const norm = Math.max(0, Math.min(1.0, levelVal)); - const targetPoint = getScalePoint(norm); - - const ndx = targetPoint.bx - pivotX; - const ndy = targetPoint.by - pivotY; - const nDist = Math.sqrt(ndx * ndx + ndy * ndy); - const nux = ndx / nDist; - const nuy = ndy / nDist; - - const needleTipX = targetPoint.bx + nux * (majorTickLen + 1.0 * scale); - const needleTipY = targetPoint.by + nuy * (majorTickLen + 1.0 * scale); - - const tBase = (needleBaseY - pivotY) / nuy; - const needleBaseX = pivotX + tBase * nux; - - const npX = -nuy; - const npY = nux; - - // 柔和投影 - ctx.save(); - ctx.shadowColor = isLight ? 'rgba(0, 0, 0, 0.20)' : 'rgba(0, 0, 0, 0.45)'; - ctx.shadowBlur = 2.5 * scale; - ctx.shadowOffsetX = (isLeftChannel ? -0.8 : 0.8) * scale; - ctx.shadowOffsetY = 1.2 * scale; - - // 碳素深色针体 (底部 65%) - const midRatio = 0.65; - const needleMidX = needleBaseX + (needleTipX - needleBaseX) * midRatio; - const needleMidY = needleBaseY + (needleTipY - needleBaseY) * midRatio; - const midW = baseW + (tipW - baseW) * midRatio; - - ctx.beginPath(); - ctx.moveTo(needleBaseX - npX * baseW, needleBaseY - npY * baseW); - ctx.lineTo(needleBaseX + npX * baseW, needleBaseY + npY * baseW); - ctx.lineTo(needleMidX + npX * midW, needleMidY + npY * midW); - ctx.lineTo(needleMidX - npX * midW, needleMidY - npY * midW); - ctx.closePath(); - ctx.fillStyle = isLight ? '#0f172a' : (theme === 'studio-monochrome' ? '#cbd5e1' : '#111318'); - ctx.fill(); - - // 荧光双色针尖 (顶部 35%) - ctx.beginPath(); - ctx.moveTo(needleMidX - npX * midW, needleMidY - npY * midW); - ctx.lineTo(needleMidX + npX * midW, needleMidY + npY * midW); - ctx.lineTo(needleTipX + npX * tipW, needleTipY + npY * tipW); - ctx.lineTo(needleTipX - npX * tipW, needleTipY - npY * tipW); - ctx.closePath(); - ctx.fillStyle = tipColor; - ctx.fill(); - ctx.restore(); - }; - - // 绘制 L 通道指针(翠绿/主题色针尖) - const tipLColor = theme === 'cyber-cyan' && !isLight ? '#00f5ff' : greenColor; - drawNeedle(levelL, tipLColor, true); - - // 绘制 R 通道指针(珊瑚红针尖) - drawNeedle(levelR, redColor, false); - - // 9. 精工同轴金属旋钮轴帽 (Machined Dual-Coaxial Pivot Cap) - ctx.save(); - const capR = 3.6 * scale; - const capGrad = ctx.createRadialGradient(pivotX - 0.5, needleBaseY - 0.5, 0.5, pivotX, needleBaseY, capR); - if (isLight) { - capGrad.addColorStop(0, '#ffffff'); - capGrad.addColorStop(0.5, '#cbd5e1'); - capGrad.addColorStop(1, '#64748b'); - } else { - capGrad.addColorStop(0, '#64748b'); - capGrad.addColorStop(0.6, '#334155'); - capGrad.addColorStop(1, '#0f172a'); - } - ctx.fillStyle = capGrad; - ctx.beginPath(); - if (typeof ctx.arc === 'function') { - ctx.arc(pivotX, needleBaseY, capR, 0, Math.PI * 2); - } else { - this.roundRect(ctx, pivotX - capR, needleBaseY - capR, capR * 2, capR * 2, capR); - } - ctx.fill(); - ctx.restore(); - - // 10. 防眩光蓝宝石双曲面玻璃反光 - const glassGrad = ctx.createLinearGradient(x, y, x, y + h * 0.45); - glassGrad.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.08)'); - glassGrad.addColorStop(0.5, isLight ? 'rgba(255, 255, 255, 0.06)' : 'rgba(255, 255, 255, 0.02)'); - glassGrad.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = glassGrad; - ctx.fillRect(x, y, w, h * 0.45); - - ctx.restore(); // 解除裁剪 - - // 11. 视窗内嵌倒角框 - ctx.strokeStyle = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 1; - this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); - ctx.stroke(); - - ctx.restore(); - } } const musicVisualizer = (function () { From 34d05f58d65b2e871a799c57186cb9eaa2d3c6c5 Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Thu, 29 Oct 2026 14:40:04 +0800 Subject: [PATCH 03/33] feat(vu-meter): retro moving-coil physics, paper grain and machined bezel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/js/visualizer.js | 123 +++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index d5fe379f..e10d5b46 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -318,12 +318,27 @@ class LXStudioVUMeterAnimation { } // 3. ANSI C16.5 Standard VU Ballistics (~300 ms rise and fall integration with responsive attack) - const tauRise = 0.120; // 120ms: snappy transient punch - const tauFall = 0.280; // 280ms: organic mechanical decay - const alphaL = 1.0 - Math.exp(-dt / (targetNormL > this.needleNormL ? tauRise : tauFall)); - const alphaR = 1.0 - Math.exp(-dt / (targetNormR > this.needleNormR ? tauRise : tauFall)); - this.needleNormL += alphaL * (targetNormL - this.needleNormL); - this.needleNormR += alphaR * (targetNormR - this.needleNormR); + // Moving-coil movement modelled as a damped spring (slight overshoot like a real VU movement), + // with hard mechanical stops at both ends of travel. + const omega = 21.0; + const zeta = 0.78; + this.velL = this.velL || 0; + this.velR = this.velR || 0; + const steps = Math.max(1, Math.ceil(dt / 0.008)); + const h = dt / steps; + for (let s = 0; s < steps; s++) { + this.velL += (omega * omega * (targetNormL - this.needleNormL) - 2 * zeta * omega * this.velL) * h; + this.velR += (omega * omega * (targetNormR - this.needleNormR) - 2 * zeta * omega * this.velR) * h; + this.needleNormL += this.velL * h; + this.needleNormR += this.velR * h; + } + const stop = (n, v) => { + if (n < 0) return [0, Math.abs(v) * 0.25]; + if (n > 1.03) return [1.03, -Math.abs(v) * 0.3]; + return [n, v]; + }; + [this.needleNormL, this.velL] = stop(this.needleNormL, this.velL); + [this.needleNormR, this.velR] = stop(this.needleNormR, this.velR); this.renderConsole(ctx, width, height, this.needleNormL, this.needleNormR, this.isCompact); } @@ -454,6 +469,32 @@ class LXStudioVUMeterAnimation { return LX_VU_DIAL_DESIGNS[theme] || LX_VU_DIAL_DESIGNS['vintage-amber']; } + // Cached paper-grain pattern for printed dial cards (null when no offscreen canvas is available) + getGrain(ctx) { + if (this._grain !== undefined) return this._grain; + this._grain = null; + try { + if (typeof document === 'undefined' || typeof document.createElement !== 'function' || typeof ctx.createPattern !== 'function') return null; + const c = document.createElement('canvas'); + c.width = c.height = 64; + const g = c.getContext && c.getContext('2d'); + if (!g || typeof g.createImageData !== 'function') return null; + const img = g.createImageData(64, 64); + let seed = 1337; + for (let i = 0; i < img.data.length; i += 4) { + seed = (seed * 1664525 + 1013904223) >>> 0; + const v = (seed >>> 24) & 0xff; + img.data[i] = img.data[i + 1] = img.data[i + 2] = v; + img.data[i + 3] = 255; + } + g.putImageData(img, 0, 0); + this._grain = ctx.createPattern(c, 'repeat') || null; + } catch (_) { + this._grain = null; + } + return this._grain; + } + static registerDesign(id, design) { if (id && design) LX_VU_DIAL_DESIGNS[id] = design; } @@ -552,6 +593,38 @@ class LXStudioVUMeterAnimation { ctx.fillRect(x, y, w, h); } + // 1b. Printed-card realism: paper grain, aged edges and a recessed window + const grain = this.getGrain(ctx); + if (grain) { + ctx.save(); + ctx.globalAlpha = isLight ? 0.10 : 0.07; + ctx.fillStyle = grain; + ctx.fillRect(x, y, w, h); + ctx.restore(); + } + const warm = D.deco === 'vintage' || D.deco === 'champagne'; + const age = ctx.createRadialGradient(x + w / 2, y + h * 0.5, Math.min(w, h) * 0.2, x + w / 2, y + h * 0.5, Math.max(w * 0.62, h * 1.1)); + age.addColorStop(0, 'rgba(0, 0, 0, 0)'); + age.addColorStop(1, warm ? 'rgba(90, 50, 10, 0.28)' : (isLight ? 'rgba(0, 0, 0, 0.10)' : 'rgba(0, 0, 0, 0.30)')); + ctx.fillStyle = age; + ctx.fillRect(x, y, w, h); + const rec = Math.min(10 * scale, h * 0.2); + const recTop = ctx.createLinearGradient(x, y, x, y + rec); + recTop.addColorStop(0, 'rgba(0, 0, 0, 0.38)'); + recTop.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = recTop; + ctx.fillRect(x, y, w, rec); + const recLeft = ctx.createLinearGradient(x, y, x + rec, y); + recLeft.addColorStop(0, 'rgba(0, 0, 0, 0.28)'); + recLeft.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = recLeft; + ctx.fillRect(x, y, rec, h); + const recRight = ctx.createLinearGradient(x + w, y, x + w - rec * 0.6, y); + recRight.addColorStop(0, 'rgba(0, 0, 0, 0.14)'); + recRight.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = recRight; + ctx.fillRect(x + w - rec * 0.6, y, rec * 0.6, h); + // 2. Profile-specific decorations this.drawDialDeco(ctx, D, P, { x, y, w, h, scale, t, isLight, isCompact, stereo, pivotX, pivotY, sx, ex, o }); @@ -794,10 +867,11 @@ class LXStudioVUMeterAnimation { ctx.shadowColor = P.glow; ctx.shadowBlur = (type === 'neon' ? 8 : 6) * scale; } else { - ctx.shadowColor = isLight ? 'rgba(0, 0, 0, 0.25)' : 'rgba(0, 0, 0, 0.50)'; - ctx.shadowBlur = (compactLike ? 2.5 : 4.5) * scale; - ctx.shadowOffsetX = (stereo ? shadowSide * 0.8 : 1.0) * scale; - ctx.shadowOffsetY = 1.5 * scale; + // Pointer floats above the card: a crisp, offset cast shadow (lit from the upper left) + ctx.shadowColor = isLight ? 'rgba(30, 20, 10, 0.38)' : 'rgba(0, 0, 0, 0.60)'; + ctx.shadowBlur = (compactLike ? 1.2 : 1.8) * scale; + ctx.shadowOffsetX = (stereo ? 1.6 + shadowSide * 0.6 : 2.2) * scale; + ctx.shadowOffsetY = (compactLike ? 1.8 : 2.6) * scale; } if (type === 'blade') { @@ -958,14 +1032,33 @@ class LXStudioVUMeterAnimation { this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); ctx.stroke(); } else { - ctx.strokeStyle = P.rim; - ctx.lineWidth = 1.1 * scale; - this.roundRect(ctx, x - 0.5, y - 0.5, w + 1, h + 1, podR); + // Machined metal bezel: brushed gradient ring with inner shadow lip and a specular top edge + const warmMetal = D.deco === 'vintage' || D.deco === 'champagne'; + const ringW = 3.2 * scale; + const mg = ctx.createLinearGradient(x, y - ringW, x, y + h + ringW); + if (warmMetal) { + mg.addColorStop(0, '#f4dfa4'); mg.addColorStop(0.25, '#b68a3a'); mg.addColorStop(0.55, '#6e4f17'); mg.addColorStop(0.8, '#c9a557'); mg.addColorStop(1, '#4a3410'); + } else if (D.deco === 'mono') { + mg.addColorStop(0, '#f1f5f9'); mg.addColorStop(0.3, '#94a3b8'); mg.addColorStop(0.6, '#475569'); mg.addColorStop(1, '#1e293b'); + } else { + mg.addColorStop(0, '#e2e8f0'); mg.addColorStop(0.3, '#7c8da3'); mg.addColorStop(0.6, '#3b4a5e'); mg.addColorStop(1, '#141c27'); + } + ctx.strokeStyle = mg; + ctx.lineWidth = ringW; + this.roundRect(ctx, x - ringW / 2, y - ringW / 2, w + ringW, h + ringW, podR + ringW / 2); ctx.stroke(); - ctx.strokeStyle = isLight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.8)'; - ctx.lineWidth = 0.8 * scale; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.lineWidth = 0.6 * scale; + this.roundRect(ctx, x - ringW, y - ringW, w + ringW * 2, h + ringW * 2, podR + ringW); + ctx.stroke(); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.75)'; + ctx.lineWidth = 1.0 * scale; this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); ctx.stroke(); + ctx.strokeStyle = P.rim; + ctx.lineWidth = 0.6 * scale; + this.roundRect(ctx, x - 0.5, y - 0.5, w + 1, h + 1, podR); + ctx.stroke(); } ctx.restore(); } From 4826324470dd07d7ba82549f262b494612e324ee Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Thu, 29 Oct 2026 15:00:14 +0800 Subject: [PATCH 04/33] feat(vu-meter): redesign dial faces per color profile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/js/visualizer.js | 239 ++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index e10d5b46..4327c7b5 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -625,6 +625,8 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = recRight; ctx.fillRect(x + w - rec * 0.6, y, rec * 0.6, h); + this.drawDialFace(ctx, D, P, { x, y, w, h, scale, isLight, compactLike, gsp: getScalePoint, majLen, gap: sz.gap * scale, fontPx: Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)), baseY, pivotX, pivotY }); + // 2. Profile-specific decorations this.drawDialDeco(ctx, D, P, { x, y, w, h, scale, t, isLight, isCompact, stereo, pivotX, pivotY, sx, ex, o }); @@ -1063,6 +1065,243 @@ class LXStudioVUMeterAnimation { ctx.restore(); } + // Per-profile dial face layout: scale window, printed zones and panel structure (under scale and pointer) + drawDialFace(ctx, D, P, c) { + const { x, y, w, h, scale, isLight, compactLike, gsp, majLen, gap, fontPx, baseY, pivotX, pivotY } = c; + const outer = majLen + gap + fontPx * 0.75; + const inner = -(compactLike ? 3 : 5) * scale; + const small = Math.max(6, Math.round(7 * scale)); + const dash = (a) => { if (typeof ctx.setLineDash === 'function') ctx.setLineDash(a); }; + const band = (n0, n1, a, b, steps = 36) => { + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const p = gsp(n0 + (n1 - n0) * (i / steps), a); + if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); + } + for (let i = steps; i >= 0; i--) { + const p = gsp(n0 + (n1 - n0) * (i / steps), b); + ctx.lineTo(p.bx, p.by); + } + ctx.closePath(); + }; + const lower = (n0, n1) => { + ctx.beginPath(); + for (let i = 0; i <= 36; i++) { + const p = gsp(n0 + (n1 - n0) * (i / 36), inner); + if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); + } + ctx.lineTo(x + w, y + h); + ctx.lineTo(x, y + h); + ctx.closePath(); + }; + ctx.save(); + if (D.deco === 'vintage') { + // Cream scale window set into a darker lacquered housing, red zone printed as a tint + lower(-0.045, 1.045); + ctx.fillStyle = isLight ? 'rgba(90, 55, 15, 0.12)' : 'rgba(50, 28, 5, 0.28)'; + ctx.fill(); + band(-0.045, 1.045, inner, outer); + const wg = ctx.createLinearGradient(x, y, x, y + h); + wg.addColorStop(0, isLight ? 'rgba(255, 253, 242, 0.80)' : 'rgba(255, 249, 228, 0.62)'); + wg.addColorStop(1, isLight ? 'rgba(255, 244, 214, 0.40)' : 'rgba(255, 232, 165, 0.28)'); + ctx.fillStyle = wg; + ctx.fill(); + band(VU_NORM_ZERO, 1.045, inner, outer, 12); + ctx.fillStyle = 'rgba(200, 30, 58, 0.11)'; + ctx.fill(); + ctx.strokeStyle = P.frame; + ctx.lineWidth = 1.0 * scale; + band(-0.045, 1.045, inner, outer); + ctx.stroke(); + ctx.lineWidth = 0.5 * scale; + band(-0.03, 1.03, inner + 1.6 * scale, outer - 1.6 * scale); + ctx.stroke(); + if (!compactLike) { + ctx.font = `700 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; + ctx.fillText('LX AUDIO', x + 12 * scale, y + h - 9 * scale); + ctx.textAlign = 'right'; + ctx.fillText('PRECISION METER', x + w - 12 * scale, y + h - 9 * scale); + } + } else if (D.deco === 'ocean') { + // Sonar chart: radial bearing lines and range rings swept from the pivot, calm surface band + const d0 = pivotY - baseY; + const half = Math.atan2(w * 0.55, d0); + const a0 = -Math.PI / 2 - half; + const a1 = -Math.PI / 2 + half; + const deep = ctx.createLinearGradient(x, y + h * 0.55, x, y + h); + deep.addColorStop(0, 'rgba(0, 40, 90, 0)'); + deep.addColorStop(1, isLight ? 'rgba(3, 105, 161, 0.18)' : 'rgba(0, 20, 55, 0.40)'); + ctx.fillStyle = deep; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = P.wave; + ctx.lineWidth = 0.5 * scale; + ctx.globalAlpha = 0.55; + const rays = 16; + for (let i = 0; i <= rays; i++) { + const a = a0 + (a1 - a0) * (i / rays); + ctx.beginPath(); + ctx.moveTo(pivotX + Math.cos(a) * (d0 - h * 0.35), pivotY + Math.sin(a) * (d0 - h * 0.35)); + ctx.lineTo(pivotX + Math.cos(a) * (d0 + h * 0.75), pivotY + Math.sin(a) * (d0 + h * 0.75)); + ctx.stroke(); + } + for (let k = -1; k <= 3; k++) { + ctx.beginPath(); + ctx.arc(pivotX, pivotY, d0 + k * h * 0.17, a0, a1); + ctx.stroke(); + } + ctx.globalAlpha = 1; + band(-0.03, 1.03, inner, outer); + const sg = ctx.createLinearGradient(x, y, x, y + h); + sg.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.50)' : 'rgba(255, 255, 255, 0.16)'); + sg.addColorStop(1, isLight ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.04)'); + ctx.fillStyle = sg; + ctx.fill(); + ctx.strokeStyle = P.arc; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 0.7 * scale; + ctx.stroke(); + ctx.globalAlpha = 1; + if (!compactLike) { + ctx.font = `600 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; + ctx.fillText('SONAR · LEVEL', x + 12 * scale, y + h - 9 * scale); + ctx.textAlign = 'right'; + ctx.fillText('dB RE 0 VU', x + w - 12 * scale, y + h - 9 * scale); + } + } else if (D.deco === 'cyber') { + // Chamfered HUD panel with dashed scale lane, bottom ruler and data readouts + const b = 4 * scale; + const cc = 7 * scale; + ctx.beginPath(); + ctx.moveTo(x + b + cc, y + b); + ctx.lineTo(x + w - b, y + b); + ctx.lineTo(x + w - b, y + h - b - cc); + ctx.lineTo(x + w - b - cc, y + h - b); + ctx.lineTo(x + b, y + h - b); + ctx.lineTo(x + b, y + b + cc); + ctx.closePath(); + ctx.fillStyle = isLight ? 'rgba(15, 118, 110, 0.05)' : 'rgba(6, 182, 212, 0.05)'; + ctx.fill(); + ctx.strokeStyle = P.grid; + ctx.lineWidth = 0.8 * scale; + ctx.stroke(); + band(-0.02, 1.02, inner, outer); + ctx.save(); + ctx.globalAlpha = 0.55; + dash([2.5 * scale, 2.5 * scale]); + ctx.lineWidth = 0.6 * scale; + ctx.stroke(); + ctx.restore(); + const ry = y + h - b - 3.5 * scale; + const n = Math.max(8, Math.floor(w / (5 * scale))); + ctx.globalAlpha = 0.5; + ctx.lineWidth = 0.6 * scale; + ctx.beginPath(); + for (let i = 0; i <= n; i++) { + const px = x + b + cc + (w - 2 * (b + cc)) * (i / n); + ctx.moveTo(px, ry); + ctx.lineTo(px, ry - (i % 5 === 0 ? 3.2 : 1.6) * scale); + } + ctx.stroke(); + ctx.globalAlpha = 1; + if (!compactLike) { + ctx.font = `700 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '1px'; + ctx.textAlign = 'right'; + ctx.fillText('RMS 300ms', x + w - b - 6 * scale, y + b + 7 * scale); + ctx.textAlign = 'left'; + ctx.fillText('ref -18 dBFS', x + b + 6 * scale, y + h - b - 12 * scale); + } + } else if (D.deco === 'champagne') { + // Arched cartouche with pearl-studded border above a pinstriped gold pedestal + lower(-0.05, 1.05); + const pg = ctx.createLinearGradient(x, y + h * 0.6, x, y + h); + pg.addColorStop(0, isLight ? 'rgba(150, 105, 20, 0.10)' : 'rgba(110, 70, 10, 0.18)'); + pg.addColorStop(1, isLight ? 'rgba(120, 80, 10, 0.26)' : 'rgba(70, 40, 5, 0.42)'); + ctx.fillStyle = pg; + ctx.fill(); + ctx.save(); + lower(-0.05, 1.05); + ctx.clip(); + ctx.strokeStyle = P.frame; + ctx.globalAlpha = 0.18; + ctx.lineWidth = 0.5 * scale; + const step = Math.max(2.5, 3 * scale); + ctx.beginPath(); + for (let px = x; px <= x + w; px += step) { ctx.moveTo(px, y); ctx.lineTo(px, y + h); } + ctx.stroke(); + ctx.restore(); + band(-0.05, 1.05, inner, outer); + const cg = ctx.createLinearGradient(x, y, x, y + h); + cg.addColorStop(0, isLight ? 'rgba(255, 255, 250, 0.75)' : 'rgba(255, 252, 236, 0.60)'); + cg.addColorStop(1, isLight ? 'rgba(255, 248, 215, 0.35)' : 'rgba(255, 236, 170, 0.25)'); + ctx.fillStyle = cg; + ctx.fill(); + ctx.strokeStyle = P.frame; + ctx.lineWidth = 1.1 * scale; + ctx.stroke(); + ctx.lineWidth = 0.5 * scale; + band(-0.035, 1.035, inner + 1.8 * scale, outer - 1.8 * scale); + ctx.stroke(); + ctx.fillStyle = P.frame; + const pearls = compactLike ? 14 : 22; + for (let i = 0; i <= pearls; i++) { + const p = gsp(-0.035 + 1.07 * (i / pearls), outer); + ctx.beginPath(); + ctx.arc(p.bx, p.by, (compactLike ? 0.6 : 0.85) * scale, 0, Math.PI * 2); + ctx.fill(); + } + if (!compactLike) { + ctx.font = `italic 700 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; + ctx.fillText('LX AUDIO', x + 12 * scale, y + h - 9 * scale); + ctx.textAlign = 'right'; + ctx.fillText('MASTER SERIES', x + w - 12 * scale, y + h - 9 * scale); + } + } else if (D.deco === 'mono') { + // Bauhaus layout: solid overload block, heavy top bar and a baseline rule + const zx = gsp(VU_NORM_ZERO).bx; + ctx.fillStyle = P.red; + ctx.globalAlpha = isLight ? 0.10 : 0.16; + ctx.fillRect(zx, y, x + w - zx, h); + ctx.globalAlpha = 0.5; + ctx.fillRect(zx, y, 0.8 * scale, h); + ctx.globalAlpha = 1; + ctx.fillStyle = P.ink; + ctx.fillRect(x, y, w, (compactLike ? 1.8 : 3) * scale); + const by = y + h - (compactLike ? 4 : 8) * scale; + ctx.strokeStyle = P.rule; + ctx.lineWidth = 0.6 * scale; + ctx.beginPath(); + ctx.moveTo(x + 8 * scale, by); + ctx.lineTo(x + w - 8 * scale, by); + ctx.stroke(); + if (!compactLike) { + ctx.font = `600 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textBaseline = 'bottom'; + ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; + ctx.fillText('dB', x + 10 * scale, by - 2 * scale); + ctx.textAlign = 'right'; + ctx.fillText('LX / 01', x + w - 10 * scale, by - 2 * scale); + } + } + ctx.restore(); + } + // Decorative layer unique to each profile (drawn on the face, under scale and pointer) drawDialDeco(ctx, D, P, c) { const { x, y, w, h, scale, t, isLight } = c; From 2d29156c5844112e3f8efafcdaa27eb3781b942a Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Thu, 29 Oct 2026 15:29:57 +0800 Subject: [PATCH 05/33] feat(vu-meter): hi-fi backlit dials and critically damped needle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/js/visualizer.js | 286 ++++++++++++++-------------------- 1 file changed, 118 insertions(+), 168 deletions(-) diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index 4327c7b5..b21717d3 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -68,12 +68,12 @@ const LX_VU_DIAL_DESIGNS = { vig: { light: ['rgba(180, 120, 20, 0.08)', 'rgba(160, 100, 10, 0.16)'], dark: ['rgba(80, 40, 10, 0.25)', 'rgba(60, 30, 5, 0.38)'], clear: 'rgba(80, 40, 10, 0)' } }, arc: 'double', tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, - needle: { type: 'blade', reach: 1.2, width: 1 }, cap: 'screw', peak: null, sheen: false, deco: 'vintage', + needle: { type: 'blade', reach: 1.2, width: 1 }, cap: 'screw', peak: null, sheen: false, deco: 'vintage', lamp: ['255, 186, 96', 1], light: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, dark: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.45)', rim: '#bfa15f' } }, - // Marine instrument: deep smiling ellipse-like arc, translucent water band, - // round dot ticks, drifting wave lines and a luminous pointer ending in a glowing bead. + // Blue-lit hi-fi power meter: deep arc on a backlit blue card, translucent scale band, + // fine bar ticks and a slim pale blade pointer over a jewel hub. 'ocean-blue': { name: 'Ocean Blue', geom: { span: 0.055, baseY: { c: 0.60, f: 0.57 }, sag: { c: 0.12, f: 0.14 }, curve: 1.6, pivot: { c: 1.75, f: 2.0 } }, @@ -85,31 +85,31 @@ const LX_VU_DIAL_DESIGNS = { dark: [[0, '#42d2ff'], [0.35, '#12a4df'], [0.75, '#0369a1'], [1, '#073b64']], vig: { light: ['rgba(3, 105, 161, 0.08)', 'rgba(2, 132, 199, 0.15)'], dark: ['rgba(0, 30, 65, 0.30)', 'rgba(0, 20, 50, 0.45)'], clear: 'rgba(0, 30, 65, 0)' } }, - arc: 'band', arcBand: 3.6, tick: { style: 'dots', len: 1, lwMajor: 1.5, lwMinor: 0.8 }, - needle: { type: 'lumen', reach: 1.0, width: 1 }, cap: 'jewel', peak: null, sheen: true, deco: 'ocean', + arc: 'band', arcBand: 3.6, tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, + needle: { type: 'blade', reach: 1.0, width: 0.85 }, cap: 'jewel', peak: null, sheen: true, deco: 'ocean', lamp: ['160, 220, 255', 0.8], light: { ink: '#0c4a6e', muted: 'rgba(12, 74, 110, 0.65)', arc: '#0369a1', red: '#e11d48', needle: '#075985', tip: '#0ea5e9', glow: 'rgba(14, 165, 233, 0.55)', wave: 'rgba(3, 105, 161, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, dark: { ink: '#f0f9ff', muted: 'rgba(224, 242, 254, 0.75)', arc: 'rgba(240, 249, 255, 0.9)', red: '#ff7a93', needle: '#f0f9ff', tip: '#ffffff', glow: 'rgba(186, 230, 253, 0.95)', wave: 'rgba(255, 255, 255, 0.45)', rim: '#0e5c8f' } }, - // HUD / sci-fi readout: flat wide arc, segmented bar-graph rail that lights with the level, - // monospace type, corner brackets, scan line and a neon hairline pointer with a diamond head. + // Modern reference meter: flat wide arc on a black card with cyan-lit printing, + // geometric sans type, translucent scale band and a hairline pointer. 'cyber-cyan': { name: 'Cyber Cyan', geom: { span: 0.045, baseY: { c: 0.68, f: 0.64 }, sag: { c: 0.05, f: 0.06 }, curve: 4, pivot: { c: 2.2, f: 2.6 } }, - font: { family: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace', weight: '700', style: 'normal', scale: 0.95 }, - tag: ch => 'CH_' + ch, tagAlign: 'left', mark: 'VU/dB', markStyle: 'normal', + font: { family: '"Futura", "Century Gothic", "Avenir Next", "Segoe UI", sans-serif', weight: '500', style: 'normal', scale: 1.0 }, + tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'normal', face: { kind: 'linear', light: [[0, '#ffffff'], [0.60, '#f0fdfa'], [1, '#ccfbf1']], dark: [[0, '#0c1015'], [0.5, '#070a0e'], [1, '#030508']], glow: { color: '6, 182, 212', a0: 0.14, a1: 0.03 } }, - arc: 'segments', tick: { style: 'bar', len: 0.85, lwMajor: 1.8, lwMinor: 0.9 }, - needle: { type: 'neon', reach: 1.0, width: 1 }, cap: 'ring', peak: 'segment', sheen: false, deco: 'cyber', + arc: 'band', arcBand: 2.4, tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, + needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'ring', peak: null, sheen: true, deco: 'cyber', lamp: ['80, 230, 255', 0.6], light: { ink: '#115e59', muted: 'rgba(17, 94, 89, 0.65)', arc: '#0f766e', red: '#e11d48', needle: '#0f766e', tip: '#06b6d4', glow: 'rgba(6, 182, 212, 0.5)', grid: 'rgba(15, 118, 110, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, dark: { ink: '#22d3ee', muted: 'rgba(34, 211, 238, 0.6)', arc: 'rgba(34, 211, 238, 0.9)', red: '#ff2d6f', needle: '#7df9ff', tip: '#00f5ff', glow: '#00f5ff', grid: 'rgba(34, 211, 238, 0.5)', rim: '#1e3040' } }, - // Art-deco luxury: sunburst rays radiating from the pivot, double gold frame with corner lozenges, - // fine wedge ticks capped with pearls, radial serif numerals and a spear pointer with a lozenge. + // Champagne-faced luxury meter: soft sunburst, double gold frame with corner lozenges, + // wedge ticks capped with pearls, radial serif numerals and a slim black blade with red tip. 'champagne-gold': { name: 'Champagne Gold', geom: { span: 0.09, baseY: { c: 0.64, f: 0.60 }, sag: { c: 0.10, f: 0.11 }, curve: 2.6, pivot: { c: 1.9, f: 2.2 } }, @@ -122,12 +122,12 @@ const LX_VU_DIAL_DESIGNS = { vig: { light: ['rgba(113, 63, 18, 0.06)', 'rgba(113, 63, 18, 0.14)'], dark: ['rgba(60, 45, 20, 0.25)', 'rgba(50, 35, 10, 0.38)'], clear: 'rgba(60, 45, 20, 0)' } }, arc: 'double', arcInset: 2.4, tick: { style: 'wedge', len: 1, lwMajor: 1.6, lwMinor: 0.9 }, - needle: { type: 'deco', reach: 1.0, width: 1 }, cap: 'rosette', peak: null, sheen: true, deco: 'champagne', - light: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#b8860b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 230, 150, 0.22)', frame: '#9c7a2a', rim: 'rgba(180, 140, 60, 0.6)' }, - dark: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#b8860b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 255, 255, 0.16)', frame: '#8a6a1f', rim: '#dab978' } + needle: { type: 'blade', reach: 1.0, width: 0.9 }, cap: 'rosette', peak: null, sheen: true, deco: 'champagne', lamp: ['255, 228, 170', 0.9], + light: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#a61b1b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 230, 150, 0.22)', frame: '#9c7a2a', rim: 'rgba(180, 140, 60, 0.6)' }, + dark: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#a61b1b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 255, 255, 0.16)', frame: '#8a6a1f', rim: '#dab978' } }, - // Bauhaus mastering meter: no scale rail, left-aligned channel letter, hairline rule, - // oversize watermark letter, long thin ticks and a spear-tipped hairline pointer with peak marker. + // Studio monitor meter: crisp white card, fine double rail, left-aligned channel letter, + // hairline rule, long thin ticks and a spear-tipped hairline pointer. 'studio-monochrome': { name: 'Studio Monochrome', geom: { span: 0.10, baseY: { c: 0.67, f: 0.63 }, sag: { c: 0.035, f: 0.04 }, curve: 2, pivot: { c: 2.1, f: 2.4 } }, @@ -139,8 +139,8 @@ const LX_VU_DIAL_DESIGNS = { dark: [[0, '#191b22'], [0.60, '#101217'], [1, '#08090d']], vig: { light: ['rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.08)'], dark: ['rgba(0, 0, 0, 0.35)', 'rgba(0, 0, 0, 0.50)'], clear: 'rgba(0, 0, 0, 0)' } }, - arc: 'none', tick: { style: 'bar', len: 1.15, lwMajor: 1.1, lwMinor: 0.7 }, - needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', peak: 'tick', sheen: false, deco: 'mono', + arc: 'double', arcInset: 1.6, tick: { style: 'bar', len: 1.15, lwMajor: 1.1, lwMinor: 0.7 }, + needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', peak: null, sheen: false, deco: 'mono', lamp: ['235, 240, 255', 0.3], light: { ink: '#0f172a', muted: 'rgba(15, 23, 42, 0.55)', arc: '#334155', red: '#f43f5e', needle: '#0f172a', tip: '#f43f5e', rule: 'rgba(15, 23, 42, 0.25)', rim: 'rgba(148, 163, 184, 0.55)' }, dark: { ink: '#f8fafc', muted: 'rgba(226, 232, 240, 0.6)', arc: 'rgba(241, 245, 249, 0.85)', red: '#f43f5e', needle: '#e2e8f0', tip: '#f43f5e', rule: 'rgba(226, 232, 240, 0.25)', rim: '#334155' } } @@ -318,10 +318,10 @@ class LXStudioVUMeterAnimation { } // 3. ANSI C16.5 Standard VU Ballistics (~300 ms rise and fall integration with responsive attack) - // Moving-coil movement modelled as a damped spring (slight overshoot like a real VU movement), - // with hard mechanical stops at both ends of travel. - const omega = 21.0; - const zeta = 0.78; + // Moving-coil movement modelled as a critically damped spring (~300 ms to settle, no overshoot), + // with padded mechanical stops at both ends of travel. + const omega = 20.0; + const zeta = 1.0; this.velL = this.velL || 0; this.velR = this.velR || 0; const steps = Math.max(1, Math.ceil(dt / 0.008)); @@ -333,8 +333,8 @@ class LXStudioVUMeterAnimation { this.needleNormR += this.velR * h; } const stop = (n, v) => { - if (n < 0) return [0, Math.abs(v) * 0.25]; - if (n > 1.03) return [1.03, -Math.abs(v) * 0.3]; + if (n < 0) return [0, 0]; + if (n > 1.03) return [1.03, 0]; return [n, v]; }; [this.needleNormL, this.velL] = stop(this.needleNormL, this.velL); @@ -625,6 +625,22 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = recRight; ctx.fillRect(x + w - rec * 0.6, y, rec * 0.6, h); + // 1c. Lamp behind the translucent dial card + if (D.lamp) { + ctx.save(); + const lx = x + w / 2; + const ly = y + h * 1.02; + const gain = D.lamp[1]; + const lg = ctx.createRadialGradient(lx, ly, 2 * scale, lx, ly, Math.max(w * 0.6, h * 1.05)); + lg.addColorStop(0, `rgba(${D.lamp[0]}, ${(isLight ? 0.30 : 0.55) * gain})`); + lg.addColorStop(0.45, `rgba(${D.lamp[0]}, ${(isLight ? 0.10 : 0.18) * gain})`); + lg.addColorStop(1, `rgba(${D.lamp[0]}, 0)`); + if (!isLight) ctx.globalCompositeOperation = 'screen'; + ctx.fillStyle = lg; + ctx.fillRect(x, y, w, h); + ctx.restore(); + } + this.drawDialFace(ctx, D, P, { x, y, w, h, scale, isLight, compactLike, gsp: getScalePoint, majLen, gap: sz.gap * scale, fontPx: Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)), baseY, pivotX, pivotY }); // 2. Profile-specific decorations @@ -1010,6 +1026,50 @@ class LXStudioVUMeterAnimation { } ctx.restore(); + // 10b. Lacquered movement shroud: hides the pivot like a real meter and carries the badge + if (!compactLike) { + const shTop = y + h * 0.84; + const shEdge = y + h * 0.94; + const warmShroud = D.deco === 'vintage' || D.deco === 'champagne'; + const curve = (first) => { + for (let i = 0; i <= 24; i++) { + const u = i / 24; + const v = 2 * u - 1; + const px = x + w * u; + const py = shTop + (shEdge - shTop) * v * v; + if (i === 0 && first) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + }; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, y + h + 1); + curve(false); + ctx.lineTo(x + w, y + h + 1); + ctx.closePath(); + const shg = ctx.createLinearGradient(x, shTop, x, y + h); + shg.addColorStop(0, warmShroud ? '#3a2a18' : '#2a2f38'); + shg.addColorStop(0.35, warmShroud ? '#1c140b' : '#14171c'); + shg.addColorStop(1, '#050505'); + ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; + ctx.shadowBlur = 3 * scale; + ctx.shadowOffsetY = -1 * scale; + ctx.fillStyle = shg; + ctx.fill(); + ctx.shadowColor = 'transparent'; + ctx.beginPath(); + curve(true); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; + ctx.lineWidth = 0.7 * scale; + ctx.stroke(); + ctx.font = `600 ${Math.max(6, Math.round(7.5 * scale))}px ${fam}`; + ctx.fillStyle = warmShroud ? 'rgba(230, 200, 140, 0.75)' : 'rgba(220, 226, 235, 0.65)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '3px'; + ctx.fillText('LX AUDIO', x + w / 2, (shTop + y + h) / 2 + 1 * scale); + ctx.restore(); + } + // 11. Glass reflection const glass = ctx.createLinearGradient(x, y, x, y + h * 0.45); glass.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'); @@ -1025,6 +1085,24 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = sg; ctx.fillRect(x, y, w, h); } + // Curved crystal reflection across the upper glass + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + w, y); + ctx.lineTo(x + w, y + h * 0.22); + for (let i = 1; i <= 24; i++) { + const u = 1 - i / 24; + const v = 2 * u - 1; + ctx.lineTo(x + w * u, y + h * (0.36 - 0.14 * v * v)); + } + ctx.closePath(); + const cr = ctx.createLinearGradient(x, y, x, y + h * 0.36); + cr.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.09)'); + cr.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = cr; + ctx.fill(); + ctx.restore(); ctx.restore(); // end clip // 12. Bezel @@ -1126,101 +1204,26 @@ class LXStudioVUMeterAnimation { ctx.textAlign = 'right'; ctx.fillText('PRECISION METER', x + w - 12 * scale, y + h - 9 * scale); } - } else if (D.deco === 'ocean') { - // Sonar chart: radial bearing lines and range rings swept from the pivot, calm surface band - const d0 = pivotY - baseY; - const half = Math.atan2(w * 0.55, d0); - const a0 = -Math.PI / 2 - half; - const a1 = -Math.PI / 2 + half; - const deep = ctx.createLinearGradient(x, y + h * 0.55, x, y + h); - deep.addColorStop(0, 'rgba(0, 40, 90, 0)'); - deep.addColorStop(1, isLight ? 'rgba(3, 105, 161, 0.18)' : 'rgba(0, 20, 55, 0.40)'); - ctx.fillStyle = deep; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = P.wave; - ctx.lineWidth = 0.5 * scale; - ctx.globalAlpha = 0.55; - const rays = 16; - for (let i = 0; i <= rays; i++) { - const a = a0 + (a1 - a0) * (i / rays); - ctx.beginPath(); - ctx.moveTo(pivotX + Math.cos(a) * (d0 - h * 0.35), pivotY + Math.sin(a) * (d0 - h * 0.35)); - ctx.lineTo(pivotX + Math.cos(a) * (d0 + h * 0.75), pivotY + Math.sin(a) * (d0 + h * 0.75)); - ctx.stroke(); - } - for (let k = -1; k <= 3; k++) { - ctx.beginPath(); - ctx.arc(pivotX, pivotY, d0 + k * h * 0.17, a0, a1); - ctx.stroke(); - } - ctx.globalAlpha = 1; - band(-0.03, 1.03, inner, outer); + } else if (D.deco === 'ocean' || D.deco === 'cyber') { + // Backlit translucent scale window with a fine double rule and printed red zone + band(-0.035, 1.035, inner, outer); const sg = ctx.createLinearGradient(x, y, x, y + h); - sg.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.50)' : 'rgba(255, 255, 255, 0.16)'); - sg.addColorStop(1, isLight ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.04)'); + sg.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.45)' : 'rgba(255, 255, 255, 0.10)'); + sg.addColorStop(1, isLight ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.02)'); ctx.fillStyle = sg; ctx.fill(); ctx.strokeStyle = P.arc; - ctx.globalAlpha = 0.45; - ctx.lineWidth = 0.7 * scale; - ctx.stroke(); - ctx.globalAlpha = 1; - if (!compactLike) { - ctx.font = `600 ${small}px ${D.font.family}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '2px'; - ctx.textAlign = 'left'; - ctx.fillText('SONAR · LEVEL', x + 12 * scale, y + h - 9 * scale); - ctx.textAlign = 'right'; - ctx.fillText('dB RE 0 VU', x + w - 12 * scale, y + h - 9 * scale); - } - } else if (D.deco === 'cyber') { - // Chamfered HUD panel with dashed scale lane, bottom ruler and data readouts - const b = 4 * scale; - const cc = 7 * scale; - ctx.beginPath(); - ctx.moveTo(x + b + cc, y + b); - ctx.lineTo(x + w - b, y + b); - ctx.lineTo(x + w - b, y + h - b - cc); - ctx.lineTo(x + w - b - cc, y + h - b); - ctx.lineTo(x + b, y + h - b); - ctx.lineTo(x + b, y + b + cc); - ctx.closePath(); - ctx.fillStyle = isLight ? 'rgba(15, 118, 110, 0.05)' : 'rgba(6, 182, 212, 0.05)'; - ctx.fill(); - ctx.strokeStyle = P.grid; + ctx.globalAlpha = 0.5; ctx.lineWidth = 0.8 * scale; ctx.stroke(); - band(-0.02, 1.02, inner, outer); - ctx.save(); - ctx.globalAlpha = 0.55; - dash([2.5 * scale, 2.5 * scale]); - ctx.lineWidth = 0.6 * scale; - ctx.stroke(); - ctx.restore(); - const ry = y + h - b - 3.5 * scale; - const n = Math.max(8, Math.floor(w / (5 * scale))); - ctx.globalAlpha = 0.5; - ctx.lineWidth = 0.6 * scale; - ctx.beginPath(); - for (let i = 0; i <= n; i++) { - const px = x + b + cc + (w - 2 * (b + cc)) * (i / n); - ctx.moveTo(px, ry); - ctx.lineTo(px, ry - (i % 5 === 0 ? 3.2 : 1.6) * scale); - } + ctx.lineWidth = 0.45 * scale; + band(-0.02, 1.02, inner + 1.6 * scale, outer - 1.6 * scale); ctx.stroke(); + ctx.globalAlpha = 0.12; + band(VU_NORM_ZERO, 1.035, inner, outer, 12); + ctx.fillStyle = P.red; + ctx.fill(); ctx.globalAlpha = 1; - if (!compactLike) { - ctx.font = `700 ${small}px ${D.font.family}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '1px'; - ctx.textAlign = 'right'; - ctx.fillText('RMS 300ms', x + w - b - 6 * scale, y + b + 7 * scale); - ctx.textAlign = 'left'; - ctx.fillText('ref -18 dBFS', x + b + 6 * scale, y + h - b - 12 * scale); - } } else if (D.deco === 'champagne') { // Arched cartouche with pearl-studded border above a pinstriped gold pedestal lower(-0.05, 1.05); @@ -1272,12 +1275,10 @@ class LXStudioVUMeterAnimation { } } else if (D.deco === 'mono') { // Bauhaus layout: solid overload block, heavy top bar and a baseline rule - const zx = gsp(VU_NORM_ZERO).bx; + band(VU_NORM_ZERO, 1.03, inner, outer, 12); ctx.fillStyle = P.red; ctx.globalAlpha = isLight ? 0.10 : 0.16; - ctx.fillRect(zx, y, x + w - zx, h); - ctx.globalAlpha = 0.5; - ctx.fillRect(zx, y, 0.8 * scale, h); + ctx.fill(); ctx.globalAlpha = 1; ctx.fillStyle = P.ink; ctx.fillRect(x, y, w, (compactLike ? 1.8 : 3) * scale); @@ -1332,48 +1333,6 @@ class LXStudioVUMeterAnimation { ctx.lineTo(px + r * 0.7, py + r * 0.7); ctx.stroke(); }); - } else if (D.deco === 'ocean') { - ctx.strokeStyle = P.wave; - ctx.lineWidth = 0.8 * scale; - const amp = 1.6 * scale; - const wl = w / 5; - for (let i = 0; i < 3; i++) { - const wy = y + h * (0.80 + i * 0.07); - ctx.beginPath(); - for (let px = 0; px <= w; px += Math.max(2, w / 48)) { - const yy = wy + Math.sin((px / wl) * Math.PI * 2 + t * (0.5 + i * 0.15) + i) * amp; - if (px === 0) ctx.moveTo(x + px, yy); else ctx.lineTo(x + px, yy); - } - ctx.stroke(); - } - } else if (D.deco === 'cyber') { - const b = 3 * scale; - const L = 7 * scale; - ctx.strokeStyle = P.grid; - ctx.lineWidth = 1 * scale; - [[x + b, y + b, 1, 1], [x + w - b, y + b, -1, 1], [x + b, y + h - b, 1, -1], [x + w - b, y + h - b, -1, -1]].forEach(([px, py, sx2, sy2]) => { - ctx.beginPath(); - ctx.moveTo(px + sx2 * L, py); - ctx.lineTo(px, py); - ctx.lineTo(px, py + sy2 * L); - ctx.stroke(); - }); - ctx.globalAlpha = 0.25; - ctx.lineWidth = 0.5 * scale; - for (let i = 1; i < 4; i++) { - const gy = y + h * (0.74 + i * 0.06); - ctx.beginPath(); - ctx.moveTo(x + b, gy); - ctx.lineTo(x + w - b, gy); - ctx.stroke(); - } - ctx.globalAlpha = isLight ? 0.18 : 0.22; - const scanX = x + ((t * 0.12) % 1) * w; - const sg = ctx.createLinearGradient(scanX - 14 * scale, 0, scanX + 2 * scale, 0); - sg.addColorStop(0, 'rgba(0, 245, 255, 0)'); - sg.addColorStop(1, P.glow); - ctx.fillStyle = sg; - ctx.fillRect(scanX - 14 * scale, y, 16 * scale, h); } else if (D.deco === 'champagne') { const R = (c.pivotY - y) + h; const half = Math.atan2(w * 0.6, c.pivotY - y); @@ -1409,15 +1368,6 @@ class LXStudioVUMeterAnimation { ctx.fill(); }); } else if (D.deco === 'mono') { - if (!c.stereo) { - ctx.globalAlpha = isLight ? 0.06 : 0.07; - ctx.fillStyle = P.ink; - ctx.font = `800 ${Math.round(h * 0.7)}px ${D.font.family}`; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - ctx.fillText(c.o.title, x + w - 6 * scale, y + h * 0.5); - ctx.globalAlpha = 1; - } const ry = y + (c.isCompact || c.stereo ? 15 : 24) * scale; ctx.strokeStyle = P.rule; ctx.lineWidth = 0.6 * scale; From 0d299aa87189393881091f9ec2487e8930212e6a Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Fri, 30 Oct 2026 00:42:47 +0800 Subject: [PATCH 06/33] feat(vu-meter): refine profile scales and analog ballistics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/js/visualizer.js | 148 +++++++++++++++++++--------------- test/vu-meter.test.cjs | 31 +++++-- 2 files changed, 108 insertions(+), 71 deletions(-) diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index b21717d3..3355b7f2 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -22,44 +22,74 @@ * Every colour profile (vuMeterTheme) owns a complete design: dial geometry, * scale style, tick style, typography, decorations, pointer shape and pivot cap. * Add a new profile with LXStudioVUMeterAnimation.registerDesign(id, design). - * The measurement scale (norm positions of every tick) is shared by all designs. + * The calibration is shared by all designs; each profile selects its own scale density. */ -const VU_NORM_ZERO = 0.86; -const VU_TICKS_COMPACT = [ - { label: '-20', norm: 0.08, major: true }, - { norm: 0.20, type: 'min' }, - { label: '-10', norm: 0.32, major: true }, - { norm: 0.46, type: 'med' }, - { label: '-5', norm: 0.60, major: true }, - { norm: 0.73, type: 'med' }, - { label: '0', norm: 0.86, major: true, isRed: true }, - { norm: 0.93, type: 'min', isRed: true }, - { label: '+3', norm: 1.00, major: true, isRed: true } -]; -const VU_TICKS_FULL = [ - { label: '-20', norm: 0.08, major: true }, - { norm: 0.16, type: 'min' }, - { norm: 0.24, type: 'min' }, - { label: '-10', norm: 0.32, major: true }, - { norm: 0.40, type: 'min' }, - { label: '-7', norm: 0.48, major: false, type: 'med' }, - { label: '-5', norm: 0.60, major: true }, - { norm: 0.67, type: 'min' }, - { label: '-3', norm: 0.73, major: false, type: 'med' }, - { norm: 0.80, type: 'min' }, - { label: '0', norm: 0.86, major: true, isRed: true }, - { label: '+1', norm: 0.91, major: false, type: 'med', isRed: true }, - { label: '+2', norm: 0.95, major: false, type: 'min', isRed: true }, - { label: '+3', norm: 1.00, major: true, isRed: true } +const VU_NORM_ZERO = 0.8; +const VU_SCALE_ANCHORS = [ + { vu: -30, norm: 0.00 }, + { vu: -20, norm: 0.08 }, + { vu: -15, norm: 0.20 }, + { vu: -10, norm: 0.32 }, + { vu: -7, norm: 0.44 }, + { vu: -5, norm: 0.54 }, + { vu: -3, norm: 0.64 }, + { vu: -1, norm: 0.74 }, + { vu: 0, norm: 0.80 }, + { vu: 1, norm: 0.8666667 }, + { vu: 2, norm: 0.9333333 }, + { vu: 3, norm: 1.00 } ]; +const mapVuToNorm = vu => { + if (vu <= -30) return 0; + if (vu >= 3) return Math.min(1.02, 1 + (vu - 3) * 0.01); + for (let i = 0; i < VU_SCALE_ANCHORS.length - 1; i++) { + const lower = VU_SCALE_ANCHORS[i]; + const upper = VU_SCALE_ANCHORS[i + 1]; + if (vu >= lower.vu && vu <= upper.vu) { + const fraction = (vu - lower.vu) / (upper.vu - lower.vu); + return lower.norm + fraction * (upper.norm - lower.norm); + } + } + return 0; +}; +const makeVuTicks = points => points.map(([vu, label, major]) => ({ + label, + norm: mapVuToNorm(vu), + major: !!major, + type: major || label ? 'med' : 'min', + isRed: vu >= 0 +})); +const VU_TICK_PROFILES = { + essential: { + compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]) + }, + broadcast: { + compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-18], [-15, '-15', true], [-12], [-10, '-10', true], [-8], [-7, '-7'], [-6], [-5, '-5', true], [-4], [-3, '-3'], [-2], [-1], [0, '0', true], [1, '+1'], [2, '+2'], [3, '+3', true]]) + }, + precision: { + compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-18, '-18'], [-15, '-15', true], [-12, '-12'], [-10, '-10', true], [-8, '-8'], [-7], [-6, '-6'], [-5, '-5', true], [-4, '-4'], [-3, '-3'], [-2, '-2'], [-1], [0, '0', true], [0.5], [1, '+1'], [1.5], [2, '+2'], [2.5], [3, '+3', true]]) + }, + atelier: { + compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-15, '-15'], [-12], [-10, '-10', true], [-8], [-5, '-5', true], [-3, '-3'], [-1], [0, '0', true], [1, '+1'], [2], [3, '+3', true]]) + }, + studio: { + compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [-1], [0, '0', true], [1], [2], [3, '+3', true]]) + } +}; const LX_VU_DIAL_DESIGNS = { // Classic broadcast meter: warm lamp-lit plate, serif numerals, double scale rail, // tapered black blade with a red painted tip, brass corner screws, domed pivot hub. 'vintage-amber': { name: 'Vintage Amber', + scaleProfile: 'broadcast', geom: { span: 0.07, baseY: { c: 0.66, f: 0.61 }, sag: { c: 0.07, f: 0.08 }, curve: 2, pivot: { c: 1.95, f: 2.25 } }, - font: { family: 'Georgia, "Times New Roman", serif', weight: '700', style: 'normal', scale: 1.0 }, + font: { family: 'Georgia, "Times New Roman", serif', weight: '500', style: 'normal', scale: 1.0 }, tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'italic', face: { kind: 'radial', cy: 0.45, @@ -76,6 +106,7 @@ const LX_VU_DIAL_DESIGNS = { // fine bar ticks and a slim pale blade pointer over a jewel hub. 'ocean-blue': { name: 'Ocean Blue', + scaleProfile: 'precision', geom: { span: 0.055, baseY: { c: 0.60, f: 0.57 }, sag: { c: 0.12, f: 0.14 }, curve: 1.6, pivot: { c: 1.75, f: 2.0 } }, font: { family: '"Avenir Next", "Inter", "Segoe UI", Roboto, sans-serif', weight: '600', style: 'normal', scale: 1.0 }, tag: ch => 'CH · ' + ch, tagAlign: 'center', mark: 'VU', markStyle: 'normal', @@ -94,6 +125,7 @@ const LX_VU_DIAL_DESIGNS = { // geometric sans type, translucent scale band and a hairline pointer. 'cyber-cyan': { name: 'Cyber Cyan', + scaleProfile: 'essential', geom: { span: 0.045, baseY: { c: 0.68, f: 0.64 }, sag: { c: 0.05, f: 0.06 }, curve: 4, pivot: { c: 2.2, f: 2.6 } }, font: { family: '"Futura", "Century Gothic", "Avenir Next", "Segoe UI", sans-serif', weight: '500', style: 'normal', scale: 1.0 }, tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'normal', @@ -112,6 +144,7 @@ const LX_VU_DIAL_DESIGNS = { // wedge ticks capped with pearls, radial serif numerals and a slim black blade with red tip. 'champagne-gold': { name: 'Champagne Gold', + scaleProfile: 'atelier', geom: { span: 0.09, baseY: { c: 0.64, f: 0.60 }, sag: { c: 0.10, f: 0.11 }, curve: 2.6, pivot: { c: 1.9, f: 2.2 } }, font: { family: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif', weight: '700', style: 'normal', scale: 1.0 }, tag: ch => '— ' + ch + ' —', tagAlign: 'center', mark: 'VU', markStyle: 'italic', rotateLabels: true, @@ -130,6 +163,7 @@ const LX_VU_DIAL_DESIGNS = { // hairline rule, long thin ticks and a spear-tipped hairline pointer. 'studio-monochrome': { name: 'Studio Monochrome', + scaleProfile: 'studio', geom: { span: 0.10, baseY: { c: 0.67, f: 0.63 }, sag: { c: 0.035, f: 0.04 }, curve: 2, pivot: { c: 2.1, f: 2.4 } }, font: { family: '"Helvetica Neue", "Inter", Arial, sans-serif', weight: '600', style: 'normal', scale: 1.05 }, tag: ch => ch, tagAlign: 'left', mark: 'VU', markStyle: 'normal', @@ -182,31 +216,13 @@ class LXStudioVUMeterAnimation { } vuToNorm(vu) { - if (vu <= -30) return 0.0; - if (vu >= 3.0) { - return Math.min(1.02, 1.0 + (vu - 3.0) * 0.01); - } - const table = [ - { vu: -30, norm: 0.00 }, - { vu: -20, norm: 0.08 }, - { vu: -15, norm: 0.18 }, - { vu: -10, norm: 0.32 }, - { vu: -7, norm: 0.48 }, - { vu: -5, norm: 0.60 }, - { vu: -3, norm: 0.73 }, - { vu: -1, norm: 0.82 }, - { vu: 0, norm: 0.86 }, - { vu: 1, norm: 0.91 }, - { vu: 2, norm: 0.95 }, - { vu: 3, norm: 1.00 } - ]; - for (let i = 0; i < table.length - 1; i++) { - if (vu >= table[i].vu && vu <= table[i + 1].vu) { - const frac = (vu - table[i].vu) / (table[i + 1].vu - table[i].vu); - return table[i].norm + frac * (table[i + 1].norm - table[i].norm); - } - } - return vu < -30 ? 0.0 : 1.0; + return mapVuToNorm(vu); + } + + getScaleTicks(theme, compactLike) { + const profile = this.getDesign(theme).scaleProfile || 'broadcast'; + const ticks = VU_TICK_PROFILES[profile] || VU_TICK_PROFILES.broadcast; + return compactLike ? ticks.compact : ticks.full; } draw(data, ctx) { @@ -310,18 +326,17 @@ class LXStudioVUMeterAnimation { const swingR = Math.cos(t * 3.4 + 0.4) * 0.05 + Math.sin(t * 1.1 + 0.45) * 0.07; const microJitter = (Math.sin(t * 16.7) + Math.cos(t * 28.3)) * 0.02; - // Base level around -10VU ~ -5VU (norm 0.35 ~ 0.55), rhythmic punch up to 0VU (norm 0.86) + // Base level around -10 VU to -5 VU, with rhythmic peaks near 0 VU. const dynamicBase = 0.42 + beat1 * 0.35 + beat2 * 0.15; targetNormL = Math.max(0.08, Math.min(0.92, (dynamicBase + swingL + microJitter) * (0.4 + 0.6 * vol))); targetNormR = Math.max(0.08, Math.min(0.92, (dynamicBase + swingR - microJitter) * (0.4 + 0.6 * vol))); } } - // 3. ANSI C16.5 Standard VU Ballistics (~300 ms rise and fall integration with responsive attack) - // Moving-coil movement modelled as a critically damped spring (~300 ms to settle, no overshoot), - // with padded mechanical stops at both ends of travel. - const omega = 20.0; - const zeta = 1.0; + // 3. ANSI C16.5-style VU ballistics with a small, time-based moving-coil overshoot. + // The damped spring keeps the response smooth while retaining a little mechanical character. + const omega = 17.5; + const zeta = 0.82; this.velL = this.velL || 0; this.velR = this.velR || 0; const steps = Math.max(1, Math.ceil(dt / 0.008)); @@ -536,7 +551,9 @@ class LXStudioVUMeterAnimation { const fam = D.font.family; // Scale geometry (arc shape differs per design) - const spanX = w * G.span; + const fontPx = Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)); + const labelInset = majLen + sz.gap * scale + fontPx * 1.1 + 2 * scale; + const spanX = Math.max(w * G.span, labelInset); const sx = x + spanX; const ex = x + w - spanX; const sw = ex - sx; @@ -641,7 +658,7 @@ class LXStudioVUMeterAnimation { ctx.restore(); } - this.drawDialFace(ctx, D, P, { x, y, w, h, scale, isLight, compactLike, gsp: getScalePoint, majLen, gap: sz.gap * scale, fontPx: Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)), baseY, pivotX, pivotY }); + this.drawDialFace(ctx, D, P, { x, y, w, h, scale, isLight, compactLike, gsp: getScalePoint, majLen, gap: sz.gap * scale, fontPx, baseY, pivotX, pivotY }); // 2. Profile-specific decorations this.drawDialDeco(ctx, D, P, { x, y, w, h, scale, t, isLight, isCompact, stereo, pivotX, pivotY, sx, ex, o }); @@ -760,7 +777,7 @@ class LXStudioVUMeterAnimation { } // 5. Ticks - const ticks = compactLike ? VU_TICKS_COMPACT : VU_TICKS_FULL; + const ticks = this.getScaleTicks(o.theme, compactLike); ticks.forEach(tk => { const p = getScalePoint(tk.norm); const len = tk.major ? majLen : (tk.type === 'med' ? medLen : minLen); @@ -894,8 +911,9 @@ class LXStudioVUMeterAnimation { if (type === 'blade') { const b0 = bw * 1.15; - poly(0, b0, 0.68, wAt(0.68, b0, tw), P.needle); - poly(0.68, wAt(0.68, b0, tw), 1, tw, tipColor); + const tipStart = 0.88; + poly(0, b0, tipStart, wAt(tipStart, b0, tw), P.needle); + poly(tipStart, wAt(tipStart, b0, tw), 1, tw, tipColor); } else if (type === 'lumen') { const a = pt(0); const b = pt(0.94); diff --git a/test/vu-meter.test.cjs b/test/vu-meter.test.cjs index 460face6..50f4ea93 100644 --- a/test/vu-meter.test.cjs +++ b/test/vu-meter.test.cjs @@ -53,15 +53,34 @@ test('vuToNorm accurately maps dial scale points to dial tick norms', () => { assert.equal(vu.vuToNorm(-30), 0.0) assert.ok(Math.abs(vu.vuToNorm(-20) - 0.08) < 1e-3, '-20 VU must map to norm 0.08') assert.ok(Math.abs(vu.vuToNorm(-10) - 0.32) < 1e-3, '-10 VU must map to norm 0.32') - assert.ok(Math.abs(vu.vuToNorm(-7) - 0.48) < 1e-3, '-7 VU must map to norm 0.48') - assert.ok(Math.abs(vu.vuToNorm(-5) - 0.60) < 1e-3, '-5 VU must map to norm 0.60') - assert.ok(Math.abs(vu.vuToNorm(-3) - 0.73) < 1e-3, '-3 VU must map to norm 0.73') - assert.ok(Math.abs(vu.vuToNorm(0) - 0.86) < 1e-3, '0 VU must map to norm 0.86') - assert.ok(Math.abs(vu.vuToNorm(1) - 0.91) < 1e-3, '+1 VU must map to norm 0.91') - assert.ok(Math.abs(vu.vuToNorm(2) - 0.95) < 1e-3, '+2 VU must map to norm 0.95') + assert.ok(Math.abs(vu.vuToNorm(-7) - 0.44) < 1e-3, '-7 VU must map to norm 0.44') + assert.ok(Math.abs(vu.vuToNorm(-5) - 0.54) < 1e-3, '-5 VU must map to norm 0.54') + assert.ok(Math.abs(vu.vuToNorm(-3) - 0.64) < 1e-3, '-3 VU must map to norm 0.64') + assert.ok(Math.abs(vu.vuToNorm(-1) - 0.74) < 1e-3, '-1 VU must map to norm 0.74') + assert.ok(Math.abs(vu.vuToNorm(0) - 0.80) < 1e-3, '0 VU must map to norm 0.80') + assert.ok(Math.abs(vu.vuToNorm(1) - 0.8666667) < 1e-3, '+1 VU must map to norm 0.8667') + assert.ok(Math.abs(vu.vuToNorm(2) - 0.9333333) < 1e-3, '+2 VU must map to norm 0.9333') assert.ok(Math.abs(vu.vuToNorm(3) - 1.00) < 1e-3, '+3 VU must map to norm 1.00') }) +test('VU dial profiles vary scale detail without changing calibration', () => { + const { LXStudioVUMeterAnimation } = createVisualizerEnvironment() + const vu = new LXStudioVUMeterAnimation() + const profiles = ['vintage-amber', 'ocean-blue', 'cyber-cyan', 'champagne-gold', 'studio-monochrome'] + const detailed = vu.getScaleTicks('ocean-blue', false) + const essential = vu.getScaleTicks('cyber-cyan', false) + + assert.ok(detailed.length > essential.length, 'precision profile should have more graduations than essential profile') + assert.equal(vu.getScaleTicks('vintage-amber', false).filter(tick => tick.label).length, 10) + assert.equal(vu.getScaleTicks('cyber-cyan', false).filter(tick => tick.label).length, 5) + assert.equal(new Set(profiles.map(profile => vu.getScaleTicks(profile, false).length)).size, profiles.length, 'each profile should have a distinct full-scale density') + + for (const profile of profiles) { + const ticks = vu.getScaleTicks(profile, true) + assert.ok(ticks.some(tick => tick.label === '0' && Math.abs(tick.norm - vu.vuToNorm(0)) < 1e-6), `${profile} should align its zero tick to the calibration`) + } +}) + test('VU meter at 100% player volume oscillates and does not stay pinned at max level (1.0)', () => { const { LXStudioVUMeterAnimation, setSimulatedTime } = createVisualizerEnvironment() From 47fb04c423f863bb4034b24c14518cb3874866f7 Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Tue, 3 Nov 2026 10:27:22 +0800 Subject: [PATCH 07/33] feat(vu-meter): add Nocturne, Heritage, Atelier, Riviera and Cobalt dial profiles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/index.html | 5 + public/music/js/visualizer.js | 174 +++++++++++++++++++++++++++++----- test/vu-meter.test.cjs | 17 ++++ 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/public/music/index.html b/public/music/index.html index 15a0209f..f83075ee 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -3127,6 +3127,11 @@

+ + + + + diff --git a/public/music/js/visualizer.js b/public/music/js/visualizer.js index 3355b7f2..eb3ba5c4 100644 --- a/public/music/js/visualizer.js +++ b/public/music/js/visualizer.js @@ -76,12 +76,35 @@ const VU_TICK_PROFILES = { compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [3, '+3', true]]), full: makeVuTicks([[-20, '-20', true], [-15, '-15'], [-12], [-10, '-10', true], [-8], [-5, '-5', true], [-3, '-3'], [-1], [0, '0', true], [1, '+1'], [2], [3, '+3', true]]) }, + reference: { + compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [-2], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-2], [0, '0', true], [2], [3, '+3', true]]) + }, + brass: { + compact: makeVuTicks([[-20, '-20', true], [-13], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-17], [-13, '-13'], [-10], [-8, '-8'], [-6], [-5, '-5', true], [-3, '-3'], [-2], [0, '0', true], [1, '+1'], [2, '+2'], [3, '+3', true]]) + }, + open: { + compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-3], [0, '0', true], [3, '+3', true]]), + full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5], [-3, '-3'], [0, '0', true], [3, '+3', true]]) + }, + mastering: { + compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-6], [-3], [0, '0', true], [1], [2], [3, '+3', true]]), + full: makeVuTicks([[-24], [-20, '-20', true], [-18], [-15, '-15', true], [-12, '-12'], [-10], [-9, '-9'], [-7], [-6, '-6', true], [-5], [-4, '-4'], [-3, '-3'], [-2, '-2'], [-1], [0, '0', true], [1, '+1'], [2, '+2'], [3, '+3', true]]) + }, studio: { compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [0, '0', true], [3, '+3', true]]), full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [-1], [0, '0', true], [1], [2], [3, '+3', true]]) } }; +// Study designs use one printed palette for both UI modes. +function studyDesign(d) { + const face = { kind: d.face.kind, cy: d.face.cy, light: d.face.stops, dark: d.face.stops, glow: d.face.glow, glowAlways: !!d.face.glow }; + return Object.assign({ tagAlign: 'center', mark: 'VU', markStyle: 'normal', peak: null, sheen: false, deco: 'study' }, d, + { face, light: d.palette, dark: d.palette }); +} + const LX_VU_DIAL_DESIGNS = { // Classic broadcast meter: warm lamp-lit plate, serif numerals, double scale rail, // tapered black blade with a red painted tip, brass corner screws, domed pivot hub. @@ -177,7 +200,68 @@ const LX_VU_DIAL_DESIGNS = { needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', peak: null, sheen: false, deco: 'mono', lamp: ['235, 240, 255', 0.3], light: { ink: '#0f172a', muted: 'rgba(15, 23, 42, 0.55)', arc: '#334155', red: '#f43f5e', needle: '#0f172a', tip: '#f43f5e', rule: 'rgba(15, 23, 42, 0.25)', rim: 'rgba(148, 163, 184, 0.55)' }, dark: { ink: '#f8fafc', muted: 'rgba(226, 232, 240, 0.6)', arc: 'rgba(241, 245, 249, 0.85)', red: '#f43f5e', needle: '#e2e8f0', tip: '#f43f5e', rule: 'rgba(226, 232, 240, 0.25)', rim: '#334155' } - } + }, + // Industrial design study: printed cards are identical in light and dark mode. + // Smoked-titanium reference meter: wide shallow sweep, hairline nickel pointer, warm diffuser. + 'study-nocturne': studyDesign({ + name: 'Nocturne', scaleProfile: 'reference', + geom: { span: 0.05, baseY: { c: 0.66, f: 0.62 }, sag: { c: 0.05, f: 0.055 }, curve: 2.2, pivot: { c: 2.3, f: 2.7 } }, + font: { family: '"Avenir Next", Avenir, "Segoe UI", sans-serif', weight: '600', style: 'normal', scale: 1.0 }, + tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', + face: { kind: 'linear', stops: [[0, '#323536'], [0.56, '#202425'], [1, '#15191a']], glow: { color: '216, 196, 159', a0: 0.12, a1: 0.04 } }, + arc: 'double', arcInset: 2.2, tick: { style: 'bar', len: 1, lwMajor: 1.2, lwMinor: 0.6 }, + needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', lamp: ['216, 196, 159', 0.45], + palette: { ink: '#e5ded0', muted: 'rgba(157, 160, 156, 0.85)', arc: '#c5a36d', red: '#c9654f', needle: '#f3eee1', tip: '#c9654f', glow: 'rgba(216, 196, 159, 0.5)', frame: 'rgba(197, 163, 109, 0.45)', edge: '#c5a36d', rule: 'rgba(197, 163, 109, 0.3)', rim: '#706d66' }, + study: { caption: 'IEC 60268-17', pins: true } + }), + // Broadcast meter on lamp-lit ivory: double rail, serif numerals, tapered blade, watts sub-scale. + 'study-heritage': studyDesign({ + name: 'Heritage', scaleProfile: 'broadcast', warm: true, markDrop: 1.75, + geom: { span: 0.07, baseY: { c: 0.62, f: 0.56 }, sag: { c: 0.08, f: 0.09 }, curve: 2, pivot: { c: 1.95, f: 2.2 } }, + font: { family: 'Georgia, "Times New Roman", serif', weight: '500', style: 'normal', scale: 1.0 }, + tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', markStyle: 'italic', + face: { kind: 'radial', cy: 0.42, stops: [[0, '#fffaf0'], [0.56, '#f0e2c5'], [1, '#d9c39d']] }, + arc: 'double', arcInset: 2.4, tick: { style: 'bar', len: 1, lwMajor: 1.5, lwMinor: 0.85 }, + needle: { type: 'blade', reach: 1.1, width: 1 }, cap: 'screw', lamp: ['255, 196, 120', 0.8], + palette: { ink: '#332a1e', muted: 'rgba(117, 102, 79, 0.9)', arc: '#826843', red: '#bf4436', needle: '#171717', tip: '#bf4436', glow: 'rgba(255, 210, 140, 0.45)', frame: 'rgba(130, 104, 67, 0.5)', edge: '#826843', rule: 'rgba(130, 104, 67, 0.35)', rim: '#b39765' }, + study: { caption: '8 Ω · 100 W REF @ 0 VU', pins: true, watts: [[-20, '1'], [-10, '10'], [-5, '32'], [-3, '50'], [0, '100'], [3, '200']] } + }), + // Mastering meter in a champagne-brass frame: single rail, wedge graduations, faceted blade. + 'study-atelier': studyDesign({ + name: 'Atelier', scaleProfile: 'brass', warm: true, + geom: { span: 0.065, baseY: { c: 0.64, f: 0.60 }, sag: { c: 0.10, f: 0.12 }, curve: 1.8, pivot: { c: 1.8, f: 2.1 } }, + font: { family: '"Avenir Next", Avenir, "Segoe UI", sans-serif', weight: '600', style: 'normal', scale: 1.0 }, + tag: ch => '· ' + ch + ' ·', + face: { kind: 'linear', stops: [[0, '#292824'], [0.56, '#1a1b19'], [1, '#121311']] }, + arc: 'none-rail', tick: { style: 'wedge', len: 1, lwMajor: 1.6, lwMinor: 0.9 }, + needle: { type: 'blade', reach: 1.0, width: 0.85 }, cap: 'rosette', lamp: ['240, 210, 160', 0.45], + palette: { ink: '#e7d3ae', muted: 'rgba(161, 143, 112, 0.9)', arc: '#d0ad73', red: '#cf674f', needle: '#e7d3ae', tip: '#cf674f', glow: 'rgba(208, 173, 115, 0.45)', frame: 'rgba(208, 173, 115, 0.5)', edge: '#a7844b', rule: 'rgba(208, 173, 115, 0.3)', rim: '#a7844b' }, + study: { caption: 'MASTERING · ANODIZED BRASS', pins: true } + }), + // Porcelain reference meter: open shallow sweep, fine charcoal indices, blue-steel hairline. + 'study-riviera': studyDesign({ + name: 'Riviera', scaleProfile: 'open', + geom: { span: 0.06, baseY: { c: 0.66, f: 0.62 }, sag: { c: 0.06, f: 0.07 }, curve: 2, pivot: { c: 2.1, f: 2.45 } }, + font: { family: '"Avenir Next", Avenir, "Segoe UI", sans-serif', weight: '500', style: 'normal', scale: 1.0 }, + tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', + face: { kind: 'radial', cy: 0.40, stops: [[0, '#f1f0e9'], [0.56, '#d9d9d1'], [1, '#c5c6c0']] }, + arc: 'double', arcInset: 2.2, tick: { style: 'bar', len: 1.05, lwMajor: 1.2, lwMinor: 0.6 }, + needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', lamp: ['255, 250, 235', 0.35], + palette: { ink: '#252923', muted: 'rgba(119, 123, 117, 0.9)', arc: '#6d7771', red: '#bd5947', needle: '#1d2a32', tip: '#bd5947', glow: 'rgba(255, 255, 245, 0.4)', frame: 'rgba(109, 119, 113, 0.45)', edge: '#6d7771', rule: 'rgba(109, 119, 113, 0.3)', rim: '#a9aaa5' }, + study: { caption: 'IEC 60268-17', pins: true } + }), + // Cobalt mastering instrument: extended dB scale, 8 Ω power arc, vermilion-tipped blade. + 'study-cobalt': studyDesign({ + name: 'Cobalt', scaleProfile: 'mastering', markDrop: 1.75, + geom: { span: 0.065, baseY: { c: 0.62, f: 0.56 }, sag: { c: 0.11, f: 0.12 }, curve: 1.7, pivot: { c: 1.8, f: 2.05 } }, + font: { family: '"Avenir Next", Avenir, "Segoe UI", sans-serif', weight: '600', style: 'normal', scale: 0.95 }, + tag: ch => 'CH · ' + ch, + face: { kind: 'linear', stops: [[0, '#16345a'], [0.56, '#0c2343'], [1, '#08172d']], glow: { color: '83, 170, 255', a0: 0.22, a1: 0.05 } }, + arc: 'none-rail', tick: { style: 'bar', len: 1, lwMajor: 1.4, lwMinor: 0.8 }, + needle: { type: 'blade', reach: 1.0, width: 0.8 }, cap: 'jewel', lamp: ['128, 186, 209', 0.6], + palette: { ink: '#dce9ef', muted: 'rgba(156, 182, 197, 0.9)', arc: '#80bad1', red: '#ff785f', needle: '#dce9ef', tip: '#ff785f', glow: 'rgba(128, 186, 209, 0.55)', frame: 'rgba(128, 186, 209, 0.45)', edge: '#80bad1', rule: 'rgba(128, 186, 209, 0.3)', rim: '#576d82' }, + study: { caption: '8 Ω · 100 W REF @ 0 VU', pins: true, watts: [[-24, '.4'], [-18, '1.6'], [-12, '6'], [-6, '25'], [-3, '50'], [0, '100'], [3, '200']] } + }) }; @@ -601,7 +685,7 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = vg; ctx.fillRect(x, y, w, h); } - if (D.face.glow && !isLight) { + if (D.face.glow && (!isLight || D.face.glowAlways)) { const gl = ctx.createRadialGradient(x + w / 2, y + h * 0.5, 10 * scale, x + w / 2, y + h * 0.5, w * 0.65); gl.addColorStop(0, `rgba(${D.face.glow.color}, ${D.face.glow.a0})`); gl.addColorStop(0.6, `rgba(${D.face.glow.color}, ${D.face.glow.a1})`); @@ -619,7 +703,7 @@ class LXStudioVUMeterAnimation { ctx.fillRect(x, y, w, h); ctx.restore(); } - const warm = D.deco === 'vintage' || D.deco === 'champagne'; + const warm = D.warm || D.deco === 'vintage' || D.deco === 'champagne'; const age = ctx.createRadialGradient(x + w / 2, y + h * 0.5, Math.min(w, h) * 0.2, x + w / 2, y + h * 0.5, Math.max(w * 0.62, h * 1.1)); age.addColorStop(0, 'rgba(0, 0, 0, 0)'); age.addColorStop(1, warm ? 'rgba(90, 50, 10, 0.28)' : (isLight ? 'rgba(0, 0, 0, 0.10)' : 'rgba(0, 0, 0, 0.30)')); @@ -772,6 +856,9 @@ class LXStudioVUMeterAnimation { strokeRail(i / N + 0.004, (i + 0.66) / N, 0, col, segW, 3); ctx.restore(); } + } else if (D.arc === 'none-rail') { + strokeRail(0, normZero, 0, P.arc, railW * 1.1, 40); + strokeRail(normZero, 1, 0, P.red, redW, 15); } else if (D.arc === 'none') { strokeRail(normZero, 1, 0, P.red, redW, 15); } @@ -844,7 +931,7 @@ class LXStudioVUMeterAnimation { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.letterSpacing = '2px'; - ctx.fillText(D.mark, x + w / 2, baseY + sz.mark * scale); + ctx.fillText(D.mark, x + w / 2, baseY + sz.mark * scale * (compactLike ? 1 : (D.markDrop || 1))); ctx.restore(); // 8. Peak-hold markers (designs that opt in) @@ -1048,7 +1135,7 @@ class LXStudioVUMeterAnimation { if (!compactLike) { const shTop = y + h * 0.84; const shEdge = y + h * 0.94; - const warmShroud = D.deco === 'vintage' || D.deco === 'champagne'; + const warmShroud = D.warm || D.deco === 'vintage' || D.deco === 'champagne'; const curve = (first) => { for (let i = 0; i <= 24; i++) { const u = i / 24; @@ -1103,24 +1190,6 @@ class LXStudioVUMeterAnimation { ctx.fillStyle = sg; ctx.fillRect(x, y, w, h); } - // Curved crystal reflection across the upper glass - ctx.save(); - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x + w, y); - ctx.lineTo(x + w, y + h * 0.22); - for (let i = 1; i <= 24; i++) { - const u = 1 - i / 24; - const v = 2 * u - 1; - ctx.lineTo(x + w * u, y + h * (0.36 - 0.14 * v * v)); - } - ctx.closePath(); - const cr = ctx.createLinearGradient(x, y, x, y + h * 0.36); - cr.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.09)'); - cr.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = cr; - ctx.fill(); - ctx.restore(); ctx.restore(); // end clip // 12. Bezel @@ -1131,7 +1200,7 @@ class LXStudioVUMeterAnimation { ctx.stroke(); } else { // Machined metal bezel: brushed gradient ring with inner shadow lip and a specular top edge - const warmMetal = D.deco === 'vintage' || D.deco === 'champagne'; + const warmMetal = D.warm || D.deco === 'vintage' || D.deco === 'champagne'; const ringW = 3.2 * scale; const mg = ctx.createLinearGradient(x, y - ringW, x, y + h + ringW); if (warmMetal) { @@ -1291,6 +1360,63 @@ class LXStudioVUMeterAnimation { ctx.textAlign = 'right'; ctx.fillText('MASTER SERIES', x + w - 12 * scale, y + h - 9 * scale); } + } else if (D.deco === 'study' && D.study) { + const S = D.study; + if (S.pins) { + [-0.035, 1.035].forEach(n => { + const p = gsp(n, inner - 2 * scale); + ctx.fillStyle = P.edge; + ctx.beginPath(); + ctx.arc(p.bx, p.by, (compactLike ? 1.4 : 2.2) * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = P.ink; + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.arc(p.bx, p.by - 0.4 * scale, 0.6 * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + }); + } + if (S.watts && !compactLike) { + const railOff = -10 * scale; + const n0 = mapVuToNorm(S.watts[0][0]); + ctx.strokeStyle = P.arc; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 0.7 * scale; + ctx.beginPath(); + for (let i = 0; i <= 40; i++) { + const p = gsp(n0 + (1 - n0) * (i / 40), railOff); + if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); + } + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.font = `500 ${Math.max(6, Math.round(8 * scale))}px ${D.font.family}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + S.watts.forEach(([vu, label]) => { + const n = mapVuToNorm(vu); + const a = gsp(n, railOff + 2.5 * scale); + const b = gsp(n, railOff - 2.5 * scale); + const col = vu >= 0 ? P.red : P.muted; + ctx.strokeStyle = col; + ctx.lineWidth = 0.8 * scale; + ctx.beginPath(); + ctx.moveTo(a.bx, a.by); + ctx.lineTo(b.bx, b.by); + ctx.stroke(); + const l = gsp(n, railOff - 9 * scale); + ctx.fillStyle = col; + ctx.fillText(label, l.bx, l.by); + }); + } + if (S.caption && !compactLike) { + ctx.font = `500 ${small}px ${D.font.family}`; + ctx.fillStyle = P.muted; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.letterSpacing = '1.5px'; + ctx.fillText(S.caption, x + w / 2, y + h * 0.8); + } } else if (D.deco === 'mono') { // Bauhaus layout: solid overload block, heavy top bar and a baseline rule band(VU_NORM_ZERO, 1.03, inner, outer, 12); diff --git a/test/vu-meter.test.cjs b/test/vu-meter.test.cjs index 50f4ea93..e54c60a9 100644 --- a/test/vu-meter.test.cjs +++ b/test/vu-meter.test.cjs @@ -81,6 +81,23 @@ test('VU dial profiles vary scale detail without changing calibration', () => { } }) +test('design-study VU profiles are registered with calibrated scales', () => { + const { LXStudioVUMeterAnimation } = createVisualizerEnvironment() + const vu = new LXStudioVUMeterAnimation() + const studies = ['study-nocturne', 'study-heritage', 'study-atelier', 'study-riviera', 'study-cobalt'] + const names = studies.map(id => vu.getDesign(id).name) + assert.deepEqual(names, ['Nocturne', 'Heritage', 'Atelier', 'Riviera', 'Cobalt']) + for (const id of studies) { + const design = vu.getDesign(id) + assert.equal(design.light, design.dark, `${id} should print one palette for both UI modes`) + for (const compact of [true, false]) { + const ticks = vu.getScaleTicks(id, compact) + assert.ok(ticks.some(tick => tick.label === '0' && Math.abs(tick.norm - vu.vuToNorm(0)) < 1e-6), `${id} should align its zero tick`) + } + } + assert.ok(vu.getScaleTicks('study-cobalt', false).length > vu.getScaleTicks('study-riviera', false).length, 'cobalt mastering scale should be denser than riviera open scale') +}) + test('VU meter at 100% player volume oscillates and does not stay pinned at max level (1.0)', () => { const { LXStudioVUMeterAnimation, setSimulatedTime } = createVisualizerEnvironment() From d9b18ccd44d75e990c578cca2c180bfc500a95ba Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Wed, 4 Nov 2026 09:32:40 +0800 Subject: [PATCH 08/33] feat(vu-meter): replace legacy dials with ported design-study renderer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/music/app.js | 7 +- public/music/index.html | 5 - public/music/js/visualizer.js | 1762 ++++++++++----------------------- test/vu-meter.test.cjs | 74 +- 4 files changed, 592 insertions(+), 1256 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index f4fcdffe..594593f7 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -157,7 +157,7 @@ const DEFAULT_SETTINGS = { detailVisualizerStyle: 'pulse', visualizerOpacity: 0.5, visualizerGlobalStyle: 'blocks', - vuMeterTheme: 'vintage-amber', + vuMeterTheme: 'study-nocturne', // Cache Settings enableServerCache: true, // 开启服务器缓存 enableServerLyricCache: true, // 开启服务器歌词文件缓存 @@ -200,6 +200,11 @@ function normalizeStoredSettings(nextSettings) { nextSettings.serverCacheNamingPattern = validPatterns.includes(nextSettings.serverCacheNamingPattern) ? nextSettings.serverCacheNamingPattern : 'simple'; + const legacyVuThemes = { 'vintage-amber': 'study-heritage', 'ocean-blue': 'study-cobalt', 'cyber-cyan': 'study-nocturne', 'champagne-gold': 'study-atelier', 'studio-monochrome': 'study-riviera' }; + const vuThemes = ['study-nocturne', 'study-heritage', 'study-atelier', 'study-riviera', 'study-cobalt']; + if (!vuThemes.includes(nextSettings.vuMeterTheme)) { + nextSettings.vuMeterTheme = legacyVuThemes[nextSettings.vuMeterTheme] || 'study-nocturne'; + } return nextSettings; } diff --git a/public/music/index.html b/public/music/index.html index f83075ee..810748fa 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -3122,11 +3122,6 @@

' + @@ -3837,12 +3845,14 @@ class App { } /** 用户在 OpenList 备份里把某用户模式改为「上传后删除本地」时给出强提示: - * 该模式会清除本地缓存索引,下次播放将无本地缓存;为保证直接命中 OpenList 直链(而非回源重下), - * 保存时会自动开启 Subsonic 的「缓存优先时优先使用 OpenList」(见 saveOpenlistConfig)。 */ + * 该模式只删「下载 / 缓存」目录里的歌曲及其歌词、封面等附件,并清除本地缓存索引, + * 不会删除系统配置、歌单与不喜欢快照、用户数据;下次播放将无本地缓存; + * 为保证直接命中 OpenList 直链(而非回源重下),保存时会自动开启 Subsonic 的 + * 「缓存优先时优先使用 OpenList」(见 saveOpenlistConfig)。 */ onOpenlistModeChange(sel) { if (!sel || sel.value !== 'upload-delete') return if (window.showInfo) { - window.showInfo('已选择「上传后删除本地」:该模式会在上传完成后清除本地缓存索引,下次播放将无本地缓存。\n保存后系统会自动开启 Subsonic「缓存优先时优先使用 OpenList」,以便直接命中 OpenList 直链而非回源重下。\n(保存时会同时开启 Subsonic「服务器存在缓存优先播放」)') + window.showInfo('已选择「上传后删除本地」:\n仅删除「下载 / 缓存」目录中已成功上传的歌曲文件及其歌词、封面等附件,并清除本地缓存索引,下次播放将无本地缓存。\n系统配置、歌单与不喜欢快照、用户数据不会被删除,始终保留本地。\n保存后系统会自动开启 Subsonic「缓存优先时优先使用 OpenList」,以便直接命中 OpenList 直链而非回源重下。\n(保存时会同时开启 Subsonic「服务器存在缓存优先播放」)') } } @@ -3914,7 +3924,26 @@ class App { try { const r = await this.request('/api/openlist/backups') let list = (r && r.list) || [] - // 状态指标卡:基于全量列表统计各状态数量(deleted 不计入 list,通常为 0) + // 先按当前 tab 的 folder scope 过滤(媒体文件记录=cache,music;系统配置=system;歌单快照=list),统计卡与下方列表口径一致 + const scope = this._olbFolderScope + if (scope) { + const allowed = scope.split(',') + list = list.filter(f => { + if (allowed.includes(f.folder_type)) return true + // 歌单快照 tab:selectedData 模式下歌单/不喜欢快照记在 folder_type='data',路径含 /(list|dislike)/snapshot,需纳入展示 + if (allowed.includes('list') && f.folder_type === 'data' && /(^|[\\/])(list|dislike)[\\/]snapshot([\\/]|$)/i.test(f.local_path || f.name || '')) return true + return false + }) + } + // 歌单快照页:按「我的歌单 / 我不喜欢」类型下拉过滤 + const inSnapshot = scope && scope.split(',').includes('list') + const tfEl = document.getElementById('olb-filter-type') + const tf = tfEl ? tfEl.value : '' + if (inSnapshot && tf) { + list = list.filter(f => this.olbSnapshotType(f) === tf) + } + this._olbCurrentList = list + // 状态指标卡:基于当前页签过滤后的列表统计各状态数量(deleted 不计入 list,通常为 0) const STATUS_LABEL = { pending: '待上传', uploading: '进行中', completed: '已完成', verified: '已验证', failed: '失败', deleted: '已删除' } const STATUS_COLOR = { pending: '#64748b', uploading: '#3b82f6', completed: '#0ea5e9', verified: '#22c55e', failed: '#ef4444', deleted: '#94a3b8' } const statusOrder = ['pending', 'uploading', 'completed', 'verified', 'failed', 'deleted'] @@ -3990,7 +4019,7 @@ class App { const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') const fmtName = (f) => { const nm = (f.name || '').toString().trim() - const base = nm || (f.local_path ? f.local_path.split(/[\\/]/).pop() : '') || '(未知)' + const base = nm ? nm.split(/[\\/]/).pop() : ((f.local_path ? f.local_path.split(/[\\/]/).pop() : '') || '(未知)') return esc(base) } const fmtSize = (n) => { @@ -4004,7 +4033,13 @@ class App { const d = new Date(f.updated_at || f.created_at || Date.now()) const color = badgeColor[f.status] || '#64748b' const label = statusMap[f.status] || f.status || '' - const typeLabel = f.folder_type === 'data' ? '数据备份' : f.folder_type === 'music' ? '下载' : (f.folder_type === 'cache' ? '缓存' : (f.folder_type === 'list' ? '快照' : (f.folder_type || ''))) + let typeLabel + if (this._olbFolderScope && this._olbFolderScope.split(',').includes('list')) { + const st = this.olbSnapshotType(f) + typeLabel = st === 'dislike' ? '我不喜欢' : (st === 'list' ? '我的歌单' : (f.folder_type === 'list' ? '我的歌单' : '数据备份')) + } else { + typeLabel = f.folder_type === 'data' ? '数据备份' : f.folder_type === 'music' ? '下载' : (f.folder_type === 'cache' ? '缓存' : (f.folder_type === 'list' ? '快照' : (f.folder_type || ''))) + } const canRetry = f.status !== 'verified' && f.status !== 'deleted' const retryBtn = canRetry ? '' @@ -4022,14 +4057,13 @@ class App { '' + '' + esc(f.user || '') + '' + '' + typeLabel + '' + - '' + fmtName(f) + '' + + '' + fmtName(f) + '' + '' + label + '' + '' + fmtSize(f.size) + '' + '' + localMark + '' + '' + errCell + '' + - '' + d.toLocaleString() + '' + - '' + retryBtn + '' + - '' + esc(pathStr) + '' + + '' + d.toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '' + + '' + retryBtn + '' + '' }).join('') const pager = '
' + @@ -4056,7 +4090,6 @@ class App { '异常原因' + '更新时间' + '操作' + - '云端路径' + '' + rows + '
' + pager this.updateOlbSelCount() } catch (e) { @@ -4080,6 +4113,64 @@ class App { } } + async retrySelectedOpenlist() { + if (!this._olbSelected || !this._olbSelected.size) return + const ids = Array.from(this._olbSelected) + if (!confirm('确认对选中的 ' + ids.length + ' 条记录提交批量重试?(失败/异常状态的记录将重新上传或验证)')) return + try { + const r = await this.request('/api/openlist/retry-batch', { method: 'POST', body: JSON.stringify({ ids }) }) + if (r && r.success) { + if (window.showInfo) window.showInfo('已提交 ' + (r.retried || 0) + ' 条重试' + ((r.failed ? (',' + r.failed + ' 条失败') : ''))) + this.loadOpenlistBackups(true) + } else if (window.showError) { + window.showError('批量重试失败: ' + ((r && (r.error || r.message)) || '未知错误')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('批量重试失败: ' + (e && e.message || e)) + } + } + + showOpenlistDetail(id) { + const list = this._olbCurrentList || [] + const f = list.find(x => x.id === id) + if (!f) { if (window.showError) window.showError('未找到该记录详情'); return } + const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + const fmtSize = (n) => { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB' } + const row = (k, v) => '' + esc(k) + '' + (v === '' || v == null ? '—' : esc(v)) + '' + const html = '' + + row('记录 ID', f.id) + + row('用户', f.user) + + row('类型', f.folder_type) + + row('文件名', f.name) + + row('状态', f.status) + + row('大小', fmtSize(f.size)) + + row('来源平台', f.source) + + row('音质', f.quality) + + row('歌曲 ID', f.songmid) + + row('本地存在', f.local_exists ? '是' : '否') + + row('重试次数', f.retry_count) + + row('本地路径', f.local_path) + + row('云端路径', f.remote_path) + + row('远端大小', f.remote_size) + + row('本地哈希', f.local_hash) + + row('更新时间', f.updated_at) + + row('错误信息', f.error) + + row('缓存元数据', f.cache_metadata ? JSON.stringify(JSON.parse(f.cache_metadata || '{}'), null, 2) : '') + + '
' + const modal = document.getElementById('modal') + const title = document.getElementById('modal-title') + const body = document.getElementById('modal-body') + if (modal && title && body) { + const nmBase = String(f.name || '').trim().split(/[\\/]/).pop() || '' + title.textContent = '记录详情 #' + f.id + (nmBase ? ' · ' + nmBase : '') + body.innerHTML = html + modal.classList.remove('hidden') + } else { + alert('记录 #' + f.id + '\n用户: ' + f.user + '\n类型: ' + f.folder_type + '\n状态: ' + f.status + '\n云端路径: ' + (f.remote_path || '') + '\n本地路径: ' + (f.local_path || '')) + } + } + toggleOpenlistSelect(el) { const id = parseInt(el.dataset.id) if (!this._olbSelected) this._olbSelected = new Set() @@ -4104,6 +4195,8 @@ class App { if (span) span.textContent = n ? ('已选 ' + n + ' 项') : '' const btn = document.getElementById('olb-bulkdel') if (btn) btn.disabled = n === 0 + const rbtn = document.getElementById('olb-bulkretry') + if (rbtn) rbtn.disabled = n === 0 } async deleteSelectedOpenlist() { @@ -4127,8 +4220,12 @@ class App { switchOpenlistTab(name) { const map = { config: ['tab-btn-openlist-config', 'openlist-pane-config'], - records: ['tab-btn-openlist-records', 'openlist-pane-records'], + media: ['tab-btn-openlist-media', 'openlist-pane-records'], + system: ['tab-btn-openlist-system', 'openlist-pane-records'], + snapshot: ['tab-btn-openlist-snapshot', 'openlist-pane-records'], } + const scopeMap = { media: 'cache,music', system: 'system', snapshot: 'list' } + this._olbFolderScope = scopeMap[name] || '' for (const [btnId, paneId] of Object.values(map)) { const b = document.getElementById(btnId); if (b) b.classList.remove('active') const p = document.getElementById(paneId); if (p) p.classList.remove('active') @@ -4136,7 +4233,7 @@ class App { const [btnId, paneId] = map[name] || map.config const b = document.getElementById(btnId); if (b) b.classList.add('active') const p = document.getElementById(paneId); if (p) p.classList.add('active') - if (name === 'records') this.loadOpenlistBackups() + if (paneId === 'openlist-pane-records') this.loadOpenlistBackups() } scheduleOpenlistReload() { @@ -4145,7 +4242,7 @@ class App { } clearOpenlistFilters() { - ['olb-filter-user', 'olb-filter-folder', 'olb-filter-status', 'olb-filter-name', 'olb-filter-path'].forEach((id) => { + ['olb-filter-user', 'olb-filter-folder', 'olb-filter-status', 'olb-filter-type', 'olb-filter-name', 'olb-filter-path'].forEach((id) => { const el = document.getElementById(id); if (el) el.value = '' }) this._olbPage = 1 @@ -4166,6 +4263,17 @@ class App { this.loadOpenlistBackups() } + // 判断 OpenList 同步记录属于「我的歌单」还是「我不喜欢」(仅歌单快照页有意义) + olbSnapshotType(f) { + if (f.folder_type === 'list') return 'list' + if (f.folder_type === 'data') { + const p = (f.local_path || f.name || '') + if (/(^|[\/])(dislike)[\/]snapshot([\/]|$)/i.test(p)) return 'dislike' + if (/(^|[\/])(list)[\/]snapshot([\/]|$)/i.test(p)) return 'list' + } + return '' + } + gotoOpenlistPage(n) { const tp = this._olbTotalPages || 1 n = Math.max(1, Math.min(tp, parseInt(n) || 1)) @@ -4392,7 +4500,7 @@ class App { res.backups.forEach((item, index) => { const isLatest = index === 0; const sizeStr = item.size ? (item.size / (1024 * 1024)).toFixed(1) + ' MB' : '大小未知'; - const timeStr = item.timeStr || (item.time ? new Date(item.time).toLocaleString() : '未知时间'); + const timeStr = item.timeStr || (item.time ? new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '未知时间'); const escapedFilename = this.escapeHtml(item.filename || item.basename); const escapedBasename = this.escapeHtml(item.basename); @@ -4863,7 +4971,7 @@ class App { if (diff < day) return Math.floor(diff / hour) + '小时前'; const date = new Date(timestamp); - return date.toLocaleString('zh-CN'); + return date.toLocaleString('zh-CN', { hour12: false }); } // ========== 文件管理器功能 ========== @@ -5032,7 +5140,7 @@ class App { formatDate(timestamp) { const date = new Date(timestamp); - return date.toLocaleString('zh-CN'); + return date.toLocaleString('zh-CN', { hour12: false }); } formatUptime(seconds) { @@ -5147,6 +5255,20 @@ class App { dirText.textContent = data.backupDir || 'backups'; dirText.title = data.backupDir || 'backups'; } + const nextText = document.getElementById('config-backup-next-text'); + if (nextText) { + if (data.autoBackupEnabled && data.nextBackupAt) { + nextText.textContent = new Date(data.nextBackupAt).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } else if (data.autoBackupEnabled) { + nextText.textContent = '计算中…'; + } else { + nextText.textContent = '已停用'; + } + } + const lastText = document.getElementById('config-backup-last-text'); + if (lastText) { + lastText.textContent = data.lastBackupAt ? new Date(data.lastBackupAt).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '暂无'; + } if (!list.length) { container.innerHTML = '
暂无系统配置备份文件。可点击上方【立即备份配置】手动创建第一份备份。
'; @@ -5162,7 +5284,7 @@ class App { return `
-
${new Date(item.time).toLocaleString()}
+
${new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
${item.name} @@ -5286,26 +5408,32 @@ class App { return; } + // 确保「全部 / 歌单快照 / 不喜欢」子页签存在 + this.ensureSnapshotKindTabs(); + const kind = this._snapshotKind || 'all'; + // 添加加载状态 container.classList.add('content-loading'); try { // 添加 user 参数 - const list = await this.request(`/api/data/snapshots?user=${encodeURIComponent(username)}`); + const all = await this.request(`/api/data/snapshots?user=${encodeURIComponent(username)}`); + const list = kind === 'all' ? all : all.filter(i => (i.kind || 'list') === kind); if (!list.length) { - container.innerHTML = '
暂无快照
'; + const emptyText = kind === 'dislike' ? '该用户暂无「不喜欢」快照' : '暂无快照'; + container.innerHTML = `
${emptyText}
`; container.classList.remove('content-loading'); return; } container.innerHTML = list.map(item => `
-
${new Date(item.time).toLocaleString()}
-
snapshot_${item.id}
+
${new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+
snapshot_${item.id}${item.kind === 'dislike' ? '我不喜欢' : '我的歌单'}
${this.formatFileSize(item.size)}
- -
+
+
+ + + 每 N 小时自动备份一次 +
+
+ + + 留空=进程启动后每 N 小时;填 HH:MM 则在该时刻首次触发,之后每 N 小时循环 +
+
+
备份与恢复管理

backups
+
+
下一次自动备份
+
—
+
+
+
上次自动备份
+
—
+

备份配置 - + +
@@ -2503,9 +2540,9 @@

OpenList 连接

播放缓存同时上传封面与歌词 - ? + ? - 随同音频文件自动备份其本地封面图片与歌词文本 + 随同音频文件自动备份其本地封面图片与歌词文本;「上传后删除本地」模式下会一并删除
@@ -2602,7 +2639,7 @@

OpenList 连接

- 同步状态与文件记录 + 同步记录(媒体文件 / 系统配置 / 歌单快照) ?

实时查看文件备份进度、验证状态及失败重试

@@ -2620,6 +2657,13 @@

立即全量同步 +

@@ -2594,16 +2592,16 @@ class App { this.configLoaded = true; this.loadedConfig = config; // 保存原始配置,供 saveConfig 对比「需重启」字段是否变更 const form = document.getElementById('config-form'); - this.renderBackupPolicy(config); + this.populateBackupForms(config); - form.elements['serverName'].value = config.serverName || ''; + if (form.elements['serverName']) form.elements['serverName'].value = config.serverName || ''; if (form.elements['debug.enabled']) { form.elements['debug.enabled'].checked = config['debug.enabled'] || false; } - form.elements['maxSnapshotNum'].value = config.maxSnapshotNum || 10; - form.elements['list.addMusicLocationType'].value = config['list.addMusicLocationType'] || 'top'; - form.elements['proxy.enabled'].checked = config['proxy.enabled'] || false; - form.elements['proxy.header'].value = config['proxy.header'] || ''; + if (form.elements['maxSnapshotNum']) form.elements['maxSnapshotNum'].value = config.maxSnapshotNum || 10; + if (form.elements['list.addMusicLocationType']) form.elements['list.addMusicLocationType'].value = config['list.addMusicLocationType'] || 'top'; + if (form.elements['proxy.enabled']) form.elements['proxy.enabled'].checked = config['proxy.enabled'] || false; + if (form.elements['proxy.header']) form.elements['proxy.header'].value = config['proxy.header'] || ''; if (form.elements['proxy.all.enabled']) { form.elements['proxy.all.enabled'].checked = config['proxy.all.enabled'] || false; } @@ -2656,11 +2654,14 @@ class App { if (form.elements['user.cacheSizeLimit']) { form.elements['user.cacheSizeLimit'].value = config['user.cacheSizeLimit'] || 2000; } + if (form.elements['cache.maxAgeDays']) { + form.elements['cache.maxAgeDays'].value = config['cache.maxAgeDays'] ?? 14; + } if (form.elements['system.allowUnsafeVM']) { form.elements['system.allowUnsafeVM'].checked = config['system.allowUnsafeVM'] === true; } - form.elements['admin.password'].value = config['admin.password'] || ''; + if (form.elements['admin.password']) form.elements['admin.password'].value = config['admin.password'] || ''; // Web播放器配置 if (form.elements['player.name']) { @@ -2673,59 +2674,6 @@ class App { form.elements['player.password'].value = config['player.password'] || ''; } - // WebDAV 配置 - if (form.elements['webdav.enable']) { - form.elements['webdav.enable'].checked = config['webdav.enable'] === true; - } - this.toggleWebdavVisibility(); - if (form.elements['webdav.url']) { - form.elements['webdav.url'].value = config['webdav.url'] || ''; - } - if (form.elements['webdav.username']) { - form.elements['webdav.username'].value = config['webdav.username'] || ''; - } - if (form.elements['webdav.password']) { - form.elements['webdav.password'].value = config['webdav.password'] || ''; - } - if (form.elements['webdav.syncPath']) { - form.elements['webdav.syncPath'].value = config['webdav.syncPath'] || '/lx-sync'; - } - if (form.elements['webdav.backupPath']) { - form.elements['webdav.backupPath'].value = config['webdav.backupPath'] || '/lx-sync-backups'; - } - if (form.elements['sync.interval']) { - form.elements['sync.interval'].value = config['sync.interval'] || 60; - } - if (form.elements['sync.backupInterval']) { - form.elements['sync.backupInterval'].value = config['sync.backupInterval'] || 24; - } - if (form.elements['webdav.excludeCache']) { - form.elements['webdav.excludeCache'].checked = config['webdav.excludeCache'] === true; - } - if (form.elements['webdav.excludeMusic']) { - form.elements['webdav.excludeMusic'].checked = config['webdav.excludeMusic'] === true; - } - - // 本地配置备份 - if (form.elements['configBackup.enable']) { - form.elements['configBackup.enable'].checked = config['configBackup.enable'] !== false; - } - if (form.elements['configBackup.retentionDays']) { - form.elements['configBackup.retentionDays'].value = config['configBackup.retentionDays'] || 7; - } - if (form.elements['configBackup.dir']) { - form.elements['configBackup.dir'].value = config['configBackup.dir'] || ''; - } - if (form.elements['configBackup.intervalHours']) { - form.elements['configBackup.intervalHours'].value = config['configBackup.intervalHours'] || 24; - } - if (form.elements['configBackup.scheduleTime']) { - form.elements['configBackup.scheduleTime'].value = config['configBackup.scheduleTime'] || '00:00'; - } - if (form.elements['snapshot.backupPath']) { - form.elements['snapshot.backupPath'].value = config['snapshot.backupPath'] || ''; - } - // URL路径配置 if (form.elements['admin.path']) { form.elements['admin.path'].value = config['admin.path'] ?? ''; @@ -2761,6 +2709,8 @@ class App { const webdavCb = document.querySelector('input[name="webdav.enable"]'); const childWrapper = document.getElementById('webdav-options'); const hintWrapper = document.getElementById('webdav-disabled-hint'); + const operations = document.getElementById('webdav-operation-panel'); + if (operations) operations.style.display = webdavCb?.checked && this.loadedConfig?.['webdav.enable'] === true ? '' : 'none'; if (webdavCb && childWrapper && hintWrapper) { if (webdavCb.checked) { childWrapper.style.display = 'block'; @@ -3163,10 +3113,12 @@ class App { } if (pathError) return; + // 备份相关字段已迁移至「备份管理」独立表单,此处仅提交「系统配置」表单自身字段,避免 FormData 缺失导致覆盖为默认值或 false + const rawMaxAge = formData.get('cache.maxAgeDays'); + const parsedMaxAge = rawMaxAge !== null && rawMaxAge !== '' ? parseInt(rawMaxAge, 10) : NaN; const config = { serverName: formData.get('serverName'), 'debug.enabled': formData.get('debug.enabled') === 'on', - maxSnapshotNum: parseInt(formData.get('maxSnapshotNum')), 'list.addMusicLocationType': formData.get('list.addMusicLocationType'), 'proxy.enabled': formData.get('proxy.enabled') === 'on', 'proxy.header': formData.get('proxy.header'), @@ -3194,25 +3146,11 @@ class App { 'user.enableLoginCacheRestriction': formData.get('user.enableLoginCacheRestriction') === 'on', 'user.enableCacheSizeLimit': formData.get('user.enableCacheSizeLimit') === 'on', 'user.cacheSizeLimit': parseInt(formData.get('user.cacheSizeLimit')) || 2000, + ...(Number.isFinite(parsedMaxAge) ? { 'cache.maxAgeDays': Math.max(0, parsedMaxAge) } : {}), 'admin.password': formData.get('admin.password'), 'player.name': (formData.get('player.name') || '').trim() || 'LX Music Web', 'player.enableAuth': formData.get('player.enableAuth') === 'on', 'player.password': formData.get('player.password'), - 'webdav.enable': formData.get('webdav.enable') === 'on', - 'webdav.url': formData.get('webdav.url'), - 'webdav.username': formData.get('webdav.username'), - 'webdav.password': formData.get('webdav.password'), - 'webdav.syncPath': (formData.get('webdav.syncPath') || '').trim() || '/lx-sync', - 'webdav.backupPath': (formData.get('webdav.backupPath') || '').trim() || '/lx-sync-backups', - 'sync.interval': parseInt(formData.get('sync.interval')) || 60, - 'sync.backupInterval': parseInt(formData.get('sync.backupInterval')) || 24, - ...this.collectBackupPolicy(), - 'configBackup.enable': formData.get('configBackup.enable') === 'on', - 'configBackup.retentionDays': parseInt(formData.get('configBackup.retentionDays')) || 7, - 'configBackup.intervalHours': parseInt(formData.get('configBackup.intervalHours')) || 24, - 'configBackup.scheduleTime': (formData.get('configBackup.scheduleTime') || '').trim(), - 'configBackup.dir': (formData.get('configBackup.dir') || '').trim(), - 'snapshot.backupPath': (formData.get('snapshot.backupPath') || '').trim(), 'admin.path': adminPath, 'player.path': playerPath, 'system.allowUnsafeVM': formData.get('system.allowUnsafeVM') === 'on', @@ -3637,35 +3575,35 @@ class App { const isChecked = (config['backup.' + destination + '.types'] || []).includes(type.id); return '' + '' + - ''; + ''; }).join(''); return '' + '' + - '
' + this.escapeHtml(type.label) + '
' + - '
' + this.escapeHtml(type.description) + '
' + + '
' + this.escapeHtml(type.label) + '
' + + '
' + this.escapeHtml(type.description) + '
' + '' + cells + - ''; + ''; }).join('') + ''; - const form = document.getElementById('config-form'); + const webdavForm = document.getElementById('webdav-form') || document.getElementById('config-form'); ['backup.webdav.mirrorDeletes', 'backup.webdav.restoreOnStartup'].forEach(key => { - if (form && form.elements[key]) { - form.elements[key].checked = config[key] === true; + if (webdavForm && webdavForm.elements[key]) { + webdavForm.elements[key].checked = config[key] === true; } }); } collectBackupPolicy() { if (!this._backupTypesLoaded) return {}; - const fd = new FormData(document.getElementById('config-form')); + const policyForm = document.getElementById('backup-policy-form') || document.getElementById('config-form'); + if (!policyForm) return {}; + const fd = new FormData(policyForm); return { 'backup.webdav.types': fd.getAll('backup.webdav.types'), 'backup.openlist.types': fd.getAll('backup.openlist.types'), - 'backup.webdav.mirrorDeletes': fd.get('backup.webdav.mirrorDeletes') === 'on', - 'backup.webdav.restoreOnStartup': fd.get('backup.webdav.restoreOnStartup') === 'on', }; } @@ -3673,95 +3611,372 @@ class App { if (!this._backupTypesLoaded) return; const status = document.getElementById('backup-policy-status'); try { - const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(this.collectBackupPolicy()) }); - status.textContent = result.warning || '备份范围已写入 config.js。云端已有副本保持不变。'; - } catch (e) { status.textContent = '保存失败:' + e.message; } + const payload = this.collectBackupPolicy(); + const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('backup-policy-form', false); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = result.warning || '备份范围已写入 config.js。云端已有副本保持不变。'; + } + if (window.showSuccess) window.showSuccess('云端备份范围已保存'); + } catch (e) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + e.message; + } + if (window.showError) window.showError('保存失败:' + e.message); + } } async restoreOpenlistBackup() { - if (!(await showSelect('恢复 OpenList 数据备份', '按系统设置中已保存的备份类型,从 OpenList 恢复同名文件。会覆盖本地对应文件,保留本地独有文件;不会上传文件。恢复后需重启服务。', { danger: true }))) return; - try { - const result = await this.request('/api/openlist/restore', { method: 'POST' }); - if (!result.success) throw new Error((result.errors || []).join(';') || result.error || '云端没有所选类型的文件'); - showSuccess('已恢复 ' + result.restored + ' 个文件。请重启服务完整加载数据。'); - } catch (e) { showError('恢复失败:' + e.message); } + await this.openBackupCenter('restore', 'openlist'); } - async loadOpenlistConfig() { - try { - const config = await this.request('/api/config') - const form = document.getElementById('openlist-form') - if (!form) return - form.elements['openlist.enable'].checked = config['openlist.enable'] === true - form.elements['openlist.endpoint'].value = config['openlist.endpoint'] || '' - form.elements['openlist.token'].value = config['openlist.token'] || '' - form.elements['openlist.root'].value = config['openlist.root'] || '/lx_music' - form.elements['openlist.externalUrl'].value = config['openlist.externalUrl'] || '' - form.elements['openlist.includeNonAudio'].checked = config['openlist.includeNonAudio'] === true - form.elements['openlist.verifyInterval'].value = config['openlist.verifyInterval'] || 5 - form.elements['openlist.syncInterval'].value = config['openlist.syncInterval'] || 60 - this.toggleOpenlistVisibility() - form.elements['openlist.enable'].onchange = () => this.toggleOpenlistVisibility() - - let users = [] - this._olbUsersLoaded = true - try { - const ulist = await this.request('/api/users') - users = (ulist || []).map(u => u.name).filter(Boolean) - } catch (e) { - this._olbUsersLoaded = false - console.warn('获取用户列表失败', e) - if (window.showError) window.showError('获取用户列表失败,OpenList 用户备份配置可能不准确:' + (e && e.message || e)) + initOpenlistRestore(config) { + const form = document.getElementById('openlist-restore-form'); + if (!form) return; + this._openlistRestoreSupported = config.openlistRestoreVersion >= 2; + const sourceText = (config['openlist.root'] || '/lx_music').replace(/\/+$/, '') + '/_data'; + const sourceEl = document.getElementById('openlist-restore-source'); + if (sourceEl) sourceEl.textContent = sourceText; + if (this._openlistRestoreInitialized) return; + const types = config.backupTypeInfo || []; + const saved = config['backup.openlist.types'] || []; + const typeMeta = { + music: { + bg: 'rgba(139, 92, 246, 0.14)', + color: '#a78bfa', + svg: '' + }, + userFiles: { + bg: 'rgba(6, 182, 212, 0.14)', + color: '#38bdf8', + svg: '' + }, + snapshots: { + bg: 'rgba(16, 185, 129, 0.14)', + color: '#34d399', + svg: '' + }, + userData: { + bg: 'rgba(99, 102, 241, 0.14)', + color: '#818cf8', + svg: '' + }, + system: { + bg: 'rgba(245, 158, 11, 0.14)', + color: '#fbbf24', + svg: '' } - this.renderOpenlistUserConfig(users, config['openlist.userBackups']) - this.loadOpenlistBackups() - } catch (e) { - console.error(e) - if (window.showError) window.showError('加载 OpenList 配置失败: ' + (e && e.message || e)) + }; + + const typesContainer = document.getElementById('openlist-restore-types'); + if (typesContainer) { + typesContainer.innerHTML = types.map(type => { + const meta = typeMeta[type.id] || { bg: 'rgba(99, 102, 241, 0.14)', color: '#818cf8', svg: '' }; + const isChecked = saved.includes(type.id); + return ''; + }).join(''); } + + form.addEventListener('input', () => this.invalidateOpenlistRestore()); + form.addEventListener('change', () => this.invalidateOpenlistRestore()); + this._openlistRestoreInitialized = true; } - async saveOpenlistConfig() { - const form = document.getElementById('openlist-form') - if (!form) return - const fd = new FormData(form) - const userBackupsRaw = this.collectOpenlistUserBackups() - const cfg = { - 'openlist.enable': fd.get('openlist.enable') === 'on', - 'openlist.endpoint': (fd.get('openlist.endpoint') || '').trim(), - 'openlist.token': fd.get('openlist.token'), - 'openlist.root': (fd.get('openlist.root') || '').trim() || '/lx_music', - 'openlist.includeNonAudio': fd.get('openlist.includeNonAudio') === 'on', - 'openlist.externalUrl': (fd.get('openlist.externalUrl') || '').trim(), - 'openlist.verifyInterval': parseInt(fd.get('openlist.verifyInterval')) || 5, - 'openlist.syncInterval': parseInt(fd.get('openlist.syncInterval')) || 60, - // 仅当用户列表成功加载后才提交备份配置,避免失败时以空选择覆盖已保存规则 - 'openlist.userBackups': this._olbUsersLoaded ? userBackupsRaw : undefined, - } - // 仅当用户列表成功加载时,才根据「上传后删除」模式自动开启 Subsonic「缓存优先时优先使用 OpenList」, - // 避免本地被删后下次播放回源重下,而是直接命中 OpenList 直链(需 Subsonic「缓存优先」也已开启)。 - if (this._olbUsersLoaded) { - try { - const arr = JSON.parse(userBackupsRaw) - if (cfg['openlist.enable'] && Array.isArray(arr) && arr.some(b => b && b.enabled && b.backupCache === true && b.mode === 'upload-delete')) { - cfg['subsonic.playCacheFirst'] = true - cfg['subsonic.playCacheFirstUseOpenlist'] = true - } - } catch {} + invalidateOpenlistRestore() { + this._openlistRestorePlan = null; + const button = document.getElementById('openlist-restore-run'); + if (button) button.disabled = true; + const result = document.getElementById('openlist-restore-preview'); + if (result) { + result.innerHTML = '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '恢复设置已改变,请先扫描预览' + + '

选项发生变动,请点击「扫描并预览差异」重新生成文件比对分析与恢复计划。

' + + '
' + + '
'; } + } + + collectOpenlistRestore() { + const fd = new FormData(document.getElementById('openlist-restore-form')); + const types = fd.getAll('types'); + if (!types.length) throw new Error('请至少勾选一种要恢复的内容分类'); + return { types, mode: fd.get('mode'), backupBeforeRestore: fd.get('backupBeforeRestore') === 'on', snapshotId: fd.get('snapshotId') || undefined }; + } + + async previewOpenlistRestore() { + if (this._openlistRestoreBusy) return; + this.invalidateOpenlistRestore(); + const box = document.getElementById('openlist-restore-preview'); try { - const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(cfg) }) - if (result.warning) { - if (window.showInfo) window.showInfo(result.warning) - else alert(result.warning) - return - } - if (window.showSuccess) window.showSuccess('OpenList 配置已保存') - else alert('OpenList 配置已保存') + if (!this._openlistRestoreSupported) throw new Error('当前服务仍是旧版恢复接口,请重启服务后重新加载本页。尚未执行任何恢复。'); + const options = this.collectOpenlistRestore(); + this._openlistRestoreBusy = true; + box.innerHTML = '
' + + '
' + + '
' + + '正在扫描云端备份清单与文件指纹...' + + '

正在比对本地现有数据与云端差异,此操作为只读安全预检,绝不改动任何本地文件。

' + + '
' + + '
'; + const result = await this.request('/api/openlist/restore', { method: 'POST', body: JSON.stringify({ ...options, preview: true }) }); + if (!result.success) throw new Error((result.errors || []).join(';') || result.error || '云端没有所选类型的文件'); + if (JSON.stringify(options) !== JSON.stringify(this.collectOpenlistRestore())) throw new Error('扫描期间设置已变化,请重新扫描'); + this._openlistRestorePlan = { options, token: result.planToken }; + const esc = value => this.escapeHtml(String(value)); + const actions = { add: '新增', overwrite: '覆盖', skip: '跳过', merge: '合并索引' }; + const actionClasses = { add: 'is-add', overwrite: 'is-overwrite', skip: 'is-skip', merge: 'is-merge' }; + + box.innerHTML = '
' + + '
' + + '' + result.added + '' + + '将新增文件' + + '
' + + '
' + + '' + result.overwritten + '' + + '将覆盖替换' + + '
' + + '
' + + '' + (result.merged || 0) + '' + + '合并索引' + + '
' + + '
' + + '' + result.skipped + '' + + '安全跳过' + + '
' + + '
' + + '
' + + '' + + '预检完成:共比对 ' + result.files.length + ' 个文件。本地独有文件均安全保留,不会向云端写回。' + + '
' + + (result.warnings || []).map(w => '
' + esc(w) + '
').join('') + + '
' + + '' + + '' + + '' + + result.files.map(file => '').join('') + + '
操作类型备份文件相对路径
' + (actions[file.action] || esc(file.action)) + '' + esc(file.path) + '
'; + document.getElementById('openlist-restore-run').disabled = result.added + result.overwritten === 0; } catch (e) { - console.error(e) - if (window.showError) window.showError('保存失败: ' + (e && e.message || e)) + box.innerHTML = '
' + + '' + + '
扫描预检失败

' + this.escapeHtml(e.message) + '

' + + '
'; + } + finally { this._openlistRestoreBusy = false; } + } + + async executeOpenlistRestore() { + if (this._openlistRestoreBusy || !this._openlistRestorePlan) return; + const plan = this._openlistRestorePlan; + if (JSON.stringify(plan.options) !== JSON.stringify(this.collectOpenlistRestore())) { this.invalidateOpenlistRestore(); return; } + const modeText = plan.options.mode === 'missing' ? '补回缺失文件,并合并新增歌曲/快照的索引,保留已有业务条目。' : '整份替换同名文件,不合并 JSON 或歌单内容。'; + const backupText = plan.options.backupBeforeRestore ? '成功后保留恢复前副本,可在任务记录中随时一键回退。' : '成功后不再保留回退副本。'; + if (!(await showSelect('确认从 OpenList 恢复', modeText + '\n' + backupText + '\n\n全部文件下载校验后再提交;提交失败会自动尝试回退。提交后服务端将进入维护状态,需重启服务生效。', { danger: true }))) return; + if (plan !== this._openlistRestorePlan || this._openlistRestoreBusy) return; + this._openlistRestoreBusy = true; + const box = document.getElementById('openlist-restore-preview'); + document.getElementById('openlist-restore-run').disabled = true; + box.innerHTML = '
' + + '
' + + '
正在初始化恢复任务并拉取文件清单...

任务由后台托管,请勿重复操作

' + + '
'; + try { + const result = await this.request('/api/openlist/restore/jobs/start', { method: 'POST', body: JSON.stringify({ ...plan.options, planToken: plan.token }) }); + if (!result.success) throw new Error(result.error || '创建恢复任务失败'); + box.innerHTML = '
' + + '' + + '
恢复任务已提交并启动

正在后台下载校验文件,请在下方实时查看任务进度与本地副本状态。任务生效后请重启服务。

' + + '
'; + await this.loadOpenlistRecoveryHistory(); + this.startOpenlistRestorePolling(); + } catch (e) { + box.innerHTML = '
' + + '' + + '
创建恢复任务失败

' + this.escapeHtml(e.message) + '

' + + '
'; + showError('恢复失败:' + e.message); + } + finally { this._openlistRestoreBusy = false; this._openlistRestorePlan = null; } + } + + async loadOpenlistRecoveryHistory() { + if (!this._openlistRestoreSupported || this._openlistHistoryLoading) return; + this._openlistHistoryLoading = true; + const box = document.getElementById('openlist-restore-tasks'); + const esc = value => this.escapeHtml(String(value)); + const labels = { + pending: '等待扫描', + downloading: '正在下载校验', + ready: '校验就绪 · 等待提交', + committing: '正在提交写入', + completed: '已完成 · 请重启服务', + failed: '恢复失败 · 可重试', + cancelled: '已取消', + interrupted: '进程中断 · 可续传', + 'recovery-required': '提交中断 · 需先回退', + 'rolled-back': '已回退副本 · 请重启' + }; + const badgeClasses = { + pending: 'is-pending', + downloading: 'is-running', + ready: 'is-ready', + committing: 'is-committing', + completed: 'is-completed', + failed: 'is-failed', + cancelled: 'is-cancelled', + interrupted: 'is-interrupted', + 'recovery-required': 'is-danger', + 'rolled-back': 'is-rolled-back' + }; + const badgeIcons = { + completed: '', + downloading: '', + committing: '', + failed: '', + 'recovery-required': '', + 'rolled-back': '' + }; + + try { + const result = await this.request('/api/openlist/restore/history'); + const select = document.getElementById('openlist-restore-version'); + const selected = select.value; + select.innerHTML = '' + (result.versions || []).map(v => '').join(''); + if ([...select.options].some(o => o.value === selected)) select.value = selected; + else if (selected) this.invalidateOpenlistRestore(); + const tasks = result.tasks || []; + + if (!tasks.length) { + box.innerHTML = (result.warning ? '
' + esc(result.warning) + '
' : '') + + '
' + + '' + + '暂无历史恢复任务。执行恢复后,本地副本状态与进度将在此集中管理。' + + '
'; + } else { + box.innerHTML = (result.warning ? '
' + esc(result.warning) + '
' : '') + + tasks.map(t => { + const active = ['pending', 'downloading', 'ready', 'committing'].includes(t.state); + const bClass = badgeClasses[t.state] || 'is-pending'; + const bIcon = badgeIcons[t.state] || ''; + const total = t.total || 0; + const downloaded = t.downloaded || 0; + const applied = t.applied || 0; + const percent = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : (t.state === 'completed' ? 100 : 0); + + let actionsHtml = ''; + if (active && t.state !== 'committing') { + actionsHtml += ''; + } + if (['failed', 'cancelled', 'interrupted'].includes(t.state)) { + actionsHtml += ''; + } + if (t.canRollback || t.state === 'recovery-required') { + actionsHtml += ''; + } + if (!active && t.state !== 'recovery-required') { + actionsHtml += ''; + } + + const errorsHtml = (t.errors || []).length ? '
' + + t.errors.map(e => '

' + esc(e) + '

').join('') + + '
' : ''; + + return '
' + + '
' + + '
' + + '
' + + '' + + '' + esc(new Date(t.createdAt).toLocaleString()) + '' + + '
' + + 'ID: ' + esc(t.id.slice(0, 8)) + '' + + '
' + + '
' + + bIcon + + '' + esc(labels[t.state] || t.state) + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '下载校验进度' + + '' + esc(downloaded) + ' / ' + esc(total) + ' 文件 (' + percent + '%)' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '已提交生效' + + '' + esc(applied) + ' 个文件' + + '
' + + (t.canRollback ? '
' + + '' + + '已保留本地快照副本(支持一键回退)' + + '
' : '') + + '
' + + errorsHtml + + '
' + + (actionsHtml ? '
' + actionsHtml + '
' : '') + + '
'; + }).join(''); + } + + if (tasks.some(t => ['pending', 'downloading', 'ready', 'committing'].includes(t.state))) this.startOpenlistRestorePolling(); + else { clearInterval(this._openlistRestoreTimer); this._openlistRestoreTimer = null; } + } catch (e) { + box.innerHTML = '
加载恢复任务失败

' + this.escapeHtml(e.message) + '

'; } + finally { this._openlistHistoryLoading = false; } + } + + startOpenlistRestorePolling() { + if (this._openlistRestoreTimer) return; + this._openlistRestoreTimer = setInterval(() => { + if (this.currentView !== 'backups') { clearInterval(this._openlistRestoreTimer); this._openlistRestoreTimer = null; return; } + this.loadOpenlistRecoveryHistory(); + }, 2000); + } + + async openlistRestoreTaskAction(id, action) { + if (['delete', 'rollback'].includes(action) && !(await showSelect(action === 'delete' ? '删除本地任务及副本' : '回退本次恢复', action === 'delete' ? '删除后不能再使用此任务继续或回退。不删除云端历史备份。' : '恢复被替换文件,并移除本次新增文件。文件有后续修改时会阻止自动回退。完成后需重启服务。', { danger: true }))) return; + try { + await this.request('/api/openlist/restore/jobs/' + encodeURIComponent(id) + (action === 'delete' ? '' : '/' + action), { method: action === 'delete' ? 'DELETE' : 'POST' }); + await this.loadOpenlistRecoveryHistory(); + } catch (e) { showError(e.message); } + } + + async loadOpenlistConfig() { + await this.loadBackupSettings(false); + } + + async saveOpenlistConfig() { + await this.saveOpenlistConnection(); } toggleOpenlistVisibility() { @@ -3769,19 +3984,33 @@ class App { const enabled = !!(form && form.elements['openlist.enable'] && form.elements['openlist.enable'].checked) const opts = document.getElementById('openlist-options') const hint = document.getElementById('openlist-disabled-hint') + const cacheHint = document.getElementById('openlist-cache-disabled-hint') if (opts) opts.style.display = enabled ? 'block' : 'none' if (hint) hint.style.display = enabled ? 'none' : 'block' + if (cacheHint) cacheHint.style.display = enabled ? 'none' : 'block' + const active = enabled && this.loadedConfig?.['openlist.enable'] === true + for (const id of ['openlist-operation-panel', 'openlist-cache-form', 'openlist-cache-shortcut']) { + const element = document.getElementById(id) + if (element) element.style.display = active ? '' : 'none' + } + if (hint && !enabled) hint.textContent = this.loadedConfig?.['openlist.enable'] === true + ? '尚未保存关闭操作,后台仍按已保存设置运行。点击“保存 OpenList 设置”后才会停止服务;已有连接与缓存规则会保留。' + : 'OpenList 备份服务已关闭。连接参数、立即操作和播放缓存设置已隐藏;原有设置保留,开启并保存后可继续使用。' + } renderOpenlistUserConfig(users, userBackupsRaw) { let backups = [] - try { backups = JSON.parse(userBackupsRaw || '[]') } catch {} + try { backups = JSON.parse(userBackupsRaw || '[]') } catch { } const selected = backups.map(b => b.user) const labelMap = {} users.forEach(u => { labelMap[u] = u }) this.renderSortableTagGroup('tag-group-openlist-users', 'openlist.userBackupUsers', selected, labelMap, users, true) const container = document.getElementById('tag-group-openlist-users') - if (container) container.onclick = () => setTimeout(() => this.renderOpenlistUserDirs(backups), 0) + if (container) container.onclick = () => setTimeout(() => { + this.renderOpenlistUserDirs(backups) + this.markBackupDirty('openlist-cache-form', true) + }, 0) this.renderOpenlistUserDirs(backups) } @@ -3791,7 +4020,7 @@ class App { const dirsContainer = document.getElementById('openlist-user-dirs') if (!dirsContainer) return const map = {} - ;(backups || []).forEach(b => { map[b.user] = b }) + ; (backups || []).forEach(b => { map[b.user] = b }) const prev = {} selected.forEach(u => { const d = document.getElementById('openlist-dl-sw-' + u) @@ -3801,12 +4030,18 @@ class App { prev[u] = { download: d ? d.checked : null, cache: c ? c.checked : null, snapshot: s ? s.checked : null, mode: m ? m.value : '' } }) const modeOptions = (sel) => ([ - ['upload', '上传(保留本地)'], - ['upload-delete', '上传后删除本地(仅删除本地歌曲及其歌词等附件)'], - ['download', '下载(远端→本地)'], - ['sync', '双向同步'], + ['upload', '缓存上传到 OpenList,保留本地'], + ['upload-delete', '缓存上传并校验后,删除本地缓存'], + ['download', '从 OpenList 下载缓存到本地'], + ['sync', '本地缓存与 OpenList 双向同步'], ]).map(([v, t]) => '').join('') - const modeHelp = '上传:本地新增自动上传,保留本地;上传后删除:仅对「下载 / 缓存」目录中的歌曲文件及其随同上传的歌词、封面等附件生效,上传校验通过后删除这些本地文件并清除缓存索引;系统配置、歌单与不喜欢快照、用户数据一律保留本地,不受此模式影响;下载:仅把云端该用户目录拉到本地并补缓存;双向同步:补齐缓存并同步修改;本地缓存淘汰不会删除远端文件' + const modeShortLabel = { + 'upload': '仅上传保留本地', + 'upload-delete': '上传后删本地', + 'download': '仅下载到本地', + 'sync': '双向同步' + } + const modeHelp = '仅对该用户服务器上的播放缓存生效。上传:复制到 OpenList 并保留本地;上传并删除:校验内容后删除本地缓存及已上传附件;下载:只从 OpenList 取回缓存,不上传;双向同步:两边补齐并同步变化。本地缓存被清理不会删除远端副本。不会自动下载所有歌单歌曲,也不影响配置、歌单、用户数据等常规备份。' const toggleHtml = (id, checked) => ' - - -

- - - - -
-
-
- - - - - - - - -
-
-

WebDAV 同步配置

-

云端数据同步与定时备份

-
-
-
-
-
- 启用 WebDAV 同步服务 - 开启或关闭 WebDAV 云端数据同步与自动备份 -
- -
- - -
-
- - -
-
-
- - - - - -
-
-

备份与恢复

-

歌单数据快照与本地配置备份设置

-
-
+ +
@@ -1441,7 +1161,8 @@

权限与缓存限制

- + 后台每12小时自动扫描并清理超出保留天数的未访问音频与歌词缓存文件。设为 0 表示永久保留,永不自动删除。
@@ -1462,21 +1183,110 @@

权限与缓存限制

+ .help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + margin-left: .35rem; + border-radius: 50%; + cursor: pointer; + user-select: none; + background: var(--accent, #3b82f6); + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; + vertical-align: middle; + border: none; + padding: 0; + font-family: inherit; + } + + .help-icon:hover { + opacity: .85; + } + + .help-pop { + position: absolute; + z-index: 9999; + max-width: 320px; + width: max-content; + background: #20242e; + color: #e5e7eb; + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 8px; + padding: .55rem .7rem; + font-size: .8rem; + line-height: 1.55; + box-shadow: 0 8px 24px rgba(0, 0, 0, .4); + white-space: normal; + } + + .config-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1.5rem; + } + + .config-toggle-grid .config-toggle-row { + margin-top: 0 !important; + } + + @media (max-width:760px) { + .config-toggle-grid { + grid-template-columns: 1fr; + } + } + + .config-section-title { + font-weight: 600; + margin: 20px 0 12px; + padding-left: 10px; + border-left: 3px solid var(--accent-primary, #3b82f6); + color: var(--text-primary); + font-size: .95rem; + line-height: 1.4; + } + + .config-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.1rem 1.5rem; + align-items: center; + } + + .config-grid-2>.config-toggle-row { + margin: 0 !important; + } + + .config-grid-2>.config-field { + margin: 0; + } + + @media (max-width:760px) { + .config-grid-2 { + grid-template-columns: 1fr; + } + } + + .config-subpanel { + margin-top: 12px; + padding: 14px 16px; + background: rgba(148, 163, 184, .06); + border: 1px solid rgba(148, 163, 184, .14); + border-radius: 10px; + } + + .config-subpanel>.config-field { + margin-top: 14px; + } + + .config-subpanel>.config-field:first-child { + margin-top: 0; + } +
@@ -1513,7 +1323,7 @@

权限与缓存限制

- +
@@ -1528,72 +1338,89 @@

权限与缓存限制

-

Subsonic 协议配置

+

Subsonic 协议配置

基础服务
-
-
- 启用 Subsonic 服务 -
- -
-
-
- Subsonic 调试日志模式 +
+
+ 启用 Subsonic 服务 +
+
- -
-
- - - -
-
-
- 启用 Subsonic 独立监听端口 +
+
+ Subsonic 调试日志模式 +
+
- -
-
本地歌曲歌单
- 返回本地歌曲歌单 + 返回本地歌曲歌单
-