feat: added pitch bend support
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import ListEventPanel from './ListEventPanel';
|
||||||
|
import { createMockMidiNote, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data';
|
||||||
|
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
id: 'region-1',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 4,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 1, endBeat: 2, velocity: 96 })],
|
||||||
|
pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 12288 })],
|
||||||
|
});
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
|
|
||||||
|
vi.mock('../stores/projectStore', () => ({
|
||||||
|
useProjectStore: () => ({
|
||||||
|
tracks: [track],
|
||||||
|
activeRegionId: 'region-1',
|
||||||
|
selectedRegionIds: ['region-1'],
|
||||||
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
|
selectedNoteIds: [],
|
||||||
|
selectedPitchBendIds: [],
|
||||||
|
playheadPosition: 4,
|
||||||
|
updateTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
refreshProjectState: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ListEventPanel', () => {
|
||||||
|
it('renders note and pitch bend rows and toggles them independently', () => {
|
||||||
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||||
|
expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
||||||
|
expect(screen.queryByText('Note')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||||
|
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,22 +6,31 @@ import { useProjectStore } from '../stores/projectStore';
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
import {
|
import {
|
||||||
|
clampMidiPitchBendValue,
|
||||||
formatMidiEventLength,
|
formatMidiEventLength,
|
||||||
formatMidiEventPosition,
|
formatMidiEventPosition,
|
||||||
MIDI_EVENT_TICKS_PER_BEAT,
|
MIDI_EVENT_TICKS_PER_BEAT,
|
||||||
|
MIDI_PITCH_BEND_CENTER,
|
||||||
|
MIDI_PITCH_BEND_MAX_SIGNED,
|
||||||
|
MIDI_PITCH_BEND_MIN_SIGNED,
|
||||||
|
midiPitchBendToNormalized,
|
||||||
|
midiPitchBendToSignedValue,
|
||||||
noteNameToPitch,
|
noteNameToPitch,
|
||||||
parseMidiEventLengthDelta,
|
parseMidiEventLengthDelta,
|
||||||
parseMidiEventLength,
|
parseMidiEventLength,
|
||||||
parseMidiEventPositionDelta,
|
parseMidiEventPositionDelta,
|
||||||
parseMidiEventPosition,
|
parseMidiEventPosition,
|
||||||
pitchToNoteNameString
|
pitchToNoteNameString,
|
||||||
|
signedPitchBendToMidiValue
|
||||||
} from '../util/midiUtil';
|
} from '../util/midiUtil';
|
||||||
import { isModifierKeyPressed } from '../util/osUtil';
|
import { isModifierKeyPressed } from '../util/osUtil';
|
||||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||||
import { CreateNoteCommand } from '../core/commands';
|
import { CreateNoteCommand } from '../core/commands';
|
||||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||||
|
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
interface ListEventPanelProps {
|
interface ListEventPanelProps {
|
||||||
@@ -30,21 +39,28 @@ interface ListEventPanelProps {
|
|||||||
|
|
||||||
interface NoteRowData {
|
interface NoteRowData {
|
||||||
id: string;
|
id: string;
|
||||||
|
type: 'note';
|
||||||
note: KGMidiNote;
|
note: KGMidiNote;
|
||||||
absoluteStartBeat: number;
|
absoluteStartBeat: number;
|
||||||
durationBeats: number;
|
durationBeats: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PitchBendRowData {
|
||||||
|
id: string;
|
||||||
|
type: 'pitch-bend';
|
||||||
|
pitchBend: KGMidiPitchBend;
|
||||||
|
absoluteBeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventRowData = NoteRowData | PitchBendRowData;
|
||||||
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
||||||
|
|
||||||
interface EditingCell {
|
interface EditingCell {
|
||||||
noteId: string;
|
eventId: string;
|
||||||
column: EditableColumn;
|
column: EditableColumn;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
|
|
||||||
|
|
||||||
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!/^\d+$/.test(trimmed)) {
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
@@ -85,6 +101,35 @@ const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string
|
|||||||
return { delta: parseInt(trimmed, 10) };
|
return { delta: parseInt(trimmed, 10) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsePitchBendInput = (raw: string): { value: number } | { error: string } => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!/^[+-]?\d+$/.test(trimmed)) {
|
||||||
|
return { error: `Pitch bend value must be an integer between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedValue = parseInt(trimmed, 10);
|
||||||
|
if (signedValue < MIDI_PITCH_BEND_MIN_SIGNED || signedValue > MIDI_PITCH_BEND_MAX_SIGNED) {
|
||||||
|
return { error: `Pitch bend value must be between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: signedPitchBendToMidiValue(signedValue) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePitchBendDeltaInput = (raw: string): { delta: number } | { error: string } => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!/^[+-]\d+$/.test(trimmed)) {
|
||||||
|
return { error: 'Use pitch bend delta like +256 or -512.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { delta: parseInt(trimmed, 10) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPitchBendInfo = (value: number): string => {
|
||||||
|
const normalized = midiPitchBendToNormalized(value);
|
||||||
|
const semitones = normalized * 2;
|
||||||
|
return `Raw ${value} | ${normalized.toFixed(3)} | ${semitones.toFixed(2)} st`;
|
||||||
|
};
|
||||||
|
|
||||||
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -92,16 +137,18 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
selectedRegionIds,
|
selectedRegionIds,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
selectedNoteIds,
|
selectedNoteIds,
|
||||||
|
selectedPitchBendIds,
|
||||||
playheadPosition,
|
playheadPosition,
|
||||||
updateTrack,
|
updateTrack,
|
||||||
refreshProjectState
|
refreshProjectState
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
const [eventType, setEventType] = useState('notes');
|
const [showNotes, setShowNotes] = useState(true);
|
||||||
|
const [showPitchBends, setShowPitchBends] = useState(true);
|
||||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const rangeAnchorNoteIdRef = useRef<string | null>(null);
|
const rangeAnchorEventIdRef = useRef<string | null>(null);
|
||||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const suppressBlurCommitRef = useRef(false);
|
const suppressBlurCommitRef = useRef(false);
|
||||||
const pendingSingleClickSelectionRef = useRef<number | null>(null);
|
const pendingSingleClickSelectionRef = useRef<number | null>(null);
|
||||||
@@ -127,28 +174,45 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteRows: NoteRowData[] = activeMidiRegion
|
const noteRows: NoteRowData[] = activeMidiRegion
|
||||||
? [...activeMidiRegion.getNotes()]
|
? activeMidiRegion.getNotes().map(note => ({
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.getStartBeat() !== b.getStartBeat()) return a.getStartBeat() - b.getStartBeat();
|
|
||||||
if (a.getPitch() !== b.getPitch()) return a.getPitch() - b.getPitch();
|
|
||||||
return a.getId().localeCompare(b.getId());
|
|
||||||
})
|
|
||||||
.map(note => ({
|
|
||||||
id: note.getId(),
|
id: note.getId(),
|
||||||
|
type: 'note',
|
||||||
note,
|
note,
|
||||||
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
|
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
|
||||||
durationBeats: note.getEndBeat() - note.getStartBeat()
|
durationBeats: note.getEndBeat() - note.getStartBeat(),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const pitchBendRows: PitchBendRowData[] = activeMidiRegion
|
||||||
|
? activeMidiRegion.getPitchBends().map(pitchBend => ({
|
||||||
|
id: pitchBend.getId(),
|
||||||
|
type: 'pitch-bend',
|
||||||
|
pitchBend,
|
||||||
|
absoluteBeat: activeMidiRegion!.getStartFromBeat() + pitchBend.getBeat(),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const eventRows: EventRowData[] = [
|
||||||
|
...(showNotes ? noteRows : []),
|
||||||
|
...(showPitchBends ? pitchBendRows : []),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat)
|
||||||
|
- (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat);
|
||||||
|
if (beatDelta !== 0) return beatDelta;
|
||||||
|
if (a.type !== b.type) return a.type === 'pitch-bend' ? -1 : 1;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
const selectedNoteIdSet = new Set(selectedNoteIds);
|
const selectedNoteIdSet = new Set(selectedNoteIds);
|
||||||
|
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
||||||
|
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
editInputRef.current?.focus();
|
editInputRef.current?.focus();
|
||||||
editInputRef.current?.select();
|
editInputRef.current?.select();
|
||||||
}
|
}
|
||||||
}, [editingCell?.noteId, editingCell?.column]);
|
}, [editingCell?.eventId, editingCell?.column]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -161,20 +225,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const commitSelection = (nextSelectedIds: Set<string>) => {
|
const commitSelection = (nextSelectedIds: Set<string>) => {
|
||||||
if (!activeMidiRegion || !parentTrack) return;
|
if (!activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
const selectedNotes = activeMidiRegion.getNotes().filter(note => nextSelectedIds.has(note.getId()));
|
const selectedEvents = eventRows
|
||||||
|
.filter(row => nextSelectedIds.has(row.id))
|
||||||
|
.map(row => row.type === 'note' ? row.note : row.pitchBend);
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
|
|
||||||
activeMidiRegion.getNotes().forEach(note => {
|
activeMidiRegion.getNotes().forEach(note => {
|
||||||
if (nextSelectedIds.has(note.getId())) {
|
if (nextSelectedIds.has(note.getId())) note.select();
|
||||||
note.select();
|
else note.deselect();
|
||||||
} else {
|
});
|
||||||
note.deselect();
|
activeMidiRegion.getPitchBends().forEach(pitchBend => {
|
||||||
}
|
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
||||||
|
else pitchBend.deselect();
|
||||||
});
|
});
|
||||||
|
|
||||||
core.clearSelectedItems();
|
core.clearSelectedItems();
|
||||||
if (selectedNotes.length > 0) {
|
if (selectedEvents.length > 0) {
|
||||||
core.addSelectedItems(selectedNotes);
|
core.addSelectedItems(selectedEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTrack(parentTrack);
|
void updateTrack(parentTrack);
|
||||||
@@ -187,9 +254,9 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditingCell = (noteId: string, column: EditableColumn, value: string) => {
|
const startEditingCell = (eventId: string, column: EditableColumn, value: string) => {
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
setEditingCell({ noteId, column, value });
|
setEditingCell({ eventId, column, value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditingCell = () => {
|
const cancelEditingCell = () => {
|
||||||
@@ -199,12 +266,17 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const commitEditingCell = async () => {
|
const commitEditingCell = async () => {
|
||||||
if (!editingCell || !activeMidiRegion || !parentTrack) return;
|
if (!editingCell || !activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
const note = activeMidiRegion.getNotes().find(candidate => candidate.getId() === editingCell.noteId);
|
const row = eventRows.find(candidate => candidate.id === editingCell.eventId);
|
||||||
if (!note) {
|
if (!row) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedValue = editingCell.value.trim();
|
||||||
|
const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
|
||||||
|
|
||||||
|
if (row.type === 'note') {
|
||||||
|
const note = row.note;
|
||||||
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
|
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
|
||||||
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
|
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
|
||||||
: [note];
|
: [note];
|
||||||
@@ -218,8 +290,6 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
|
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
|
||||||
const trimmedValue = editingCell.value.trim();
|
|
||||||
const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
|
|
||||||
|
|
||||||
if (editingCell.column === 'position') {
|
if (editingCell.column === 'position') {
|
||||||
if (isDeltaEdit) {
|
if (isDeltaEdit) {
|
||||||
@@ -367,26 +437,112 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates);
|
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
||||||
KGCore.instance().executeCommand(command);
|
} else {
|
||||||
|
const pitchBend = row.pitchBend;
|
||||||
|
const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1
|
||||||
|
? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId()))
|
||||||
|
: [pitchBend];
|
||||||
|
|
||||||
|
const snapshots = targetPitchBends.map(targetPitchBend => ({
|
||||||
|
pitchBendId: targetPitchBend.getId(),
|
||||||
|
beat: targetPitchBend.getBeat(),
|
||||||
|
value: targetPitchBend.getValue(),
|
||||||
|
}));
|
||||||
|
const updates: Array<{ pitchBendId: string; beat?: number; value?: number }> = [];
|
||||||
|
|
||||||
|
if (editingCell.column === 'position') {
|
||||||
|
if (isDeltaEdit) {
|
||||||
|
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetPitchBend of targetPitchBends) {
|
||||||
|
const nextBeat = targetPitchBend.getBeat() + parsed.deltaBeats;
|
||||||
|
if (nextBeat < 0) {
|
||||||
|
await showAlert('Position delta would move one or more pitch bends before the start of the current MIDI region.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updates.push({ pitchBendId: targetPitchBend.getId(), beat: nextBeat });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
|
||||||
|
if (relativeBeat < 0) {
|
||||||
|
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetPitchBend of targetPitchBends) {
|
||||||
|
updates.push({ pitchBendId: targetPitchBend.getId(), beat: relativeBeat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCell.column === 'val') {
|
||||||
|
if (isDeltaEdit) {
|
||||||
|
const parsed = parsePitchBendDeltaInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetPitchBend of targetPitchBends) {
|
||||||
|
const nextSignedValue = midiPitchBendToSignedValue(targetPitchBend.getValue()) + parsed.delta;
|
||||||
|
if (nextSignedValue < MIDI_PITCH_BEND_MIN_SIGNED || nextSignedValue > MIDI_PITCH_BEND_MAX_SIGNED) {
|
||||||
|
await showAlert(`Pitch bend delta would move one or more events outside the valid range ${MIDI_PITCH_BEND_MIN_SIGNED}–${MIDI_PITCH_BEND_MAX_SIGNED}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updates.push({
|
||||||
|
pitchBendId: targetPitchBend.getId(),
|
||||||
|
value: signedPitchBendToMidiValue(nextSignedValue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parsePitchBendInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetPitchBend of targetPitchBends) {
|
||||||
|
updates.push({ pitchBendId: targetPitchBend.getId(), value: clampMidiPitchBendValue(parsed.value) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
setEditingCell(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
||||||
|
}
|
||||||
|
|
||||||
await updateTrack(parentTrack);
|
await updateTrack(parentTrack);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
const handleRowClick = (eventId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (editingCell) return;
|
if (editingCell) return;
|
||||||
if (!activeMidiRegion) return;
|
|
||||||
|
|
||||||
const isModifierPressed = isModifierKeyPressed(event);
|
const isModifierPressed = isModifierKeyPressed(event);
|
||||||
const nextSelectedIds = new Set(selectedNoteIdSet);
|
const nextSelectedIds = new Set(selectedEventIdSet);
|
||||||
const isAlreadySelected = selectedNoteIdSet.has(noteId);
|
const isAlreadySelected = selectedEventIdSet.has(eventId);
|
||||||
const hasMultiSelection = selectedNoteIds.length > 1;
|
const hasMultiSelection = selectedEventIdSet.size > 1;
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current);
|
const anchorIndex = eventRows.findIndex(row => row.id === rangeAnchorEventIdRef.current);
|
||||||
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
|
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
|
||||||
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
|
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
|
||||||
|
|
||||||
@@ -395,22 +551,22 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) {
|
for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) {
|
||||||
nextSelectedIds.add(noteRows[index].id);
|
nextSelectedIds.add(eventRows[index].id);
|
||||||
}
|
}
|
||||||
} else if (isModifierPressed) {
|
} else if (isModifierPressed) {
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
if (nextSelectedIds.has(noteId)) {
|
if (nextSelectedIds.has(eventId)) {
|
||||||
nextSelectedIds.delete(noteId);
|
nextSelectedIds.delete(eventId);
|
||||||
} else {
|
} else {
|
||||||
nextSelectedIds.add(noteId);
|
nextSelectedIds.add(eventId);
|
||||||
}
|
}
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorEventIdRef.current = eventId;
|
||||||
} else {
|
} else {
|
||||||
if (isAlreadySelected && hasMultiSelection) {
|
if (isAlreadySelected && hasMultiSelection) {
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
|
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
|
||||||
const delayedSelection = new Set<string>([noteId]);
|
const delayedSelection = new Set<string>([eventId]);
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorEventIdRef.current = eventId;
|
||||||
commitSelection(delayedSelection);
|
commitSelection(delayedSelection);
|
||||||
pendingSingleClickSelectionRef.current = null;
|
pendingSingleClickSelectionRef.current = null;
|
||||||
}, 220);
|
}, 220);
|
||||||
@@ -419,12 +575,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
nextSelectedIds.clear();
|
nextSelectedIds.clear();
|
||||||
nextSelectedIds.add(noteId);
|
nextSelectedIds.add(eventId);
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorEventIdRef.current = eventId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.shiftKey && rangeAnchorNoteIdRef.current === null) {
|
if (event.shiftKey && rangeAnchorEventIdRef.current === null) {
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorEventIdRef.current = eventId;
|
||||||
}
|
}
|
||||||
|
|
||||||
commitSelection(nextSelectedIds);
|
commitSelection(nextSelectedIds);
|
||||||
@@ -434,7 +590,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
clearPendingSingleClickSelection();
|
clearPendingSingleClickSelection();
|
||||||
rangeAnchorNoteIdRef.current = null;
|
rangeAnchorEventIdRef.current = null;
|
||||||
commitSelection(new Set());
|
commitSelection(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -552,7 +708,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
createdNote.select();
|
createdNote.select();
|
||||||
KGCore.instance().clearSelectedItems();
|
KGCore.instance().clearSelectedItems();
|
||||||
KGCore.instance().addSelectedItem(createdNote);
|
KGCore.instance().addSelectedItem(createdNote);
|
||||||
rangeAnchorNoteIdRef.current = createdNote.getId();
|
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||||
}
|
}
|
||||||
await updateTrack(parentTrack);
|
await updateTrack(parentTrack);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
@@ -566,9 +722,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
<div className="list-event-panel-body">
|
<div className="list-event-panel-body">
|
||||||
<div className="list-event-tabs" role="tablist" aria-label="Event types">
|
<div className="list-event-tabs" role="tablist" aria-label="Event types">
|
||||||
<button className="list-event-tab active" aria-pressed="true">Notes</button>
|
<button
|
||||||
<button className="list-event-tab" aria-pressed="false">Pitch Bends</button>
|
className={`list-event-tab${showNotes ? ' active' : ''}`}
|
||||||
<button className="list-event-tab" aria-pressed="false">Controller</button>
|
aria-pressed={showNotes}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNotes(value => !value)}
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`list-event-tab${showPitchBends ? ' active' : ''}`}
|
||||||
|
aria-pressed={showPitchBends}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPitchBends(value => !value)}
|
||||||
|
>
|
||||||
|
Pitch Bends
|
||||||
|
</button>
|
||||||
|
<button className="list-event-tab" aria-pressed="false" type="button" disabled>Controller</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!activeMidiRegion ? (
|
{!activeMidiRegion ? (
|
||||||
@@ -587,14 +757,6 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
<KGDropdown
|
|
||||||
options={EVENT_TYPE_OPTIONS}
|
|
||||||
value={eventType}
|
|
||||||
onChange={setEventType}
|
|
||||||
label="Event Type"
|
|
||||||
buttonClassName="list-event-dropdown-button"
|
|
||||||
showValueAsLabel={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||||
@@ -638,22 +800,26 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{noteRows.map((row, index) => (
|
{eventRows.map((row, index) => {
|
||||||
(() => {
|
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
||||||
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
const statusText = 'Note';
|
const statusText = row.type === 'note' ? 'Note' : 'Pitch Bend';
|
||||||
const noteText = pitchToNoteNameString(row.note.getPitch());
|
const numText = row.type === 'note' ? pitchToNoteNameString(row.note.getPitch()) : '';
|
||||||
const velocityText = String(row.note.getVelocity());
|
const valText = row.type === 'note'
|
||||||
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
|
? String(row.note.getVelocity())
|
||||||
const isEditingPosition = editingCell?.noteId === row.id && editingCell.column === 'position';
|
: String(midiPitchBendToSignedValue(row.pitchBend.getValue()));
|
||||||
const isEditingNum = editingCell?.noteId === row.id && editingCell.column === 'num';
|
const lengthText = row.type === 'note'
|
||||||
const isEditingVal = editingCell?.noteId === row.id && editingCell.column === 'val';
|
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
|
||||||
const isEditingLength = editingCell?.noteId === row.id && editingCell.column === 'length';
|
: formatPitchBendInfo(row.pitchBend.getValue());
|
||||||
|
const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
|
||||||
|
const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
|
||||||
|
const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
|
||||||
|
const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
|
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
||||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -682,10 +848,11 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
</td>
|
</td>
|
||||||
<td title={statusText}>{statusText}</td>
|
<td title={statusText}>{statusText}</td>
|
||||||
<td
|
<td
|
||||||
title={noteText}
|
title={numText}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
|
if (row.type !== 'note') return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
startEditingCell(row.id, 'num', noteText);
|
startEditingCell(row.id, 'num', numText);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditingNum ? (
|
{isEditingNum ? (
|
||||||
@@ -699,13 +866,13 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
onDoubleClick={(event) => event.stopPropagation()}
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
/>
|
/>
|
||||||
) : noteText}
|
) : numText}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
title={velocityText}
|
title={valText}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
startEditingCell(row.id, 'val', velocityText);
|
startEditingCell(row.id, 'val', valText);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditingVal ? (
|
{isEditingVal ? (
|
||||||
@@ -719,11 +886,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
onDoubleClick={(event) => event.stopPropagation()}
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
/>
|
/>
|
||||||
) : velocityText}
|
) : valText}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
title={lengthText}
|
title={lengthText}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
|
if (row.type !== 'note') return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
startEditingCell(row.id, 'length', lengthText);
|
startEditingCell(row.id, 'length', lengthText);
|
||||||
}}
|
}}
|
||||||
@@ -743,8 +911,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})()
|
})}
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { KGProjectStorage } from './io/KGProjectStorage';
|
|||||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||||
import { KGMidiNote } from './midi/KGMidiNote';
|
import { KGMidiNote } from './midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||||
import { KGRegion } from './region/KGRegion';
|
import { KGRegion } from './region/KGRegion';
|
||||||
import { generateUniqueId } from '../util/miscUtil';
|
import { generateUniqueId } from '../util/miscUtil';
|
||||||
import { KGCommand, KGCommandHistory } from './commands';
|
import { KGCommand, KGCommandHistory } from './commands';
|
||||||
@@ -551,6 +552,13 @@ export class KGCore {
|
|||||||
);
|
);
|
||||||
clonedRegion.addNote(clonedNote);
|
clonedRegion.addNote(clonedNote);
|
||||||
});
|
});
|
||||||
|
region.getPitchBends().forEach(pitchBend => {
|
||||||
|
clonedRegion.addPitchBend(new KGMidiPitchBend(
|
||||||
|
generateUniqueId('KGMidiPitchBend'),
|
||||||
|
pitchBend.getBeat(),
|
||||||
|
pitchBend.getValue()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
clonedItems.push(clonedRegion);
|
clonedItems.push(clonedRegion);
|
||||||
break;
|
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 * as Tone from 'tone';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||||
|
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||||
|
|
||||||
// InstrumentType is defined in KGMidiTrack and re-used here
|
// 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
|
* KGAudioBus - Represents a complete audio bus for a track
|
||||||
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
||||||
* Each instance manages a single track's audio processing chain
|
* Each instance manages a single track's audio processing chain
|
||||||
*/
|
*/
|
||||||
export class KGAudioBus {
|
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
|
// Core audio components
|
||||||
private sampler: Tone.Sampler;
|
private sampler: Tone.Sampler;
|
||||||
|
private audioBuffers: Tone.ToneAudioBuffers;
|
||||||
private instrument: InstrumentType;
|
private instrument: InstrumentType;
|
||||||
|
|
||||||
// Audio properties
|
// Audio properties
|
||||||
private volume: number;
|
private volume: number;
|
||||||
private muted: boolean;
|
private muted: boolean;
|
||||||
private solo: boolean;
|
private solo: boolean;
|
||||||
|
private liveMidiPitchBend: number = 0;
|
||||||
|
private liveMidiSources: Map<number, LiveMidiSource[]> = new Map();
|
||||||
|
|
||||||
// Audio processing chain (for future expansion)
|
// Audio processing chain (for future expansion)
|
||||||
// private gain: Tone.Gain;
|
// private gain: Tone.Gain;
|
||||||
@@ -29,12 +44,14 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
private constructor(
|
private constructor(
|
||||||
sampler: Tone.Sampler,
|
sampler: Tone.Sampler,
|
||||||
|
audioBuffers: Tone.ToneAudioBuffers,
|
||||||
instrument: InstrumentType,
|
instrument: InstrumentType,
|
||||||
volume: number,
|
volume: number,
|
||||||
muted: boolean,
|
muted: boolean,
|
||||||
solo: boolean
|
solo: boolean
|
||||||
) {
|
) {
|
||||||
this.sampler = sampler;
|
this.sampler = sampler;
|
||||||
|
this.audioBuffers = audioBuffers;
|
||||||
this.instrument = instrument;
|
this.instrument = instrument;
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
@@ -61,10 +78,14 @@ export class KGAudioBus {
|
|||||||
|
|
||||||
// Create the sampler using the factory
|
// Create the sampler using the factory
|
||||||
const samplerFactory = KGToneSamplerFactory.instance();
|
const samplerFactory = KGToneSamplerFactory.instance();
|
||||||
const sampler = await samplerFactory.createSampler(String(instrument));
|
const buffersPool = KGToneBuffersPool.instance();
|
||||||
|
const [sampler, audioBuffers] = await Promise.all([
|
||||||
|
samplerFactory.createSampler(String(instrument)),
|
||||||
|
buffersPool.getToneAudioBuffers(String(instrument)),
|
||||||
|
]);
|
||||||
|
|
||||||
// Create the audio bus instance
|
// 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}`);
|
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||||
return audioBus;
|
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
|
* Release a specific note
|
||||||
* Used for ending sustained notes like piano key releases
|
* 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
|
* Release all currently playing notes
|
||||||
*/
|
*/
|
||||||
public releaseAll(): void {
|
public releaseAll(): void {
|
||||||
try {
|
try {
|
||||||
this.sampler.releaseAll();
|
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) {
|
} catch (error) {
|
||||||
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
||||||
}
|
}
|
||||||
@@ -206,12 +312,20 @@ export class KGAudioBus {
|
|||||||
try {
|
try {
|
||||||
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
||||||
|
|
||||||
|
this.releaseAll();
|
||||||
|
|
||||||
// Dispose of the current sampler
|
// Dispose of the current sampler
|
||||||
this.sampler.dispose();
|
this.sampler.dispose();
|
||||||
|
|
||||||
// Create new sampler with new instrument
|
// Create new sampler with new instrument
|
||||||
const samplerFactory = KGToneSamplerFactory.instance();
|
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;
|
this.instrument = newInstrument;
|
||||||
|
|
||||||
// Restore volume settings
|
// Restore volume settings
|
||||||
@@ -269,6 +383,7 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
try {
|
try {
|
||||||
|
this.releaseAll();
|
||||||
this.sampler.dispose();
|
this.sampler.dispose();
|
||||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -352,4 +467,80 @@ export class KGAudioBus {
|
|||||||
solo: this.solo
|
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 { KGProject } from '../KGProject';
|
||||||
import type { KGMidiNote } from '../midi/KGMidiNote';
|
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
|
import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
import { midiPitchBendToNormalized, pitchToNoteNameString } from '../../util/midiUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||||
@@ -399,6 +400,7 @@ export class KGAudioInterface {
|
|||||||
// Clear any existing scheduled events
|
// Clear any existing scheduled events
|
||||||
this.clearScheduledEvents();
|
this.clearScheduledEvents();
|
||||||
this.clearDelayedTransportStart();
|
this.clearDelayedTransportStart();
|
||||||
|
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||||
|
|
||||||
console.log("Preparing playback");
|
console.log("Preparing playback");
|
||||||
|
|
||||||
@@ -476,20 +478,31 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
// Schedule MIDI track events
|
// Schedule MIDI track events
|
||||||
if (audioBus && track.getType() === 'MIDI') {
|
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 => {
|
track.getRegions().forEach(region => {
|
||||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
||||||
|
|
||||||
if (region.getCurrentType() === 'KGMidiRegion') {
|
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)
|
// Get notes from region (assuming it has a getNotes method)
|
||||||
if (midiRegion.getNotes) {
|
if (midiRegion.getNotes) {
|
||||||
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
||||||
// Calculate absolute note timing in beats (note position + region start position)
|
// Calculate absolute note timing in beats (note position + region start position)
|
||||||
const regionStartBeat = region.getStartFromBeat();
|
|
||||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||||
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
||||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
|
||||||
|
|
||||||
// Skip notes outside loop range when looping
|
// Skip notes outside loop range when looping
|
||||||
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
||||||
@@ -500,34 +513,70 @@ export class KGAudioInterface {
|
|||||||
if (noteStartBeat < startPosition) {
|
if (noteStartBeat < startPosition) {
|
||||||
return; // Skip notes that would have already finished before playback starts
|
return; // Skip notes that would have already finished before playback starts
|
||||||
}
|
}
|
||||||
|
trackNotes.push({
|
||||||
|
note,
|
||||||
|
absoluteStartBeat: noteStartBeat,
|
||||||
|
absoluteEndBeat: noteEndBeat,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Convert beats to Tone.js time format for scheduling
|
const boundedTrackPitchBends = trackPitchBends
|
||||||
const noteStartTime = this.beatsToToneTime(noteStartBeat);
|
.filter(({ absoluteBeat }) => absoluteBeat >= scheduleStartBeat && absoluteBeat < scheduleEndBeat)
|
||||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
.sort((a, b) => a.absoluteBeat - b.absoluteBeat);
|
||||||
|
const initialPitchBend = [...boundedTrackPitchBends]
|
||||||
|
.reverse()
|
||||||
|
.find(({ absoluteBeat }) => absoluteBeat <= startPosition);
|
||||||
|
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBend?.pitchBend.getValue() ?? 8192));
|
||||||
|
|
||||||
// Convert MIDI note number to note name
|
if (isLooping && !boundedTrackPitchBends.some(({ absoluteBeat }) => absoluteBeat === scheduleStartBeat)) {
|
||||||
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) => {
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
// Check if track should play considering solo logic
|
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
|
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);
|
}, noteStartTime);
|
||||||
|
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule audio/wav track events
|
// Schedule audio/wav track events
|
||||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||||
@@ -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
|
* Release a specific note
|
||||||
* Used for piano key release
|
* 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
|
* Clear all scheduled events
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
|||||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||||
|
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||||
|
export { CreateMidiEventsCommand, type PitchBendCreationData, type NoteCreationData } from './note/CreateMidiEventsCommand';
|
||||||
|
|
||||||
// Project commands
|
// Project commands
|
||||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
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 { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { CreateMidiEventsCommand, type NoteCreationData } from './CreateMidiEventsCommand';
|
||||||
import { generateUniqueId } from '../../../util/miscUtil';
|
|
||||||
|
|
||||||
/**
|
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to create multiple MIDI notes in regions
|
* Command to create multiple MIDI notes in regions
|
||||||
* Handles bulk creation as a single undoable operation
|
* Handles bulk creation as a single undoable operation
|
||||||
*/
|
*/
|
||||||
export class CreateNotesCommand extends KGCommand {
|
export class CreateNotesCommand extends CreateMidiEventsCommand {
|
||||||
private noteCreationData: NoteCreationData[];
|
|
||||||
private createdNotes: Array<{
|
|
||||||
note: KGMidiNote;
|
|
||||||
regionId: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
constructor(noteCreationData: NoteCreationData[]) {
|
constructor(noteCreationData: NoteCreationData[]) {
|
||||||
super();
|
super(noteCreationData, []);
|
||||||
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`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
if (this.noteCreationData.length === 1) {
|
const noteCreationData = this.getNoteCreationData();
|
||||||
const noteData = this.noteCreationData[0];
|
if (noteCreationData.length === 1) {
|
||||||
|
const noteData = noteCreationData[0];
|
||||||
// Convert MIDI pitch to note name for user-friendly description
|
// 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 noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
const octave = Math.floor(noteData.pitch / 12) - 1;
|
const octave = Math.floor(noteData.pitch / 12) - 1;
|
||||||
const noteName = noteNames[noteData.pitch % 12];
|
const noteName = noteNames[noteData.pitch % 12];
|
||||||
return `Create note ${noteName}${octave}`;
|
return `Create note ${noteName}${octave}`;
|
||||||
}
|
}
|
||||||
return `Create ${this.noteCreationData.length} notes`;
|
return `Create ${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!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { KGCore } from '../../KGCore';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
import { useProjectStore } from '../../../stores/projectStore';
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ interface RegionSnapshot {
|
|||||||
pitch: number;
|
pitch: number;
|
||||||
velocity: number;
|
velocity: number;
|
||||||
}>;
|
}>;
|
||||||
|
pitchBends: Array<{
|
||||||
|
id: string;
|
||||||
|
beat: number;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedRegion {
|
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 {
|
export class MergeMidiRegionsCommand extends KGCommand {
|
||||||
private readonly regionIdsToMerge: string[];
|
private readonly regionIdsToMerge: string[];
|
||||||
private targetTrack: KGTrack | null = null;
|
private targetTrack: KGTrack | null = null;
|
||||||
@@ -102,6 +116,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
pitch: note.getPitch(),
|
pitch: note.getPitch(),
|
||||||
velocity: note.getVelocity(),
|
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());
|
), survivingRegionStart + this.survivingRegion.getLength());
|
||||||
|
|
||||||
const mergedNotes = [...this.survivingRegion.getNotes()];
|
const mergedNotes = [...this.survivingRegion.getNotes()];
|
||||||
|
const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
|
||||||
for (const { region } of resolvedRegions.slice(1)) {
|
for (const { region } of resolvedRegions.slice(1)) {
|
||||||
const regionStart = region.getStartFromBeat();
|
const regionStart = region.getStartFromBeat();
|
||||||
region.getNotes().forEach(note => {
|
region.getNotes().forEach(note => {
|
||||||
@@ -127,10 +147,17 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
absoluteEnd - survivingRegionStart
|
absoluteEnd - survivingRegionStart
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
region.getPitchBends().forEach(pitchBend => {
|
||||||
|
mergedPitchBends.push(clonePitchBend(
|
||||||
|
pitchBend,
|
||||||
|
regionStart + pitchBend.getBeat() - survivingRegionStart
|
||||||
|
));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
||||||
this.survivingRegion.setNotes(mergedNotes);
|
this.survivingRegion.setNotes(mergedNotes);
|
||||||
|
this.survivingRegion.setPitchBends(mergedPitchBends);
|
||||||
|
|
||||||
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
||||||
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
||||||
@@ -165,6 +192,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
note.pitch,
|
note.pitch,
|
||||||
note.velocity
|
note.velocity
|
||||||
)));
|
)));
|
||||||
|
this.survivingRegion.setPitchBends(survivingSnapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||||
|
pitchBend.id,
|
||||||
|
pitchBend.beat,
|
||||||
|
pitchBend.value
|
||||||
|
)));
|
||||||
|
|
||||||
for (const { region } of this.removedRegions) {
|
for (const { region } of this.removedRegions) {
|
||||||
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
||||||
@@ -180,6 +212,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
note.pitch,
|
note.pitch,
|
||||||
note.velocity
|
note.velocity
|
||||||
)));
|
)));
|
||||||
|
region.setPitchBends(snapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||||
|
pitchBend.id,
|
||||||
|
pitchBend.beat,
|
||||||
|
pitchBend.value
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const regions = [...this.targetTrack.getRegions()];
|
const regions = [...this.targetTrack.getRegions()];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
|
|||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
import { generateUniqueId } from '../../../util/miscUtil';
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
import { useProjectStore } from '../../../stores/projectStore';
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
@@ -82,6 +83,13 @@ export class PasteRegionsCommand extends KGCommand {
|
|||||||
);
|
);
|
||||||
(newRegion as KGMidiRegion).addNote(copiedNote);
|
(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`);
|
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
|||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to resize a region (change start position and/or length)
|
* Command to resize a region (change start position and/or length)
|
||||||
@@ -23,6 +24,10 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
originalStartBeat: number;
|
originalStartBeat: number;
|
||||||
originalEndBeat: number;
|
originalEndBeat: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
private pitchBendAdjustments: Array<{
|
||||||
|
pitchBendId: string;
|
||||||
|
originalBeat: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Audio region clip offset support
|
// Audio region clip offset support
|
||||||
private newClipStartOffsetSeconds?: number;
|
private newClipStartOffsetSeconds?: number;
|
||||||
@@ -81,6 +86,13 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||||
note.setEndBeat(note.getEndBeat() - 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`);
|
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`);
|
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
|
// Restore clip offset for audio regions
|
||||||
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
|||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
import { generateUniqueId } from '../../../util/miscUtil';
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
import { useProjectStore } from '../../../stores/projectStore';
|
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.region1 = region1;
|
||||||
this.region2 = region2;
|
this.region2 = region2;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ interface NoteAdjustment {
|
|||||||
originalEndBeat: number;
|
originalEndBeat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PitchBendAdjustment {
|
||||||
|
pitchBendId: string;
|
||||||
|
originalBeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
const EPSILON = 1e-9;
|
const EPSILON = 1e-9;
|
||||||
|
|
||||||
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
||||||
@@ -168,6 +173,7 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
private originalStates: RegionSnapshot[] = [];
|
private originalStates: RegionSnapshot[] = [];
|
||||||
private targetRegions: KGRegion[] = [];
|
private targetRegions: KGRegion[] = [];
|
||||||
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
||||||
|
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
primaryRegionId: string,
|
primaryRegionId: string,
|
||||||
@@ -282,6 +288,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||||
note.setEndBeat(note.getEndBeat() - 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) {
|
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
|
||||||
@@ -315,6 +328,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
note.setEndBeat(adjustment.originalEndBeat);
|
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) {
|
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
|
* Handles Web MIDI API integration for keyboard input
|
||||||
*/
|
*/
|
||||||
export class KGMidiInput {
|
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 for singleton pattern
|
||||||
private static _instance: KGMidiInput | null = null;
|
private static _instance: KGMidiInput | null = null;
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ export class KGMidiInput {
|
|||||||
// Recording callbacks
|
// Recording callbacks
|
||||||
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
||||||
private onRecordNoteOff: ((pitch: 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 to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@@ -182,7 +186,8 @@ export class KGMidiInput {
|
|||||||
else if (command === 0xe0) {
|
else if (command === 0xe0) {
|
||||||
const pitchBendValue = (velocity << 7) | pitch;
|
const pitchBendValue = (velocity << 7) | pitch;
|
||||||
console.log(`MIDI Pitch Bend: value=${pitchBendValue}, channel=${channel}`);
|
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
|
// Trigger note attack if audio context is ready
|
||||||
if (audioInterface.getIsAudioContextStarted()) {
|
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}`);
|
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
|
// Get audio interface and stop playing the note
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||||
audioInterface.releaseNote(selectedTrackId, pitch);
|
audioInterface.releaseLiveMidiNote(selectedTrackId, pitch);
|
||||||
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
|
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
* Clean up MIDI resources
|
||||||
*/
|
*/
|
||||||
@@ -274,10 +300,12 @@ export class KGMidiInput {
|
|||||||
|
|
||||||
public setRecordingCallbacks(
|
public setRecordingCallbacks(
|
||||||
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
||||||
onNoteOff: ((pitch: number) => void) | null
|
onNoteOff: ((pitch: number) => void) | null,
|
||||||
|
onPitchBend: ((value: number) => void) | null = null
|
||||||
): void {
|
): void {
|
||||||
this.onRecordNoteOn = onNoteOn;
|
this.onRecordNoteOn = onNoteOn;
|
||||||
this.onRecordNoteOff = onNoteOff;
|
this.onRecordNoteOff = onNoteOff;
|
||||||
|
this.onRecordPitchBend = onPitchBend;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== GETTERS =====
|
// ===== 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 { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { instanceToPlain, plainToInstance } from 'class-transformer';
|
||||||
import { KGMidiRegion } from './KGMidiRegion';
|
import { KGMidiRegion } from './KGMidiRegion';
|
||||||
import { KGRegion } from './KGRegion';
|
import { KGRegion } from './KGRegion';
|
||||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||||
import { createMockMidiNote } from '../../test/utils/mock-data';
|
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||||
|
|
||||||
describe('KGMidiRegion', () => {
|
describe('KGMidiRegion', () => {
|
||||||
@@ -36,6 +38,7 @@ describe('KGMidiRegion', () => {
|
|||||||
expect(testRegion.getStartFromBeat()).toBe(4);
|
expect(testRegion.getStartFromBeat()).toBe(4);
|
||||||
expect(testRegion.getLength()).toBe(8);
|
expect(testRegion.getLength()).toBe(8);
|
||||||
expect(testRegion.getNotes()).toEqual([]);
|
expect(testRegion.getNotes()).toEqual([]);
|
||||||
|
expect(testRegion.getPitchBends()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default values for optional parameters', () => {
|
it('should use default values for optional parameters', () => {
|
||||||
@@ -44,6 +47,7 @@ describe('KGMidiRegion', () => {
|
|||||||
expect(defaultRegion.getStartFromBeat()).toBe(0);
|
expect(defaultRegion.getStartFromBeat()).toBe(0);
|
||||||
expect(defaultRegion.getLength()).toBe(0);
|
expect(defaultRegion.getLength()).toBe(0);
|
||||||
expect(defaultRegion.getNotes()).toEqual([]);
|
expect(defaultRegion.getNotes()).toEqual([]);
|
||||||
|
expect(defaultRegion.getPitchBends()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set the correct type identifier', () => {
|
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', () => {
|
describe('inheritance from KGRegion', () => {
|
||||||
it('should inherit all base region properties', () => {
|
it('should inherit all base region properties', () => {
|
||||||
expect(region.getId()).toBe('test-region-1');
|
expect(region.getId()).toBe('test-region-1');
|
||||||
@@ -349,5 +384,18 @@ describe('KGMidiRegion', () => {
|
|||||||
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
||||||
expect(finalNotes).toContain(newNote); // concurrent-4
|
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 { Expose, Type } from 'class-transformer';
|
||||||
import { KGRegion } from './KGRegion';
|
import { KGRegion } from './KGRegion';
|
||||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGMidiRegion - Class representing a MIDI region in the DAW
|
* KGMidiRegion - Class representing a MIDI region in the DAW
|
||||||
@@ -14,6 +15,10 @@ export class KGMidiRegion extends KGRegion {
|
|||||||
@Type(() => KGMidiNote)
|
@Type(() => KGMidiNote)
|
||||||
protected notes: 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) {
|
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||||
this.__type = 'KGMidiRegion';
|
this.__type = 'KGMidiRegion';
|
||||||
@@ -29,6 +34,14 @@ export class KGMidiRegion extends KGRegion {
|
|||||||
this.notes = notes;
|
this.notes = notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPitchBends(): KGMidiPitchBend[] {
|
||||||
|
return this.pitchBends;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPitchBends(pitchBends: KGMidiPitchBend[]): void {
|
||||||
|
this.pitchBends = pitchBends;
|
||||||
|
}
|
||||||
|
|
||||||
// Add a single note
|
// Add a single note
|
||||||
public addNote(note: KGMidiNote): void {
|
public addNote(note: KGMidiNote): void {
|
||||||
this.notes.push(note);
|
this.notes.push(note);
|
||||||
@@ -39,6 +52,14 @@ export class KGMidiRegion extends KGRegion {
|
|||||||
this.notes = this.notes.filter(note => note.getId() !== noteId);
|
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
|
// Override getCurrentType to return specific subclass type
|
||||||
public override getCurrentType(): string {
|
public override getCurrentType(): string {
|
||||||
return 'KGMidiRegion';
|
return 'KGMidiRegion';
|
||||||
|
|||||||
+45
-10
@@ -20,8 +20,9 @@ import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
|||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
|
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
||||||
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -72,6 +73,7 @@ interface ProjectState {
|
|||||||
|
|
||||||
// Selection state for UI reactivity
|
// Selection state for UI reactivity
|
||||||
selectedNoteIds: string[];
|
selectedNoteIds: string[];
|
||||||
|
selectedPitchBendIds: string[];
|
||||||
selectedRegionIds: string[];
|
selectedRegionIds: string[];
|
||||||
selectedTrackId: string | null;
|
selectedTrackId: string | null;
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ interface ProjectState {
|
|||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
recordingTargetRegionId: string | null;
|
recordingTargetRegionId: string | null;
|
||||||
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
||||||
|
recordingPitchBends: Array<{ beat: number; value: number }>;
|
||||||
recordingOriginalPlayhead: number;
|
recordingOriginalPlayhead: number;
|
||||||
|
|
||||||
// Undo/redo state
|
// Undo/redo state
|
||||||
@@ -207,6 +210,7 @@ interface ProjectState {
|
|||||||
// Module-level recording state (not reactive — only used for timing during active recording)
|
// Module-level recording state (not reactive — only used for timing during active recording)
|
||||||
let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }> = new Map(); // pitch → note-on data
|
let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }> = new Map(); // pitch → note-on data
|
||||||
let _recordingRegionStartBeat: number = 0;
|
let _recordingRegionStartBeat: number = 0;
|
||||||
|
let _lastRecordedPitchBendValue: number | null = null;
|
||||||
|
|
||||||
function getRecordingLoopEndBeatRelative(): number | null {
|
function getRecordingLoopEndBeatRelative(): number | null {
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
@@ -270,11 +274,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const noteIds = selectedItems
|
const noteIds = selectedItems
|
||||||
.filter(item => item instanceof KGMidiNote)
|
.filter(item => item instanceof KGMidiNote)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
const pitchBendIds = selectedItems
|
||||||
|
.filter(item => item instanceof KGMidiPitchBend)
|
||||||
|
.map(item => item.getId());
|
||||||
const regionIds = selectedItems
|
const regionIds = selectedItems
|
||||||
.filter(item => item instanceof KGRegion)
|
.filter(item => item instanceof KGRegion)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
|
||||||
set({ selectedNoteIds: noteIds, selectedRegionIds: regionIds });
|
set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, selectedRegionIds: regionIds });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register the sync callback with KGCore
|
// Register the sync callback with KGCore
|
||||||
@@ -339,6 +346,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Initial selection state
|
// Initial selection state
|
||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
|
selectedPitchBendIds: [],
|
||||||
selectedRegionIds: [],
|
selectedRegionIds: [],
|
||||||
selectedTrackId: initialSelectedTrackId,
|
selectedTrackId: initialSelectedTrackId,
|
||||||
|
|
||||||
@@ -377,6 +385,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
isRecording: false,
|
isRecording: false,
|
||||||
recordingTargetRegionId: null,
|
recordingTargetRegionId: null,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
|
recordingPitchBends: [],
|
||||||
recordingOriginalPlayhead: 0,
|
recordingOriginalPlayhead: 0,
|
||||||
|
|
||||||
// Initial cross-component scroll request state
|
// Initial cross-component scroll request state
|
||||||
@@ -875,10 +884,12 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
||||||
_recordingActiveNotes = new Map();
|
_recordingActiveNotes = new Map();
|
||||||
|
_lastRecordedPitchBendValue = null;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isRecording: true,
|
isRecording: true,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
|
recordingPitchBends: [],
|
||||||
recordingTargetRegionId: activeRegionId,
|
recordingTargetRegionId: activeRegionId,
|
||||||
recordingOriginalPlayhead: playheadPosition,
|
recordingOriginalPlayhead: playheadPosition,
|
||||||
});
|
});
|
||||||
@@ -911,6 +922,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}],
|
}],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
(value: number) => {
|
||||||
|
if (_lastRecordedPitchBendValue === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRecordedPitchBendValue = value;
|
||||||
|
const beat = buildCorrectedBeat();
|
||||||
|
set(state => ({
|
||||||
|
recordingPitchBends: [...state.recordingPitchBends, { beat, value }],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -929,10 +951,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
stopRecording: async () => {
|
stopRecording: async () => {
|
||||||
const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
const { recordingNotes, recordingPitchBends, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
||||||
|
|
||||||
// Finalize any held keys
|
// Finalize any held keys
|
||||||
const finalNotes = [...recordingNotes];
|
const finalNotes = [...recordingNotes];
|
||||||
|
const finalPitchBends = [...recordingPitchBends];
|
||||||
const bpm = get().bpm;
|
const bpm = get().bpm;
|
||||||
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||||
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
||||||
@@ -949,9 +972,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
});
|
});
|
||||||
_recordingActiveNotes.clear();
|
_recordingActiveNotes.clear();
|
||||||
|
|
||||||
KGMidiInput.instance().setRecordingCallbacks(null, null);
|
if (_lastRecordedPitchBendValue !== null && _lastRecordedPitchBendValue !== MIDI_PITCH_BEND_CENTER) {
|
||||||
|
finalPitchBends.push({
|
||||||
|
beat: endBeatForHeld,
|
||||||
|
value: MIDI_PITCH_BEND_CENTER,
|
||||||
|
});
|
||||||
|
_lastRecordedPitchBendValue = MIDI_PITCH_BEND_CENTER;
|
||||||
|
}
|
||||||
|
|
||||||
if (finalNotes.length > 0 && recordingTargetRegionId) {
|
KGMidiInput.instance().setRecordingCallbacks(null, null, null);
|
||||||
|
|
||||||
|
if ((finalNotes.length > 0 || finalPitchBends.length > 0) && recordingTargetRegionId) {
|
||||||
const noteData: NoteCreationData[] = finalNotes.map(n => ({
|
const noteData: NoteCreationData[] = finalNotes.map(n => ({
|
||||||
regionId: recordingTargetRegionId,
|
regionId: recordingTargetRegionId,
|
||||||
startBeat: n.startBeat,
|
startBeat: n.startBeat,
|
||||||
@@ -959,14 +990,20 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
pitch: n.pitch,
|
pitch: n.pitch,
|
||||||
velocity: n.velocity,
|
velocity: n.velocity,
|
||||||
}));
|
}));
|
||||||
const command = new CreateNotesCommand(noteData);
|
const pitchBendData: PitchBendCreationData[] = finalPitchBends.map(event => ({
|
||||||
|
regionId: recordingTargetRegionId,
|
||||||
|
beat: event.beat,
|
||||||
|
value: event.value,
|
||||||
|
}));
|
||||||
|
const command = new CreateMidiEventsCommand(noteData, pitchBendData);
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopPlaying();
|
await stopPlaying();
|
||||||
setPlayheadPosition(recordingOriginalPlayhead);
|
setPlayheadPosition(recordingOriginalPlayhead);
|
||||||
set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
|
set({ isRecording: false, recordingNotes: [], recordingPitchBends: [], recordingTargetRegionId: null });
|
||||||
|
_lastRecordedPitchBendValue = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleLoop: () => {
|
toggleLoop: () => {
|
||||||
@@ -1297,5 +1334,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -361,13 +361,59 @@ describe('Project Store Synchronization Integration Tests', () => {
|
|||||||
expect(storeState.isRecording).toBe(false);
|
expect(storeState.isRecording).toBe(false);
|
||||||
expect(storeState.isPlaying).toBe(false);
|
expect(storeState.isPlaying).toBe(false);
|
||||||
expect(storeState.recordingNotes).toHaveLength(0);
|
expect(storeState.recordingNotes).toHaveLength(0);
|
||||||
|
expect(storeState.recordingPitchBends).toHaveLength(0);
|
||||||
expect(executeCommandSpy).toHaveBeenCalled();
|
expect(executeCommandSpy).toHaveBeenCalled();
|
||||||
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
|
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
|
||||||
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
|
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null, null);
|
||||||
expect(testRegion.getNotes()).toHaveLength(1);
|
expect(testRegion.getNotes()).toHaveLength(1);
|
||||||
expect(testRegion.getNotes()[0].getVelocity()).toBe(96);
|
expect(testRegion.getNotes()[0].getVelocity()).toBe(96);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records pitch bends and skips consecutive duplicates', async () => {
|
||||||
|
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
|
||||||
|
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
|
||||||
|
testTrack.addRegion(testRegion);
|
||||||
|
testProject.setTracks([testTrack]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProjectStore.getState().loadProject(testProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(mockAudioInterface, 'getTransportPosition')
|
||||||
|
.mockReturnValueOnce(20)
|
||||||
|
.mockReturnValueOnce(20.5)
|
||||||
|
.mockReturnValueOnce(21);
|
||||||
|
const setRecordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
|
||||||
|
const { setActiveRegionId, setPlayheadPosition, startRecording, stopTransport } = useProjectStore.getState();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setActiveRegionId(testRegion.getId());
|
||||||
|
setPlayheadPosition(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await startRecording();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPitchBend = setRecordingCallbacksSpy.mock.calls.at(-1)?.[2];
|
||||||
|
expect(onPitchBend).toBeTypeOf('function');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
onPitchBend?.(8192);
|
||||||
|
onPitchBend?.(8192);
|
||||||
|
onPitchBend?.(12288);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await stopTransport();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testRegion.getPitchBends()).toHaveLength(2);
|
||||||
|
expect(testRegion.getPitchBends().map(event => event.getValue())).toEqual([8192, 12288]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should cut a held looped recording note at the loop end when note off arrives after wrap', async () => {
|
it('should cut a held looped recording note at the loop end when note off arrives after wrap', async () => {
|
||||||
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
|
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
|
||||||
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
|
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const mockAudioInterface = {
|
|||||||
scheduleNote: vi.fn().mockReturnValue(undefined),
|
scheduleNote: vi.fn().mockReturnValue(undefined),
|
||||||
scheduleNotes: vi.fn().mockReturnValue(undefined),
|
scheduleNotes: vi.fn().mockReturnValue(undefined),
|
||||||
clearScheduledNotes: vi.fn().mockReturnValue(undefined),
|
clearScheduledNotes: vi.fn().mockReturnValue(undefined),
|
||||||
|
triggerLiveMidiNoteAttack: vi.fn().mockReturnValue(undefined),
|
||||||
|
releaseLiveMidiNote: vi.fn().mockReturnValue(undefined),
|
||||||
|
setLiveMidiPitchBend: vi.fn().mockReturnValue(undefined),
|
||||||
|
|
||||||
// Transport
|
// Transport
|
||||||
getCurrentBeat: vi.fn().mockReturnValue(0),
|
getCurrentBeat: vi.fn().mockReturnValue(0),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const mockSampler = {
|
|||||||
triggerRelease: vi.fn(),
|
triggerRelease: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
attack: 0,
|
||||||
|
release: 0.1,
|
||||||
|
curve: 'exponential',
|
||||||
|
output: {},
|
||||||
toDestination: vi.fn().mockReturnThis(),
|
toDestination: vi.fn().mockReturnThis(),
|
||||||
connect: vi.fn().mockReturnThis(),
|
connect: vi.fn().mockReturnThis(),
|
||||||
disconnect: vi.fn().mockReturnThis(),
|
disconnect: vi.fn().mockReturnThis(),
|
||||||
@@ -18,6 +22,14 @@ export const mockSampler = {
|
|||||||
get: vi.fn().mockReturnValue({}),
|
get: vi.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockBufferSource = {
|
||||||
|
playbackRate: { value: 1 },
|
||||||
|
connect: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
onended: undefined as (() => void) | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
// Mock Transport
|
// Mock Transport
|
||||||
export const mockTransport = {
|
export const mockTransport = {
|
||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
@@ -35,6 +47,26 @@ export const mockTransport = {
|
|||||||
// Mock Tone namespace
|
// Mock Tone namespace
|
||||||
export const mockTone = {
|
export const mockTone = {
|
||||||
Sampler: vi.fn().mockImplementation(() => mockSampler),
|
Sampler: vi.fn().mockImplementation(() => mockSampler),
|
||||||
|
BufferSource: vi.fn().mockImplementation(() => {
|
||||||
|
const instance = {
|
||||||
|
...mockBufferSource,
|
||||||
|
playbackRate: { value: 1 },
|
||||||
|
};
|
||||||
|
instance.connect.mockImplementation(() => instance);
|
||||||
|
instance.start.mockImplementation(() => instance);
|
||||||
|
instance.stop.mockImplementation(() => instance);
|
||||||
|
return instance;
|
||||||
|
}),
|
||||||
|
ToneBufferSource: vi.fn().mockImplementation(() => {
|
||||||
|
const instance = {
|
||||||
|
...mockBufferSource,
|
||||||
|
playbackRate: { value: 1 },
|
||||||
|
};
|
||||||
|
instance.connect.mockImplementation(() => instance);
|
||||||
|
instance.start.mockImplementation(() => instance);
|
||||||
|
instance.stop.mockImplementation(() => instance);
|
||||||
|
return instance;
|
||||||
|
}),
|
||||||
Transport: mockTransport,
|
Transport: mockTransport,
|
||||||
Buffer: vi.fn().mockImplementation(() => ({
|
Buffer: vi.fn().mockImplementation(() => ({
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
|||||||
triggerRelease: vi.fn(),
|
triggerRelease: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
attack: 0,
|
||||||
|
release: 0.1,
|
||||||
|
curve: 'exponential',
|
||||||
|
output: {},
|
||||||
volume: {
|
volume: {
|
||||||
value: -12
|
value: -12
|
||||||
},
|
},
|
||||||
@@ -20,6 +24,19 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
|||||||
toDestination: vi.fn()
|
toDestination: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const MockBufferSource = vi.fn().mockImplementation((options?: { playbackRate?: number }) => {
|
||||||
|
const instance = {
|
||||||
|
playbackRate: {
|
||||||
|
value: options?.playbackRate ?? 1,
|
||||||
|
},
|
||||||
|
connect: vi.fn(() => instance),
|
||||||
|
start: vi.fn(() => instance),
|
||||||
|
stop: vi.fn(() => instance),
|
||||||
|
onended: undefined as (() => void) | undefined,
|
||||||
|
};
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
|
||||||
// Mock Transport object
|
// Mock Transport object
|
||||||
export const MockTransport = {
|
export const MockTransport = {
|
||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
@@ -94,6 +111,8 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
|
|||||||
// Complete Tone.js mock
|
// Complete Tone.js mock
|
||||||
export const ToneMock = {
|
export const ToneMock = {
|
||||||
Sampler: MockSampler,
|
Sampler: MockSampler,
|
||||||
|
BufferSource: MockBufferSource,
|
||||||
|
ToneBufferSource: MockBufferSource,
|
||||||
Loop: MockLoop,
|
Loop: MockLoop,
|
||||||
Transport: MockTransport,
|
Transport: MockTransport,
|
||||||
Destination: MockDestination,
|
Destination: MockDestination,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||||
import { KGProject } from '../../core/KGProject';
|
import { KGProject } from '../../core/KGProject';
|
||||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
@@ -41,6 +42,7 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
startFromBeat: number
|
startFromBeat: number
|
||||||
length: number
|
length: number
|
||||||
notes: KGMidiNote[]
|
notes: KGMidiNote[]
|
||||||
|
pitchBends: KGMidiPitchBend[]
|
||||||
}> = {}): KGMidiRegion => {
|
}> = {}): KGMidiRegion => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
id: 'test-region-1',
|
id: 'test-region-1',
|
||||||
@@ -65,10 +67,28 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
if (overrides.notes) {
|
if (overrides.notes) {
|
||||||
overrides.notes.forEach(note => region.addNote(note));
|
overrides.notes.forEach(note => region.addNote(note));
|
||||||
}
|
}
|
||||||
|
if (overrides.pitchBends) {
|
||||||
|
overrides.pitchBends.forEach(pitchBend => region.addPitchBend(pitchBend));
|
||||||
|
}
|
||||||
|
|
||||||
return region;
|
return region;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createMockMidiPitchBend = (overrides: Partial<{
|
||||||
|
id: string
|
||||||
|
beat: number
|
||||||
|
value: number
|
||||||
|
}> = {}): KGMidiPitchBend => {
|
||||||
|
const defaults = {
|
||||||
|
id: 'test-bend-1',
|
||||||
|
beat: 0,
|
||||||
|
value: 8192,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new KGMidiPitchBend(defaults.id, defaults.beat, defaults.value);
|
||||||
|
};
|
||||||
|
|
||||||
export const createMockMidiTrack = (overrides: Partial<{
|
export const createMockMidiTrack = (overrides: Partial<{
|
||||||
name: string
|
name: string
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export const pianoRollIndexToPitch = (index: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MIDI_EVENT_TICKS_PER_BEAT = 480;
|
export const MIDI_EVENT_TICKS_PER_BEAT = 480;
|
||||||
|
export const MIDI_PITCH_BEND_MIN = 0;
|
||||||
|
export const MIDI_PITCH_BEND_CENTER = 8192;
|
||||||
|
export const MIDI_PITCH_BEND_MAX = 16383;
|
||||||
|
export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
|
||||||
|
export const MIDI_PITCH_BEND_MAX_SIGNED = 8191;
|
||||||
|
|
||||||
export const pitchToNoteName = (pitch: number) => {
|
export const pitchToNoteName = (pitch: number) => {
|
||||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
@@ -36,6 +41,22 @@ export const pitchToNoteNameString = (pitch: number) => {
|
|||||||
return `${note}${octave}`;
|
return `${note}${octave}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clampMidiPitchBendValue = (value: number): number => (
|
||||||
|
Math.max(MIDI_PITCH_BEND_MIN, Math.min(MIDI_PITCH_BEND_MAX, Math.round(value)))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const midiPitchBendToSignedValue = (value: number): number => (
|
||||||
|
clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
|
||||||
|
);
|
||||||
|
|
||||||
|
export const signedPitchBendToMidiValue = (value: number): number => (
|
||||||
|
clampMidiPitchBendValue(value + MIDI_PITCH_BEND_CENTER)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const midiPitchBendToNormalized = (value: number): number => (
|
||||||
|
midiPitchBendToSignedValue(value) / MIDI_PITCH_BEND_CENTER
|
||||||
|
);
|
||||||
|
|
||||||
export const noteNameToPitch = (noteName: string): number => {
|
export const noteNameToPitch = (noteName: string): number => {
|
||||||
const noteMap: { [key: string]: number } = {
|
const noteMap: { [key: string]: number } = {
|
||||||
'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3,
|
'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3,
|
||||||
|
|||||||
Reference in New Issue
Block a user