From a0dcc4e5b96e44f93bb1e3fb266dd0b85136c0f5 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Tue, 21 Apr 2026 09:39:05 -0700 Subject: [PATCH 1/6] beginnings of piano roll --- localtypings/pxtmusic.d.ts | 1 + pxtlib/music.ts | 58 +- theme/piano-roll/piano-roll.less | 148 +++ theme/pxt.less | 2 + webapp/src/assetEditor.tsx | 3 +- webapp/src/components/musicEditor/playback.ts | 97 ++ .../components/pianoRoll/DeleteErrorModal.tsx | 25 + .../components/pianoRoll/DeleteTrackModal.tsx | 37 + .../components/pianoRoll/DrumWarningModal.tsx | 38 + webapp/src/components/pianoRoll/Header.tsx | 111 ++ webapp/src/components/pianoRoll/NoteEvent.tsx | 29 + .../src/components/pianoRoll/PianoOctave.tsx | 99 ++ webapp/src/components/pianoRoll/PianoRoll.tsx | 187 +++ webapp/src/components/pianoRoll/Sidebar.tsx | 23 + webapp/src/components/pianoRoll/Workspace.tsx | 214 ++++ webapp/src/components/pianoRoll/context.tsx | 63 + webapp/src/components/pianoRoll/types.ts | 1009 +++++++++++++++++ webapp/src/components/pianoRoll/utils.ts | 62 + .../pianoRoll/workspaceBackground.tsx | 44 + 19 files changed, 2223 insertions(+), 27 deletions(-) create mode 100644 theme/piano-roll/piano-roll.less create mode 100644 webapp/src/components/pianoRoll/DeleteErrorModal.tsx create mode 100644 webapp/src/components/pianoRoll/DeleteTrackModal.tsx create mode 100644 webapp/src/components/pianoRoll/DrumWarningModal.tsx create mode 100644 webapp/src/components/pianoRoll/Header.tsx create mode 100644 webapp/src/components/pianoRoll/NoteEvent.tsx create mode 100644 webapp/src/components/pianoRoll/PianoOctave.tsx create mode 100644 webapp/src/components/pianoRoll/PianoRoll.tsx create mode 100644 webapp/src/components/pianoRoll/Sidebar.tsx create mode 100644 webapp/src/components/pianoRoll/Workspace.tsx create mode 100644 webapp/src/components/pianoRoll/context.tsx create mode 100644 webapp/src/components/pianoRoll/types.ts create mode 100644 webapp/src/components/pianoRoll/utils.ts create mode 100644 webapp/src/components/pianoRoll/workspaceBackground.tsx diff --git a/localtypings/pxtmusic.d.ts b/localtypings/pxtmusic.d.ts index 7961550e4f26..6cf3ea0bd36b 100644 --- a/localtypings/pxtmusic.d.ts +++ b/localtypings/pxtmusic.d.ts @@ -60,6 +60,7 @@ declare namespace pxt.assets.music { } export interface DrumInstrument { + name?: string; startFrequency: number; startVolume: number; steps: DrumSoundStep[]; diff --git a/pxtlib/music.ts b/pxtlib/music.ts index eb2db98cc1a2..06747a84fd3b 100644 --- a/pxtlib/music.ts +++ b/pxtlib/music.ts @@ -460,7 +460,12 @@ namespace pxt.assets.music { song.tracks = base.tracks.map((track, index) => { const existing = song.tracks.find(t => t.id === index); - if (existing) track.notes = existing.notes; + if (existing) { + track.notes = existing.notes; + if (track.instrument) { + track.instrument.octave = existing.instrument?.octave || track.instrument.octave; + } + } return track; }) } @@ -709,7 +714,7 @@ namespace pxt.assets.music { }, drums: [ { - /* neutral kick */ + name: lf("neutral kick"), startFrequency: 100, startVolume: 1024, steps: [ @@ -728,7 +733,7 @@ namespace pxt.assets.music { ] }, { - /* punchy kick */ + name: lf("punchy kick"), startFrequency: 200, startVolume: 1024, steps: [{ @@ -740,7 +745,7 @@ namespace pxt.assets.music { }, { - /* booming kick */ + name: lf("booming kick"), startFrequency: 100, startVolume: 1024, steps: [{ @@ -753,7 +758,7 @@ namespace pxt.assets.music { { - /* snare 1 */ + name: lf("snare 1"), startFrequency: 175, startVolume: 1024, steps: [ @@ -785,7 +790,7 @@ namespace pxt.assets.music { }, { - /* snare 2 */ + name: lf("snare 2"), startFrequency: 220, startVolume: 1024, steps: [ @@ -818,7 +823,7 @@ namespace pxt.assets.music { { - /* hat 1 */ + name: lf("hat 1"), startFrequency: 400, startVolume: 500, steps: [ @@ -838,7 +843,7 @@ namespace pxt.assets.music { }, { - /* hat 2 */ + name: lf("hat 2"), startFrequency: 400, startVolume: 0, steps: [ @@ -865,7 +870,7 @@ namespace pxt.assets.music { { - /* hat 3 */ + name: lf("hat 3"), startFrequency: 400, startVolume: 0, steps: [ @@ -897,7 +902,7 @@ namespace pxt.assets.music { }, { - /* hat 4 */ + name: lf("hat 4"), startFrequency: 400, startVolume: 0, steps: [ @@ -929,7 +934,7 @@ namespace pxt.assets.music { }, { - /* double hat */ + name: lf("double hat"), startFrequency: 3500, startVolume: 1024, steps: [ @@ -967,7 +972,7 @@ namespace pxt.assets.music { }, { - /* metallic */ + name: lf("metallic"), startFrequency: 2000, startVolume: 1024, steps: [ @@ -987,7 +992,7 @@ namespace pxt.assets.music { }, { - /* low tom */ + name: lf("low tom"), startFrequency: 200, startVolume: 200, steps: [ @@ -1013,7 +1018,7 @@ namespace pxt.assets.music { }, { - /* mid tom */ + name: lf("mid tom"), startFrequency: 300, startVolume: 200, steps: [ @@ -1039,7 +1044,7 @@ namespace pxt.assets.music { }, { - /* hi tom */ + name: lf("hi tom"), startFrequency: 500, startVolume: 200, steps: [ @@ -1064,7 +1069,7 @@ namespace pxt.assets.music { ] }, { - /* lo tom 2 */ + name: lf("lo tom 2"), startFrequency: 200, startVolume: 1024, steps: [ @@ -1077,7 +1082,7 @@ namespace pxt.assets.music { ] }, { - /* mid tom 2 */ + name: lf("mid tom 2"), startFrequency: 300, startVolume: 1024, steps: [ @@ -1092,7 +1097,7 @@ namespace pxt.assets.music { { - /* hi tom 2 */ + name: lf("hi tom 2"), startFrequency: 400, startVolume: 1024, steps: [ @@ -1107,7 +1112,7 @@ namespace pxt.assets.music { { - /* thump 1 */ + name: lf("thump 1"), startFrequency: 200, startVolume: 1024, steps: [ @@ -1127,7 +1132,7 @@ namespace pxt.assets.music { }, { - /* thump 2 */ + name: lf("thump 2"), startFrequency: 450, startVolume: 1024, steps: [ @@ -1147,7 +1152,7 @@ namespace pxt.assets.music { }, { - /* cymbal */ + name: lf("cymbal"), startFrequency: 2500, startVolume: 1024, steps: [ @@ -1167,7 +1172,7 @@ namespace pxt.assets.music { }, { - /* crash 1 */ + name: lf("crash 1"), startFrequency: 3000, startVolume: 1024, steps: [ @@ -1187,7 +1192,7 @@ namespace pxt.assets.music { }, { - /* crash 2 */ + name: lf("crash 2"), startFrequency: 800, startVolume: 0, steps: [ @@ -1207,7 +1212,7 @@ namespace pxt.assets.music { }, { - /* crash 3 */ + name: lf("crash 3"), startFrequency: 400, startVolume: 0, steps: [ @@ -1227,7 +1232,7 @@ namespace pxt.assets.music { }, { - /* buzzer */ + name: lf("buzzer"), startFrequency: 2000, startVolume: 1024, steps: [ @@ -1244,7 +1249,8 @@ namespace pxt.assets.music { waveform: 16 } ] - },] + }, + ] } ] } diff --git a/theme/piano-roll/piano-roll.less b/theme/piano-roll/piano-roll.less new file mode 100644 index 000000000000..a39f6f34bda4 --- /dev/null +++ b/theme/piano-roll/piano-roll.less @@ -0,0 +1,148 @@ +.piano-roll { + display: flex; + flex-direction: column; + + --black-key-height: calc(var(--white-key-height) * 0.625); + --sidebar-width: 100px; + + --octave-height: calc(var(--white-key-height) * 7); + + --grid-cell-height: calc(var(--octave-height) / 12); + --grid-cell-width: calc(var(--octave-width) / 16); + + --key-border: 1px solid black; + + --note-event-color: #a3f0c5; + --note-event-border: 2px solid #21905c; + --note-event-text-color: #21905c; + + --header-background: #f0f0f0; + + --header-height: 2.5rem; + + max-height: 100%; +} + +.piano-roll .header-container { + height: var(--header-height); + flex-shrink: 0; +} + +.piano-roll .header { + height: var(--header-height); + display: flex; + flex-direction: row; + gap: 0.5rem; + background-color: var(--header-background); + align-items: center; + ; +} + +.piano-roll-root { + height: 100vh; + width: 100%; +} + +.piano-roll .sidebar-container { + width: var(--sidebar-width); + position: relative; + +} + +.piano-roll .content-container { + display: flex; + flex-direction: row; +} + +.octave-sidebar { + user-select: none; + + .key.white { + height: var(--white-key-height); + border-right: var(--key-border); + border-bottom: var(--key-border); + display: flex; + align-items: flex-end; + justify-content: flex-end; + + &.active, &.playing { + background-color: var(--note-event-color); + } + } + + .key.black { + height: var(--black-key-height); + width: calc(var(--sidebar-width) * 0.8); + border: var(--key-border); + border-left: none; + background-color: black; + position: absolute; + margin-top: calc(var(--black-key-height) * -0.5); + + &.active, &.playing { + background-color: var(--note-event-color); + } + } + + .drum { + height: var(--grid-cell-height); + border-bottom: var(--key-border); + width: var(--sidebar-width); + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 0.25rem; + + &.active, &.playing { + background-color: var(--note-event-color); + } + } +} + +.piano-roll .workspace-container { + flex: 1; + overflow-x: hidden; +} + +.piano-roll .workspace { + background-size: var(--octave-width) var(--octave-height); + background-repeat: repeat; + position: relative; +} + +.piano-roll .workspace .note-event { + height: calc(var(--grid-cell-height) + 1px); + position: absolute; + display: flex; + align-items: center; + user-select: none; + + padding-left: 0.25rem; + + font-size: calc(var(--grid-cell-height) * 0.6); + background-color: var(--note-event-color); + border: var(--note-event-border); + color: var(--note-event-text-color); + + cursor: pointer; +} + +.piano-roll .workspace .note-event:hover { + border-color: white; + color: white; +} + +.piano-roll .scroll-container { + max-height: 100%; + width: 100%; + overflow-y: auto; +} + +.piano-roll .workspace .playhead { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 100%; + background-color: red; +} \ No newline at end of file diff --git a/theme/pxt.less b/theme/pxt.less index f3a918c0d5d5..93bc940ae5ec 100644 --- a/theme/pxt.less +++ b/theme/pxt.less @@ -49,5 +49,7 @@ @import "react-common"; +@import "piano-roll/piano-roll"; + /* Reference import */ @import (reference) "semantic.less"; diff --git a/webapp/src/assetEditor.tsx b/webapp/src/assetEditor.tsx index 094c9b046c6a..9077d578c215 100644 --- a/webapp/src/assetEditor.tsx +++ b/webapp/src/assetEditor.tsx @@ -7,6 +7,7 @@ import * as ReactDOM from "react-dom"; import { ImageFieldEditor } from "./components/ImageFieldEditor"; import { setTelemetryFunction } from './components/ImageEditor/store/imageReducer'; import { IFrameEmbeddedClient } from "../../pxtservices/iframeEmbeddedClient"; +import { PianoRoll } from "./components/pianoRoll/PianoRoll"; document.addEventListener("DOMContentLoaded", () => { @@ -15,7 +16,7 @@ document.addEventListener("DOMContentLoaded", () => { function init() { const assetDiv = document.getElementById("asset-editor-field-div") as HTMLDivElement; - ReactDOM.render(, assetDiv); + ReactDOM.render(, assetDiv); } interface AssetEditorState { diff --git a/webapp/src/components/musicEditor/playback.ts b/webapp/src/components/musicEditor/playback.ts index 7fb11a0c2263..14e6f4ce6621 100644 --- a/webapp/src/components/musicEditor/playback.ts +++ b/webapp/src/components/musicEditor/playback.ts @@ -1,8 +1,11 @@ let sequencer: pxsim.music.Sequencer; let playbackStateListeners: ((state: "play" | "loop" | "stop") => void)[] = []; let onTickListeners: ((tick: number) => void)[] = []; +let playbackSong: pxt.assets.music.Song; +let noteTracker: NoteTracker; export async function startPlaybackAsync(song: pxt.assets.music.Song, loop: boolean, ticks?: number) { + playbackSong = song; if (!sequencer) { sequencer = new pxsim.music.Sequencer(); await sequencer.initAsync(); @@ -38,6 +41,7 @@ export function setLooping(loop: boolean) { export async function updatePlaybackSongAsync(song: pxt.assets.music.Song) { if (sequencer) sequencer.updateSong(song); + playbackSong = song; } export function stopPlayback() { @@ -59,3 +63,96 @@ export function addPlaybackStateListener(listener: (state: "play" | "stop" | "lo export function removePlaybackStateListener(listener: (state: "play" | "stop" | "loop") => void) { playbackStateListeners = playbackStateListeners.filter(l => listener !== l); } + +export function addNoteChangeListener(listener: (track: number, note: number, on: boolean) => void) { + if (!noteTracker) noteTracker = new NoteTracker(); + noteTracker.addNoteChangeListener(listener); +} + +export function removeNoteChangeListener(listener: (track: number, note: number, on: boolean) => void) { + if (noteTracker) noteTracker.removeNoteChangeListener(listener); +} + + +export class NoteTracker { + private activeNotes: { [track: number]: number[] } = {}; + private noteChangeListeners: ((track: number, note: number, on: boolean) => void)[] = []; + + constructor() { + addTickListener(this.onTick); + addPlaybackStateListener(this.onPlaybackStateChange); + } + + protected onTick = (tick: number) => { + const newActiveNotes: { [track: number]: number[] } = {}; + for (let i = 0; i < playbackSong.tracks.length; i++) { + newActiveNotes[i] = []; + + const track = playbackSong.tracks[i]; + + for (const event of track.notes) { + if (event.startTick > tick) break; + + if (event.startTick <= tick && event.endTick > tick) { + for (const note of event.notes) { + newActiveNotes[i].push(note.note); + } + } + } + } + + for (let i = 0; i < playbackSong.tracks.length; i++) { + const oldNotes = this.activeNotes[i] || []; + const newNotes = newActiveNotes[i] || []; + + for (const note of oldNotes) { + if (!newNotes.includes(note)) { + for (const listener of this.noteChangeListeners) { + listener(i, note, false); + } + } + } + + for (const note of newNotes) { + if (!oldNotes.includes(note)) { + for (const listener of this.noteChangeListeners) { + listener(i, note, true); + } + } + } + } + + this.activeNotes = newActiveNotes; + } + + protected onPlaybackStateChange = (state: "play" | "stop" | "loop") => { + if (state === "stop") { + for (const track in this.activeNotes) { + for (const note of this.activeNotes[track]) { + for (const listener of this.noteChangeListeners) { + listener(parseInt(track), note, false); + } + } + } + + this.activeNotes = {}; + } + } + + addNoteChangeListener(listener: (track: number, note: number, on: boolean) => void) { + this.noteChangeListeners.push(listener); + } + + removeNoteChangeListener(listener: (track: number, note: number, on: boolean) => void) { + this.noteChangeListeners = this.noteChangeListeners.filter(l => listener !== l); + } + + dispose() { + removeTickListener(this.onTick); + removePlaybackStateListener(this.onPlaybackStateChange); + } + + getActiveNotes(track: number) { + return this.activeNotes[track] || []; + } +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/DeleteErrorModal.tsx b/webapp/src/components/pianoRoll/DeleteErrorModal.tsx new file mode 100644 index 000000000000..14e1cb4e694b --- /dev/null +++ b/webapp/src/components/pianoRoll/DeleteErrorModal.tsx @@ -0,0 +1,25 @@ +import { Modal } from "../../../../react-common/components/controls/Modal"; +import { lf } from "./types"; + +interface Props { + onClose(): void; +} + +export const DeleteErrorModal = (props: Props) => { + const { onClose } = props; + + return ( + +

{lf("Songs must have at least one track.")}

+
+ ) +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/DeleteTrackModal.tsx b/webapp/src/components/pianoRoll/DeleteTrackModal.tsx new file mode 100644 index 000000000000..78e7b71b3305 --- /dev/null +++ b/webapp/src/components/pianoRoll/DeleteTrackModal.tsx @@ -0,0 +1,37 @@ +import { Modal } from "../../../../react-common/components/controls/Modal"; +import { lf } from "./types"; + +interface Props { + trackId: number; + onClose(): void; + onDelete(trackId: number): void; +} + +export const DeleteTrackModal = (props: Props) => { + const { trackId, onClose, onDelete } = props; + + const handleDelete = () => { + onDelete(trackId); + onClose(); + }; + + return ( + +

{lf("Are you sure you want to delete this track? This action cannot be undone.")}

+
+ ) +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/DrumWarningModal.tsx b/webapp/src/components/pianoRoll/DrumWarningModal.tsx new file mode 100644 index 000000000000..8db247bb69e7 --- /dev/null +++ b/webapp/src/components/pianoRoll/DrumWarningModal.tsx @@ -0,0 +1,38 @@ +import { Modal } from "../../../../react-common/components/controls/Modal"; +import { lf } from "./types"; + +interface Props { + trackId: number; + instrumentId: number; + onClose(): void; + onConfirm(trackId: number, instrumentId: number): void; +} + +export const DrumWarningModal = (props: Props) => { + const { trackId, instrumentId, onClose, onConfirm } = props; + + const handleConfirm = () => { + onConfirm(trackId, instrumentId); + onClose(); + }; + + return ( + +

{lf("Switching between instruments and drums will cause all existing notes to be deleted. Are you sure you want to continue?")}

+
+ ) +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/Header.tsx b/webapp/src/components/pianoRoll/Header.tsx new file mode 100644 index 000000000000..66f2ddd36741 --- /dev/null +++ b/webapp/src/components/pianoRoll/Header.tsx @@ -0,0 +1,111 @@ +import { Button } from "../../../../react-common/components/controls/Button"; +import { Dropdown, DropdownItem } from "../../../../react-common/components/controls/Dropdown"; +import { Song, lf } from "./types"; + +interface Props { + song: Song; + selectedTrack: number; + + playing: boolean; + + onTrackSelected(trackId: number): void; + onTrackCreated(): void; + onTrackDeleted(trackId: number): void; + onInstrumentSelected(trackId: number, instrumentId: number): void; + togglePlaying(): void; +} + +export const Header = (props: Props) => { + const { song, selectedTrack, playing, onTrackSelected, onInstrumentSelected, onTrackCreated, onTrackDeleted, togglePlaying } = props; + + const onTrackDropdownChange = (id: string) => { + if (id === "new-track") { + onTrackCreated(); + } + else if (id === "delete-track") { + onTrackDeleted(selectedTrack); + } + else { + const trackId = parseTrackId(id); + onTrackSelected(trackId); + } + }; + + const onInstrumentDropdownChange = (id: string) => { + const instrumentId = parseInstrumentId(id); + onInstrumentSelected(selectedTrack, instrumentId); + }; + + const track = song.tracks.find(t => t.id === selectedTrack); + + const trackDropdownOptions: DropdownItem[] = song.tracks.map( + track => { + const label = lf("Track {0}", track.id); + + return { + label, + title: label, + id: trackId(track.id) + } + } + ); + + trackDropdownOptions.push({ + label: lf("New Track..."), + title: lf("New Track..."), + id: "new-track" + }); + + trackDropdownOptions.push({ + label: lf("Delete Track"), + title: lf("Delete Track"), + id: "delete-track", + disabled: song.tracks.length === 1 + }); + + return ( +
+ + ({ + label: instrument.name, + title: instrument.name, + id: instrumentId(instrument.id) + }) + )} + selectedId={instrumentId(track?.instrumentId ?? 0)} + onItemSelected={onInstrumentDropdownChange} + /> + +
+ ); +} + +function trackId(trackId: number) { + return `track-${trackId}`; +} + +function parseTrackId(id: string) { + return parseInt(id.replace("track-", "")); +} + +function instrumentId(instrumentId: number) { + return `instrument-${instrumentId}`; +} + +function parseInstrumentId(id: string) { + return parseInt(id.replace("instrument-", "")); +} + diff --git a/webapp/src/components/pianoRoll/NoteEvent.tsx b/webapp/src/components/pianoRoll/NoteEvent.tsx new file mode 100644 index 000000000000..d08622af5d9a --- /dev/null +++ b/webapp/src/components/pianoRoll/NoteEvent.tsx @@ -0,0 +1,29 @@ +import { usePianoRollTheme } from "./context"; +import { NoteEvent } from "./types"; +import { getNoteName, noteLeft, noteTop, noteWidth } from "./utils"; + +interface Props { + event: NoteEvent; + isDrumTrack?: boolean; +} + +export const NoteEventView = (props: Props) => { + const { event, isDrumTrack } = props; + const { duration, note, start, id } = event; + + const theme = usePianoRollTheme(); + + return ( +
+ {isDrumTrack ? undefined : getNoteName(note)} +
+ ); +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/PianoOctave.tsx b/webapp/src/components/pianoRoll/PianoOctave.tsx new file mode 100644 index 000000000000..853726c5dfe4 --- /dev/null +++ b/webapp/src/components/pianoRoll/PianoOctave.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef } from "react"; +import { classList } from "../../../../react-common/components/util"; +import { addNoteChangeListener, removeNoteChangeListener } from "../musicEditor/playback"; +import { Instrument, isDrumInstrument } from "./types"; +import { getNoteName, isBlackKey, range} from "./utils"; + +interface Props { + octave: number; + selectedTrack: number; + instrument: Instrument; +} + +export const PianoOctave = (props: Props) => { + const { octave, selectedTrack, instrument } = props; + + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + const handleNoteChange = (track: number, note: number, on: boolean) => { + if (track !== selectedTrack) return; + if (note < octave * 12 || note >= (octave + 1) * 12) return; + + const key = container.querySelector(`#note-${note}`) as HTMLDivElement | null; + if (key) { + key.classList.toggle("active", on); + } + } + + for (let i = 0; i < 12; i++) { + const note = (11 - i) + octave * 12; + const key = container.querySelector(`#note-${note}`) as HTMLDivElement | null; + if (key) { + key.classList.toggle("active", false); + } + } + + addNoteChangeListener(handleNoteChange); + + return () => { + removeNoteChangeListener(handleNoteChange); + }; + }, [octave, selectedTrack]); + + const playNote = useCallback(async (note: number) => { + const ref = containerRef.current?.querySelector(`#note-${note}`) as HTMLDivElement | null; + if (ref) { + ref.classList.add("playing"); + } + + if (isDrumInstrument(instrument)) { + const drum = instrument.drums[note]; + if (drum) { + await pxsim.music.playDrumAsync(drum); + } + } + else { + await pxsim.music.playNoteAsync(note, instrument.instrument, 300) + } + + if (ref) { + ref.classList.remove("playing"); + } + }, [instrument]); + + const isDrum = isDrumInstrument(instrument); + + return ( +
+ { + range(0, 12).map(index => { + const note = (11 - index) + octave * 12; + const isBlack = isBlackKey(note); + const noteName = isDrum ? instrument.drums[note].name! : getNoteName(note); + + const classes = classList( + isDrum ? "drum" : "key", + isBlack ? "black" : "white", + getNoteName(note, false).replace("#", "sharp") + ); + + const text = ((note % 12) && !isDrum) ? undefined : noteName; // Only show note name for C notes + + const onClick = () => { + playNote(note); + } + + return ( +
+ {text} +
+ ); + }) + } +
+ ); +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/PianoRoll.tsx b/webapp/src/components/pianoRoll/PianoRoll.tsx new file mode 100644 index 000000000000..37cb60b0ba4e --- /dev/null +++ b/webapp/src/components/pianoRoll/PianoRoll.tsx @@ -0,0 +1,187 @@ +import { PianoRollThemeProvider, usePianoRollThemeContext } from "./context" +import { Workspace } from "./Workspace" +import { Sidebar } from "./Sidebar" +import { useEffect, useState } from "react" +import { changeTrackInstrument, getEmptySong, isDrumInstrument, Song, toPXTSong, Track, updateTrack } from "./types" +import { Header } from "./Header" +import { DeleteTrackModal } from "./DeleteTrackModal" +import { DeleteErrorModal } from "./DeleteErrorModal" +import { DrumWarningModal } from "./DrumWarningModal" +import { isPlaying, startPlaybackAsync, stopPlayback, updatePlaybackSongAsync } from "../musicEditor/playback" + +interface PianoRollProps { + +} + +type modalType = "delete-track" | "delete-error" | "drum-warning"; + +export const PianoRoll = (props: PianoRollProps) => { + return ( + + + + ) +} + +const PianoRollInternal = (props: PianoRollProps) => { + const { state: theme, dispatch: updateTheme } = usePianoRollThemeContext(); + + const [song, setSong] = useState(getEmptySong()); + const [selectedTrack, setSelectedTrack] = useState(song.tracks[0].id); + const [playing, setPlaying] = useState(false); + + const [modal, setModal] = useState<{ type: modalType, trackId?: number, instrumentId?: number } | null>(null); + + const updateSong = (song: Song) => { + setSong(song); + + if (isPlaying()) { + updatePlaybackSongAsync(toPXTSong(song)); + } + } + + const onTrackEdit = (updatedTrack: Track) => { + updateSong(updateTrack(updatedTrack, song)); + } + + const onTrackSelected = (trackId: number) => { + setSelectedTrack(trackId); + } + + const onTrackCreated = () => { + const newTrack: Track = { + id: song.nextId++, + nextId: 0, + instrumentId: song.instruments[0].id, + events: [] + } + + updateSong({ + ...song, + tracks: [...song.tracks, newTrack] + }); + + setSelectedTrack(newTrack.id); + } + + const onTrackDeleted = (trackId: number) => { + const toDelete = song.tracks.find(t => t.id === trackId); + + if (song.tracks.length === 1) { + setModal({ type: "delete-error" }); + } + else if (!toDelete?.events.length) { + updateSong({ + ...song, + tracks: song.tracks.filter(t => t.id !== trackId) + }); + + setSelectedTrack(song.tracks[0].id); + } + else { + setModal({ type: "delete-track", trackId }); + } + } + + const onInstrumentSelected = (trackId: number, instrumentId: number) => { + const track = song.tracks.find(t => t.id === trackId)!; + const oldInstrument = song.instruments.find(i => i.id === track.instrumentId)!; + const newInstrument = song.instruments.find(i => i.id === instrumentId)!; + + if (isDrumInstrument(oldInstrument) && !isDrumInstrument(newInstrument) && track.events.length) { + setModal({ type: "drum-warning", trackId, instrumentId }); + return; + } + + setTrackInstrument(trackId, instrumentId); + } + + const deleteTrack = (trackId: number) => { + updateSong({ + ...song, + tracks: song.tracks.filter(t => t.id !== trackId) + }); + + setSelectedTrack(song.tracks[0].id); + } + + const setTrackInstrument = (trackId: number, instrumentId: number) => { + updateSong(changeTrackInstrument(trackId, instrumentId, song)); + } + + const playNote = (note: number) => { + const track = song.tracks.find(t => t.id === selectedTrack)!; + const instrument = song.instruments.find(i => i.id === track.instrumentId)!; + + if (isDrumInstrument(instrument)) { + const drum = instrument.drums[note]; + pxsim.music.playDrumAsync(drum) + } + else { + pxsim.music.playNoteAsync(note, instrument.instrument, 300) + } + } + + const togglePlaying = () => { + if (playing) { + stopPlayback(); + } + else { + startPlaybackAsync(toPXTSong(song), true); + } + + setPlaying(!playing); + } + + const closeModal = () => setModal(null); + + const track = song.tracks.find(t => t.id === selectedTrack)!; + const instrument = song.instruments.find(i => i.id === track.instrumentId)!; + + useEffect(() => { + if (theme.minOctave !== instrument.minOctave || theme.maxOctave !== instrument.maxOctave) { + updateTheme({ minOctave: instrument.minOctave, maxOctave: instrument.maxOctave }); + } + }, [instrument.minOctave, instrument.maxOctave, theme.minOctave, theme.maxOctave, updateTheme]) + + return ( +
+ { modal?.type === "delete-track" && + + } + { modal?.type === "delete-error" && + + } + { modal?.type === "drum-warning" && + + } +
+
+
+
+
+
+ +
+
+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/Sidebar.tsx b/webapp/src/components/pianoRoll/Sidebar.tsx new file mode 100644 index 000000000000..786ee55421cb --- /dev/null +++ b/webapp/src/components/pianoRoll/Sidebar.tsx @@ -0,0 +1,23 @@ +import { usePianoRollTheme } from "./context"; +import { PianoOctave } from "./PianoOctave"; +import { Instrument } from "./types"; +import { range } from "./utils"; + +interface Props { + selectedTrack: number; + instrument: Instrument; +} + +export const Sidebar = (props: Props) => { + const { selectedTrack, instrument } = props; + const { minOctave, maxOctave } = instrument; + + const octaves = range(minOctave, maxOctave + 1); + octaves.reverse(); + + return ( +
+ {octaves.map(octave => )} +
+ ); +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/Workspace.tsx b/webapp/src/components/pianoRoll/Workspace.tsx new file mode 100644 index 000000000000..50d4be909121 --- /dev/null +++ b/webapp/src/components/pianoRoll/Workspace.tsx @@ -0,0 +1,214 @@ +import { useEffect, useRef } from "react"; +import { addPlaybackStateListener, addTickListener, removePlaybackStateListener, removeTickListener } from "../musicEditor/playback"; +import { usePianoRollTheme } from "./context"; +import { NoteEventView } from "./NoteEvent" +import { changeNoteEventDuration, getMaxDuration, newNoteEvent, NoteEvent, Track } from "./types"; +import { noteWidth, workspaceHeight, workspaceWidth, xToTick, yToNote } from "./utils"; +import { useWorkspaceBackground } from "./workspaceBackground"; + +interface Props { + track: Track; + isDrumTrack: boolean; + playNote: (note: number) => void; + onEdit: (track: Track) => void; +} + +interface GestureState { + startX: number; + startY: number; + startScrollX: number; + startScrollY: number; + noteEvent?: NoteEvent; + isScrolling?: boolean + noteElement?: HTMLDivElement; +} + +export const Workspace = (props: Props) => { + const { track, onEdit, isDrumTrack, playNote } = props; + + const bg = useWorkspaceBackground(); + const theme = usePianoRollTheme(); + + const workspaceRef = useRef(null); + const playheadRef = useRef(null); + const gestureState = useRef(null); + + useEffect(() => { + const horizontalScroller = workspaceRef.current?.parentElement; + const verticalScroller = horizontalScroller?.parentElement?.parentElement; + + const clientToNoteCoordinates = (clientX: number, clientY: number) => { + const bounds = workspaceRef.current?.getBoundingClientRect(); + if (!bounds) return null; + + const x = clientX - bounds.left; + const y = clientY - bounds.top; + + const note = yToNote(theme, y); + const time = xToTick(theme, x); + + return { note, time }; + } + + const getNewNoteDuration = (clientX: number, clientY: number) => { + const editing = gestureState.current!.noteEvent!; + const coords = clientToNoteCoordinates(clientX, clientY); + if (!coords) return 1; + + const max = getMaxDuration(editing.note, editing.start + 1, track); + + return Math.max(1, Math.min(max, coords.time - editing.start + 1)); + } + + const getNoteEventAtPosition = (x: number, y: number): NoteEvent | undefined => { + const { note, time } = clientToNoteCoordinates(x, y) || {}; + if (note === undefined || time === undefined) return undefined; + + return track.events.find(e => e.note === note && e.start <= time && time < e.start + e.duration); + } + + const updateGesture = (e: PointerEvent) => { + if (!gestureState.current) return; + + const deltaX = e.clientX - gestureState.current.startX; + const deltaY = e.clientY - gestureState.current.startY; + if (!gestureState.current.isScrolling) { + if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) { + gestureState.current.isScrolling = true; + } + } + + if (gestureState.current.isScrolling) { + if (!gestureState.current.noteEvent || isDrumTrack) { + if (horizontalScroller) { + horizontalScroller.scrollLeft = gestureState.current.startScrollX - deltaX; + } + if (verticalScroller) { + verticalScroller.scrollTop = gestureState.current.startScrollY - deltaY; + } + } + else { + const editing = gestureState.current.noteEvent; + + if (!gestureState.current.noteElement) { + gestureState.current.noteElement = document.getElementById(`note-${editing.id}`) as HTMLDivElement; + } + + if (gestureState.current.noteElement) { + gestureState.current.noteElement.style.width = `${noteWidth(theme, getNewNoteDuration(e.clientX, e.clientY))}px`; + } + } + } + } + + const onPointerDown = (e: PointerEvent) => { + gestureState.current = { + startX: e.clientX, + startY: e.clientY, + startScrollX: horizontalScroller?.scrollLeft || 0, + startScrollY: verticalScroller?.scrollTop || 0, + noteEvent: getNoteEventAtPosition(e.clientX, e.clientY) + }; + + updateGesture(e); + } + + const onPointerMove = (e: PointerEvent) => { + updateGesture(e); + } + + const onPointerUp = (e: PointerEvent) => { + if (!gestureState.current) return; + updateGesture(e); + + if (!gestureState.current.isScrolling) { + if (gestureState.current.noteEvent) { + onEdit({ + ...track, + events: track.events.filter(e => e !== gestureState.current?.noteEvent) + }); + } + else { + const coords = clientToNoteCoordinates(gestureState.current.startX, gestureState.current.startY); + + if (coords) { + onEdit(newNoteEvent(coords.note, coords.time, track, isDrumTrack)); + playNote(coords.note); + } + } + } + else if (gestureState.current.noteEvent && !isDrumTrack) { + onEdit(changeNoteEventDuration(gestureState.current.noteEvent.id, getNewNoteDuration(e.clientX, e.clientY), track)); + } + + gestureState.current = null; + } + + workspaceRef.current?.addEventListener("pointerdown", onPointerDown); + workspaceRef.current?.addEventListener("pointermove", onPointerMove); + workspaceRef.current?.addEventListener("pointerup", onPointerUp); + workspaceRef.current?.addEventListener("pointercancel", onPointerUp); + workspaceRef.current?.addEventListener("pointerleave", onPointerUp); + + return () => { + workspaceRef.current?.removeEventListener("pointerdown", onPointerDown); + workspaceRef.current?.removeEventListener("pointermove", onPointerMove); + workspaceRef.current?.removeEventListener("pointerup", onPointerUp); + workspaceRef.current?.removeEventListener("pointercancel", onPointerUp); + workspaceRef.current?.removeEventListener("pointerleave", onPointerUp); + } + }, [track, onEdit, theme.minOctave, theme.maxOctave, isDrumTrack]) + + + useEffect(() => { + const tickTime = pxsim.music.tickToMs(120, 4, 1); + const tickDistance = noteWidth(theme, 1); + let playbackHeadPosition = 0; + let isPlaying = false; + let animationFrameRef: number; + let lastTime: number; + + const onTick = (tick: number) => { + playbackHeadPosition = noteWidth(theme, tick); + lastTime = Date.now(); + if (!isPlaying) { + isPlaying = true; + playheadRef.current.style.left = `${playbackHeadPosition}px`; + playheadRef.current.style.display = "unset"; + animationFrameRef = requestAnimationFrame(onAnimationFrame); + } + } + + const onStop = () => { + isPlaying = false; + playheadRef.current.style.display = "none"; + if (animationFrameRef) cancelAnimationFrame(animationFrameRef); + } + + const onAnimationFrame = () => { + const position = playbackHeadPosition + tickDistance * (Date.now() - lastTime) / tickTime; + playheadRef.current.style.left = `${position}px`; + if (isPlaying) animationFrameRef = requestAnimationFrame(onAnimationFrame); + } + + addTickListener(onTick); + addPlaybackStateListener(onStop); + + return () => { + removeTickListener(onTick); + removePlaybackStateListener(onStop); + if (animationFrameRef) cancelAnimationFrame(animationFrameRef); + } + }, [theme]) + + return ( +
+
+ {track.events.map((e, i) => )} +
+ ); +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/context.tsx b/webapp/src/components/pianoRoll/context.tsx new file mode 100644 index 000000000000..359c0e01e9f6 --- /dev/null +++ b/webapp/src/components/pianoRoll/context.tsx @@ -0,0 +1,63 @@ +import { createContext, useContext, useReducer } from "react"; + +export interface PianoRollTheme { + octaveWidth: number; + whiteKeyHeight: number; + measures: number; + minOctave: number; + maxOctave: number; +} + +const defaultTheme: PianoRollTheme = { + octaveWidth: 500, + whiteKeyHeight: 40, + measures: 4, + minOctave: 3, + maxOctave: 5 +}; + +interface ThemeAndUpdate { + state: PianoRollTheme; + dispatch: (newTheme: Partial) => void; +} + +export const PianoRollContext = createContext({ + state: undefined!, + dispatch: undefined! +}); + +function reducer(state: PianoRollTheme, newTheme: Partial): PianoRollTheme { + return { ...state, ...newTheme }; +} + +export function PianoRollThemeProvider( + props: React.PropsWithChildren<{}> +): React.ReactElement { + const [state, dispatch] = useReducer(reducer, defaultTheme); + + const value = { state, dispatch }; + + const handleRootRef = (el: HTMLDivElement | null) => { + if (el) { + el.setAttribute("style", `--octave-width: ${value.state.octaveWidth}px; --white-key-height: ${value.state.whiteKeyHeight}px;`); + } + } + + return ( + +
+ {props.children} +
+
+ ); +} + +export function usePianoRollTheme() { + return useContext(PianoRollContext).state; +} + +export function usePianoRollThemeContext() { + return useContext(PianoRollContext); +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/types.ts b/webapp/src/components/pianoRoll/types.ts new file mode 100644 index 000000000000..fb835431c784 --- /dev/null +++ b/webapp/src/components/pianoRoll/types.ts @@ -0,0 +1,1009 @@ +export interface NoteEvent { + id: number; + start: number; + duration: number; + note: number; +} + +export interface Track { + instrumentId: number; + events: NoteEvent[]; + id: number; + nextId: number; +} + +export interface Song { + instruments: Instrument[]; + + tracks: Track[]; + measures: number; + nextId: number; +} + +interface BaseInstrument { + name: string; + id: number; + minOctave: number; + maxOctave: number; +} + +export interface MelodicInstrument extends BaseInstrument { + instrument: pxt.assets.music.Instrument; +} + +export interface DrumInstrument extends BaseInstrument { + drums: pxt.assets.music.DrumInstrument[]; +} + +export type Instrument = MelodicInstrument | DrumInstrument; + +export function getEmptySong(): Song { + const makecodeSong = getEmptyPXTSong(4); + + const instruments: Instrument[] = makecodeSong.tracks.map(t => { + if (t.drums) { + return { + id: t.id, + name: t.name, + minOctave: 0, + maxOctave: 1, + drums: t.drums + } as DrumInstrument; + } + else { + return { + id: t.id, + name: t.name, + instrument: t.instrument, + minOctave: 3, + maxOctave: 5 + } as MelodicInstrument; + } + }); + + const song: Song = { + nextId: 1, + instruments, + tracks: [{ + instrumentId: 0, + events: [], + nextId: 0, + id: 0 + }], + measures: 4 + }; + + return song; +} + +export function getNextNoteEvent(note: number, start: number, track: Track): NoteEvent | undefined { + return track.events.find(e => e.note === note && e.start > start); +} + +export function getMaxDuration(note: number, start: number, track: Track): number { + const nextEvent = getNextNoteEvent(note, start, track); + if (!nextEvent) return Infinity; + + return nextEvent.start - start; +} + +export function newNoteEvent(note: number, start: number, track: Track, isDrumTrack: boolean): Track { + const newEvent: NoteEvent = { + id: track.nextId++, + note, + start, + duration: isDrumTrack ? 1 : Math.min(4, getMaxDuration(note, start, track)) + }; + + return insertNoteEvent(newEvent, track); +} + +export function changeNoteEventDuration(id: number, duration: number, track: Track): Track { + const eventIndex = track.events.findIndex(e => e.id === id); + if (eventIndex === -1) return track; + + const event = track.events[eventIndex]; + const maxDuration = getMaxDuration(event.note, event.start, track); + + const updatedEvent = { + ...event, + duration: Math.max(1, Math.min(duration, maxDuration)) + }; + + return { + ...track, + events: [ + ...track.events.slice(0, eventIndex), + updatedEvent, + ...track.events.slice(eventIndex + 1) + ] + }; +} + +function insertNoteEvent(newEvent: NoteEvent, track: Track): Track { + for (let i = 0; i < track.events.length; i++) { + if (track.events[i].start > newEvent.start) { + return { + ...track, + events: [ + ...track.events.slice(0, i), + newEvent, + ...track.events.slice(i) + ] + }; + } + } + + return { + ...track, + events: [...track.events, newEvent] + } +} + +export function newTrack(instrumentId: number, song: Song): Song { + const newTrack: Track = { + instrumentId, + events: [], + id: song.nextId++, + nextId: 0 + }; + + return { + ...song, + tracks: [...song.tracks, newTrack] + }; +} + +export function updateTrack(updatedTrack: Track, song: Song): Song { + const trackIndex = song.tracks.findIndex(t => t.id === updatedTrack.id); + if (trackIndex === -1) return song; + + return { + ...song, + tracks: [ + ...song.tracks.slice(0, trackIndex), + updatedTrack, + ...song.tracks.slice(trackIndex + 1) + ] + }; +} + +export function changeTrackInstrument(trackId: number, instrumentId: number, song: Song): Song { + const trackIndex = song.tracks.findIndex(t => t.id === trackId); + const track = { ...song.tracks[trackIndex] }; + + const oldInstrument = song.instruments.find(i => i.id === track.instrumentId)!; + const newInstrument = song.instruments.find(i => i.id === instrumentId)!; + + if (isDrumInstrument(oldInstrument) !== isDrumInstrument(newInstrument)) { + track.events = []; + } + else { + if (oldInstrument.minOctave !== newInstrument.minOctave) { + for (const event of track.events) { + event.note += (newInstrument.minOctave - oldInstrument.minOctave) * 12; + } + } + } + + track.instrumentId = instrumentId; + + return updateTrack(track, song); + +} + +export function isDrumInstrument(instrument: Instrument): instrument is DrumInstrument { + return (instrument as DrumInstrument).drums !== undefined; +} + +export function isMelodicInstrument(instrument: Instrument): instrument is MelodicInstrument { + return (instrument as MelodicInstrument).instrument !== undefined; +} + +export const lf = pxt.U.lf; + +export function toPXTSong(song: Song): pxt.assets.music.Song { + return { + ticksPerBeat: 4, + beatsPerMeasure: 4, + beatsPerMinute: 120, + measures: song.measures, + tracks: song.tracks.map(track => { + const instrument = song.instruments.find(i => i.id === track.instrumentId)!; + + const pxtTrack: pxt.assets.music.Track = { + id: track.instrumentId, + name: instrument.name, + notes: track.events.map(e => ({ + startTick: e.start, + endTick: (e.start + e.duration), + notes: [{ + note: e.note, + enharmonicSpelling: "normal" + }] + })), + instrument: isDrumInstrument(instrument) ? undefined : (instrument as MelodicInstrument).instrument, + drums: isDrumInstrument(instrument) ? (instrument as DrumInstrument).drums : undefined + } + + return pxtTrack; + }) + } +} + + +function getEmptyPXTSong(measures: number): pxt.assets.music.Song { + return { + ticksPerBeat: 8, + beatsPerMeasure: 4, + beatsPerMinute: 120, + measures, + tracks: [ + { + id: 0, + name: lf("Dog"), + notes: [], + iconURI: "music-editor/dog.png", + instrument: { + waveform: 1, + octave: 4, + ampEnvelope: { + attack: 10, + decay: 100, + sustain: 500, + release: 100, + amplitude: 1024 + }, + pitchLFO: { + frequency: 5, + amplitude: 0 + } + } + }, + { + id: 1, + name: lf("Duck"), + notes: [], + iconURI: "music-editor/duck.png", + instrument: { + waveform: 15, + octave: 4, + ampEnvelope: { + attack: 5, + decay: 530, + sustain: 705, + release: 450, + amplitude: 1024 + }, + pitchEnvelope: { + attack: 5, + decay: 40, + sustain: 0, + release: 100, + amplitude: 40 + }, + ampLFO: { + frequency: 3, + amplitude: 20 + }, + pitchLFO: { + frequency: 6, + amplitude: 2 + } + } + }, + { + id: 2, + name: lf("Cat"), + notes: [], + iconURI: "music-editor/cat.png", + instrument: { + waveform: 12, + octave: 5, + ampEnvelope: { + attack: 150, + decay: 100, + sustain: 365, + release: 400, + amplitude: 1024 + }, + pitchEnvelope: { + attack: 120, + decay: 300, + sustain: 0, + release: 100, + amplitude: 50 + }, + pitchLFO: { + frequency: 10, + amplitude: 6 + } + } + }, + { + id: 3, + name: lf("Fish"), + notes: [], + iconURI: "music-editor/fish.png", + instrument: { + waveform: 1, + octave: 3, + ampEnvelope: { + attack: 220, + decay: 105, + sustain: 1024, + release: 350, + amplitude: 1024 + }, + ampLFO: { + frequency: 5, + amplitude: 100 + }, + pitchLFO: { + frequency: 1, + amplitude: 4 + } + } + }, + { + id: 4, + name: lf("Car"), + notes: [], + iconURI: "music-editor/car.png", + instrument: { + waveform: 16, + octave: 4, + ampEnvelope: { + attack: 5, + decay: 100, + sustain: 1024, + release: 30, + amplitude: 1024 + }, + pitchLFO: { + frequency: 10, + amplitude: 4 + } + } + }, + { + id: 5, + name: lf("Computer"), + notes: [], + iconURI: "music-editor/computer.png", + instrument: { + waveform: 15, + octave: 2, + ampEnvelope: { + attack: 10, + decay: 100, + sustain: 500, + release: 10, + amplitude: 1024 + } + } + }, + { + id: 6, + name: lf("Burger"), + notes: [], + iconURI: "music-editor/burger.png", + instrument: { + waveform: 1, + octave: 2, + ampEnvelope: { + attack: 10, + decay: 100, + sustain: 500, + release: 100, + amplitude: 1024 + } + } + }, + { + id: 7, + name: lf("Cherry"), + notes: [], + iconURI: "music-editor/cherry.png", + instrument: { + waveform: 2, + octave: 3, + ampEnvelope: { + attack: 10, + decay: 100, + sustain: 500, + release: 100, + amplitude: 1024 + } + } + }, + { + id: 8, + name: lf("Lemon"), + notes: [], + iconURI: "music-editor/lemon.png", + instrument: { + waveform: 14, + octave: 2, + ampEnvelope: { + attack: 5, + decay: 70, + sustain: 870, + release: 50, + amplitude: 1024 + }, + pitchEnvelope: { + attack: 10, + decay: 45, + sustain: 0, + release: 100, + amplitude: 20 + }, + ampLFO: { + frequency: 1, + amplitude: 50 + }, + pitchLFO: { + frequency: 2, + amplitude: 1 + } + } + }, + { + id: 9, + name: lf("Drums"), + notes: [], + iconURI: "music-editor/explosion.png", + instrument: { + waveform: 11, + octave: 4, + ampEnvelope: { + attack: 10, + decay: 100, + sustain: 500, + release: 100, + amplitude: 1024 + } + }, + drums: [ + { + name: lf("neutral kick"), + startFrequency: 100, + startVolume: 1024, + steps: [ + { + waveform: 3, + frequency: 120, + duration: 10, + volume: 1024 + }, + { + waveform: 3, + frequency: 1, + duration: 100, + volume: 0 + } + ] + }, + { + name: lf("punchy kick"), + startFrequency: 200, + startVolume: 1024, + steps: [{ + frequency: 0, + volume: 0, + duration: 100, + waveform: 1 + }] + }, + + { + name: lf("booming kick"), + startFrequency: 100, + startVolume: 1024, + steps: [{ + frequency: 0, + volume: 0, + duration: 250, + waveform: 1 + }] + }, + + + { + name: lf("snare 1"), + startFrequency: 175, + startVolume: 1024, + steps: [ + { + waveform: 1, + frequency: 200, + duration: 10, + volume: 1024 + }, + { + waveform: 1, + frequency: 150, + duration: 20, + volume: 1024 + }, + { + waveform: 5, + frequency: 1, + duration: 20, + volume: 100 + }, + { + waveform: 5, + frequency: 1, + duration: 300, + volume: 0 + }, + ] + }, + + { + name: lf("snare 2"), + startFrequency: 220, + startVolume: 1024, + steps: [ + { + waveform: 1, + frequency: 250, + duration: 10, + volume: 1024 + }, + { + waveform: 1, + frequency: 200, + duration: 20, + volume: 1024 + }, + { + waveform: 5, + frequency: 2000, + duration: 20, + volume: 100 + }, + { + waveform: 5, + frequency: 2000, + duration: 200, + volume: 0 + }, + ] + }, + + + { + name: lf("hat 1"), + startFrequency: 400, + startVolume: 500, + steps: [ + { + frequency: 450, + volume: 500, + duration: 10, + waveform: 5 + }, + { + frequency: 400, + volume: 20, + duration: 20, + waveform: 5 + }, + ] + }, + + { + name: lf("hat 2"), + startFrequency: 400, + startVolume: 0, + steps: [ + { + frequency: 450, + volume: 500, + duration: 5, + waveform: 5 + }, + { + frequency: 900, + volume: 5, + duration: 50, + waveform: 5 + }, + { + frequency: 900, + volume: 0, + duration: 250, + waveform: 5 + } + ] + }, + + + { + name: lf("hat 3"), + startFrequency: 400, + startVolume: 0, + steps: [ + { + frequency: 450, + volume: 500, + duration: 5, + waveform: 5 + }, + { + frequency: 900, + volume: 200, + duration: 50, + waveform: 5 + }, + { + frequency: 900, + volume: 5, + duration: 100, + waveform: 5 + }, + { + frequency: 900, + volume: 0, + duration: 400, + waveform: 5 + } + ] + }, + + { + name: lf("hat 4"), + startFrequency: 400, + startVolume: 0, + steps: [ + { + frequency: 450, + volume: 500, + duration: 5, + waveform: 5 + }, + { + frequency: 900, + volume: 200, + duration: 100, + waveform: 5 + }, + { + frequency: 900, + volume: 5, + duration: 200, + waveform: 5 + }, + { + frequency: 900, + volume: 0, + duration: 500, + waveform: 5 + } + ] + }, + + { + name: lf("double hat"), + startFrequency: 3500, + startVolume: 1024, + steps: [ + { + frequency: 4000, + volume: 0, + duration: 10, + waveform: 4 + }, + { + frequency: 3500, + volume: 800, + duration: 1, + waveform: 4 + }, + { + frequency: 4000, + volume: 0, + duration: 40, + waveform: 4 + }, + { + frequency: 3500, + volume: 400, + duration: 1, + waveform: 4 + }, + { + frequency: 4000, + volume: 0, + duration: 40, + waveform: 4 + }, + ] + }, + + { + name: lf("metallic"), + startFrequency: 2000, + startVolume: 1024, + steps: [ + { + frequency: 1800, + volume: 15, + duration: 100, + waveform: 4 + }, + { + frequency: 1800, + volume: 0, + duration: 200, + waveform: 4 + } + ] + }, + + { + name: lf("low tom"), + startFrequency: 200, + startVolume: 200, + steps: [ + { + frequency: 125, + volume: 200, + duration: 25, + waveform: 14 + }, + { + frequency: 100, + volume: 15, + duration: 50, + waveform: 14 + }, + { + frequency: 120, + volume: 0, + duration: 250, + waveform: 14 + } + ] + }, + + { + name: lf("mid tom"), + startFrequency: 300, + startVolume: 200, + steps: [ + { + frequency: 225, + volume: 200, + duration: 25, + waveform: 14 + }, + { + frequency: 200, + volume: 15, + duration: 50, + waveform: 14 + }, + { + frequency: 220, + volume: 0, + duration: 250, + waveform: 14 + } + ] + }, + + { + name: lf("hi tom"), + startFrequency: 500, + startVolume: 200, + steps: [ + { + frequency: 425, + volume: 200, + duration: 25, + waveform: 14 + }, + { + frequency: 400, + volume: 15, + duration: 50, + waveform: 14 + }, + { + frequency: 420, + volume: 0, + duration: 250, + waveform: 14 + } + ] + }, + { + name: lf("lo tom 2"), + startFrequency: 200, + startVolume: 1024, + steps: [ + { + frequency: 75, + volume: 0, + duration: 200, + waveform: 1 + } + ] + }, + { + name: lf("mid tom 2"), + startFrequency: 300, + startVolume: 1024, + steps: [ + { + frequency: 200, + volume: 0, + duration: 200, + waveform: 1 + } + ] + }, + + + { + name: lf("hi tom 2"), + startFrequency: 400, + startVolume: 1024, + steps: [ + { + frequency: 300, + volume: 0, + duration: 200, + waveform: 1 + } + ] + }, + + + { + name: lf("thump 1"), + startFrequency: 200, + startVolume: 1024, + steps: [ + { + frequency: 200, + volume: 15, + duration: 100, + waveform: 4 + }, + { + frequency: 150, + volume: 0, + duration: 200, + waveform: 4 + } + ] + }, + + { + name: lf("thump 2"), + startFrequency: 450, + startVolume: 1024, + steps: [ + { + frequency: 350, + volume: 15, + duration: 100, + waveform: 4 + }, + { + frequency: 300, + volume: 0, + duration: 100, + waveform: 4 + } + ] + }, + + { + name: lf("cymbal"), + startFrequency: 2500, + startVolume: 1024, + steps: [ + { + frequency: 2500, + volume: 100, + duration: 150, + waveform: 4 + }, + { + frequency: 2550, + volume: 0, + duration: 500, + waveform: 4 + } + ] + }, + + { + name: lf("crash 1"), + startFrequency: 3000, + startVolume: 1024, + steps: [ + { + frequency: 3000, + volume: 100, + duration: 300, + waveform: 4 + }, + { + frequency: 3060, + volume: 0, + duration: 500, + waveform: 4 + } + ] + }, + + { + name: lf("crash 2"), + startFrequency: 800, + startVolume: 0, + steps: [ + { + frequency: 800, + volume: 1024, + duration: 10, + waveform: 4 + }, + { + frequency: 800, + volume: 0, + duration: 490, + waveform: 4 + } + ] + }, + + { + name: lf("crash 3"), + startFrequency: 400, + startVolume: 0, + steps: [ + { + frequency: 400, + volume: 1024, + duration: 10, + waveform: 4 + }, + { + frequency: 400, + volume: 0, + duration: 400, + waveform: 4 + } + ] + }, + + { + name: lf("buzzer"), + startFrequency: 2000, + startVolume: 1024, + steps: [ + { + frequency: 2000, + volume: 100, + duration: 150, + waveform: 16 + }, + { + frequency: 2000, + volume: 0, + duration: 200, + waveform: 16 + } + ] + },] + } + ] + } +} \ No newline at end of file diff --git a/webapp/src/components/pianoRoll/utils.ts b/webapp/src/components/pianoRoll/utils.ts new file mode 100644 index 000000000000..cde6bc8e7773 --- /dev/null +++ b/webapp/src/components/pianoRoll/utils.ts @@ -0,0 +1,62 @@ +import { PianoRollTheme } from "./context"; + +export function isBlackKey(note: number) { + const noteInOctave = note % 12; + return [1, 3, 6, 8, 10].includes(noteInOctave); +} + +export function getNoteName(note: number, includeOctave: boolean = true) { + const noteInOctave = note % 12; + const octave = Math.floor(note / 12); + const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + return includeOctave ? `${noteNames[noteInOctave]}${octave}` : noteNames[noteInOctave]; +} + +export function range(start: number, end: number) { + return Array.from({ length: end - start }, (_, i) => start + i); +} + +export function noteWidth(theme: PianoRollTheme, duration: number) { + return (theme.octaveWidth / 16) * duration + 1; +} + +export function noteLeft(theme: PianoRollTheme, start: number) { + return (theme.octaveWidth / 16) * start; +} + +export function noteTop(theme: PianoRollTheme, note: number) { + return (maxNote(theme) - note) * noteHeight(theme); +} + +export function noteHeight(theme: PianoRollTheme) { + return octaveHeight(theme) / 12; +} + +export function octaveHeight(theme: PianoRollTheme) { + return theme.whiteKeyHeight * 7; +} + +export function workspaceHeight(theme: PianoRollTheme) { + return (theme.maxOctave - theme.minOctave + 1) * octaveHeight(theme); +} + +export function workspaceWidth(theme: PianoRollTheme) { + return theme.measures * theme.octaveWidth; +} + +export function xToTick(theme: PianoRollTheme, x: number) { + return Math.floor(x / (theme.octaveWidth / 16)); +} + +export function yToNote(theme: PianoRollTheme, y: number) { + const note = maxNote(theme) - Math.floor(y / noteHeight(theme)); + return note; +} + +export function maxNote(theme: PianoRollTheme) { + return (theme.maxOctave + 1) * 12 - 1; +} + +export function minNote(theme: PianoRollTheme) { + return theme.minOctave * 12; +} diff --git a/webapp/src/components/pianoRoll/workspaceBackground.tsx b/webapp/src/components/pianoRoll/workspaceBackground.tsx new file mode 100644 index 000000000000..68d3b27c7921 --- /dev/null +++ b/webapp/src/components/pianoRoll/workspaceBackground.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { usePianoRollTheme } from "./context" + +function createWorkspaceBackground( + octaveWidth: number, + octaveHeight: number, + borderColor: string = "#1e343d", + blackKeyColor: string = "#2e4c58", + backgroundColor: string = "#36535f" +) { + return ` + + + + + + + + + + + + ${[1, 3, 5, 8, 10].map(i => ``).join("")} + + + + +`.trim().replace(/\s+/g, " ") +} + +function getBackgroundCss(octaveWidth: number, whiteKeyHeight: number) { + return `url("data:image/svg+xml,${encodeURIComponent(createWorkspaceBackground(octaveWidth, 7 * whiteKeyHeight))}")`; +} + +export function useWorkspaceBackground() { + const theme = usePianoRollTheme(); + const [bg, setBg] = useState(getBackgroundCss(theme.octaveWidth, theme.whiteKeyHeight)); + + useEffect(() => { + setBg(getBackgroundCss(theme.octaveWidth, theme.whiteKeyHeight)); + }, [theme.octaveWidth, theme.whiteKeyHeight]) + + return bg; +} \ No newline at end of file From dd8d64f99ceaec81557c0baf1ce002f9057632af Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Tue, 21 Apr 2026 10:54:38 -0700 Subject: [PATCH 2/6] reuse playback controls --- theme/piano-roll/piano-roll.less | 30 ++++- webapp/public/asseteditor.html | 2 +- webapp/src/assetEditor.tsx | 4 + .../components/musicEditor/MusicEditor.tsx | 18 ++- .../musicEditor/PlaybackControls.tsx | 102 +++++++++------ .../components/pianoRoll/DeleteTrackModal.tsx | 5 +- .../components/pianoRoll/DrumWarningModal.tsx | 3 +- webapp/src/components/pianoRoll/Header.tsx | 12 +- webapp/src/components/pianoRoll/PianoRoll.tsx | 121 +++++++++++++++--- webapp/src/components/pianoRoll/Workspace.tsx | 9 +- webapp/src/components/pianoRoll/types.ts | 31 +++-- 11 files changed, 243 insertions(+), 94 deletions(-) diff --git a/theme/piano-roll/piano-roll.less b/theme/piano-roll/piano-roll.less index a39f6f34bda4..d9d1615f9cb1 100644 --- a/theme/piano-roll/piano-roll.less +++ b/theme/piano-roll/piano-roll.less @@ -23,30 +23,48 @@ max-height: 100%; } +.piano-roll-root { + height: 100vh; + overflow-y: hidden; +} + .piano-roll .header-container { height: var(--header-height); flex-shrink: 0; } -.piano-roll .header { +.piano-roll .header, .piano-roll .footer { height: var(--header-height); display: flex; flex-direction: row; gap: 0.5rem; background-color: var(--header-background); align-items: center; - ; } -.piano-roll-root { - height: 100vh; - width: 100%; +.piano-roll .footer { + flex-shrink: 0; + + .music-playback-controls { + .common-button { + height: calc(var(--header-height) - 0.3rem) + } + + .music-undo-redo .common-button { + color: black; + background: none; + border: none; + + &.disabled { + opacity: 0.5; + } + } + } } .piano-roll .sidebar-container { width: var(--sidebar-width); position: relative; - } .piano-roll .content-container { diff --git a/webapp/public/asseteditor.html b/webapp/public/asseteditor.html index 5a72ce91a219..223e6b50baa3 100644 --- a/webapp/public/asseteditor.html +++ b/webapp/public/asseteditor.html @@ -19,7 +19,7 @@ - +