feat: implemented track level automation

This commit is contained in:
Xiaohan-Tian
2026-05-08 15:44:07 -07:00
parent 6e5d9466f4
commit 31e02b4149
24 changed files with 1931 additions and 36 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 = 9;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10;
@Expose()
@Type(() => KGTrack, {
+70 -8
View File
@@ -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
};
+141 -3
View File
@@ -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
*/
+67 -7
View File
@@ -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,
+124 -5
View File
@@ -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;
+6
View File
@@ -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);
});
});
+21
View File
@@ -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);
});
+49
View File
@@ -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);
+68
View File
@@ -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';
}
}