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();
|
||||
});
|
||||
});
|
||||
+477
-310
@@ -6,22 +6,31 @@ import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import {
|
||||
clampMidiPitchBendValue,
|
||||
formatMidiEventLength,
|
||||
formatMidiEventPosition,
|
||||
MIDI_EVENT_TICKS_PER_BEAT,
|
||||
MIDI_PITCH_BEND_CENTER,
|
||||
MIDI_PITCH_BEND_MAX_SIGNED,
|
||||
MIDI_PITCH_BEND_MIN_SIGNED,
|
||||
midiPitchBendToNormalized,
|
||||
midiPitchBendToSignedValue,
|
||||
noteNameToPitch,
|
||||
parseMidiEventLengthDelta,
|
||||
parseMidiEventLength,
|
||||
parseMidiEventPositionDelta,
|
||||
parseMidiEventPosition,
|
||||
pitchToNoteNameString
|
||||
pitchToNoteNameString,
|
||||
signedPitchBendToMidiValue
|
||||
} from '../util/midiUtil';
|
||||
import { isModifierKeyPressed } from '../util/osUtil';
|
||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||
import { CreateNoteCommand } from '../core/commands';
|
||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||
import { showAlert } from '../util/dialogUtil';
|
||||
|
||||
interface ListEventPanelProps {
|
||||
@@ -30,21 +39,28 @@ interface ListEventPanelProps {
|
||||
|
||||
interface NoteRowData {
|
||||
id: string;
|
||||
type: 'note';
|
||||
note: KGMidiNote;
|
||||
absoluteStartBeat: number;
|
||||
durationBeats: number;
|
||||
}
|
||||
|
||||
interface PitchBendRowData {
|
||||
id: string;
|
||||
type: 'pitch-bend';
|
||||
pitchBend: KGMidiPitchBend;
|
||||
absoluteBeat: number;
|
||||
}
|
||||
|
||||
type EventRowData = NoteRowData | PitchBendRowData;
|
||||
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
||||
|
||||
interface EditingCell {
|
||||
noteId: string;
|
||||
eventId: string;
|
||||
column: EditableColumn;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
|
||||
|
||||
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
@@ -85,6 +101,35 @@ const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string
|
||||
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 {
|
||||
tracks,
|
||||
@@ -92,16 +137,18 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
selectedRegionIds,
|
||||
timeSignature,
|
||||
selectedNoteIds,
|
||||
selectedPitchBendIds,
|
||||
playheadPosition,
|
||||
updateTrack,
|
||||
refreshProjectState
|
||||
} = useProjectStore();
|
||||
|
||||
const [eventType, setEventType] = useState('notes');
|
||||
const [showNotes, setShowNotes] = useState(true);
|
||||
const [showPitchBends, setShowPitchBends] = useState(true);
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
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 suppressBlurCommitRef = useRef(false);
|
||||
const pendingSingleClickSelectionRef = useRef<number | null>(null);
|
||||
@@ -127,28 +174,45 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
}
|
||||
|
||||
const noteRows: NoteRowData[] = activeMidiRegion
|
||||
? [...activeMidiRegion.getNotes()]
|
||||
.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(),
|
||||
note,
|
||||
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
|
||||
durationBeats: note.getEndBeat() - note.getStartBeat()
|
||||
}))
|
||||
? activeMidiRegion.getNotes().map(note => ({
|
||||
id: note.getId(),
|
||||
type: 'note',
|
||||
note,
|
||||
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + 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 selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
||||
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCell) {
|
||||
editInputRef.current?.focus();
|
||||
editInputRef.current?.select();
|
||||
}
|
||||
}, [editingCell?.noteId, editingCell?.column]);
|
||||
}, [editingCell?.eventId, editingCell?.column]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -161,20 +225,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const commitSelection = (nextSelectedIds: Set<string>) => {
|
||||
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();
|
||||
|
||||
activeMidiRegion.getNotes().forEach(note => {
|
||||
if (nextSelectedIds.has(note.getId())) {
|
||||
note.select();
|
||||
} else {
|
||||
note.deselect();
|
||||
}
|
||||
if (nextSelectedIds.has(note.getId())) note.select();
|
||||
else note.deselect();
|
||||
});
|
||||
activeMidiRegion.getPitchBends().forEach(pitchBend => {
|
||||
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
||||
else pitchBend.deselect();
|
||||
});
|
||||
|
||||
core.clearSelectedItems();
|
||||
if (selectedNotes.length > 0) {
|
||||
core.addSelectedItems(selectedNotes);
|
||||
if (selectedEvents.length > 0) {
|
||||
core.addSelectedItems(selectedEvents);
|
||||
}
|
||||
|
||||
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();
|
||||
setEditingCell({ noteId, column, value });
|
||||
setEditingCell({ eventId, column, value });
|
||||
};
|
||||
|
||||
const cancelEditingCell = () => {
|
||||
@@ -199,194 +266,283 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const commitEditingCell = async () => {
|
||||
if (!editingCell || !activeMidiRegion || !parentTrack) return;
|
||||
|
||||
const note = activeMidiRegion.getNotes().find(candidate => candidate.getId() === editingCell.noteId);
|
||||
if (!note) {
|
||||
const row = eventRows.find(candidate => candidate.id === editingCell.eventId);
|
||||
if (!row) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
|
||||
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
|
||||
: [note];
|
||||
|
||||
const snapshots = targetNotes.map(targetNote => ({
|
||||
noteId: targetNote.getId(),
|
||||
pitch: targetNote.getPitch(),
|
||||
velocity: targetNote.getVelocity(),
|
||||
startBeat: targetNote.getStartBeat(),
|
||||
endBeat: targetNote.getEndBeat()
|
||||
}));
|
||||
|
||||
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 (isDeltaEdit) {
|
||||
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
if (row.type === 'note') {
|
||||
const note = row.note;
|
||||
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
|
||||
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
|
||||
: [note];
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
|
||||
if (nextStartBeat < 0) {
|
||||
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
|
||||
const snapshots = targetNotes.map(targetNote => ({
|
||||
noteId: targetNote.getId(),
|
||||
pitch: targetNote.getPitch(),
|
||||
velocity: targetNote.getVelocity(),
|
||||
startBeat: targetNote.getStartBeat(),
|
||||
endBeat: targetNote.getEndBeat()
|
||||
}));
|
||||
|
||||
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: 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;
|
||||
}
|
||||
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
startBeat: nextStartBeat,
|
||||
endBeat: nextStartBeat + currentDuration
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
|
||||
if (nextStartBeat < 0) {
|
||||
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
|
||||
return;
|
||||
}
|
||||
|
||||
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
|
||||
if (relativeStartBeat < 0) {
|
||||
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
startBeat: relativeStartBeat,
|
||||
endBeat: relativeStartBeat + currentDuration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'num') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parsePitchDeltaInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const nextPitch = targetNote.getPitch() + parsed.delta;
|
||||
if (nextPitch < 0 || nextPitch > 127) {
|
||||
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0–127.');
|
||||
return;
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
startBeat: nextStartBeat,
|
||||
endBeat: nextStartBeat + currentDuration
|
||||
});
|
||||
}
|
||||
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
|
||||
}
|
||||
} else {
|
||||
const parsed = parseNoteNameInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'val') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parseVelocityDeltaInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const nextVelocity = targetNote.getVelocity() + parsed.delta;
|
||||
if (nextVelocity < 0 || nextVelocity > 127) {
|
||||
await showAlert('Velocity delta would move one or more notes outside the valid range 0–127.');
|
||||
return;
|
||||
}
|
||||
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
|
||||
}
|
||||
} else {
|
||||
const parsed = parseVelocityInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'length') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
const nextDuration = currentDuration + parsed.deltaBeats;
|
||||
if (nextDuration <= 0) {
|
||||
await showAlert('Length delta would make one or more notes non-positive in duration.');
|
||||
} else {
|
||||
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
endBeat: targetNote.getStartBeat() + nextDuration
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
|
||||
if (relativeStartBeat < 0) {
|
||||
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
endBeat: targetNote.getStartBeat() + parsed.duration
|
||||
});
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
startBeat: relativeStartBeat,
|
||||
endBeat: relativeStartBeat + currentDuration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'num') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parsePitchDeltaInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const nextPitch = targetNote.getPitch() + parsed.delta;
|
||||
if (nextPitch < 0 || nextPitch > 127) {
|
||||
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0–127.');
|
||||
return;
|
||||
}
|
||||
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
|
||||
}
|
||||
} else {
|
||||
const parsed = parseNoteNameInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'val') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parseVelocityDeltaInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const nextVelocity = targetNote.getVelocity() + parsed.delta;
|
||||
if (nextVelocity < 0 || nextVelocity > 127) {
|
||||
await showAlert('Velocity delta would move one or more notes outside the valid range 0–127.');
|
||||
return;
|
||||
}
|
||||
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
|
||||
}
|
||||
} else {
|
||||
const parsed = parseVelocityInput(trimmedValue);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingCell.column === 'length') {
|
||||
if (isDeltaEdit) {
|
||||
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||
const nextDuration = currentDuration + parsed.deltaBeats;
|
||||
if (nextDuration <= 0) {
|
||||
await showAlert('Length delta would make one or more notes non-positive in duration.');
|
||||
return;
|
||||
}
|
||||
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
endBeat: targetNote.getStartBeat() + nextDuration
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
if ('error' in parsed) {
|
||||
await showAlert(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const targetNote of targetNotes) {
|
||||
updates.push({
|
||||
noteId: targetNote.getId(),
|
||||
endBeat: targetNote.getStartBeat() + parsed.duration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
||||
} 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));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates);
|
||||
KGCore.instance().executeCommand(command);
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
setEditingCell(null);
|
||||
};
|
||||
|
||||
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
const handleRowClick = (eventId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
event.stopPropagation();
|
||||
if (editingCell) return;
|
||||
if (!activeMidiRegion) return;
|
||||
|
||||
const isModifierPressed = isModifierKeyPressed(event);
|
||||
const nextSelectedIds = new Set(selectedNoteIdSet);
|
||||
const isAlreadySelected = selectedNoteIdSet.has(noteId);
|
||||
const hasMultiSelection = selectedNoteIds.length > 1;
|
||||
const nextSelectedIds = new Set(selectedEventIdSet);
|
||||
const isAlreadySelected = selectedEventIdSet.has(eventId);
|
||||
const hasMultiSelection = selectedEventIdSet.size > 1;
|
||||
|
||||
if (event.shiftKey) {
|
||||
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 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) {
|
||||
nextSelectedIds.add(noteRows[index].id);
|
||||
nextSelectedIds.add(eventRows[index].id);
|
||||
}
|
||||
} else if (isModifierPressed) {
|
||||
clearPendingSingleClickSelection();
|
||||
if (nextSelectedIds.has(noteId)) {
|
||||
nextSelectedIds.delete(noteId);
|
||||
if (nextSelectedIds.has(eventId)) {
|
||||
nextSelectedIds.delete(eventId);
|
||||
} else {
|
||||
nextSelectedIds.add(noteId);
|
||||
nextSelectedIds.add(eventId);
|
||||
}
|
||||
rangeAnchorNoteIdRef.current = noteId;
|
||||
rangeAnchorEventIdRef.current = eventId;
|
||||
} else {
|
||||
if (isAlreadySelected && hasMultiSelection) {
|
||||
clearPendingSingleClickSelection();
|
||||
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
|
||||
const delayedSelection = new Set<string>([noteId]);
|
||||
rangeAnchorNoteIdRef.current = noteId;
|
||||
const delayedSelection = new Set<string>([eventId]);
|
||||
rangeAnchorEventIdRef.current = eventId;
|
||||
commitSelection(delayedSelection);
|
||||
pendingSingleClickSelectionRef.current = null;
|
||||
}, 220);
|
||||
@@ -419,12 +575,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
|
||||
clearPendingSingleClickSelection();
|
||||
nextSelectedIds.clear();
|
||||
nextSelectedIds.add(noteId);
|
||||
rangeAnchorNoteIdRef.current = noteId;
|
||||
nextSelectedIds.add(eventId);
|
||||
rangeAnchorEventIdRef.current = eventId;
|
||||
}
|
||||
|
||||
if (event.shiftKey && rangeAnchorNoteIdRef.current === null) {
|
||||
rangeAnchorNoteIdRef.current = noteId;
|
||||
if (event.shiftKey && rangeAnchorEventIdRef.current === null) {
|
||||
rangeAnchorEventIdRef.current = eventId;
|
||||
}
|
||||
|
||||
commitSelection(nextSelectedIds);
|
||||
@@ -434,7 +590,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
event.stopPropagation();
|
||||
if (event.target !== event.currentTarget) return;
|
||||
clearPendingSingleClickSelection();
|
||||
rangeAnchorNoteIdRef.current = null;
|
||||
rangeAnchorEventIdRef.current = null;
|
||||
commitSelection(new Set());
|
||||
};
|
||||
|
||||
@@ -552,7 +708,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
createdNote.select();
|
||||
KGCore.instance().clearSelectedItems();
|
||||
KGCore.instance().addSelectedItem(createdNote);
|
||||
rangeAnchorNoteIdRef.current = createdNote.getId();
|
||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||
}
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
@@ -566,9 +722,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
|
||||
<div className="list-event-panel-body">
|
||||
<div className="list-event-tabs" role="tablist" aria-label="Event types">
|
||||
<button className="list-event-tab active" aria-pressed="true">Notes</button>
|
||||
<button className="list-event-tab" aria-pressed="false">Pitch Bends</button>
|
||||
<button className="list-event-tab" aria-pressed="false">Controller</button>
|
||||
<button
|
||||
className={`list-event-tab${showNotes ? ' active' : ''}`}
|
||||
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>
|
||||
|
||||
{!activeMidiRegion ? (
|
||||
@@ -587,14 +757,6 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={EVENT_TYPE_OPTIONS}
|
||||
value={eventType}
|
||||
onChange={setEventType}
|
||||
label="Event Type"
|
||||
buttonClassName="list-event-dropdown-button"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||
@@ -638,113 +800,118 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{noteRows.map((row, index) => (
|
||||
(() => {
|
||||
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
const statusText = 'Note';
|
||||
const noteText = pitchToNoteNameString(row.note.getPitch());
|
||||
const velocityText = String(row.note.getVelocity());
|
||||
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
const isEditingPosition = editingCell?.noteId === row.id && editingCell.column === 'position';
|
||||
const isEditingNum = editingCell?.noteId === row.id && editingCell.column === 'num';
|
||||
const isEditingVal = editingCell?.noteId === row.id && editingCell.column === 'val';
|
||||
const isEditingLength = editingCell?.noteId === row.id && editingCell.column === 'length';
|
||||
{eventRows.map((row, index) => {
|
||||
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
||||
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||
const statusText = row.type === 'note' ? 'Note' : 'Pitch Bend';
|
||||
const numText = row.type === 'note' ? pitchToNoteNameString(row.note.getPitch()) : '';
|
||||
const valText = row.type === 'note'
|
||||
? String(row.note.getVelocity())
|
||||
: String(midiPitchBendToSignedValue(row.pitchBend.getValue()));
|
||||
const lengthText = row.type === 'note'
|
||||
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
|
||||
: 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 (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearPendingSingleClickSelection();
|
||||
}}
|
||||
>
|
||||
<td
|
||||
title={positionText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearPendingSingleClickSelection();
|
||||
startEditingCell(row.id, 'position', positionText);
|
||||
}}
|
||||
>
|
||||
<td
|
||||
title={positionText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'position', positionText);
|
||||
}}
|
||||
>
|
||||
{isEditingPosition ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : positionText}
|
||||
</td>
|
||||
<td title={statusText}>{statusText}</td>
|
||||
<td
|
||||
title={noteText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'num', noteText);
|
||||
}}
|
||||
>
|
||||
{isEditingNum ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : noteText}
|
||||
</td>
|
||||
<td
|
||||
title={velocityText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'val', velocityText);
|
||||
}}
|
||||
>
|
||||
{isEditingVal ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : velocityText}
|
||||
</td>
|
||||
<td
|
||||
title={lengthText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'length', lengthText);
|
||||
}}
|
||||
>
|
||||
{isEditingLength ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : lengthText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{isEditingPosition ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : positionText}
|
||||
</td>
|
||||
<td title={statusText}>{statusText}</td>
|
||||
<td
|
||||
title={numText}
|
||||
onDoubleClick={(event) => {
|
||||
if (row.type !== 'note') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'num', numText);
|
||||
}}
|
||||
>
|
||||
{isEditingNum ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : numText}
|
||||
</td>
|
||||
<td
|
||||
title={valText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'val', valText);
|
||||
}}
|
||||
>
|
||||
{isEditingVal ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : valText}
|
||||
</td>
|
||||
<td
|
||||
title={lengthText}
|
||||
onDoubleClick={(event) => {
|
||||
if (row.type !== 'note') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'length', lengthText);
|
||||
}}
|
||||
>
|
||||
{isEditingLength ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : lengthText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KGProjectStorage } from './io/KGProjectStorage';
|
||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { KGMidiNote } from './midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||
import { KGRegion } from './region/KGRegion';
|
||||
import { generateUniqueId } from '../util/miscUtil';
|
||||
import { KGCommand, KGCommandHistory } from './commands';
|
||||
@@ -551,6 +552,13 @@ export class KGCore {
|
||||
);
|
||||
clonedRegion.addNote(clonedNote);
|
||||
});
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
clonedRegion.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
});
|
||||
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MockBufferSource, MockSampler } from '../../test/mocks/tone';
|
||||
|
||||
vi.mock('tone', async () => {
|
||||
const { ToneMock } = await import('../../test/mocks/tone');
|
||||
return ToneMock;
|
||||
});
|
||||
|
||||
const getToneAudioBuffersMock = vi.fn();
|
||||
const createSamplerMock = vi.fn();
|
||||
|
||||
vi.mock('./KGToneBuffersPool', () => ({
|
||||
KGToneBuffersPool: {
|
||||
instance: () => ({
|
||||
getToneAudioBuffers: getToneAudioBuffersMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./KGToneSamplerFactory', () => ({
|
||||
KGToneSamplerFactory: {
|
||||
instance: () => ({
|
||||
createSampler: createSamplerMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
|
||||
describe('KGAudioBus live MIDI pitch bend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockSampler.mockClear();
|
||||
MockBufferSource.mockClear();
|
||||
|
||||
const sampler = MockSampler();
|
||||
createSamplerMock.mockResolvedValue(sampler);
|
||||
getToneAudioBuffersMock.mockResolvedValue({
|
||||
loaded: true,
|
||||
has: (key: string) => key === 'C4',
|
||||
get: (key: string) => key === 'C4' ? { duration: 1 } : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('retunes held live MIDI notes when pitch bend changes', async () => {
|
||||
const audioBus = await KGAudioBus.create('acoustic_grand_piano');
|
||||
|
||||
audioBus.triggerLiveMidiAttack(60, 0, 1);
|
||||
|
||||
expect(MockBufferSource).toHaveBeenCalledTimes(1);
|
||||
const source = MockBufferSource.mock.results[0].value;
|
||||
expect(source.start).toHaveBeenCalledWith(0, 0, 1, 1);
|
||||
expect(source.playbackRate.value).toBeCloseTo(1, 5);
|
||||
|
||||
audioBus.setLiveMidiPitchBend(1);
|
||||
|
||||
expect(source.playbackRate.value).toBeCloseTo(Math.pow(2, 2 / 12), 5);
|
||||
});
|
||||
|
||||
it('stops active live MIDI sources on release and reset paths', async () => {
|
||||
const audioBus = await KGAudioBus.create('acoustic_grand_piano');
|
||||
|
||||
audioBus.triggerLiveMidiAttack(60, 0, 0.5);
|
||||
const source = MockBufferSource.mock.results[0].value;
|
||||
|
||||
audioBus.releaseLiveMidiNote(60, 1.25);
|
||||
expect(source.stop).toHaveBeenCalledWith(1.25);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,39 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
|
||||
// InstrumentType is defined in KGMidiTrack and re-used here
|
||||
|
||||
interface LiveMidiSource {
|
||||
source: Tone.ToneBufferSource;
|
||||
basePlaybackRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGAudioBus - Represents a complete audio bus for a track
|
||||
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
||||
* Each instance manages a single track's audio processing chain
|
||||
*/
|
||||
export class KGAudioBus {
|
||||
// Fixed at +/-2 semitones for now. Future work: make this user-configurable
|
||||
// or honor MIDI RPN 0,0 (Pitch Bend Sensitivity).
|
||||
private static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2;
|
||||
private static readonly LIVE_MIDI_NOTE_NAMES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Core audio components
|
||||
private sampler: Tone.Sampler;
|
||||
private audioBuffers: Tone.ToneAudioBuffers;
|
||||
private instrument: InstrumentType;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
private liveMidiPitchBend: number = 0;
|
||||
private liveMidiSources: Map<number, LiveMidiSource[]> = new Map();
|
||||
|
||||
// Audio processing chain (for future expansion)
|
||||
// private gain: Tone.Gain;
|
||||
@@ -29,12 +44,14 @@ export class KGAudioBus {
|
||||
*/
|
||||
private constructor(
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
instrument: InstrumentType,
|
||||
volume: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = instrument;
|
||||
this.volume = volume;
|
||||
this.muted = muted;
|
||||
@@ -60,11 +77,15 @@ export class KGAudioBus {
|
||||
console.log(`Creating KGAudioBus for ${instrument}...`);
|
||||
|
||||
// Create the sampler using the factory
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const sampler = await samplerFactory.createSampler(String(instrument));
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const [sampler, audioBuffers] = await Promise.all([
|
||||
samplerFactory.createSampler(String(instrument)),
|
||||
buffersPool.getToneAudioBuffers(String(instrument)),
|
||||
]);
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, instrument, volume, muted, solo);
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, volume, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
@@ -117,6 +138,42 @@ export class KGAudioBus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack for live MIDI keyboard monitoring.
|
||||
* This path tracks the underlying buffer sources so pitch bend can retune held notes.
|
||||
*/
|
||||
public triggerLiveMidiAttack(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
this.triggerPitchBendAwareAttack(pitch, time, velocity);
|
||||
}
|
||||
|
||||
public triggerPitchBendAwareAttack(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number,
|
||||
duration?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const liveSource = this.createPitchBendAwareSource(pitch, time, velocity, duration);
|
||||
if (!liveSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSources = this.liveMidiSources.get(pitch) ?? [];
|
||||
activeSources.push(liveSource);
|
||||
this.liveMidiSources.set(pitch, activeSources);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering live MIDI attack for pitch ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for ending sustained notes like piano key releases
|
||||
@@ -132,12 +189,61 @@ export class KGAudioBus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a live MIDI note and forget any active bent sources tied to that pitch.
|
||||
*/
|
||||
public releaseLiveMidiNote(pitch: number, time?: number): void {
|
||||
try {
|
||||
const activeSources = this.liveMidiSources.get(pitch);
|
||||
if (!activeSources || activeSources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopTime = time ?? Tone.now();
|
||||
activeSources.forEach(({ source }) => {
|
||||
try {
|
||||
source.stop(stopTime);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping live MIDI source for pitch ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
});
|
||||
this.liveMidiSources.delete(pitch);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing live MIDI note ${pitch} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public setLiveMidiPitchBend(normalizedBend: number): void {
|
||||
this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend));
|
||||
|
||||
for (const activeSources of this.liveMidiSources.values()) {
|
||||
activeSources.forEach(({ source, basePlaybackRate }) => {
|
||||
source.playbackRate.value = this.applyPitchBendToPlaybackRate(basePlaybackRate);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public resetLiveMidiPitchBend(): void {
|
||||
this.setLiveMidiPitchBend(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all currently playing notes
|
||||
*/
|
||||
public releaseAll(): void {
|
||||
try {
|
||||
this.sampler.releaseAll();
|
||||
this.liveMidiSources.forEach((activeSources) => {
|
||||
activeSources.forEach(({ source }) => {
|
||||
try {
|
||||
source.stop();
|
||||
} catch (error) {
|
||||
console.error(`Error stopping live MIDI source on ${this.instrument}:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.liveMidiSources.clear();
|
||||
this.resetLiveMidiPitchBend();
|
||||
} catch (error) {
|
||||
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
||||
}
|
||||
@@ -206,12 +312,20 @@ export class KGAudioBus {
|
||||
try {
|
||||
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
||||
|
||||
this.releaseAll();
|
||||
|
||||
// Dispose of the current sampler
|
||||
this.sampler.dispose();
|
||||
|
||||
// Create new sampler with new instrument
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
this.sampler = await samplerFactory.createSampler(String(newInstrument));
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const [sampler, audioBuffers] = await Promise.all([
|
||||
samplerFactory.createSampler(String(newInstrument)),
|
||||
buffersPool.getToneAudioBuffers(String(newInstrument)),
|
||||
]);
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = newInstrument;
|
||||
|
||||
// Restore volume settings
|
||||
@@ -269,6 +383,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
this.releaseAll();
|
||||
this.sampler.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
@@ -352,4 +467,80 @@ export class KGAudioBus {
|
||||
solo: this.solo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private createPitchBendAwareSource(
|
||||
pitch: number,
|
||||
time?: number,
|
||||
velocity?: number,
|
||||
duration?: number
|
||||
): LiveMidiSource | null {
|
||||
const closestPitch = this.findClosestBufferedPitch(pitch);
|
||||
if (closestPitch === null) {
|
||||
console.warn(`No audio buffer found for live MIDI pitch ${pitch} on ${this.instrument}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bufferKey = this.midiPitchToBufferKey(closestPitch);
|
||||
const buffer = this.audioBuffers.get(bufferKey);
|
||||
if (!buffer) {
|
||||
console.warn(`Missing audio buffer ${bufferKey} for ${this.instrument}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
|
||||
const source = new Tone.ToneBufferSource({
|
||||
url: buffer,
|
||||
fadeIn: this.sampler.attack,
|
||||
fadeOut: this.sampler.release,
|
||||
curve: this.sampler.curve,
|
||||
playbackRate: this.applyPitchBendToPlaybackRate(basePlaybackRate),
|
||||
}).connect(this.sampler.output);
|
||||
|
||||
source.onended = () => {
|
||||
const currentSources = this.liveMidiSources.get(pitch);
|
||||
if (!currentSources) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSources = currentSources.filter((entry) => entry.source !== source);
|
||||
if (nextSources.length === 0) {
|
||||
this.liveMidiSources.delete(pitch);
|
||||
} else {
|
||||
this.liveMidiSources.set(pitch, nextSources);
|
||||
}
|
||||
};
|
||||
|
||||
source.start(time, 0, duration ?? buffer.duration / basePlaybackRate, velocity ?? 1);
|
||||
return { source, basePlaybackRate };
|
||||
}
|
||||
|
||||
private applyPitchBendToPlaybackRate(basePlaybackRate: number): number {
|
||||
const bendSemitones = this.liveMidiPitchBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES;
|
||||
return basePlaybackRate * Math.pow(2, bendSemitones / 12);
|
||||
}
|
||||
|
||||
private findClosestBufferedPitch(targetPitch: number): number | null {
|
||||
const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[this.instrument]?.pitchRange || [21, 108];
|
||||
const boundedPitch = Math.max(minPitch, Math.min(maxPitch, targetPitch));
|
||||
|
||||
for (let offset = 0; offset <= 96; offset++) {
|
||||
const upwardPitch = boundedPitch + offset;
|
||||
if (upwardPitch <= maxPitch && this.audioBuffers.has(this.midiPitchToBufferKey(upwardPitch))) {
|
||||
return upwardPitch;
|
||||
}
|
||||
|
||||
const downwardPitch = boundedPitch - offset;
|
||||
if (downwardPitch >= minPitch && this.audioBuffers.has(this.midiPitchToBufferKey(downwardPitch))) {
|
||||
return downwardPitch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private midiPitchToBufferKey(pitch: number): string {
|
||||
const octave = Math.floor((pitch - 12) / 12);
|
||||
const noteIndex = (pitch - 12) % 12;
|
||||
return `${KGAudioBus.LIVE_MIDI_NOTE_NAMES[noteIndex]}${octave}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { KGProject } from '../KGProject';
|
||||
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import { midiPitchBendToNormalized, pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
@@ -399,6 +400,7 @@ export class KGAudioInterface {
|
||||
// Clear any existing scheduled events
|
||||
this.clearScheduledEvents();
|
||||
this.clearDelayedTransportStart();
|
||||
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||
|
||||
console.log("Preparing playback");
|
||||
|
||||
@@ -476,20 +478,31 @@ export class KGAudioInterface {
|
||||
|
||||
// Schedule MIDI track events
|
||||
if (audioBus && track.getType() === 'MIDI') {
|
||||
const trackPitchBends: Array<{ pitchBend: KGMidiPitchBend; absoluteBeat: number }> = [];
|
||||
const trackNotes: Array<{ note: KGMidiNote; absoluteStartBeat: number; absoluteEndBeat: number }> = [];
|
||||
|
||||
track.getRegions().forEach(region => {
|
||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
||||
|
||||
if (region.getCurrentType() === 'KGMidiRegion') {
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[]; getPitchBends: () => KGMidiPitchBend[] };
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
|
||||
if (midiRegion.getPitchBends) {
|
||||
midiRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
|
||||
trackPitchBends.push({
|
||||
pitchBend,
|
||||
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get notes from region (assuming it has a getNotes method)
|
||||
if (midiRegion.getNotes) {
|
||||
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
||||
// Calculate absolute note timing in beats (note position + region start position)
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
||||
|
||||
// Skip notes outside loop range when looping
|
||||
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
||||
@@ -500,33 +513,69 @@ export class KGAudioInterface {
|
||||
if (noteStartBeat < startPosition) {
|
||||
return; // Skip notes that would have already finished before playback starts
|
||||
}
|
||||
|
||||
// Convert beats to Tone.js time format for scheduling
|
||||
const noteStartTime = this.beatsToToneTime(noteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
|
||||
// Convert MIDI note number to note name
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
|
||||
);
|
||||
|
||||
// Schedule the note with delay offset
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
trackNotes.push({
|
||||
note,
|
||||
absoluteStartBeat: noteStartBeat,
|
||||
absoluteEndBeat: noteEndBeat,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const boundedTrackPitchBends = trackPitchBends
|
||||
.filter(({ absoluteBeat }) => absoluteBeat >= scheduleStartBeat && absoluteBeat < scheduleEndBeat)
|
||||
.sort((a, b) => a.absoluteBeat - b.absoluteBeat);
|
||||
const initialPitchBend = [...boundedTrackPitchBends]
|
||||
.reverse()
|
||||
.find(({ absoluteBeat }) => absoluteBeat <= startPosition);
|
||||
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBend?.pitchBend.getValue() ?? 8192));
|
||||
|
||||
if (isLooping && !boundedTrackPitchBends.some(({ absoluteBeat }) => absoluteBeat === scheduleStartBeat)) {
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.setLiveMidiPitchBend(0);
|
||||
}
|
||||
}, this.beatsToToneTime(scheduleStartBeat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
}
|
||||
|
||||
boundedTrackPitchBends.forEach(({ pitchBend, absoluteBeat }) => {
|
||||
if (absoluteBeat < startPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule(() => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(pitchBend.getValue()));
|
||||
}
|
||||
}, this.beatsToToneTime(absoluteBeat));
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
|
||||
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
||||
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
|
||||
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
const velocity = note.getVelocity() / 127;
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
|
||||
);
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, Tone.Time(noteDuration).toSeconds());
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule audio/wav track events
|
||||
@@ -771,6 +820,31 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack for live MIDI keyboard monitoring.
|
||||
* Unlike piano-roll audition, this path tracks active sources so pitch bend can retune them.
|
||||
*/
|
||||
public triggerLiveMidiNoteAttack(trackId: string, pitch: number, velocity: number = 127, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedVelocity = velocity / 127;
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerLiveMidiAttack(pitch, triggerTime, normalizedVelocity);
|
||||
console.log(`Triggered live MIDI attack for pitch ${pitch} on track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering live MIDI note attack for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for piano key release
|
||||
@@ -793,6 +867,37 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public releaseLiveMidiNote(trackId: string, pitch: number, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseTime = time ?? Tone.now();
|
||||
audioBus.releaseLiveMidiNote(pitch, releaseTime);
|
||||
console.log(`Released live MIDI note ${pitch} on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing live MIDI note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public setLiveMidiPitchBend(trackId: string, normalizedBend: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
audioBus.setLiveMidiPitchBend(normalizedBend);
|
||||
console.log(`Set live MIDI pitch bend to ${normalizedBend} on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error setting live MIDI pitch bend for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled events
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,8 @@ export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||
export { CreateMidiEventsCommand, type PitchBendCreationData, type NoteCreationData } from './note/CreateMidiEventsCommand';
|
||||
|
||||
// Project commands
|
||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface NoteCreationData {
|
||||
regionId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export interface PitchBendCreationData {
|
||||
regionId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
pitchBendId?: string;
|
||||
}
|
||||
|
||||
export class CreateMidiEventsCommand extends KGCommand {
|
||||
private noteCreationData: NoteCreationData[];
|
||||
private pitchBendCreationData: PitchBendCreationData[];
|
||||
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
|
||||
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
|
||||
|
||||
constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
|
||||
super();
|
||||
this.noteCreationData = noteCreationData.map(data => ({
|
||||
...data,
|
||||
noteId: data.noteId || generateUniqueId('KGMidiNote'),
|
||||
}));
|
||||
this.pitchBendCreationData = pitchBendCreationData.map(data => ({
|
||||
...data,
|
||||
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
this.createdNotes = [];
|
||||
this.createdPitchBends = [];
|
||||
|
||||
for (const noteData of this.noteCreationData) {
|
||||
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
|
||||
const newNote = new KGMidiNote(
|
||||
noteData.noteId!,
|
||||
noteData.startBeat,
|
||||
noteData.endBeat,
|
||||
noteData.pitch,
|
||||
noteData.velocity
|
||||
);
|
||||
targetRegion.addNote(newNote);
|
||||
this.createdNotes.push({ note: newNote, regionId: noteData.regionId });
|
||||
}
|
||||
|
||||
for (const pitchBendData of this.pitchBendCreationData) {
|
||||
const targetRegion = this.resolveRegion(tracks, pitchBendData.regionId);
|
||||
const newPitchBend = new KGMidiPitchBend(
|
||||
pitchBendData.pitchBendId!,
|
||||
pitchBendData.beat,
|
||||
pitchBendData.value
|
||||
);
|
||||
targetRegion.addPitchBend(newPitchBend);
|
||||
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const tracks = core.getCurrentProject().getTracks();
|
||||
|
||||
for (const data of this.createdNotes) {
|
||||
const region = this.resolveRegion(tracks, data.regionId);
|
||||
region.removeNote(data.note.getId());
|
||||
const selectedNote = core.getSelectedItems().find(item => item instanceof KGMidiNote && item.getId() === data.note.getId());
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
}
|
||||
|
||||
for (const data of this.createdPitchBends) {
|
||||
const region = this.resolveRegion(tracks, data.regionId);
|
||||
region.removePitchBend(data.pitchBend.getId());
|
||||
const selectedPitchBend = core.getSelectedItems().find(item => item instanceof KGMidiPitchBend && item.getId() === data.pitchBend.getId());
|
||||
if (selectedPitchBend) {
|
||||
core.removeSelectedItem(selectedPitchBend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteCount = this.noteCreationData.length;
|
||||
const pitchBendCount = this.pitchBendCreationData.length;
|
||||
|
||||
if (noteCount > 0 && pitchBendCount > 0) {
|
||||
return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (pitchBendCount > 0) {
|
||||
return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
|
||||
}
|
||||
return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
|
||||
}
|
||||
|
||||
public getNoteCreationData(): NoteCreationData[] {
|
||||
return this.noteCreationData;
|
||||
}
|
||||
|
||||
public getCreatedNotes(): Array<{ note: KGMidiNote; regionId: string }> {
|
||||
return this.createdNotes;
|
||||
}
|
||||
|
||||
public getCreatedNoteIds(): string[] {
|
||||
return this.noteCreationData.map(data => data.noteId!);
|
||||
}
|
||||
|
||||
private resolveRegion(tracks: KGTrack[], regionId: string): KGMidiRegion {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`MIDI region with ID ${regionId} not found`);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,28 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { CreateMidiEventsCommand, type NoteCreationData } from './CreateMidiEventsCommand';
|
||||
|
||||
/**
|
||||
* Data structure for a note to be created
|
||||
*/
|
||||
export interface NoteCreationData {
|
||||
regionId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
noteId?: string;
|
||||
}
|
||||
export type { NoteCreationData } from './CreateMidiEventsCommand';
|
||||
|
||||
/**
|
||||
* Command to create multiple MIDI notes in regions
|
||||
* Handles bulk creation as a single undoable operation
|
||||
*/
|
||||
export class CreateNotesCommand extends KGCommand {
|
||||
private noteCreationData: NoteCreationData[];
|
||||
private createdNotes: Array<{
|
||||
note: KGMidiNote;
|
||||
regionId: string;
|
||||
}> = [];
|
||||
|
||||
export class CreateNotesCommand extends CreateMidiEventsCommand {
|
||||
constructor(noteCreationData: NoteCreationData[]) {
|
||||
super();
|
||||
this.noteCreationData = noteCreationData.map(data => ({
|
||||
...data,
|
||||
noteId: data.noteId || generateUniqueId('KGMidiNote')
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Clear any existing created note data to prevent duplicates on re-execution
|
||||
this.createdNotes = [];
|
||||
|
||||
// Create all notes
|
||||
for (const noteData of this.noteCreationData) {
|
||||
// Find the target region
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === noteData.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
|
||||
}
|
||||
|
||||
// Create the new MIDI note
|
||||
const newNote = new KGMidiNote(
|
||||
noteData.noteId!,
|
||||
noteData.startBeat,
|
||||
noteData.endBeat,
|
||||
noteData.pitch,
|
||||
noteData.velocity
|
||||
);
|
||||
|
||||
// Add the note to the region
|
||||
targetRegion.addNote(newNote);
|
||||
|
||||
// Store for undo
|
||||
this.createdNotes.push({
|
||||
note: newNote,
|
||||
regionId: noteData.regionId
|
||||
});
|
||||
}
|
||||
|
||||
const noteCount = this.createdNotes.length;
|
||||
const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
|
||||
console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.createdNotes.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Remove all created notes from their regions
|
||||
for (const data of this.createdNotes) {
|
||||
// Find the region
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === data.regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
region.removeNote(data.note.getId());
|
||||
|
||||
// Clear selection if this note was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedNote = selectedItems.find(item =>
|
||||
item instanceof KGMidiNote && item.getId() === data.note.getId()
|
||||
);
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
|
||||
super(noteCreationData, []);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.noteCreationData.length === 1) {
|
||||
const noteData = this.noteCreationData[0];
|
||||
const noteCreationData = this.getNoteCreationData();
|
||||
if (noteCreationData.length === 1) {
|
||||
const noteData = noteCreationData[0];
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(noteData.pitch / 12) - 1;
|
||||
const noteName = noteNames[noteData.pitch % 12];
|
||||
return `Create note ${noteName}${octave}`;
|
||||
}
|
||||
return `Create ${this.noteCreationData.length} notes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note creation data that was/will be processed
|
||||
*/
|
||||
public getNoteCreationData(): NoteCreationData[] {
|
||||
return this.noteCreationData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note instances (only available after execute)
|
||||
*/
|
||||
public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
|
||||
return this.createdNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regions that were affected by this creation
|
||||
*/
|
||||
public getAffectedRegionIds(): string[] {
|
||||
return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of notes that were/will be created
|
||||
*/
|
||||
public getCreatedNoteIds(): string[] {
|
||||
return this.noteCreationData.map(data => data.noteId!);
|
||||
return `Create ${noteCreationData.length} notes`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,4 +119,4 @@ export class CreateNoteCommand extends CreateNotesCommand {
|
||||
velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
interface PitchBendSnapshot {
|
||||
pitchBendId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface PitchBendUpdate {
|
||||
pitchBendId: string;
|
||||
beat?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export class UpdatePitchBendPropertiesCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private snapshots: PitchBendSnapshot[];
|
||||
private updates: PitchBendUpdate[];
|
||||
private targetRegion: KGMidiRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
|
||||
constructor(regionId: string, snapshots: PitchBendSnapshot[], updates: PitchBendUpdate[]) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.snapshots = [...snapshots];
|
||||
this.updates = [...updates];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
|
||||
if (region) {
|
||||
this.targetRegion = region;
|
||||
this.parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.targetRegion) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
for (const update of this.updates) {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === update.pitchBendId);
|
||||
if (pitchBend) {
|
||||
if (update.beat !== undefined) pitchBend.setBeat(update.beat);
|
||||
if (update.value !== undefined) pitchBend.setValue(update.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
this.snapshots.forEach(snapshot => {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === snapshot.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(snapshot.beat);
|
||||
pitchBend.setValue(snapshot.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.snapshots.length;
|
||||
return count === 1 ? 'Update pitch bend properties' : `Update ${count} pitch bends' properties`;
|
||||
}
|
||||
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
@@ -16,6 +17,11 @@ interface RegionSnapshot {
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
}>;
|
||||
pitchBends: Array<{
|
||||
id: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResolvedRegion {
|
||||
@@ -33,6 +39,14 @@ function cloneNote(note: KGMidiNote, startBeat: number, endBeat: number): KGMidi
|
||||
);
|
||||
}
|
||||
|
||||
function clonePitchBend(pitchBend: KGMidiPitchBend, beat: number): KGMidiPitchBend {
|
||||
return new KGMidiPitchBend(
|
||||
pitchBend.getId(),
|
||||
beat,
|
||||
pitchBend.getValue()
|
||||
);
|
||||
}
|
||||
|
||||
export class MergeMidiRegionsCommand extends KGCommand {
|
||||
private readonly regionIdsToMerge: string[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
@@ -102,6 +116,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
pitch: note.getPitch(),
|
||||
velocity: note.getVelocity(),
|
||||
})),
|
||||
pitchBends: region.getPitchBends().map(pitchBend => ({
|
||||
id: pitchBend.getId(),
|
||||
beat: pitchBend.getBeat(),
|
||||
value: pitchBend.getValue(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +135,7 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
), survivingRegionStart + this.survivingRegion.getLength());
|
||||
|
||||
const mergedNotes = [...this.survivingRegion.getNotes()];
|
||||
const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
|
||||
for (const { region } of resolvedRegions.slice(1)) {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
region.getNotes().forEach(note => {
|
||||
@@ -127,10 +147,17 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
absoluteEnd - survivingRegionStart
|
||||
));
|
||||
});
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
mergedPitchBends.push(clonePitchBend(
|
||||
pitchBend,
|
||||
regionStart + pitchBend.getBeat() - survivingRegionStart
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
||||
this.survivingRegion.setNotes(mergedNotes);
|
||||
this.survivingRegion.setPitchBends(mergedPitchBends);
|
||||
|
||||
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
||||
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
||||
@@ -165,6 +192,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
note.pitch,
|
||||
note.velocity
|
||||
)));
|
||||
this.survivingRegion.setPitchBends(survivingSnapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||
pitchBend.id,
|
||||
pitchBend.beat,
|
||||
pitchBend.value
|
||||
)));
|
||||
|
||||
for (const { region } of this.removedRegions) {
|
||||
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
||||
@@ -180,6 +212,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
||||
note.pitch,
|
||||
note.velocity
|
||||
)));
|
||||
region.setPitchBends(snapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
|
||||
pitchBend.id,
|
||||
pitchBend.beat,
|
||||
pitchBend.value
|
||||
)));
|
||||
}
|
||||
|
||||
const regions = [...this.targetTrack.getRegions()];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
@@ -82,6 +83,13 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
);
|
||||
(newRegion as KGMidiRegion).addNote(copiedNote);
|
||||
});
|
||||
originalRegion.getPitchBends().forEach(pitchBend => {
|
||||
(newRegion as KGMidiRegion).addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
});
|
||||
|
||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||
} else {
|
||||
@@ -203,4 +211,4 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
public static fromRegions(targetTrackId: string, pastePosition: number, regions: KGRegion[]): PasteRegionsCommand {
|
||||
return new PasteRegionsCommand(targetTrackId, pastePosition, regions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
|
||||
/**
|
||||
* Command to resize a region (change start position and/or length)
|
||||
@@ -23,6 +24,10 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
originalStartBeat: number;
|
||||
originalEndBeat: number;
|
||||
}> = [];
|
||||
private pitchBendAdjustments: Array<{
|
||||
pitchBendId: string;
|
||||
originalBeat: number;
|
||||
}> = [];
|
||||
|
||||
// Audio region clip offset support
|
||||
private newClipStartOffsetSeconds?: number;
|
||||
@@ -81,6 +86,13 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
targetRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
|
||||
this.pitchBendAdjustments.push({
|
||||
pitchBendId: pitchBend.getId(),
|
||||
originalBeat: pitchBend.getBeat(),
|
||||
});
|
||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||
});
|
||||
|
||||
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
||||
}
|
||||
@@ -122,6 +134,15 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
|
||||
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
||||
}
|
||||
if (this.pitchBendAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||
const pitchBends = this.targetRegion.getPitchBends();
|
||||
this.pitchBendAdjustments.forEach(adjustment => {
|
||||
const pitchBend = pitchBends.find(candidate => candidate.getId() === adjustment.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(adjustment.originalBeat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore clip offset for audio regions
|
||||
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
||||
@@ -214,4 +235,4 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
|
||||
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength, newClipStartOffsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
@@ -114,6 +115,22 @@ export class SplitRegionCommand extends KGCommand {
|
||||
}
|
||||
}
|
||||
|
||||
for (const pitchBend of originalRegion.getPitchBends()) {
|
||||
if (pitchBend.getBeat() < splitOffsetBeats) {
|
||||
region1.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat(),
|
||||
pitchBend.getValue()
|
||||
));
|
||||
} else {
|
||||
region2.addPitchBend(new KGMidiPitchBend(
|
||||
generateUniqueId('KGMidiPitchBend'),
|
||||
pitchBend.getBeat() - splitOffsetBeats,
|
||||
pitchBend.getValue()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
this.region1 = region1;
|
||||
this.region2 = region2;
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ interface NoteAdjustment {
|
||||
originalEndBeat: number;
|
||||
}
|
||||
|
||||
interface PitchBendAdjustment {
|
||||
pitchBendId: string;
|
||||
originalBeat: number;
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
||||
@@ -168,6 +173,7 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
private originalStates: RegionSnapshot[] = [];
|
||||
private targetRegions: KGRegion[] = [];
|
||||
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
||||
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
|
||||
|
||||
constructor(
|
||||
primaryRegionId: string,
|
||||
@@ -282,6 +288,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
this.pitchBendAdjustments.set(region.getId(), region.getPitchBends().map(pitchBend => ({
|
||||
pitchBendId: pitchBend.getId(),
|
||||
originalBeat: pitchBend.getBeat(),
|
||||
})));
|
||||
region.getPitchBends().forEach(pitchBend => {
|
||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||
});
|
||||
}
|
||||
|
||||
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
|
||||
@@ -315,6 +328,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
||||
note.setEndBeat(adjustment.originalEndBeat);
|
||||
}
|
||||
});
|
||||
const pitchBendAdjustments = this.pitchBendAdjustments.get(region.getId()) ?? [];
|
||||
pitchBendAdjustments.forEach(adjustment => {
|
||||
const pitchBend = region.getPitchBends().find(candidate => candidate.getId() === adjustment.pitchBendId);
|
||||
if (pitchBend) {
|
||||
pitchBend.setBeat(adjustment.originalBeat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
|
||||
getStateMock: vi.fn(),
|
||||
audioInterfaceMock: {
|
||||
getIsInitialized: vi.fn(),
|
||||
getIsAudioContextStarted: vi.fn(),
|
||||
startAudioContext: vi.fn(),
|
||||
triggerLiveMidiNoteAttack: vi.fn(),
|
||||
releaseLiveMidiNote: vi.fn(),
|
||||
setLiveMidiPitchBend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: getStateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../audio-interface/KGAudioInterface', () => ({
|
||||
KGAudioInterface: {
|
||||
instance: () => audioInterfaceMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { KGMidiInput } from './KGMidiInput';
|
||||
|
||||
describe('KGMidiInput pitch bend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStateMock.mockReturnValue({ selectedTrackId: 'track-1' });
|
||||
audioInterfaceMock.getIsInitialized.mockReturnValue(true);
|
||||
audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true);
|
||||
audioInterfaceMock.startAudioContext.mockResolvedValue(undefined);
|
||||
(KGMidiInput as unknown as { _instance: KGMidiInput | null })._instance = null;
|
||||
});
|
||||
|
||||
it('routes live MIDI note on/off through the live monitoring path', () => {
|
||||
const midiInput = KGMidiInput.instance() as unknown as {
|
||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
||||
};
|
||||
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
|
||||
|
||||
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('track-1', 60, 100);
|
||||
expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('track-1', 60);
|
||||
});
|
||||
|
||||
it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
|
||||
const midiInput = KGMidiInput.instance() as unknown as {
|
||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
||||
};
|
||||
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) });
|
||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x7f, 0x7f]) });
|
||||
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenNthCalledWith(1, 'track-1', 0);
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenCalledTimes(2);
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[0]).toBe('track-1');
|
||||
expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[1]).toBeCloseTo(8191 / 8192, 5);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
* Handles Web MIDI API integration for keyboard input
|
||||
*/
|
||||
export class KGMidiInput {
|
||||
private static readonly PITCH_BEND_CENTER = 8192;
|
||||
private static readonly PITCH_BEND_MAX_OFFSET = 8192;
|
||||
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGMidiInput | null = null;
|
||||
|
||||
@@ -18,6 +21,7 @@ export class KGMidiInput {
|
||||
// Recording callbacks
|
||||
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
||||
private onRecordNoteOff: ((pitch: number) => void) | null = null;
|
||||
private onRecordPitchBend: ((value: number) => void) | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
@@ -182,7 +186,8 @@ export class KGMidiInput {
|
||||
else if (command === 0xe0) {
|
||||
const pitchBendValue = (velocity << 7) | pitch;
|
||||
console.log(`MIDI Pitch Bend: value=${pitchBendValue}, channel=${channel}`);
|
||||
// TODO: Handle pitch bend
|
||||
this.triggerPitchBend(this.normalizePitchBend(pitchBendValue));
|
||||
this.onRecordPitchBend?.(pitchBendValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +217,7 @@ export class KGMidiInput {
|
||||
|
||||
// Trigger note attack if audio context is ready
|
||||
if (audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.triggerNoteAttack(selectedTrackId, pitch, velocity);
|
||||
audioInterface.triggerLiveMidiNoteAttack(selectedTrackId, pitch, velocity);
|
||||
console.log(`MIDI triggered note attack: pitch=${pitch}, velocity=${velocity}, track=${selectedTrackId}`);
|
||||
}
|
||||
}
|
||||
@@ -237,7 +242,7 @@ export class KGMidiInput {
|
||||
// Get audio interface and stop playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.releaseNote(selectedTrackId, pitch);
|
||||
audioInterface.releaseLiveMidiNote(selectedTrackId, pitch);
|
||||
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -245,6 +250,27 @@ export class KGMidiInput {
|
||||
}
|
||||
}
|
||||
|
||||
private triggerPitchBend(normalizedBend: number): void {
|
||||
try {
|
||||
const selectedTrackId = useProjectStore.getState().selectedTrackId;
|
||||
if (!selectedTrackId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.setLiveMidiPitchBend(selectedTrackId, normalizedBend);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error applying MIDI pitch bend (${normalizedBend}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePitchBend(pitchBendValue: number): number {
|
||||
const normalizedBend = (pitchBendValue - KGMidiInput.PITCH_BEND_CENTER) / KGMidiInput.PITCH_BEND_MAX_OFFSET;
|
||||
return Math.max(-1, Math.min(1, normalizedBend));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up MIDI resources
|
||||
*/
|
||||
@@ -274,10 +300,12 @@ export class KGMidiInput {
|
||||
|
||||
public setRecordingCallbacks(
|
||||
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
||||
onNoteOff: ((pitch: number) => void) | null
|
||||
onNoteOff: ((pitch: number) => void) | null,
|
||||
onPitchBend: ((value: number) => void) | null = null
|
||||
): void {
|
||||
this.onRecordNoteOn = onNoteOn;
|
||||
this.onRecordNoteOff = onNoteOff;
|
||||
this.onRecordPitchBend = onPitchBend;
|
||||
}
|
||||
|
||||
// ===== GETTERS =====
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGMidiPitchBend } from './KGMidiPitchBend';
|
||||
|
||||
describe('KGMidiPitchBend', () => {
|
||||
it('stores beat and raw pitch bend value', () => {
|
||||
const event = new KGMidiPitchBend('bend-1', 1.5, 4096);
|
||||
|
||||
expect(event.getId()).toBe('bend-1');
|
||||
expect(event.getBeat()).toBe(1.5);
|
||||
expect(event.getValue()).toBe(4096);
|
||||
expect(event.getCurrentType()).toBe('KGMidiPitchBend');
|
||||
});
|
||||
|
||||
it('supports selection state', () => {
|
||||
const event = new KGMidiPitchBend('bend-1', 0, 8192);
|
||||
|
||||
expect(event.isSelected()).toBe(false);
|
||||
event.select();
|
||||
expect(event.isSelected()).toBe(true);
|
||||
event.deselect();
|
||||
expect(event.isSelected()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
export class KGMidiPitchBend implements Selectable {
|
||||
@Expose()
|
||||
private id: string = '';
|
||||
|
||||
@Expose()
|
||||
private beat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private value: number = 8192;
|
||||
|
||||
@Expose()
|
||||
private selected: boolean = false;
|
||||
|
||||
constructor(id: string, beat: number = 0, value: number = 8192) {
|
||||
this.id = id;
|
||||
this.beat = beat;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getBeat(): number {
|
||||
return this.beat;
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setBeat(beat: number): void {
|
||||
this.beat = beat;
|
||||
}
|
||||
|
||||
public setValue(value: number): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
public deselect(): void {
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
public getRootType(): string {
|
||||
return 'KGMidiPitchBend';
|
||||
}
|
||||
|
||||
public getCurrentType(): string {
|
||||
return 'KGMidiPitchBend';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { instanceToPlain, plainToInstance } from 'class-transformer';
|
||||
import { KGMidiRegion } from './KGMidiRegion';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||
|
||||
describe('KGMidiRegion', () => {
|
||||
@@ -36,6 +38,7 @@ describe('KGMidiRegion', () => {
|
||||
expect(testRegion.getStartFromBeat()).toBe(4);
|
||||
expect(testRegion.getLength()).toBe(8);
|
||||
expect(testRegion.getNotes()).toEqual([]);
|
||||
expect(testRegion.getPitchBends()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use default values for optional parameters', () => {
|
||||
@@ -44,6 +47,7 @@ describe('KGMidiRegion', () => {
|
||||
expect(defaultRegion.getStartFromBeat()).toBe(0);
|
||||
expect(defaultRegion.getLength()).toBe(0);
|
||||
expect(defaultRegion.getNotes()).toEqual([]);
|
||||
expect(defaultRegion.getPitchBends()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set the correct type identifier', () => {
|
||||
@@ -214,6 +218,37 @@ describe('KGMidiRegion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitch bend management', () => {
|
||||
let pitchBend1: KGMidiPitchBend;
|
||||
let pitchBend2: KGMidiPitchBend;
|
||||
|
||||
beforeEach(() => {
|
||||
pitchBend1 = new KGMidiPitchBend('bend-1', 0.5, 8192);
|
||||
pitchBend2 = new KGMidiPitchBend('bend-2', 1.5, 12288);
|
||||
});
|
||||
|
||||
it('adds and returns pitch bends', () => {
|
||||
region.addPitchBend(pitchBend1);
|
||||
region.addPitchBend(pitchBend2);
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend1, pitchBend2]);
|
||||
});
|
||||
|
||||
it('removes pitch bends by id', () => {
|
||||
region.setPitchBends([pitchBend1, pitchBend2]);
|
||||
region.removePitchBend('bend-1');
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend2]);
|
||||
});
|
||||
|
||||
it('replaces all pitch bends when setting a new array', () => {
|
||||
region.setPitchBends([pitchBend1]);
|
||||
region.setPitchBends([pitchBend2]);
|
||||
|
||||
expect(region.getPitchBends()).toEqual([pitchBend2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance from KGRegion', () => {
|
||||
it('should inherit all base region properties', () => {
|
||||
expect(region.getId()).toBe('test-region-1');
|
||||
@@ -349,5 +384,18 @@ describe('KGMidiRegion', () => {
|
||||
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
||||
expect(finalNotes).toContain(newNote); // concurrent-4
|
||||
});
|
||||
|
||||
it('preserves pitch bends through class-transformer serialization', () => {
|
||||
region.addNote(createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }));
|
||||
region.addPitchBend(new KGMidiPitchBend('bend-1', 0.5, 12288));
|
||||
|
||||
const plain = instanceToPlain(region);
|
||||
const restored = plainToInstance(KGMidiRegion, plain);
|
||||
|
||||
expect(restored.getNotes()).toHaveLength(1);
|
||||
expect(restored.getPitchBends()).toHaveLength(1);
|
||||
expect(restored.getPitchBends()[0]).toBeInstanceOf(KGMidiPitchBend);
|
||||
expect(restored.getPitchBends()[0].getValue()).toBe(12288);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||
|
||||
/**
|
||||
* KGMidiRegion - Class representing a MIDI region in the DAW
|
||||
@@ -14,6 +15,10 @@ export class KGMidiRegion extends KGRegion {
|
||||
@Type(() => KGMidiNote)
|
||||
protected notes: KGMidiNote[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGMidiPitchBend)
|
||||
protected pitchBends: KGMidiPitchBend[] = [];
|
||||
|
||||
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||
this.__type = 'KGMidiRegion';
|
||||
@@ -29,6 +34,14 @@ export class KGMidiRegion extends KGRegion {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
public getPitchBends(): KGMidiPitchBend[] {
|
||||
return this.pitchBends;
|
||||
}
|
||||
|
||||
public setPitchBends(pitchBends: KGMidiPitchBend[]): void {
|
||||
this.pitchBends = pitchBends;
|
||||
}
|
||||
|
||||
// Add a single note
|
||||
public addNote(note: KGMidiNote): void {
|
||||
this.notes.push(note);
|
||||
@@ -39,6 +52,14 @@ export class KGMidiRegion extends KGRegion {
|
||||
this.notes = this.notes.filter(note => note.getId() !== noteId);
|
||||
}
|
||||
|
||||
public addPitchBend(pitchBend: KGMidiPitchBend): void {
|
||||
this.pitchBends.push(pitchBend);
|
||||
}
|
||||
|
||||
public removePitchBend(pitchBendId: string): void {
|
||||
this.pitchBends = this.pitchBends.filter(pitchBend => pitchBend.getId() !== pitchBendId);
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGMidiRegion';
|
||||
|
||||
+45
-10
@@ -20,8 +20,9 @@ import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
||||
import * as Tone from 'tone';
|
||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
||||
import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
|
||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||
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
|
||||
@@ -72,6 +73,7 @@ interface ProjectState {
|
||||
|
||||
// Selection state for UI reactivity
|
||||
selectedNoteIds: string[];
|
||||
selectedPitchBendIds: string[];
|
||||
selectedRegionIds: string[];
|
||||
selectedTrackId: string | null;
|
||||
|
||||
@@ -105,6 +107,7 @@ interface ProjectState {
|
||||
isRecording: boolean;
|
||||
recordingTargetRegionId: string | null;
|
||||
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
||||
recordingPitchBends: Array<{ beat: number; value: number }>;
|
||||
recordingOriginalPlayhead: number;
|
||||
|
||||
// Undo/redo state
|
||||
@@ -207,6 +210,7 @@ interface ProjectState {
|
||||
// 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 _recordingRegionStartBeat: number = 0;
|
||||
let _lastRecordedPitchBendValue: number | null = null;
|
||||
|
||||
function getRecordingLoopEndBeatRelative(): number | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
@@ -270,11 +274,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
const noteIds = selectedItems
|
||||
.filter(item => item instanceof KGMidiNote)
|
||||
.map(item => item.getId());
|
||||
const pitchBendIds = selectedItems
|
||||
.filter(item => item instanceof KGMidiPitchBend)
|
||||
.map(item => item.getId());
|
||||
const regionIds = selectedItems
|
||||
.filter(item => item instanceof KGRegion)
|
||||
.map(item => item.getId());
|
||||
|
||||
set({ selectedNoteIds: noteIds, selectedRegionIds: regionIds });
|
||||
set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, selectedRegionIds: regionIds });
|
||||
};
|
||||
|
||||
// Register the sync callback with KGCore
|
||||
@@ -339,6 +346,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
|
||||
// Initial selection state
|
||||
selectedNoteIds: [],
|
||||
selectedPitchBendIds: [],
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: initialSelectedTrackId,
|
||||
|
||||
@@ -377,6 +385,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
isRecording: false,
|
||||
recordingTargetRegionId: null,
|
||||
recordingNotes: [],
|
||||
recordingPitchBends: [],
|
||||
recordingOriginalPlayhead: 0,
|
||||
|
||||
// Initial cross-component scroll request state
|
||||
@@ -875,10 +884,12 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
|
||||
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
||||
_recordingActiveNotes = new Map();
|
||||
_lastRecordedPitchBendValue = null;
|
||||
|
||||
set({
|
||||
isRecording: true,
|
||||
recordingNotes: [],
|
||||
recordingPitchBends: [],
|
||||
recordingTargetRegionId: activeRegionId,
|
||||
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 () => {
|
||||
const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
||||
const { recordingNotes, recordingPitchBends, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
||||
|
||||
// Finalize any held keys
|
||||
const finalNotes = [...recordingNotes];
|
||||
const finalPitchBends = [...recordingPitchBends];
|
||||
const bpm = get().bpm;
|
||||
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
||||
@@ -949,9 +972,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
});
|
||||
_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 => ({
|
||||
regionId: recordingTargetRegionId,
|
||||
startBeat: n.startBeat,
|
||||
@@ -959,14 +990,20 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
pitch: n.pitch,
|
||||
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);
|
||||
refreshProjectState();
|
||||
}
|
||||
|
||||
await stopPlaying();
|
||||
setPlayheadPosition(recordingOriginalPlayhead);
|
||||
set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
|
||||
set({ isRecording: false, recordingNotes: [], recordingPitchBends: [], recordingTargetRegionId: null });
|
||||
_lastRecordedPitchBendValue = null;
|
||||
},
|
||||
|
||||
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.isPlaying).toBe(false);
|
||||
expect(storeState.recordingNotes).toHaveLength(0);
|
||||
expect(storeState.recordingPitchBends).toHaveLength(0);
|
||||
expect(executeCommandSpy).toHaveBeenCalled();
|
||||
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
|
||||
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
|
||||
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null, null);
|
||||
expect(testRegion.getNotes()).toHaveLength(1);
|
||||
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 () => {
|
||||
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
|
||||
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),
|
||||
scheduleNotes: 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
|
||||
getCurrentBeat: vi.fn().mockReturnValue(0),
|
||||
|
||||
@@ -11,6 +11,10 @@ export const mockSampler = {
|
||||
triggerRelease: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
loaded: true,
|
||||
attack: 0,
|
||||
release: 0.1,
|
||||
curve: 'exponential',
|
||||
output: {},
|
||||
toDestination: vi.fn().mockReturnThis(),
|
||||
connect: vi.fn().mockReturnThis(),
|
||||
disconnect: vi.fn().mockReturnThis(),
|
||||
@@ -18,6 +22,14 @@ export const mockSampler = {
|
||||
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
|
||||
export const mockTransport = {
|
||||
start: vi.fn(),
|
||||
@@ -35,6 +47,26 @@ export const mockTransport = {
|
||||
// Mock Tone namespace
|
||||
export const mockTone = {
|
||||
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,
|
||||
Buffer: vi.fn().mockImplementation(() => ({
|
||||
loaded: true,
|
||||
@@ -55,4 +87,4 @@ export const mockTone = {
|
||||
state: 'running',
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
||||
triggerRelease: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
loaded: true,
|
||||
attack: 0,
|
||||
release: 0.1,
|
||||
curve: 'exponential',
|
||||
output: {},
|
||||
volume: {
|
||||
value: -12
|
||||
},
|
||||
@@ -20,6 +24,19 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
||||
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
|
||||
export const MockTransport = {
|
||||
start: vi.fn(),
|
||||
@@ -94,6 +111,8 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||
// Complete Tone.js mock
|
||||
export const ToneMock = {
|
||||
Sampler: MockSampler,
|
||||
BufferSource: MockBufferSource,
|
||||
ToneBufferSource: MockBufferSource,
|
||||
Loop: MockLoop,
|
||||
Transport: MockTransport,
|
||||
Destination: MockDestination,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
@@ -41,6 +42,7 @@ export const createMockMidiRegion = (overrides: Partial<{
|
||||
startFromBeat: number
|
||||
length: number
|
||||
notes: KGMidiNote[]
|
||||
pitchBends: KGMidiPitchBend[]
|
||||
}> = {}): KGMidiRegion => {
|
||||
const defaults = {
|
||||
id: 'test-region-1',
|
||||
@@ -65,10 +67,28 @@ export const createMockMidiRegion = (overrides: Partial<{
|
||||
if (overrides.notes) {
|
||||
overrides.notes.forEach(note => region.addNote(note));
|
||||
}
|
||||
if (overrides.pitchBends) {
|
||||
overrides.pitchBends.forEach(pitchBend => region.addPitchBend(pitchBend));
|
||||
}
|
||||
|
||||
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<{
|
||||
name: string
|
||||
id: number
|
||||
@@ -156,4 +176,4 @@ export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMi
|
||||
});
|
||||
|
||||
return { project, track, region };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,6 +22,11 @@ export const pianoRollIndexToPitch = (index: number) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
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}`;
|
||||
};
|
||||
|
||||
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 => {
|
||||
const noteMap: { [key: string]: number } = {
|
||||
'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