diff --git a/src/components/ChordPickerPopup.tsx b/src/components/ChordPickerPopup.tsx index 68c1f9f..35c08ea 100644 --- a/src/components/ChordPickerPopup.tsx +++ b/src/components/ChordPickerPopup.tsx @@ -65,7 +65,7 @@ function createFallbackDescriptor(value: string): ChordDescriptor { } function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor { - let quality = descriptor.quality; + const quality = descriptor.quality; let extensions = [...descriptor.extensions]; const dedupe = (nextExtensions: ChordExtension[]) => Array.from(new Set(nextExtensions)); diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 5272efb..7f86e6b 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -79,11 +79,11 @@ function formatKGOneTabLabel(tab: Tab): string { return 'Separator'; } -export function getDefaultKGOneTab(mode: KGOneMode): Tab { +function getDefaultKGOneTab(mode: KGOneMode): Tab { return mode === 'local-separator' ? 'separator' : 'fullsong'; } -export function getKGOneMode(): KGOneMode { +function getKGOneMode(): KGOneMode { const enabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false; return enabled ? 'server' : 'local-separator'; } diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index bc67358..6f98476 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1270,7 +1270,7 @@ const MainContent: React.FC = ({ } const sortedRegions = [...chordRegions] - .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()) + .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()); const targetRegion = direction === 'forward' ? sortedRegions.find(region => region.getStartFromBeat() > currentStartBeat && region.getStartFromBeat() <= targetBarBeat) : [...sortedRegions] diff --git a/src/components/piano-roll/PianoRoll.test.ts b/src/components/piano-roll/PianoRoll.test.ts index 1a4b761..2ba3ef6 100644 --- a/src/components/piano-roll/PianoRoll.test.ts +++ b/src/components/piano-roll/PianoRoll.test.ts @@ -36,7 +36,7 @@ import { getRegionStartScrollLeft, getRegionPlayheadRelation, getScrollLeftForViewportRequest, -} from './PianoRoll'; +} from './pianoRollViewport'; import type { SheetMeasureMetric } from './sheetNotationTypes'; function createContainer({ clientWidth, scrollWidth }: { clientWidth: number; scrollWidth: number }): HTMLDivElement { diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index fac6e52..0da1aee 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -25,37 +25,12 @@ import { import type { PianoRollAutomationType } from './pianoRollAutomation'; import type { SheetMeasureMetric } from './sheetNotationTypes'; import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation'; - -type RegionPlayheadRelation = 'before' | 'inside' | 'after'; -type ViewportSwitchAlignment = 'center' | 'region-start' | 'region-end'; -type ViewportClampScope = 'region' | 'track'; - -interface PendingModeSwitchRequest { - sourceSheetMusicViewEnabled: boolean; - destinationSheetMusicViewEnabled: boolean; - destinationSheetMusicTrackScopeEnabled: boolean; - alignment: ViewportSwitchAlignment; - anchorBeat: number; - clampScope: ViewportClampScope; -} - -interface ModeSwitchRequestOptions { - playheadBeat: number; - regionStartBeat: number; - regionEndBeat: number; - sourceSheetMusicViewEnabled: boolean; - destinationSheetMusicViewEnabled: boolean; - destinationSheetMusicTrackScopeEnabled: boolean; -} - -interface ScrollLeftForViewportRequestOptions { - request: PendingModeSwitchRequest; - container: HTMLDivElement; - sheetMeasureMetrics: SheetMeasureMetric[]; - activeRegionStartBeat: number; - activeRegionEndBeat: number; - songEndBeat: number; -} +import { + createPendingModeSwitchRequest, + getRegionStartScrollLeft, + getScrollLeftForViewportRequest, + type PendingModeSwitchRequest, +} from './pianoRollViewport'; interface PianoRollProps { onClose: () => void; @@ -1389,233 +1364,3 @@ const PianoRoll: React.FC = ({ }; export default PianoRoll; - -export function getRegionPlayheadRelation( - playheadBeat: number, - regionStartBeat: number, - regionEndBeat: number -): RegionPlayheadRelation { - if (playheadBeat < regionStartBeat) { - return 'before'; - } - - if (playheadBeat > regionEndBeat) { - return 'after'; - } - - return 'inside'; -} - -export function createPendingModeSwitchRequest({ - playheadBeat, - regionStartBeat, - regionEndBeat, - sourceSheetMusicViewEnabled, - destinationSheetMusicViewEnabled, - destinationSheetMusicTrackScopeEnabled, -}: ModeSwitchRequestOptions): PendingModeSwitchRequest { - const relation = getRegionPlayheadRelation(playheadBeat, regionStartBeat, regionEndBeat); - const enteringTrackScopeSheet = ( - !sourceSheetMusicViewEnabled && - destinationSheetMusicViewEnabled && - destinationSheetMusicTrackScopeEnabled - ); - - if (relation === 'inside') { - return { - sourceSheetMusicViewEnabled, - destinationSheetMusicViewEnabled, - destinationSheetMusicTrackScopeEnabled, - alignment: 'center', - anchorBeat: playheadBeat, - clampScope: destinationSheetMusicTrackScopeEnabled ? 'track' : 'region', - }; - } - - if (enteringTrackScopeSheet) { - return { - sourceSheetMusicViewEnabled, - destinationSheetMusicViewEnabled, - destinationSheetMusicTrackScopeEnabled, - alignment: 'center', - anchorBeat: playheadBeat, - clampScope: 'track', - }; - } - - return { - sourceSheetMusicViewEnabled, - destinationSheetMusicViewEnabled, - destinationSheetMusicTrackScopeEnabled, - alignment: relation === 'before' ? 'region-start' : 'region-end', - anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat, - clampScope: 'region', - }; -} - -function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): { - visibleWidth: number; - keysWidth: number; -} { - const keysWidth = sheetMusicViewEnabled - ? 0 - : (parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') - ) || 60); - - return { - visibleWidth: Math.max(0, container.clientWidth - keysWidth), - keysWidth, - }; -} - -function getPixelForAbsoluteBeat( - beat: number, - sheetMusicViewEnabled: boolean, - sheetMusicTrackScopeEnabled: boolean, - sheetMeasureMetrics: SheetMeasureMetric[], - activeRegionStartBeat: number -): number { - if (sheetMusicViewEnabled) { - return getSheetPlayheadPixel( - sheetMusicTrackScopeEnabled - ? Math.max(0, beat) - : Math.max(0, beat - activeRegionStartBeat), - sheetMeasureMetrics - ); - } - - const beatWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') - ) || 40; - return beat * beatWidth; -} - -function getScopeBoundsInPixels( - request: PendingModeSwitchRequest, - sheetMeasureMetrics: SheetMeasureMetric[], - activeRegionStartBeat: number, - activeRegionEndBeat: number, - songEndBeat: number -): { startPx: number; endPx: number } { - if (request.destinationSheetMusicViewEnabled) { - if (request.clampScope === 'track') { - return { - startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics), - endPx: getSheetPlayheadPixel(songEndBeat, sheetMeasureMetrics), - }; - } - - return { - startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics), - endPx: getSheetPlayheadPixel(activeRegionEndBeat - activeRegionStartBeat, sheetMeasureMetrics), - }; - } - - const beatWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') - ) || 40; - - if (request.clampScope === 'track') { - return { - startPx: 0, - endPx: songEndBeat * beatWidth, - }; - } - - return { - startPx: activeRegionStartBeat * beatWidth, - endPx: activeRegionEndBeat * beatWidth, - }; -} - -function clampScrollLeftToContainer(container: HTMLDivElement, scrollLeft: number): number { - return Math.max(0, Math.min(scrollLeft, container.scrollWidth - container.clientWidth)); -} - -function getCenteredScrollLeft({ - pixelPosition, - visibleWidth, - scopeStartPx, - scopeEndPx, - container, -}: { - pixelPosition: number; - visibleWidth: number; - scopeStartPx: number; - scopeEndPx: number; - container: HTMLDivElement; -}): number { - const unclamped = pixelPosition - visibleWidth / 2; - const maxScopeScrollLeft = Math.max(scopeStartPx, scopeEndPx - visibleWidth); - const clampedToScope = Math.max(scopeStartPx, Math.min(unclamped, maxScopeScrollLeft)); - return clampScrollLeftToContainer(container, clampedToScope); -} - -function getRegionEndAlignedScrollLeft({ - visibleWidth, - scopeEndPx, - container, -}: { - visibleWidth: number; - scopeEndPx: number; - container: HTMLDivElement; -}): number { - return clampScrollLeftToContainer(container, Math.max(0, scopeEndPx - visibleWidth)); -} - -export function getScrollLeftForViewportRequest({ - request, - container, - sheetMeasureMetrics, - activeRegionStartBeat, - activeRegionEndBeat, - songEndBeat, -}: ScrollLeftForViewportRequestOptions): number { - const { visibleWidth } = getHorizontalViewportMetrics( - container, - request.destinationSheetMusicViewEnabled - ); - const pixelPosition = getPixelForAbsoluteBeat( - request.anchorBeat, - request.destinationSheetMusicViewEnabled, - request.destinationSheetMusicTrackScopeEnabled, - sheetMeasureMetrics, - activeRegionStartBeat - ); - const { startPx, endPx } = getScopeBoundsInPixels( - request, - sheetMeasureMetrics, - activeRegionStartBeat, - activeRegionEndBeat, - songEndBeat - ); - - if (request.alignment === 'region-start') { - return clampScrollLeftToContainer(container, startPx); - } - - if (request.alignment === 'region-end') { - return getRegionEndAlignedScrollLeft({ - visibleWidth, - scopeEndPx: endPx, - container, - }); - } - - return getCenteredScrollLeft({ - pixelPosition, - visibleWidth, - scopeStartPx: startPx, - scopeEndPx: endPx, - container, - }); -} - -export function getRegionStartScrollLeft(startBeat: number): number { - const beatWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') - ) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH; - - return Math.max(0, startBeat * beatWidth); -} diff --git a/src/components/piano-roll/PianoRollAutomationLane.tsx b/src/components/piano-roll/PianoRollAutomationLane.tsx index 32b4427..44e1eda 100644 --- a/src/components/piano-roll/PianoRollAutomationLane.tsx +++ b/src/components/piano-roll/PianoRollAutomationLane.tsx @@ -613,7 +613,7 @@ const PianoRollAutomationLane: React.FC = ({ return; } - let nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set(); + const nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set(); renderedPointsSorted.forEach(point => { const isIntersecting = ( diff --git a/src/components/piano-roll/SheetMusicView.test.tsx b/src/components/piano-roll/SheetMusicView.test.tsx index eda0754..dc59f04 100644 --- a/src/components/piano-roll/SheetMusicView.test.tsx +++ b/src/components/piano-roll/SheetMusicView.test.tsx @@ -74,8 +74,8 @@ vi.mock('vexflow', () => { return this; } - addKeySignature() { - vexflowMocks.addKeySignatureMock(...arguments); + addKeySignature(...args: unknown[]) { + vexflowMocks.addKeySignatureMock(...args); return this; } diff --git a/src/components/piano-roll/pianoRollViewport.ts b/src/components/piano-roll/pianoRollViewport.ts new file mode 100644 index 0000000..7fba92b --- /dev/null +++ b/src/components/piano-roll/pianoRollViewport.ts @@ -0,0 +1,264 @@ +import { TOOLBAR_CONSTANTS } from '../../constants'; +import type { SheetMeasureMetric } from './sheetNotationTypes'; +import { getSheetPlayheadPixel } from './sheetNotation'; + +export type RegionPlayheadRelation = 'before' | 'inside' | 'after'; +type ViewportSwitchAlignment = 'center' | 'region-start' | 'region-end'; +type ViewportClampScope = 'region' | 'track'; + +export interface PendingModeSwitchRequest { + sourceSheetMusicViewEnabled: boolean; + destinationSheetMusicViewEnabled: boolean; + destinationSheetMusicTrackScopeEnabled: boolean; + alignment: ViewportSwitchAlignment; + anchorBeat: number; + clampScope: ViewportClampScope; +} + +export interface ModeSwitchRequestOptions { + playheadBeat: number; + regionStartBeat: number; + regionEndBeat: number; + sourceSheetMusicViewEnabled: boolean; + destinationSheetMusicViewEnabled: boolean; + destinationSheetMusicTrackScopeEnabled: boolean; +} + +export interface ScrollLeftForViewportRequestOptions { + request: PendingModeSwitchRequest; + container: HTMLDivElement; + sheetMeasureMetrics: SheetMeasureMetric[]; + activeRegionStartBeat: number; + activeRegionEndBeat: number; + songEndBeat: number; +} + +export function getRegionPlayheadRelation( + playheadBeat: number, + regionStartBeat: number, + regionEndBeat: number +): RegionPlayheadRelation { + if (playheadBeat < regionStartBeat) { + return 'before'; + } + + if (playheadBeat > regionEndBeat) { + return 'after'; + } + + return 'inside'; +} + +export function createPendingModeSwitchRequest({ + playheadBeat, + regionStartBeat, + regionEndBeat, + sourceSheetMusicViewEnabled, + destinationSheetMusicViewEnabled, + destinationSheetMusicTrackScopeEnabled, +}: ModeSwitchRequestOptions): PendingModeSwitchRequest { + const relation = getRegionPlayheadRelation(playheadBeat, regionStartBeat, regionEndBeat); + const enteringTrackScopeSheet = ( + !sourceSheetMusicViewEnabled && + destinationSheetMusicViewEnabled && + destinationSheetMusicTrackScopeEnabled + ); + + if (relation === 'inside') { + return { + sourceSheetMusicViewEnabled, + destinationSheetMusicViewEnabled, + destinationSheetMusicTrackScopeEnabled, + alignment: 'center', + anchorBeat: playheadBeat, + clampScope: destinationSheetMusicTrackScopeEnabled ? 'track' : 'region', + }; + } + + if (enteringTrackScopeSheet) { + return { + sourceSheetMusicViewEnabled, + destinationSheetMusicViewEnabled, + destinationSheetMusicTrackScopeEnabled, + alignment: 'center', + anchorBeat: playheadBeat, + clampScope: 'track', + }; + } + + return { + sourceSheetMusicViewEnabled, + destinationSheetMusicViewEnabled, + destinationSheetMusicTrackScopeEnabled, + alignment: relation === 'before' ? 'region-start' : 'region-end', + anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat, + clampScope: 'region', + }; +} + +function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): { + visibleWidth: number; + keysWidth: number; +} { + const keysWidth = sheetMusicViewEnabled + ? 0 + : (parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60); + + return { + visibleWidth: Math.max(0, container.clientWidth - keysWidth), + keysWidth, + }; +} + +function getPixelForAbsoluteBeat( + beat: number, + sheetMusicViewEnabled: boolean, + sheetMusicTrackScopeEnabled: boolean, + sheetMeasureMetrics: SheetMeasureMetric[], + activeRegionStartBeat: number +): number { + if (sheetMusicViewEnabled) { + return getSheetPlayheadPixel( + sheetMusicTrackScopeEnabled + ? Math.max(0, beat) + : Math.max(0, beat - activeRegionStartBeat), + sheetMeasureMetrics + ); + } + + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + return beat * beatWidth; +} + +function getScopeBoundsInPixels( + request: PendingModeSwitchRequest, + sheetMeasureMetrics: SheetMeasureMetric[], + activeRegionStartBeat: number, + activeRegionEndBeat: number, + songEndBeat: number +): { startPx: number; endPx: number } { + if (request.destinationSheetMusicViewEnabled) { + if (request.clampScope === 'track') { + return { + startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics), + endPx: getSheetPlayheadPixel(songEndBeat, sheetMeasureMetrics), + }; + } + + return { + startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics), + endPx: getSheetPlayheadPixel(activeRegionEndBeat - activeRegionStartBeat, sheetMeasureMetrics), + }; + } + + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + + if (request.clampScope === 'track') { + return { + startPx: 0, + endPx: songEndBeat * beatWidth, + }; + } + + return { + startPx: activeRegionStartBeat * beatWidth, + endPx: activeRegionEndBeat * beatWidth, + }; +} + +function clampScrollLeftToContainer(container: HTMLDivElement, scrollLeft: number): number { + return Math.max(0, Math.min(scrollLeft, container.scrollWidth - container.clientWidth)); +} + +function getCenteredScrollLeft({ + pixelPosition, + visibleWidth, + scopeStartPx, + scopeEndPx, + container, +}: { + pixelPosition: number; + visibleWidth: number; + scopeStartPx: number; + scopeEndPx: number; + container: HTMLDivElement; +}): number { + const unclamped = pixelPosition - visibleWidth / 2; + const maxScopeScrollLeft = Math.max(scopeStartPx, scopeEndPx - visibleWidth); + const clampedToScope = Math.max(scopeStartPx, Math.min(unclamped, maxScopeScrollLeft)); + return clampScrollLeftToContainer(container, clampedToScope); +} + +function getRegionEndAlignedScrollLeft({ + visibleWidth, + scopeEndPx, + container, +}: { + visibleWidth: number; + scopeEndPx: number; + container: HTMLDivElement; +}): number { + return clampScrollLeftToContainer(container, Math.max(0, scopeEndPx - visibleWidth)); +} + +export function getScrollLeftForViewportRequest({ + request, + container, + sheetMeasureMetrics, + activeRegionStartBeat, + activeRegionEndBeat, + songEndBeat, +}: ScrollLeftForViewportRequestOptions): number { + const { visibleWidth } = getHorizontalViewportMetrics( + container, + request.destinationSheetMusicViewEnabled + ); + const pixelPosition = getPixelForAbsoluteBeat( + request.anchorBeat, + request.destinationSheetMusicViewEnabled, + request.destinationSheetMusicTrackScopeEnabled, + sheetMeasureMetrics, + activeRegionStartBeat + ); + const { startPx, endPx } = getScopeBoundsInPixels( + request, + sheetMeasureMetrics, + activeRegionStartBeat, + activeRegionEndBeat, + songEndBeat + ); + + if (request.alignment === 'region-start') { + return clampScrollLeftToContainer(container, startPx); + } + + if (request.alignment === 'region-end') { + return getRegionEndAlignedScrollLeft({ + visibleWidth, + scopeEndPx: endPx, + container, + }); + } + + return getCenteredScrollLeft({ + pixelPosition, + visibleWidth, + scopeStartPx: startPx, + scopeEndPx: endPx, + container, + }); +} + +export function getRegionStartScrollLeft(startBeat: number): number { + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH; + + return Math.max(0, startBeat * beatWidth); +} diff --git a/src/components/track/TrackAutomationLane.test.ts b/src/components/track/TrackAutomationLane.test.ts index e73ba0b..95db842 100644 --- a/src/components/track/TrackAutomationLane.test.ts +++ b/src/components/track/TrackAutomationLane.test.ts @@ -15,7 +15,7 @@ vi.mock('../../stores/projectStore', () => ({ }), })); -const { __trackAutomationTestUtils } = await import('./TrackAutomationLane'); +const __trackAutomationTestUtils = await import('./trackAutomationLaneUtils'); describe('TrackAutomationLane mapping', () => { it('maps volume 0.0dB to the visual midpoint and back', () => { diff --git a/src/components/track/TrackAutomationLane.tsx b/src/components/track/TrackAutomationLane.tsx index fff95da..e4c205f 100644 --- a/src/components/track/TrackAutomationLane.tsx +++ b/src/components/track/TrackAutomationLane.tsx @@ -11,6 +11,14 @@ import { PIANO_ROLL_CONSTANTS } from '../../constants'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { isModifierKeyPressed } from '../../util/osUtil'; import { KGMainContentState } from '../../core/state/KGMainContentState'; +import { + formatAutomationValue, + getLaneMetrics, + volumeToY, + yToVolume, + panToY, + yToPan, +} from './trackAutomationLaneUtils'; interface TrackAutomationLaneProps { track: KGTrack; @@ -32,7 +40,6 @@ interface PreviewPoint { value: number; } -const LANE_PADDING_Y = 12; const POINT_RADIUS = 5; const SELECTED_POINT_COLOR = '#FFFFFF'; const TRACK_AUTOMATION_COLORS: Record = { @@ -40,105 +47,6 @@ const TRACK_AUTOMATION_COLORS: Record = { pan: '#90EE90', }; -function formatAutomationValue(automationType: TrackAutomationType, value: number): string { - if (automationType === 'volume') { - if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) { - return '−∞'; - } - - return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`; - } - - 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, @@ -467,7 +375,7 @@ const TrackAutomationLane: React.FC = ({ return; } - let nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set(); + const nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set(); renderedPoints.forEach(point => { const isIntersecting = ( point.x + POINT_RADIUS >= left && diff --git a/src/components/track/trackAutomationLaneUtils.ts b/src/components/track/trackAutomationLaneUtils.ts new file mode 100644 index 0000000..8dd3b2e --- /dev/null +++ b/src/components/track/trackAutomationLaneUtils.ts @@ -0,0 +1,93 @@ +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; +import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint'; + +const LANE_PADDING_Y = 12; + +export function formatAutomationValue(automationType: TrackAutomationType, value: number): string { + if (automationType === 'volume') { + if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) { + return '−∞'; + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`; + } + + const logicPanValue = value <= 0 + ? Math.round(value * 64) + : Math.round(value * 63); + return `${logicPanValue >= 0 ? '+' : ''}${logicPanValue}`; +} + +export 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 }; +} + +export 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; +} + +export 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) + ); +} + +export 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); +} + +export function yToPan(y: number, laneHeight: number): number { + const { top, middle, bottom } = getLaneMetrics(laneHeight); + const clampedY = Math.min(bottom, Math.max(top, y)); + + 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)); +} diff --git a/src/util/localSeparatorGpuDsp.ts b/src/util/localSeparatorGpuDsp.ts index c3f70dc..41cb0e4 100644 --- a/src/util/localSeparatorGpuDsp.ts +++ b/src/util/localSeparatorGpuDsp.ts @@ -10,12 +10,73 @@ function localSeparatorLog(message: string, payload?: unknown): void { console.log(`[localSeparator] ${message}`, payload); } -type GPUDeviceLike = any; -type GPUBufferLike = any; -type GPUComputePipelineLike = any; +interface GPUBufferLike { + destroy(): void; + mapAsync(mode: number): Promise; + getMappedRange(): ArrayBuffer; + unmap(): void; +} -declare const GPUBufferUsage: any; -declare const GPUMapMode: any; +interface GPUComputePassEncoderLike { + setPipeline(pipeline: GPUComputePipelineLike): void; + setBindGroup(index: number, bindGroup: unknown): void; + dispatchWorkgroups(x: number): void; + end(): void; +} + +interface GPUCommandEncoderLike { + copyBufferToBuffer(source: GPUBufferLike, sourceOffset: number, destination: GPUBufferLike, destinationOffset: number, size: number): void; + beginComputePass(): GPUComputePassEncoderLike; + finish(): unknown; +} + +interface GPUComputePipelineLike { + getBindGroupLayout(index: number): unknown; +} + +interface GPUDeviceLike { + queue: { + submit(commands: unknown[]): void; + writeBuffer(buffer: GPUBufferLike, bufferOffset: number, data: BufferSource): void; + }; + createBuffer(descriptor: { size: number; usage: number }): GPUBufferLike; + createCommandEncoder(): GPUCommandEncoderLike; + createBindGroup(descriptor: { layout: unknown; entries: Array<{ binding: number; resource: { buffer: GPUBufferLike } }> }): unknown; + createComputePipeline(descriptor: { + layout: 'auto'; + compute: { + module: unknown; + entryPoint: string; + }; + }): GPUComputePipelineLike; + createShaderModule(descriptor: { code: string }): unknown; +} + +interface GPUAdapterLike { + features?: { + values?(): IterableIterator; + }; + limits?: unknown; + info?: unknown; + requestDevice(): Promise; +} + +interface NavigatorWithGpu { + gpu?: { + requestAdapter(options: { powerPreference: string }): Promise; + }; +} + +declare const GPUBufferUsage: { + COPY_DST: number; + MAP_READ: number; + STORAGE: number; + COPY_SRC: number; + UNIFORM: number; +}; +declare const GPUMapMode: { + READ: number; +}; const FRAMING_SHADER = ` struct Params { @@ -85,7 +146,7 @@ export class LocalSeparatorGpuDsp { } localSeparatorLog('Requesting WebGPU adapter for GPU DSP.'); - const adapter = await (navigator as { gpu?: { requestAdapter: (options: { powerPreference: string }) => Promise } }).gpu?.requestAdapter({ + const adapter = await (navigator as NavigatorWithGpu).gpu?.requestAdapter({ powerPreference: 'high-performance', }); if (!adapter) {