From 95ca3394a8273fb92bff5b003c11eb5309f3b0f0 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 1 May 2026 11:38:35 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20switch=20track=20volume=20to=20dB=20sca?= =?UTF-8?q?le=20with=20Logic=20Pro-style=20fader=20(=E2=88=92=E2=88=9EdB?= =?UTF-8?q?=20~=20+12dB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/track/Track.css | 30 +++++- src/components/track/TrackInfoItem.tsx | 101 ++++++++++++++++-- src/constants/coreConstants.ts | 4 +- src/core/KGProject.ts | 2 +- src/core/audio-interface/KGAudioBus.ts | 15 +-- src/core/audio-interface/KGAudioPlayerBus.ts | 15 +-- .../project-upgrader/KGProjectUpgrader.ts | 5 + src/core/project-upgrader/upgradeToV7.test.ts | 64 +++++++++++ src/core/project-upgrader/upgradeToV7.ts | 23 ++++ src/core/track/KGMidiTrack.test.ts | 60 ++++++----- src/core/track/KGTrack.ts | 5 +- 11 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 src/core/project-upgrader/upgradeToV7.test.ts create mode 100644 src/core/project-upgrader/upgradeToV7.ts diff --git a/src/components/track/Track.css b/src/components/track/Track.css index c78f7d4..838f481 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -51,7 +51,7 @@ .track-info { width: 200px; height: 120px; - padding: 15px; + padding: 15px 5px 15px 15px; background-color: #2d2d2d; display: flex; flex-direction: column; @@ -155,6 +155,34 @@ gap: 6px; } +.volume-slider .volume-label { + flex-shrink: 0; + font-size: 9px; + color: #aaa; + min-width: 22px; + text-align: left; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.volume-slider .volume-label-clickable { + cursor: pointer; +} + +.volume-slider .volume-label-clickable:hover { + color: #ddd; +} + +.volume-slider .volume-label-input { + background: #2a2a2a; + border: 1px solid #555; + border-radius: 3px; + color: #e0e0e0; + padding: 0 2px; + width: 36px; + outline: none; +} + .volume-slider input[type="range"] { flex: 1; min-width: 0; diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index b6c7b96..f03b95f 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -13,6 +13,35 @@ import { DEBUG_MODE } from '../../constants/uiConstants'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider'; + +const UNITY_POS = 750; +const SLIDER_MAX = 1000; + +function sliderToDb(pos: number): number { + const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB; + if (pos <= 0) return MIN; + if (pos >= SLIDER_MAX) return MAX; + if (pos <= UNITY_POS) { + const t = pos / UNITY_POS; + return MIN * (1 - t * t); + } + return MAX * (pos - UNITY_POS) / (SLIDER_MAX - UNITY_POS); +} + +function dbToSlider(db: number): number { + const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB; + if (db <= MIN) return 0; + if (db >= MAX) return SLIDER_MAX; + if (db <= 0) return Math.round(Math.sqrt(1 - db / MIN) * UNITY_POS); + return Math.round(UNITY_POS + (db / MAX) * (SLIDER_MAX - UNITY_POS)); +} + +function formatDb(db: number): string { + if (db <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) return '−∞'; + return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`; +} interface TrackInfoItemProps { track: KGTrack; index: number; @@ -56,6 +85,9 @@ const TrackInfoItem: React.FC = ({ const settingsDropdownRef = useRef(null); const suppressDragRef = useRef(false); const [volume, setVolume] = useState(track.getVolume()); + const [isEditingVolume, setIsEditingVolume] = useState(false); + const [volumeInputText, setVolumeInputText] = useState(''); + const volumeInputRef = useRef(null); // Local flag to track slider interaction; not used for rendering const isAdjustingVolumeRef = useRef(false); const [muted, setMuted] = useState(false); @@ -129,11 +161,10 @@ const TrackInfoItem: React.FC = ({ const handleVolumeChange = (e: React.ChangeEvent) => { e.stopPropagation(); - const next = Number(e.target.value) / 100; + const next = sliderToDb(Number(e.target.value)); isAdjustingVolumeRef.current = true; setVolume(next); try { - // Live preview: update audio only while sliding KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next); } catch (err) { console.error('Failed to update live volume:', err); @@ -167,6 +198,43 @@ const TrackInfoItem: React.FC = ({ } }; + const handleVolumeLabelClick = (e: React.MouseEvent) => { + e.stopPropagation(); + const displayText = volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB + ? '-60' + : volume.toFixed(1); + setVolumeInputText(displayText); + setIsEditingVolume(true); + setTimeout(() => { + volumeInputRef.current?.select(); + }, 0); + }; + + const commitVolumeLabelInput = () => { + setIsEditingVolume(false); + const parsed = parseFloat(volumeInputText); + if (isNaN(parsed)) return; + const clamped = Math.max( + AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, + Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, parsed) + ); + setVolume(clamped); + try { + useProjectStore.getState().updateTrackProperties(track.getId(), { volume: clamped }); + } catch (err) { + console.error('Failed to set volume from label input:', err); + } + }; + + const handleVolumeLabelKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitVolumeLabelInput(); + } else if (e.key === 'Escape') { + setIsEditingVolume(false); + } + }; + const handleToggleMute = (e: React.MouseEvent) => { e.stopPropagation(); const next = !muted; @@ -302,8 +370,9 @@ const TrackInfoItem: React.FC = ({ { e.stopPropagation(); isAdjustingVolumeRef.current = true; }} onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }} @@ -314,12 +383,32 @@ const TrackInfoItem: React.FC = ({ /> + {isEditingVolume ? ( + setVolumeInputText(e.target.value)} + onKeyDown={handleVolumeLabelKeyDown} + onBlur={commitVolumeLabelInput} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {formatDb(volume)} + + )} diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts index 9c42339..f675279 100644 --- a/src/constants/coreConstants.ts +++ b/src/constants/coreConstants.ts @@ -63,7 +63,9 @@ export const KEY_SIGNATURE_MAP = { export const AUDIO_INTERFACE_CONSTANTS = { DEFAULT_MASTER_VOLUME: 0.8, - DEFAULT_TRACK_VOLUME: 0.8, + DEFAULT_TRACK_VOLUME: 0, // 0 dB (unity gain) + MIN_TRACK_VOLUME_DB: -60, // practical floor; displayed as −∞ + MAX_TRACK_VOLUME_DB: 12, }; export const SAMPLER_CONSTANTS = { diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 83f8151..28647d7 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -52,7 +52,7 @@ export class KGProject { @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 7; @Expose() @Type(() => KGTrack, { diff --git a/src/core/audio-interface/KGAudioBus.ts b/src/core/audio-interface/KGAudioBus.ts index e1a51db..af6679e 100644 --- a/src/core/audio-interface/KGAudioBus.ts +++ b/src/core/audio-interface/KGAudioBus.ts @@ -283,9 +283,8 @@ export class KGAudioBus { */ private updateSamplerVolume(): void { try { - const effectiveVolume = this.muted ? 0 : this.volume; - const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity; - this.sampler.volume.value = volumeDb; + const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + this.sampler.volume.value = isSilent ? -Infinity : this.volume; } catch (error) { console.error(`Error updating volume for ${this.instrument}:`, error); } @@ -297,14 +296,8 @@ export class KGAudioBus { */ public applyEffectiveVolume(hasSoloedTracks: boolean): void { try { - let effectiveVolume = this.volume; - if (this.muted) { - effectiveVolume = 0; - } else if (hasSoloedTracks && !this.solo) { - effectiveVolume = 0; - } - const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity; - this.sampler.volume.value = volumeDb; + const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + this.sampler.volume.value = isSilent ? -Infinity : this.volume; } catch (error) { console.error(`Error applying effective volume for ${this.instrument}:`, error); } diff --git a/src/core/audio-interface/KGAudioPlayerBus.ts b/src/core/audio-interface/KGAudioPlayerBus.ts index f4f97ca..a39376d 100644 --- a/src/core/audio-interface/KGAudioPlayerBus.ts +++ b/src/core/audio-interface/KGAudioPlayerBus.ts @@ -200,14 +200,8 @@ export class KGAudioPlayerBus { */ public applyEffectiveVolume(hasSoloedTracks: boolean): void { try { - let effectiveVolume = this.volume; - if (this.muted) { - effectiveVolume = 0; - } else if (hasSoloedTracks && !this.solo) { - effectiveVolume = 0; - } - const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity; - this.gainNode.gain.value = Math.pow(10, volumeDb / 20); + const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20); } catch (error) { console.error('Error applying effective volume for audio player bus:', error); } @@ -266,9 +260,8 @@ export class KGAudioPlayerBus { private updateGainVolume(): void { try { - const effectiveVolume = this.muted ? 0 : this.volume; - // Convert linear volume to gain value - this.gainNode.gain.value = effectiveVolume; + const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20); } catch (error) { console.error('Error updating gain volume:', error); } diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index 1ab940d..92100be 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -5,6 +5,7 @@ import { upgradeToV3 } from './upgradeToV3'; import { upgradeToV4 } from './upgradeToV4'; import { upgradeToV5 } from './upgradeToV5'; import { upgradeToV6 } from './upgradeToV6'; +import { upgradeToV7 } from './upgradeToV7'; /** * Upgrade the given project to the latest structure version, one version at a time. @@ -48,6 +49,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject { workingProject = upgradeToV6(workingProject); break; } + case 7: { + workingProject = upgradeToV7(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV7.test.ts b/src/core/project-upgrader/upgradeToV7.test.ts new file mode 100644 index 0000000..6df72bd --- /dev/null +++ b/src/core/project-upgrader/upgradeToV7.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { KGProject } from '../KGProject'; +import { KGMidiTrack } from '../track/KGMidiTrack'; +import { upgradeToV7 } from './upgradeToV7'; +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; + +function makeProject(volumes: number[]): KGProject { + const tracks = volumes.map((v, i) => { + const track = new KGMidiTrack(`Track ${i}`, i); + // Bypass setVolume clamping to simulate old 0–1 linear values stored in JSON + (track as unknown as { volume: number }).volume = v; + return track; + }); + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, undefined, tracks, 6); + return project; +} + +describe('upgradeToV7', () => { + it('converts 1.0 linear to 0 dB', () => { + const project = makeProject([1.0]); + upgradeToV7(project); + expect(project.getTracks()[0].getVolume()).toBeCloseTo(0, 5); + }); + + it('converts 0.8 linear to ~−1.94 dB', () => { + const project = makeProject([0.8]); + upgradeToV7(project); + expect(project.getTracks()[0].getVolume()).toBeCloseTo(20 * Math.log10(0.8), 3); + }); + + it('converts 0.5 linear to ~−6.02 dB', () => { + const project = makeProject([0.5]); + upgradeToV7(project); + expect(project.getTracks()[0].getVolume()).toBeCloseTo(-6.021, 2); + }); + + it('converts 0.0 linear to MIN_TRACK_VOLUME_DB', () => { + const project = makeProject([0.0]); + upgradeToV7(project); + expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB); + }); + + it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => { + // linear > 1.0 would give positive dB; cap at +6 + const project = makeProject([2.0]); + upgradeToV7(project); + expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB); + }); + + it('bumps project structure version to 7', () => { + const project = makeProject([1.0]); + upgradeToV7(project); + expect(project.getProjectStructureVersion()).toBe(7); + }); + + it('handles multiple tracks independently', () => { + const project = makeProject([1.0, 0.5, 0.0]); + upgradeToV7(project); + const tracks = project.getTracks(); + expect(tracks[0].getVolume()).toBeCloseTo(0, 5); + expect(tracks[1].getVolume()).toBeCloseTo(-6.021, 2); + expect(tracks[2].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV7.ts b/src/core/project-upgrader/upgradeToV7.ts new file mode 100644 index 0000000..9ccab3f --- /dev/null +++ b/src/core/project-upgrader/upgradeToV7.ts @@ -0,0 +1,23 @@ +import { KGProject } from '../KGProject'; +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; + +export function upgradeToV7(project: KGProject): KGProject { + try { + // Convert track volumes from 0–1 linear scale to dB + for (const track of project.getTracks()) { + const linear = track.getVolume(); + let db: number; + if (linear <= 0) { + db = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; + } else { + db = 20 * Math.log10(linear); + db = Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, + Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, db)); + } + track.setVolume(db); + } + } finally { + project.setProjectStructureVersion(7); + } + return project; +} diff --git a/src/core/track/KGMidiTrack.test.ts b/src/core/track/KGMidiTrack.test.ts index be30ef1..e7066bd 100644 --- a/src/core/track/KGMidiTrack.test.ts +++ b/src/core/track/KGMidiTrack.test.ts @@ -19,18 +19,18 @@ describe('KGMidiTrack', () => { expect(defaultTrack.getId()).toBe(0) expect(defaultTrack.getType()).toBe(TrackType.MIDI) expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano') - expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME + expect(defaultTrack.getVolume()).toBe(0) // DEFAULT_TRACK_VOLUME (0 dB) expect(defaultTrack.getRegions()).toEqual([]) }) it('should create track with custom parameters', () => { - const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6) + const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4) expect(customTrack.getName()).toBe('My Piano Track') expect(customTrack.getId()).toBe(5) expect(customTrack.getType()).toBe(TrackType.MIDI) expect(customTrack.getInstrument()).toBe('electric_piano_1') - expect(customTrack.getVolume()).toBe(0.6) + expect(customTrack.getVolume()).toBe(-4) }) it('should set correct type identifier', () => { @@ -193,33 +193,33 @@ describe('KGMidiTrack', () => { describe('inheritance from KGTrack', () => { it('should inherit all base track properties', () => { - const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9) + const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1) expect(customTrack.getName()).toBe('Test Track') expect(customTrack.getId()).toBe(42) expect(customTrack.getType()).toBe(TrackType.MIDI) - expect(customTrack.getVolume()).toBe(0.9) + expect(customTrack.getVolume()).toBe(-1) }) it('should inherit base track setters', () => { track.setName('Updated Track') expect(track.getName()).toBe('Updated Track') - track.setVolume(0.5) - expect(track.getVolume()).toBe(0.5) + track.setVolume(-6) + expect(track.getVolume()).toBe(-6) track.setTrackIndex(3) expect(track.getTrackIndex()).toBe(3) }) it('should inherit volume controls', () => { - expect(track.getVolume()).toBe(0.8) // Default volume + expect(track.getVolume()).toBe(0) // Default volume (0 dB) - track.setVolume(0.5) - expect(track.getVolume()).toBe(0.5) + track.setVolume(-6) + expect(track.getVolume()).toBe(-6) - track.setVolume(1.0) - expect(track.getVolume()).toBe(1.0) + track.setVolume(0) + expect(track.getVolume()).toBe(0) }) }) @@ -275,8 +275,8 @@ describe('KGMidiTrack', () => { // Setup initial state track.setName('Piano Track') track.setInstrument('acoustic_grand_piano') - track.setVolume(0.7) - + track.setVolume(-3) + const regions = [ createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }), createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' }) @@ -286,21 +286,21 @@ describe('KGMidiTrack', () => { // Verify initial state expect(track.getName()).toBe('Piano Track') expect(track.getInstrument()).toBe('acoustic_grand_piano') - expect(track.getVolume()).toBe(0.7) + expect(track.getVolume()).toBe(-3) expect(track.getRegions()).toHaveLength(2) // Modify state track.setInstrument('electric_piano_1') - track.addRegion(createMockMidiRegion({ - id: 'r3', - trackId: '0', - name: 'Chorus' + track.addRegion(createMockMidiRegion({ + id: 'r3', + trackId: '0', + name: 'Chorus' })) // Verify modified state expect(track.getName()).toBe('Piano Track') expect(track.getInstrument()).toBe('electric_piano_1') - expect(track.getVolume()).toBe(0.7) + expect(track.getVolume()).toBe(-3) expect(track.getRegions()).toHaveLength(3) }) @@ -337,15 +337,21 @@ describe('KGMidiTrack', () => { }) it('should handle volume boundaries', () => { - track.setVolume(0.0) - expect(track.getVolume()).toBe(0.0) + track.setVolume(-60) + expect(track.getVolume()).toBe(-60) - track.setVolume(1.0) - expect(track.getVolume()).toBe(1.0) + track.setVolume(0) + expect(track.getVolume()).toBe(0) - // Volume outside normal range (should still work) - track.setVolume(1.5) - expect(track.getVolume()).toBe(1.5) + track.setVolume(6) + expect(track.getVolume()).toBe(6) + + // Volume outside valid dB range is clamped + track.setVolume(10) + expect(track.getVolume()).toBe(6) + + track.setVolume(-100) + expect(track.getVolume()).toBe(-60) }) it('should handle large number of regions', () => { diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index 11bbf3d..08c8495 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -104,7 +104,10 @@ export class KGTrack { } public setVolume(volume: number): void { - this.volume = volume; + this.volume = Math.max( + AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, + Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, volume) + ); } public setRegions(regions: KGRegion[]): void {