feat: switch track volume to dB scale with Logic Pro-style fader (−∞dB ~ +12dB)

This commit is contained in:
Xiaohan-Tian
2026-05-01 11:38:35 -07:00
parent 77885c8e2e
commit 95ca3394a8
11 changed files with 265 additions and 59 deletions
+1 -1
View File
@@ -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, {
+4 -11
View File
@@ -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);
}
+4 -11
View File
@@ -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);
}
@@ -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}`);
@@ -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 01 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);
});
});
+23
View File
@@ -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 01 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;
}
+33 -27
View File
@@ -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', () => {
+4 -1
View File
@@ -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 {