Add piano roll track editing
This commit is contained in:
parent
3de1b05fb3
commit
976b41cb83
|
|
@ -504,6 +504,31 @@ impl Engine {
|
|||
// Add a pre-loaded MIDI clip to the track
|
||||
let _ = self.project.add_midi_clip(track_id, clip);
|
||||
}
|
||||
Command::UpdateMidiClipNotes(track_id, clip_id, notes) => {
|
||||
// Update all notes in a MIDI clip
|
||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||
// Clear existing events
|
||||
clip.events.clear();
|
||||
|
||||
// Add new events from the notes array
|
||||
for (start_time, note, velocity, duration) in notes {
|
||||
// Convert time to sample timestamp
|
||||
let timestamp = (start_time * self.sample_rate as f64) as u64;
|
||||
let note_on = MidiEvent::note_on(timestamp, 0, note, velocity);
|
||||
clip.events.push(note_on);
|
||||
|
||||
// Add note off event
|
||||
let note_off_timestamp = ((start_time + duration) * self.sample_rate as f64) as u64;
|
||||
let note_off = MidiEvent::note_off(note_off_timestamp, 0, note, 64);
|
||||
clip.events.push(note_off);
|
||||
}
|
||||
|
||||
// Sort events by timestamp
|
||||
clip.events.sort_by_key(|e| e.timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::RequestBufferPoolStats => {
|
||||
// Send buffer pool statistics back to UI
|
||||
let stats = self.buffer_pool.stats();
|
||||
|
|
@ -1010,6 +1035,11 @@ impl EngineController {
|
|||
let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip));
|
||||
}
|
||||
|
||||
/// Update all notes in a MIDI clip
|
||||
pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(f64, u8, u8, f64)>) {
|
||||
let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes));
|
||||
}
|
||||
|
||||
/// Request buffer pool statistics
|
||||
/// The statistics will be sent via an AudioEvent::BufferPoolStats event
|
||||
pub fn request_buffer_pool_stats(&mut self) {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ pub enum Command {
|
|||
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64),
|
||||
/// Add a pre-loaded MIDI clip to a track
|
||||
AddLoadedMidiClip(TrackId, MidiClip),
|
||||
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
|
||||
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
|
||||
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>),
|
||||
|
||||
// Diagnostics commands
|
||||
/// Request buffer pool statistics
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -507,6 +507,23 @@ pub async fn audio_load_midi_file(
|
|||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_update_midi_clip_notes(
|
||||
state: tauri::State<'_, Arc<Mutex<AudioState>>>,
|
||||
track_id: u32,
|
||||
clip_id: u32,
|
||||
notes: Vec<(f64, u8, u8, f64)>,
|
||||
) -> Result<(), String> {
|
||||
let mut audio_state = state.lock().unwrap();
|
||||
|
||||
if let Some(controller) = &mut audio_state.controller {
|
||||
controller.update_midi_clip_notes(track_id, clip_id, notes);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Audio not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SerializedAudioEvent {
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ pub fn run() {
|
|||
audio::audio_create_midi_clip,
|
||||
audio::audio_add_midi_note,
|
||||
audio::audio_load_midi_file,
|
||||
audio::audio_update_midi_clip_notes,
|
||||
audio::audio_send_midi_note_on,
|
||||
audio::audio_send_midi_note_off,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 12.7 12.7"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
sodipodi:docname="piano.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="16.035511"
|
||||
inkscape:cx="22.231908"
|
||||
inkscape:cy="24.102756"
|
||||
inkscape:window-width="2256"
|
||||
inkscape:window-height="1432"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<inkscape:path-effect
|
||||
effect="fillet_chamfer"
|
||||
id="path-effect1"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
nodesatellites_param="F,0,0,1,0,0.79375001,0,1 @ F,0,0,1,0,0.79375001,0,1 @ F,0,0,1,0,0.79375001,0,1 @ F,0,0,1,0,0.79375001,0,1"
|
||||
radius="3"
|
||||
unit="px"
|
||||
method="auto"
|
||||
mode="F"
|
||||
chamfer_steps="1"
|
||||
flexible="false"
|
||||
use_knot_distance="true"
|
||||
apply_no_radius="true"
|
||||
apply_with_radius="true"
|
||||
only_selected="false"
|
||||
hide_knots="false" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect1"
|
||||
width="10.427897"
|
||||
height="10.427897"
|
||||
x="1.1219889"
|
||||
y="1.1714885"
|
||||
inkscape:path-effect="#path-effect1"
|
||||
sodipodi:type="rect"
|
||||
d="m 1.9157389,1.1714885 h 8.8403971 a 0.79375001,0.79375001 45 0 1 0.79375,0.79375 v 8.8403975 a 0.79375001,0.79375001 135 0 1 -0.79375,0.79375 H 1.9157389 a 0.79375001,0.79375001 45 0 1 -0.79375,-0.79375 l 0,-8.8403975 a 0.79375001,0.79375001 135 0 1 0.79375,-0.79375 z" />
|
||||
<path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="M 6.3946773,6.6255579 V 11.361012"
|
||||
id="path1" />
|
||||
<path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="M 3.6389113,6.6255579 V 11.361012"
|
||||
id="path1-5" />
|
||||
<path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="M 9.1504432,6.6255589 V 11.361012"
|
||||
id="path1-2" />
|
||||
<rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect2"
|
||||
width="1.6664836"
|
||||
height="5.4449468"
|
||||
x="5.537518"
|
||||
y="1.2044882" />
|
||||
<rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect2-7"
|
||||
width="1.6664836"
|
||||
height="5.4449468"
|
||||
x="8.4957237"
|
||||
y="1.245263"
|
||||
inkscape:transform-center-x="2.557475"
|
||||
inkscape:transform-center-y="1.4189861" />
|
||||
<rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.499999;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect2-6"
|
||||
width="1.6664836"
|
||||
height="5.4449468"
|
||||
x="2.5793126"
|
||||
y="1.2770908" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
168
src/main.js
168
src/main.js
|
|
@ -61,7 +61,7 @@ import {
|
|||
shadow,
|
||||
} from "./styles.js";
|
||||
import { Icon } from "./icon.js";
|
||||
import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, VirtualPiano, Widget } from "./widgets.js";
|
||||
import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, VirtualPiano, PianoRollEditor, Widget } from "./widgets.js";
|
||||
|
||||
// State management
|
||||
import {
|
||||
|
|
@ -746,42 +746,6 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
createPane(panes.stage),
|
||||
);
|
||||
|
||||
// Add audio test button (temporary for Phase 0)
|
||||
const testBtn = document.createElement('button');
|
||||
testBtn.textContent = 'Test Audio';
|
||||
testBtn.style.position = 'fixed';
|
||||
testBtn.style.top = '10px';
|
||||
testBtn.style.right = '10px';
|
||||
testBtn.style.zIndex = '10000';
|
||||
testBtn.style.padding = '10px';
|
||||
testBtn.style.backgroundColor = '#4CAF50';
|
||||
testBtn.style.color = 'white';
|
||||
testBtn.style.border = 'none';
|
||||
testBtn.style.borderRadius = '4px';
|
||||
testBtn.style.cursor = 'pointer';
|
||||
testBtn.onclick = async () => {
|
||||
try {
|
||||
console.log('Initializing audio...');
|
||||
const result = await invoke('audio_init');
|
||||
console.log(result);
|
||||
|
||||
console.log('Creating MIDI beep...');
|
||||
await invoke('audio_test_beep');
|
||||
|
||||
console.log('Playing...');
|
||||
await invoke('audio_play');
|
||||
|
||||
setTimeout(async () => {
|
||||
await invoke('audio_stop');
|
||||
console.log('Stopped');
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('Audio test failed:', error);
|
||||
alert('Audio test failed: ' + error);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(testBtn);
|
||||
|
||||
// Initialize audio system on startup
|
||||
(async () => {
|
||||
try {
|
||||
|
|
@ -960,6 +924,11 @@ async function playPause() {
|
|||
console.error('Failed to start audio playback:', error);
|
||||
}
|
||||
|
||||
// Re-enable auto-scroll when playback starts
|
||||
if (context.pianoRollEditor) {
|
||||
context.pianoRollEditor.autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
playbackLoop();
|
||||
} else {
|
||||
// Stop recording if active
|
||||
|
|
@ -1134,16 +1103,11 @@ async function handleAudioEvent(event) {
|
|||
// Sync frontend time with DAW time
|
||||
if (playing) {
|
||||
// Quantize time to framerate for animation playback
|
||||
console.log('[PlaybackPosition] context.activeObject:', context.activeObject, 'root:', root, 'same?', context.activeObject === root);
|
||||
console.log('[PlaybackPosition] root.frameRate:', root.frameRate, 'activeObject.frameRate:', context.activeObject.frameRate);
|
||||
const framerate = context.activeObject.frameRate;
|
||||
console.log('[PlaybackPosition] framerate:', framerate, 'event.time:', event.time, 'currentTime before:', context.activeObject.currentTime);
|
||||
const frameDuration = 1 / framerate;
|
||||
const quantizedTime = Math.floor(event.time / frameDuration) * frameDuration;
|
||||
console.log('[PlaybackPosition] frameDuration:', frameDuration, 'quantizedTime:', quantizedTime);
|
||||
|
||||
context.activeObject.currentTime = quantizedTime;
|
||||
console.log('[PlaybackPosition] currentTime after:', context.activeObject.currentTime);
|
||||
if (context.timelineWidget?.timelineState) {
|
||||
context.timelineWidget.timelineState.currentTime = quantizedTime;
|
||||
}
|
||||
|
|
@ -1158,6 +1122,11 @@ async function handleAudioEvent(event) {
|
|||
context.pianoWidget.setPlayingNotes(playingNotes);
|
||||
context.pianoRedraw();
|
||||
}
|
||||
|
||||
// Update piano roll editor to show playhead
|
||||
if (context.pianoRollRedraw) {
|
||||
context.pianoRollRedraw();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -4474,10 +4443,34 @@ function createPane(paneType = undefined, div = undefined) {
|
|||
// Create and append the new menu to the DOM
|
||||
popupMenu = createPaneMenu(div);
|
||||
|
||||
// Position the menu below the button
|
||||
// Position the menu intelligently to stay onscreen
|
||||
const buttonRect = event.target.getBoundingClientRect();
|
||||
popupMenu.style.left = `${buttonRect.left}px`;
|
||||
popupMenu.style.top = `${buttonRect.bottom + window.scrollY}px`;
|
||||
const menuRect = popupMenu.getBoundingClientRect();
|
||||
|
||||
// Default: position below and to the right of the button
|
||||
let left = buttonRect.left;
|
||||
let top = buttonRect.bottom + window.scrollY;
|
||||
|
||||
// Check if menu goes off the right edge
|
||||
if (left + menuRect.width > window.innerWidth) {
|
||||
// Align right edge of menu with right edge of button
|
||||
left = buttonRect.right - menuRect.width;
|
||||
}
|
||||
|
||||
// Check if menu goes off the bottom edge
|
||||
if (buttonRect.bottom + menuRect.height > window.innerHeight) {
|
||||
// Position above the button instead
|
||||
top = buttonRect.top + window.scrollY - menuRect.height;
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the left edge
|
||||
left = Math.max(0, left);
|
||||
|
||||
// Ensure menu doesn't go off the top edge
|
||||
top = Math.max(window.scrollY, top);
|
||||
|
||||
popupMenu.style.left = `${left}px`;
|
||||
popupMenu.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
// Prevent the click event from propagating to the window click listener
|
||||
|
|
@ -6024,6 +6017,87 @@ function piano() {
|
|||
return piano_cvs;
|
||||
}
|
||||
|
||||
function pianoRoll() {
|
||||
let canvas = document.createElement("canvas");
|
||||
canvas.className = "piano-roll";
|
||||
|
||||
// Create the piano roll editor widget
|
||||
canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0);
|
||||
|
||||
function updateCanvasSize() {
|
||||
const canvasStyles = window.getComputedStyle(canvas);
|
||||
const width = parseInt(canvasStyles.width);
|
||||
const height = parseInt(canvasStyles.height);
|
||||
|
||||
// Update widget dimensions
|
||||
canvas.pianoRollEditor.width = width;
|
||||
canvas.pianoRollEditor.height = height;
|
||||
|
||||
// Set actual size in memory (scaled for retina displays)
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
|
||||
// Normalize coordinate system to use CSS pixels
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
|
||||
// Render the piano roll
|
||||
canvas.pianoRollEditor.draw(ctx);
|
||||
}
|
||||
|
||||
// Store references in context for global access and playback updates
|
||||
context.pianoRollEditor = canvas.pianoRollEditor;
|
||||
context.pianoRollCanvas = canvas;
|
||||
context.pianoRollRedraw = updateCanvasSize;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
// Pointer event handlers (works with mouse and touch)
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
canvas.pianoRollEditor.handleMouseEvent("mousedown", x, y);
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
canvas.pianoRollEditor.handleMouseEvent("mousemove", x, y);
|
||||
|
||||
// Update cursor based on widget state
|
||||
if (canvas.pianoRollEditor.cursor) {
|
||||
canvas.style.cursor = canvas.pianoRollEditor.cursor;
|
||||
}
|
||||
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointerup", (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
canvas.pianoRollEditor.handleMouseEvent("mouseup", x, y);
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
canvas.pianoRollEditor.wheel(e);
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
// Prevent text selection
|
||||
canvas.addEventListener("selectstart", (e) => e.preventDefault());
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const panes = {
|
||||
stage: {
|
||||
name: "stage",
|
||||
|
|
@ -6053,6 +6127,10 @@ const panes = {
|
|||
name: "piano",
|
||||
func: piano,
|
||||
},
|
||||
pianoRoll: {
|
||||
name: "piano-roll",
|
||||
func: pianoRoll,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ body {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection in input fields and textareas */
|
||||
input,
|
||||
textarea {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #ffe21c);
|
||||
}
|
||||
|
|
|
|||
658
src/widgets.js
658
src/widgets.js
|
|
@ -4460,6 +4460,661 @@ class VirtualPiano extends Widget {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Piano Roll Editor
|
||||
* MIDI note editor with piano keyboard on left and grid on right
|
||||
*/
|
||||
class PianoRollEditor extends Widget {
|
||||
constructor(width, height, x, y) {
|
||||
super(x, y)
|
||||
this.width = width
|
||||
this.height = height
|
||||
|
||||
// Display settings
|
||||
this.keyboardWidth = 60 // Width of piano keyboard on left
|
||||
this.noteHeight = 16 // Height of each note row
|
||||
this.pixelsPerSecond = 100 // Horizontal zoom
|
||||
this.minNote = 21 // A0
|
||||
this.maxNote = 108 // C8
|
||||
this.totalNotes = this.maxNote - this.minNote + 1
|
||||
|
||||
// Scroll state
|
||||
this.scrollX = 0
|
||||
this.scrollY = 0
|
||||
this.initialScrollSet = false // Track if we've set initial scroll position
|
||||
|
||||
// Interaction state
|
||||
this.selectedNotes = new Set() // Set of note indices
|
||||
this.dragMode = null // null, 'move', 'resize-left', 'resize-right', 'create'
|
||||
this.dragStartX = 0
|
||||
this.dragStartY = 0
|
||||
this.creatingNote = null // Temporary note being created
|
||||
this.isDragging = false
|
||||
|
||||
// Note preview playback state
|
||||
this.playingNote = null // Currently playing note number
|
||||
this.playingNoteMaxDuration = null // Max duration in seconds
|
||||
this.playingNoteStartTime = null // Timestamp when note started playing
|
||||
|
||||
// Auto-scroll state
|
||||
this.autoScrollEnabled = true // Auto-scroll to follow playhead during playback
|
||||
this.lastPlayheadTime = 0 // Track last playhead position
|
||||
|
||||
// Start timer to check for note duration expiry
|
||||
this.checkNoteDurationTimer = setInterval(() => this.checkNoteDuration(), 50)
|
||||
}
|
||||
|
||||
checkNoteDuration() {
|
||||
if (this.playingNote !== null && this.playingNoteMaxDuration !== null && this.playingNoteStartTime !== null) {
|
||||
const elapsed = (Date.now() - this.playingNoteStartTime) / 1000
|
||||
if (elapsed >= this.playingNoteMaxDuration) {
|
||||
// Stop the note
|
||||
const clipData = this.getSelectedClip()
|
||||
if (clipData) {
|
||||
invoke('audio_send_midi_note_off', {
|
||||
trackId: clipData.trackId,
|
||||
note: this.playingNote
|
||||
})
|
||||
this.playingNote = null
|
||||
this.playingNoteMaxDuration = null
|
||||
this.playingNoteStartTime = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the currently selected MIDI clip from context
|
||||
getSelectedClip() {
|
||||
if (typeof context === 'undefined' || !context.activeObject || !context.activeObject.audioTracks) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the first MIDI track with a selected clip
|
||||
for (const track of context.activeObject.audioTracks) {
|
||||
if (track.type === 'midi' && track.clips && track.clips.length > 0) {
|
||||
// For now, just return the first clip on the first MIDI track
|
||||
// TODO: Add proper clip selection mechanism
|
||||
return { clip: track.clips[0], trackId: track.audioTrackId }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
hitTest(x, y) {
|
||||
return x >= 0 && x <= this.width && y >= 0 && y <= this.height
|
||||
}
|
||||
|
||||
// Convert screen coordinates to note/time
|
||||
screenToNote(y) {
|
||||
const gridY = y + this.scrollY
|
||||
const noteIndex = Math.floor(gridY / this.noteHeight)
|
||||
return this.maxNote - noteIndex // Invert (higher notes at top)
|
||||
}
|
||||
|
||||
screenToTime(x) {
|
||||
const gridX = x - this.keyboardWidth + this.scrollX
|
||||
return gridX / this.pixelsPerSecond
|
||||
}
|
||||
|
||||
// Convert note/time to screen coordinates
|
||||
noteToScreenY(note) {
|
||||
const noteIndex = this.maxNote - note
|
||||
return noteIndex * this.noteHeight - this.scrollY
|
||||
}
|
||||
|
||||
timeToScreenX(time) {
|
||||
return time * this.pixelsPerSecond - this.scrollX + this.keyboardWidth
|
||||
}
|
||||
|
||||
// Find note at screen position
|
||||
findNoteAtPosition(x, y) {
|
||||
const clipData = this.getSelectedClip()
|
||||
if (!clipData || !clipData.clip.notes) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const note = this.screenToNote(y)
|
||||
const time = this.screenToTime(x)
|
||||
|
||||
// Search in reverse order so we find top-most notes first
|
||||
for (let i = clipData.clip.notes.length - 1; i >= 0; i--) {
|
||||
const n = clipData.clip.notes[i]
|
||||
const noteMatches = Math.round(n.note) === Math.round(note)
|
||||
const timeInRange = time >= n.start_time && time <= (n.start_time + n.duration)
|
||||
|
||||
if (noteMatches && timeInRange) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// Check if clicking on the right edge resize handle
|
||||
isOnResizeHandle(x, noteIndex) {
|
||||
const clipData = this.getSelectedClip()
|
||||
if (!clipData || noteIndex < 0 || noteIndex >= clipData.clip.notes.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const note = clipData.clip.notes[noteIndex]
|
||||
const noteEndX = this.timeToScreenX(note.start_time + note.duration)
|
||||
|
||||
// Consider clicking within 8 pixels of the right edge as resize
|
||||
return Math.abs(x - noteEndX) < 8
|
||||
}
|
||||
|
||||
mousedown(x, y) {
|
||||
this._globalEvents.add("mousemove")
|
||||
this._globalEvents.add("mouseup")
|
||||
|
||||
this.isDragging = true
|
||||
this.dragStartX = x
|
||||
this.dragStartY = y
|
||||
|
||||
// Check if clicking on keyboard or grid
|
||||
if (x < this.keyboardWidth) {
|
||||
// Clicking on keyboard - could preview note
|
||||
return
|
||||
}
|
||||
|
||||
const note = this.screenToNote(y)
|
||||
const time = this.screenToTime(x)
|
||||
|
||||
// Check if clicking on an existing note
|
||||
const noteIndex = this.findNoteAtPosition(x, y)
|
||||
|
||||
if (noteIndex >= 0) {
|
||||
// Clicking on an existing note
|
||||
const clipData = this.getSelectedClip()
|
||||
if (this.isOnResizeHandle(x, noteIndex)) {
|
||||
// Start resizing
|
||||
this.dragMode = 'resize'
|
||||
this.resizingNoteIndex = noteIndex
|
||||
this.selectedNotes.clear()
|
||||
this.selectedNotes.add(noteIndex)
|
||||
} else {
|
||||
// Start moving
|
||||
this.dragMode = 'move'
|
||||
this.movingStartTime = time
|
||||
this.movingStartNote = note
|
||||
|
||||
// Select this note (or add to selection with Ctrl/Cmd)
|
||||
if (!this.selectedNotes.has(noteIndex)) {
|
||||
this.selectedNotes.clear()
|
||||
this.selectedNotes.add(noteIndex)
|
||||
}
|
||||
|
||||
// Play preview of the note
|
||||
if (clipData && clipData.clip.notes[noteIndex]) {
|
||||
const clickedNote = clipData.clip.notes[noteIndex]
|
||||
this.playingNote = clickedNote.note
|
||||
this.playingNoteMaxDuration = clickedNote.duration
|
||||
this.playingNoteStartTime = Date.now()
|
||||
|
||||
invoke('audio_send_midi_note_on', {
|
||||
trackId: clipData.trackId,
|
||||
note: clickedNote.note,
|
||||
velocity: clickedNote.velocity
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clicking on empty space - start creating a new note
|
||||
this.dragMode = 'create'
|
||||
this.selectedNotes.clear()
|
||||
|
||||
// Create a temporary note for preview
|
||||
const newNoteValue = Math.round(note)
|
||||
this.creatingNote = {
|
||||
note: newNoteValue,
|
||||
start_time: time,
|
||||
duration: 0.1, // Minimum duration
|
||||
velocity: 100
|
||||
}
|
||||
|
||||
// Play preview of the new note
|
||||
const clipData = this.getSelectedClip()
|
||||
if (clipData) {
|
||||
this.playingNote = newNoteValue
|
||||
this.playingNoteMaxDuration = null // No max duration for creating notes
|
||||
this.playingNoteStartTime = Date.now()
|
||||
|
||||
invoke('audio_send_midi_note_on', {
|
||||
trackId: clipData.trackId,
|
||||
note: newNoteValue,
|
||||
velocity: 100
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mousemove(x, y) {
|
||||
// Update cursor based on hover position even when not dragging
|
||||
if (!this.isDragging && x >= this.keyboardWidth) {
|
||||
const noteIndex = this.findNoteAtPosition(x, y)
|
||||
if (noteIndex >= 0 && this.isOnResizeHandle(x, noteIndex)) {
|
||||
this.cursor = 'ew-resize'
|
||||
} else {
|
||||
this.cursor = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isDragging) return
|
||||
|
||||
const clipData = this.getSelectedClip()
|
||||
if (!clipData) return
|
||||
|
||||
if (this.dragMode === 'create') {
|
||||
// Extend the note being created
|
||||
if (this.creatingNote) {
|
||||
const currentTime = this.screenToTime(x)
|
||||
const duration = Math.max(0.1, currentTime - this.creatingNote.start_time)
|
||||
this.creatingNote.duration = duration
|
||||
}
|
||||
} else if (this.dragMode === 'move') {
|
||||
// Move selected notes
|
||||
const currentTime = this.screenToTime(x)
|
||||
const currentNote = this.screenToNote(y)
|
||||
|
||||
const deltaTime = currentTime - this.movingStartTime
|
||||
const deltaNote = Math.round(currentNote - this.movingStartNote)
|
||||
|
||||
// Check if pitch changed
|
||||
if (deltaNote !== 0) {
|
||||
const firstSelectedIndex = Array.from(this.selectedNotes)[0]
|
||||
if (firstSelectedIndex >= 0 && firstSelectedIndex < clipData.clip.notes.length) {
|
||||
const movedNote = clipData.clip.notes[firstSelectedIndex]
|
||||
const newPitch = Math.max(0, Math.min(127, movedNote.note + deltaNote))
|
||||
|
||||
// Stop old note if one is playing
|
||||
if (this.playingNote !== null) {
|
||||
invoke('audio_send_midi_note_off', {
|
||||
trackId: clipData.trackId,
|
||||
note: this.playingNote
|
||||
})
|
||||
}
|
||||
|
||||
// Update playing note to new pitch
|
||||
this.playingNote = newPitch
|
||||
this.playingNoteMaxDuration = movedNote.duration
|
||||
this.playingNoteStartTime = Date.now()
|
||||
|
||||
// Play new note at new pitch
|
||||
invoke('audio_send_midi_note_on', {
|
||||
trackId: clipData.trackId,
|
||||
note: newPitch,
|
||||
velocity: movedNote.velocity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions of all selected notes
|
||||
for (const noteIndex of this.selectedNotes) {
|
||||
if (noteIndex >= 0 && noteIndex < clipData.clip.notes.length) {
|
||||
const note = clipData.clip.notes[noteIndex]
|
||||
note.start_time = Math.max(0, note.start_time + deltaTime)
|
||||
note.note = Math.max(0, Math.min(127, note.note + deltaNote))
|
||||
}
|
||||
}
|
||||
|
||||
// Update drag start positions for next move
|
||||
this.movingStartTime = currentTime
|
||||
this.movingStartNote = currentNote
|
||||
|
||||
// Trigger timeline redraw to show updated notes
|
||||
if (context.timelineWidget) {
|
||||
context.timelineWidget.requestRedraw()
|
||||
}
|
||||
} else if (this.dragMode === 'resize') {
|
||||
// Resize the selected note
|
||||
if (this.resizingNoteIndex >= 0 && this.resizingNoteIndex < clipData.clip.notes.length) {
|
||||
const note = clipData.clip.notes[this.resizingNoteIndex]
|
||||
const currentTime = this.screenToTime(x)
|
||||
const newDuration = Math.max(0.1, currentTime - note.start_time)
|
||||
note.duration = newDuration
|
||||
|
||||
// Trigger timeline redraw to show updated notes
|
||||
if (context.timelineWidget) {
|
||||
context.timelineWidget.requestRedraw()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mouseup(x, y) {
|
||||
this._globalEvents.delete("mousemove")
|
||||
this._globalEvents.delete("mouseup")
|
||||
|
||||
const clipData = this.getSelectedClip()
|
||||
|
||||
// Stop playing note
|
||||
if (this.playingNote !== null && clipData) {
|
||||
invoke('audio_send_midi_note_off', {
|
||||
trackId: clipData.trackId,
|
||||
note: this.playingNote
|
||||
})
|
||||
this.playingNote = null
|
||||
this.playingNoteMaxDuration = null
|
||||
this.playingNoteStartTime = null
|
||||
}
|
||||
|
||||
// If we were creating a note, add it to the clip
|
||||
if (this.dragMode === 'create' && this.creatingNote && clipData) {
|
||||
if (!clipData.clip.notes) {
|
||||
clipData.clip.notes = []
|
||||
}
|
||||
|
||||
// Binary search to find insertion position to maintain sorted order
|
||||
const newNote = { ...this.creatingNote }
|
||||
let left = 0
|
||||
let right = clipData.clip.notes.length
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (clipData.clip.notes[mid].start_time < newNote.start_time) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid
|
||||
}
|
||||
}
|
||||
clipData.clip.notes.splice(left, 0, newNote)
|
||||
|
||||
// Trigger timeline redraw to show new note
|
||||
if (context.timelineWidget) {
|
||||
context.timelineWidget.requestRedraw()
|
||||
}
|
||||
|
||||
// Sync to backend
|
||||
this.syncNotesToBackend(clipData)
|
||||
}
|
||||
|
||||
// If we moved or resized notes, sync to backend
|
||||
if ((this.dragMode === 'move' || this.dragMode === 'resize') && clipData) {
|
||||
if (context.timelineWidget) {
|
||||
context.timelineWidget.requestRedraw()
|
||||
}
|
||||
|
||||
// Sync to backend
|
||||
this.syncNotesToBackend(clipData)
|
||||
}
|
||||
|
||||
this.isDragging = false
|
||||
this.dragMode = null
|
||||
this.creatingNote = null
|
||||
this.resizingNoteIndex = -1
|
||||
}
|
||||
|
||||
wheel(e) {
|
||||
// Support horizontal scrolling from trackpad (deltaX) or Shift+scroll (deltaY)
|
||||
if (e.deltaX !== 0) {
|
||||
// Trackpad horizontal scroll
|
||||
this.scrollX += e.deltaX
|
||||
} else if (e.shiftKey) {
|
||||
// Shift+wheel for horizontal scroll
|
||||
this.scrollX += e.deltaY
|
||||
} else {
|
||||
// Normal vertical scroll
|
||||
this.scrollY += e.deltaY
|
||||
}
|
||||
|
||||
this.scrollX = Math.max(0, this.scrollX)
|
||||
this.scrollY = Math.max(0, this.scrollY)
|
||||
|
||||
// Disable auto-scroll when user manually scrolls
|
||||
this.autoScrollEnabled = false
|
||||
}
|
||||
|
||||
syncNotesToBackend(clipData) {
|
||||
// Convert notes to backend format: (start_time, note, velocity, duration)
|
||||
const notes = clipData.clip.notes.map(n => [
|
||||
n.start_time,
|
||||
n.note,
|
||||
n.velocity,
|
||||
n.duration
|
||||
])
|
||||
|
||||
// Send to backend
|
||||
invoke('audio_update_midi_clip_notes', {
|
||||
trackId: clipData.trackId,
|
||||
clipId: clipData.clip.clipId,
|
||||
notes: notes
|
||||
}).catch(err => {
|
||||
console.error('Failed to update MIDI notes:', err)
|
||||
})
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// Update dimensions
|
||||
// (width/height will be set by parent container)
|
||||
|
||||
// Set initial scroll position to center on G4 (MIDI note 67) on first draw
|
||||
if (!this.initialScrollSet && this.height > 0) {
|
||||
const g4Index = this.maxNote - 67 // G4 is MIDI note 67
|
||||
const g4Y = g4Index * this.noteHeight
|
||||
// Center G4 in the viewport
|
||||
this.scrollY = g4Y - (this.height / 2)
|
||||
this.initialScrollSet = true
|
||||
}
|
||||
|
||||
// Auto-scroll to follow playhead during playback
|
||||
if (this.autoScrollEnabled && context.activeObject && this.width > 0) {
|
||||
const playheadTime = context.activeObject.currentTime || 0
|
||||
|
||||
// Check if playhead is moving forward (playing)
|
||||
if (playheadTime > this.lastPlayheadTime) {
|
||||
// Center playhead in viewport
|
||||
const gridWidth = this.width - this.keyboardWidth
|
||||
const playheadScreenX = playheadTime * this.pixelsPerSecond
|
||||
const targetScrollX = playheadScreenX - (gridWidth / 2)
|
||||
|
||||
this.scrollX = Math.max(0, targetScrollX)
|
||||
}
|
||||
|
||||
this.lastPlayheadTime = playheadTime
|
||||
}
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(0, 0, this.width, this.height)
|
||||
|
||||
// Draw piano keyboard
|
||||
this.drawKeyboard(ctx, this.width, this.height)
|
||||
|
||||
// Draw grid
|
||||
this.drawGrid(ctx, this.width, this.height)
|
||||
|
||||
// Draw notes if we have a selected clip
|
||||
const selected = this.getSelectedClip()
|
||||
if (selected && selected.clip && selected.clip.notes) {
|
||||
this.drawNotes(ctx, this.width, this.height, selected.clip)
|
||||
}
|
||||
|
||||
// Draw playhead
|
||||
this.drawPlayhead(ctx, this.width, this.height)
|
||||
}
|
||||
|
||||
drawKeyboard(ctx, width, height) {
|
||||
const keyboardWidth = this.keyboardWidth
|
||||
|
||||
// Draw keyboard background
|
||||
ctx.fillStyle = shade
|
||||
ctx.fillRect(0, 0, keyboardWidth, height)
|
||||
|
||||
// Draw keys
|
||||
for (let note = this.minNote; note <= this.maxNote; note++) {
|
||||
const y = this.noteToScreenY(note)
|
||||
|
||||
if (y < 0 || y > height) continue
|
||||
|
||||
const isBlackKey = [1, 3, 6, 8, 10].includes(note % 12)
|
||||
|
||||
ctx.fillStyle = isBlackKey ? '#333' : '#fff'
|
||||
ctx.fillRect(5, y, keyboardWidth - 10, this.noteHeight - 1)
|
||||
|
||||
// Draw note label for C notes
|
||||
if (note % 12 === 0) {
|
||||
ctx.fillStyle = '#999'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(`C${Math.floor(note / 12) - 1}`, keyboardWidth - 15, y + this.noteHeight / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx, width, height) {
|
||||
const gridLeft = this.keyboardWidth
|
||||
const gridWidth = width - gridLeft
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(gridLeft, 0, gridWidth, height)
|
||||
ctx.clip()
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(gridLeft, 0, gridWidth, height)
|
||||
|
||||
// Draw horizontal lines (note separators)
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
for (let note = this.minNote; note <= this.maxNote; note++) {
|
||||
const y = this.noteToScreenY(note)
|
||||
|
||||
if (y < 0 || y > height) continue
|
||||
|
||||
// Highlight C notes
|
||||
if (note % 12 === 0) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
|
||||
} else {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gridLeft, y)
|
||||
ctx.lineTo(width, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw vertical lines (time grid)
|
||||
const beatInterval = 0.5 // Half second intervals
|
||||
const startTime = Math.floor(this.scrollX / this.pixelsPerSecond / beatInterval) * beatInterval
|
||||
const endTime = (this.scrollX + gridWidth) / this.pixelsPerSecond
|
||||
|
||||
for (let time = startTime; time <= endTime; time += beatInterval) {
|
||||
const x = this.timeToScreenX(time)
|
||||
|
||||
if (x < gridLeft || x > width) continue
|
||||
|
||||
// Every second is brighter
|
||||
if (Math.abs(time % 1.0) < 0.01) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
|
||||
} else {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, height)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
drawNotes(ctx, width, height, clip) {
|
||||
const gridLeft = this.keyboardWidth
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(gridLeft, 0, width - gridLeft, height)
|
||||
ctx.clip()
|
||||
|
||||
// Draw existing notes
|
||||
ctx.fillStyle = '#6fdc6f'
|
||||
|
||||
for (let i = 0; i < clip.notes.length; i++) {
|
||||
const note = clip.notes[i]
|
||||
|
||||
const x = this.timeToScreenX(note.start_time)
|
||||
const y = this.noteToScreenY(note.note)
|
||||
const noteWidth = note.duration * this.pixelsPerSecond
|
||||
const noteHeight = this.noteHeight - 2
|
||||
|
||||
// Skip if off-screen
|
||||
if (x + noteWidth < gridLeft || x > width || y + noteHeight < 0 || y > height) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Highlight selected notes
|
||||
if (this.selectedNotes.has(i)) {
|
||||
ctx.fillStyle = '#8ffc8f'
|
||||
} else {
|
||||
ctx.fillStyle = '#6fdc6f'
|
||||
}
|
||||
|
||||
ctx.fillRect(x, y, noteWidth, noteHeight)
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'
|
||||
ctx.strokeRect(x, y, noteWidth, noteHeight)
|
||||
}
|
||||
|
||||
// Draw note being created
|
||||
if (this.creatingNote) {
|
||||
const x = this.timeToScreenX(this.creatingNote.start_time)
|
||||
const y = this.noteToScreenY(this.creatingNote.note)
|
||||
const noteWidth = this.creatingNote.duration * this.pixelsPerSecond
|
||||
const noteHeight = this.noteHeight - 2
|
||||
|
||||
// Draw with a slightly transparent color to indicate it's being created
|
||||
ctx.fillStyle = 'rgba(111, 220, 111, 0.7)'
|
||||
ctx.fillRect(x, y, noteWidth, noteHeight)
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.strokeRect(x, y, noteWidth, noteHeight)
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
drawPlayhead(ctx, width, height) {
|
||||
// Get current playhead time from context
|
||||
if (typeof context === 'undefined' || !context.activeObject) {
|
||||
return
|
||||
}
|
||||
|
||||
const playheadTime = context.activeObject.currentTime || 0
|
||||
const gridLeft = this.keyboardWidth
|
||||
|
||||
// Convert time to screen X position
|
||||
const playheadX = this.timeToScreenX(playheadTime)
|
||||
|
||||
// Only draw if playhead is visible
|
||||
if (playheadX < gridLeft || playheadX > width) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(gridLeft, 0, width - gridLeft, height)
|
||||
ctx.clip()
|
||||
|
||||
// Draw playhead line
|
||||
ctx.strokeStyle = 'rgba(255, 100, 100, 0.8)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(playheadX, 0)
|
||||
ctx.lineTo(playheadX, height)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
SCROLL,
|
||||
Widget,
|
||||
|
|
@ -4473,5 +5128,6 @@ export {
|
|||
ScrollableWindowHeaders,
|
||||
TimelineWindow,
|
||||
TimelineWindowV2,
|
||||
VirtualPiano
|
||||
VirtualPiano,
|
||||
PianoRollEditor
|
||||
};
|
||||
Loading…
Reference in New Issue