diff --git a/src/editor/editor.js b/src/editor/editor.js index 4225aea..5d1c74b 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -231,9 +231,15 @@ export default class Editor } + static reduce_trackClear(state, action) + { + return { ...state, tracks: [] } + } + + static reduce_trackAdd(state, action) { - state = Track.handlerForTrackKind(action.kind).init(state, action) + state = Track.handlerForTrackKind(action.kind).init(state, null, action) state = Editor.Cursor.place(state, null, state.tracks.length - 1) return Editor.reduce_resize(state, { type: "resize", w: state.w, h: state.h }) diff --git a/src/editor/track.js b/src/editor/track.js index 225d9fd..b1de551 100644 --- a/src/editor/track.js +++ b/src/editor/track.js @@ -42,6 +42,13 @@ export default class Track if (!fn) return null + if (state.tracks[trackIndex].trackId) + { + const track = state.project.tracks.find(t => t.id === state.tracks[trackIndex].trackId) + if (!track) + return null + } + return fn(state, trackIndex, args) } diff --git a/src/editor/trackNotes.js b/src/editor/trackNotes.js index 7705d27..e0995da 100644 --- a/src/editor/trackNotes.js +++ b/src/editor/trackNotes.js @@ -16,6 +16,7 @@ export default class TrackNotes { ...Track.init(state, action), kind: "notes", + trackId: action.trackId, rowScale: 15, } @@ -112,7 +113,8 @@ export default class TrackNotes return state const id = hover.id - const note = state.project.notes.findById(id) + const track = state.project.tracks.find(t => t.id === state.tracks[trackIndex].trackId) + const note = track.notes.findById(id) if (!note) return state @@ -124,8 +126,9 @@ export default class TrackNotes static selectionAddAtCursor(state, trackIndex) { const timeRange = new Range(state.cursor.time1, state.cursor.time2, false, false).sorted() - - for (const note of state.project.notes.iterAtRange(timeRange)) + const track = state.project.tracks.find(t => t.id === state.tracks[trackIndex].trackId) + + for (const note of track.notes.iterAtRange(timeRange)) state = Track.selectionAdd(state, trackIndex, note.id) return state @@ -153,14 +156,15 @@ export default class TrackNotes static selectionRemoveConflictingBehind(state, trackIndex) { const selection = state.tracks[trackIndex].selection + const track = state.project.tracks.find(t => t.id === state.tracks[trackIndex].trackId) for (const id of selection) { - const elem = state.project.findById(id) + const elem = track.notes.findById(id) if (!elem) continue - for (const note of state.project.notes.iterAtRange(elem.range)) + for (const note of track.notes.iterAtRange(elem.range)) { if (selection.has(note.id)) continue @@ -168,13 +172,15 @@ export default class TrackNotes if (note.pitch !== elem.pitch) continue - state = { ...state, project: state.project.removeById(note.id) } + let project = state.project.upsertNote(note, true) for (const slice of note.range.iterSlices(elem.range)) { - const newNote = new Project.Note(slice, note.pitch) - state = { ...state, project: state.project.upsertNote(newNote) } + const newNote = new Project.Note(note.trackId, slice, note.pitch) + project = project.upsertNote(newNote) } + + state = { ...state, project } } } @@ -188,8 +194,9 @@ export default class TrackNotes { if (elem instanceof Project.Note) { + const trackId = state.tracks[trackIndex].trackId const range = elem.range.displace(pasteData.time.subtract(state.clipboard.range.start)) - const note = new Project.Note(range, elem.pitch) + const note = new Project.Note(trackId, range, elem.pitch) const id = state.project.nextId state = { ...state, project: state.project.upsertNote(note) } state = Track.selectionAdd(state, trackIndex, id) @@ -202,19 +209,25 @@ export default class TrackNotes static previousAnchor(state, trackIndex, time) { - return state.project.notes.findPreviousAnchor(time) + const trackId = state.tracks[trackIndex].trackId + const track = state.project.tracks.find(t => t.id === trackId) + + return track.notes.findPreviousAnchor(time) } static deleteRange(state, trackIndex, range) { - for (const note of state.project.notes.iterAtRange(range)) + const trackId = state.tracks[trackIndex].trackId + const track = state.project.tracks.find(t => t.id === trackId) + + for (const note of track.notes.iterAtRange(range)) { - state = { ...state, project: state.project.removeById(note.id) } + state = { ...state, project: state.project.upsertNote(note, true) } for (const slice of note.range.iterSlices(range)) { - const newNote = new Project.Note(slice, note.pitch) + const newNote = new Project.Note(trackId, slice, note.pitch) state = { ...state, project: state.project.upsertNote(newNote) } } } @@ -279,7 +292,8 @@ export default class TrackNotes soundPreview = TrackNotes.mergeSoundPreview(soundPreview, newPitch) } - state = { ...state, project: state.project.update(elem.withChanges(changes)) } + let project = state.project.upsertNote(elem.withChanges(changes)) + state = { ...state, project } } state = Editor.soundPreviewSet(state, soundPreview) @@ -311,7 +325,8 @@ export default class TrackNotes { if (draw.kind == "note") { - const note = new Project.Note(new Range(draw.time1, draw.time2).sorted(), draw.pitch) + const editorTrack = state.tracks[trackIndex] + const note = new Project.Note(editorTrack.trackId, new Range(draw.time1, draw.time2).sorted(), draw.pitch) const id = state.project.nextId state = { ...state, project: state.project.upsertNote(note) } state = Track.selectionAdd(state, trackIndex, id) @@ -334,7 +349,7 @@ export default class TrackNotes let changes = {} changes.range = elem.range.displace(commandData.timeDelta) - state = { ...state, project: state.project.update(elem.withChanges(changes)) } + state = { ...state, project: state.project.upsertNote(elem.withChanges(changes)) } } return state @@ -365,7 +380,7 @@ export default class TrackNotes changes.pitch = elem.pitch + commandData.pitchDelta soundPreview = TrackNotes.mergeSoundPreview(soundPreview, changes.pitch) - state = { ...state, project: state.project.update(elem.withChanges(changes)) } + state = { ...state, project: state.project.upsertNote(elem.withChanges(changes)) } } state = Editor.soundPreviewSet(state, soundPreview) @@ -454,6 +469,9 @@ export default class TrackNotes static *iterNotesAndKeyChangesAtRange(state, trackIndex, range) { const defaultKey = Editor.defaultKey() + const track = state.project.tracks.find(t => t.id === state.tracks[trackIndex].trackId) + if (!track) + return for (const pair of state.project.keyChanges.iterActiveAtRangePairwise(range)) { @@ -466,12 +484,39 @@ export default class TrackNotes const time1 = keyCh1.time.max(range.start) const time2 = keyCh2.time.min(range.end) - for (const note of state.project.notes.iterAtRange(new Range(time1, time2))) + for (const note of track.notes.iterAtRange(new Range(time1, time2))) yield [note, keyCh1, keyCh1X, keyCh2X] } } + static *iterOnionNotesAndKeyChangesAtRange(state, trackIndex, range) + { + const defaultKey = Editor.defaultKey() + + for (const pair of state.project.keyChanges.iterActiveAtRangePairwise(range)) + { + const keyCh1 = pair[0] || new Project.KeyChange(range.start, defaultKey) + const keyCh2 = pair[1] || new Project.KeyChange(range.end, defaultKey) + + const keyCh1X = Editor.xAtTime(state, keyCh1.time) + const keyCh2X = Editor.xAtTime(state, keyCh2.time) + + const time1 = keyCh1.time.max(range.start) + const time2 = keyCh2.time.min(range.end) + + for (const track of state.project.tracks) + { + if (track.id === state.tracks[trackIndex].trackId) + continue + + for (const note of track.notes.iterAtRange(new Range(time1, time2))) + yield [note, keyCh1, keyCh1X, keyCh2X] + } + } + } + + static render(state, trackIndex, ctx) { const track = state.tracks[trackIndex] @@ -517,6 +562,17 @@ export default class TrackNotes } } + for (const [note, keyCh, xMin, xMax] of TrackNotes.iterOnionNotesAndKeyChangesAtRange(state, trackIndex, visibleTimeRange)) + { + const row = TrackNotes.rowForPitch(state, trackIndex, note.pitch, keyCh.key) + const mode = keyCh.key.scale.metadata.mode + const fillStyle = CanvasUtils.fillStyleForDegree(ctx, keyCh.key.degreeForMidi(note.pitch) + mode) + const hovering = track.hover && track.hover.id == note.id + const playing = state.playback.playing && note.range.overlapsPoint(state.playback.time) + + TrackNotes.renderOutlineNote(state, trackIndex, ctx, note.range, row, xMin, xMax, fillStyle, 0.25, hovering, false, playing) + } + for (const [note, keyCh, xMin, xMax] of TrackNotes.iterNotesAndKeyChangesAtRange(state, trackIndex, visibleTimeRange)) { if (!state.playback.playing && track.selection.has(note.id)) @@ -527,7 +583,7 @@ export default class TrackNotes const fillStyle = CanvasUtils.fillStyleForDegree(ctx, keyCh.key.degreeForMidi(note.pitch) + mode) const hovering = track.hover && track.hover.id == note.id const playing = state.playback.playing && note.range.overlapsPoint(state.playback.time) - TrackNotes.renderNote(state, trackIndex, ctx, note.range, row, xMin, xMax, fillStyle, hovering, false, playing) + TrackNotes.renderNote(state, trackIndex, ctx, note.range, row, xMin, xMax, fillStyle, 1, hovering, false, playing) } if (!state.playback.playing) @@ -541,7 +597,7 @@ export default class TrackNotes const mode = keyCh.key.scale.metadata.mode const fillStyle = CanvasUtils.fillStyleForDegree(ctx, keyCh.key.degreeForMidi(note.pitch) + mode) const hovering = track.hover && track.hover.id == note.id - TrackNotes.renderNote(state, trackIndex, ctx, note.range, row, xMin, xMax, fillStyle, hovering, true, false) + TrackNotes.renderNote(state, trackIndex, ctx, note.range, row, xMin, xMax, fillStyle, 1, hovering, true, false) } } @@ -555,25 +611,26 @@ export default class TrackNotes const row = TrackNotes.rowForPitch(state, trackIndex, draw.pitch, key) const mode = key.scale.metadata.mode const fillStyle = CanvasUtils.fillStyleForDegree(ctx, key.degreeForMidi(draw.pitch) + mode) - TrackNotes.renderNote(state, trackIndex, ctx, new Range(draw.time1, draw.time2).sorted(), row, -Infinity, Infinity, fillStyle) + TrackNotes.renderNote(state, trackIndex, ctx, new Range(draw.time1, draw.time2).sorted(), row, -Infinity, Infinity, fillStyle, 1) ctx.globalAlpha = 1 } } - static renderNote(state, trackIndex, ctx, range, row, xMin, xMax, fillStyle, hovering, selected, playing) + static renderNote(state, trackIndex, ctx, range, row, xMin, xMax, fillStyle, alpha, hovering, selected, playing) { const rect = TrackNotes.rectForNote(state, trackIndex, range, row, xMin, xMax) ctx.fillStyle = fillStyle - + ctx.globalAlpha = alpha + ctx.beginPath() ctx.fillRect(rect.x, rect.y, rect.w, rect.h) if (hovering) { - ctx.globalAlpha = 0.4 + ctx.globalAlpha = alpha * 0.4 ctx.fillStyle = "#fff" ctx.fillRect(rect.x, rect.y, rect.w, rect.h) ctx.globalAlpha = 1 @@ -582,10 +639,44 @@ export default class TrackNotes if (selected || playing) { const margin = 3 - ctx.globalAlpha = 0.6 + ctx.globalAlpha = alpha * 0.6 ctx.fillStyle = "#fff" ctx.fillRect(rect.x, rect.y + margin, rect.w, rect.h - margin * 2) ctx.globalAlpha = 1 } + + ctx.globalAlpha = 1 + } + + + static renderOutlineNote(state, trackIndex, ctx, range, row, xMin, xMax, fillStyle, alpha, hovering, selected, playing) + { + const rect = TrackNotes.rectForNote(state, trackIndex, range, row, xMin, xMax) + + ctx.strokeStyle = fillStyle + ctx.lineWidth = 3 + ctx.globalAlpha = alpha + + ctx.beginPath() + ctx.strokeRect(rect.x, rect.y, rect.w, rect.h) + + /*if (hovering) + { + ctx.globalAlpha = alpha * 0.4 + ctx.fillStyle = "#fff" + ctx.strokeRect(rect.x, rect.y, rect.w, rect.h) + ctx.globalAlpha = 1 + } + + if (selected || playing) + { + const margin = 3 + ctx.globalAlpha = alpha * 0.6 + ctx.fillStyle = "#fff" + ctx.strokeRect(rect.x, rect.y + margin, rect.w, rect.h - margin * 2) + ctx.globalAlpha = 1 + }*/ + + ctx.globalAlpha = 1 } } \ No newline at end of file diff --git a/src/project/ioMidi.js b/src/project/ioMidi.js index 63a2bf2..1864d97 100644 --- a/src/project/ioMidi.js +++ b/src/project/ioMidi.js @@ -28,9 +28,20 @@ export default class IOMidi const msPerQuarterNote = (tempoEv ? tempoEv.msPerQuarterNote : 500000) song.baseBpm = Math.round(60 * 1000 * 1000 / msPerQuarterNote) - let notesToAdd = [] for (const track of midi.tracks) { + let trackName = "New Track" + for (const ev of track.events) + { + if (ev.kind == "trackName") + trackName = ev.name + } + + const trackId = song.nextId + song = song.upsertTrack(new Project.Track().withChanges({ name: trackName })) + + let notesToAdd = [] + for (const noteOn of track.events) { if (noteOn.kind != "noteOn" || noteOn.channel == 9 || noteOn.velocity == 0) @@ -58,11 +69,12 @@ export default class IOMidi const onTick = Rational.fromFloat(noteOn.time / midi.ticksPerQuarterNote / 4, 27720) const offTick = Rational.fromFloat(noteOff.time / midi.ticksPerQuarterNote / 4, 27720) - notesToAdd.push(new Project.Note(new Range(onTick, offTick), noteOn.key)) + notesToAdd.push(new Project.Note(trackId, new Range(onTick, offTick), noteOn.key)) } + + song = song.upsertNotes(notesToAdd) } - song = song.upsertNotes(notesToAdd) for (const track of midi.tracks) { diff --git a/src/project/playbackSynth.js b/src/project/playbackSynth.js index bfcf0dc..2db0d67 100644 --- a/src/project/playbackSynth.js +++ b/src/project/playbackSynth.js @@ -32,14 +32,17 @@ export default class PlaybackSynth } // Register notes. - for (const note of project.notes.iterAll()) + for (const track of project.tracks) { - if (note.range.end.compare(startTick) <= 0) - continue - - addNoteEvent(note.range.start, note.range.duration, 0, note.pitch, 1) + for (const note of track.notes.iterAll()) + { + if (note.range.end.compare(startTick) <= 0) + continue + + addNoteEvent(note.range.start, note.range.duration, 0, note.pitch, 1) + } } - + // Register chords. for (const chord of project.chords.iterAll()) { diff --git a/src/project/project.js b/src/project/project.js index a02f4c6..e469086 100644 --- a/src/project/project.js +++ b/src/project/project.js @@ -6,6 +6,7 @@ import Theory from "../theory.js" import IOJson from "./ioJson.js" import IOMidi from "./ioMidi.js" import IOCompressedStr from "./ioCompressedStr.js" +import { default as Immutable } from "immutable" export default class Project @@ -15,7 +16,7 @@ export default class Project this.nextId = 1 this.baseBpm = 120 this.range = new Range(new Rational(0), new Rational(4)) - this.notes = new ListOfRanges() + this.tracks = [] this.chords = new ListOfRanges() this.meterChanges = new ListOfPoints() this.keyChanges = new ListOfPoints() @@ -27,6 +28,7 @@ export default class Project return new Project() .upsertKeyChange(new Project.KeyChange(new Rational(0, 4), Theory.Key.parse("C Major"))) .upsertMeterChange(new Project.MeterChange(new Rational(0, 4), new Theory.Meter(4, 4))) + .upsertTrack(new Project.Track()) .withRefreshedRange() } @@ -37,7 +39,7 @@ export default class Project song.nextId = this.nextId song.baseBpm = this.baseBpm song.range = this.range - song.notes = this.notes + song.tracks = this.tracks song.chords = this.chords song.meterChanges = this.meterChanges song.keyChanges = this.keyChanges @@ -49,7 +51,10 @@ export default class Project withRefreshedRange() { let range = null - range = Range.merge(range, this.notes.getTotalRange()) + + for (const track of this.tracks.values()) + range = Range.merge(range, track.notes.getTotalRange()) + range = Range.merge(range, this.chords.getTotalRange()) range = Range.merge(range, this.meterChanges.getTotalRange()) range = Range.merge(range, this.keyChanges.getTotalRange()) @@ -68,6 +73,56 @@ export default class Project } + _upsertTrack(track, remove = false) + { + let nextId = this.nextId + let list = this.tracks + + if (track.id < 0) + { + track = Object.assign({}, track, { id: nextId }) + nextId++ + } + + if (!remove) + { + const index = list.findIndex(t => t.id === track.id) + if (index < 0) + list = [ ...list, track ] + else + list = [ ...list.slice(0, index), track, ...list.slice(index + 1) ] + } + else + list = list.filter(t => t.id !== track.id) + + return this.withChanges({ nextId, tracks: list }) + } + + + _upsertTrackElement(track, listField, elem, remove = false) + { + let nextId = this.nextId + let list = track[listField] + + if (elem.id < 0) + { + elem = elem.withChanges({ id: nextId }) + nextId++ + } + else + { + list = list.removeById(elem.id) + } + + if (!remove) + list = list.add(elem) + + let res = this.withChanges({ nextId }) + res = res._upsertTrack({ ...track, [listField]: list }) + return res + } + + _upsertElement(listField, elem, remove = false) { let nextId = this.nextId @@ -120,13 +175,25 @@ export default class Project upsertNote(note, remove = false) { - return this._upsertElement("notes", note, remove) + const track = this.tracks.find(t => t.id === note.trackId) + return this._upsertTrackElement(track, "notes", note, remove) } upsertNotes(notes, remove = false) { - return this._upsertElements("notes", notes, remove) + let res = this + for (const note of notes) + res = res.upsertNote(note, remove) + + return res + //return this._upsertElements("notes", notes, remove) + } + + + upsertTrack(track, remove = false) + { + return this._upsertTrack(track, remove) } @@ -150,8 +217,14 @@ export default class Project findById(id) { + for (const track of this.tracks.values()) + { + const elem = track.notes.findById(id) + if (elem) + return elem + } + return ( - this.notes.findById(id) || this.chords.findById(id) || this.keyChanges.findById(id) || this.meterChanges.findById(id) @@ -162,7 +235,7 @@ export default class Project update(elem) { let song = this.withChanges({}) - song.notes = song.notes.update(elem) + //song.notes = song.notes.update(elem) song.chords = song.chords.update(elem) song.keyChanges = song.keyChanges.update(elem) song.meterChanges = song.meterChanges.update(elem) @@ -173,7 +246,7 @@ export default class Project removeById(id) { let song = this.withChanges({}) - song.notes = song.notes.removeById(id) + //song.notes = song.notes.removeById(id) song.chords = song.chords.removeById(id) song.keyChanges = song.keyChanges.removeById(id) song.meterChanges = song.meterChanges.removeById(id) @@ -211,11 +284,29 @@ export default class Project } + static Track = class ProjectTrack + { + constructor() + { + this.id = -1 + this.name = "New Track" + this.notes = new ListOfRanges() + } + + + withChanges(obj) + { + return Object.assign(new ProjectTrack(), { id: this.id, name: this.name, notes: this.notes }, obj) + } + } + + static Note = class ProjectNote { - constructor(range, pitch) + constructor(trackId, range, pitch) { this.id = -1 + this.trackId = trackId this.range = range this.pitch = pitch } @@ -223,7 +314,7 @@ export default class Project withChanges(obj) { - return Object.assign(new ProjectNote(this.range, this.pitch), { id: this.id }, obj) + return Object.assign(new ProjectNote(this.trackId, this.range, this.pitch), { id: this.id }, obj) } } diff --git a/src/toolbox/App.js b/src/toolbox/App.js index 8d2ba0e..a56f0fa 100644 --- a/src/toolbox/App.js +++ b/src/toolbox/App.js @@ -6,6 +6,7 @@ import ToolboxPlayback from "./ToolboxPlayback.js" import ToolboxFile from "./ToolboxFile.js" import ToolboxEdit from "./ToolboxEdit.js" import ToolboxInput from "./ToolboxInput.js" +import ToolboxTracks from "./ToolboxTracks.js" import Ribbon from "./Ribbon.js" @@ -157,7 +158,7 @@ export default function App(props) state = Editor.reduce(state, { type: "init", project: Project.getDefault() }) state = Editor.reduce(state, { type: "trackAdd", kind: "markers" }) state = Editor.reduce(state, { type: "trackAdd", kind: "chords" }) - state = Editor.reduce(state, { type: "trackAdd", kind: "notes" }) + state = Editor.reduce(state, { type: "trackAdd", kind: "notes", trackId: state.project.tracks[0].id }) state = Editor.reduce(state, { type: "clearUndoStack" }) const urlData = @@ -191,6 +192,7 @@ export default function App(props) boxSizing: "border-box", display: "grid", gridTemplate: "150px 1fr / 1fr", + backgroundColor: "#eee", }}>