From bd067cae64a549973a1da7c749d041ed36f40ed5 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 8 May 2026 16:16:34 -0700 Subject: [PATCH] fix: minor UI adjustments --- src/components/track/Track.css | 9 ++ .../track/TrackAutomationLane.test.ts | 54 ++++++++ src/components/track/TrackAutomationLane.tsx | 124 +++++++++++++++--- 3 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 src/components/track/TrackAutomationLane.test.ts diff --git a/src/components/track/Track.css b/src/components/track/Track.css index 536c2c2..b8afd8d 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -265,6 +265,15 @@ background: rgba(0, 0, 0, 0.35); } +.track-automation-center-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: rgba(255, 255, 255, 0.18); + pointer-events: none; +} + .track-automation-svg { position: absolute; inset: 0; diff --git a/src/components/track/TrackAutomationLane.test.ts b/src/components/track/TrackAutomationLane.test.ts new file mode 100644 index 0000000..e73ba0b --- /dev/null +++ b/src/components/track/TrackAutomationLane.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector: (state: { + selectedTrackAutomationPointIds: string[]; + updateTrack: () => Promise; + refreshProjectState: () => void; + bumpTrackAutomationRedrawVersion: () => void; + }) => unknown) => selector({ + selectedTrackAutomationPointIds: [], + updateTrack: async () => {}, + refreshProjectState: () => {}, + bumpTrackAutomationRedrawVersion: () => {}, + }), +})); + +const { __trackAutomationTestUtils } = await import('./TrackAutomationLane'); + +describe('TrackAutomationLane mapping', () => { + it('maps volume 0.0dB to the visual midpoint and back', () => { + const laneHeight = 120; + const middle = __trackAutomationTestUtils.getLaneMetrics(laneHeight).middle; + + expect(__trackAutomationTestUtils.volumeToY(0, laneHeight)).toBe(middle); + expect(__trackAutomationTestUtils.yToVolume(middle, laneHeight)).toBe(0); + }); + + it('maps the volume bounds to the top and bottom limits', () => { + const laneHeight = 120; + const { top, bottom } = __trackAutomationTestUtils.getLaneMetrics(laneHeight); + + expect(__trackAutomationTestUtils.volumeToY(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, laneHeight)).toBe(top); + expect(__trackAutomationTestUtils.volumeToY(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, laneHeight)).toBe(bottom); + }); + + it('maps pan center to a reachable 1-pixel midpoint band', () => { + const laneHeight = 120; + const middle = __trackAutomationTestUtils.getLaneMetrics(laneHeight).middle; + + expect(__trackAutomationTestUtils.panToY(0, laneHeight)).toBe(middle); + expect(__trackAutomationTestUtils.yToPan(middle, laneHeight)).toBe(0); + expect(__trackAutomationTestUtils.yToPan(middle - 0.5, laneHeight)).toBe(0); + expect(__trackAutomationTestUtils.yToPan(middle + 0.5, laneHeight)).toBe(0); + }); + + it('formats volume and pan labels with the refined representation', () => { + expect(__trackAutomationTestUtils.formatAutomationValue('volume', 0)).toBe('+0.0dB'); + expect(__trackAutomationTestUtils.formatAutomationValue('volume', AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB)).toBe('−∞'); + expect(__trackAutomationTestUtils.formatAutomationValue('pan', -1)).toBe('-64'); + expect(__trackAutomationTestUtils.formatAutomationValue('pan', 0)).toBe('+0'); + expect(__trackAutomationTestUtils.formatAutomationValue('pan', 1)).toBe('+63'); + }); +}); diff --git a/src/components/track/TrackAutomationLane.tsx b/src/components/track/TrackAutomationLane.tsx index 6056624..fff95da 100644 --- a/src/components/track/TrackAutomationLane.tsx +++ b/src/components/track/TrackAutomationLane.tsx @@ -42,17 +42,103 @@ const TRACK_AUTOMATION_COLORS: Record = { function formatAutomationValue(automationType: TrackAutomationType, value: number): string { if (automationType === 'volume') { - return `${value >= 0 ? '+' : ''}${value.toFixed(1)}`; + if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) { + return '−∞'; + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`; } - const magnitude = Math.round(Math.abs(value) * 100); - if (magnitude === 0) { - return 'C'; - } - - return `${value < 0 ? 'L' : 'R'}${magnitude}`; + const logicPanValue = value <= 0 + ? Math.round(value * 64) + : Math.round(value * 63); + return `${logicPanValue >= 0 ? '+' : ''}${logicPanValue}`; } +function getLaneMetrics(laneHeight: number) { + const top = LANE_PADDING_Y; + const bottom = laneHeight - LANE_PADDING_Y; + const middle = Math.round((top + bottom) / 2); + return { top, middle, bottom }; +} + +function volumeToY(value: number, laneHeight: number): number { + const clamped = Math.max( + AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, + Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, value) + ); + const { top, middle, bottom } = getLaneMetrics(laneHeight); + + if (clamped >= 0) { + const normalized = clamped / AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB; + return middle - (middle - top) * normalized; + } + + const normalized = clamped / AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + return middle + (bottom - middle) * normalized; +} + +function yToVolume(y: number, laneHeight: number): number { + const { top, middle, bottom } = getLaneMetrics(laneHeight); + const clampedY = Math.min(bottom, Math.max(top, y)); + + if (clampedY <= middle) { + const normalized = middle === top ? 0 : (middle - clampedY) / (middle - top); + return Math.min( + AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, + Math.max(0, normalized * AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB) + ); + } + + const normalized = bottom === middle ? 0 : (clampedY - middle) / (bottom - middle); + return Math.max( + AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, + Math.min(0, normalized * AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) + ); +} + +function panToY(value: number, laneHeight: number): number { + const clamped = Math.max(-1, Math.min(1, value)); + const { top, middle, bottom } = getLaneMetrics(laneHeight); + + if (clamped === 0) { + return middle; + } + + if (clamped > 0) { + return middle - (middle - top) * clamped; + } + + return middle + (bottom - middle) * Math.abs(clamped); +} + +function yToPan(y: number, laneHeight: number): number { + const { top, middle, bottom } = getLaneMetrics(laneHeight); + const clampedY = Math.min(bottom, Math.max(top, y)); + + // Reserve a full pixel around the midpoint for exact center. + if (Math.abs(clampedY - middle) <= 0.5) { + return 0; + } + + if (clampedY < middle) { + const normalized = middle === top ? 0 : (middle - clampedY) / (middle - top); + return Math.max(0, Math.min(1, normalized)); + } + + const normalized = bottom === middle ? 0 : (clampedY - middle) / (bottom - middle); + return Math.max(-1, Math.min(0, -normalized)); +} + +export const __trackAutomationTestUtils = { + formatAutomationValue, + getLaneMetrics, + volumeToY, + yToVolume, + panToY, + yToPan, +}; + const TrackAutomationLane: React.FC = ({ track, automationType, @@ -133,20 +219,20 @@ const TrackAutomationLane: React.FC = ({ const toY = (value: number): number => { const laneHeight = laneRef.current?.clientHeight ?? 120; - const usableHeight = laneHeight - LANE_PADDING_Y * 2; - const normalized = (value - minValue) / (maxValue - minValue); - return laneHeight - LANE_PADDING_Y - normalized * usableHeight; + if (automationType === 'volume') { + return volumeToY(value, laneHeight); + } + + return panToY(value, laneHeight); }; const toValue = (y: number): number => { const laneHeight = laneRef.current?.clientHeight ?? 120; - const usableHeight = laneHeight - LANE_PADDING_Y * 2; - const clampedY = Math.min(laneHeight - LANE_PADDING_Y, Math.max(LANE_PADDING_Y, y)); - const normalized = (laneHeight - LANE_PADDING_Y - clampedY) / usableHeight; - const rawValue = minValue + normalized * (maxValue - minValue); - return automationType === 'volume' - ? Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, rawValue)) - : Math.max(-1, Math.min(1, rawValue)); + if (automationType === 'volume') { + return yToVolume(y, laneHeight); + } + + return yToPan(y, laneHeight); }; const renderedPoints = points.map(point => { @@ -502,6 +588,10 @@ const TrackAutomationLane: React.FC = ({ onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }} >
+