feat: added pitch bend support
This commit is contained in:
@@ -6,6 +6,7 @@ import { KGProjectStorage } from './io/KGProjectStorage';
|
||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { KGMidiNote } from './midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||
import { KGRegion } from './region/KGRegion';
|
||||
import { generateUniqueId } from '../util/miscUtil';
|
||||
import { KGCommand, KGCommandHistory } from './commands';
|
||||
@@ -551,6 +552,13 @@ export class KGCore {
|
||||
);
|
||||
clonedRegion.addNote(clonedNote);
|
||||
});
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
clonedRegion.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
});
|
||||
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MockBufferSource, MockSampler } from '../../test/mocks/tone';
|
||||
|
||||
vi.mock('tone', async () => {
|
||||
const { ToneMock } = await import('../../test/mocks/tone');
|
||||
return ToneMock;
|
||||
});
|
||||
|
||||
const getToneAudioBuffersMock = vi.fn();
|
||||
const createSamplerMock = vi.fn();
|
||||
|
||||
vi.mock('./KGToneBuffersPool', () => ({
|
||||
KGToneBuffersPool: {
|
||||
instance: () => ({
|
||||
getToneAudioBuffers: getToneAudioBuffersMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./KGToneSamplerFactory', () => ({
|
||||
KGToneSamplerFactory: {
|
||||
instance: () => ({
|
||||
createSampler: createSamplerMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
|
||||
describe('KGAudioBus live MIDI pitch bend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockSampler.mockClear();
|
||||
MockBufferSource.mockClear();
|
||||
|
||||
const sampler = MockSampler();
|
||||
createSamplerMock.mockResolvedValue(sampler);
|
||||
getToneAudioBuffersMock.mockResolvedValue({
|
||||
loaded: true,
|
||||
has: (key: string) => key === 'C4',
|
||||
get: (key: string) => key === 'C4' ? { duration: 1 } : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('retunes held live MIDI notes when pitch bend changes', async () => {
|
||||
const audioBus = await KGAudioBus.create('acoustic_grand_piano');
|
||||
|
||||
audioBus.triggerLiveMidiAttack(60, 0, 1);
|
||||
|
||||
expect(MockBufferSource).toHaveBeenCalledTimes(1);
|
||||
const source = MockBufferSource.mock.results[0].value;
|
||||
expect(source.start).toHaveBeenCalledWith(0, 0, 1, 1);
|
||||
expect(source.playbackRate.value).toBeCloseTo(1, 5);
|
||||
|
||||
audioBus.setLiveMidiPitchBend(1);
|
||||
|
||||
expect(source.playbackRate.value).toBeCloseTo(Math.pow(2, 2 / 12), 5);
|
||||
});
|
||||
|
||||
it('stops active live MIDI sources on release and reset paths', async () => {
|
||||
const audioBus = await KGAudioBus.create('acoustic_grand_piano');
|
||||
|
||||
audioBus.triggerLiveMidiAttack(60, 0, 0.5);
|
||||
const source = MockBufferSource.mock.results[0].value;
|
||||
|
||||
audioBus.releaseLiveMidiNote(60, 1.25);
|
||||
expect(source.stop).toHaveBeenCalledWith(1.25);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,39 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
|
||||
// InstrumentType is defined in KGMidiTrack and re-used here
|
||||
|
||||
interface LiveMidiSource {
|
||||
source: Tone.ToneBufferSource;
|
||||
basePlaybackRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGAudioBus - Represents a complete audio bus for a track
|
||||
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
||||
* Each instance manages a single track's audio processing chain
|
||||
*/
|
||||
export class KGAudioBus {
|
||||
// Fixed at +/-2 semitones for now. Future work: make this user-configurable
|
||||
// or honor MIDI RPN 0,0 (Pitch Bend Sensitivity).
|
||||
private static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2;
|
||||
private static readonly LIVE_MIDI_NOTE_NAMES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Core audio components
|
||||
private sampler: Tone.Sampler;
|
||||
private audioBuffers: Tone.ToneAudioBuffers;
|
||||
private instrument: InstrumentType;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
private liveMidiPitchBend: number = 0;
|
||||
private liveMidiSources: Map<number, LiveMidiSource[]> = new Map();
|
||||
|
||||
// Audio processing chain (for future expansion)
|
||||
// private gain: Tone.Gain;
|
||||
@@ -29,12 +44,14 @@ export class KGAudioBus {
|
||||
*/
|
||||
private constructor(
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
instrument: InstrumentType,
|
||||
volume: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = instrument;
|
||||
this.volume = volume;
|
||||
this.muted = muted;
|
||||
@@ -60,11 +77,15 @@ export class KGAudioBus {
|
||||
console.log(`Creating KGAudioBus for ${instrument}...`);
|
||||
|
||||
// Create the sampler using the factory
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const sampler = await samplerFactory.createSampler(String(instrument));
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const [sampler, audioBuffers] = await Promise.all([
|
||||
samplerFactory.createSampler(String(instrument)),
|
||||
buffersPool.getToneAudioBuffers(String(instrument)),
|
||||
]);
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, instrument, volume, muted, solo);
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, volume, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
@@ -117,6 +138,42 @@ export class KGAudioBus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack for live MIDI keyboard monitoring.
|
||||
* This path tracks the underlying buffer sources so pitch bend can retune held notes.
|
||||
*/
|
||||
public triggerLiveMidiAttack(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
this.triggerPitchBendAwareAttack(pitch, time, velocity);
|
||||
}
|
||||
|
||||
public triggerPitchBendAwareAttack(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number,
|
||||
duration?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const liveSource = this.createPitchBendAwareSource(pitch, time, velocity, duration);
|
||||
if (!liveSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSources = this.liveMidiSources.get(pitch) ?? [];
|
||||
activeSources.push(liveSource);
|
||||
this.liveMidiSources.set(pitch, activeSources);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering live MIDI attack for pitch ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for ending sustained notes like piano key releases
|
||||
@@ -132,12 +189,61 @@ export class KGAudioBus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a live MIDI note and forget any active bent sources tied to that pitch.
|
||||
*/
|
||||
public releaseLiveMidiNote(pitch: number, time?: number): void {
|
||||
try {
|
||||
const activeSources = this.liveMidiSources.get(pitch);
|
||||
if (!activeSources || activeSources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopTime = time ?? Tone.now();
|
||||
activeSources.forEach(({ source }) => {
|
||||
try {
|
||||
source.stop(stopTime);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping live MIDI source for pitch ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
});
|
||||
this.liveMidiSources.delete(pitch);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing live MIDI note ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public setLiveMidiPitchBend(normalizedBend: number): void {
|
||||
this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend));
|
||||
|
||||
for (const activeSources of this.liveMidiSources.values()) {
|
||||
activeSources.forEach(({ source, basePlaybackRate }) => {
|
||||
source.playbackRate.value = this.applyPitchBendToPlaybackRate(basePlaybackRate);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public resetLiveMidiPitchBend(): void {
|
||||
this.setLiveMidiPitchBend(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all currently playing notes
|
||||
*/
|
||||
public releaseAll(): void {
|
||||
try {
|
||||
this.sampler.releaseAll();
|
||||
this.liveMidiSources.forEach((activeSources) => {
|
||||
activeSources.forEach(({ source }) => {
|
||||
try {
|
||||
source.stop();
|
||||
} catch (error) {
|
||||
console.error(`Error stopping live MIDI source on ${this.instrument}:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.liveMidiSources.clear();
|
||||
this.resetLiveMidiPitchBend();
|
||||
} catch (error) {
|
||||
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
||||
}
|
||||
@@ -206,12 +312,20 @@ export class KGAudioBus {
|
||||
try {
|
||||
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
||||
|
||||
this.releaseAll();
|
||||
|
||||
// Dispose of the current sampler
|
||||
this.sampler.dispose();
|
||||
|
||||
// Create new sampler with new instrument
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
this.sampler = await samplerFactory.createSampler(String(newInstrument));
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const [sampler, audioBuffers] = await Promise.all([
|
||||
samplerFactory.createSampler(String(newInstrument)),
|
||||
buffersPool.getToneAudioBuffers(String(newInstrument)),
|
||||
]);
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = newInstrument;
|
||||
|
||||
// Restore volume settings
|
||||
@@ -269,6 +383,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
this.releaseAll();
|
||||
this.sampler.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
@@ -352,4 +467,80 @@ export class KGAudioBus {
|
||||
solo: this.solo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private createPitchBendAwareSource(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number,
|
||||
duration?: number
|
||||
): LiveMidiSource | null {
|
||||
const closestPitch = this.findClosestBufferedPitch(pitch);
|
||||
if (closestPitch === null) {
|
||||
console.warn(`No audio buffer found for live MIDI pitch ${pitch} on ${this.instrument}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bufferKey = this.midiPitchToBufferKey(closestPitch);
|
||||
const buffer = this.audioBuffers.get(bufferKey);
|
||||
if (!buffer) {
|
||||
console.warn(`Missing audio buffer ${bufferKey} for ${this.instrument}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
|
||||
const source = new Tone.ToneBufferSource({
|
||||
url: buffer,
|
||||
fadeIn: this.sampler.attack,
|
||||
fadeOut: this.sampler.release,
|
||||
curve: this.sampler.curve,
|
||||
playbackRate: this.applyPitchBendToPlaybackRate(basePlaybackRate),
|
||||
}).connect(this.sampler.output);
|
||||
|
||||
source.onended = () => {
|
||||
const currentSources = this.liveMidiSources.get(pitch);
|
||||
if (!currentSources) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSources = currentSources.filter((entry) => entry.source !== source);
|
||||
if (nextSources.length === 0) {
|
||||
this.liveMidiSources.delete(pitch);
|
||||
} else {
|
||||
this.liveMidiSources.set(pitch, nextSources);
|
||||
}
|
||||
};
|
||||
|
||||
source.start(time, 0, duration ?? buffer.duration / basePlaybackRate, velocity ?? 1);
|
||||
return { source, basePlaybackRate };
|
||||
}
|
||||
|
||||
private applyPitchBendToPlaybackRate(basePlaybackRate: number): number {
|
||||
const bendSemitones = this.liveMidiPitchBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES;
|
||||
return basePlaybackRate * Math.pow(2, bendSemitones / 12);
|
||||
}
|
||||
|
||||
private findClosestBufferedPitch(targetPitch: number): number | null {
|
||||
const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[this.instrument]?.pitchRange || [21, 108];
|
||||
const boundedPitch = Math.max(minPitch, Math.min(maxPitch, targetPitch));
|
||||
|
||||
for (let offset = 0; offset <= 96; offset++) {
|
||||
const upwardPitch = boundedPitch + offset;
|
||||
if (upwardPitch <= maxPitch && this.audioBuffers.has(this.midiPitchToBufferKey(upwardPitch))) {
|
||||
return upwardPitch;
|
||||
}
|
||||
|
||||
const downwardPitch = boundedPitch - offset;
|
||||
if (downwardPitch >= minPitch && this.audioBuffers.has(this.midiPitchToBufferKey(downwardPitch))) {
|
||||
return downwardPitch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private midiPitchToBufferKey(pitch: number): string {
|
||||
const octave = Math.floor((pitch - 12) / 12);
|
||||
const noteIndex = (pitch - 12) % 12;
|
||||
return `${KGAudioBus.LIVE_MIDI_NOTE_NAMES[noteIndex]}${octave}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { KGProject } from '../KGProject';
|
||||
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import { midiPitchBendToNormalized, pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
@@ -399,6 +400,7 @@ export class KGAudioInterface {
|
||||
// Clear any existing scheduled events
|
||||
this.clearScheduledEvents();
|
||||
this.clearDelayedTransportStart();
|
||||
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||
|
||||
console.log("Preparing playback");
|
||||
|
||||
@@ -476,20 +478,31 @@ export class KGAudioInterface {
|
||||
|
||||
// Schedule MIDI track events
|
||||
if (audioBus && track.getType() === 'MIDI') {
|
||||
const trackPitchBends: Array<{ pitchBend: KGMidiPitchBend; absoluteBeat: number }> = [];
|
||||
const trackNotes: Array<{ note: KGMidiNote; absoluteStartBeat: number; absoluteEndBeat: number }> = [];
|
||||
|
||||
track.getRegions().forEach(region => {
|
||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
||||
|
||||
if (region.getCurrentType() === 'KGMidiRegion') {
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[]; getPitchBends: () => KGMidiPitchBend[] };
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
|
||||
if (midiRegion.getPitchBends) {
|
||||
midiRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
|
||||
trackPitchBends.push({
|
||||
pitchBend,
|
||||
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get notes from region (assuming it has a getNotes method)
|
||||
if (midiRegion.getNotes) {
|
||||
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
||||
// Calculate absolute note timing in beats (note position + region start position)
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
||||
|
||||
// Skip notes outside loop range when looping
|
||||
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
||||
@@ -500,33 +513,69 @@ export class KGAudioInterface {
|
||||
if (noteStartBeat < startPosition) {
|
||||
return; // Skip notes that would have already finished before playback starts
|
||||
}
|
||||
|
||||
// Convert beats to Tone.js time format for scheduling
|
||||
const noteStartTime = this.beatsToToneTime(noteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
|
||||
// Convert MIDI note number to note name
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
|
||||
);
|
||||
|
||||
// Schedule the note with delay offset
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
trackNotes.push({
|
||||
note,
|
||||
absoluteStartBeat: noteStartBeat,
|
||||
absoluteEndBeat: noteEndBeat,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const boundedTrackPitchBends = trackPitchBends
|
||||
.filter(({ absoluteBeat }) => absoluteBeat >= scheduleStartBeat && absoluteBeat < scheduleEndBeat)
|
||||
.sort((a, b) => a.absoluteBeat - b.absoluteBeat);
|
||||
const initialPitchBend = [...boundedTrackPitchBends]
|
||||
.reverse()
|
||||
.find(({ absoluteBeat }) => absoluteBeat <= startPosition);
|
||||
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBend?.pitchBend.getValue() ?? 8192));
|
||||
|
||||
if (isLooping && !boundedTrackPitchBends.some(({ absoluteBeat }) => absoluteBeat === scheduleStartBeat)) {
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.setLiveMidiPitchBend(0);
|
||||
}
|
||||
}, this.beatsToToneTime(scheduleStartBeat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
}
|
||||
|
||||
boundedTrackPitchBends.forEach(({ pitchBend, absoluteBeat }) => {
|
||||
if (absoluteBeat < startPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule(() => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(pitchBend.getValue()));
|
||||
}
|
||||
}, this.beatsToToneTime(absoluteBeat));
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
|
||||
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
||||
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
|
||||
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
const velocity = note.getVelocity() / 127;
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
|
||||
);
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, Tone.Time(noteDuration).toSeconds());
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule audio/wav track events
|
||||
@@ -771,6 +820,31 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack for live MIDI keyboard monitoring.
|
||||
* Unlike piano-roll audition, this path tracks active sources so pitch bend can retune them.
|
||||
*/
|
||||
public triggerLiveMidiNoteAttack(trackId: string, pitch: number, velocity: number = 127, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedVelocity = velocity / 127;
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerLiveMidiAttack(pitch, triggerTime, normalizedVelocity);
|
||||
console.log(`Triggered live MIDI attack for pitch ${pitch} on track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering live MIDI note attack for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for piano key release
|
||||
@@ -793,6 +867,37 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public releaseLiveMidiNote(trackId: string, pitch: number, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseTime = time ?? Tone.now();
|
||||
audioBus.releaseLiveMidiNote(pitch, releaseTime);
|
||||
console.log(`Released live MIDI note ${pitch} on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing live MIDI note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public setLiveMidiPitchBend(trackId: string, normalizedBend: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
audioBus.setLiveMidiPitchBend(normalizedBend);
|
||||
console.log(`Set live MIDI pitch bend to ${normalizedBend} on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error setting live MIDI pitch bend for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled events
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,8 @@ export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||
export { CreateMidiEventsCommand, type PitchBendCreationData, type NoteCreationData } from './note/CreateMidiEventsCommand';
|
||||
|
||||
// Project commands
|
||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface NoteCreationData {
|
||||
regionId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export interface PitchBendCreationData {
|
||||
regionId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
pitchBendId?: string;
|
||||
}
|
||||
|
||||
export class CreateMidiEventsCommand extends KGCommand {
|
||||
private noteCreationData: NoteCreationData[];
|
||||
private pitchBendCreationData: PitchBendCreationData[];
|
||||
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
|
||||
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
|
||||
|
||||
constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
|
||||
super();
|
||||
this.noteCreationData = noteCreationData.map(data => ({
|
||||
...data,
|
||||
noteId: data.noteId || generateUniqueId('KGMidiNote'),
|
||||
}));
|
||||
this.pitchBendCreationData = pitchBendCreationData.map(data => ({
|
||||
...data,
|
||||
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
this.createdNotes = [];
|
||||
this.createdPitchBends = [];
|
||||
|
||||
for (const noteData of this.noteCreationData) {
|
||||
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
|
||||
const newNote = new KGMidiNote(
|
||||
noteData.noteId!,
|
||||
noteData.startBeat,
|
||||
noteData.endBeat,
|
||||
noteData.pitch,
|
||||
noteData.velocity
|
||||
);
|
||||
targetRegion.addNote(newNote);
|
||||
this.createdNotes.push({ note: newNote, regionId: noteData.regionId });
|
||||
}
|
||||
|
||||
for (const pitchBendData of this.pitchBendCreationData) {
|
||||
const targetRegion = this.resolveRegion(tracks, pitchBendData.regionId);
|
||||
const newPitchBend = new KGMidiPitchBend(
|
||||
pitchBendData.pitchBendId!,
|
||||
pitchBendData.beat,
|
||||
pitchBendData.value
|
||||
);
|
||||
targetRegion.addPitchBend(newPitchBend);
|
||||
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const tracks = core.getCurrentProject().getTracks();
|
||||
|
||||
for (const data of this.createdNotes) {
|
||||
const region = this.resolveRegion(tracks, data.regionId);
|
||||
region.removeNote(data.note.getId());
|
||||
const selectedNote = core.getSelectedItems().find(item => item instanceof KGMidiNote && item.getId() === data.note.getId());
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
}
|
||||
|
||||
for (const data of this.createdPitchBends) {
|
||||
const region = this.resolveRegion(tracks, data.regionId);
|
||||
region.removePitchBend(data.pitchBend.getId());
|
||||
const selectedPitchBend = core.getSelectedItems().find(item => item instanceof KGMidiPitchBend && item.getId() === data.pitchBend.getId());
|
||||
if (selectedPitchBend) {
|
||||
core.removeSelectedItem(selectedPitchBend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteCount = this.noteCreationData.length;
|
||||
const pitchBendCount = this.pitchBendCreationData.length;
|
||||
|
||||
if (noteCount > 0 && pitchBendCount > 0) {
|
||||
return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (pitchBendCount > 0) {
|
||||
return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
|
||||
}
|
||||
return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
|
||||
}
|
||||
|
||||
public getNoteCreationData(): NoteCreationData[] {
|
||||
return this.noteCreationData;
|
||||
}
|
||||
|
||||
public getCreatedNotes(): Array<{ note: KGMidiNote; regionId: string }> {
|
||||
return this.createdNotes;
|
||||
}
|
||||
|
||||
public getCreatedNoteIds(): string[] {
|
||||
return this.noteCreationData.map(data => data.noteId!);
|
||||
}
|
||||
|
||||
private resolveRegion(tracks: KGTrack[], regionId: string): KGMidiRegion {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`MIDI region with ID ${regionId} not found`);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,28 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { CreateMidiEventsCommand, type NoteCreationData } from './CreateMidiEventsCommand';
|
||||
|
||||
/**
|
||||
* Data structure for a note to be created
|
||||
*/
|
||||
export interface NoteCreationData {
|
||||
regionId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
noteId?: string;
|
||||
}
|
||||
export type { NoteCreationData } from './CreateMidiEventsCommand';
|
||||
|
||||
/**
|
||||
* Command to create multiple MIDI notes in regions
|
||||
* Handles bulk creation as a single undoable operation
|
||||
*/
|
||||
export class CreateNotesCommand extends KGCommand {
|
||||
private noteCreationData: NoteCreationData[];
|
||||
private createdNotes: Array<{
|
||||
note: KGMidiNote;
|
||||
regionId: string;
|
||||
}> = [];
|
||||
|
||||
export class CreateNotesCommand extends CreateMidiEventsCommand {
|
||||
constructor(noteCreationData: NoteCreationData[]) {
|
||||
super();
|
||||
this.noteCreationData = noteCreationData.map(data => ({
|
||||
...data,
|
||||
noteId: data.noteId || generateUniqueId('KGMidiNote')
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Clear any existing created note data to prevent duplicates on re-execution
|
||||
this.createdNotes = [];
|
||||
|
||||
// Create all notes
|
||||
for (const noteData of this.noteCreationData) {
|
||||
// Find the target region
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === noteData.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
|
||||
}
|
||||
|
||||
// Create the new MIDI note
|
||||
const newNote = new KGMidiNote(
|
||||
noteData.noteId!,
|
||||
noteData.startBeat,
|
||||
noteData.endBeat,
|
||||
noteData.pitch,
|
||||
noteData.velocity
|
||||
);
|
||||
|
||||
// Add the note to the region
|
||||
targetRegion.addNote(newNote);
|
||||
|
||||
// Store for undo
|
||||
this.createdNotes.push({
|
||||
note: newNote,
|
||||
regionId: noteData.regionId
|
||||
});
|
||||
}
|
||||
|
||||
const noteCount = this.createdNotes.length;
|
||||
const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
|
||||
console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.createdNotes.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Remove all created notes from their regions
|
||||
for (const data of this.createdNotes) {
|
||||
// Find the region
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === data.regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
region.removeNote(data.note.getId());
|
||||
|
||||
// Clear selection if this note was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedNote = selectedItems.find(item =>
|
||||
item instanceof KGMidiNote && item.getId() === data.note.getId()
|
||||
);
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
|
||||
super(noteCreationData, []);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.noteCreationData.length === 1) {
|
||||
const noteData = this.noteCreationData[0];
|
||||
const noteCreationData = this.getNoteCreationData();
|
||||
if (noteCreationData.length === 1) {
|
||||
const noteData = noteCreationData[0];
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(noteData.pitch / 12) - 1;
|
||||
const noteName = noteNames[noteData.pitch % 12];
|
||||
return `Create note ${noteName}${octave}`;
|
||||
}
|
||||
return `Create ${this.noteCreationData.length} notes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note creation data that was/will be processed
|
||||
*/
|
||||
public getNoteCreationData(): NoteCreationData[] {
|
||||
return this.noteCreationData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note instances (only available after execute)
|
||||
*/
|
||||
public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
|
||||
return this.createdNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regions that were affected by this creation
|
||||
*/
|
||||
public getAffectedRegionIds(): string[] {
|
||||
return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of notes that were/will be created
|
||||
*/
|
||||
public getCreatedNoteIds(): string[] {
|
||||
return this.noteCreationData.map(data => data.noteId!);
|
||||
return `Create ${noteCreationData.length} notes`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,4 +119,4 @@ export class CreateNoteCommand extends CreateNotesCommand {
|
||||
velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
interface PitchBendSnapshot {
|
||||
pitchBendId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface PitchBendUpdate {
|
||||
pitchBendId: string;
|
||||
beat?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export class UpdatePitchBendPropertiesCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private snapshots: PitchBendSnapshot[];
|
||||
private updates: PitchBendUpdate[];
|
||||
private targetRegion: KGMidiRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
|
||||
constructor(regionId: string, snapshots: PitchBendSnapshot[], updates: PitchBendUpdate[]) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.snapshots = [...snapshots];
|
||||
this.updates = [...updates];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
|
||||
if (region) {
|
||||
this.targetRegion = region;
|
||||
this.parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.targetRegion) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
for (const update of this.updates) {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === update.pitchBendId);
|
||||
if (pitchBend) {
|
||||
if (update.beat !== undefined) pitchBend.setBeat(update.beat);
|
||||
if (update.value !== undefined) pitchBend.setValue(update.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
this.snapshots.forEach(snapshot => {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === snapshot.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(snapshot.beat);
|
||||
pitchBend.setValue(snapshot.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.snapshots.length;
|
||||
return count === 1 ? 'Update pitch bend properties' : `Update ${count} pitch bends' properties`;
|
||||
}
|
||||
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
@@ -16,6 +17,11 @@ interface RegionSnapshot {
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
}>;
|
||||
pitchBends: Array<{
|
||||
id: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResolvedRegion {
|
||||
@@ -33,6 +39,14 @@ function cloneNote(note: KGMidiNote, startBeat: number, endBeat: number): KGMidi
|
||||
);
|
||||
}
|
||||
|
||||
function clonePitchBend(pitchBend: KGMidiPitchBend, beat: number): KGMidiPitchBend {
|
||||
return new KGMidiPitchBend(
|
||||
pitchBend.getId(),
|
||||
beat,
|
||||
pitchBend.getValue()
|
||||
);
|
||||
}
|
||||
|
||||
export class MergeMidiRegionsCommand extends KGCommand {
|
||||
private readonly regionIdsToMerge: string[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
@@ -102,6 +116,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
pitch: note.getPitch(),
|
||||
velocity: note.getVelocity(),
|
||||
})),
|
||||
pitchBends: region.getPitchBends().map(pitchBend => ({
|
||||
id: pitchBend.getId(),
|
||||
beat: pitchBend.getBeat(),
|
||||
value: pitchBend.getValue(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +135,7 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
), survivingRegionStart + this.survivingRegion.getLength());
|
||||
|
||||
const mergedNotes = [...this.survivingRegion.getNotes()];
|
||||
const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
|
||||
for (const { region } of resolvedRegions.slice(1)) {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
region.getNotes().forEach(note => {
|
||||
@@ -127,10 +147,17 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
absoluteEnd - survivingRegionStart
|
||||
));
|
||||
});
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
mergedPitchBends.push(clonePitchBend(
|
||||
pitchBend,
|
||||
regionStart + pitchBend.getBeat() - survivingRegionStart
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
||||
this.survivingRegion.setNotes(mergedNotes);
|
||||
this.survivingRegion.setPitchBends(mergedPitchBends);
|
||||
|
||||
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
||||
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
||||
@@ -165,6 +192,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
note.pitch,
|
||||
note.velocity
|
||||
)));
|
||||
this.survivingRegion.setPitchBends(survivingSnapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||
pitchBend.id,
|
||||
pitchBend.beat,
|
||||
pitchBend.value
|
||||
)));
|
||||
|
||||
for (const { region } of this.removedRegions) {
|
||||
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
||||
@@ -180,6 +212,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
note.pitch,
|
||||
note.velocity
|
||||
)));
|
||||
region.setPitchBends(snapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||
pitchBend.id,
|
||||
pitchBend.beat,
|
||||
pitchBend.value
|
||||
)));
|
||||
}
|
||||
|
||||
const regions = [...this.targetTrack.getRegions()];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
@@ -82,6 +83,13 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
);
|
||||
(newRegion as KGMidiRegion).addNote(copiedNote);
|
||||
});
|
||||
originalRegion.getPitchBends().forEach(pitchBend => {
|
||||
(newRegion as KGMidiRegion).addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
});
|
||||
|
||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||
} else {
|
||||
@@ -203,4 +211,4 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
public static fromRegions(targetTrackId: string, pastePosition: number, regions: KGRegion[]): PasteRegionsCommand {
|
||||
return new PasteRegionsCommand(targetTrackId, pastePosition, regions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
|
||||
/**
|
||||
* Command to resize a region (change start position and/or length)
|
||||
@@ -23,6 +24,10 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
originalStartBeat: number;
|
||||
originalEndBeat: number;
|
||||
}> = [];
|
||||
private pitchBendAdjustments: Array<{
|
||||
pitchBendId: string;
|
||||
originalBeat: number;
|
||||
}> = [];
|
||||
|
||||
// Audio region clip offset support
|
||||
private newClipStartOffsetSeconds?: number;
|
||||
@@ -81,6 +86,13 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
targetRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
|
||||
this.pitchBendAdjustments.push({
|
||||
pitchBendId: pitchBend.getId(),
|
||||
originalBeat: pitchBend.getBeat(),
|
||||
});
|
||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||
});
|
||||
|
||||
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
||||
}
|
||||
@@ -122,6 +134,15 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
|
||||
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
||||
}
|
||||
if (this.pitchBendAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
this.pitchBendAdjustments.forEach(adjustment => {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === adjustment.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(adjustment.originalBeat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore clip offset for audio regions
|
||||
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
||||
@@ -214,4 +235,4 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
|
||||
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength, newClipStartOffsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
@@ -114,6 +115,22 @@ export class SplitRegionCommand extends KGCommand {
|
||||
}
|
||||
}
|
||||
|
||||
for (const pitchBend of originalRegion.getPitchBends()) {
|
||||
if (pitchBend.getBeat() < splitOffsetBeats) {
|
||||
region1.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
} else {
|
||||
region2.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat() - splitOffsetBeats,
|
||||
pitchBend.getValue()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
this.region1 = region1;
|
||||
this.region2 = region2;
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ interface NoteAdjustment {
|
||||
originalEndBeat: number;
|
||||
}
|
||||
|
||||
interface PitchBendAdjustment {
|
||||
pitchBendId: string;
|
||||
originalBeat: number;
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
||||
@@ -168,6 +173,7 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
private originalStates: RegionSnapshot[] = [];
|
||||
private targetRegions: KGRegion[] = [];
|
||||
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
||||
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
|
||||
|
||||
constructor(
|
||||
primaryRegionId: string,
|
||||
@@ -282,6 +288,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
this.pitchBendAdjustments.set(region.getId(), region.getPitchBends().map(pitchBend => ({
|
||||
pitchBendId: pitchBend.getId(),
|
||||
originalBeat: pitchBend.getBeat(),
|
||||
})));
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||
});
|
||||
}
|
||||
|
||||
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
|
||||
@@ -315,6 +328,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
note.setEndBeat(adjustment.originalEndBeat);
|
||||
}
|
||||
});
|
||||
const pitchBendAdjustments = this.pitchBendAdjustments.get(region.getId()) ?? [];
|
||||
pitchBendAdjustments.forEach(adjustment => {
|
||||
const pitchBend = region.getPitchBends().find(candidate => candidate.getId() === adjustment.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(adjustment.originalBeat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
|
||||
getStateMock: vi.fn(),
|
||||
audioInterfaceMock: {
|
||||
getIsInitialized: vi.fn(),
|
||||
getIsAudioContextStarted: vi.fn(),
|
||||
startAudioContext: vi.fn(),
|
||||
triggerLiveMidiNoteAttack: vi.fn(),
|
||||
releaseLiveMidiNote: vi.fn(),
|
||||
setLiveMidiPitchBend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: getStateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../audio-interface/KGAudioInterface', () => ({
|
||||
KGAudioInterface: {
|
||||
instance: () => audioInterfaceMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { KGMidiInput } from './KGMidiInput';
|
||||
|
||||
describe('KGMidiInput pitch bend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStateMock.mockReturnValue({ selectedTrackId: 'track-1' });
|
||||
audioInterfaceMock.getIsInitialized.mockReturnValue(true);
|
||||
audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true);
|
||||
audioInterfaceMock.startAudioContext.mockResolvedValue(undefined);
|
||||
(KGMidiInput as unknown as { _instance: KGMidiInput | null })._instance = null;
|
||||
});
|
||||
|
||||
it('routes live MIDI note on/off through the live monitoring path', () => {
|
||||
const midiInput = KGMidiInput.instance() as unknown as {
|
||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
||||
};
|
||||
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
|
||||
|
||||
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('track-1', 60, 100);
|
||||
expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('track-1', 60);
|
||||
});
|
||||
|
||||
it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
|
||||
const midiInput = KGMidiInput.instance() as unknown as {
|
||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
||||
};
|
||||
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) });
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x7f, 0x7f]) });
|
||||
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenNthCalledWith(1, 'track-1', 0);
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenCalledTimes(2);
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[0]).toBe('track-1');
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[1]).toBeCloseTo(8191 / 8192, 5);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
* Handles Web MIDI API integration for keyboard input
|
||||
*/
|
||||
export class KGMidiInput {
|
||||
private static readonly PITCH_BEND_CENTER = 8192;
|
||||
private static readonly PITCH_BEND_MAX_OFFSET = 8192;
|
||||
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGMidiInput | null = null;
|
||||
|
||||
@@ -18,6 +21,7 @@ export class KGMidiInput {
|
||||
// Recording callbacks
|
||||
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
||||
private onRecordNoteOff: ((pitch: number) => void) | null = null;
|
||||
private onRecordPitchBend: ((value: number) => void) | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
@@ -182,7 +186,8 @@ export class KGMidiInput {
|
||||
else if (command === 0xe0) {
|
||||
const pitchBendValue = (velocity << 7) | pitch;
|
||||
console.log(`MIDI Pitch Bend: value=${pitchBendValue}, channel=${channel}`);
|
||||
// TODO: Handle pitch bend
|
||||
this.triggerPitchBend(this.normalizePitchBend(pitchBendValue));
|
||||
this.onRecordPitchBend?.(pitchBendValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +217,7 @@ export class KGMidiInput {
|
||||
|
||||
// Trigger note attack if audio context is ready
|
||||
if (audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.triggerNoteAttack(selectedTrackId, pitch, velocity);
|
||||
audioInterface.triggerLiveMidiNoteAttack(selectedTrackId, pitch, velocity);
|
||||
console.log(`MIDI triggered note attack: pitch=${pitch}, velocity=${velocity}, track=${selectedTrackId}`);
|
||||
}
|
||||
}
|
||||
@@ -237,7 +242,7 @@ export class KGMidiInput {
|
||||
// Get audio interface and stop playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.releaseNote(selectedTrackId, pitch);
|
||||
audioInterface.releaseLiveMidiNote(selectedTrackId, pitch);
|
||||
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -245,6 +250,27 @@ export class KGMidiInput {
|
||||
}
|
||||
}
|
||||
|
||||
private triggerPitchBend(normalizedBend: number): void {
|
||||
try {
|
||||
const selectedTrackId = useProjectStore.getState().selectedTrackId;
|
||||
if (!selectedTrackId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.setLiveMidiPitchBend(selectedTrackId, normalizedBend);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error applying MIDI pitch bend (${normalizedBend}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePitchBend(pitchBendValue: number): number {
|
||||
const normalizedBend = (pitchBendValue - KGMidiInput.PITCH_BEND_CENTER) / KGMidiInput.PITCH_BEND_MAX_OFFSET;
|
||||
return Math.max(-1, Math.min(1, normalizedBend));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up MIDI resources
|
||||
*/
|
||||
@@ -274,10 +300,12 @@ export class KGMidiInput {
|
||||
|
||||
public setRecordingCallbacks(
|
||||
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
||||
onNoteOff: ((pitch: number) => void) | null
|
||||
onNoteOff: ((pitch: number) => void) | null,
|
||||
onPitchBend: ((value: number) => void) | null = null
|
||||
): void {
|
||||
this.onRecordNoteOn = onNoteOn;
|
||||
this.onRecordNoteOff = onNoteOff;
|
||||
this.onRecordPitchBend = onPitchBend;
|
||||
}
|
||||
|
||||
// ===== GETTERS =====
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGMidiPitchBend } from './KGMidiPitchBend';
|
||||
|
||||
describe('KGMidiPitchBend', () => {
|
||||
it('stores beat and raw pitch bend value', () => {
|
||||
const event = new KGMidiPitchBend('bend-1', 1.5, 4096);
|
||||
|
||||
expect(event.getId()).toBe('bend-1');
|
||||
expect(event.getBeat()).toBe(1.5);
|
||||
expect(event.getValue()).toBe(4096);
|
||||
expect(event.getCurrentType()).toBe('KGMidiPitchBend');
|
||||
});
|
||||
|
||||
it('supports selection state', () => {
|
||||
const event = new KGMidiPitchBend('bend-1', 0, 8192);
|
||||
|
||||
expect(event.isSelected()).toBe(false);
|
||||
event.select();
|
||||
expect(event.isSelected()).toBe(true);
|
||||
event.deselect();
|
||||
expect(event.isSelected()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
export class KGMidiPitchBend implements Selectable {
|
||||
@Expose()
|
||||
private id: string = '';
|
||||
|
||||
@Expose()
|
||||
private beat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private value: number = 8192;
|
||||
|
||||
@Expose()
|
||||
private selected: boolean = false;
|
||||
|
||||
constructor(id: string, beat: number = 0, value: number = 8192) {
|
||||
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 'KGMidiPitchBend';
|
||||
}
|
||||
|
||||
public getCurrentType(): string {
|
||||
return 'KGMidiPitchBend';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { instanceToPlain, plainToInstance } from 'class-transformer';
|
||||
import { KGMidiRegion } from './KGMidiRegion';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||
|
||||
describe('KGMidiRegion', () => {
|
||||
@@ -36,6 +38,7 @@ describe('KGMidiRegion', () => {
|
||||
expect(testRegion.getStartFromBeat()).toBe(4);
|
||||
expect(testRegion.getLength()).toBe(8);
|
||||
expect(testRegion.getNotes()).toEqual([]);
|
||||
expect(testRegion.getPitchBends()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use default values for optional parameters', () => {
|
||||
@@ -44,6 +47,7 @@ describe('KGMidiRegion', () => {
|
||||
expect(defaultRegion.getStartFromBeat()).toBe(0);
|
||||
expect(defaultRegion.getLength()).toBe(0);
|
||||
expect(defaultRegion.getNotes()).toEqual([]);
|
||||
expect(defaultRegion.getPitchBends()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set the correct type identifier', () => {
|
||||
@@ -214,6 +218,37 @@ describe('KGMidiRegion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitch bend management', () => {
|
||||
let pitchBend1: KGMidiPitchBend;
|
||||
let pitchBend2: KGMidiPitchBend;
|
||||
|
||||
beforeEach(() => {
|
||||
pitchBend1 = new KGMidiPitchBend('bend-1', 0.5, 8192);
|
||||
pitchBend2 = new KGMidiPitchBend('bend-2', 1.5, 12288);
|
||||
});
|
||||
|
||||
it('adds and returns pitch bends', () => {
|
||||
region.addPitchBend(pitchBend1);
|
||||
region.addPitchBend(pitchBend2);
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend1, pitchBend2]);
|
||||
});
|
||||
|
||||
it('removes pitch bends by id', () => {
|
||||
region.setPitchBends([pitchBend1, pitchBend2]);
|
||||
region.removePitchBend('bend-1');
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend2]);
|
||||
});
|
||||
|
||||
it('replaces all pitch bends when setting a new array', () => {
|
||||
region.setPitchBends([pitchBend1]);
|
||||
region.setPitchBends([pitchBend2]);
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance from KGRegion', () => {
|
||||
it('should inherit all base region properties', () => {
|
||||
expect(region.getId()).toBe('test-region-1');
|
||||
@@ -349,5 +384,18 @@ describe('KGMidiRegion', () => {
|
||||
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
||||
expect(finalNotes).toContain(newNote); // concurrent-4
|
||||
});
|
||||
|
||||
it('preserves pitch bends through class-transformer serialization', () => {
|
||||
region.addNote(createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }));
|
||||
region.addPitchBend(new KGMidiPitchBend('bend-1', 0.5, 12288));
|
||||
|
||||
const plain = instanceToPlain(region);
|
||||
const restored = plainToInstance(KGMidiRegion, plain);
|
||||
|
||||
expect(restored.getNotes()).toHaveLength(1);
|
||||
expect(restored.getPitchBends()).toHaveLength(1);
|
||||
expect(restored.getPitchBends()[0]).toBeInstanceOf(KGMidiPitchBend);
|
||||
expect(restored.getPitchBends()[0].getValue()).toBe(12288);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
|
||||
/**
|
||||
* KGMidiRegion - Class representing a MIDI region in the DAW
|
||||
@@ -14,6 +15,10 @@ export class KGMidiRegion extends KGRegion {
|
||||
@Type(() => KGMidiNote)
|
||||
protected notes: KGMidiNote[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGMidiPitchBend)
|
||||
protected pitchBends: KGMidiPitchBend[] = [];
|
||||
|
||||
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||
this.__type = 'KGMidiRegion';
|
||||
@@ -29,6 +34,14 @@ export class KGMidiRegion extends KGRegion {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
public getPitchBends(): KGMidiPitchBend[] {
|
||||
return this.pitchBends;
|
||||
}
|
||||
|
||||
public setPitchBends(pitchBends: KGMidiPitchBend[]): void {
|
||||
this.pitchBends = pitchBends;
|
||||
}
|
||||
|
||||
// Add a single note
|
||||
public addNote(note: KGMidiNote): void {
|
||||
this.notes.push(note);
|
||||
@@ -39,6 +52,14 @@ export class KGMidiRegion extends KGRegion {
|
||||
this.notes = this.notes.filter(note => note.getId() !== noteId);
|
||||
}
|
||||
|
||||
public addPitchBend(pitchBend: KGMidiPitchBend): void {
|
||||
this.pitchBends.push(pitchBend);
|
||||
}
|
||||
|
||||
public removePitchBend(pitchBendId: string): void {
|
||||
this.pitchBends = this.pitchBends.filter(pitchBend => pitchBend.getId() !== pitchBendId);
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGMidiRegion';
|
||||
|
||||
Reference in New Issue
Block a user