piano roll improvements

This commit is contained in:
Skyler Lehmkuhl 2025-11-24 11:11:10 -05:00
parent 1fcefab966
commit 422f97382b
2 changed files with 648 additions and 54 deletions

View File

@ -10508,11 +10508,108 @@ function piano() {
} }
function pianoRoll() { function pianoRoll() {
// Create container for piano roll and properties panel
let container = document.createElement("div");
container.className = "piano-roll-container";
container.style.position = "relative";
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
let canvas = document.createElement("canvas"); let canvas = document.createElement("canvas");
canvas.className = "piano-roll"; canvas.className = "piano-roll";
canvas.style.flex = "1";
// Create properties panel
let propertiesPanel = document.createElement("div");
propertiesPanel.className = "piano-roll-properties";
propertiesPanel.style.display = "flex";
propertiesPanel.style.gap = "15px";
propertiesPanel.style.padding = "10px";
propertiesPanel.style.backgroundColor = "#1e1e1e";
propertiesPanel.style.borderLeft = "1px solid #333";
propertiesPanel.style.alignItems = "center";
propertiesPanel.style.fontSize = "12px";
propertiesPanel.style.color = "#ccc";
// Create property sections
const createPropertySection = (label, isEditable = false) => {
const section = document.createElement("div");
section.style.display = "flex";
section.style.flexDirection = "column";
section.style.gap = "5px";
const labelEl = document.createElement("label");
labelEl.textContent = label;
labelEl.style.fontSize = "11px";
labelEl.style.color = "#999";
section.appendChild(labelEl);
if (isEditable) {
const inputContainer = document.createElement("div");
inputContainer.style.display = "flex";
inputContainer.style.gap = "5px";
inputContainer.style.alignItems = "center";
const input = document.createElement("input");
input.type = "number";
input.style.width = "45px";
input.style.padding = "3px";
input.style.backgroundColor = "#2a2a2a";
input.style.border = "1px solid #444";
input.style.borderRadius = "3px";
input.style.color = "#ccc";
input.style.fontSize = "12px";
input.style.boxSizing = "border-box";
inputContainer.appendChild(input);
const slider = document.createElement("input");
slider.type = "range";
slider.style.flex = "1";
slider.style.minWidth = "80px";
inputContainer.appendChild(slider);
section.appendChild(inputContainer);
return { section, input, slider };
} else {
const value = document.createElement("span");
value.style.color = "#fff";
value.textContent = "-";
section.appendChild(value);
return { section, value };
}
};
const pitchSection = createPropertySection("Pitch");
const velocitySection = createPropertySection("Velocity", true);
const modulationSection = createPropertySection("Modulation", true);
// Configure velocity slider
velocitySection.input.min = 1;
velocitySection.input.max = 127;
velocitySection.slider.min = 1;
velocitySection.slider.max = 127;
// Configure modulation slider
modulationSection.input.min = 0;
modulationSection.input.max = 127;
modulationSection.slider.min = 0;
modulationSection.slider.max = 127;
propertiesPanel.appendChild(pitchSection.section);
propertiesPanel.appendChild(velocitySection.section);
propertiesPanel.appendChild(modulationSection.section);
container.appendChild(canvas);
container.appendChild(propertiesPanel);
// Create the piano roll editor widget // Create the piano roll editor widget
canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0); canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0);
canvas.pianoRollEditor.propertiesPanel = {
pitch: pitchSection.value,
velocity: { input: velocitySection.input, slider: velocitySection.slider },
modulation: { input: modulationSection.input, slider: modulationSection.slider }
};
function updateCanvasSize() { function updateCanvasSize() {
const canvasStyles = window.getComputedStyle(canvas); const canvasStyles = window.getComputedStyle(canvas);
@ -10533,6 +10630,30 @@ function pianoRoll() {
// Render the piano roll // Render the piano roll
canvas.pianoRollEditor.draw(ctx); canvas.pianoRollEditor.draw(ctx);
// Update properties panel layout based on aspect ratio
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
const isWide = containerWidth > containerHeight;
if (isWide) {
// Side layout
container.style.flexDirection = "row";
propertiesPanel.style.flexDirection = "column";
propertiesPanel.style.width = "240px";
propertiesPanel.style.height = "auto";
propertiesPanel.style.borderLeft = "1px solid #333";
propertiesPanel.style.borderTop = "none";
propertiesPanel.style.alignItems = "stretch";
} else {
// Bottom layout
container.style.flexDirection = "column";
propertiesPanel.style.flexDirection = "row";
propertiesPanel.style.width = "auto";
propertiesPanel.style.height = "60px";
propertiesPanel.style.borderLeft = "none";
propertiesPanel.style.borderTop = "1px solid #333";
}
} }
// Store references in context for global access and playback updates // Store references in context for global access and playback updates
@ -10543,7 +10664,7 @@ function pianoRoll() {
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
updateCanvasSize(); updateCanvasSize();
}); });
resizeObserver.observe(canvas); resizeObserver.observe(container);
// Pointer event handlers (works with mouse and touch) // Pointer event handlers (works with mouse and touch)
canvas.addEventListener("pointerdown", (e) => { canvas.addEventListener("pointerdown", (e) => {
@ -10585,7 +10706,69 @@ function pianoRoll() {
// Prevent text selection // Prevent text selection
canvas.addEventListener("selectstart", (e) => e.preventDefault()); canvas.addEventListener("selectstart", (e) => e.preventDefault());
return canvas; // Add event handlers for velocity and modulation inputs/sliders
const syncInputSlider = (input, slider) => {
input.addEventListener("input", () => {
const value = parseInt(input.value);
if (!isNaN(value)) {
slider.value = value;
}
});
slider.addEventListener("input", () => {
input.value = slider.value;
});
};
syncInputSlider(velocitySection.input, velocitySection.slider);
syncInputSlider(modulationSection.input, modulationSection.slider);
// Handle property changes
const updateNoteProperty = (property, value) => {
const clipData = canvas.pianoRollEditor.getSelectedClip();
if (!clipData || !clipData.clip || !clipData.clip.notes) return;
if (canvas.pianoRollEditor.selectedNotes.size === 0) return;
for (const noteIndex of canvas.pianoRollEditor.selectedNotes) {
if (noteIndex >= 0 && noteIndex < clipData.clip.notes.length) {
const note = clipData.clip.notes[noteIndex];
if (property === "velocity") {
note.velocity = value;
} else if (property === "modulation") {
note.modulation = value;
}
}
}
canvas.pianoRollEditor.syncNotesToBackend(clipData);
updateCanvasSize();
};
velocitySection.input.addEventListener("change", (e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= 1 && value <= 127) {
updateNoteProperty("velocity", value);
}
});
velocitySection.slider.addEventListener("change", (e) => {
const value = parseInt(e.target.value);
updateNoteProperty("velocity", value);
});
modulationSection.input.addEventListener("change", (e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= 0 && value <= 127) {
updateNoteProperty("modulation", value);
}
});
modulationSection.slider.addEventListener("change", (e) => {
const value = parseInt(e.target.value);
updateNoteProperty("modulation", value);
});
return container;
} }
function presetBrowser() { function presetBrowser() {

View File

@ -1390,6 +1390,18 @@ class TimelineWindowV2 extends Widget {
trackHeight - 10 trackHeight - 10
) )
// Highlight selected MIDI clip
if (isMIDI && context.pianoRollEditor && clip.clipId === context.pianoRollEditor.selectedClipId) {
ctx.strokeStyle = '#6fdc6f' // Bright green for selected MIDI clip
ctx.lineWidth = 2
ctx.strokeRect(
startX,
y + 5,
clipWidth,
trackHeight - 10
)
}
// Draw clip name if there's enough space // Draw clip name if there's enough space
const minWidthForLabel = 40 const minWidthForLabel = 40
if (clipWidth >= minWidthForLabel) { if (clipWidth >= minWidthForLabel) {
@ -2265,6 +2277,16 @@ class TimelineWindowV2 extends Widget {
// Select the track // Select the track
this.selectTrack(track) this.selectTrack(track)
// If this is a MIDI clip, update piano roll selection
if (audioClipInfo.isMIDI && context.pianoRollEditor) {
context.pianoRollEditor.selectedClipId = audioClipInfo.clip.clipId
context.pianoRollEditor.selectedNotes.clear()
// Trigger piano roll redraw to show the selection change
if (context.pianoRollRedraw) {
context.pianoRollRedraw()
}
}
// Start audio clip dragging // Start audio clip dragging
const clickTime = this.timelineState.pixelToTime(adjustedX) const clickTime = this.timelineState.pixelToTime(adjustedX)
this.draggingAudioClip = { this.draggingAudioClip = {
@ -2866,7 +2888,8 @@ class TimelineWindowV2 extends Widget {
return { return {
clip: clip, clip: clip,
clipIndex: i, clipIndex: i,
audioTrack: audioTrack audioTrack: audioTrack,
isMIDI: audioTrack.type === 'midi'
} }
} }
} }
@ -5372,10 +5395,12 @@ class PianoRollEditor extends Widget {
// Interaction state // Interaction state
this.selectedNotes = new Set() // Set of note indices this.selectedNotes = new Set() // Set of note indices
this.dragMode = null // null, 'move', 'resize-left', 'resize-right', 'create' this.selectedClipId = null // Currently selected clip ID for editing
this.dragMode = null // null, 'move', 'resize', 'create', 'select'
this.dragStartX = 0 this.dragStartX = 0
this.dragStartY = 0 this.dragStartY = 0
this.creatingNote = null // Temporary note being created this.creatingNote = null // Temporary note being created
this.selectionRect = null // Rectangle for multi-select {startX, startY, endX, endY}
this.isDragging = false this.isDragging = false
// Note preview playback state // Note preview playback state
@ -5387,10 +5412,24 @@ class PianoRollEditor extends Widget {
this.autoScrollEnabled = true // Auto-scroll to follow playhead during playback this.autoScrollEnabled = true // Auto-scroll to follow playhead during playback
this.lastPlayheadTime = 0 // Track last playhead position this.lastPlayheadTime = 0 // Track last playhead position
// Properties panel state
this.propertyInputs = {} // Will hold references to input elements
// Start timer to check for note duration expiry // Start timer to check for note duration expiry
this.checkNoteDurationTimer = setInterval(() => this.checkNoteDuration(), 50) this.checkNoteDurationTimer = setInterval(() => this.checkNoteDuration(), 50)
} }
// Get the dimensions of the piano roll grid area (excluding keyboard)
// Note: Properties panel is outside the canvas now, so we don't subtract it here
getGridBounds() {
return {
left: this.keyboardWidth,
top: 0,
width: this.width - this.keyboardWidth,
height: this.height
}
}
checkNoteDuration() { checkNoteDuration() {
if (this.playingNote !== null && this.playingNoteMaxDuration !== null && this.playingNoteStartTime !== null) { if (this.playingNote !== null && this.playingNoteMaxDuration !== null && this.playingNoteStartTime !== null) {
const elapsed = (Date.now() - this.playingNoteStartTime) / 1000 const elapsed = (Date.now() - this.playingNoteStartTime) / 1000
@ -5410,23 +5449,46 @@ class PianoRollEditor extends Widget {
} }
} }
// Get the currently selected MIDI clip from context // Get all MIDI clips and the selected clip from the first MIDI track
getSelectedClip() { getMidiClipsData() {
if (typeof context === 'undefined' || !context.activeObject || !context.activeObject.audioTracks) { if (typeof context === 'undefined' || !context.activeObject || !context.activeObject.audioTracks) {
return null return null
} }
// Find the first MIDI track with a selected clip // Find the first MIDI track
for (const track of context.activeObject.audioTracks) { for (const track of context.activeObject.audioTracks) {
if (track.type === 'midi' && track.clips && track.clips.length > 0) { if (track.type === 'midi' && track.clips && track.clips.length > 0) {
// For now, just return the first clip on the first MIDI track // If no clip is selected, default to the first clip
// TODO: Add proper clip selection mechanism if (this.selectedClipId === null && track.clips.length > 0) {
return { clip: track.clips[0], trackId: track.audioTrackId } this.selectedClipId = track.clips[0].clipId
}
// Find the selected clip
let selectedClip = track.clips.find(c => c.clipId === this.selectedClipId)
// If selected clip not found (maybe deleted), select first clip
if (!selectedClip && track.clips.length > 0) {
selectedClip = track.clips[0]
this.selectedClipId = selectedClip.clipId
}
return {
allClips: track.clips,
selectedClip: selectedClip,
trackId: track.audioTrackId
}
} }
} }
return null return null
} }
// Get the currently selected MIDI clip (for backward compatibility)
getSelectedClip() {
const data = this.getMidiClipsData()
if (!data || !data.selectedClip) return null
return { clip: data.selectedClip, trackId: data.trackId }
}
hitTest(x, y) { hitTest(x, y) {
return x >= 0 && x <= this.width && y >= 0 && y <= this.height return x >= 0 && x <= this.width && y >= 0 && y <= this.height
} }
@ -5453,7 +5515,22 @@ class PianoRollEditor extends Widget {
return time * this.pixelsPerSecond - this.scrollX + this.keyboardWidth return time * this.pixelsPerSecond - this.scrollX + this.keyboardWidth
} }
// Find note at screen position // Find which clip contains the given time
findClipAtTime(time) {
const clipsData = this.getMidiClipsData()
if (!clipsData || !clipsData.allClips) return null
for (const clip of clipsData.allClips) {
const clipStart = clip.startTime || 0
const clipEnd = clipStart + (clip.duration || 0)
if (time >= clipStart && time <= clipEnd) {
return clip
}
}
return null
}
// Find note at screen position (only searches selected clip)
findNoteAtPosition(x, y) { findNoteAtPosition(x, y) {
const clipData = this.getSelectedClip() const clipData = this.getSelectedClip()
if (!clipData || !clipData.clip.notes) { if (!clipData || !clipData.clip.notes) {
@ -5462,12 +5539,14 @@ class PianoRollEditor extends Widget {
const note = this.screenToNote(y) const note = this.screenToNote(y)
const time = this.screenToTime(x) const time = this.screenToTime(x)
const clipStartTime = clipData.clip.startTime || 0
const clipLocalTime = time - clipStartTime
// Search in reverse order so we find top-most notes first // Search in reverse order so we find top-most notes first
for (let i = clipData.clip.notes.length - 1; i >= 0; i--) { for (let i = clipData.clip.notes.length - 1; i >= 0; i--) {
const n = clipData.clip.notes[i] const n = clipData.clip.notes[i]
const noteMatches = Math.round(n.note) === Math.round(note) const noteMatches = Math.round(n.note) === Math.round(note)
const timeInRange = time >= n.start_time && time <= (n.start_time + n.duration) const timeInRange = clipLocalTime >= n.start_time && clipLocalTime <= (n.start_time + n.duration)
if (noteMatches && timeInRange) { if (noteMatches && timeInRange) {
return i return i
@ -5485,7 +5564,9 @@ class PianoRollEditor extends Widget {
} }
const note = clipData.clip.notes[noteIndex] const note = clipData.clip.notes[noteIndex]
const noteEndX = this.timeToScreenX(note.start_time + note.duration) const clipStartTime = clipData.clip.startTime || 0
const globalEndTime = clipStartTime + note.start_time + note.duration
const noteEndX = this.timeToScreenX(globalEndTime)
// Consider clicking within 8 pixels of the right edge as resize // Consider clicking within 8 pixels of the right edge as resize
return Math.abs(x - noteEndX) < 8 return Math.abs(x - noteEndX) < 8
@ -5508,6 +5589,22 @@ class PianoRollEditor extends Widget {
const note = this.screenToNote(y) const note = this.screenToNote(y)
const time = this.screenToTime(x) const time = this.screenToTime(x)
// Check if clicking on a different clip and switch to it
const clickedClip = this.findClipAtTime(time)
if (clickedClip && clickedClip.clipId !== this.selectedClipId) {
this.selectedClipId = clickedClip.clipId
this.selectedNotes.clear()
// Redraw to show the new selection
if (context.timelineWidget) {
context.timelineWidget.requestRedraw()
}
// Don't start dragging/editing on the same click that switches clips
this.isDragging = false
this._globalEvents.delete("mousemove")
this._globalEvents.delete("mouseup")
return
}
// Check if clicking on an existing note // Check if clicking on an existing note
const noteIndex = this.findNoteAtPosition(x, y) const noteIndex = this.findNoteAtPosition(x, y)
@ -5547,21 +5644,28 @@ class PianoRollEditor extends Widget {
} }
} }
} else { } else {
// Clicking on empty space - start creating a new note // Clicking on empty space
const isShiftHeld = this.lastClickEvent?.shiftKey || false
if (isShiftHeld) {
// Shift+click: Start creating a new note
this.dragMode = 'create' this.dragMode = 'create'
this.selectedNotes.clear() this.selectedNotes.clear()
// Create a temporary note for preview // Create a temporary note for preview (store in clip-local time)
const clipData = this.getSelectedClip()
const clipStartTime = clipData?.clip?.startTime || 0
const clipLocalTime = time - clipStartTime
const newNoteValue = Math.round(note) const newNoteValue = Math.round(note)
this.creatingNote = { this.creatingNote = {
note: newNoteValue, note: newNoteValue,
start_time: time, start_time: clipLocalTime,
duration: 0.1, // Minimum duration duration: 0.1, // Minimum duration
velocity: 100 velocity: 100
} }
// Play preview of the new note // Play preview of the new note
const clipData = this.getSelectedClip()
if (clipData) { if (clipData) {
this.playingNote = newNoteValue this.playingNote = newNoteValue
this.playingNoteMaxDuration = null // No max duration for creating notes this.playingNoteMaxDuration = null // No max duration for creating notes
@ -5573,6 +5677,17 @@ class PianoRollEditor extends Widget {
velocity: 100 velocity: 100
}) })
} }
} else {
// Regular click: Start selection rectangle
this.dragMode = 'select'
this.selectedNotes.clear()
this.selectionRect = {
startX: x,
startY: y,
endX: x,
endY: y
}
}
} }
} }
@ -5596,7 +5711,9 @@ class PianoRollEditor extends Widget {
// Extend the note being created // Extend the note being created
if (this.creatingNote) { if (this.creatingNote) {
const currentTime = this.screenToTime(x) const currentTime = this.screenToTime(x)
const duration = Math.max(0.1, currentTime - this.creatingNote.start_time) const clipStartTime = clipData.clip.startTime || 0
const clipLocalTime = currentTime - clipStartTime
const duration = Math.max(0.1, clipLocalTime - this.creatingNote.start_time)
this.creatingNote.duration = duration this.creatingNote.duration = duration
} }
} else if (this.dragMode === 'move') { } else if (this.dragMode === 'move') {
@ -5658,7 +5775,9 @@ class PianoRollEditor extends Widget {
if (this.resizingNoteIndex >= 0 && this.resizingNoteIndex < clipData.clip.notes.length) { if (this.resizingNoteIndex >= 0 && this.resizingNoteIndex < clipData.clip.notes.length) {
const note = clipData.clip.notes[this.resizingNoteIndex] const note = clipData.clip.notes[this.resizingNoteIndex]
const currentTime = this.screenToTime(x) const currentTime = this.screenToTime(x)
const newDuration = Math.max(0.1, currentTime - note.start_time) const clipStartTime = clipData.clip.startTime || 0
const clipLocalTime = currentTime - clipStartTime
const newDuration = Math.max(0.1, clipLocalTime - note.start_time)
note.duration = newDuration note.duration = newDuration
// Trigger timeline redraw to show updated notes // Trigger timeline redraw to show updated notes
@ -5666,6 +5785,15 @@ class PianoRollEditor extends Widget {
context.timelineWidget.requestRedraw() context.timelineWidget.requestRedraw()
} }
} }
} else if (this.dragMode === 'select') {
// Update selection rectangle
if (this.selectionRect) {
this.selectionRect.endX = x
this.selectionRect.endY = y
// Update selected notes based on rectangle
this.updateSelectionFromRect(clipData)
}
} }
} }
@ -5675,6 +5803,31 @@ class PianoRollEditor extends Widget {
const clipData = this.getSelectedClip() const clipData = this.getSelectedClip()
// Check if this was a simple click (not a drag) on empty space
if (this.dragMode === 'select' && this.dragStartX !== undefined && this.dragStartY !== undefined) {
const dragDistance = Math.sqrt(
Math.pow(x - this.dragStartX, 2) + Math.pow(y - this.dragStartY, 2)
)
// If drag distance is minimal (< 5 pixels), treat it as a click to reposition playhead
if (dragDistance < 5) {
const time = this.screenToTime(x)
// Set playhead position
if (context.activeObject) {
context.activeObject.currentTime = time
// Request redraws to show the new playhead position
if (context.timelineWidget) {
context.timelineWidget.requestRedraw()
}
if (context.pianoRollRedraw) {
context.pianoRollRedraw()
}
}
}
}
// Stop playing note // Stop playing note
if (this.playingNote !== null && clipData) { if (this.playingNote !== null && clipData) {
invoke('audio_send_midi_note_off', { invoke('audio_send_midi_note_off', {
@ -5728,6 +5881,7 @@ class PianoRollEditor extends Widget {
this.isDragging = false this.isDragging = false
this.dragMode = null this.dragMode = null
this.creatingNote = null this.creatingNote = null
this.selectionRect = null
this.resizingNoteIndex = -1 this.resizingNoteIndex = -1
} }
@ -5751,6 +5905,76 @@ class PianoRollEditor extends Widget {
this.autoScrollEnabled = false this.autoScrollEnabled = false
} }
keydown(e) {
// Handle delete/backspace to delete selected notes
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedNotes.size > 0) {
const clipData = this.getSelectedClip()
if (clipData && clipData.clip && clipData.clip.notes) {
// Convert set to sorted array in reverse order to avoid index shifting
const indicesToDelete = Array.from(this.selectedNotes).sort((a, b) => b - a)
for (const index of indicesToDelete) {
if (index >= 0 && index < clipData.clip.notes.length) {
clipData.clip.notes.splice(index, 1)
}
}
// Clear selection
this.selectedNotes.clear()
// Sync to backend
this.syncNotesToBackend(clipData)
// Trigger redraws
if (context.timelineWidget) {
context.timelineWidget.requestRedraw()
}
if (context.pianoRollRedraw) {
context.pianoRollRedraw()
}
}
e.preventDefault()
}
}
}
updateSelectionFromRect(clipData) {
if (!clipData || !clipData.clip || !clipData.clip.notes || !this.selectionRect) {
return
}
const clipStartTime = clipData.clip.startTime || 0
this.selectedNotes.clear()
// Get rectangle bounds
const minX = Math.min(this.selectionRect.startX, this.selectionRect.endX)
const maxX = Math.max(this.selectionRect.startX, this.selectionRect.endX)
const minY = Math.min(this.selectionRect.startY, this.selectionRect.endY)
const maxY = Math.max(this.selectionRect.startY, this.selectionRect.endY)
// Convert to time/note coordinates
const minTime = this.screenToTime(minX)
const maxTime = this.screenToTime(maxX)
const minNote = this.screenToNote(maxY) // Note: Y is inverted
const maxNote = this.screenToNote(minY)
// Check each note
for (let i = 0; i < clipData.clip.notes.length; i++) {
const note = clipData.clip.notes[i]
const noteGlobalStart = clipStartTime + note.start_time
const noteGlobalEnd = noteGlobalStart + note.duration
// Check if note overlaps with selection rectangle
const timeOverlaps = noteGlobalEnd >= minTime && noteGlobalStart <= maxTime
const noteOverlaps = note.note >= minNote && note.note <= maxNote
if (timeOverlaps && noteOverlaps) {
this.selectedNotes.add(i)
}
}
}
syncNotesToBackend(clipData) { syncNotesToBackend(clipData) {
// Convert notes to backend format: (start_time, note, velocity, duration) // Convert notes to backend format: (start_time, note, velocity, duration)
const notes = clipData.clip.notes.map(n => [ const notes = clipData.clip.notes.map(n => [
@ -5810,14 +6034,124 @@ class PianoRollEditor extends Widget {
// Draw grid // Draw grid
this.drawGrid(ctx, this.width, this.height) this.drawGrid(ctx, this.width, this.height)
// Draw notes if we have a selected clip // Draw clip boundaries
const selected = this.getSelectedClip() const clipsData = this.getMidiClipsData()
if (selected && selected.clip && selected.clip.notes) { if (clipsData && clipsData.allClips) {
this.drawNotes(ctx, this.width, this.height, selected.clip) this.drawClipBoundaries(ctx, this.width, this.height, clipsData.allClips)
}
// Draw notes for all clips in the track
if (clipsData && clipsData.allClips) {
// Draw non-selected clips first (at lower opacity)
for (const clip of clipsData.allClips) {
if (clip.clipId !== this.selectedClipId && clip.notes) {
this.drawNotes(ctx, this.width, this.height, clip, 0.3)
}
}
// Draw selected clip on top (at full opacity)
if (clipsData.selectedClip && clipsData.selectedClip.notes) {
this.drawNotes(ctx, this.width, this.height, clipsData.selectedClip, 1.0)
}
} }
// Draw playhead // Draw playhead
this.drawPlayhead(ctx, this.width, this.height) this.drawPlayhead(ctx, this.width, this.height)
// Draw selection rectangle
if (this.selectionRect) {
this.drawSelectionRect(ctx, this.width, this.height)
}
// Update HTML properties panel
this.updatePropertiesPanel()
}
drawSelectionRect(ctx, width, height) {
if (!this.selectionRect) return
const gridLeft = this.keyboardWidth
const minX = Math.max(gridLeft, Math.min(this.selectionRect.startX, this.selectionRect.endX))
const maxX = Math.min(width, Math.max(this.selectionRect.startX, this.selectionRect.endX))
const minY = Math.max(0, Math.min(this.selectionRect.startY, this.selectionRect.endY))
const maxY = Math.min(height, Math.max(this.selectionRect.startY, this.selectionRect.endY))
ctx.save()
// Draw filled rectangle with transparency
ctx.fillStyle = 'rgba(100, 150, 255, 0.2)'
ctx.fillRect(minX, minY, maxX - minX, maxY - minY)
// Draw border
ctx.strokeStyle = 'rgba(100, 150, 255, 0.6)'
ctx.lineWidth = 1
ctx.strokeRect(minX, minY, maxX - minX, maxY - minY)
ctx.restore()
}
updatePropertiesPanel() {
// Update the HTML properties panel with current selection
if (!this.propertiesPanel) return
const clipData = this.getSelectedClip()
const properties = this.getSelectedNoteProperties(clipData)
// Update pitch (display-only)
this.propertiesPanel.pitch.textContent = properties.pitch || '-'
// Update velocity
if (properties.velocity !== null) {
this.propertiesPanel.velocity.input.value = properties.velocity
this.propertiesPanel.velocity.slider.value = properties.velocity
} else {
this.propertiesPanel.velocity.input.value = ''
this.propertiesPanel.velocity.slider.value = 64 // Default middle value
}
// Update modulation
if (properties.modulation !== null) {
this.propertiesPanel.modulation.input.value = properties.modulation
this.propertiesPanel.modulation.slider.value = properties.modulation
} else {
this.propertiesPanel.modulation.input.value = ''
this.propertiesPanel.modulation.slider.value = 0
}
}
getSelectedNoteProperties(clipData) {
if (!clipData || !clipData.clip || !clipData.clip.notes || this.selectedNotes.size === 0) {
return { pitch: null, velocity: null, modulation: null }
}
const selectedIndices = Array.from(this.selectedNotes)
const notes = selectedIndices.map(i => clipData.clip.notes[i]).filter(n => n)
if (notes.length === 0) {
return { pitch: null, velocity: null, modulation: null }
}
// Check if all selected notes have the same values
const firstNote = notes[0]
const allSamePitch = notes.every(n => n.note === firstNote.note)
const allSameVelocity = notes.every(n => n.velocity === firstNote.velocity)
const allSameModulation = notes.every(n => (n.modulation || 0) === (firstNote.modulation || 0))
// Convert MIDI note number to name
const noteName = allSamePitch ? this.midiNoteToName(firstNote.note) : null
return {
pitch: noteName,
velocity: allSameVelocity ? firstNote.velocity : null,
modulation: allSameModulation ? (firstNote.modulation || 0) : null
}
}
midiNoteToName(midiNote) {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
const octave = Math.floor(midiNote / 12) - 1
const noteName = noteNames[midiNote % 12]
return `${noteName}${octave} (${midiNote})`
} }
drawKeyboard(ctx, width, height) { drawKeyboard(ctx, width, height) {
@ -5850,17 +6184,19 @@ class PianoRollEditor extends Widget {
} }
drawGrid(ctx, width, height) { drawGrid(ctx, width, height) {
const gridLeft = this.keyboardWidth const gridBounds = this.getGridBounds()
const gridWidth = width - gridLeft const gridLeft = gridBounds.left
const gridWidth = gridBounds.width
const gridHeight = gridBounds.height
ctx.save() ctx.save()
ctx.beginPath() ctx.beginPath()
ctx.rect(gridLeft, 0, gridWidth, height) ctx.rect(gridLeft, 0, gridWidth, gridHeight)
ctx.clip() ctx.clip()
// Draw background // Draw background
ctx.fillStyle = backgroundColor ctx.fillStyle = backgroundColor
ctx.fillRect(gridLeft, 0, gridWidth, height) ctx.fillRect(gridLeft, 0, gridWidth, gridHeight)
// Draw horizontal lines (note separators) // Draw horizontal lines (note separators)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
@ -5910,7 +6246,7 @@ class PianoRollEditor extends Widget {
ctx.restore() ctx.restore()
} }
drawNotes(ctx, width, height, clip) { drawClipBoundaries(ctx, width, height, clips) {
const gridLeft = this.keyboardWidth const gridLeft = this.keyboardWidth
ctx.save() ctx.save()
@ -5918,13 +6254,73 @@ class PianoRollEditor extends Widget {
ctx.rect(gridLeft, 0, width - gridLeft, height) ctx.rect(gridLeft, 0, width - gridLeft, height)
ctx.clip() ctx.clip()
// Draw existing notes // Draw background highlight for selected clip
ctx.fillStyle = '#6fdc6f' const selectedClip = clips.find(c => c.clipId === this.selectedClipId)
if (selectedClip) {
const clipStart = selectedClip.startTime || 0
const clipEnd = clipStart + (selectedClip.duration || 0)
const startX = Math.max(gridLeft, this.timeToScreenX(clipStart))
const endX = Math.min(width, this.timeToScreenX(clipEnd))
if (endX > startX) {
ctx.fillStyle = 'rgba(111, 220, 111, 0.05)' // Very subtle green tint
ctx.fillRect(startX, 0, endX - startX, height)
}
}
// Draw start and end lines for each clip
for (const clip of clips) {
const clipStart = clip.startTime || 0
const clipEnd = clipStart + (clip.duration || 0)
const isSelected = clip.clipId === this.selectedClipId
// Use brighter green for selected clip, dimmer for others
const color = isSelected ? 'rgba(111, 220, 111, 0.5)' : 'rgba(111, 220, 111, 0.2)'
const lineWidth = isSelected ? 2 : 1
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
// Draw clip start line
const startX = this.timeToScreenX(clipStart)
if (startX >= gridLeft && startX <= width) {
ctx.beginPath()
ctx.moveTo(startX, 0)
ctx.lineTo(startX, height)
ctx.stroke()
}
// Draw clip end line
const endX = this.timeToScreenX(clipEnd)
if (endX >= gridLeft && endX <= width) {
ctx.beginPath()
ctx.moveTo(endX, 0)
ctx.lineTo(endX, height)
ctx.stroke()
}
}
ctx.restore()
}
drawNotes(ctx, width, height, clip, opacity = 1.0) {
const gridLeft = this.keyboardWidth
const clipStartTime = clip.startTime || 0
const isSelectedClip = clip.clipId === this.selectedClipId
ctx.save()
ctx.globalAlpha = opacity
ctx.beginPath()
ctx.rect(gridLeft, 0, width - gridLeft, height)
ctx.clip()
// Draw existing notes at their global timeline position
for (let i = 0; i < clip.notes.length; i++) { for (let i = 0; i < clip.notes.length; i++) {
const note = clip.notes[i] const note = clip.notes[i]
const x = this.timeToScreenX(note.start_time) // Convert note time to global timeline time
const globalTime = clipStartTime + note.start_time
const x = this.timeToScreenX(globalTime)
const y = this.noteToScreenY(note.note) const y = this.noteToScreenY(note.note)
const noteWidth = note.duration * this.pixelsPerSecond const noteWidth = note.duration * this.pixelsPerSecond
const noteHeight = this.noteHeight - 2 const noteHeight = this.noteHeight - 2
@ -5934,11 +6330,24 @@ class PianoRollEditor extends Widget {
continue continue
} }
// Highlight selected notes // Calculate brightness based on velocity (1-127)
if (this.selectedNotes.has(i)) { // Map velocity to brightness range: 0.35 (min) to 1.0 (max)
ctx.fillStyle = '#8ffc8f' const velocity = note.velocity || 100
const brightness = 0.35 + (velocity / 127) * 0.65
// Highlight selected notes (only for selected clip)
if (isSelectedClip && this.selectedNotes.has(i)) {
// Selected note: brighter green with velocity-based brightness
const r = Math.round(143 * brightness)
const g = Math.round(252 * brightness)
const b = Math.round(143 * brightness)
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`
} else { } else {
ctx.fillStyle = '#6fdc6f' // Normal note: velocity-based brightness
const r = Math.round(111 * brightness)
const g = Math.round(220 * brightness)
const b = Math.round(111 * brightness)
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`
} }
ctx.fillRect(x, y, noteWidth, noteHeight) ctx.fillRect(x, y, noteWidth, noteHeight)
@ -5948,9 +6357,11 @@ class PianoRollEditor extends Widget {
ctx.strokeRect(x, y, noteWidth, noteHeight) ctx.strokeRect(x, y, noteWidth, noteHeight)
} }
// Draw note being created // Draw note being created (only for selected clip)
if (this.creatingNote) { if (this.creatingNote && isSelectedClip) {
const x = this.timeToScreenX(this.creatingNote.start_time) // Note being created is in clip-local time, convert to global
const globalTime = clipStartTime + this.creatingNote.start_time
const x = this.timeToScreenX(globalTime)
const y = this.noteToScreenY(this.creatingNote.note) const y = this.noteToScreenY(this.creatingNote.note)
const noteWidth = this.creatingNote.duration * this.pixelsPerSecond const noteWidth = this.creatingNote.duration * this.pixelsPerSecond
const noteHeight = this.noteHeight - 2 const noteHeight = this.noteHeight - 2