feat: implemented track level automation
This commit is contained in:
@@ -52,7 +52,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 9;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -30,9 +30,13 @@ export class KGAudioBus {
|
||||
private sampler: Tone.Sampler;
|
||||
private audioBuffers: Tone.ToneAudioBuffers;
|
||||
private instrument: InstrumentType;
|
||||
private panner: Tone.Panner;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private automationVolume: number | null = null;
|
||||
private pan: number;
|
||||
private automationPan: number | null = null;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
private liveMidiPitchBend: number = 0;
|
||||
@@ -51,19 +55,24 @@ export class KGAudioBus {
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
instrument: InstrumentType,
|
||||
panner: Tone.Panner,
|
||||
volume: number,
|
||||
pan: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = instrument;
|
||||
this.panner = panner;
|
||||
this.volume = volume;
|
||||
this.pan = pan;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
// Set initial volume on the sampler
|
||||
this.updateSamplerVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
@@ -75,6 +84,7 @@ export class KGAudioBus {
|
||||
public static async create(
|
||||
instrument: InstrumentType = 'acoustic_grand_piano',
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
pan: number = 0,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioBus> {
|
||||
@@ -90,7 +100,9 @@ export class KGAudioBus {
|
||||
]);
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, volume, muted, solo);
|
||||
const panner = new Tone.Panner(pan);
|
||||
sampler.connect(panner);
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, panner, volume, pan, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
@@ -315,6 +327,11 @@ export class KGAudioBus {
|
||||
console.log(`Set ${this.instrument} volume to ${volume}`);
|
||||
}
|
||||
|
||||
public setAutomationVolume(volume: number | null): void {
|
||||
this.automationVolume = volume;
|
||||
this.updateSamplerVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume
|
||||
*/
|
||||
@@ -322,6 +339,31 @@ export class KGAudioBus {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public setPan(pan: number): void {
|
||||
this.pan = Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public setAutomationPan(pan: number | null): void {
|
||||
this.automationPan = pan === null ? null : Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public scheduleAutomationPan(pan: number, time: number): void {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
this.automationPan = clampedPan;
|
||||
if (typeof this.panner.pan.setValueAtTime === 'function') {
|
||||
this.panner.pan.setValueAtTime(clampedPan, time);
|
||||
return;
|
||||
}
|
||||
|
||||
this.panner.pan.value = clampedPan;
|
||||
}
|
||||
|
||||
public getPan(): number {
|
||||
return this.pan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mute state for this audio bus
|
||||
*/
|
||||
@@ -382,9 +424,11 @@ export class KGAudioBus {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = newInstrument;
|
||||
this.sampler.connect(this.panner);
|
||||
|
||||
// Restore volume settings
|
||||
this.updateSamplerVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`Instrument changed successfully to ${newInstrument}`);
|
||||
} catch (error) {
|
||||
@@ -400,7 +444,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.sampler.connect(destination);
|
||||
this.panner.connect(destination);
|
||||
console.log(`Connected ${this.instrument} to audio destination`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
||||
@@ -412,7 +456,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.sampler.disconnect();
|
||||
this.panner.disconnect();
|
||||
console.log(`Disconnected ${this.instrument} from all destinations`);
|
||||
} catch (error) {
|
||||
console.error(`Error disconnecting ${this.instrument}:`, error);
|
||||
@@ -424,7 +468,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public toDestination(): void {
|
||||
try {
|
||||
this.sampler.toDestination();
|
||||
this.panner.toDestination();
|
||||
console.log(`Connected ${this.instrument} to main output`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
||||
@@ -440,6 +484,7 @@ export class KGAudioBus {
|
||||
try {
|
||||
this.releaseAll();
|
||||
this.sampler.dispose();
|
||||
this.panner.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
||||
@@ -453,21 +498,36 @@ export class KGAudioBus {
|
||||
*/
|
||||
private updateSamplerVolume(): void {
|
||||
try {
|
||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume;
|
||||
} catch (error) {
|
||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePanValue(): void {
|
||||
try {
|
||||
const effectivePan = this.automationPan ?? this.pan;
|
||||
if (typeof this.panner.pan.setValueAtTime === 'function') {
|
||||
this.panner.pan.setValueAtTime(effectivePan, Tone.now());
|
||||
} else {
|
||||
this.panner.pan.value = effectivePan;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating pan for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply effective volume considering both mute and solo context
|
||||
* When any track is soloed, only soloed tracks should be audible
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume;
|
||||
} catch (error) {
|
||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||
}
|
||||
@@ -512,12 +572,14 @@ export class KGAudioBus {
|
||||
public getState(): {
|
||||
instrument: InstrumentType;
|
||||
volume: number;
|
||||
pan: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
} {
|
||||
return {
|
||||
instrument: this.instrument,
|
||||
volume: this.volume,
|
||||
pan: this.pan,
|
||||
muted: this.muted,
|
||||
solo: this.solo
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
resolveMidiAutomationValueAtBeat,
|
||||
resolveSustainExtendedEndBeat,
|
||||
} from '../../util/midiAutomationUtil';
|
||||
import {
|
||||
bakeTrackAutomationPointsInWindow,
|
||||
getTrackAutomationDefaultValue,
|
||||
resolveTrackAutomationValueAtBeat,
|
||||
} from '../../util/trackAutomationUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
@@ -230,7 +235,7 @@ export class KGAudioInterface {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume);
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0);
|
||||
|
||||
// Connect to master gain if available, otherwise to destination
|
||||
if (this.masterGain) {
|
||||
@@ -283,7 +288,7 @@ export class KGAudioInterface {
|
||||
|
||||
try {
|
||||
console.log(`Creating audio player bus for track ${trackId}`);
|
||||
const playerBus = await KGAudioPlayerBus.create(volume);
|
||||
const playerBus = await KGAudioPlayerBus.create(volume, 0);
|
||||
|
||||
if (this.masterGain) {
|
||||
playerBus.connect(this.masterGain);
|
||||
@@ -413,6 +418,7 @@ export class KGAudioInterface {
|
||||
this.clearScheduledEvents();
|
||||
this.clearDelayedTransportStart();
|
||||
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||
this.clearTrackAutomationOverrides();
|
||||
|
||||
console.log("Preparing playback");
|
||||
|
||||
@@ -485,7 +491,12 @@ export class KGAudioInterface {
|
||||
project.getTracks().forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
const interpolationIntervalMs = (configManager.get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10;
|
||||
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
|
||||
|
||||
this.applyTrackAutomationAtBeat(track, automationWindowStartBeat);
|
||||
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, project.getBpm());
|
||||
|
||||
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
|
||||
|
||||
@@ -693,7 +704,6 @@ export class KGAudioInterface {
|
||||
}
|
||||
|
||||
// Schedule audio/wav track events
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (playerBus && track.getType() === 'Wave') {
|
||||
track.getRegions().forEach(region => {
|
||||
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||
@@ -868,6 +878,7 @@ export class KGAudioInterface {
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.stopAll();
|
||||
});
|
||||
this.clearTrackAutomationOverrides();
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
@@ -895,6 +906,7 @@ export class KGAudioInterface {
|
||||
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
||||
const duration = this.beatsToToneTime(durationInBeats);
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
@@ -922,6 +934,7 @@ export class KGAudioInterface {
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
@@ -948,6 +961,7 @@ export class KGAudioInterface {
|
||||
|
||||
const normalizedVelocity = velocity / 127;
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
@@ -1166,6 +1180,24 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public setTrackPan(trackId: string, pan: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setPan(pan);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setPan(pan);
|
||||
}
|
||||
if (!audioBus && !playerBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} pan:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track mute state
|
||||
*/
|
||||
@@ -1253,6 +1285,12 @@ export class KGAudioInterface {
|
||||
return audioBus?.getVolume() ?? playerBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
}
|
||||
|
||||
public getTrackPan(trackId: string): number {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
return audioBus?.getPan() ?? playerBus?.getPan() ?? 0;
|
||||
}
|
||||
|
||||
public getTrackMuted(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
@@ -1290,6 +1328,106 @@ export class KGAudioInterface {
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
private applyTrackAutomationAtBeat(track: { getId(): number; getVolumeAutomation(): Array<{ getBeat(): number; getValue(): number }>; getPanAutomation(): Array<{ getBeat(): number; getValue(): number }> }, beat: number): void {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
const volumePoints = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panPoints = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
|
||||
const nextVolume = volumePoints.length > 0
|
||||
? resolveTrackAutomationValueAtBeat(volumePoints, 'volume', beat, getTrackAutomationDefaultValue('volume'))
|
||||
: null;
|
||||
const nextPan = panPoints.length > 0
|
||||
? resolveTrackAutomationValueAtBeat(panPoints, 'pan', beat, getTrackAutomationDefaultValue('pan'))
|
||||
: null;
|
||||
|
||||
if (audioBus) {
|
||||
audioBus.setAutomationVolume(nextVolume);
|
||||
audioBus.setAutomationPan(nextPan);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setAutomationVolume(nextVolume);
|
||||
playerBus.setAutomationPan(nextPan);
|
||||
}
|
||||
this.updateAllEffectiveVolumes();
|
||||
}
|
||||
|
||||
private applyTrackAutomationForCurrentBeat(trackId: string): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(candidate => candidate.getId().toString() === trackId);
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyTrackAutomationAtBeat(track, this.getTransportPosition());
|
||||
}
|
||||
|
||||
private scheduleTrackAutomation(
|
||||
track: { getId(): number; getVolumeAutomation(): Array<{ getBeat(): number; getValue(): number }>; getPanAutomation(): Array<{ getBeat(): number; getValue(): number }> },
|
||||
windowStartBeat: number,
|
||||
windowEndBeat: number,
|
||||
interpolationIntervalMs: number,
|
||||
bpm: number
|
||||
): void {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (!audioBus && !playerBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const volumePoints = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panPoints = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
|
||||
bakeTrackAutomationPointsInWindow(volumePoints, 'volume', windowStartBeat, windowEndBeat, interpolationIntervalMs, bpm)
|
||||
.forEach(({ beat, value }) => {
|
||||
if (beat <= windowStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule(() => {
|
||||
if (audioBus) {
|
||||
audioBus.setAutomationVolume(value);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setAutomationVolume(value);
|
||||
}
|
||||
this.updateAllEffectiveVolumes();
|
||||
}, this.beatsToToneTime(beat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
|
||||
bakeTrackAutomationPointsInWindow(panPoints, 'pan', windowStartBeat, windowEndBeat, interpolationIntervalMs, bpm)
|
||||
.forEach(({ beat, value }) => {
|
||||
if (beat <= windowStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
if (audioBus) {
|
||||
audioBus.scheduleAutomationPan(value, time);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.scheduleAutomationPan(value, time);
|
||||
}
|
||||
}, this.beatsToToneTime(beat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
|
||||
private clearTrackAutomationOverrides(): void {
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.setAutomationVolume(null);
|
||||
audioBus.setAutomationPan(null);
|
||||
});
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.setAutomationVolume(null);
|
||||
playerBus.setAutomationPan(null);
|
||||
});
|
||||
this.updateAllEffectiveVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup audio capture for screen sharing
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
export class KGAudioPlayerBus {
|
||||
// Gain node for volume/mute routing
|
||||
private gainNode: Tone.Gain;
|
||||
private pannerNode: Tone.Panner;
|
||||
|
||||
// Cached audio buffers keyed by audioFileId
|
||||
private audioBuffers: Map<string, Tone.ToneAudioBuffer> = new Map();
|
||||
@@ -19,6 +20,9 @@ export class KGAudioPlayerBus {
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private automationVolume: number | null = null;
|
||||
private pan: number;
|
||||
private automationPan: number | null = null;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
|
||||
@@ -27,16 +31,21 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
private constructor(
|
||||
gainNode: Tone.Gain,
|
||||
pannerNode: Tone.Panner,
|
||||
volume: number,
|
||||
pan: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.gainNode = gainNode;
|
||||
this.pannerNode = pannerNode;
|
||||
this.volume = volume;
|
||||
this.pan = pan;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
this.updateGainVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
@@ -46,12 +55,15 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
public static async create(
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
pan: number = 0,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioPlayerBus> {
|
||||
try {
|
||||
const gainNode = new Tone.Gain(1);
|
||||
const bus = new KGAudioPlayerBus(gainNode, volume, muted, solo);
|
||||
const pannerNode = new Tone.Panner(pan);
|
||||
gainNode.connect(pannerNode);
|
||||
const bus = new KGAudioPlayerBus(gainNode, pannerNode, volume, pan, muted, solo);
|
||||
console.log('KGAudioPlayerBus created successfully');
|
||||
return bus;
|
||||
} catch (error) {
|
||||
@@ -172,10 +184,40 @@ export class KGAudioPlayerBus {
|
||||
console.log(`Set audio player bus volume to ${volume}`);
|
||||
}
|
||||
|
||||
public setAutomationVolume(volume: number | null): void {
|
||||
this.automationVolume = volume;
|
||||
this.updateGainVolume();
|
||||
}
|
||||
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public setPan(pan: number): void {
|
||||
this.pan = Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public setAutomationPan(pan: number | null): void {
|
||||
this.automationPan = pan === null ? null : Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public scheduleAutomationPan(pan: number, time: number): void {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
this.automationPan = clampedPan;
|
||||
if (typeof this.pannerNode.pan.setValueAtTime === 'function') {
|
||||
this.pannerNode.pan.setValueAtTime(clampedPan, time);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pannerNode.pan.value = clampedPan;
|
||||
}
|
||||
|
||||
public getPan(): number {
|
||||
return this.pan;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.updateGainVolume();
|
||||
@@ -200,8 +242,9 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
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);
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, effectiveVolume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error applying effective volume for audio player bus:', error);
|
||||
}
|
||||
@@ -224,7 +267,7 @@ export class KGAudioPlayerBus {
|
||||
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.gainNode.connect(destination);
|
||||
this.pannerNode.connect(destination);
|
||||
console.log('Connected audio player bus to destination');
|
||||
} catch (error) {
|
||||
console.error('Error connecting audio player bus:', error);
|
||||
@@ -233,7 +276,7 @@ export class KGAudioPlayerBus {
|
||||
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
this.pannerNode.disconnect();
|
||||
console.log('Disconnected audio player bus');
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting audio player bus:', error);
|
||||
@@ -250,6 +293,7 @@ export class KGAudioPlayerBus {
|
||||
}
|
||||
this.audioBuffers.clear();
|
||||
this.gainNode.dispose();
|
||||
this.pannerNode.dispose();
|
||||
console.log('Disposed KGAudioPlayerBus');
|
||||
} catch (error) {
|
||||
console.error('Error disposing KGAudioPlayerBus:', error);
|
||||
@@ -260,17 +304,32 @@ export class KGAudioPlayerBus {
|
||||
|
||||
private updateGainVolume(): void {
|
||||
try {
|
||||
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);
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, effectiveVolume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error updating gain volume:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePanValue(): void {
|
||||
try {
|
||||
const effectivePan = this.automationPan ?? this.pan;
|
||||
if (typeof this.pannerNode.pan.setValueAtTime === 'function') {
|
||||
this.pannerNode.pan.setValueAtTime(effectivePan, Tone.now());
|
||||
} else {
|
||||
this.pannerNode.pan.value = effectivePan;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating audio player pan:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DEBUGGING =====
|
||||
|
||||
public getState(): {
|
||||
volume: number;
|
||||
pan: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
bufferCount: number;
|
||||
@@ -278,6 +337,7 @@ export class KGAudioPlayerBus {
|
||||
} {
|
||||
return {
|
||||
volume: this.volume,
|
||||
pan: this.pan,
|
||||
muted: this.muted,
|
||||
solo: this.solo,
|
||||
bufferCount: this.audioBuffers.size,
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
type BakedMidiAutomationPoint,
|
||||
type MidiAutomationPoint,
|
||||
} from '../../util/midiAutomationUtil';
|
||||
import {
|
||||
bakeTrackAutomationPointsInWindow,
|
||||
getTrackAutomationDefaultValue,
|
||||
resolveTrackAutomationValueAtBeat,
|
||||
} from '../../util/trackAutomationUtil';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
import { KGAudioInterface } from './KGAudioInterface';
|
||||
@@ -121,6 +126,8 @@ export class KGOfflineRenderer {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
volumeAutomation: MidiAutomationPoint[];
|
||||
panAutomation: MidiAutomationPoint[];
|
||||
regions: Array<{
|
||||
startBeat: number;
|
||||
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
||||
@@ -134,6 +141,8 @@ export class KGOfflineRenderer {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
volumeAutomation: MidiAutomationPoint[];
|
||||
panAutomation: MidiAutomationPoint[];
|
||||
regions: Array<{
|
||||
startBeat: number;
|
||||
lengthBeats: number;
|
||||
@@ -207,7 +216,9 @@ export class KGOfflineRenderer {
|
||||
)
|
||||
));
|
||||
|
||||
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions, pitchBends, controllerEventsByType });
|
||||
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, volumeAutomation, panAutomation, regions, pitchBends, controllerEventsByType });
|
||||
} else if (track.getType() === 'Wave') {
|
||||
const volume = audioInterface.getTrackVolume(trackId);
|
||||
const muted = audioInterface.getTrackMuted(trackId);
|
||||
@@ -233,7 +244,9 @@ export class KGOfflineRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
audioTrackData.push({ trackId, volume, muted, solo, regions });
|
||||
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
audioTrackData.push({ trackId, volume, muted, solo, volumeAutomation, panAutomation, regions });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,8 +315,24 @@ export class KGOfflineRenderer {
|
||||
});
|
||||
|
||||
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
||||
sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted);
|
||||
sampler.connect(masterGain);
|
||||
sampler.volume.value = 0;
|
||||
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
||||
const trackPanner = new Tone.Panner(0);
|
||||
sampler.connect(trackGain);
|
||||
trackGain.connect(trackPanner);
|
||||
trackPanner.connect(masterGain);
|
||||
applyOfflineTrackAutomation(
|
||||
trackGain,
|
||||
trackPanner,
|
||||
trackInfo.volumeAutomation,
|
||||
trackInfo.panAutomation,
|
||||
trackInfo.volume,
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
secondsPerBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
);
|
||||
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
||||
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
||||
);
|
||||
@@ -396,7 +425,21 @@ export class KGOfflineRenderer {
|
||||
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
||||
|
||||
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
||||
trackGain.connect(masterGain);
|
||||
const trackPanner = new Tone.Panner(0);
|
||||
trackGain.connect(trackPanner);
|
||||
trackPanner.connect(masterGain);
|
||||
applyOfflineTrackAutomation(
|
||||
trackGain,
|
||||
trackPanner,
|
||||
trackInfo.volumeAutomation,
|
||||
trackInfo.panAutomation,
|
||||
trackInfo.volume,
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
secondsPerBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
);
|
||||
|
||||
for (const regionInfo of trackInfo.regions) {
|
||||
const regionStartBeat = regionInfo.startBeat;
|
||||
@@ -552,6 +595,15 @@ function setOfflineGainValue(gainNode: Tone.Gain, value: number, time: number):
|
||||
gainNode.gain.value = value;
|
||||
}
|
||||
|
||||
function setOfflinePanValue(panner: Tone.Panner, value: number, time: number): void {
|
||||
if (typeof panner.pan.setValueAtTime === 'function') {
|
||||
panner.pan.setValueAtTime(value, time);
|
||||
return;
|
||||
}
|
||||
|
||||
panner.pan.value = value;
|
||||
}
|
||||
|
||||
function createOfflinePitchBendAwareSource(
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
@@ -619,6 +671,73 @@ export function applyOfflineExpressionAutomation(
|
||||
});
|
||||
}
|
||||
|
||||
function applyOfflineTrackAutomation(
|
||||
gainNode: Tone.Gain,
|
||||
pannerNode: Tone.Panner,
|
||||
volumeAutomation: MidiAutomationPoint[],
|
||||
panAutomation: MidiAutomationPoint[],
|
||||
baseVolume: number,
|
||||
renderStartBeat: number,
|
||||
renderEndBeat: number,
|
||||
secondsPerBeat: number,
|
||||
interpolationIntervalMs: number,
|
||||
bpm: number
|
||||
): void {
|
||||
if (volumeAutomation.length > 0) {
|
||||
const initialVolume = resolveTrackAutomationValueAtBeat(
|
||||
volumeAutomation,
|
||||
'volume',
|
||||
renderStartBeat,
|
||||
getTrackAutomationDefaultValue('volume')
|
||||
);
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(initialVolume, false), 0);
|
||||
bakeTrackAutomationPointsInWindow(
|
||||
volumeAutomation,
|
||||
'volume',
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
).forEach(point => {
|
||||
if (point.beat <= renderStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime);
|
||||
});
|
||||
} else {
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(baseVolume, false), 0);
|
||||
}
|
||||
|
||||
if (panAutomation.length > 0) {
|
||||
const initialPan = resolveTrackAutomationValueAtBeat(
|
||||
panAutomation,
|
||||
'pan',
|
||||
renderStartBeat,
|
||||
getTrackAutomationDefaultValue('pan')
|
||||
);
|
||||
setOfflinePanValue(pannerNode, initialPan, 0);
|
||||
bakeTrackAutomationPointsInWindow(
|
||||
panAutomation,
|
||||
'pan',
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
).forEach(point => {
|
||||
if (point.beat <= renderStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
||||
setOfflinePanValue(pannerNode, point.value, automationTime);
|
||||
});
|
||||
} else {
|
||||
setOfflinePanValue(pannerNode, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
||||
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
return isSilent ? -Infinity : volumeDb;
|
||||
|
||||
@@ -12,6 +12,12 @@ export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||
export {
|
||||
CreateTrackAutomationPointsCommand,
|
||||
type TrackAutomationPointCreationData,
|
||||
} from './track/CreateTrackAutomationPointsCommand';
|
||||
export { DeleteTrackAutomationPointsCommand } from './track/DeleteTrackAutomationPointsCommand';
|
||||
export { UpdateTrackAutomationPointsCommand } from './track/UpdateTrackAutomationPointsCommand';
|
||||
|
||||
// Region commands
|
||||
export { CreateRegionCommand } from './region/CreateRegionCommand';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
|
||||
|
||||
export interface TrackAutomationPointCreationData {
|
||||
beat: number;
|
||||
value: number;
|
||||
pointId?: string;
|
||||
}
|
||||
|
||||
export class CreateTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly creationData: TrackAutomationPointCreationData[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
private createdPointIds: string[] = [];
|
||||
|
||||
constructor(trackId: number, automationType: TrackAutomationType, creationData: TrackAutomationPointCreationData[]) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.creationData = creationData.map(data => ({
|
||||
...data,
|
||||
pointId: data.pointId ?? generateUniqueId('KGTrackAutomationPoint'),
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const nextPoints = instantiateTrackAutomationPoints(this.automationType, [
|
||||
...this.originalPoints.map(point => ({
|
||||
id: point.getId(),
|
||||
beat: point.getBeat(),
|
||||
value: point.getValue(),
|
||||
})),
|
||||
...this.creationData.map(data => ({
|
||||
id: data.pointId!,
|
||||
beat: data.beat,
|
||||
value: data.value,
|
||||
})),
|
||||
]);
|
||||
this.createdPointIds = nextPoints
|
||||
.filter(point => this.creationData.some(data => data.pointId === point.getId()))
|
||||
.map(point => point.getId());
|
||||
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
const core = KGCore.instance();
|
||||
core.getSelectedItems()
|
||||
.filter(item => item instanceof KGTrackAutomationPoint && this.createdPointIds.includes(item.getId()))
|
||||
.forEach(item => core.removeSelectedItem(item));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.creationData.length;
|
||||
return count === 1
|
||||
? `Create ${this.automationType} automation point`
|
||||
: `Create ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
public getCreatedPointIds(): string[] {
|
||||
return this.createdPointIds;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
|
||||
export class DeleteTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly pointIds: string[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(trackId: number, automationType: TrackAutomationType, pointIds: string[]) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.pointIds = pointIds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const remainingPoints = this.originalPoints.filter(point => !this.pointIds.includes(point.getId()));
|
||||
if (remainingPoints.length === this.originalPoints.length) {
|
||||
throw new Error('No track automation points found to delete');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, remainingPoints);
|
||||
const core = KGCore.instance();
|
||||
core.getSelectedItems()
|
||||
.filter(item => item instanceof KGTrackAutomationPoint && this.pointIds.includes(item.getId()))
|
||||
.forEach(item => core.removeSelectedItem(item));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.pointIds.length;
|
||||
return count === 1
|
||||
? `Delete ${this.automationType} automation point`
|
||||
: `Delete ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { CreateTrackAutomationPointsCommand } from './CreateTrackAutomationPointsCommand';
|
||||
import { DeleteTrackAutomationPointsCommand } from './DeleteTrackAutomationPointsCommand';
|
||||
import { UpdateTrackAutomationPointsCommand } from './UpdateTrackAutomationPointsCommand';
|
||||
import { KGTrackAutomationPoint } from '../../track/KGTrackAutomationPoint';
|
||||
|
||||
vi.mock('../../KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('track automation commands', () => {
|
||||
let track: KGTrack;
|
||||
let project: KGProject;
|
||||
const mockCore = {
|
||||
getCurrentProject: vi.fn(),
|
||||
getSelectedItems: vi.fn(() => []),
|
||||
removeSelectedItem: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
track = new KGTrack('Track 1', 1);
|
||||
project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10);
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
mockCore.getSelectedItems.mockReturnValue([]);
|
||||
mockCore.removeSelectedItem.mockReset();
|
||||
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||
});
|
||||
|
||||
it('creates and dedupes same-beat automation points', () => {
|
||||
const command = new CreateTrackAutomationPointsCommand(1, 'volume', [
|
||||
{ beat: 1, value: -6, pointId: 'point-1' },
|
||||
{ beat: 1, value: -3, pointId: 'point-2' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getVolumeAutomation()).toHaveLength(1);
|
||||
expect(track.getVolumeAutomation()[0].getId()).toBe('point-2');
|
||||
expect(track.getVolumeAutomation()[0].getValue()).toBe(-3);
|
||||
});
|
||||
|
||||
it('restores deleted automation points on undo', () => {
|
||||
track.setPanAutomation([
|
||||
new KGTrackAutomationPoint('point-1', 1, -0.5),
|
||||
new KGTrackAutomationPoint('point-2', 2, 0.5),
|
||||
]);
|
||||
const command = new DeleteTrackAutomationPointsCommand(1, 'pan', ['point-1']);
|
||||
|
||||
command.execute();
|
||||
expect(track.getPanAutomation()).toHaveLength(1);
|
||||
|
||||
command.undo();
|
||||
expect(track.getPanAutomation()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updates points and removes collisions caused by moves', () => {
|
||||
track.setPanAutomation([
|
||||
new KGTrackAutomationPoint('point-1', 1, -0.5),
|
||||
new KGTrackAutomationPoint('point-2', 2, 0.5),
|
||||
]);
|
||||
const command = new UpdateTrackAutomationPointsCommand(
|
||||
1,
|
||||
'pan',
|
||||
[
|
||||
{ pointId: 'point-1', beat: 1, value: -0.5 },
|
||||
{ pointId: 'point-2', beat: 2, value: 0.5 },
|
||||
],
|
||||
[
|
||||
{ pointId: 'point-1', beat: 2, value: -0.25 },
|
||||
]
|
||||
);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getPanAutomation()).toHaveLength(1);
|
||||
expect(track.getPanAutomation()[0].getId()).toBe('point-2');
|
||||
|
||||
command.undo();
|
||||
expect(track.getPanAutomation()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
|
||||
|
||||
interface TrackAutomationPointSnapshot {
|
||||
pointId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface TrackAutomationPointUpdate {
|
||||
pointId: string;
|
||||
beat?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export class UpdateTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly snapshots: TrackAutomationPointSnapshot[];
|
||||
private readonly updates: TrackAutomationPointUpdate[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(
|
||||
trackId: number,
|
||||
automationType: TrackAutomationType,
|
||||
snapshots: TrackAutomationPointSnapshot[],
|
||||
updates: TrackAutomationPointUpdate[]
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.snapshots = [...snapshots];
|
||||
this.updates = [...updates];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const nextPoints = instantiateTrackAutomationPoints(this.automationType, this.originalPoints.map(point => {
|
||||
const update = this.updates.find(candidate => candidate.pointId === point.getId());
|
||||
return {
|
||||
id: point.getId(),
|
||||
beat: update?.beat ?? point.getBeat(),
|
||||
value: update?.value ?? point.getValue(),
|
||||
};
|
||||
}));
|
||||
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.snapshots.length;
|
||||
return count === 1
|
||||
? `Update ${this.automationType} automation point`
|
||||
: `Update ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { upgradeToV6 } from './upgradeToV6';
|
||||
import { upgradeToV7 } from './upgradeToV7';
|
||||
import { upgradeToV8 } from './upgradeToV8';
|
||||
import { upgradeToV9 } from './upgradeToV9';
|
||||
import { upgradeToV10 } from './upgradeToV10';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -63,6 +64,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV9(workingProject);
|
||||
break;
|
||||
}
|
||||
case 10: {
|
||||
workingProject = upgradeToV10(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,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
import { KGTrackAutomationPoint } from '../track/KGTrackAutomationPoint';
|
||||
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||
import { upgradeToV10 } from './upgradeToV10';
|
||||
|
||||
describe('upgradeToV10', () => {
|
||||
it('initializes missing track automation arrays on legacy tracks', () => {
|
||||
const track = new KGTrack('Legacy Track', 1);
|
||||
delete (track as unknown as { volumeAutomation?: unknown }).volumeAutomation;
|
||||
delete (track as unknown as { panAutomation?: unknown }).panAutomation;
|
||||
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 9);
|
||||
|
||||
upgradeToV10(project);
|
||||
|
||||
expect(track.getVolumeAutomation()).toEqual([]);
|
||||
expect(track.getPanAutomation()).toEqual([]);
|
||||
expect(project.getProjectStructureVersion()).toBe(10);
|
||||
});
|
||||
|
||||
it('preserves existing track automation points through the main upgrader path', () => {
|
||||
const track = new KGTrack('Legacy Track', 1);
|
||||
track.setVolumeAutomation([new KGTrackAutomationPoint('point-1', 1, -3)]);
|
||||
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 9);
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(10);
|
||||
expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
export function upgradeToV10(project: KGProject): KGProject {
|
||||
try {
|
||||
for (const track of project.getTracks()) {
|
||||
const volumeAutomation = (track as unknown as { volumeAutomation?: unknown }).volumeAutomation;
|
||||
if (!Array.isArray(volumeAutomation)) {
|
||||
track.setVolumeAutomation([]);
|
||||
}
|
||||
|
||||
const panAutomation = (track as unknown as { panAutomation?: unknown }).panAutomation;
|
||||
if (!Array.isArray(panAutomation)) {
|
||||
track.setPanAutomation([]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
project.setProjectStructureVersion(10);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ describe('upgradeToV8', () => {
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(9);
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(10);
|
||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]);
|
||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { WithDefault } from '../../types/projectTypes';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from './KGTrackAutomationPoint';
|
||||
import { clampTrackAutomationValue } from '../../util/trackAutomationUtil';
|
||||
|
||||
// Track type enum
|
||||
export enum TrackType {
|
||||
@@ -49,6 +51,14 @@ export class KGTrack {
|
||||
})
|
||||
protected regions: KGRegion[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrackAutomationPoint)
|
||||
protected volumeAutomation: KGTrackAutomationPoint[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrackAutomationPoint)
|
||||
protected panAutomation: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(name: string = 'Untitled Track', id: number = 0, type: TrackType = TrackType.MIDI, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
@@ -114,6 +124,45 @@ export class KGTrack {
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
public getVolumeAutomation(): KGTrackAutomationPoint[] {
|
||||
return this.volumeAutomation;
|
||||
}
|
||||
|
||||
public setVolumeAutomation(points: KGTrackAutomationPoint[]): void {
|
||||
this.volumeAutomation = points
|
||||
.map(point => {
|
||||
point.setValue(clampTrackAutomationValue('volume', point.getValue()));
|
||||
return point;
|
||||
})
|
||||
.sort((left, right) => left.getBeat() - right.getBeat());
|
||||
}
|
||||
|
||||
public getPanAutomation(): KGTrackAutomationPoint[] {
|
||||
return this.panAutomation;
|
||||
}
|
||||
|
||||
public setPanAutomation(points: KGTrackAutomationPoint[]): void {
|
||||
this.panAutomation = points
|
||||
.map(point => {
|
||||
point.setValue(clampTrackAutomationValue('pan', point.getValue()));
|
||||
return point;
|
||||
})
|
||||
.sort((left, right) => left.getBeat() - right.getBeat());
|
||||
}
|
||||
|
||||
public getAutomationPoints(type: TrackAutomationType): KGTrackAutomationPoint[] {
|
||||
return type === 'volume' ? this.volumeAutomation : this.panAutomation;
|
||||
}
|
||||
|
||||
public setAutomationPoints(type: TrackAutomationType, points: KGTrackAutomationPoint[]): void {
|
||||
if (type === 'volume') {
|
||||
this.setVolumeAutomation(points);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPanAutomation(points);
|
||||
}
|
||||
|
||||
// Add a single region
|
||||
public addRegion(region: KGRegion): void {
|
||||
this.regions.push(region);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
export type TrackAutomationType = 'volume' | 'pan';
|
||||
|
||||
export class KGTrackAutomationPoint implements Selectable {
|
||||
@Expose()
|
||||
private id: string = '';
|
||||
|
||||
@Expose()
|
||||
private beat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private value: number = 0;
|
||||
|
||||
@Expose()
|
||||
private selected: boolean = false;
|
||||
|
||||
constructor(id: string, beat: number = 0, value: number = 0) {
|
||||
this.id = id;
|
||||
this.beat = beat;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getBeat(): number {
|
||||
return this.beat;
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setBeat(beat: number): void {
|
||||
this.beat = beat;
|
||||
}
|
||||
|
||||
public setValue(value: number): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
public deselect(): void {
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
public getRootType(): string {
|
||||
return 'KGTrackAutomationPoint';
|
||||
}
|
||||
|
||||
public getCurrentType(): string {
|
||||
return 'KGTrackAutomationPoint';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user