├── .prettierrc ├── sandbox.config.json ├── .npmignore ├── test └── helpers │ └── setup-browser-env.js ├── src ├── helpers │ ├── sizeToTime.js │ ├── setAtIds.js │ ├── measuresToTime.js │ ├── addPoints.js │ ├── getPointOffset.js │ ├── getLetterFromPitch.js │ ├── duplicateNotes.js │ ├── translateNote.js │ ├── createTrack.js │ ├── resizeNote.js │ ├── getPitchName.js │ ├── createSequence.js │ ├── createNote.js │ ├── getNoteLength.js │ ├── getNotesInArea.js │ ├── someNoteWillMoveOutside.js │ ├── index.js │ └── createSong.js ├── models │ ├── index.js │ ├── part │ │ ├── disableLooping.js │ │ ├── stop.js │ │ ├── startAtTime.js │ │ ├── dispose.js │ │ ├── startAtOffset.js │ │ ├── __tests__ │ │ │ ├── disableLooping.test.js │ │ │ ├── stop.test.js │ │ │ ├── dispose.test.js │ │ │ ├── startAtTime.test.js │ │ │ ├── startAtOffset.test.js │ │ │ └── mapEvents.test.js │ │ ├── index.js │ │ └── mapEvents.js │ ├── volumeNode │ │ ├── mute.js │ │ ├── unmute.js │ │ ├── setVolume.js │ │ ├── dispose.js │ │ ├── index.js │ │ └── __tests__ │ │ │ ├── mute.test.js │ │ │ ├── unmute.test.js │ │ │ ├── setVolume.test.js │ │ │ └── dispose.test.js │ └── instrument │ │ ├── dispose.js │ │ ├── playNote.js │ │ ├── index.js │ │ ├── setVoice.js │ │ └── __tests__ │ │ ├── dispose.test.js │ │ ├── setVoice.test.js │ │ └── playNote.test.js ├── selectors │ ├── index.js │ ├── getIsAnyTrackSoloing.js │ ├── getLoopStartPoint.js │ ├── getLoopEndPoint.js │ └── __tests__ │ │ ├── getLoopStartPoint.test.js │ │ └── getLoopEndPoint.test.js ├── song │ ├── effects │ │ ├── handleBPMEdit.js │ │ ├── interpretDiff │ │ │ ├── interpretBPMEditedDiff.js │ │ │ ├── interpretNoteAddedDiff.js │ │ │ ├── interpretNoteDeletedDiff.js │ │ │ ├── interpretSequenceAddedDiff.js │ │ │ ├── interpretTrackDeletedDiff.js │ │ │ ├── interpretSequenceDeletedDiff.js │ │ │ ├── interpretMeasureCountEditedDiff.js │ │ │ ├── interpretFocusedSequenceIdEditedDiff.js │ │ │ ├── interpretTrackAddedDiff.js │ │ │ ├── interpretSequenceEditedDiff.js │ │ │ ├── interpretNoteArrayEditedDiff.js │ │ │ ├── interpretNoteEditedDiff.js │ │ │ ├── interpretSequencesDiff.js │ │ │ ├── interpretTracksDiff.js │ │ │ ├── interpretTrackEditedDiff.js │ │ │ ├── interpretNotesDiff.js │ │ │ └── index.js │ │ ├── handleFocusedSequenceIdEdit.js │ │ ├── handleTrackDeletionRequest.js │ │ ├── handleSongUpdate.js │ │ ├── index.js │ │ └── __tests__ │ │ │ └── interpretDiff.test.js │ └── reducer.js ├── __tests__ │ ├── helpers.getPitchName.test.js │ ├── index.test.js │ ├── helpers.sizeToTime.test.js │ ├── helpers.measuresToTime.test.js │ ├── helpers.getNoteLength.test.js │ └── helpers.getLetterFromPitch.test.js ├── playbackState │ └── effects │ │ ├── stopPlayback.js │ │ ├── pausePlayback.js │ │ ├── startPlayback.js │ │ ├── __tests__ │ │ ├── pausePlayback.test.js │ │ ├── startPlayback.test.js │ │ ├── stopPlayback.test.js │ │ └── setPosition.js │ │ ├── index.js │ │ └── setPosition.js ├── state.js ├── transportPart │ ├── reducer.js │ └── effects │ │ ├── disableTransportPartLooping.js │ │ ├── startTransportPart.js │ │ ├── index.js │ │ ├── __tests__ │ │ ├── disableTransportPartLooping.test.js │ │ ├── startTransportPart.test.js │ │ ├── setToneLoopPoints.test.js │ │ └── setTransportPartEvents.test.js │ │ ├── setToneLoopPoints.js │ │ └── setTransportPartEvents.js ├── parts │ ├── effects │ │ ├── disposePart.js │ │ ├── acceptSequenceDeletion.js │ │ ├── stopPart.js │ │ ├── reloadSequence.js │ │ ├── disablePartLooping.js │ │ ├── startPart.js │ │ ├── __tests__ │ │ │ ├── stopPart.test.js │ │ │ ├── acceptSequenceDeletion.test.js │ │ │ ├── disposePart.test.js │ │ │ ├── disablePartLooping.test.js │ │ │ └── startPart.test.js │ │ ├── index.js │ │ ├── setPartEvents.js │ │ └── setPartEventsByNoteId.js │ └── reducer.js ├── volumeNodes │ ├── effects │ │ ├── handleTrackVolumeEdit.js │ │ ├── handleTrackAdded.js │ │ ├── updateMuting.js │ │ └── index.js │ └── reducer.js ├── toneAdapter │ ├── __tests__ │ │ ├── toneAdapter.setBPM.test.js │ │ ├── toneAdapter.setTransportPosition.test.js │ │ ├── toneAdapter.stop.test.js │ │ ├── toneAdapter.pause.test.js │ │ ├── toneAdapter.start.test.js │ │ ├── toneAdapter.chainToMaster.test.js │ │ ├── toneAdapter.setLoopPoints.test.js │ │ ├── toneAdapter.createVolume.test.js │ │ ├── toneAdapter.createSequence.test.js │ │ └── toneAdapter.createInstrument.test.js │ └── index.js ├── instruments │ ├── effects │ │ ├── handleTrackVoiceEdit.js │ │ ├── handleNotePlay.js │ │ ├── index.js │ │ ├── __tests__ │ │ │ ├── handleTrackVoiceEdit.test.js │ │ │ ├── handleNotePlay.test.js │ │ │ └── handlePartStepTriggered.test.js │ │ └── handlePartStepTriggered.js │ ├── reducer.js │ └── __tests__ │ │ └── reducer.test.js ├── bus.js ├── effects.js ├── reducer.js ├── index.js ├── constants.js └── actions.js ├── examples ├── index.html ├── render.js ├── sampleSong.js ├── sampleSongAlt.js ├── index.js └── loadTestingSong.json ├── README.md ├── .travis.yml ├── configs └── .eslintrc.js ├── LICENSE ├── .gitignore └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: "all" -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node" 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .gitignore 3 | .nyc_output 4 | configs 5 | coverage 6 | docs 7 | examples 8 | src 9 | test 10 | -------------------------------------------------------------------------------- /test/helpers/setup-browser-env.js: -------------------------------------------------------------------------------- 1 | import browserEnv from 'browser-env'; 2 | 3 | browserEnv(['window', 'document', 'navigator']); 4 | -------------------------------------------------------------------------------- /src/helpers/sizeToTime.js: -------------------------------------------------------------------------------- 1 | export function sizeToTime(size, toneAdapter) { 2 | return (size + 1) * toneAdapter.Time('32n'); 3 | } 4 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/helpers/setAtIds.js: -------------------------------------------------------------------------------- 1 | export function setAtIds(array, obj) { 2 | return array.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), obj); 3 | } 4 | -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | export { instrument } from './instrument'; 2 | export { part } from './part'; 3 | export { volumeNode } from './volumeNode'; 4 | -------------------------------------------------------------------------------- /src/selectors/index.js: -------------------------------------------------------------------------------- 1 | export * from './getIsAnyTrackSoloing'; 2 | export * from './getLoopEndPoint'; 3 | export * from './getLoopStartPoint'; 4 | -------------------------------------------------------------------------------- /src/models/part/disableLooping.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set'; 2 | 3 | export function disableLooping(part) { 4 | set(part, 'loop', false); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/volumeNode/mute.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set'; 2 | 3 | export function mute(volumeNode) { 4 | set(volumeNode, 'mute', true); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/part/stop.js: -------------------------------------------------------------------------------- 1 | import invokeArgs from 'lodash/fp/invokeArgs'; 2 | 3 | export function stop(part) { 4 | invokeArgs('stop', [0], part); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/volumeNode/unmute.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set'; 2 | 3 | export function unmute(volumeNode) { 4 | set(volumeNode, 'mute', false); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/measuresToTime.js: -------------------------------------------------------------------------------- 1 | export function measuresToTime(measures, toneAdapter) { 2 | return Math.floor(measures * 32) * toneAdapter.Time('32n'); 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/addPoints.js: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/fp/curry'; 2 | 3 | export const addPoints = curry((b, a) => ({ 4 | x: a.x + b.x, 5 | y: a.y + b.y, 6 | })); 7 | -------------------------------------------------------------------------------- /src/helpers/getPointOffset.js: -------------------------------------------------------------------------------- 1 | export function getPointOffset(start, end) { 2 | return { 3 | x: end.x - start.x, 4 | y: end.y - start.y, 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/models/instrument/dispose.js: -------------------------------------------------------------------------------- 1 | import invoke from 'lodash/fp/invoke'; 2 | 3 | export function dispose(instrument) { 4 | invoke('dispose', instrument); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/volumeNode/setVolume.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set'; 2 | 3 | export function setVolume(volumeNode, value) { 4 | set(volumeNode, 'volume.value', value); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/part/startAtTime.js: -------------------------------------------------------------------------------- 1 | import invokeArgs from 'lodash/fp/invokeArgs'; 2 | 3 | export function startAtTime(startTime, part) { 4 | invokeArgs('start', [startTime], part); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/getLetterFromPitch.js: -------------------------------------------------------------------------------- 1 | export function getLetterFromPitch(pitch) { 2 | return ['B', 'A#', 'A', 'G#', 'G', 'F#', 'F', 'E', 'D#', 'D', 'C#', 'C'][ 3 | pitch % 12 4 | ]; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/part/dispose.js: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash/fp/isFunction'; 2 | 3 | export function dispose(part) { 4 | if (!isFunction(part.dispose)) return; 5 | 6 | part.dispose(); 7 | } 8 | -------------------------------------------------------------------------------- /src/models/part/startAtOffset.js: -------------------------------------------------------------------------------- 1 | import invokeArgs from 'lodash/fp/invokeArgs'; 2 | 3 | export function startAtOffset(offsetTime, part) { 4 | invokeArgs('start', [undefined, offsetTime], part); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/duplicateNotes.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/fp/map'; 2 | import { createNote } from './createNote'; 3 | 4 | export const duplicateNotes = map(note => 5 | createNote(note.sequenceId, note.points), 6 | ); 7 | -------------------------------------------------------------------------------- /src/models/volumeNode/dispose.js: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash/fp/isFunction'; 2 | 3 | export function dispose(volumeNode) { 4 | if (!isFunction(volumeNode.dispose)) return; 5 | 6 | volumeNode.dispose(); 7 | } 8 | -------------------------------------------------------------------------------- /src/models/instrument/playNote.js: -------------------------------------------------------------------------------- 1 | import invokeArgs from 'lodash/fp/invokeArgs'; 2 | 3 | export function playNote(instrument, name, length = '16n', time) { 4 | invokeArgs('triggerAttackRelease', [name, length, time], instrument); 5 | } 6 | -------------------------------------------------------------------------------- /src/song/effects/handleBPMEdit.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | 3 | export function handleBPMEdit(getState, action, shared) { 4 | const bpm = getOr(0, 'payload.bpm', action); 5 | 6 | shared.toneAdapter.setBPM(bpm); 7 | } 8 | -------------------------------------------------------------------------------- /src/models/instrument/index.js: -------------------------------------------------------------------------------- 1 | import { dispose } from './dispose'; 2 | import { playNote } from './playNote'; 3 | import { setVoice } from './setVoice'; 4 | 5 | export const instrument = { 6 | dispose, 7 | playNote, 8 | setVoice, 9 | }; 10 | -------------------------------------------------------------------------------- /src/helpers/translateNote.js: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/fp/curry'; 2 | import map from 'lodash/fp/map'; 3 | import { addPoints } from './addPoints'; 4 | 5 | export const translateNote = curry((delta, note) => ({ 6 | ...note, 7 | points: map(addPoints(delta), note.points), 8 | })); 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretBPMEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretBPMEditedDiff(diff) { 5 | const bpm = getOr(0, 'rhs', diff); 6 | 7 | return actions.bpmEdited(bpm); 8 | } 9 | -------------------------------------------------------------------------------- /src/song/reducer.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../actions'; 2 | 3 | export default function reducer(state = {}, action) { 4 | switch (action.type) { 5 | case actions.SONG_UPDATED: 6 | return action.payload.song; 7 | default: 8 | return state; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/selectors/getIsAnyTrackSoloing.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/fp/compose'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import some from 'lodash/fp/some'; 4 | 5 | export const getIsAnyTrackSoloing = compose( 6 | some(getOr(false, 'isSoloing')), 7 | getOr({}, 'song.tracks'), 8 | ); 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretNoteAddedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretNoteAddedDiff(diff) { 5 | const note = getOr({}, 'rhs', diff); 6 | 7 | return actions.noteAdded(note, note.id); 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/helpers.getPitchName.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getPitchName } from '../helpers/getPitchName'; 3 | 4 | test('should return pitch translated into note name', t => { 5 | const expected = 'C3'; 6 | const result = getPitchName(47); 7 | t.is(result, expected); 8 | }); 9 | -------------------------------------------------------------------------------- /src/models/volumeNode/index.js: -------------------------------------------------------------------------------- 1 | import { dispose } from './dispose'; 2 | import { mute } from './mute'; 3 | import { setVolume } from './setVolume'; 4 | import { unmute } from './unmute'; 5 | 6 | export const volumeNode = { 7 | dispose, 8 | mute, 9 | setVolume, 10 | unmute, 11 | }; 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dawww 2 | Simple backend for a step sequencer 3 | 4 | [![npm](https://img.shields.io/npm/v/dawww.svg?style=flat-square)](https://www.npmjs.com/package/dawww) 5 | [![Travis](https://img.shields.io/travis/nickjohnson-dev/dawww.svg?style=flat-square)](https://travis-ci.org/nickjohnson-dev/dawww) 6 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretNoteDeletedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretNoteDeletedDiff(diff) { 5 | const note = getOr({}, 'lhs', diff); 6 | 7 | return actions.noteDeleted(note, note.id); 8 | } 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretSequenceAddedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretSequenceAddedDiff(diff) { 5 | const sequence = getOr({}, 'rhs', diff); 6 | 7 | return actions.sequenceAdded(sequence); 8 | } 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretTrackDeletedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretTrackDeletedDiff(diff) { 5 | const track = getOr({}, 'lhs', diff); 6 | 7 | return actions.trackDeletionRequested(track); 8 | } 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretSequenceDeletedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretSequenceDeletedDiff(diff) { 5 | const sequence = getOr({}, 'lhs', diff); 6 | 7 | return actions.sequenceDeletionRequested(sequence); 8 | } 9 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretMeasureCountEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretMeasureCountEditedDiff(diff) { 5 | const measureCount = getOr(0, 'rhs', diff); 6 | 7 | return actions.measureCountEdited(measureCount); 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/fp/isObject'; 2 | import test from 'ava'; 3 | import Dawww from '../index'; 4 | 5 | test('should return object', t => { 6 | const expected = true; 7 | const returnValue = Dawww({}); 8 | const result = isObject(returnValue); 9 | t.is(result, expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/helpers/createTrack.js: -------------------------------------------------------------------------------- 1 | import shortid from 'shortid'; 2 | import * as constants from '../constants'; 3 | 4 | export function createTrack(voice = constants.DEFAULT_VOICE) { 5 | return { 6 | id: shortid.generate(), 7 | isMuted: false, 8 | isSoloing: false, 9 | volume: -10, 10 | voice, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/models/instrument/setVoice.js: -------------------------------------------------------------------------------- 1 | import invokeArgs from 'lodash/fp/invokeArgs'; 2 | 3 | export function setVoice(instrument, value) { 4 | invokeArgs( 5 | 'set', 6 | [ 7 | { 8 | oscillator: { 9 | type: value.toLowerCase(), 10 | }, 11 | }, 12 | ], 13 | instrument, 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/models/volumeNode/__tests__/mute.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mute } from '../mute'; 3 | 4 | test('should set mute field on volumeNode: true', t => { 5 | const expected = true; 6 | const volumeNode = { mute: false }; 7 | mute(volumeNode); 8 | const result = volumeNode.mute; 9 | t.is(result, expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretFocusedSequenceIdEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | 4 | export function interpretFocusedSequenceIdEditedDiff(diff) { 5 | const focusedSequenceId = getOr('', 'rhs', diff); 6 | 7 | return actions.focusedSequenceIdEdited(focusedSequenceId); 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/resizeNote.js: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/fp/curry'; 2 | import initial from 'lodash/fp/initial'; 3 | import last from 'lodash/fp/last'; 4 | import { addPoints } from './addPoints'; 5 | 6 | export const resizeNote = curry((delta, note) => ({ 7 | ...note, 8 | points: [...initial(note.points), addPoints(delta, last(note.points))], 9 | })); 10 | -------------------------------------------------------------------------------- /src/models/part/__tests__/disableLooping.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { disableLooping } from '../disableLooping'; 3 | 4 | test('should set loop field on part: false', t => { 5 | const expected = false; 6 | const part = { loop: true }; 7 | disableLooping(part); 8 | const result = part.loop; 9 | t.is(result, expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/models/volumeNode/__tests__/unmute.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { unmute } from '../unmute'; 3 | 4 | test('should set mute field on volumeNode: false', t => { 5 | const expected = false; 6 | const volumeNode = { mute: true }; 7 | unmute(volumeNode); 8 | const result = volumeNode.mute; 9 | t.is(result, expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/playbackState/effects/stopPlayback.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import * as constants from '../../constants'; 3 | 4 | export function stopPlayback(getState, action, shared) { 5 | shared.toneAdapter.stop(); 6 | shared.dispatch(actions.playbackStateSet(constants.PLAYBACK_STATES.STOPPED)); 7 | shared.dispatch(actions.positionSetRequested(0)); 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/getPitchName.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants'; 2 | import { getLetterFromPitch } from './getLetterFromPitch'; 3 | 4 | export function getPitchName(pitch) { 5 | const octaveNumber = 6 | constants.OCTAVE_RANGE.length - 1 - Math.floor(pitch / 12); 7 | const letter = getLetterFromPitch(pitch); 8 | return `${letter}${octaveNumber}`; 9 | } 10 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | let state = { 2 | instruments: {}, 3 | parts: {}, 4 | song: { 5 | notes: {}, 6 | sequences: {}, 7 | tracks: {}, 8 | }, 9 | transportPart: {}, 10 | volumeNodes: {}, 11 | }; 12 | 13 | export function getState() { 14 | return { ...state }; 15 | } 16 | 17 | export function setState(updates) { 18 | state = { ...state, ...updates }; 19 | } 20 | -------------------------------------------------------------------------------- /src/transportPart/reducer.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../actions'; 2 | 3 | export default function reducer(state = {}, action, shared) { 4 | switch (action.type) { 5 | case actions.MEASURE_COUNT_EDITED: 6 | return shared.toneAdapter.createSequence({ 7 | length: action.payload.measureCount * 32, 8 | }); 9 | default: 10 | return state; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/__tests__/helpers.sizeToTime.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { sizeToTime } from '../helpers/sizeToTime'; 3 | 4 | test('should return (size + 1) * toneAdapter.Time("32n") as Tone time', t => { 5 | const expected = 16; 6 | const mockToneAdapter = { 7 | Time: () => 2, 8 | }; 9 | const result = sizeToTime(7, mockToneAdapter); 10 | t.is(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/models/part/__tests__/stop.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { stop } from '../stop'; 4 | 5 | test('should invoke stop method on part with 0', t => { 6 | const expected = [0]; 7 | const part = { 8 | stop: sinon.spy(), 9 | }; 10 | stop(part); 11 | const result = part.stop.lastCall.args; 12 | t.deepEqual(result, expected); 13 | }); 14 | -------------------------------------------------------------------------------- /src/models/volumeNode/__tests__/setVolume.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { setVolume } from '../setVolume'; 3 | 4 | test('should set volume.value field on volumeNode: value', t => { 5 | const expected = -5; 6 | const volumeNode = { volume: { value: -100 } }; 7 | setVolume(volumeNode, -5); 8 | const result = volumeNode.volume.value; 9 | t.is(result, expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/models/part/__tests__/dispose.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { dispose } from '../dispose'; 4 | 5 | test('should invoke dispose method on part', t => { 6 | const expected = true; 7 | const part = { 8 | dispose: sinon.spy(), 9 | }; 10 | dispose(part); 11 | const result = part.dispose.calledOnce; 12 | t.is(result, expected); 13 | }); 14 | -------------------------------------------------------------------------------- /src/__tests__/helpers.measuresToTime.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { measuresToTime } from '../helpers/measuresToTime'; 3 | 4 | test('should return measureCount * 32 * toneAdapter.Time("32n")', t => { 5 | const expected = 64; 6 | const mockToneAdapter = { 7 | Time: () => 2, 8 | }; 9 | const result = measuresToTime(1, mockToneAdapter); 10 | t.is(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/parts/effects/disposePart.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function disposePart(getState, action, shared) { 5 | const dispose = getOr(noop, 'models.part.dispose', shared); 6 | const sequenceId = getOr('', 'payload.sequence.id', action); 7 | const part = getOr({}, `parts[${sequenceId}]`, getState()); 8 | 9 | dispose(part); 10 | } 11 | -------------------------------------------------------------------------------- /src/transportPart/effects/disableTransportPartLooping.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function disableTransportPartLooping(getState, action, shared) { 5 | const disableLooping = getOr(noop, 'models.part.disableLooping', shared); 6 | const transportPart = getOr({}, 'transportPart', getState()); 7 | 8 | disableLooping(transportPart); 9 | } 10 | -------------------------------------------------------------------------------- /src/volumeNodes/effects/handleTrackVolumeEdit.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | 3 | export function handleTrackVolumeEdit(getState, action, shared) { 4 | const id = getOr('', 'payload.id', action); 5 | const volumeNode = getOr({}, `volumeNodes[${id}]`, getState()); 6 | const volume = getOr(0, 'payload.value', action); 7 | 8 | shared.models.volumeNode.setVolume(volumeNode, volume); 9 | } 10 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.setBPM.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { createToneAdapter } from '../index'; 3 | 4 | test('should set Transport.bpm.value of Tone to value', t => { 5 | const expected = 150; 6 | const Tone = {}; 7 | const toneAdapter = createToneAdapter(Tone); 8 | toneAdapter.setBPM(150); 9 | const result = Tone.Transport.bpm.value; 10 | t.is(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/helpers/createSequence.js: -------------------------------------------------------------------------------- 1 | import isNil from 'lodash/fp/isNil'; 2 | import shortid from 'shortid'; 3 | 4 | export function createSequence(trackId, position = 0, measureCount = 1) { 5 | if (isNil(trackId)) { 6 | throw new Error('Please provide a trackId to createSequence'); 7 | } 8 | 9 | return { 10 | id: shortid.generate(), 11 | measureCount, 12 | position, 13 | trackId, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/volumeNodes/effects/handleTrackAdded.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | 3 | export function handleTrackAdded(getState, action, shared) { 4 | const id = getOr('', 'payload.track.id', action); 5 | const instrument = getOr({}, `instruments[${id}]`, getState()); 6 | const volumeNode = getOr({}, `volumeNodes[${id}]`, getState()); 7 | 8 | shared.toneAdapter.chainToMaster(instrument, volumeNode); 9 | } 10 | -------------------------------------------------------------------------------- /src/models/instrument/__tests__/dispose.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { dispose } from '../dispose'; 4 | 5 | test('should invoke dispose method on instrument', t => { 6 | const expected = true; 7 | const instrument = { 8 | dispose: sinon.spy(), 9 | }; 10 | dispose(instrument); 11 | const result = instrument.dispose.calledOnce; 12 | t.is(result, expected); 13 | }); 14 | -------------------------------------------------------------------------------- /src/models/part/__tests__/startAtTime.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { startAtTime } from '../startAtTime'; 4 | 5 | test('should invoke start method on part with startTime', t => { 6 | const expected = ['(0 * 32n)']; 7 | const start = sinon.spy(); 8 | startAtTime('(0 * 32n)', { start }); 9 | const result = start.lastCall.args; 10 | t.deepEqual(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/models/volumeNode/__tests__/dispose.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { dispose } from '../dispose'; 4 | 5 | test('should invoke dispose method on volumeNode', t => { 6 | const expected = true; 7 | const volumeNode = { 8 | dispose: sinon.spy(), 9 | }; 10 | dispose(volumeNode); 11 | const result = volumeNode.dispose.calledOnce; 12 | t.is(result, expected); 13 | }); 14 | -------------------------------------------------------------------------------- /src/parts/effects/acceptSequenceDeletion.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | import * as actions from '../../actions'; 4 | 5 | export function acceptSequenceDeletion(getState, action, shared) { 6 | const dispatch = getOr(noop, 'dispatch', shared); 7 | const sequence = getOr({}, 'payload.sequence', action); 8 | 9 | dispatch(actions.sequenceDeletionAccepted(sequence)); 10 | } 11 | -------------------------------------------------------------------------------- /src/parts/effects/stopPart.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function stopPart(getState, action, shared) { 5 | const stop = getOr(noop, 'models.part.stop', shared); 6 | const sequence = getOr({}, 'payload.sequence', action); 7 | const sequenceId = getOr('', 'id', sequence); 8 | const part = getOr({}, `parts[${sequenceId}]`, getState()); 9 | 10 | stop(part); 11 | } 12 | -------------------------------------------------------------------------------- /examples/render.js: -------------------------------------------------------------------------------- 1 | import { patch } from 'superfine'; 2 | 3 | const root = document.querySelector('#root'); 4 | let element = root; 5 | let oldNode; 6 | let oldProps = {}; 7 | 8 | export default function render(getNode, propUpdates) { 9 | const props = { 10 | ...oldProps, 11 | ...propUpdates, 12 | }; 13 | const node = getNode(props); 14 | 15 | oldNode = patch(oldNode, node, element); 16 | 17 | oldProps = props; 18 | } 19 | -------------------------------------------------------------------------------- /src/parts/effects/reloadSequence.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../actions'; 3 | 4 | export function reloadSequence(getState, action, shared) { 5 | const id = getOr('', 'payload.id', action); 6 | const sequence = getOr({}, `song.sequences[${id}]`, getState()); 7 | 8 | shared.dispatch(actions.sequenceDeletionRequested(sequence)); 9 | shared.dispatch(actions.sequenceAdded(sequence)); 10 | } 11 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.setTransportPosition.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { createToneAdapter } from '../index'; 3 | 4 | test('should set Transport.position of Tone to position', t => { 5 | const expected = 0; 6 | const Tone = {}; 7 | const toneAdapter = createToneAdapter(Tone); 8 | toneAdapter.setTransportPosition(0); 9 | const result = Tone.Transport.position; 10 | t.is(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/models/part/index.js: -------------------------------------------------------------------------------- 1 | import { disableLooping } from './disableLooping'; 2 | import { dispose } from './dispose'; 3 | import { mapEvents } from './mapEvents'; 4 | import { startAtOffset } from './startAtOffset'; 5 | import { startAtTime } from './startAtTime'; 6 | import { stop } from './stop'; 7 | 8 | export const part = { 9 | disableLooping, 10 | dispose, 11 | mapEvents, 12 | startAtOffset, 13 | startAtTime, 14 | stop, 15 | }; 16 | -------------------------------------------------------------------------------- /src/models/instrument/__tests__/setVoice.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { setVoice } from '../setVoice'; 4 | 5 | test('should invoke set method on instrument with { oscillator: { type: voice } }', t => { 6 | const expected = [{ oscillator: { type: 'tuba' } }]; 7 | const set = sinon.spy(); 8 | setVoice({ set }, 'tuba'); 9 | const result = set.lastCall.args; 10 | t.deepEqual(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/__tests__/helpers.getNoteLength.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getNoteLength } from '../helpers/getNoteLength'; 3 | 4 | test('should return last(note.points).x - (first(note.points).x + 1) as Tone time', t => { 5 | const expected = 14; 6 | const result = getNoteLength( 7 | { 8 | points: [{ x: 1 }, { x: 4 }, { x: 7 }], 9 | }, 10 | { 11 | Time: () => 2, 12 | }, 13 | ); 14 | t.is(result, expected); 15 | }); 16 | -------------------------------------------------------------------------------- /src/models/part/__tests__/startAtOffset.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { startAtOffset } from '../startAtOffset'; 4 | 5 | test('should invoke start method on part with undefined and offsetTime', t => { 6 | const expected = [undefined, '(0 * 32n)']; 7 | const start = sinon.spy(); 8 | startAtOffset('(0 * 32n)', { start }); 9 | const result = start.lastCall.args; 10 | t.deepEqual(result, expected); 11 | }); 12 | -------------------------------------------------------------------------------- /src/helpers/createNote.js: -------------------------------------------------------------------------------- 1 | import isNil from 'lodash/fp/isNil'; 2 | import shortid from 'shortid'; 3 | 4 | export function createNote(sequenceId, points) { 5 | if (isNil(points)) { 6 | throw new Error('Please provide points to createNote'); 7 | } 8 | 9 | if (isNil(sequenceId)) { 10 | throw new Error('Please provide a sequenceId to createNote'); 11 | } 12 | 13 | return { 14 | id: shortid.generate(), 15 | points, 16 | sequenceId, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/parts/effects/disablePartLooping.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function disablePartLooping(getState, action, shared) { 5 | const disableLooping = getOr(noop, 'models.part.disableLooping', shared); 6 | const sequence = getOr({}, 'payload.sequence', action); 7 | const sequenceId = getOr('', 'id', sequence); 8 | const part = getOr({}, `parts[${sequenceId}]`, getState()); 9 | 10 | disableLooping(part); 11 | } 12 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretTrackAddedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import some from 'lodash/fp/some'; 3 | import * as actions from '../../../actions'; 4 | 5 | export function interpretTrackAddedDiff(diff, song) { 6 | const track = getOr({}, 'rhs', diff); 7 | const tracks = getOr({}, 'tracks', song); 8 | const isAnyTrackSoloing = some(getOr(false, 'isSoloing'), tracks); 9 | 10 | return actions.trackAdded({ isAnyTrackSoloing, track }); 11 | } 12 | -------------------------------------------------------------------------------- /src/instruments/effects/handleTrackVoiceEdit.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function handleTrackVoiceEdit(getState, action, shared) { 5 | const setVoice = getOr(noop, 'models.instrument.setVoice', shared); 6 | const id = getOr('', 'payload.id', action); 7 | const instrument = getOr({}, `instruments[${id}]`, getState()); 8 | const voice = getOr(0, 'payload.value', action); 9 | 10 | setVoice(instrument, voice); 11 | } 12 | -------------------------------------------------------------------------------- /src/playbackState/effects/pausePlayback.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | import * as actions from '../../actions'; 4 | import * as constants from '../../constants'; 5 | 6 | export function pausePlayback(getState, action, shared) { 7 | const dispatch = getOr(noop, 'dispatch', shared); 8 | const pause = getOr(noop, 'toneAdapter.pause', shared); 9 | pause(); 10 | dispatch(actions.playbackStateSet(constants.PLAYBACK_STATES.PAUSED)); 11 | } 12 | -------------------------------------------------------------------------------- /src/playbackState/effects/startPlayback.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | import * as actions from '../../actions'; 4 | import * as constants from '../../constants'; 5 | 6 | export function startPlayback(getState, action, shared) { 7 | const dispatch = getOr(noop, 'dispatch', shared); 8 | const start = getOr(noop, 'toneAdapter.start', shared); 9 | start(); 10 | dispatch(actions.playbackStateSet(constants.PLAYBACK_STATES.STARTED)); 11 | } 12 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.stop.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should invoke Tone.Transport.pause method', t => { 6 | const expected = true; 7 | const pause = sinon.spy(); 8 | const toneAdapter = createToneAdapter({ 9 | Transport: { 10 | pause, 11 | }, 12 | }); 13 | toneAdapter.pause(); 14 | const result = pause.calledOnce; 15 | t.is(result, expected); 16 | }); 17 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.pause.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should invoke Tone.Transport.pause method', t => { 6 | const expected = true; 7 | const pause = sinon.spy(); 8 | const toneAdapter = createToneAdapter({ 9 | Transport: { 10 | pause, 11 | }, 12 | }); 13 | toneAdapter.pause(); 14 | const result = pause.calledOnce; 15 | t.is(result, expected); 16 | }); 17 | -------------------------------------------------------------------------------- /src/selectors/getLoopStartPoint.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import isEmpty from 'lodash/fp/isEmpty'; 3 | 4 | export const getLoopStartPoint = state => { 5 | const focusedSequenceId = getOr('', 'song.focusedSequenceId', state); 6 | const focusedSequence = getOr( 7 | {}, 8 | `song.sequences[${focusedSequenceId}]`, 9 | state, 10 | ); 11 | 12 | if (isEmpty(focusedSequence)) { 13 | return 0; 14 | } 15 | 16 | return getOr(0, 'position', focusedSequence); 17 | }; 18 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.start.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should invoke Tone.Transport.start method with args', t => { 6 | const expected = [2]; 7 | const start = sinon.spy(); 8 | const toneAdapter = createToneAdapter({ 9 | Transport: { 10 | start, 11 | }, 12 | }); 13 | toneAdapter.start(2); 14 | const result = start.lastCall.args; 15 | t.deepEqual(result, expected); 16 | }); 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | notifications: 3 | email: 4 | recipients: 5 | - nickjohnson.dev@gmail.com 6 | node_js: 8 7 | cache: 8 | yarn: true 9 | directories: 10 | - node_modules 11 | script: 12 | - yarn run lint 13 | - yarn run testonce 14 | - yarn run build 15 | after_success: 16 | - './node_modules/.bin/nyc report --reporter=text-lcov > coverage.lcov && ./node_modules/.bin/codecov' 17 | deploy: 18 | provider: script 19 | skip_cleanup: true 20 | script: 21 | - npx semantic-release 22 | -------------------------------------------------------------------------------- /src/bus.js: -------------------------------------------------------------------------------- 1 | import getEventEmitter from 'event-emitter'; 2 | 3 | const eventEmitter = getEventEmitter(); 4 | 5 | export const channels = { 6 | ACTION_OCCURRED: 'ACTION_OCCURRED', 7 | PLAYBACK_STATE_SET: 'PLAYBACK_STATE_SET', 8 | POSITION_SET: 'POSITION_SET', 9 | UPDATE_REQUESTED: 'UPDATE_REQUESTED', 10 | }; 11 | 12 | export const emit = channelName => payload => { 13 | eventEmitter.emit(channelName, payload); 14 | }; 15 | 16 | export const on = (channelName, callback) => { 17 | eventEmitter.on(channelName, callback); 18 | }; 19 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.chainToMaster.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should invoke chain on source with rest of args, and then Tone.Master', t => { 6 | const expected = ['a', 'b', 'c']; 7 | const chain = sinon.spy(); 8 | const toneAdapter = createToneAdapter({ 9 | Master: 'c', 10 | }); 11 | toneAdapter.chainToMaster({ chain }, 'a', 'b'); 12 | const result = chain.lastCall.args; 13 | t.deepEqual(result, expected); 14 | }); 15 | -------------------------------------------------------------------------------- /src/instruments/effects/handleNotePlay.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function handleNotePlay(getState, action, shared) { 5 | const { trackId, pitch, length, time } = action.payload; 6 | const getPitchName = getOr(noop, 'helpers.getPitchName', shared); 7 | const playNote = getOr(noop, 'models.instrument.playNote', shared); 8 | const instrument = getOr({}, `instruments[${trackId}]`, getState()); 9 | const name = getPitchName(pitch); 10 | 11 | playNote(instrument, name, length, time); 12 | } 13 | -------------------------------------------------------------------------------- /src/song/effects/handleFocusedSequenceIdEdit.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | 3 | export function handleFocusedSequenceIdEdit(getState, action, shared) { 4 | const loopStartPoint = shared.selectors.getLoopStartPoint(getState()); 5 | const loopEndPoint = shared.selectors.getLoopEndPoint(getState()); 6 | 7 | shared.toneAdapter.setLoopPoints( 8 | shared.helpers.measuresToTime(loopStartPoint, shared.toneAdapter), 9 | shared.helpers.measuresToTime(loopEndPoint, shared.toneAdapter), 10 | ); 11 | 12 | shared.dispatch(actions.positionSetRequested(0)); 13 | } 14 | -------------------------------------------------------------------------------- /src/volumeNodes/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/fp/omit'; 2 | import * as actions from '../actions'; 3 | 4 | export default function reducer(state = {}, action, shared) { 5 | switch (action.type) { 6 | case actions.TRACK_ADDED: 7 | return { 8 | ...state, 9 | [action.payload.track.id]: shared.toneAdapter.createVolume({ 10 | track: action.payload.track, 11 | }), 12 | }; 13 | case actions.TRACK_DELETION_ACCEPTED: 14 | return omit([action.payload.track.id], state); 15 | default: 16 | return state; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/getNoteLength.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/fp/compose'; 2 | import first from 'lodash/fp/first'; 3 | import getOr from 'lodash/fp/getOr'; 4 | import last from 'lodash/fp/last'; 5 | import { sizeToTime } from './sizeToTime'; 6 | 7 | export function getNoteLength(note, toneAdapter) { 8 | const start = compose( 9 | getOr(0, 'x'), 10 | first, 11 | getOr([], 'points'), 12 | )(note); 13 | const end = compose( 14 | getOr(start + 1, 'x'), 15 | last, 16 | getOr([], 'points'), 17 | )(note); 18 | 19 | return sizeToTime(end - start, toneAdapter); 20 | } 21 | -------------------------------------------------------------------------------- /src/instruments/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/fp/omit'; 2 | import * as actions from '../actions'; 3 | 4 | export default function reducer(state = {}, action, shared) { 5 | switch (action.type) { 6 | case actions.TRACK_ADDED: 7 | return { 8 | ...state, 9 | [action.payload.track.id]: shared.toneAdapter.createInstrument({ 10 | track: action.payload.track, 11 | }), 12 | }; 13 | case actions.TRACK_DELETION_ACCEPTED: 14 | return omit([action.payload.track.id], state); 15 | default: 16 | return state; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/part/mapEvents.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import invokeArgs from 'lodash/fp/invokeArgs'; 3 | import times from 'lodash/fp/times'; 4 | 5 | export function mapEvents(iteratee, part) { 6 | const length = getOr(0, 'length', part); 7 | const mappedEvents = []; 8 | 9 | times(index => { 10 | const event = invokeArgs('at', [index], part); 11 | mappedEvents[index] = iteratee(event, index); 12 | }, length); 13 | 14 | times(index => { 15 | const event = getOr({}, index, mappedEvents); 16 | invokeArgs('at', [index, event], part); 17 | }, length); 18 | } 19 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | import instrumentsEffects from './instruments/effects'; 2 | import partsEffects from './parts/effects'; 3 | import playbackStateEffects from './playbackState/effects'; 4 | import songEffects from './song/effects'; 5 | import transportPartEffects from './transportPart/effects'; 6 | import volumeNodesEffects from './volumeNodes/effects'; 7 | 8 | export default function effects(...args) { 9 | instrumentsEffects(...args); 10 | partsEffects(...args); 11 | playbackStateEffects(...args); 12 | songEffects(...args); 13 | transportPartEffects(...args); 14 | volumeNodesEffects(...args); 15 | } 16 | -------------------------------------------------------------------------------- /src/song/effects/handleTrackDeletionRequest.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../actions'; 3 | 4 | export function handleTrackDeletionRequest(getState, action, shared) { 5 | const track = getOr({}, 'payload.track', action); 6 | const id = getOr('', 'id', action); 7 | const instrument = getOr({}, `instruments[${id}]`, getState()); 8 | const volumeNode = getOr({}, `volumeNodes[${id}]`, getState()); 9 | 10 | shared.models.instrument.dispose(instrument); 11 | 12 | shared.models.volumeNode.dispose(volumeNode); 13 | 14 | shared.dispatch(actions.trackDeletionAccepted(track)); 15 | } 16 | -------------------------------------------------------------------------------- /src/parts/effects/startPart.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function startPart(getState, action, shared) { 5 | const measuresToTime = getOr(noop, 'helpers.measuresToTime', shared); 6 | const startAtTime = getOr(noop, 'models.part.startAtTime', shared); 7 | const sequence = getOr({}, 'payload.sequence', action); 8 | const sequenceId = getOr('', 'id', sequence); 9 | const position = getOr(0, 'position', sequence); 10 | const part = getOr({}, `parts[${sequenceId}]`, getState()); 11 | 12 | startAtTime(measuresToTime(position, shared.toneAdapter), part); 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/getNotesInArea.js: -------------------------------------------------------------------------------- 1 | import first from 'lodash/fp/first'; 2 | import isEqual from 'lodash/fp/isEqual'; 3 | 4 | const getIsInside = (start, end, target) => { 5 | const tx = target.x; 6 | const ty = target.y; 7 | const x1 = Math.min(start.x, end.x); 8 | const x2 = Math.max(start.x, end.x); 9 | const y1 = Math.min(start.y, end.y); 10 | const y2 = Math.max(start.y, end.y); 11 | 12 | return x1 <= tx && tx <= x2 && y1 <= ty && ty <= y2; 13 | }; 14 | 15 | export function getNotesInArea(start, end, allNotes) { 16 | if (isEqual(start, end)) return []; 17 | return allNotes.filter(n => getIsInside(start, end, first(n.points))); 18 | } 19 | -------------------------------------------------------------------------------- /src/song/effects/handleSongUpdate.js: -------------------------------------------------------------------------------- 1 | import deepDiff from 'deep-diff'; 2 | import { interpretDiff } from './interpretDiff'; 3 | 4 | export function handleSongUpdate(getState, action, shared) { 5 | const { prevSong, song } = action.payload; 6 | const differences = deepDiff(prevSong, song) || []; 7 | 8 | // console.group('handleSongUpdate'); 9 | // differences.forEach((d, i) => { 10 | // console.log(`D ${i + 1}: `, d); 11 | // console.log(`I ${i + 1}: `, interpretDiff(differences[i], song)); 12 | // }); 13 | // console.groupEnd('handleSongUpdate'); 14 | 15 | differences.forEach(diff => { 16 | shared.dispatch(interpretDiff(diff, song)); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import instrumentsReducer from './instruments/reducer'; 2 | import partsReducer from './parts/reducer'; 3 | import songReducer from './song/reducer'; 4 | import transportPartReducer from './transportPart/reducer'; 5 | import volumeNodesReducer from './volumeNodes/reducer'; 6 | 7 | export default function reducer(state, ...rest) { 8 | return { 9 | instruments: instrumentsReducer(state.instruments, ...rest), 10 | parts: partsReducer(state.parts, ...rest), 11 | song: songReducer(state.song, ...rest), 12 | transportPart: transportPartReducer(state.transportPart, ...rest), 13 | volumeNodes: volumeNodesReducer(state.volumeNodes, ...rest), 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/transportPart/effects/startTransportPart.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function startTransportPart(getState, action, shared) { 5 | const measuresToTime = getOr(noop, 'helpers.measuresToTime', shared); 6 | const startAtOffset = getOr(noop, 'models.part.startAtOffset', shared); 7 | const getLoopStartPoint = getOr(noop, 'selectors.getLoopStartPoint', shared); 8 | const loopStartPoint = getLoopStartPoint(getState()); 9 | const loopStartTime = measuresToTime(loopStartPoint, shared.toneAdapter); 10 | const transportPart = getOr({}, 'transportPart', getState()); 11 | 12 | startAtOffset(loopStartTime, transportPart); 13 | } 14 | -------------------------------------------------------------------------------- /src/parts/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/fp/omit'; 2 | import * as actions from '../actions'; 3 | 4 | export default function reducer(state = {}, action, shared) { 5 | switch (action.type) { 6 | case actions.SEQUENCE_ADDED: 7 | return { 8 | ...state, 9 | [action.payload.sequence.id]: shared.toneAdapter.createSequence({ 10 | length: action.payload.sequence.measureCount * 32, 11 | }), 12 | }; 13 | case actions.SEQUENCE_DELETION_ACCEPTED: 14 | // This corresponding part needs to be disposed before the reference is lost. 15 | return omit([action.payload.sequence.id], state); 16 | default: 17 | return state; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/models/part/__tests__/mapEvents.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { mapEvents } from '../mapEvents'; 4 | 5 | test('should invoke at method of part (part.length) times, passing the index each time, then it should invoke at again (part.length) times, passing the index and the result of iteratee called with the corresponding value returned by the last set of calls', t => { 6 | const part = { 7 | at: sinon.spy(i => i), 8 | length: 3, 9 | }; 10 | mapEvents((e, i) => `${e}!${i}`, part); 11 | t.deepEqual(part.at.getCall(3).args, [0, '0!0']); 12 | t.deepEqual(part.at.getCall(4).args, [1, '1!1']); 13 | t.deepEqual(part.at.getCall(5).args, [2, '2!2']); 14 | }); 15 | -------------------------------------------------------------------------------- /src/parts/effects/__tests__/stopPart.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { stopPart } from '../stopPart'; 4 | 5 | test('should invoke models.part.stop with part', t => { 6 | const expected = [{ id: 'a' }]; 7 | const stop = sinon.spy(); 8 | stopPart( 9 | () => ({ 10 | parts: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | sequence: { 17 | id: 'a', 18 | }, 19 | }, 20 | }, 21 | { 22 | models: { 23 | part: { 24 | stop, 25 | }, 26 | }, 27 | }, 28 | ); 29 | const result = stop.lastCall.args; 30 | t.deepEqual(result, expected); 31 | }); 32 | -------------------------------------------------------------------------------- /src/parts/effects/__tests__/acceptSequenceDeletion.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import * as actions from '../../../actions'; 4 | import { acceptSequenceDeletion } from '../acceptSequenceDeletion'; 5 | 6 | test('should invoke dispatch with actions.sequenceDeletionAccepted(sequence)', t => { 7 | const expected = [actions.sequenceDeletionAccepted({ id: 'a' })]; 8 | const dispatch = sinon.spy(); 9 | acceptSequenceDeletion( 10 | () => ({}), 11 | { 12 | payload: { 13 | sequence: { id: 'a' }, 14 | }, 15 | }, 16 | { 17 | dispatch, 18 | }, 19 | ); 20 | const result = dispatch.lastCall.args; 21 | t.deepEqual(result, expected); 22 | }); 23 | -------------------------------------------------------------------------------- /src/instruments/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { handleNotePlay } from './handleNotePlay'; 3 | import { handlePartStepTriggered } from './handlePartStepTriggered'; 4 | import { handleTrackVoiceEdit } from './handleTrackVoiceEdit'; 5 | 6 | export default function effects(getState, action, shared) { 7 | switch (action.type) { 8 | case actions.NOTE_PLAYED: 9 | handleNotePlay(getState, action, shared); 10 | break; 11 | case actions.PART_STEP_TRIGGERED: 12 | handlePartStepTriggered(getState, action, shared); 13 | break; 14 | case actions.TRACK_VOICE_EDITED: 15 | handleTrackVoiceEdit(getState, action, shared); 16 | break; 17 | default: 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/parts/effects/__tests__/disposePart.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { disposePart } from '../disposePart'; 4 | 5 | test('should invoke models.part.dispose with part', t => { 6 | const expected = [{ id: 'a' }]; 7 | const dispose = sinon.spy(); 8 | disposePart( 9 | () => ({ 10 | parts: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | sequence: { 17 | id: 'a', 18 | }, 19 | }, 20 | }, 21 | { 22 | models: { 23 | part: { 24 | dispose, 25 | }, 26 | }, 27 | }, 28 | ); 29 | const result = dispose.lastCall.args; 30 | t.deepEqual(result, expected); 31 | }); 32 | -------------------------------------------------------------------------------- /src/selectors/getLoopEndPoint.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import isEmpty from 'lodash/fp/isEmpty'; 3 | 4 | export const getLoopEndPoint = state => { 5 | const focusedSequenceId = getOr('', 'song.focusedSequenceId', state); 6 | const focusedSequence = getOr( 7 | {}, 8 | `song.sequences[${focusedSequenceId}]`, 9 | state, 10 | ); 11 | const measureCount = getOr(0, 'song.measureCount', state); 12 | 13 | if (isEmpty(focusedSequence)) { 14 | return measureCount; 15 | } 16 | 17 | const focusedSequencePosition = getOr(0, 'position', focusedSequence); 18 | const focusedSequenceMeasureCount = getOr(0, 'measureCount', focusedSequence); 19 | 20 | return focusedSequencePosition + focusedSequenceMeasureCount; 21 | }; 22 | -------------------------------------------------------------------------------- /src/parts/effects/__tests__/disablePartLooping.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { disablePartLooping } from '../disablePartLooping'; 4 | 5 | test('should invoke models.part.disableLooping with part', t => { 6 | const expected = [{ id: 'a' }]; 7 | const disableLooping = sinon.spy(); 8 | disablePartLooping( 9 | () => ({ 10 | parts: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | sequence: { id: 'a' }, 17 | }, 18 | }, 19 | { 20 | models: { 21 | part: { 22 | disableLooping, 23 | }, 24 | }, 25 | }, 26 | ); 27 | const result = disableLooping.lastCall.args; 28 | t.deepEqual(result, expected); 29 | }); 30 | -------------------------------------------------------------------------------- /src/transportPart/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { disableTransportPartLooping } from './disableTransportPartLooping'; 3 | import { setTransportPartEvents } from './setTransportPartEvents'; 4 | import { setToneLoopPoints } from './setToneLoopPoints'; 5 | import { startTransportPart } from './startTransportPart'; 6 | 7 | export default function effects(getState, action, shared) { 8 | switch (action.type) { 9 | case actions.MEASURE_COUNT_EDITED: 10 | setTransportPartEvents(getState, action, shared); 11 | startTransportPart(getState, action, shared); 12 | disableTransportPartLooping(getState, action, shared); 13 | setToneLoopPoints(getState, action, shared); 14 | break; 15 | default: 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/playbackState/effects/__tests__/pausePlayback.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import * as actions from '../../../actions'; 4 | import * as constants from '../../../constants'; 5 | import { pausePlayback } from '../pausePlayback'; 6 | 7 | test('should invoke toneAdapter.pause, dispatch with actions.playbackStateSet(constants.PLAYBACK_STATES.PAUSED)', t => { 8 | const dispatch = sinon.spy(); 9 | const pause = sinon.spy(); 10 | pausePlayback( 11 | () => ({}), 12 | {}, 13 | { 14 | toneAdapter: { 15 | pause, 16 | }, 17 | dispatch, 18 | }, 19 | ); 20 | t.deepEqual(dispatch.lastCall.args, [ 21 | actions.playbackStateSet(constants.PLAYBACK_STATES.PAUSED), 22 | ]); 23 | t.deepEqual(pause.calledOnce, true); 24 | }); 25 | -------------------------------------------------------------------------------- /src/playbackState/effects/__tests__/startPlayback.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import * as actions from '../../../actions'; 4 | import * as constants from '../../../constants'; 5 | import { startPlayback } from '../startPlayback'; 6 | 7 | test('should invoke toneAdapter.start, dispatch with actions.playbackStateSet(constants.PLAYBACK_STATES.STARTED)', t => { 8 | const dispatch = sinon.spy(); 9 | const start = sinon.spy(); 10 | startPlayback( 11 | () => ({}), 12 | {}, 13 | { 14 | toneAdapter: { 15 | start, 16 | }, 17 | dispatch, 18 | }, 19 | ); 20 | t.deepEqual(dispatch.lastCall.args, [ 21 | actions.playbackStateSet(constants.PLAYBACK_STATES.STARTED), 22 | ]); 23 | t.deepEqual(start.calledOnce, true); 24 | }); 25 | -------------------------------------------------------------------------------- /src/transportPart/effects/__tests__/disableTransportPartLooping.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { disableTransportPartLooping } from '../disableTransportPartLooping'; 4 | 5 | test('should invoke models.part.disableLooping with getState().transportPart', t => { 6 | const expected = [{ id: 'a' }]; 7 | const disableLooping = sinon.spy(); 8 | disableTransportPartLooping( 9 | () => ({ 10 | transportPart: { id: 'a' }, 11 | }), 12 | {}, 13 | { 14 | models: { 15 | part: { 16 | disableLooping, 17 | }, 18 | }, 19 | selectors: { 20 | getLoopStartPoint: () => '0', 21 | }, 22 | }, 23 | ); 24 | const result = disableLooping.lastCall.args; 25 | t.deepEqual(result, expected); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/someNoteWillMoveOutside.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/fp/compose'; 2 | import get from 'lodash/fp/get'; 3 | import map from 'lodash/fp/map'; 4 | import some from 'lodash/fp/some'; 5 | import * as constants from '../constants'; 6 | import { addPoints } from './addPoints'; 7 | 8 | function isOutOfBounds(measureCount) { 9 | return some( 10 | point => 11 | point.x < 0 || 12 | point.x > measureCount * 8 * 4 - 1 || 13 | point.y < 0 || 14 | point.y > constants.OCTAVE_RANGE.length * 12 - 1, 15 | ); 16 | } 17 | 18 | export function someNoteWillMoveOutside(measureCount, delta, notes) { 19 | const hasPointOutside = compose( 20 | isOutOfBounds(measureCount), 21 | map(addPoints(delta)), 22 | get('points'), 23 | ); 24 | return some(hasPointOutside, notes); 25 | } 26 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretSequenceEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import last from 'lodash/fp/last'; 3 | import * as actions from '../../../actions'; 4 | 5 | export function interpretSequenceEditedDiff(diff) { 6 | const id = getOr([], 'path[1]', diff); 7 | const prevValue = getOr('', 'lhs', diff); 8 | const value = getOr('', 'rhs', diff); 9 | 10 | switch (last(getOr([], 'path', diff))) { 11 | case 'measureCount': 12 | return actions.sequenceMeasureCountEdited({ id, prevValue, value }); 13 | case 'position': 14 | return actions.sequencePositionEdited({ id, prevValue, value }); 15 | case 'trackId': 16 | return actions.sequenceTrackIdEdited({ id, prevValue, value }); 17 | default: 18 | return actions.unknown(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretNoteArrayEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import last from 'lodash/fp/last'; 3 | import * as actions from '../../../actions'; 4 | import * as constants from '../../../constants'; 5 | 6 | export function interpretNoteArrayEditedDiff(diff) { 7 | const id = getOr([], 'path[1]', diff); 8 | const index = getOr(-1, 'index', diff); 9 | const prevValue = getOr({}, 'item.lhs', diff); 10 | const value = getOr({}, 'item.rhs', diff); 11 | 12 | switch (last(getOr('', 'item.kind', diff))) { 13 | case constants.DIFF_KIND_D: 14 | return actions.notePointDeleted({ id, index, prevValue }); 15 | case constants.DIFF_KIND_N: 16 | return actions.notePointAdded({ id, index, value }); 17 | default: 18 | return actions.unknown(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/instruments/effects/__tests__/handleTrackVoiceEdit.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { handleTrackVoiceEdit } from '../handleTrackVoiceEdit'; 4 | 5 | test('should invoke models.instrument.setVoice with instrument, action.payload.value', t => { 6 | const expected = [{ id: 'a' }, 'guitar']; 7 | const setVoice = sinon.spy(); 8 | handleTrackVoiceEdit( 9 | () => ({ 10 | instruments: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | id: 'a', 17 | value: 'guitar', 18 | }, 19 | }, 20 | { 21 | models: { 22 | instrument: { 23 | setVoice, 24 | }, 25 | }, 26 | }, 27 | ); 28 | const result = setVoice.lastCall.args; 29 | t.deepEqual(result, expected); 30 | }); 31 | -------------------------------------------------------------------------------- /src/transportPart/effects/setToneLoopPoints.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | 4 | export function setToneLoopPoints(getState, action, shared) { 5 | const measuresToTime = getOr(noop, 'helpers.measuresToTime', shared); 6 | const getLoopEndPoint = getOr(noop, 'selectors.getLoopEndPoint', shared); 7 | const getLoopStartPoint = getOr(noop, 'selectors.getLoopStartPoint', shared); 8 | const setLoopPoints = getOr(noop, 'toneAdapter.setLoopPoints', shared); 9 | const loopEndPoint = getLoopEndPoint(getState()); 10 | const loopEndTime = measuresToTime(loopEndPoint, shared.toneAdapter); 11 | const loopStartPoint = getLoopStartPoint(getState()); 12 | const loopStartTime = measuresToTime(loopStartPoint, shared.toneAdapter); 13 | 14 | setLoopPoints(loopStartTime, loopEndTime); 15 | } 16 | -------------------------------------------------------------------------------- /configs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | jasmine: true, 5 | }, 6 | extends: [ 7 | 'airbnb-base', 8 | 'plugin:import/errors', 9 | 'plugin:import/warnings', 10 | // 'plugin:lodash-fp/recommended', 11 | ], 12 | parser: "babel-eslint", 13 | parserOptions: { 14 | ecmaFeatures: { 15 | experimentalObjectRestSpread: true, 16 | }, 17 | }, 18 | plugins: [ 19 | // 'lodash-fp', 20 | ], 21 | rules: { 22 | 'import/no-extraneous-dependencies': 0, 23 | 'import/prefer-default-export': 0, 24 | 'linebreak-style': 0, 25 | // 'lodash-fp/no-unused-result': 0, 26 | 'new-cap': 0, 27 | 'no-use-before-define': [ 28 | 'error', 29 | { 30 | classes: true, 31 | functions: false, 32 | }, 33 | ], 34 | 'prefer-const': 2, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /src/models/instrument/__tests__/playNote.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { playNote } from '../playNote'; 4 | 5 | test('should invoke triggerAttackRelease method on instrument with name, length, time', t => { 6 | const expected = ['C3', '(8 * 32n)', '(0 * 32n)']; 7 | const triggerAttackRelease = sinon.spy(); 8 | playNote({ triggerAttackRelease }, 'C3', '(8 * 32n)', '(0 * 32n)'); 9 | const result = triggerAttackRelease.lastCall.args; 10 | t.deepEqual(result, expected); 11 | }); 12 | 13 | test('should use "16n" as value for length when length is not defined', t => { 14 | const expected = ['C3', '16n', undefined]; 15 | const triggerAttackRelease = sinon.spy(); 16 | playNote({ triggerAttackRelease }, 'C3'); 17 | const result = triggerAttackRelease.lastCall.args; 18 | t.deepEqual(result, expected); 19 | }); 20 | -------------------------------------------------------------------------------- /src/playbackState/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { pausePlayback } from './pausePlayback'; 3 | import { setPosition } from './setPosition'; 4 | import { startPlayback } from './startPlayback'; 5 | import { stopPlayback } from './stopPlayback'; 6 | 7 | export default function effects(getState, action, shared) { 8 | switch (action.type) { 9 | case actions.PLAYBACK_PAUSE_REQUESTED: 10 | pausePlayback(getState, action, shared); 11 | break; 12 | case actions.PLAYBACK_START_REQUESTED: 13 | startPlayback(getState, action, shared); 14 | break; 15 | case actions.PLAYBACK_STOP_REQUESTED: 16 | stopPlayback(getState, action, shared); 17 | break; 18 | case actions.POSITION_SET_REQUESTED: 19 | setPosition(getState, action, shared); 20 | break; 21 | default: 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretNoteEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import last from 'lodash/fp/last'; 3 | import * as actions from '../../../actions'; 4 | 5 | export function interpretNoteEditedDiff(diff) { 6 | const id = getOr([], 'path[1]', diff); 7 | const index = getOr(-1, 'path[3]', diff); 8 | const property = last(getOr([], 'path', diff)); 9 | const prevValue = getOr('', 'lhs', diff); 10 | const value = getOr('', 'rhs', diff); 11 | 12 | switch (property) { 13 | case 'sequenceId': 14 | return actions.noteSequenceIdEdited({ id, prevValue, value }); 15 | case 'x': 16 | return actions.notePointXEdited({ id, index, prevValue, value }); 17 | case 'y': 18 | return actions.notePointYEdited({ id, index, prevValue, value }); 19 | default: 20 | return actions.unknown(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretSequencesDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | import * as constants from '../../../constants'; 4 | import { interpretSequenceAddedDiff } from './interpretSequenceAddedDiff'; 5 | import { interpretSequenceDeletedDiff } from './interpretSequenceDeletedDiff'; 6 | import { interpretSequenceEditedDiff } from './interpretSequenceEditedDiff'; 7 | 8 | export function interpretSequencesDiff(diff) { 9 | switch (getOr('', 'kind', diff)) { 10 | case constants.DIFF_KIND_D: 11 | return interpretSequenceDeletedDiff(diff); 12 | case constants.DIFF_KIND_E: 13 | return interpretSequenceEditedDiff(diff); 14 | case constants.DIFF_KIND_N: 15 | return interpretSequenceAddedDiff(diff); 16 | default: 17 | return actions.unknown(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretTracksDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | import * as constants from '../../../constants'; 4 | import { interpretTrackAddedDiff } from './interpretTrackAddedDiff'; 5 | import { interpretTrackDeletedDiff } from './interpretTrackDeletedDiff'; 6 | import { interpretTrackEditedDiff } from './interpretTrackEditedDiff'; 7 | 8 | export function interpretTracksDiff(diff, ...rest) { 9 | switch (getOr('', 'kind', diff)) { 10 | case constants.DIFF_KIND_D: 11 | return interpretTrackDeletedDiff(diff, ...rest); 12 | case constants.DIFF_KIND_E: 13 | return interpretTrackEditedDiff(diff, ...rest); 14 | case constants.DIFF_KIND_N: 15 | return interpretTrackAddedDiff(diff, ...rest); 16 | default: 17 | return actions.unknown(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/transportPart/effects/setTransportPartEvents.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | import * as actions from '../../actions'; 4 | 5 | export function setTransportPartEvents(getState, action, shared) { 6 | const dispatch = getOr(noop, 'dispatch', shared); 7 | const mapEvents = getOr(noop, 'models.part.mapEvents', shared); 8 | const transportPart = getOr({}, 'transportPart', getState()); 9 | 10 | mapEvents( 11 | (event, index) => ({ 12 | fn: payload => { 13 | const focusedSequenceId = getOr( 14 | '', 15 | 'song.focusedSequenceId', 16 | getState(), 17 | ); 18 | 19 | if (focusedSequenceId) return; 20 | 21 | dispatch(actions.positionSet(payload)); 22 | }, 23 | payload: index, 24 | }), 25 | transportPart, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.setLoopPoints.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should invoke Tone.Transport.setLoopPoints method with args', t => { 6 | const expected = ['a', 'b', 'c']; 7 | const setLoopPoints = sinon.spy(); 8 | const toneAdapter = createToneAdapter({ 9 | Transport: { 10 | setLoopPoints, 11 | }, 12 | }); 13 | toneAdapter.setLoopPoints('a', 'b', 'c'); 14 | const result = setLoopPoints.lastCall.args; 15 | t.deepEqual(result, expected); 16 | }); 17 | 18 | test('should set Tone.Transport.loop to "true"', t => { 19 | const expected = true; 20 | const Tone = {}; 21 | const toneAdapter = createToneAdapter(Tone); 22 | toneAdapter.setLoopPoints(); 23 | const result = Tone.Transport.loop; 24 | t.is(result, expected); 25 | }); 26 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretTrackEditedDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import last from 'lodash/fp/last'; 3 | import * as actions from '../../../actions'; 4 | 5 | export function interpretTrackEditedDiff(diff) { 6 | const id = getOr([], 'path[1]', diff); 7 | const prevValue = getOr('', 'lhs', diff); 8 | const value = getOr('', 'rhs', diff); 9 | 10 | switch (last(getOr([], 'path', diff))) { 11 | case 'isMuted': 12 | return actions.trackIsMutedEdited({ id, prevValue, value }); 13 | case 'isSoloing': 14 | return actions.trackIsSoloingEdited({ id, prevValue, value }); 15 | case 'voice': 16 | return actions.trackVoiceEdited({ id, prevValue, value }); 17 | case 'volume': 18 | return actions.trackVolumeEdited({ id, prevValue, value }); 19 | default: 20 | return actions.unknown(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/transportPart/effects/__tests__/startTransportPart.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { startTransportPart } from '../startTransportPart'; 4 | 5 | test('should invoke models.part.startAtOffset with helpers.measuresToTime(selectors.getLoopStartPoint)', t => { 6 | const expected = ['time:0', { id: 'a' }]; 7 | const startAtOffset = sinon.spy(); 8 | startTransportPart( 9 | () => ({ 10 | transportPart: { id: 'a' }, 11 | }), 12 | {}, 13 | { 14 | helpers: { 15 | measuresToTime: x => `time:${x}`, 16 | }, 17 | models: { 18 | part: { 19 | startAtOffset, 20 | }, 21 | }, 22 | selectors: { 23 | getLoopStartPoint: () => '0', 24 | }, 25 | }, 26 | ); 27 | const result = startAtOffset.lastCall.args; 28 | t.deepEqual(result, expected); 29 | }); 30 | -------------------------------------------------------------------------------- /src/parts/effects/__tests__/startPart.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { startPart } from '../startPart'; 4 | 5 | test('should invoke models.part.startAtTime with helpers.measuresToTime(sequence.position), part', t => { 6 | const expected = ['1!', { id: 'a' }]; 7 | const startAtTime = sinon.spy(); 8 | startPart( 9 | () => ({ 10 | parts: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | sequence: { 17 | id: 'a', 18 | position: 1, 19 | }, 20 | }, 21 | }, 22 | { 23 | helpers: { 24 | measuresToTime: x => `${x}!`, 25 | }, 26 | models: { 27 | part: { 28 | startAtTime, 29 | }, 30 | }, 31 | }, 32 | ); 33 | const result = startAtTime.lastCall.args; 34 | t.deepEqual(result, expected); 35 | }); 36 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export { addPoints } from './addPoints'; 2 | export { createNote } from './createNote'; 3 | export { createSequence } from './createSequence'; 4 | export { createSong } from './createSong'; 5 | export { createTrack } from './createTrack'; 6 | export { duplicateNotes } from './duplicateNotes'; 7 | export { getLetterFromPitch } from './getLetterFromPitch'; 8 | export { getNoteLength } from './getNoteLength'; 9 | export { getNotesInArea } from './getNotesInArea'; 10 | export { getPitchName } from './getPitchName'; 11 | export { getPointOffset } from './getPointOffset'; 12 | export { measuresToTime } from './measuresToTime'; 13 | export { resizeNote } from './resizeNote'; 14 | export { setAtIds } from './setAtIds'; 15 | export { sizeToTime } from './sizeToTime'; 16 | export { someNoteWillMoveOutside } from './someNoteWillMoveOutside'; 17 | export { translateNote } from './translateNote'; 18 | -------------------------------------------------------------------------------- /src/volumeNodes/effects/updateMuting.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | 3 | export function updateMuting(getState, action, shared) { 4 | const volumeNodes = getOr({}, 'volumeNodes', getState()); 5 | const anySolo = shared.selectors.getIsAnyTrackSoloing(getState()); 6 | 7 | Object.keys(volumeNodes).forEach(key => { 8 | const volumeNode = volumeNodes[key]; 9 | const isMuted = getOr(false, `song.tracks[${key}].isMuted`, getState()); 10 | const isSoloing = getOr(false, `song.tracks[${key}].isSoloing`, getState()); 11 | const isOneOfSomeSoloingTracks = anySolo && !isMuted && isSoloing; 12 | const noMutingOrSoloing = !anySolo && !isMuted && !isSoloing; 13 | 14 | if (isOneOfSomeSoloingTracks || noMutingOrSoloing) { 15 | shared.models.volumeNode.unmute(volumeNode); 16 | return; 17 | } 18 | 19 | shared.models.volumeNode.mute(volumeNode); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/playbackState/effects/setPosition.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import noop from 'lodash/fp/noop'; 3 | import * as actions from '../../actions'; 4 | 5 | export function setPosition(getState, action, shared) { 6 | const dispatch = getOr(noop, 'dispatch', shared); 7 | const sizeToTime = getOr(noop, 'helpers.sizeToTime', shared); 8 | const getLoopStartPoint = getOr(noop, 'selectors.getLoopStartPoint', shared); 9 | const setTransportPosition = getOr( 10 | noop, 11 | 'toneAdapter.setTransportPosition', 12 | shared, 13 | ); 14 | const loopStartPoint = getLoopStartPoint(getState()); 15 | const position = getOr(0, 'payload.position', action); 16 | const positionAsTime = sizeToTime( 17 | loopStartPoint * 32 + position - 1, 18 | shared.toneAdapter, 19 | ); 20 | 21 | setTransportPosition(positionAsTime); 22 | 23 | dispatch(actions.positionSet(position)); 24 | } 25 | -------------------------------------------------------------------------------- /src/playbackState/effects/__tests__/stopPlayback.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import * as actions from '../../../actions'; 4 | import * as constants from '../../../constants'; 5 | import { stopPlayback } from '../stopPlayback'; 6 | 7 | test('should invoke toneAdapter.stop, dispatch with actions.playbackStateSet(constants.PLAYBACK_STATES.STARTED), dispatch with actions.positionSetRequested(0)', t => { 8 | const dispatch = sinon.spy(); 9 | const stop = sinon.spy(); 10 | 11 | stopPlayback( 12 | () => ({}), 13 | {}, 14 | { 15 | toneAdapter: { 16 | stop, 17 | }, 18 | dispatch, 19 | }, 20 | ); 21 | t.deepEqual(dispatch.getCall(0).args, [ 22 | actions.playbackStateSet(constants.PLAYBACK_STATES.STOPPED), 23 | ]); 24 | t.deepEqual(dispatch.getCall(1).args, [actions.positionSetRequested(0)]); 25 | t.deepEqual(stop.calledOnce, true); 26 | }); 27 | -------------------------------------------------------------------------------- /src/song/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { handleBPMEdit } from './handleBPMEdit'; 3 | import { handleFocusedSequenceIdEdit } from './handleFocusedSequenceIdEdit'; 4 | import { handleSongUpdate } from './handleSongUpdate'; 5 | import { handleTrackDeletionRequest } from './handleTrackDeletionRequest'; 6 | 7 | export default function effects(getState, action, shared) { 8 | switch (action.type) { 9 | case actions.BPM_EDITED: 10 | handleBPMEdit(getState, action, shared); 11 | break; 12 | case actions.FOCUSED_SEQUENCE_ID_EDITED: 13 | handleFocusedSequenceIdEdit(getState, action, shared); 14 | break; 15 | case actions.SONG_UPDATED: 16 | handleSongUpdate(getState, action, shared); 17 | break; 18 | case actions.TRACK_DELETION_REQUESTED: 19 | handleTrackDeletionRequest(getState, action, shared); 20 | break; 21 | default: 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/transportPart/effects/__tests__/setToneLoopPoints.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { setToneLoopPoints } from '../setToneLoopPoints'; 4 | 5 | test('should invoke toneAdapter.setLoopPoints with helpers.measuresToTime(selectors.getLoopStartPoint) and helpers.measuresToTime(selectors.getLoopEndPoint)', t => { 6 | const expected = ['time:0', 'time:1']; 7 | const setLoopPoints = sinon.spy(); 8 | setToneLoopPoints( 9 | () => ({ 10 | transportPart: { id: 'a' }, 11 | }), 12 | {}, 13 | { 14 | helpers: { 15 | measuresToTime: x => `time:${x}`, 16 | }, 17 | selectors: { 18 | getLoopEndPoint: () => '1', 19 | getLoopStartPoint: () => '0', 20 | }, 21 | toneAdapter: { 22 | setLoopPoints, 23 | }, 24 | }, 25 | ); 26 | const result = setLoopPoints.lastCall.args; 27 | t.deepEqual(result, expected); 28 | }); 29 | -------------------------------------------------------------------------------- /src/instruments/effects/__tests__/handleNotePlay.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { handleNotePlay } from '../handleNotePlay'; 4 | 5 | test('should invoke models.instrument.playNote with instrument, name, length, time', t => { 6 | const expected = [{ id: 'a' }, 'C3', 2, '(0 * 32n)']; 7 | const playNote = sinon.spy(); 8 | handleNotePlay( 9 | () => ({ 10 | instruments: { 11 | a: { id: 'a' }, 12 | }, 13 | }), 14 | { 15 | payload: { 16 | length: 2, 17 | time: '(0 * 32n)', 18 | pitch: 'C', 19 | trackId: 'a', 20 | }, 21 | }, 22 | { 23 | helpers: { 24 | getPitchName: x => `${x}3`, 25 | }, 26 | models: { 27 | instrument: { 28 | playNote, 29 | }, 30 | }, 31 | }, 32 | ); 33 | const result = playNote.lastCall.args; 34 | t.deepEqual(result, expected); 35 | }); 36 | -------------------------------------------------------------------------------- /src/selectors/__tests__/getLoopStartPoint.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getLoopStartPoint } from '../getLoopStartPoint'; 3 | 4 | test('should return song.sequences[song.focusedSequenceId].position when song.sequences[song.focusedSequenceId] is not empty', t => { 5 | const expected = 2; 6 | const result = getLoopStartPoint({ 7 | song: { 8 | focusedSequenceId: 'b', 9 | sequences: { 10 | a: {}, 11 | b: { 12 | measureCount: 1, 13 | position: 2, 14 | }, 15 | }, 16 | }, 17 | }); 18 | t.is(result, expected); 19 | }); 20 | 21 | test('should return 0 when song.sequences[song.focusedSequenceId] is empty', t => { 22 | const expected = 0; 23 | const result = getLoopStartPoint({ 24 | song: { 25 | focusedSequenceId: 'b', 26 | measureCount: 4, 27 | sequences: { 28 | a: {}, 29 | b: {}, 30 | }, 31 | }, 32 | }); 33 | t.is(result, expected); 34 | }); 35 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/interpretNotesDiff.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | import * as constants from '../../../constants'; 4 | import { interpretNoteAddedDiff } from './interpretNoteAddedDiff'; 5 | import { interpretNoteArrayEditedDiff } from './interpretNoteArrayEditedDiff'; 6 | import { interpretNoteDeletedDiff } from './interpretNoteDeletedDiff'; 7 | import { interpretNoteEditedDiff } from './interpretNoteEditedDiff'; 8 | 9 | export function interpretNotesDiff(diff) { 10 | switch (getOr('', 'kind', diff)) { 11 | case constants.DIFF_KIND_A: 12 | return interpretNoteArrayEditedDiff(diff); 13 | case constants.DIFF_KIND_D: 14 | return interpretNoteDeletedDiff(diff); 15 | case constants.DIFF_KIND_E: 16 | return interpretNoteEditedDiff(diff); 17 | case constants.DIFF_KIND_N: 18 | return interpretNoteAddedDiff(diff); 19 | default: 20 | return actions.unknown(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/volumeNodes/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { handleTrackAdded } from './handleTrackAdded'; 3 | import { handleTrackVolumeEdit } from './handleTrackVolumeEdit'; 4 | import { updateMuting } from './updateMuting'; 5 | 6 | export default function effects(getState, action, shared) { 7 | switch (action.type) { 8 | case actions.TRACK_ADDED: 9 | handleTrackAdded(getState, action, shared); 10 | updateMuting(getState, action, shared); 11 | break; 12 | case actions.TRACK_DELETION_ACCEPTED: 13 | updateMuting(getState, action, shared); 14 | break; 15 | case actions.TRACK_IS_MUTED_EDITED: 16 | updateMuting(getState, action, shared); 17 | break; 18 | case actions.TRACK_IS_SOLOING_EDITED: 19 | updateMuting(getState, action, shared); 20 | break; 21 | case actions.TRACK_VOLUME_EDITED: 22 | handleTrackVolumeEdit(getState, action, shared); 23 | break; 24 | default: 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.createVolume.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should return instance of Tone.Volume ', t => { 6 | const expected = true; 7 | class Volume {} 8 | const toneAdapter = createToneAdapter({ 9 | Volume, 10 | }); 11 | const returnValue = toneAdapter.createVolume(); 12 | const result = returnValue instanceof Volume; 13 | t.is(result, expected); 14 | }); 15 | 16 | test('should invoke Tone.Volume constructor with options.track.volume', t => { 17 | const expected = [-5]; 18 | const constructor = sinon.spy(); 19 | class Volume { 20 | constructor(...args) { 21 | constructor(...args); 22 | } 23 | } 24 | const toneAdapter = createToneAdapter({ 25 | Volume, 26 | }); 27 | toneAdapter.createVolume({ 28 | track: { 29 | volume: -5, 30 | }, 31 | }); 32 | const result = constructor.lastCall.args; 33 | t.deepEqual(result, expected); 34 | }); 35 | -------------------------------------------------------------------------------- /src/helpers/createSong.js: -------------------------------------------------------------------------------- 1 | import shortid from 'shortid'; 2 | import * as constants from '../constants'; 3 | import { createNote } from './createNote'; 4 | import { createSequence } from './createSequence'; 5 | import { createTrack } from './createTrack'; 6 | 7 | const initialTracks = [createTrack()]; 8 | 9 | const initialSequences = [createSequence(initialTracks[0].id)]; 10 | 11 | const initialNotes = [ 12 | createNote(initialSequences[0].id, [{ x: 2, y: 40 }, { x: 3, y: 40 }]), 13 | ]; 14 | 15 | export function createSong() { 16 | return { 17 | bpm: constants.DEFAULT_BPM, 18 | id: shortid.generate(), 19 | measureCount: constants.DEFAULT_MEASURE_COUNT, 20 | name: constants.DEFAULT_SONG_NAME, 21 | notes: initialNotes.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), 22 | sequences: initialSequences.reduce( 23 | (acc, cur) => ({ ...acc, [cur.id]: cur }), 24 | {}, 25 | ), 26 | tracks: initialTracks.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/selectors/__tests__/getLoopEndPoint.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getLoopEndPoint } from '../getLoopEndPoint'; 3 | 4 | test('should return sum of song.sequences[song.focusedSequenceId].position and song.sequences[song.focusedSequenceId].measureCount when song.sequences[song.focusedSequenceId] is not empty', t => { 5 | const expected = 3; 6 | const result = getLoopEndPoint({ 7 | song: { 8 | focusedSequenceId: 'b', 9 | sequences: { 10 | a: {}, 11 | b: { 12 | measureCount: 1, 13 | position: 2, 14 | }, 15 | }, 16 | }, 17 | }); 18 | t.is(result, expected); 19 | }); 20 | 21 | test('should return song.measureCount when song.sequences[song.focusedSequenceId] is empty', t => { 22 | const expected = 4; 23 | const result = getLoopEndPoint({ 24 | song: { 25 | focusedSequenceId: 'b', 26 | measureCount: 4, 27 | sequences: { 28 | a: {}, 29 | b: {}, 30 | }, 31 | }, 32 | }); 33 | t.is(result, expected); 34 | }); 35 | -------------------------------------------------------------------------------- /src/transportPart/effects/__tests__/setTransportPartEvents.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { setTransportPartEvents } from '../setTransportPartEvents'; 4 | import * as actions from '../../../actions'; 5 | 6 | test('should invoke models.part.mapEvents with function that returns { fn: function that invokes dispatch with actions.positionSet(payload) when focusedSequenceId is not defined, payload: index }, and getState().transportPart', t => { 7 | const dispatch = sinon.spy(); 8 | const mapEvents = sinon.spy(); 9 | setTransportPartEvents( 10 | () => ({ 11 | transportPart: { id: 'a' }, 12 | }), 13 | {}, 14 | { 15 | models: { 16 | part: { 17 | mapEvents, 18 | }, 19 | }, 20 | dispatch, 21 | }, 22 | ); 23 | t.deepEqual(mapEvents.lastCall.args[1], { id: 'a' }); 24 | const event = mapEvents.lastCall.args[0](null, 0); 25 | t.is(event.payload, 0); 26 | event.fn('a'); 27 | t.deepEqual(dispatch.lastCall.args, [actions.positionSet('a')]); 28 | }); 29 | -------------------------------------------------------------------------------- /src/playbackState/effects/__tests__/setPosition.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import * as actions from '../../../actions'; 4 | import { setPosition } from '../setPosition'; 5 | 6 | test('should invoke toneAdapter.setTransportPosition with loopStartPoint + position - 1 as time, dispatch with actions.playbackStateSet(constants.PLAYBACK_STATES.STARTED)', t => { 7 | const dispatch = sinon.spy(); 8 | const setTransportPosition = sinon.spy(); 9 | setPosition( 10 | () => ({ 11 | loopStartPoint: 1, 12 | }), 13 | { 14 | payload: { 15 | position: 1, 16 | }, 17 | }, 18 | { 19 | helpers: { 20 | sizeToTime: x => `${x}!`, 21 | }, 22 | toneAdapter: { 23 | setTransportPosition, 24 | }, 25 | selectors: { 26 | getLoopStartPoint: state => state.loopStartPoint, 27 | }, 28 | dispatch, 29 | }, 30 | ); 31 | t.deepEqual(dispatch.lastCall.args, [actions.positionSet(1)]); 32 | t.deepEqual(setTransportPosition.lastCall.args, ['32!']); 33 | }); 34 | -------------------------------------------------------------------------------- /src/instruments/effects/handlePartStepTriggered.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import isEmpty from 'lodash/fp/isEmpty'; 3 | import noop from 'lodash/fp/noop'; 4 | 5 | export function handlePartStepTriggered(getState, action, shared) { 6 | const getNoteLength = getOr(noop, 'helpers.getNoteLength', shared); 7 | const getPitchName = getOr(noop, 'helpers.getPitchName', shared); 8 | const playNote = getOr(noop, 'models.instrument.playNote', shared); 9 | const time = getOr(0, 'payload.time', action); 10 | const trackId = getOr('', 'payload.trackId', action); 11 | const instrument = getOr({}, `instruments[${trackId}]`, getState()); 12 | const noteIds = getOr([], 'payload.noteIds', action); 13 | 14 | noteIds.forEach(noteId => { 15 | const note = getOr({}, `song.notes[${noteId}]`, getState()); 16 | 17 | if (isEmpty(note)) return; 18 | 19 | const pitch = getOr(-1, 'points[0].y', note); 20 | const name = getPitchName(pitch); 21 | const length = getNoteLength(note, shared.toneAdapter); 22 | 23 | playNote(instrument, name, length, time); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nick Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.createSequence.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should return instance of Tone.Sequence ', t => { 6 | const expected = true; 7 | class Sequence {} 8 | const toneAdapter = createToneAdapter({ 9 | Sequence, 10 | Time: x => x, 11 | }); 12 | const returnValue = toneAdapter.createSequence(); 13 | const result = returnValue instanceof Sequence; 14 | t.is(result, expected); 15 | }); 16 | 17 | test('should invoke Tone.Sequence constructor with correct onSequenceStep method, range of numbers with length === length, "32n"', t => { 18 | const onSequenceStep = () => {}; 19 | const expected = [onSequenceStep, [0, 1, 2], '32n']; 20 | const constructor = sinon.spy(); 21 | class Sequence { 22 | constructor(...args) { 23 | constructor(...args); 24 | } 25 | } 26 | const toneAdapter = { 27 | ...createToneAdapter({ Sequence, Time: x => x }), 28 | onSequenceStep, 29 | }; 30 | toneAdapter.createSequence({ 31 | length: 3, 32 | }); 33 | const result = constructor.lastCall.args; 34 | t.deepEqual(result, expected); 35 | }); 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | dist 61 | 62 | # parcel cache 63 | .cache 64 | -------------------------------------------------------------------------------- /src/song/effects/interpretDiff/index.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import * as actions from '../../../actions'; 3 | import { interpretBPMEditedDiff } from './interpretBPMEditedDiff'; 4 | import { interpretFocusedSequenceIdEditedDiff } from './interpretFocusedSequenceIdEditedDiff'; 5 | import { interpretMeasureCountEditedDiff } from './interpretMeasureCountEditedDiff'; 6 | import { interpretNotesDiff } from './interpretNotesDiff'; 7 | import { interpretSequencesDiff } from './interpretSequencesDiff'; 8 | import { interpretTracksDiff } from './interpretTracksDiff'; 9 | 10 | export function interpretDiff(diff, ...rest) { 11 | switch (getOr('', 'path[0]', diff)) { 12 | case 'bpm': 13 | return interpretBPMEditedDiff(diff, ...rest); 14 | case 'focusedSequenceId': 15 | return interpretFocusedSequenceIdEditedDiff(diff, ...rest); 16 | case 'measureCount': 17 | return interpretMeasureCountEditedDiff(diff, ...rest); 18 | case 'notes': 19 | return interpretNotesDiff(diff, ...rest); 20 | case 'sequences': 21 | return interpretSequencesDiff(diff, ...rest); 22 | case 'tracks': 23 | return interpretTracksDiff(diff, ...rest); 24 | default: 25 | return actions.unknown(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/instruments/effects/__tests__/handlePartStepTriggered.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { handlePartStepTriggered } from '../handlePartStepTriggered'; 4 | 5 | test('should invoke models.instrument.playNote once for each noteId with instrument, name, length, time', t => { 6 | const playNote = sinon.spy(); 7 | handlePartStepTriggered( 8 | () => ({ 9 | instruments: { 10 | a: { id: 'a' }, 11 | }, 12 | song: { 13 | notes: { 14 | a: { 15 | length: 2, 16 | points: [{ y: 'C' }], 17 | }, 18 | b: { 19 | length: 3, 20 | points: [{ y: 'D' }], 21 | }, 22 | }, 23 | }, 24 | }), 25 | { 26 | payload: { 27 | length: 2, 28 | time: '(0 * 32n)', 29 | noteIds: ['a', 'b'], 30 | trackId: 'a', 31 | }, 32 | }, 33 | { 34 | helpers: { 35 | getNoteLength: x => x.length, 36 | getPitchName: x => `${x}3`, 37 | }, 38 | models: { 39 | instrument: { 40 | playNote, 41 | }, 42 | }, 43 | }, 44 | ); 45 | t.deepEqual(playNote.getCall(0).args, [{ id: 'a' }, 'C3', 2, '(0 * 32n)']); 46 | t.deepEqual(playNote.getCall(1).args, [{ id: 'a' }, 'D3', 3, '(0 * 32n)']); 47 | }); 48 | -------------------------------------------------------------------------------- /src/parts/effects/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../actions'; 2 | import { acceptSequenceDeletion } from './acceptSequenceDeletion'; 3 | import { disablePartLooping } from './disablePartLooping'; 4 | import { disposePart } from './disposePart'; 5 | import { reloadSequence } from './reloadSequence'; 6 | import { setPartEventsByNoteId } from './setPartEventsByNoteId'; 7 | import { setPartEvents } from './setPartEvents'; 8 | import { startPart } from './startPart'; 9 | 10 | export default function effects(getState, action, shared) { 11 | switch (action.type) { 12 | case actions.NOTE_ADDED: 13 | case actions.NOTE_DELETED: 14 | case actions.NOTE_POINT_ADDED: 15 | case actions.NOTE_POINT_DELETED: 16 | case actions.NOTE_POINT_X_EDITED: 17 | case actions.NOTE_POINT_Y_EDITED: 18 | setPartEventsByNoteId(getState, action, shared); 19 | break; 20 | case actions.SEQUENCE_ADDED: 21 | setPartEvents(getState, action, shared); 22 | startPart(getState, action, shared); 23 | disablePartLooping(getState, action, shared); 24 | break; 25 | case actions.SEQUENCE_DELETION_REQUESTED: 26 | disposePart(getState, action, shared); 27 | acceptSequenceDeletion(getState, action, shared); 28 | break; 29 | case actions.SEQUENCE_MEASURE_COUNT_EDITED: 30 | reloadSequence(getState, action, shared); 31 | break; 32 | case actions.SEQUENCE_POSITION_EDITED: 33 | reloadSequence(getState, action, shared); 34 | break; 35 | default: 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/toneAdapter/__tests__/toneAdapter.createInstrument.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { createToneAdapter } from '../index'; 4 | 5 | test('should return instance of Tone.PolySynth', t => { 6 | const expected = true; 7 | class PolySynth {} 8 | const toneAdapter = createToneAdapter({ 9 | PolySynth, 10 | }); 11 | const returnValue = toneAdapter.createInstrument(); 12 | const result = returnValue instanceof PolySynth; 13 | t.is(result, expected); 14 | }); 15 | 16 | test('should invoke Tone.PolySynth constructor with 5', t => { 17 | const expected = [5]; 18 | const constructor = sinon.spy(); 19 | class PolySynth { 20 | constructor(...args) { 21 | constructor(...args); 22 | } 23 | } 24 | const toneAdapter = createToneAdapter({ 25 | PolySynth, 26 | }); 27 | toneAdapter.createInstrument(); 28 | const result = constructor.lastCall.args; 29 | t.deepEqual(result, expected); 30 | }); 31 | 32 | test('should invoke set method of Tone.PolySynth with { oscillator: { type: options.track.voice } }', t => { 33 | const expected = [ 34 | { 35 | oscillator: { 36 | type: 'foo', 37 | }, 38 | }, 39 | ]; 40 | const set = sinon.spy(); 41 | class PolySynth { 42 | set = set; 43 | } 44 | const toneAdapter = createToneAdapter({ 45 | PolySynth, 46 | }); 47 | toneAdapter.createInstrument({ 48 | track: { 49 | voice: 'foo', 50 | }, 51 | }); 52 | const result = set.lastCall.args; 53 | t.deepEqual(result, expected); 54 | }); 55 | -------------------------------------------------------------------------------- /src/parts/effects/setPartEvents.js: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/fp/filter'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import noop from 'lodash/fp/noop'; 4 | import times from 'lodash/fp/times'; 5 | import * as actions from '../../actions'; 6 | 7 | export function setPartEvents(getState, action, shared) { 8 | const sequence = getOr({}, 'payload.sequence', action); 9 | const sequenceId = getOr('', 'id', sequence); 10 | const trackId = getOr('', 'trackId', sequence); 11 | const allNotes = getOr({}, 'song.notes', getState()); 12 | const notesInSequence = filter(n => n.sequenceId === sequenceId, allNotes); 13 | const part = getOr({ at: noop }, `parts[${sequenceId}]`, getState()); 14 | 15 | times(i => { 16 | const notesAtStep = filter(note => { 17 | const notePosition = getOr(-1, 'points[0].x', note); 18 | return notePosition === i; 19 | }, notesInSequence); 20 | const noteIdsAtStep = notesAtStep.map(getOr('', 'id')); 21 | 22 | const fn = (payload, time) => { 23 | const focusedSequenceId = getOr('', 'song.focusedSequenceId', getState()); 24 | 25 | if (focusedSequenceId !== '' && focusedSequenceId === sequenceId) { 26 | shared.dispatch(actions.positionSet(i)); 27 | } 28 | 29 | shared.dispatch( 30 | actions.partStepTriggered({ 31 | noteIds: payload.noteIds, 32 | trackId: payload.trackId, 33 | time, 34 | }), 35 | ); 36 | }; 37 | const payload = { 38 | noteIds: noteIdsAtStep, 39 | i, 40 | trackId, 41 | }; 42 | 43 | part.at(i, { fn, payload }); 44 | }, part.length); 45 | } 46 | -------------------------------------------------------------------------------- /src/instruments/__tests__/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as actions from '../../actions'; 3 | import reducer from '../reducer'; 4 | 5 | test('should return state with new instrument, with same id as track, merged in when action.type === TRACK_ADDED', t => { 6 | const expected = { 7 | a: { id: 'a' }, 8 | b: { id: 'b' }, 9 | }; 10 | const result = reducer( 11 | { 12 | a: { id: 'a' }, 13 | }, 14 | { 15 | type: actions.TRACK_ADDED, 16 | payload: { 17 | track: { 18 | id: 'b', 19 | otherKey: [], 20 | }, 21 | }, 22 | }, 23 | { 24 | toneAdapter: { 25 | createInstrument: args => ({ id: args.track.id }), 26 | }, 27 | }, 28 | ); 29 | t.deepEqual(result, expected); 30 | }); 31 | 32 | test('should return state without instrument with same id as track merged in when action.type === TRACK_DELETION_ACCEPTED', t => { 33 | const expected = { 34 | a: { id: 'a' }, 35 | }; 36 | const result = reducer( 37 | { 38 | a: { id: 'a' }, 39 | b: { id: 'b' }, 40 | }, 41 | { 42 | type: actions.TRACK_DELETION_ACCEPTED, 43 | payload: { 44 | track: { 45 | id: 'b', 46 | otherKey: [], 47 | }, 48 | }, 49 | }, 50 | ); 51 | t.deepEqual(result, expected); 52 | }); 53 | 54 | test('should return state when action.type is not handled', t => { 55 | const expected = { 56 | a: { id: 'a' }, 57 | }; 58 | const result = reducer( 59 | { 60 | a: { id: 'a' }, 61 | }, 62 | { 63 | type: actions.UNKNOWN, 64 | }, 65 | {}, 66 | ); 67 | t.deepEqual(result, expected); 68 | }); 69 | -------------------------------------------------------------------------------- /src/parts/effects/setPartEventsByNoteId.js: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/fp/filter'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import noop from 'lodash/fp/noop'; 4 | import times from 'lodash/fp/times'; 5 | import * as actions from '../../actions'; 6 | 7 | export function setPartEventsByNoteId(getState, action, shared) { 8 | const noteId = getOr('', 'payload.id', action); 9 | const note = getOr({}, `song.notes[${noteId}]`, getState()); 10 | const sequence = getOr({}, `song.sequences[${note.sequenceId}]`, getState()); 11 | const trackId = getOr('', 'trackId', sequence); 12 | const allNotes = getOr({}, 'song.notes', getState()); 13 | const notesInSequence = filter( 14 | n => n.sequenceId === note.sequenceId, 15 | allNotes, 16 | ); 17 | const part = getOr({ at: noop }, `parts[${note.sequenceId}]`, getState()); 18 | 19 | times(i => { 20 | const notesAtStep = filter(n => { 21 | const notePosition = getOr(-1, 'points[0].x', n); 22 | return notePosition === i; 23 | }, notesInSequence); 24 | const noteIdsAtStep = notesAtStep.map(getOr('', 'id')); 25 | 26 | const fn = (payload, time) => { 27 | const focusedSequenceId = getOr('', 'song.focusedSequenceId', getState()); 28 | 29 | if (focusedSequenceId !== '' && focusedSequenceId === note.sequenceId) { 30 | shared.dispatch(actions.positionSet(i)); 31 | } 32 | 33 | shared.dispatch( 34 | actions.partStepTriggered({ 35 | noteIds: payload.noteIds, 36 | trackId: payload.trackId, 37 | time, 38 | }), 39 | ); 40 | }; 41 | const payload = { 42 | noteIds: noteIdsAtStep, 43 | i, 44 | trackId, 45 | }; 46 | 47 | part.at(i, { fn, payload }); 48 | }, part.length); 49 | } 50 | -------------------------------------------------------------------------------- /examples/sampleSong.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bpm: 140, 3 | focusedSequenceId: '1', 4 | id: '0', 5 | measureCount: 2, 6 | notes: { 7 | 0: { 8 | id: '0', 9 | points: [{ x: 0, y: 47 }, { x: 3, y: 47 }], 10 | sequenceId: '0', 11 | }, 12 | // 1: { 13 | // id: '1', 14 | // points: [{ x: 4, y: 35 }, { x: 7, y: 35 }], 15 | // sequenceId: '0', 16 | // }, 17 | // 2: { 18 | // id: '2', 19 | // points: [{ x: 8, y: 47 }, { x: 11, y: 47 }], 20 | // sequenceId: '0', 21 | // }, 22 | // 3: { 23 | // id: '3', 24 | // points: [{ x: 12, y: 35 }, { x: 15, y: 35 }], 25 | // sequenceId: '0', 26 | // }, 27 | 4: { 28 | id: '4', 29 | points: [{ x: 0, y: 11 }, { x: 3, y: 11 }], 30 | sequenceId: '1', 31 | }, 32 | // 5: { 33 | // id: '5', 34 | // points: [{ x: 4, y: 23 }, { x: 7, y: 23 }], 35 | // sequenceId: '1', 36 | // }, 37 | // 6: { 38 | // id: '6', 39 | // points: [{ x: 8, y: 35 }, { x: 11, y: 35 }], 40 | // sequenceId: '1', 41 | // }, 42 | // 7: { 43 | // id: '7', 44 | // points: [{ x: 12, y: 47 }, { x: 15, y: 47 }], 45 | // sequenceId: '1', 46 | // }, 47 | // 8: { 48 | // id: '8', 49 | // points: [{ x: 32, y: 47 }, { x: 35, y: 47 }], 50 | // sequenceId: '0', 51 | // }, 52 | }, 53 | sequences: { 54 | 0: { 55 | id: '0', 56 | measureCount: 1, 57 | position: 0, 58 | trackId: '0', 59 | }, 60 | 1: { 61 | id: '1', 62 | measureCount: 1, 63 | position: 1, 64 | trackId: '1', 65 | }, 66 | }, 67 | title: 'Sample Song', 68 | tracks: { 69 | 0: { 70 | id: '0', 71 | isMuted: false, 72 | isSoloing: false, 73 | voice: 'square', 74 | volume: -5, 75 | }, 76 | 1: { 77 | id: '1', 78 | isMuted: false, 79 | isSoloing: false, 80 | voice: 'sawtooth', 81 | volume: -5, 82 | }, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /examples/sampleSongAlt.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bpm: 140, 3 | focusedSequenceId: '0', 4 | id: '0', 5 | measureCount: 2, 6 | notes: { 7 | 0: { 8 | id: '0', 9 | points: [{ x: 0, y: 49 }, { x: 3, y: 49 }], 10 | sequenceId: '0', 11 | }, 12 | // 1: { 13 | // id: '1', 14 | // points: [{ x: 4, y: 35 }, { x: 7, y: 35 }], 15 | // sequenceId: '0', 16 | // }, 17 | // 2: { 18 | // id: '2', 19 | // points: [{ x: 8, y: 47 }, { x: 11, y: 47 }], 20 | // sequenceId: '0', 21 | // }, 22 | // 3: { 23 | // id: '3', 24 | // points: [{ x: 12, y: 35 }, { x: 15, y: 35 }], 25 | // sequenceId: '0', 26 | // }, 27 | // 4: { 28 | // id: '4', 29 | // points: [{ x: 0, y: 11 }, { x: 3, y: 11 }], 30 | // sequenceId: '1', 31 | // }, 32 | // 5: { 33 | // id: '5', 34 | // points: [{ x: 4, y: 23 }, { x: 7, y: 23 }], 35 | // sequenceId: '1', 36 | // }, 37 | // 6: { 38 | // id: '6', 39 | // points: [{ x: 8, y: 35 }, { x: 11, y: 35 }], 40 | // sequenceId: '1', 41 | // }, 42 | // 7: { 43 | // id: '7', 44 | // points: [{ x: 12, y: 47 }, { x: 15, y: 47 }], 45 | // sequenceId: '1', 46 | // }, 47 | // 8: { 48 | // id: '8', 49 | // points: [{ x: 32, y: 47 }, { x: 35, y: 47 }], 50 | // sequenceId: '0', 51 | // }, 52 | }, 53 | sequences: { 54 | 0: { 55 | id: '0', 56 | measureCount: 1, 57 | position: 0, 58 | trackId: '0', 59 | }, 60 | 1: { 61 | id: '1', 62 | measureCount: 1, 63 | position: 1, 64 | trackId: '1', 65 | }, 66 | }, 67 | title: 'Sample Song', 68 | tracks: { 69 | 0: { 70 | id: '0', 71 | isMuted: false, 72 | isSoloing: false, 73 | voice: 'square', 74 | volume: -5, 75 | }, 76 | 1: { 77 | id: '1', 78 | isMuted: false, 79 | isSoloing: false, 80 | voice: 'sawtooth', 81 | volume: -5, 82 | }, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /src/__tests__/helpers.getLetterFromPitch.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getLetterFromPitch } from '../helpers/getLetterFromPitch'; 3 | 4 | test('should return "B" when pitch % 12 === 0', t => { 5 | const expected = 'B'; 6 | const result = getLetterFromPitch(36); 7 | t.is(result, expected); 8 | }); 9 | 10 | test('should return "A#" when pitch % 12 === 1', t => { 11 | const expected = 'A#'; 12 | const result = getLetterFromPitch(37); 13 | t.is(result, expected); 14 | }); 15 | 16 | test('should return "A" when pitch % 12 === 2', t => { 17 | const expected = 'A'; 18 | const result = getLetterFromPitch(38); 19 | t.is(result, expected); 20 | }); 21 | 22 | test('should return "G#" when pitch % 12 === 3', t => { 23 | const expected = 'G#'; 24 | const result = getLetterFromPitch(39); 25 | t.is(result, expected); 26 | }); 27 | 28 | test('should return "G" when pitch % 12 === 4', t => { 29 | const expected = 'G'; 30 | const result = getLetterFromPitch(40); 31 | t.is(result, expected); 32 | }); 33 | 34 | test('should return "F#" when pitch % 12 === 5', t => { 35 | const expected = 'F#'; 36 | const result = getLetterFromPitch(41); 37 | t.is(result, expected); 38 | }); 39 | 40 | test('should return "F" when pitch % 12 === 6', t => { 41 | const expected = 'F'; 42 | const result = getLetterFromPitch(42); 43 | t.is(result, expected); 44 | }); 45 | 46 | test('should return "E" when pitch % 12 === 7', t => { 47 | const expected = 'E'; 48 | const result = getLetterFromPitch(43); 49 | t.is(result, expected); 50 | }); 51 | 52 | test('should return "D#" when pitch % 12 === 8', t => { 53 | const expected = 'D#'; 54 | const result = getLetterFromPitch(44); 55 | t.is(result, expected); 56 | }); 57 | 58 | test('should return "D" when pitch % 12 === 9', t => { 59 | const expected = 'D'; 60 | const result = getLetterFromPitch(45); 61 | t.is(result, expected); 62 | }); 63 | 64 | test('should return "C#" when pitch % 12 === 10', t => { 65 | const expected = 'C#'; 66 | const result = getLetterFromPitch(46); 67 | t.is(result, expected); 68 | }); 69 | 70 | test('should return "C" when pitch % 12 === 11', t => { 71 | const expected = 'C'; 72 | const result = getLetterFromPitch(47); 73 | t.is(result, expected); 74 | }); 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dawww", 3 | "version": "0.0.0-development", 4 | "description": "Simple backend for a step sequencer", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist --ignore test.js", 8 | "trybuild": "babel src --ignore test.js", 9 | "clean": "rimraf dist", 10 | "lint": "prettier src --write \"{examples,src}/**/*.js\"", 11 | "prebuild": "npm run clean -s", 12 | "prepush": "npm run lint && npm run testonce && npm run trybuild", 13 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 14 | "start": "parcel ./examples/index.html", 15 | "test": "ava --watch", 16 | "testonce": "nyc ava" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "7.4.4", 20 | "@babel/core": "7.4.4", 21 | "@babel/plugin-proposal-class-properties": "7.4.4", 22 | "@babel/preset-env": "7.4.4", 23 | "@babel/register": "7.4.4", 24 | "ava": "1.4.1", 25 | "browser-env": "3.2.6", 26 | "codecov": "3.3.0", 27 | "husky": "0.14.3", 28 | "nyc": "14.1.0", 29 | "parcel": "1.12.3", 30 | "prettier": "1.17.0", 31 | "rimraf": "2.6.3", 32 | "semantic-release": "15.9.12", 33 | "sinon": "7.3.2", 34 | "superfine": "6.0.1" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/nickjohnson-dev/dawww.git" 39 | }, 40 | "ava": { 41 | "require": [ 42 | "@babel/register", 43 | "./test/helpers/setup-browser-env.js" 44 | ] 45 | }, 46 | "babel": { 47 | "plugins": [ 48 | "@babel/plugin-proposal-class-properties" 49 | ], 50 | "presets": [ 51 | "@babel/preset-env" 52 | ] 53 | }, 54 | "browserslist": [ 55 | ">0.2%", 56 | "not dead", 57 | "not ie <= 11", 58 | "not op_mini all" 59 | ], 60 | "eslintConfig": { 61 | "extends": [ 62 | "./configs/.eslintrc.js" 63 | ] 64 | }, 65 | "keywords": [], 66 | "author": "", 67 | "license": "ISC", 68 | "bugs": { 69 | "url": "https://github.com/nickjohnson-dev/dawww/issues" 70 | }, 71 | "homepage": "https://github.com/nickjohnson-dev/dawww#readme", 72 | "dependencies": { 73 | "deep-diff": "0.3.8", 74 | "event-emitter": "0.3.5", 75 | "lodash": "4.17.11", 76 | "shortid": "2.2.14", 77 | "tone": "13.4.9" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/toneAdapter/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import getOr from 'lodash/fp/getOr'; 3 | import invokeArgs from 'lodash/fp/invokeArgs'; 4 | import isFunction from 'lodash/fp/isFunction'; 5 | import range from 'lodash/fp/range'; 6 | // eslint-disable-next-line lodash-fp/use-fp 7 | import set from 'lodash/set'; 8 | 9 | export function createToneAdapter(Tone) { 10 | return { 11 | chainToMaster(source, ...rest) { 12 | invokeArgs('chain', [...rest, Tone.Master], source); 13 | }, 14 | 15 | createInstrument(options) { 16 | const voice = getOr('sine', 'track.voice', options); 17 | const instrument = new Tone.PolySynth(5); 18 | 19 | invokeArgs( 20 | 'set', 21 | [ 22 | { 23 | oscillator: { 24 | type: voice.toLowerCase(), 25 | }, 26 | }, 27 | ], 28 | instrument, 29 | ); 30 | 31 | return instrument; 32 | }, 33 | 34 | createSequence(options) { 35 | const length = getOr(0, 'length', options); 36 | const Sequence = getOr(Object, 'Sequence', Tone); 37 | 38 | return new Sequence( 39 | this.onSequenceStep, 40 | range(0, length), 41 | Tone.Time('32n'), 42 | ); 43 | }, 44 | 45 | createVolume(options) { 46 | const volume = getOr(0, 'track.volume', options); 47 | const Volume = getOr(Object, 'Volume', Tone); 48 | 49 | return new Volume(volume); 50 | }, 51 | 52 | onSequenceStep(time, step) { 53 | if (!isFunction(step.fn)) return; 54 | step.fn(step.payload, time); 55 | }, 56 | 57 | pause() { 58 | invokeArgs('Transport.pause', [], Tone); 59 | }, 60 | 61 | setBPM(value) { 62 | set(Tone, 'Transport.bpm.value', value); 63 | }, 64 | 65 | setLoopPoints(...args) { 66 | invokeArgs('Transport.setLoopPoints', args, Tone); 67 | set(Tone, 'Transport.loop', true); 68 | }, 69 | 70 | setTransportPosition(position) { 71 | set(Tone, 'Transport.position', position); 72 | }, 73 | 74 | start(...args) { 75 | invokeArgs('Transport.start', args, Tone); 76 | }, 77 | 78 | stop() { 79 | invokeArgs('Transport.pause', [], Tone); 80 | }, 81 | 82 | Time(...args) { 83 | return invokeArgs('Time', args, Tone); 84 | }, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import Tone from 'tone'; 3 | import { channels, emit, on } from './bus'; 4 | import * as actions from './actions'; 5 | import * as constants from './constants'; 6 | import * as helpers from './helpers'; 7 | import * as models from './models'; 8 | import * as selectors from './selectors'; 9 | import { getState, setState } from './state'; 10 | import effects from './effects'; 11 | import reducer from './reducer'; 12 | import { createToneAdapter } from './toneAdapter'; 13 | 14 | export default function Dawww(options) { 15 | const dispatch = emit(channels.ACTION_OCCURRED); 16 | const toneAdapter = createToneAdapter(Tone); 17 | const shared = { 18 | helpers, 19 | dispatch, 20 | models, 21 | selectors, 22 | toneAdapter, 23 | }; 24 | const updateSong = song => 25 | dispatch( 26 | actions.songUpdated({ 27 | prevSong: getOr({}, 'song', getState()), 28 | song, 29 | }), 30 | ); 31 | 32 | on(channels.ACTION_OCCURRED, action => { 33 | setState(reducer(getState(), action, shared)); 34 | 35 | // console.log('ACTION_OCCURRED', action, getState()); 36 | effects(getState, action, shared); 37 | 38 | if (action.type === actions.POSITION_SET) { 39 | emit(channels.POSITION_SET)(action.payload.position); 40 | } 41 | 42 | if (action.type === actions.PLAYBACK_STATE_SET) { 43 | emit(channels.PLAYBACK_STATE_SET)(action.payload.playbackState); 44 | } 45 | }); 46 | 47 | // Load initial song data 48 | updateSong( 49 | getOr( 50 | { 51 | notes: {}, 52 | sequences: {}, 53 | tracks: {}, 54 | }, 55 | 'song', 56 | options, 57 | ), 58 | ); 59 | 60 | return { 61 | onPositionChange: fn => on(channels.POSITION_SET, fn), 62 | onStateChange: fn => on(channels.PLAYBACK_STATE_SET, fn), 63 | pause: () => dispatch(actions.playbackPauseRequested()), 64 | preview: (trackId, pitch) => 65 | dispatch( 66 | actions.notePlayed({ 67 | pitch, 68 | trackId, 69 | }), 70 | ), 71 | setPosition: (...args) => dispatch(actions.positionSetRequested(...args)), 72 | start: () => dispatch(actions.playbackStartRequested()), 73 | stop: () => dispatch(actions.playbackStopRequested()), 74 | updateSong, 75 | }; 76 | } 77 | 78 | /* eslint-disable import/namespace */ 79 | Object.keys(constants).forEach(key => { 80 | Dawww[key] = constants[key]; 81 | }); 82 | Object.keys(helpers).forEach(key => { 83 | Dawww[key] = helpers[key]; 84 | }); 85 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'superfine'; 2 | import Tone from 'tone'; 3 | import Dawww from '../src'; 4 | import render from './render'; 5 | import sampleSong from './loadTestingSong.json'; 6 | import sampleSongAlt from './sampleSongAlt'; 7 | 8 | ['keydown', 'mousedown', 'touchdown'].forEach(eventName => { 9 | document.body.addEventListener(eventName, () => { 10 | Tone.start(); 11 | }); 12 | }); 13 | 14 | const dawww = Dawww({ 15 | song: sampleSong, 16 | }); 17 | 18 | const view = props => 19 | h('div', {}, [ 20 | h('div', {}, [props.playbackState]), 21 | h( 22 | 'div', 23 | { 24 | style: { 25 | backgroundColor: '#ddd', 26 | height: '20px', 27 | position: 'relative', 28 | width: `${sampleSong.measureCount * 64}px`, 29 | }, 30 | }, 31 | [ 32 | h( 33 | 'div', 34 | { 35 | style: { 36 | left: `${2 * props.position}px`, 37 | position: 'absolute', 38 | top: 0, 39 | width: '100px', 40 | }, 41 | }, 42 | [`| ${props.position}`], 43 | ), 44 | ], 45 | ), 46 | h('div', {}, [ 47 | h( 48 | 'button', 49 | { 50 | onclick: () => { 51 | if (props.playbackState === 'STARTED') { 52 | dawww.pause(); 53 | } else { 54 | dawww.start(); 55 | } 56 | }, 57 | }, 58 | [props.playbackState === 'STARTED' ? 'pause' : 'start'], 59 | ), 60 | h( 61 | 'button', 62 | { 63 | onclick: () => { 64 | dawww.stop(); 65 | }, 66 | }, 67 | ['stop'], 68 | ), 69 | h( 70 | 'button', 71 | { 72 | onclick: () => { 73 | dawww.updateSong(sampleSong); 74 | }, 75 | }, 76 | ['load song'], 77 | ), 78 | h( 79 | 'button', 80 | { 81 | onclick: () => { 82 | dawww.updateSong(sampleSongAlt); 83 | }, 84 | }, 85 | ['update song'], 86 | ), 87 | h( 88 | 'button', 89 | { 90 | onclick: () => { 91 | dawww.preview(0, 47); 92 | }, 93 | }, 94 | ['play C3'], 95 | ), 96 | h( 97 | 'button', 98 | { 99 | onclick: () => { 100 | dawww.setPosition(8); 101 | }, 102 | }, 103 | ['set position 8'], 104 | ), 105 | ]), 106 | ]); 107 | 108 | dawww.onPositionChange(position => { 109 | render(view, { position }); 110 | }); 111 | 112 | dawww.onStateChange(playbackState => { 113 | render(view, { playbackState }); 114 | }); 115 | 116 | render(view, { 117 | playbackState: 'STOPPED', 118 | position: 0, 119 | }); 120 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import { rangeStep } from 'lodash/fp'; 2 | 3 | export const BPM_RANGE = rangeStep(10, 60, 401); 4 | export const DEFAULT_BPM = 120; 5 | export const DEFAULT_MEASURE_COUNT = 4; 6 | export const DEFAULT_SONG_NAME = 'New Song'; 7 | export const DEFAULT_VOICE = 'SQUARE'; 8 | export const DIFF_KIND_A = 'A'; 9 | export const DIFF_KIND_D = 'D'; 10 | export const DIFF_KIND_E = 'E'; 11 | export const DIFF_KIND_N = 'N'; 12 | export const MAX_BPM = 400; 13 | export const MIN_BPM = 60; 14 | export const OCTAVE_RANGE = [0, 1, 2, 3, 4, 5, 6]; 15 | export const PLAYBACK_STATES = { 16 | PAUSED: 'PAUSED', 17 | STARTED: 'STARTED', 18 | STOPPED: 'STOPPED', 19 | }; 20 | export const SCALE = [ 21 | { name: 'B6', y: 0 }, 22 | { name: 'A#6', y: 1 }, 23 | { name: 'A6', y: 2 }, 24 | { name: 'G#6', y: 3 }, 25 | { name: 'G6', y: 4 }, 26 | { name: 'F#6', y: 5 }, 27 | { name: 'F6', y: 6 }, 28 | { name: 'E6', y: 7 }, 29 | { name: 'D#6', y: 8 }, 30 | { name: 'D6', y: 9 }, 31 | { name: 'C#6', y: 10 }, 32 | { name: 'C6', y: 11 }, 33 | { name: 'B5', y: 12 }, 34 | { name: 'A#5', y: 13 }, 35 | { name: 'A5', y: 14 }, 36 | { name: 'G#5', y: 15 }, 37 | { name: 'G5', y: 16 }, 38 | { name: 'F#5', y: 17 }, 39 | { name: 'F5', y: 18 }, 40 | { name: 'E5', y: 19 }, 41 | { name: 'D#5', y: 20 }, 42 | { name: 'D5', y: 21 }, 43 | { name: 'C#5', y: 22 }, 44 | { name: 'C5', y: 23 }, 45 | { name: 'B4', y: 24 }, 46 | { name: 'A#4', y: 25 }, 47 | { name: 'A4', y: 26 }, 48 | { name: 'G#4', y: 27 }, 49 | { name: 'G4', y: 28 }, 50 | { name: 'F#4', y: 29 }, 51 | { name: 'F4', y: 30 }, 52 | { name: 'E4', y: 31 }, 53 | { name: 'D#4', y: 32 }, 54 | { name: 'D4', y: 33 }, 55 | { name: 'C#4', y: 34 }, 56 | { name: 'C4', y: 35 }, 57 | { name: 'B3', y: 36 }, 58 | { name: 'A#3', y: 37 }, 59 | { name: 'A3', y: 38 }, 60 | { name: 'G#3', y: 39 }, 61 | { name: 'G3', y: 40 }, 62 | { name: 'F#3', y: 41 }, 63 | { name: 'F3', y: 42 }, 64 | { name: 'E3', y: 43 }, 65 | { name: 'D#3', y: 44 }, 66 | { name: 'D3', y: 45 }, 67 | { name: 'C#3', y: 46 }, 68 | { name: 'C3', y: 47 }, 69 | { name: 'B2', y: 48 }, 70 | { name: 'A#2', y: 49 }, 71 | { name: 'A2', y: 50 }, 72 | { name: 'G#2', y: 51 }, 73 | { name: 'G2', y: 52 }, 74 | { name: 'F#2', y: 53 }, 75 | { name: 'F2', y: 54 }, 76 | { name: 'E2', y: 55 }, 77 | { name: 'D#2', y: 56 }, 78 | { name: 'D2', y: 57 }, 79 | { name: 'C#2', y: 58 }, 80 | { name: 'C2', y: 59 }, 81 | { name: 'B1', y: 60 }, 82 | { name: 'A#1', y: 61 }, 83 | { name: 'A1', y: 62 }, 84 | { name: 'G#1', y: 63 }, 85 | { name: 'G1', y: 64 }, 86 | { name: 'F#1', y: 65 }, 87 | { name: 'F1', y: 66 }, 88 | { name: 'E1', y: 67 }, 89 | { name: 'D#1', y: 68 }, 90 | { name: 'D1', y: 69 }, 91 | { name: 'C#1', y: 70 }, 92 | { name: 'C1', y: 71 }, 93 | { name: 'B0', y: 72 }, 94 | { name: 'A#0', y: 73 }, 95 | { name: 'A0', y: 74 }, 96 | { name: 'G#0', y: 75 }, 97 | { name: 'G0', y: 76 }, 98 | { name: 'F#0', y: 77 }, 99 | { name: 'F0', y: 78 }, 100 | { name: 'E0', y: 79 }, 101 | { name: 'D#0', y: 80 }, 102 | { name: 'D0', y: 81 }, 103 | { name: 'C#0', y: 82 }, 104 | { name: 'C0', y: 83 }, 105 | ]; 106 | export const VOICES = { 107 | PWM: 'PWM', 108 | SAWTOOTH: 'SAWTOOTH', 109 | SINE: 'SINE', 110 | SQUARE: 'SQUARE', 111 | }; 112 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const BPM_EDITED = 'BPM_EDITED'; 2 | export const FOCUSED_SEQUENCE_ID_EDITED = 'FOCUSED_SEQUENCE_ID_EDITED'; 3 | export const MEASURE_COUNT_EDITED = 'MEASURE_COUNT_EDITED'; 4 | export const NOTE_ADDED = 'NOTE_ADDED'; 5 | export const NOTE_DELETED = 'NOTE_DELETED'; 6 | export const NOTE_PLAYED = 'NOTE_PLAYED'; 7 | export const NOTE_POINT_ADDED = 'NOTE_POINT_ADDED'; 8 | export const NOTE_POINT_DELETED = 'NOTE_POINT_DELETED'; 9 | export const NOTE_POINT_X_EDITED = 'NOTE_POINT_X_EDITED'; 10 | export const NOTE_POINT_Y_EDITED = 'NOTE_POINT_Y_EDITED'; 11 | export const NOTE_SEQUENCE_ID_EDITED = 'NOTE_SEQUENCE_ID_EDITED'; 12 | export const PART_STEP_TRIGGERED = 'PART_STEP_TRIGGERED'; 13 | export const PLAYBACK_PAUSE_REQUESTED = 'PLAYBACK_PAUSE_REQUESTED'; 14 | export const PLAYBACK_START_REQUESTED = 'PLAYBACK_START_REQUESTED'; 15 | export const PLAYBACK_STATE_SET = 'PLAYBACK_STATE_SET'; 16 | export const PLAYBACK_STOP_REQUESTED = 'PLAYBACK_STOP_REQUESTED'; 17 | export const POSITION_SET = 'POSITION_SET'; 18 | export const POSITION_SET_REQUESTED = 'POSITION_SET_REQUESTED'; 19 | export const SEQUENCE_ADDED = 'SEQUENCE_ADDED'; 20 | export const SEQUENCE_DELETION_ACCEPTED = 'SEQUENCE_DELETION_ACCEPTED'; 21 | export const SEQUENCE_DELETION_REQUESTED = 'SEQUENCE_DELETION_REQUESTED'; 22 | export const SEQUENCE_MEASURE_COUNT_EDITED = 'SEQUENCE_MEASURE_COUNT_EDITED'; 23 | export const SEQUENCE_POSITION_EDITED = 'SEQUENCE_POSITION_EDITED'; 24 | export const SEQUENCE_TRACK_ID_EDITED = 'SEQUENCE_TRACK_ID_EDITED'; 25 | export const SONG_UPDATED = 'SONG_UPDATED'; 26 | export const TRACK_ADDED = 'TRACK_ADDED'; 27 | export const TRACK_DELETION_ACCEPTED = 'TRACK_DELETION_ACCEPTED'; 28 | export const TRACK_DELETION_REQUESTED = 'TRACK_DELETION_REQUESTED'; 29 | export const TRACK_IS_MUTED_EDITED = 'TRACK_IS_MUTED_EDITED'; 30 | export const TRACK_IS_SOLOING_EDITED = 'TRACK_IS_SOLOING_EDITED'; 31 | export const TRACK_VOICE_EDITED = 'TRACK_VOICE_EDITED'; 32 | export const TRACK_VOLUME_EDITED = 'TRACK_VOLUME_EDITED'; 33 | export const UNKNOWN = 'UNKNOWN'; 34 | 35 | export const bpmEdited = bpm => ({ 36 | type: BPM_EDITED, 37 | payload: { bpm }, 38 | }); 39 | 40 | export const focusedSequenceIdEdited = focusedSequenceId => ({ 41 | type: FOCUSED_SEQUENCE_ID_EDITED, 42 | payload: { focusedSequenceId }, 43 | }); 44 | 45 | export const measureCountEdited = measureCount => ({ 46 | type: MEASURE_COUNT_EDITED, 47 | payload: { measureCount }, 48 | }); 49 | 50 | export const noteAdded = (note, id) => ({ 51 | type: NOTE_ADDED, 52 | payload: { id, note }, 53 | }); 54 | 55 | export const noteDeleted = (note, id) => ({ 56 | type: NOTE_DELETED, 57 | payload: { id, note }, 58 | }); 59 | 60 | export const notePlayed = ({ length, pitch, position, time, trackId }) => ({ 61 | type: NOTE_PLAYED, 62 | payload: { length, pitch, position, time, trackId }, 63 | }); 64 | 65 | export const notePointAdded = ({ id, index, value }) => ({ 66 | type: NOTE_POINT_ADDED, 67 | payload: { id, index, value }, 68 | }); 69 | 70 | export const notePointDeleted = ({ id, index, prevValue }) => ({ 71 | type: NOTE_POINT_DELETED, 72 | payload: { id, index, prevValue }, 73 | }); 74 | 75 | export const notePointXEdited = ({ id, index, prevValue, value }) => ({ 76 | type: NOTE_POINT_X_EDITED, 77 | payload: { id, index, prevValue, value }, 78 | }); 79 | 80 | export const notePointYEdited = ({ id, index, prevValue, value }) => ({ 81 | type: NOTE_POINT_Y_EDITED, 82 | payload: { id, index, prevValue, value }, 83 | }); 84 | 85 | export const noteSequenceIdEdited = ({ id, prevValue, value }) => ({ 86 | type: NOTE_SEQUENCE_ID_EDITED, 87 | payload: { id, prevValue, value }, 88 | }); 89 | 90 | export const partStepTriggered = ({ noteIds, time, trackId }) => ({ 91 | type: PART_STEP_TRIGGERED, 92 | payload: { noteIds, time, trackId }, 93 | }); 94 | 95 | export const playbackPauseRequested = () => ({ 96 | type: PLAYBACK_PAUSE_REQUESTED, 97 | }); 98 | 99 | export const playbackStartRequested = () => ({ 100 | type: PLAYBACK_START_REQUESTED, 101 | }); 102 | 103 | export const playbackStateSet = playbackState => ({ 104 | type: PLAYBACK_STATE_SET, 105 | payload: { playbackState }, 106 | }); 107 | 108 | export const playbackStopRequested = () => ({ 109 | type: PLAYBACK_STOP_REQUESTED, 110 | }); 111 | 112 | export const positionSet = position => ({ 113 | type: POSITION_SET, 114 | payload: { position }, 115 | }); 116 | 117 | export const positionSetRequested = position => ({ 118 | type: POSITION_SET_REQUESTED, 119 | payload: { position }, 120 | }); 121 | 122 | export const sequenceAdded = sequence => ({ 123 | type: SEQUENCE_ADDED, 124 | payload: { sequence }, 125 | }); 126 | 127 | export const sequenceDeletionAccepted = sequence => ({ 128 | type: SEQUENCE_DELETION_ACCEPTED, 129 | payload: { sequence }, 130 | }); 131 | 132 | export const sequenceDeletionRequested = sequence => ({ 133 | type: SEQUENCE_DELETION_REQUESTED, 134 | payload: { sequence }, 135 | }); 136 | 137 | export const sequenceMeasureCountEdited = ({ id, prevValue, value }) => ({ 138 | type: SEQUENCE_MEASURE_COUNT_EDITED, 139 | payload: { id, prevValue, value }, 140 | }); 141 | 142 | export const sequencePositionEdited = ({ id, prevValue, value }) => ({ 143 | type: SEQUENCE_POSITION_EDITED, 144 | payload: { id, prevValue, value }, 145 | }); 146 | 147 | export const sequenceTrackIdEdited = ({ id, prevValue, value }) => ({ 148 | type: SEQUENCE_TRACK_ID_EDITED, 149 | payload: { id, prevValue, value }, 150 | }); 151 | 152 | export const songUpdated = ({ prevSong, song }) => ({ 153 | type: SONG_UPDATED, 154 | payload: { prevSong, song }, 155 | }); 156 | 157 | export const trackAdded = ({ isAnyTrackSoloing, track }) => ({ 158 | type: TRACK_ADDED, 159 | payload: { isAnyTrackSoloing, track }, 160 | }); 161 | 162 | export const trackDeletionAccepted = track => ({ 163 | type: TRACK_DELETION_ACCEPTED, 164 | payload: { track }, 165 | }); 166 | 167 | export const trackDeletionRequested = track => ({ 168 | type: TRACK_DELETION_REQUESTED, 169 | payload: { track }, 170 | }); 171 | 172 | export const trackIsMutedEdited = ({ id, prevValue, value }) => ({ 173 | type: TRACK_IS_MUTED_EDITED, 174 | payload: { id, prevValue, value }, 175 | }); 176 | 177 | export const trackIsSoloingEdited = ({ id, prevValue, value }) => ({ 178 | type: TRACK_IS_SOLOING_EDITED, 179 | payload: { id, prevValue, value }, 180 | }); 181 | 182 | export const trackVoiceEdited = ({ id, prevValue, value }) => ({ 183 | type: TRACK_VOICE_EDITED, 184 | payload: { id, prevValue, value }, 185 | }); 186 | 187 | export const trackVolumeEdited = ({ id, prevValue, value }) => ({ 188 | type: TRACK_VOLUME_EDITED, 189 | payload: { id, prevValue, value }, 190 | }); 191 | 192 | export const unknown = () => ({ 193 | type: UNKNOWN, 194 | }); 195 | -------------------------------------------------------------------------------- /src/song/effects/__tests__/interpretDiff.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as actions from '../../../actions'; 3 | import * as constants from '../../../constants'; 4 | import { interpretDiff } from '../interpretDiff'; 5 | 6 | test('should return interpreted action when diff is BPM_EDITED type', t => { 7 | const expected = { 8 | type: actions.BPM_EDITED, 9 | payload: { 10 | bpm: 200, 11 | }, 12 | }; 13 | const result = interpretDiff({ 14 | kind: constants.DIFF_KIND_E, 15 | path: ['bpm'], 16 | rhs: 200, 17 | }); 18 | t.deepEqual(result, expected); 19 | }); 20 | 21 | test('should return interpreted action when diff is FOCUSED_SEQUENCE_ID_EDITED type', t => { 22 | const expected = { 23 | type: actions.FOCUSED_SEQUENCE_ID_EDITED, 24 | payload: { 25 | focusedSequenceId: 'a', 26 | }, 27 | }; 28 | const result = interpretDiff({ 29 | kind: constants.DIFF_KIND_E, 30 | path: ['focusedSequenceId'], 31 | rhs: 'a', 32 | }); 33 | t.deepEqual(result, expected); 34 | }); 35 | 36 | test('should return interpreted action when diff is MEASURE_COUNT_EDITED type', t => { 37 | const expected = { 38 | type: actions.MEASURE_COUNT_EDITED, 39 | payload: { 40 | measureCount: 4, 41 | }, 42 | }; 43 | const result = interpretDiff({ 44 | kind: constants.DIFF_KIND_E, 45 | path: ['measureCount'], 46 | rhs: 4, 47 | }); 48 | t.deepEqual(result, expected); 49 | }); 50 | 51 | test('should return interpreted action when diff is NOTE_ADDED type', t => { 52 | const expected = { 53 | type: actions.NOTE_ADDED, 54 | payload: { 55 | id: 'a', 56 | note: { id: 'a' }, 57 | }, 58 | }; 59 | const result = interpretDiff({ 60 | kind: constants.DIFF_KIND_N, 61 | path: ['notes'], 62 | rhs: { id: 'a' }, 63 | }); 64 | t.deepEqual(result, expected); 65 | }); 66 | 67 | test('should return interpreted action when diff is NOTE_DELETED type', t => { 68 | const expected = { 69 | type: actions.NOTE_DELETED, 70 | payload: { 71 | id: 'a', 72 | note: { id: 'a' }, 73 | }, 74 | }; 75 | const result = interpretDiff({ 76 | kind: constants.DIFF_KIND_D, 77 | lhs: { id: 'a' }, 78 | path: ['notes'], 79 | }); 80 | t.deepEqual(result, expected); 81 | }); 82 | 83 | test('should return interpreted action when diff is NOTE_POINT_ADDED type', t => { 84 | const expected = { 85 | type: actions.NOTE_POINT_ADDED, 86 | payload: { 87 | id: 'a', 88 | index: 13, 89 | value: { x: 10, y: 10 }, 90 | }, 91 | }; 92 | const result = interpretDiff({ 93 | index: 13, 94 | item: { 95 | kind: constants.DIFF_KIND_N, 96 | rhs: { x: 10, y: 10 }, 97 | }, 98 | kind: constants.DIFF_KIND_A, 99 | path: ['notes', 'a', 'points'], 100 | }); 101 | t.deepEqual(result, expected); 102 | }); 103 | 104 | test('should return interpreted action when diff is NOTE_POINT_DELETED type', t => { 105 | const expected = { 106 | type: actions.NOTE_POINT_DELETED, 107 | payload: { 108 | id: 'a', 109 | index: 13, 110 | prevValue: { x: 10, y: 10 }, 111 | }, 112 | }; 113 | const result = interpretDiff({ 114 | index: 13, 115 | item: { 116 | kind: constants.DIFF_KIND_D, 117 | lhs: { x: 10, y: 10 }, 118 | }, 119 | kind: constants.DIFF_KIND_A, 120 | path: ['notes', 'a', 'points'], 121 | }); 122 | t.deepEqual(result, expected); 123 | }); 124 | 125 | test('should return interpreted action when diff is NOTE_POINT_X_EDITED type', t => { 126 | const expected = { 127 | type: actions.NOTE_POINT_X_EDITED, 128 | payload: { 129 | id: 'a', 130 | index: 0, 131 | prevValue: 3, 132 | value: 4, 133 | }, 134 | }; 135 | const result = interpretDiff({ 136 | kind: constants.DIFF_KIND_E, 137 | lhs: 3, 138 | path: ['notes', 'a', 'points', 0, 'x'], 139 | rhs: 4, 140 | }); 141 | t.deepEqual(result, expected); 142 | }); 143 | 144 | test('should return interpreted action when diff is NOTE_POINT_Y_EDITED type', t => { 145 | const expected = { 146 | type: actions.NOTE_POINT_Y_EDITED, 147 | payload: { 148 | id: 'a', 149 | index: 0, 150 | prevValue: 3, 151 | value: 4, 152 | }, 153 | }; 154 | const result = interpretDiff({ 155 | kind: constants.DIFF_KIND_E, 156 | lhs: 3, 157 | path: ['notes', 'a', 'points', 0, 'y'], 158 | rhs: 4, 159 | }); 160 | t.deepEqual(result, expected); 161 | }); 162 | 163 | test('should return interpreted action when diff is NOTE_SEQUENCE_ID_EDITED type', t => { 164 | const expected = { 165 | type: actions.NOTE_SEQUENCE_ID_EDITED, 166 | payload: { 167 | id: 'a', 168 | prevValue: 'a', 169 | value: 'b', 170 | }, 171 | }; 172 | const result = interpretDiff({ 173 | kind: constants.DIFF_KIND_E, 174 | lhs: 'a', 175 | path: ['notes', 'a', 'sequenceId'], 176 | rhs: 'b', 177 | }); 178 | t.deepEqual(result, expected); 179 | }); 180 | 181 | test('should return interpreted action when diff is SEQUENCE_ADDED type', t => { 182 | const expected = { 183 | type: actions.SEQUENCE_ADDED, 184 | payload: { 185 | sequence: { id: 'a' }, 186 | }, 187 | }; 188 | const result = interpretDiff({ 189 | kind: constants.DIFF_KIND_N, 190 | path: ['sequences'], 191 | rhs: { id: 'a' }, 192 | }); 193 | t.deepEqual(result, expected); 194 | }); 195 | 196 | test('should return interpreted action when diff is SEQUENCE_DELETION_REQUESTED type', t => { 197 | const expected = { 198 | type: actions.SEQUENCE_DELETION_REQUESTED, 199 | payload: { 200 | sequence: { id: 'a' }, 201 | }, 202 | }; 203 | const result = interpretDiff({ 204 | kind: constants.DIFF_KIND_D, 205 | lhs: { id: 'a' }, 206 | path: ['sequences'], 207 | }); 208 | t.deepEqual(result, expected); 209 | }); 210 | 211 | test('should return interpreted action when diff is SEQUENCE_MEASURE_COUNT_EDITED type', t => { 212 | const expected = { 213 | type: actions.SEQUENCE_MEASURE_COUNT_EDITED, 214 | payload: { 215 | id: 'a', 216 | prevValue: 1, 217 | value: 4, 218 | }, 219 | }; 220 | const result = interpretDiff({ 221 | kind: constants.DIFF_KIND_E, 222 | lhs: 1, 223 | path: ['sequences', 'a', 'measureCount'], 224 | rhs: 4, 225 | }); 226 | t.deepEqual(result, expected); 227 | }); 228 | 229 | test('should return interpreted action when diff is SEQUENCE_POSITION_EDITED type', t => { 230 | const expected = { 231 | type: actions.SEQUENCE_POSITION_EDITED, 232 | payload: { 233 | id: 'a', 234 | prevValue: 1, 235 | value: 2, 236 | }, 237 | }; 238 | const result = interpretDiff({ 239 | kind: constants.DIFF_KIND_E, 240 | lhs: 1, 241 | path: ['sequences', 'a', 'position'], 242 | rhs: 2, 243 | }); 244 | t.deepEqual(result, expected); 245 | }); 246 | 247 | test('should return interpreted action when diff is SEQUENCE_TRACK_ID_EDITED type', t => { 248 | const expected = { 249 | type: actions.SEQUENCE_TRACK_ID_EDITED, 250 | payload: { 251 | id: 'a', 252 | prevValue: 'a', 253 | value: 'b', 254 | }, 255 | }; 256 | const result = interpretDiff({ 257 | kind: constants.DIFF_KIND_E, 258 | lhs: 'a', 259 | path: ['sequences', 'a', 'trackId'], 260 | rhs: 'b', 261 | }); 262 | t.deepEqual(result, expected); 263 | }); 264 | 265 | test('should return interpreted action when diff is TRACK_ADDED type', t => { 266 | const expected = { 267 | type: actions.TRACK_ADDED, 268 | payload: { 269 | isAnyTrackSoloing: true, 270 | track: { id: 'a' }, 271 | }, 272 | }; 273 | const result = interpretDiff( 274 | { 275 | kind: constants.DIFF_KIND_N, 276 | path: ['tracks'], 277 | rhs: { id: 'a' }, 278 | }, 279 | { 280 | tracks: { 281 | 0: { isSoloing: true }, 282 | 1: { isSoloing: false }, 283 | }, 284 | }, 285 | ); 286 | t.deepEqual(result, expected); 287 | }); 288 | 289 | test('should return interpreted action when diff is TRACK_DELETION_REQUESTED type', t => { 290 | const expected = { 291 | type: actions.TRACK_DELETION_REQUESTED, 292 | payload: { 293 | track: { id: 'a' }, 294 | }, 295 | }; 296 | const result = interpretDiff({ 297 | kind: constants.DIFF_KIND_D, 298 | lhs: { id: 'a' }, 299 | path: ['tracks'], 300 | }); 301 | t.deepEqual(result, expected); 302 | }); 303 | 304 | test('should return interpreted action when diff is TRACK_IS_MUTED_EDITED type', t => { 305 | const expected = { 306 | type: actions.TRACK_IS_MUTED_EDITED, 307 | payload: { 308 | id: 'a', 309 | prevValue: false, 310 | value: true, 311 | }, 312 | }; 313 | const result = interpretDiff({ 314 | kind: constants.DIFF_KIND_E, 315 | lhs: false, 316 | path: ['tracks', 'a', 'isMuted'], 317 | rhs: true, 318 | }); 319 | t.deepEqual(result, expected); 320 | }); 321 | 322 | test('should return interpreted action when diff is TRACK_IS_SOLOING_EDITED type', t => { 323 | const expected = { 324 | type: actions.TRACK_IS_SOLOING_EDITED, 325 | payload: { 326 | id: 'a', 327 | prevValue: false, 328 | value: true, 329 | }, 330 | }; 331 | const result = interpretDiff({ 332 | kind: constants.DIFF_KIND_E, 333 | lhs: false, 334 | path: ['tracks', 'a', 'isSoloing'], 335 | rhs: true, 336 | }); 337 | t.deepEqual(result, expected); 338 | }); 339 | 340 | test('should return interpreted action when diff is TRACK_VOICE_EDITED type', t => { 341 | const expected = { 342 | type: actions.TRACK_VOICE_EDITED, 343 | payload: { 344 | id: 'a', 345 | prevValue: 'sine', 346 | value: 'square', 347 | }, 348 | }; 349 | const result = interpretDiff({ 350 | kind: constants.DIFF_KIND_E, 351 | lhs: 'sine', 352 | path: ['tracks', 'a', 'voice'], 353 | rhs: 'square', 354 | }); 355 | t.deepEqual(result, expected); 356 | }); 357 | 358 | test('should return interpreted action when diff is TRACK_VOLUME_EDITED type', t => { 359 | const expected = { 360 | type: actions.TRACK_VOLUME_EDITED, 361 | payload: { 362 | id: 'a', 363 | prevValue: -10, 364 | value: -5, 365 | }, 366 | }; 367 | const result = interpretDiff({ 368 | kind: constants.DIFF_KIND_E, 369 | lhs: -10, 370 | path: ['tracks', 'a', 'volume'], 371 | rhs: -5, 372 | }); 373 | t.deepEqual(result, expected); 374 | }); 375 | 376 | test('should return interpreted action when diff is UNKNOWN type', t => { 377 | const expected = { 378 | type: actions.UNKNOWN, 379 | }; 380 | const result = interpretDiff({}); 381 | t.deepEqual(result, expected); 382 | }); 383 | -------------------------------------------------------------------------------- /examples/loadTestingSong.json: -------------------------------------------------------------------------------- 1 | {"bpm":170,"id":"BJDS-hIlE","measureCount":12,"name":"First Song","notes":{"B12QoH3O4":{"id":"B12QoH3O4","points":[{"x":108,"y":47},{"x":108,"y":47}],"sequenceId":"BJk4IHnO4"},"B12eczG33o4":{"id":"B12eczG33o4","points":[{"x":68,"y":47},{"x":68,"y":47}],"sequenceId":"S19zM3noV"},"B15NOrndV":{"id":"B15NOrndV","points":[{"x":16,"y":24},{"x":23,"y":24}],"sequenceId":"B1xaWwiZB4"},"B17GzkKyM4":{"id":"B17GzkKyM4","points":[{"x":16,"y":47},{"x":17,"y":47}],"sequenceId":"SkGzkKyz4"},"B17HTVbfN":{"id":"B17HTVbfN","points":[{"x":12,"y":25},{"x":13,"y":25}],"sequenceId":"B1z8hV-fE"},"B19Pzn3s4":{"id":"B19Pzn3s4","points":[{"x":4,"y":47},{"x":5,"y":47}],"sequenceId":"HJ1wzh2iV"},"B1D9RQJbX4":{"id":"B1D9RQJbX4","points":[{"x":38,"y":38},{"x":43,"y":38}],"sequenceId":"rk5Amy-QV"},"B1KCAkaGIV":{"id":"B1KCAkaGIV","points":[{"x":2,"y":31},{"x":3,"y":31}],"sequenceId":"ryRRypGUV"},"B1MBzGnhsN":{"id":"B1MBzGnhsN","points":[{"x":16,"y":36},{"x":23,"y":36}],"sequenceId":"BkHGzh3iN"},"B1M_NVy-7V":{"id":"B1M_NVy-7V","points":[{"x":28,"y":62},{"x":29,"y":62}],"sequenceId":"BJjZ8F8GN"},"B1Mn7sShd4":{"id":"B1Mn7sShd4","points":[{"x":100,"y":47},{"x":100,"y":47}],"sequenceId":"BJk4IHnO4"},"B1NMM1K1zE":{"id":"B1NMM1K1zE","points":[{"x":4,"y":40},{"x":5,"y":40}],"sequenceId":"SkGzkKyz4"},"B1Ngc0Xk-7E":{"id":"B1Ngc0Xk-7E","points":[{"x":26,"y":55},{"x":27,"y":55}],"sequenceId":"rk5Amy-QV"},"B1Nqfzn2s4":{"id":"B1Nqfzn2s4","points":[{"x":24,"y":47},{"x":24,"y":47}],"sequenceId":"S19zM3noV"},"B1P47JWm4":{"id":"B1P47JWm4","points":[{"x":54,"y":50},{"x":55,"y":50}],"sequenceId":"B1z8hV-fE"},"B1RHvFce4":{"id":"B1RHvFce4","points":[{"x":18,"y":28},{"x":19,"y":28}],"sequenceId":"BJxoEVpLeV"},"B1RZcfM3hjN":{"id":"B1RZcfM3hjN","points":[{"x":36,"y":16},{"x":37,"y":16}],"sequenceId":"S19zM3noV"},"B1UlcMM33sN":{"id":"B1UlcMM33sN","points":[{"x":4,"y":16},{"x":5,"y":16}],"sequenceId":"S19zM3noV"},"B1WhKrh_E":{"id":"B1WhKrh_E","points":[{"x":12,"y":47},{"x":12,"y":47}],"sequenceId":"BJk4IHnO4"},"B1Z3QoB2_4":{"id":"B1Z3QoB2_4","points":[{"x":104,"y":47},{"x":104,"y":47}],"sequenceId":"BJk4IHnO4"},"B1ejaFSnuN":{"id":"B1ejaFSnuN","points":[{"x":24,"y":47},{"x":24,"y":47}],"sequenceId":"BJk4IHnO4"},"B1fOM2hiN":{"id":"B1fOM2hiN","points":[{"x":16,"y":47},{"x":17,"y":47}],"sequenceId":"HJ1wzh2iV"},"B1fl5MM3hsV":{"id":"B1fl5MM3hsV","points":[{"x":36,"y":47},{"x":36,"y":47}],"sequenceId":"S19zM3noV"},"B1iQFHndE":{"id":"B1iQFHndE","points":[{"x":56,"y":16},{"x":57,"y":16}],"sequenceId":"BJk4IHnO4"},"B1lfTEbf4":{"id":"B1lfTEbf4","points":[{"x":0,"y":38},{"x":1,"y":38}],"sequenceId":"B1z8hV-fE"},"B1xzdfnhoV":{"id":"B1xzdfnhoV","points":[{"x":20,"y":47},{"x":21,"y":47}],"sequenceId":"HJ1wzh2iV"},"BJ09zy-m4":{"id":"BJ09zy-m4","points":[{"x":48,"y":40},{"x":49,"y":40}],"sequenceId":"B1z8hV-fE"},"BJ3-cMzh3sN":{"id":"BJ3-cMzh3sN","points":[{"x":88,"y":16},{"x":89,"y":16}],"sequenceId":"S19zM3noV"},"BJ3EMJb7E":{"id":"BJ3EMJb7E","points":[{"x":32,"y":34},{"x":37,"y":34}],"sequenceId":"B1z8hV-fE"},"BJ7f__JzV":{"id":"BJ7f__JzV","points":[{"x":22,"y":35},{"x":23,"y":35}],"sequenceId":"ry8FDu1GV"},"BJAqwdkfN":{"id":"BJAqwdkfN","points":[{"x":8,"y":47},{"x":9,"y":47}],"sequenceId":"ry8FDu1GV"},"BJDrHpS3ON":{"id":"BJDrHpS3ON","points":[{"x":24,"y":40},{"x":31,"y":40}],"sequenceId":"B1xaWwiZB4"},"BJF5zG2noV":{"id":"BJF5zG2noV","points":[{"x":116,"y":47},{"x":116,"y":47}],"sequenceId":"S19zM3noV"},"BJIFZqrhdN":{"id":"BJIFZqrhdN","points":[{"x":60,"y":47},{"x":60,"y":47}],"sequenceId":"BJk4IHnO4"},"BJJeBGf22oN":{"id":"BJJeBGf22oN","points":[{"x":48,"y":38},{"x":63,"y":38}],"sequenceId":"BkHGzh3iN"},"BJU-czfnnjE":{"id":"BJU-czfnnjE","points":[{"x":96,"y":47},{"x":96,"y":47}],"sequenceId":"S19zM3noV"},"BJdNyt1fV":{"id":"BJdNyt1fV","points":[{"x":8,"y":43},{"x":9,"y":43}],"sequenceId":"H1oXJY1MN"},"BJe-qMG3njE":{"id":"BJe-qMG3njE","points":[{"x":76,"y":16},{"x":77,"y":16}],"sequenceId":"S19zM3noV"},"BJsfz1FkGE":{"id":"BJsfz1FkGE","points":[{"x":24,"y":47},{"x":25,"y":47}],"sequenceId":"SkGzkKyz4"},"BJxCGkWmE":{"id":"BJxCGkWmE","points":[{"x":12,"y":55},{"x":13,"y":55}],"sequenceId":"B1z8hV-fE"},"BJxMzkYJzV":{"id":"BJxMzkYJzV","points":[{"x":26,"y":42},{"x":27,"y":42}],"sequenceId":"SkGzkKyz4"},"Bk49AQybmN":{"id":"Bk49AQybmN","points":[{"x":8,"y":26},{"x":10,"y":26}],"sequenceId":"rk5Amy-QV"},"Bk6-5Mf32jN":{"id":"Bk6-5Mf32jN","points":[{"x":80,"y":47},{"x":80,"y":47}],"sequenceId":"S19zM3noV"},"BkNRC1TG8V":{"id":"BkNRC1TG8V","points":[{"x":10,"y":31},{"x":11,"y":31}],"sequenceId":"ryRRypGUV"},"BkNe5Gf22i4":{"id":"BkNe5Gf22i4","points":[{"x":4,"y":47},{"x":4,"y":47}],"sequenceId":"S19zM3noV"},"BkXgqfGh2sE":{"id":"BkXgqfGh2sE","points":[{"x":68,"y":16},{"x":69,"y":16}],"sequenceId":"S19zM3noV"},"BkZb9GM22o4":{"id":"BkZb9GM22o4","points":[{"x":112,"y":47},{"x":112,"y":47}],"sequenceId":"S19zM3noV"},"Bkbqzz2hiE":{"id":"Bkbqzz2hiE","points":[{"x":100,"y":47},{"x":100,"y":47}],"sequenceId":"S19zM3noV"},"Bkg3FB2uV":{"id":"Bkg3FB2uV","points":[{"x":8,"y":47},{"x":8,"y":47}],"sequenceId":"BJk4IHnO4"},"BkjHTEZGE":{"id":"BkjHTEZGE","points":[{"x":20,"y":25},{"x":21,"y":25}],"sequenceId":"B1z8hV-fE"},"BkjxqzG2hoE":{"id":"BkjxqzG2hoE","points":[{"x":124,"y":47},{"x":124,"y":47}],"sequenceId":"S19zM3noV"},"Bkk-qRmJbQ4":{"id":"Bkk-qRmJbQ4","points":[{"x":58,"y":40},{"x":59,"y":40}],"sequenceId":"rk5Amy-QV"},"BknBz32iN":{"id":"BknBz32iN","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"BkkHMnnjV"},"Bkrl5GGhhsE":{"id":"Bkrl5GGhhsE","points":[{"x":56,"y":47},{"x":56,"y":47}],"sequenceId":"S19zM3noV"},"BkxqRmy-74":{"id":"BkxqRmy-74","points":[{"x":12,"y":25},{"x":13,"y":25}],"sequenceId":"rk5Amy-QV"},"BkxwEm1b7E":{"id":"BkxwEm1b7E","points":[{"x":56,"y":54},{"x":57,"y":54}],"sequenceId":"B1z8hV-fE"},"By890QJbQE":{"id":"By890QJbQE","points":[{"x":32,"y":34},{"x":37,"y":34}],"sequenceId":"rk5Amy-QV"},"By8Sfz22jN":{"id":"By8Sfz22jN","points":[{"x":64,"y":39},{"x":71,"y":39}],"sequenceId":"BkHGzh3iN"},"ByC5fMhniE":{"id":"ByC5fMhniE","points":[{"x":28,"y":16},{"x":29,"y":16}],"sequenceId":"S19zM3noV"},"ByDzGJK1MN":{"id":"ByDzGJK1MN","points":[{"x":6,"y":35},{"x":7,"y":35}],"sequenceId":"SkGzkKyz4"},"ByKWqMznhj4":{"id":"ByKWqMznhj4","points":[{"x":12,"y":16},{"x":13,"y":16}],"sequenceId":"S19zM3noV"},"ByMF-qB3ON":{"id":"ByMF-qB3ON","points":[{"x":44,"y":47},{"x":44,"y":47}],"sequenceId":"BJk4IHnO4"},"ByMbNsS2OE":{"id":"ByMbNsS2OE","points":[{"x":116,"y":47},{"x":116,"y":47}],"sequenceId":"BJk4IHnO4"},"ByTMzktkfN":{"id":"ByTMzktkfN","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"SkGzkKyz4"},"ByYIpEZG4":{"id":"ByYIpEZG4","points":[{"x":24,"y":28},{"x":25,"y":28}],"sequenceId":"B1z8hV-fE"},"ByZTSiS2_4":{"id":"ByZTSiS2_4","points":[{"x":120,"y":16},{"x":121,"y":16}],"sequenceId":"BJk4IHnO4"},"BycxcRXyWXE":{"id":"BycxcRXyWXE","points":[{"x":38,"y":50},{"x":39,"y":50}],"sequenceId":"rk5Amy-QV"},"Bync0Q1ZmE":{"id":"Bync0Q1ZmE","points":[{"x":60,"y":38},{"x":61,"y":38}],"sequenceId":"rk5Amy-QV"},"ByowMyZX4":{"id":"ByowMyZX4","points":[{"x":38,"y":37},{"x":43,"y":37}],"sequenceId":"B1z8hV-fE"},"ByxQ0v9txN":{"id":"ByxQ0v9txN","points":[{"x":10,"y":31},{"x":11,"y":31}],"sequenceId":"BJxoEVpLeV"},"ByzxSzM2hoV":{"id":"ByzxSzM2hoV","points":[{"x":80,"y":40},{"x":111,"y":40}],"sequenceId":"BkHGzh3iN"},"H10xqzMn2j4":{"id":"H10xqzMn2j4","points":[{"x":28,"y":47},{"x":28,"y":47}],"sequenceId":"S19zM3noV"},"H13qfG3ho4":{"id":"H13qfG3ho4","points":[{"x":32,"y":47},{"x":32,"y":47}],"sequenceId":"S19zM3noV"},"H16bCHhuE":{"id":"H16bCHhuE","points":[{"x":48,"y":47},{"x":48,"y":47}],"sequenceId":"BJk4IHnO4"},"H17-RS2d4":{"id":"H17-RS2d4","points":[{"x":32,"y":47},{"x":32,"y":47}],"sequenceId":"BJk4IHnO4"},"H1AGMyFJG4":{"id":"H1AGMyFJG4","points":[{"x":18,"y":42},{"x":19,"y":42}],"sequenceId":"SkGzkKyz4"},"H1BGz1KkMN":{"id":"H1BGz1KkMN","points":[{"x":12,"y":39},{"x":13,"y":39}],"sequenceId":"SkGzkKyz4"},"H1BSTH3_4":{"id":"H1BSTH3_4","points":[{"x":16,"y":36},{"x":23,"y":36}],"sequenceId":"B1xaWwiZB4"},"H1Dg9zz33iN":{"id":"H1Dg9zz33iN","points":[{"x":60,"y":16},{"x":61,"y":16}],"sequenceId":"S19zM3noV"},"H1GmCvctgN":{"id":"H1GmCvctgN","points":[{"x":26,"y":30},{"x":27,"y":30}],"sequenceId":"BJxoEVpLeV"},"H1MRRkpM8N":{"id":"H1MRRkpM8N","points":[{"x":16,"y":28},{"x":17,"y":28}],"sequenceId":"ryRRypGUV"},"H1NYZqShuE":{"id":"H1NYZqShuE","points":[{"x":52,"y":47},{"x":52,"y":47}],"sequenceId":"BJk4IHnO4"},"H1OHzGnniN":{"id":"H1OHzGnniN","points":[{"x":80,"y":28},{"x":111,"y":28}],"sequenceId":"BkHGzh3iN"},"H1VDN7JbXV":{"id":"H1VDN7JbXV","points":[{"x":40,"y":54},{"x":41,"y":54}],"sequenceId":"B1z8hV-fE"},"H1VSFrnd4":{"id":"H1VSFrnd4","points":[{"x":28,"y":16},{"x":29,"y":16}],"sequenceId":"BJk4IHnO4"},"H1XimKS3dV":{"id":"H1XimKS3dV","points":[{"x":44,"y":16},{"x":45,"y":16}],"sequenceId":"BJk4IHnO4"},"H1Z2ahCLgN":{"id":"H1Z2ahCLgN","points":[{"x":26,"y":42},{"x":27,"y":42}],"sequenceId":"HyeMmW28gN"},"H1bKW9H3d4":{"id":"H1bKW9H3d4","points":[{"x":40,"y":47},{"x":40,"y":47}],"sequenceId":"BJk4IHnO4"},"H1eerMfnhoN":{"id":"H1eerMfnhoN","points":[{"x":0,"y":47},{"x":7,"y":47}],"sequenceId":"BkHGzh3iN"},"H1gr62CLlV":{"id":"H1gr62CLlV","points":[{"x":14,"y":35},{"x":15,"y":35}],"sequenceId":"HyeMmW28gN"},"H1qSMz2njE":{"id":"H1qSMz2njE","points":[{"x":40,"y":40},{"x":47,"y":40}],"sequenceId":"BkHGzh3iN"},"H1sLvKqlN":{"id":"H1sLvKqlN","points":[{"x":28,"y":31},{"x":29,"y":31}],"sequenceId":"BJxoEVpLeV"},"H1x5aPqtx4":{"id":"H1x5aPqtx4","points":[{"x":8,"y":28},{"x":9,"y":28}],"sequenceId":"BJxoEVpLeV"},"H1zNdB2uE":{"id":"H1zNdB2uE","points":[{"x":8,"y":23},{"x":15,"y":23}],"sequenceId":"B1xaWwiZB4"},"HJ2YGJ-QN":{"id":"HJ2YGJ-QN","points":[{"x":44,"y":38},{"x":47,"y":38}],"sequenceId":"B1z8hV-fE"},"HJ7Z5fG3hi4":{"id":"HJ7Z5fG3hi4","points":[{"x":84,"y":47},{"x":84,"y":47}],"sequenceId":"S19zM3noV"},"HJ9qA71W7N":{"id":"HJ9qA71W7N","points":[{"x":52,"y":38},{"x":53,"y":38}],"sequenceId":"rk5Amy-QV"},"HJBcMz2njN":{"id":"HJBcMz2njN","points":[{"x":56,"y":16},{"x":57,"y":16}],"sequenceId":"S19zM3noV"},"HJCBzfnnj4":{"id":"HJCBzfnnj4","points":[{"x":24,"y":28},{"x":31,"y":28}],"sequenceId":"BkHGzh3iN"},"HJEI_HnO4":{"id":"HJEI_HnO4","points":[{"x":48,"y":26},{"x":63,"y":26}],"sequenceId":"B1xaWwiZB4"},"HJFr_Sh_V":{"id":"HJFr_Sh_V","points":[{"x":32,"y":25},{"x":39,"y":25}],"sequenceId":"B1xaWwiZB4"},"HJMvN71bQE":{"id":"HJMvN71bQE","points":[{"x":60,"y":55},{"x":61,"y":55}],"sequenceId":"B1z8hV-fE"},"HJUBHaBhOE":{"id":"HJUBHaBhOE","points":[{"x":64,"y":39},{"x":71,"y":39}],"sequenceId":"B1xaWwiZB4"},"HJUCCJaGUV":{"id":"HJUCCJaGUV","points":[{"x":28,"y":38},{"x":29,"y":38}],"sequenceId":"ryRRypGUV"},"HJY9CXy-XE":{"id":"HJY9CXy-XE","points":[{"x":48,"y":40},{"x":49,"y":40}],"sequenceId":"rk5Amy-QV"},"HJ_QOO1GE":{"id":"HJ_QOO1GE","points":[{"x":28,"y":42},{"x":29,"y":42}],"sequenceId":"ry8FDu1GV"},"HJfGMJFyfV":{"id":"HJfGMJFyfV","points":[{"x":22,"y":35},{"x":23,"y":35}],"sequenceId":"SkGzkKyz4"},"HJqff1YJzE":{"id":"HJqff1YJzE","points":[{"x":10,"y":42},{"x":11,"y":42}],"sequenceId":"SkGzkKyz4"},"HJr05SnuE":{"id":"HJr05SnuE","points":[{"x":92,"y":47},{"x":92,"y":47}],"sequenceId":"BJk4IHnO4"},"HJs5zfnnoE":{"id":"HJs5zfnnoE","points":[{"x":48,"y":47},{"x":48,"y":47}],"sequenceId":"S19zM3noV"},"HJspzk-m4":{"id":"HJspzk-m4","points":[{"x":6,"y":50},{"x":7,"y":50}],"sequenceId":"B1z8hV-fE"},"HJsrzMh3iE":{"id":"HJsrzMh3iE","points":[{"x":72,"y":42},{"x":79,"y":42}],"sequenceId":"BkHGzh3iN"},"HJxOp3C8lN":{"id":"HJxOp3C8lN","points":[{"x":22,"y":35},{"x":23,"y":35}],"sequenceId":"HyeMmW28gN"},"HJxYZ9Bnd4":{"id":"HJxYZ9Bnd4","points":[{"x":36,"y":47},{"x":36,"y":47}],"sequenceId":"BJk4IHnO4"},"HJyxczMnhoV":{"id":"HJyxczMnhoV","points":[{"x":44,"y":16},{"x":45,"y":16}],"sequenceId":"S19zM3noV"},"HJzu6hCUgE":{"id":"HJzu6hCUgE","points":[{"x":16,"y":47},{"x":17,"y":47}],"sequenceId":"HyeMmW28gN"},"Hk0dFBhO4":{"id":"Hk0dFBhO4","points":[{"x":68,"y":16},{"x":69,"y":16}],"sequenceId":"BJk4IHnO4"},"Hk1ntH3dN":{"id":"Hk1ntH3dN","points":[{"x":4,"y":47},{"x":4,"y":47}],"sequenceId":"BJk4IHnO4"},"Hk1p3AIgN":{"id":"Hk1p3AIgN","points":[{"x":4,"y":40},{"x":5,"y":40}],"sequenceId":"HyeMmW28gN"},"HkHY-qHhuE":{"id":"HkHY-qHhuE","points":[{"x":56,"y":47},{"x":56,"y":47}],"sequenceId":"BJk4IHnO4"},"HkOjOBnO4":{"id":"HkOjOBnO4","points":[{"x":4,"y":16},{"x":5,"y":16}],"sequenceId":"BJk4IHnO4"},"HkYeqGG3hs4":{"id":"HkYeqGG3hs4","points":[{"x":20,"y":47},{"x":20,"y":47}],"sequenceId":"S19zM3noV"},"Hkb5RXJW74":{"id":"Hkb5RXJW74","points":[{"x":0,"y":38},{"x":1,"y":38}],"sequenceId":"rk5Amy-QV"},"HkgH171ZmV":{"id":"HkgH171ZmV","points":[{"x":22,"y":50},{"x":23,"y":50}],"sequenceId":"B1z8hV-fE"},"HkgrrpS3OV":{"id":"HkgrrpS3OV","points":[{"x":8,"y":35},{"x":15,"y":35}],"sequenceId":"B1xaWwiZB4"},"HkoCCkafLV":{"id":"HkoCCkafLV","points":[{"x":8,"y":26},{"x":9,"y":26}],"sequenceId":"ryRRypGUV"},"HkqcGf3njE":{"id":"HkqcGf3njE","points":[{"x":120,"y":16},{"x":121,"y":16}],"sequenceId":"S19zM3noV"},"HkwCC1afI4":{"id":"HkwCC1afI4","points":[{"x":20,"y":36},{"x":21,"y":36}],"sequenceId":"ryRRypGUV"},"HkxgFCLeN":{"id":"HkxgFCLeN","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"ryDyKA8lN"},"HkylACkTzI4":{"id":"HkylACkTzI4","points":[{"x":26,"y":33},{"x":27,"y":33}],"sequenceId":"ryRRypGUV"},"HkzgqAQJ-Q4":{"id":"HkzgqAQJ-Q4","points":[{"x":22,"y":50},{"x":23,"y":50}],"sequenceId":"rk5Amy-QV"},"Hy1ecCQybQV":{"id":"Hy1ecCQybQV","points":[{"x":8,"y":54},{"x":9,"y":54}],"sequenceId":"rk5Amy-QV"},"Hy3fzJK1zN":{"id":"Hy3fzJK1zN","points":[{"x":30,"y":35},{"x":31,"y":35}],"sequenceId":"SkGzkKyz4"},"Hy5wtr3_4":{"id":"Hy5wtr3_4","points":[{"x":80,"y":28},{"x":111,"y":28}],"sequenceId":"B1xaWwiZB4"},"Hy8Gz1FkMN":{"id":"Hy8Gz1FkMN","points":[{"x":28,"y":39},{"x":29,"y":39}],"sequenceId":"SkGzkKyz4"},"HyAjw_JMN":{"id":"HyAjw_JMN","points":[{"x":14,"y":42},{"x":15,"y":42}],"sequenceId":"ry8FDu1GV"},"HyGiQYB3dE":{"id":"HyGiQYB3dE","points":[{"x":60,"y":16},{"x":61,"y":16}],"sequenceId":"BJk4IHnO4"},"HyGqMz22iV":{"id":"HyGqMz22iV","points":[{"x":12,"y":47},{"x":12,"y":47}],"sequenceId":"S19zM3noV"},"HyJ8uS3_N":{"id":"HyJ8uS3_N","points":[{"x":40,"y":28},{"x":47,"y":28}],"sequenceId":"B1xaWwiZB4"},"HyL9Gfn3i4":{"id":"HyL9Gfn3i4","points":[{"x":60,"y":47},{"x":60,"y":47}],"sequenceId":"S19zM3noV"},"HyM-R9H2u4":{"id":"HyM-R9H2u4","points":[{"x":72,"y":47},{"x":72,"y":47}],"sequenceId":"BJk4IHnO4"},"HyMMuMhnsN":{"id":"HyMMuMhnsN","points":[{"x":28,"y":47},{"x":29,"y":47}],"sequenceId":"HJ1wzh2iV"},"HyNHH6B3OV":{"id":"HyNHH6B3OV","points":[{"x":40,"y":40},{"x":47,"y":40}],"sequenceId":"B1xaWwiZB4"},"HyPhfJZQN":{"id":"HyPhfJZQN","points":[{"x":0,"y":50},{"x":1,"y":50}],"sequenceId":"B1z8hV-fE"},"HyQAEkb7N":{"id":"HyQAEkb7N","points":[{"x":20,"y":62},{"x":21,"y":62}],"sequenceId":"BJjZ8F8GN"},"HyQw47yZXV":{"id":"HyQw47yZXV","points":[{"x":38,"y":50},{"x":39,"y":50}],"sequenceId":"B1z8hV-fE"},"HySahAUeE":{"id":"HySahAUeE","points":[{"x":12,"y":39},{"x":13,"y":39}],"sequenceId":"HyeMmW28gN"},"HyWgBfM3hjV":{"id":"HyWgBfM3hjV","points":[{"x":72,"y":30},{"x":79,"y":30}],"sequenceId":"BkHGzh3iN"},"Hy_SHTr2u4":{"id":"Hy_SHTr2u4","points":[{"x":72,"y":42},{"x":79,"y":42}],"sequenceId":"B1xaWwiZB4"},"Hy_ZcGz3njN":{"id":"Hy_ZcGz3njN","points":[{"x":64,"y":47},{"x":64,"y":47}],"sequenceId":"S19zM3noV"},"HybSMz3niN":{"id":"HybSMz3niN","points":[{"x":24,"y":40},{"x":31,"y":40}],"sequenceId":"BkHGzh3iN"},"HybjptS2uN":{"id":"HybjptS2uN","points":[{"x":20,"y":47},{"x":20,"y":47}],"sequenceId":"BJk4IHnO4"},"HybzGJFkzV":{"id":"HybzGJFkzV","points":[{"x":14,"y":35},{"x":15,"y":35}],"sequenceId":"SkGzkKyz4"},"HyeeqAQkWXV":{"id":"HyeeqAQkWXV","points":[{"x":12,"y":55},{"x":13,"y":55}],"sequenceId":"rk5Amy-QV"},"HyiZcGGhhjN":{"id":"HyiZcGGhhjN","points":[{"x":0,"y":47},{"x":0,"y":47}],"sequenceId":"S19zM3noV"},"HylOBSyWQV":{"id":"HylOBSyWQV","points":[{"x":28,"y":62},{"x":29,"y":62}],"sequenceId":"SJdrH1ZQ4"},"Hyy_G3hoV":{"id":"Hyy_G3hoV","points":[{"x":8,"y":47},{"x":9,"y":47}],"sequenceId":"HJ1wzh2iV"},"S1-MdzhhsN":{"id":"S1-MdzhhsN","points":[{"x":24,"y":47},{"x":25,"y":47}],"sequenceId":"HJ1wzh2iV"},"S13l50Qy-m4":{"id":"S13l50Qy-m4","points":[{"x":44,"y":55},{"x":45,"y":55}],"sequenceId":"rk5Amy-QV"},"S17dHrJ-74":{"id":"S17dHrJ-74","points":[{"x":12,"y":62},{"x":13,"y":62}],"sequenceId":"SJdrH1ZQ4"},"S19Z5GM3no4":{"id":"S19Z5GM3no4","points":[{"x":122,"y":17},{"x":123,"y":17}],"sequenceId":"S19zM3noV"},"S1CzVkW74":{"id":"S1CzVkW74","points":[{"x":4,"y":62},{"x":5,"y":62}],"sequenceId":"BJjZ8F8GN"},"S1DiMk-XV":{"id":"S1DiMk-XV","points":[{"x":56,"y":43},{"x":57,"y":43}],"sequenceId":"B1z8hV-fE"},"S1HgqRmy-Q4":{"id":"S1HgqRmy-Q4","points":[{"x":28,"y":58},{"x":29,"y":58}],"sequenceId":"rk5Amy-QV"},"S1Ogq0QJ-7V":{"id":"S1Ogq0QJ-7V","points":[{"x":58,"y":57},{"x":59,"y":57}],"sequenceId":"rk5Amy-QV"},"S1Pv4mk-7V":{"id":"S1Pv4mk-7V","points":[{"x":32,"y":50},{"x":33,"y":50}],"sequenceId":"B1z8hV-fE"},"S1TARyazLE":{"id":"S1TARyazLE","points":[{"x":18,"y":31},{"x":19,"y":31}],"sequenceId":"ryRRypGUV"},"S1TBiH2uE":{"id":"S1TBiH2uE","points":[{"x":124,"y":16},{"x":125,"y":16}],"sequenceId":"BJk4IHnO4"},"S1ZOBHJ-7V":{"id":"S1ZOBHJ-7V","points":[{"x":20,"y":62},{"x":21,"y":62}],"sequenceId":"SJdrH1ZQ4"},"S1ax5zGh2oV":{"id":"S1ax5zGh2oV","points":[{"x":58,"y":17},{"x":59,"y":17}],"sequenceId":"S19zM3noV"},"S1dMMytkfN":{"id":"S1dMMytkfN","points":[{"x":2,"y":42},{"x":3,"y":42}],"sequenceId":"SkGzkKyz4"},"S1hG64ZzV":{"id":"S1hG64ZzV","points":[{"x":8,"y":26},{"x":10,"y":26}],"sequenceId":"B1z8hV-fE"},"S1jEyYyM4":{"id":"S1jEyYyM4","points":[{"x":12,"y":45},{"x":13,"y":45}],"sequenceId":"H1oXJY1MN"},"S1jlXJbQN":{"id":"S1jlXJbQN","points":[{"x":28,"y":58},{"x":29,"y":58}],"sequenceId":"B1z8hV-fE"},"S1l4Od1fV":{"id":"S1l4Od1fV","points":[{"x":30,"y":43},{"x":31,"y":43}],"sequenceId":"ry8FDu1GV"},"S1rBzf32oN":{"id":"S1rBzf32oN","points":[{"x":32,"y":25},{"x":39,"y":25}],"sequenceId":"BkHGzh3iN"},"S1rP4mk-mE":{"id":"S1rP4mk-mE","points":[{"x":44,"y":55},{"x":45,"y":55}],"sequenceId":"B1z8hV-fE"},"S1zH1Q1ZXV":{"id":"S1zH1Q1ZXV","points":[{"x":26,"y":55},{"x":27,"y":55}],"sequenceId":"B1z8hV-fE"},"SJ3T2RLeV":{"id":"SJ3T2RLeV","points":[{"x":28,"y":39},{"x":29,"y":39}],"sequenceId":"HyeMmW28gN"},"SJS1mJZ7E":{"id":"SJS1mJZ7E","points":[{"x":16,"y":54},{"x":17,"y":54}],"sequenceId":"B1z8hV-fE"},"SJW4jBnd4":{"id":"SJW4jBnd4","points":[{"x":124,"y":47},{"x":124,"y":47}],"sequenceId":"BJk4IHnO4"},"SJZZR9S2_N":{"id":"SJZZR9S2_N","points":[{"x":68,"y":47},{"x":68,"y":47}],"sequenceId":"BJk4IHnO4"},"SJgi7YShdN":{"id":"SJgi7YShdN","points":[{"x":58,"y":17},{"x":59,"y":17}],"sequenceId":"BJk4IHnO4"},"SJjgdB3_V":{"id":"SJjgdB3_V","points":[{"x":0,"y":35},{"x":7,"y":35}],"sequenceId":"B1xaWwiZB4"},"SJkz5zzn3j4":{"id":"SJkz5zzn3j4","points":[{"x":16,"y":47},{"x":16,"y":47}],"sequenceId":"S19zM3noV"},"SJnRo4WGE":{"id":"SJnRo4WGE","points":[{"x":6,"y":38},{"x":7,"y":38}],"sequenceId":"ry8FDu1GV"},"SJnfOOyMV":{"id":"SJnfOOyMV","points":[{"x":26,"y":40},{"x":27,"y":40}],"sequenceId":"ry8FDu1GV"},"SJoaKH3_N":{"id":"SJoaKH3_N","points":[{"x":28,"y":47},{"x":28,"y":47}],"sequenceId":"BJk4IHnO4"},"SJylffkF1M4":{"id":"SJylffkF1M4","points":[{"x":8,"y":47},{"x":9,"y":47}],"sequenceId":"SkGzkKyz4"},"SJzUDKqgV":{"id":"SJzUDKqgV","points":[{"x":22,"y":31},{"x":23,"y":31}],"sequenceId":"BJxoEVpLeV"},"Sk-wtH2_V":{"id":"Sk-wtH2_V","points":[{"x":64,"y":27},{"x":71,"y":27}],"sequenceId":"B1xaWwiZB4"},"Sk5oz1b7V":{"id":"Sk5oz1b7V","points":[{"x":60,"y":46},{"x":61,"y":46}],"sequenceId":"B1z8hV-fE"},"SkDxcC71-7N":{"id":"SkDxcC71-7N","points":[{"x":56,"y":54},{"x":57,"y":54}],"sequenceId":"rk5Amy-QV"},"SkGrCcH2dE":{"id":"SkGrCcH2dE","points":[{"x":88,"y":47},{"x":88,"y":47}],"sequenceId":"BJk4IHnO4"},"SkM5Rm1-QV":{"id":"SkM5Rm1-QV","points":[{"x":20,"y":25},{"x":21,"y":25}],"sequenceId":"rk5Amy-QV"},"SkO9RQy-7V":{"id":"SkO9RQy-7V","points":[{"x":44,"y":37},{"x":47,"y":37}],"sequenceId":"rk5Amy-QV"},"SkVKFSnu4":{"id":"SkVKFSnu4","points":[{"x":76,"y":16},{"x":77,"y":16}],"sequenceId":"BJk4IHnO4"},"Sk_fRBhdN":{"id":"Sk_fRBhdN","points":[{"x":112,"y":47},{"x":112,"y":47}],"sequenceId":"BJk4IHnO4"},"Skb7aVZMV":{"id":"Skb7aVZMV","points":[{"x":16,"y":22},{"x":17,"y":22}],"sequenceId":"B1z8hV-fE"},"SkbAqB2_N":{"id":"SkbAqB2_N","points":[{"x":76,"y":47},{"x":76,"y":47}],"sequenceId":"BJk4IHnO4"},"SkbS0qH3OE":{"id":"SkbS0qH3OE","points":[{"x":84,"y":47},{"x":84,"y":47}],"sequenceId":"BJk4IHnO4"},"SkbphA8lN":{"id":"SkbphA8lN","points":[{"x":6,"y":35},{"x":7,"y":35}],"sequenceId":"HyeMmW28gN"},"SkdywY9xV":{"id":"SkdywY9xV","points":[{"x":14,"y":26},{"x":15,"y":26}],"sequenceId":"BJxoEVpLeV"},"Skg9P_yME":{"id":"Skg9P_yME","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"ry8FDu1GV"},"SkqCCkpGIN":{"id":"SkqCCkpGIN","points":[{"x":12,"y":35},{"x":13,"y":35}],"sequenceId":"ryRRypGUV"},"Sy-v4mJWXE":{"id":"Sy-v4mJWXE","points":[{"x":58,"y":57},{"x":59,"y":57}],"sequenceId":"B1z8hV-fE"},"Sy76HsBn_V":{"id":"Sy76HsBn_V","points":[{"x":108,"y":16},{"x":109,"y":16}],"sequenceId":"BJk4IHnO4"},"Sy7EktyG4":{"id":"Sy7EktyG4","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"H1oXJY1MN"},"SyIx5RQkZXN":{"id":"SyIx5RQkZXN","points":[{"x":54,"y":50},{"x":55,"y":50}],"sequenceId":"rk5Amy-QV"},"SyKgFALeE":{"id":"SyKgFALeE","points":[{"x":8,"y":35},{"x":9,"y":35}],"sequenceId":"ryDyKA8lN"},"SyQS_H2O4":{"id":"SyQS_H2O4","points":[{"x":24,"y":28},{"x":31,"y":28}],"sequenceId":"B1xaWwiZB4"},"SyUv4XyWX4":{"id":"SyUv4XyWX4","points":[{"x":48,"y":54},{"x":49,"y":54}],"sequenceId":"B1z8hV-fE"},"SyXczMhhjV":{"id":"SyXczMhhjV","points":[{"x":104,"y":47},{"x":104,"y":47}],"sequenceId":"S19zM3noV"},"SyYRiEWzN":{"id":"SyYRiEWzN","points":[{"x":4,"y":43},{"x":5,"y":43}],"sequenceId":"ry8FDu1GV"},"SyZrBpBnOE":{"id":"SyZrBpBnOE","points":[{"x":48,"y":38},{"x":63,"y":38}],"sequenceId":"B1xaWwiZB4"},"SyfarjSn_V":{"id":"SyfarjSn_V","points":[{"x":100,"y":16},{"x":101,"y":16}],"sequenceId":"BJk4IHnO4"},"SyhhP9FlE":{"id":"SyhhP9FlE","points":[{"x":4,"y":26},{"x":5,"y":26}],"sequenceId":"BJxoEVpLeV"},"SyrBSTBnuE":{"id":"SyrBSTBnuE","points":[{"x":0,"y":47},{"x":7,"y":47}],"sequenceId":"B1xaWwiZB4"},"Syug5ffhnoV":{"id":"Syug5ffhnoV","points":[{"x":72,"y":47},{"x":72,"y":47}],"sequenceId":"S19zM3noV"},"r11-NT8eV":{"id":"r11-NT8eV","points":[{"x":2,"y":42},{"x":3,"y":42}],"sequenceId":"HyeMmW28gN"},"r17gBGGnnsE":{"id":"r17gBGGnnsE","points":[{"x":32,"y":37},{"x":39,"y":37}],"sequenceId":"BkHGzh3iN"},"r1BfRBhOV":{"id":"r1BfRBhOV","points":[{"x":96,"y":47},{"x":96,"y":47}],"sequenceId":"BJk4IHnO4"},"r1Hvz2noE":{"id":"r1Hvz2noE","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"HJ1wzh2iV"},"r1NZqGfh3j4":{"id":"r1NZqGfh3j4","points":[{"x":108,"y":16},{"x":109,"y":16}],"sequenceId":"S19zM3noV"},"r1O6nCUxE":{"id":"r1O6nCUxE","points":[{"x":20,"y":39},{"x":21,"y":39}],"sequenceId":"HyeMmW28gN"},"r1QCRyTf8V":{"id":"r1QCRyTf8V","points":[{"x":30,"y":43},{"x":31,"y":43}],"sequenceId":"ryRRypGUV"},"r1R5AXkZmN":{"id":"r1R5AXkZmN","points":[{"x":6,"y":50},{"x":7,"y":50}],"sequenceId":"rk5Amy-QV"},"r1R7EJbQE":{"id":"r1R7EJbQE","points":[{"x":12,"y":62},{"x":13,"y":62}],"sequenceId":"BJjZ8F8GN"},"r1T9AQJ-QN":{"id":"r1T9AQJ-QN","points":[{"x":0,"y":50},{"x":1,"y":50}],"sequenceId":"rk5Amy-QV"},"r1TecA7J-7N":{"id":"r1TecA7J-7N","points":[{"x":48,"y":54},{"x":49,"y":54}],"sequenceId":"rk5Amy-QV"},"r1VwKS3uE":{"id":"r1VwKS3uE","points":[{"x":72,"y":30},{"x":79,"y":30}],"sequenceId":"B1xaWwiZB4"},"r1WBTn08eN":{"id":"r1WBTn08eN","points":[{"x":10,"y":42},{"x":11,"y":42}],"sequenceId":"HyeMmW28gN"},"r1X9Cmkb7V":{"id":"r1X9Cmkb7V","points":[{"x":24,"y":28},{"x":25,"y":28}],"sequenceId":"rk5Amy-QV"},"r1Z-EiS3_V":{"id":"r1Z-EiS3_V","points":[{"x":120,"y":47},{"x":120,"y":47}],"sequenceId":"BJk4IHnO4"},"r1Zx9GG32s4":{"id":"r1Zx9GG32s4","points":[{"x":92,"y":47},{"x":92,"y":47}],"sequenceId":"S19zM3noV"},"r1bxcA7kW7V":{"id":"r1bxcA7kW7V","points":[{"x":16,"y":54},{"x":17,"y":54}],"sequenceId":"rk5Amy-QV"},"r1cl9MGnnoE":{"id":"r1cl9MGnnoE","points":[{"x":124,"y":16},{"x":125,"y":16}],"sequenceId":"S19zM3noV"},"r1dqzzhhoV":{"id":"r1dqzzhhoV","points":[{"x":44,"y":47},{"x":44,"y":47}],"sequenceId":"S19zM3noV"},"r1eM0r3ON":{"id":"r1eM0r3ON","points":[{"x":64,"y":47},{"x":64,"y":47}],"sequenceId":"BJk4IHnO4"},"r1fhT3CLlE":{"id":"r1fhT3CLlE","points":[{"x":24,"y":47},{"x":25,"y":47}],"sequenceId":"HyeMmW28gN"},"r1gCCkpzIN":{"id":"r1gCCkpzIN","points":[{"x":22,"y":40},{"x":23,"y":40}],"sequenceId":"ryRRypGUV"},"r1hCCJTz84":{"id":"r1hCCJTz84","points":[{"x":24,"y":30},{"x":25,"y":30}],"sequenceId":"ryRRypGUV"},"r1jxc0m1W7N":{"id":"r1jxc0m1W7N","points":[{"x":40,"y":54},{"x":41,"y":54}],"sequenceId":"rk5Amy-QV"},"r1te5AXJ-mE":{"id":"r1te5AXJ-mE","points":[{"x":60,"y":55},{"x":61,"y":55}],"sequenceId":"rk5Amy-QV"},"r1v9Mz33iE":{"id":"r1v9Mz33iE","points":[{"x":8,"y":47},{"x":8,"y":47}],"sequenceId":"S19zM3noV"},"r1w-9GGh3iE":{"id":"r1w-9GGh3iE","points":[{"x":120,"y":47},{"x":120,"y":47}],"sequenceId":"S19zM3noV"},"rJ-WKAUx4":{"id":"rJ-WKAUx4","points":[{"x":16,"y":35},{"x":17,"y":35}],"sequenceId":"ryDyKA8lN"},"rJ0afkb7E":{"id":"rJ0afkb7E","points":[{"x":8,"y":54},{"x":9,"y":54}],"sequenceId":"B1z8hV-fE"},"rJDTXk-mN":{"id":"rJDTXk-mN","points":[{"x":58,"y":45},{"x":59,"y":45}],"sequenceId":"B1z8hV-fE"},"rJFBGf33jV":{"id":"rJFBGf33jV","points":[{"x":40,"y":28},{"x":47,"y":28}],"sequenceId":"BkHGzh3iN"},"rJJb5fMhnj4":{"id":"rJJb5fMhnj4","points":[{"x":88,"y":47},{"x":88,"y":47}],"sequenceId":"S19zM3noV"},"rJReqR7JWX4":{"id":"rJReqR7JWX4","points":[{"x":32,"y":50},{"x":33,"y":50}],"sequenceId":"rk5Amy-QV"},"rJVHfz33iN":{"id":"rJVHfz33iN","points":[{"x":48,"y":26},{"x":63,"y":26}],"sequenceId":"BkHGzh3iN"},"rJYGfJtJMN":{"id":"rJYGfJtJMN","points":[{"x":20,"y":39},{"x":21,"y":39}],"sequenceId":"SkGzkKyz4"},"rJdCCkaGUN":{"id":"rJdCCkaGUN","points":[{"x":4,"y":35},{"x":5,"y":35}],"sequenceId":"ryRRypGUV"},"rJel5zG3hiE":{"id":"rJel5zG3hiE","points":[{"x":40,"y":47},{"x":40,"y":47}],"sequenceId":"S19zM3noV"},"rJl2a3CUeN":{"id":"rJl2a3CUeN","points":[{"x":30,"y":35},{"x":31,"y":35}],"sequenceId":"HyeMmW28gN"},"rJmSrpr3_4":{"id":"rJmSrpr3_4","points":[{"x":80,"y":40},{"x":111,"y":40}],"sequenceId":"B1xaWwiZB4"},"rJwSGMnhjV":{"id":"rJwSGMnhjV","points":[{"x":8,"y":35},{"x":15,"y":35}],"sequenceId":"BkHGzh3iN"},"rkB9RQ1-QV":{"id":"rkB9RQ1-QV","points":[{"x":16,"y":22},{"x":17,"y":22}],"sequenceId":"rk5Amy-QV"},"rkBCCy6fUE":{"id":"rkBCCy6fUE","points":[{"x":0,"y":26},{"x":1,"y":26}],"sequenceId":"ryRRypGUV"},"rkBlKAUlV":{"id":"rkBlKAUlV","points":[{"x":2,"y":40},{"x":3,"y":40}],"sequenceId":"ryDyKA8lN"},"rkGW9MfnniV":{"id":"rkGW9MfnniV","points":[{"x":76,"y":47},{"x":76,"y":47}],"sequenceId":"S19zM3noV"},"rkMp_rndE":{"id":"rkMp_rndE","points":[{"x":12,"y":16},{"x":13,"y":16}],"sequenceId":"BJk4IHnO4"},"rkRC01pfLV":{"id":"rkRC01pfLV","points":[{"x":14,"y":38},{"x":15,"y":38}],"sequenceId":"ryRRypGUV"},"rkWzQ-3IxE":{"id":"rkWzQ-3IxE","points":[{"x":0,"y":47},{"x":1,"y":47}],"sequenceId":"HyeMmW28gN"},"rkZdTnCLlE":{"id":"rkZdTnCLlE","points":[{"x":18,"y":42},{"x":19,"y":42}],"sequenceId":"HyeMmW28gN"},"rkaSzf33s4":{"id":"rkaSzf33s4","points":[{"x":64,"y":27},{"x":71,"y":27}],"sequenceId":"BkHGzh3iN"},"rkacfMh3s4":{"id":"rkacfMh3s4","points":[{"x":52,"y":47},{"x":52,"y":47}],"sequenceId":"S19zM3noV"},"rkek_G33o4":{"id":"rkek_G33o4","points":[{"x":12,"y":47},{"x":13,"y":47}],"sequenceId":"HJ1wzh2iV"},"rkl6HsrhuE":{"id":"rkl6HsrhuE","points":[{"x":122,"y":17},{"x":123,"y":17}],"sequenceId":"BJk4IHnO4"},"rkoZdukM4":{"id":"rkoZdukM4","points":[{"x":18,"y":40},{"x":19,"y":40}],"sequenceId":"ry8FDu1GV"},"rkrV1KyGN":{"id":"rkrV1KyGN","points":[{"x":4,"y":45},{"x":5,"y":45}],"sequenceId":"H1oXJY1MN"},"rkvx0B3dV":{"id":"rkvx0B3dV","points":[{"x":0,"y":47},{"x":0,"y":47}],"sequenceId":"BJk4IHnO4"},"rkx5MM2njV":{"id":"rkx5MM2njV","points":[{"x":108,"y":47},{"x":108,"y":47}],"sequenceId":"S19zM3noV"},"rkxHMfhnj4":{"id":"rkxHMfhnj4","points":[{"x":16,"y":24},{"x":23,"y":24}],"sequenceId":"BkHGzh3iN"},"rkxiG1ZXE":{"id":"rkxiG1ZXE","points":[{"x":52,"y":42},{"x":53,"y":42}],"sequenceId":"B1z8hV-fE"},"ry7l90XJ-XE":{"id":"ry7l90XJ-XE","points":[{"x":24,"y":54},{"x":25,"y":54}],"sequenceId":"rk5Amy-QV"},"ry8YtH2dN":{"id":"ry8YtH2dN","points":[{"x":88,"y":16},{"x":89,"y":16}],"sequenceId":"BJk4IHnO4"},"ryMuBSJ-X4":{"id":"ryMuBSJ-X4","points":[{"x":4,"y":62},{"x":5,"y":62}],"sequenceId":"SJdrH1ZQ4"},"rySb9zz32iN":{"id":"rySb9zz32iN","points":[{"x":100,"y":16},{"x":101,"y":16}],"sequenceId":"S19zM3noV"},"ryXfRrh_V":{"id":"ryXfRrh_V","points":[{"x":80,"y":47},{"x":80,"y":47}],"sequenceId":"BJk4IHnO4"},"ryZi7tBhdN":{"id":"ryZi7tBhdN","points":[{"x":36,"y":16},{"x":37,"y":16}],"sequenceId":"BJk4IHnO4"},"rybBymJWQ4":{"id":"rybBymJWQ4","points":[{"x":24,"y":54},{"x":25,"y":54}],"sequenceId":"B1z8hV-fE"},"rybC0ypG84":{"id":"rybC0ypG84","points":[{"x":6,"y":38},{"x":7,"y":38}],"sequenceId":"ryRRypGUV"},"ryfBHTS3_4":{"id":"ryfBHTS3_4","points":[{"x":32,"y":37},{"x":39,"y":37}],"sequenceId":"B1xaWwiZB4"},"ryfr6hC8eN":{"id":"ryfr6hC8eN","points":[{"x":8,"y":47},{"x":9,"y":47}],"sequenceId":"HyeMmW28gN"},"ryhHfznnjN":{"id":"ryhHfznnjN","points":[{"x":0,"y":35},{"x":7,"y":35}],"sequenceId":"BkHGzh3iN"},"ryhlCH2OE":{"id":"ryhlCH2OE","points":[{"x":16,"y":47},{"x":16,"y":47}],"sequenceId":"BJk4IHnO4"},"ryicCXJb7V":{"id":"ryicCXJb7V","points":[{"x":56,"y":37},{"x":57,"y":37}],"sequenceId":"rk5Amy-QV"},"rymHzz3nsN":{"id":"rymHzz3nsN","points":[{"x":8,"y":23},{"x":15,"y":23}],"sequenceId":"BkHGzh3iN"},"rywAs4-zV":{"id":"rywAs4-zV","points":[{"x":2,"y":45},{"x":3,"y":45}],"sequenceId":"ry8FDu1GV"}},"sequences":{"B1xaWwiZB4":{"id":"B1xaWwiZB4","measureCount":4,"notes":[{"id":"B15NOrndV","points":[{"x":16,"y":24},{"x":23,"y":24}],"sequenceId":"B1xaWwiZB4"},{"id":"BJDrHpS3ON","points":[{"x":24,"y":40},{"x":31,"y":40}],"sequenceId":"B1xaWwiZB4"},{"id":"H1BSTH3_4","points":[{"x":16,"y":36},{"x":23,"y":36}],"sequenceId":"B1xaWwiZB4"},{"id":"H1zNdB2uE","points":[{"x":8,"y":23},{"x":15,"y":23}],"sequenceId":"B1xaWwiZB4"},{"id":"HJEI_HnO4","points":[{"x":48,"y":26},{"x":63,"y":26}],"sequenceId":"B1xaWwiZB4"},{"id":"HJFr_Sh_V","points":[{"x":32,"y":25},{"x":39,"y":25}],"sequenceId":"B1xaWwiZB4"},{"id":"HJUBHaBhOE","points":[{"x":64,"y":39},{"x":71,"y":39}],"sequenceId":"B1xaWwiZB4"},{"id":"HkgrrpS3OV","points":[{"x":8,"y":35},{"x":15,"y":35}],"sequenceId":"B1xaWwiZB4"},{"id":"Hy5wtr3_4","points":[{"x":80,"y":28},{"x":111,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"HyJ8uS3_N","points":[{"x":40,"y":28},{"x":47,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"HyNHH6B3OV","points":[{"x":40,"y":40},{"x":47,"y":40}],"sequenceId":"B1xaWwiZB4"},{"id":"Hy_SHTr2u4","points":[{"x":72,"y":42},{"x":79,"y":42}],"sequenceId":"B1xaWwiZB4"},{"id":"SJjgdB3_V","points":[{"x":0,"y":35},{"x":7,"y":35}],"sequenceId":"B1xaWwiZB4"},{"id":"Sk-wtH2_V","points":[{"x":64,"y":27},{"x":71,"y":27}],"sequenceId":"B1xaWwiZB4"},{"id":"SyQS_H2O4","points":[{"x":24,"y":28},{"x":31,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"SyZrBpBnOE","points":[{"x":48,"y":38},{"x":63,"y":38}],"sequenceId":"B1xaWwiZB4"},{"id":"SyrBSTBnuE","points":[{"x":0,"y":47},{"x":7,"y":47}],"sequenceId":"B1xaWwiZB4"},{"id":"r1VwKS3uE","points":[{"x":72,"y":30},{"x":79,"y":30}],"sequenceId":"B1xaWwiZB4"},{"id":"rJmSrpr3_4","points":[{"x":80,"y":40},{"x":111,"y":40}],"sequenceId":"B1xaWwiZB4"},{"id":"ryfBHTS3_4","points":[{"x":32,"y":37},{"x":39,"y":37}],"sequenceId":"B1xaWwiZB4"}],"position":0,"trackId":"Sk6ZvoZSE"},"BJk4IHnO4":{"id":"BJk4IHnO4","measureCount":4,"notes":[{"id":"B12QoH3O4","points":[{"x":108,"y":47},{"x":108,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1Mn7sShd4","points":[{"x":100,"y":47},{"x":100,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1WhKrh_E","points":[{"x":12,"y":47},{"x":12,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1Z3QoB2_4","points":[{"x":104,"y":47},{"x":104,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1ejaFSnuN","points":[{"x":24,"y":47},{"x":24,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1iQFHndE","points":[{"x":56,"y":16},{"x":57,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"BJIFZqrhdN","points":[{"x":60,"y":47},{"x":60,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Bkg3FB2uV","points":[{"x":8,"y":47},{"x":8,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByMF-qB3ON","points":[{"x":44,"y":47},{"x":44,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByMbNsS2OE","points":[{"x":116,"y":47},{"x":116,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByZTSiS2_4","points":[{"x":120,"y":16},{"x":121,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H16bCHhuE","points":[{"x":48,"y":47},{"x":48,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H17-RS2d4","points":[{"x":32,"y":47},{"x":32,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H1NYZqShuE","points":[{"x":52,"y":47},{"x":52,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H1VSFrnd4","points":[{"x":28,"y":16},{"x":29,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H1XimKS3dV","points":[{"x":44,"y":16},{"x":45,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H1bKW9H3d4","points":[{"x":40,"y":47},{"x":40,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HJr05SnuE","points":[{"x":92,"y":47},{"x":92,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HJxYZ9Bnd4","points":[{"x":36,"y":47},{"x":36,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Hk0dFBhO4","points":[{"x":68,"y":16},{"x":69,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"Hk1ntH3dN","points":[{"x":4,"y":47},{"x":4,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HkHY-qHhuE","points":[{"x":56,"y":47},{"x":56,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HkOjOBnO4","points":[{"x":4,"y":16},{"x":5,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"HyGiQYB3dE","points":[{"x":60,"y":16},{"x":61,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"HyM-R9H2u4","points":[{"x":72,"y":47},{"x":72,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HybjptS2uN","points":[{"x":20,"y":47},{"x":20,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"S1TBiH2uE","points":[{"x":124,"y":16},{"x":125,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"SJW4jBnd4","points":[{"x":124,"y":47},{"x":124,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SJZZR9S2_N","points":[{"x":68,"y":47},{"x":68,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SJgi7YShdN","points":[{"x":58,"y":17},{"x":59,"y":17}],"sequenceId":"BJk4IHnO4"},{"id":"SJoaKH3_N","points":[{"x":28,"y":47},{"x":28,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkGrCcH2dE","points":[{"x":88,"y":47},{"x":88,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkVKFSnu4","points":[{"x":76,"y":16},{"x":77,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"Sk_fRBhdN","points":[{"x":112,"y":47},{"x":112,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkbAqB2_N","points":[{"x":76,"y":47},{"x":76,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkbS0qH3OE","points":[{"x":84,"y":47},{"x":84,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Sy76HsBn_V","points":[{"x":108,"y":16},{"x":109,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"SyfarjSn_V","points":[{"x":100,"y":16},{"x":101,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"r1BfRBhOV","points":[{"x":96,"y":47},{"x":96,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"r1Z-EiS3_V","points":[{"x":120,"y":47},{"x":120,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"r1eM0r3ON","points":[{"x":64,"y":47},{"x":64,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"rkMp_rndE","points":[{"x":12,"y":16},{"x":13,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"rkl6HsrhuE","points":[{"x":122,"y":17},{"x":123,"y":17}],"sequenceId":"BJk4IHnO4"},{"id":"rkvx0B3dV","points":[{"x":0,"y":47},{"x":0,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ry8YtH2dN","points":[{"x":88,"y":16},{"x":89,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"ryXfRrh_V","points":[{"x":80,"y":47},{"x":80,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ryZi7tBhdN","points":[{"x":36,"y":16},{"x":37,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"ryhlCH2OE","points":[{"x":16,"y":47},{"x":16,"y":47}],"sequenceId":"BJk4IHnO4"}],"position":0,"trackId":"r1s4VpLx4"},"BkHGzh3iN":{"id":"BkHGzh3iN","measureCount":4,"notes":[{"id":"rkxHMfhnj4","points":[{"x":16,"y":24},{"x":23,"y":24}],"sequenceId":"BkHGzh3iN"},{"id":"HybSMz3niN","points":[{"x":24,"y":40},{"x":31,"y":40}],"sequenceId":"BkHGzh3iN"},{"id":"B1MBzGnhsN","points":[{"x":16,"y":36},{"x":23,"y":36}],"sequenceId":"BkHGzh3iN"},{"id":"rymHzz3nsN","points":[{"x":8,"y":23},{"x":15,"y":23}],"sequenceId":"BkHGzh3iN"},{"id":"rJVHfz33iN","points":[{"x":48,"y":26},{"x":63,"y":26}],"sequenceId":"BkHGzh3iN"},{"id":"S1rBzf32oN","points":[{"x":32,"y":25},{"x":39,"y":25}],"sequenceId":"BkHGzh3iN"},{"id":"By8Sfz22jN","points":[{"x":64,"y":39},{"x":71,"y":39}],"sequenceId":"BkHGzh3iN"},{"id":"rJwSGMnhjV","points":[{"x":8,"y":35},{"x":15,"y":35}],"sequenceId":"BkHGzh3iN"},{"id":"H1OHzGnniN","points":[{"x":80,"y":28},{"x":111,"y":28}],"sequenceId":"BkHGzh3iN"},{"id":"rJFBGf33jV","points":[{"x":40,"y":28},{"x":47,"y":28}],"sequenceId":"BkHGzh3iN"},{"id":"H1qSMz2njE","points":[{"x":40,"y":40},{"x":47,"y":40}],"sequenceId":"BkHGzh3iN"},{"id":"HJsrzMh3iE","points":[{"x":72,"y":42},{"x":79,"y":42}],"sequenceId":"BkHGzh3iN"},{"id":"ryhHfznnjN","points":[{"x":0,"y":35},{"x":7,"y":35}],"sequenceId":"BkHGzh3iN"},{"id":"rkaSzf33s4","points":[{"x":64,"y":27},{"x":71,"y":27}],"sequenceId":"BkHGzh3iN"},{"id":"HJCBzfnnj4","points":[{"x":24,"y":28},{"x":31,"y":28}],"sequenceId":"BkHGzh3iN"},{"id":"BJJeBGf22oN","points":[{"x":48,"y":38},{"x":63,"y":38}],"sequenceId":"BkHGzh3iN"},{"id":"H1eerMfnhoN","points":[{"x":0,"y":47},{"x":7,"y":47}],"sequenceId":"BkHGzh3iN"},{"id":"HyWgBfM3hjV","points":[{"x":72,"y":30},{"x":79,"y":30}],"sequenceId":"BkHGzh3iN"},{"id":"ByzxSzM2hoV","points":[{"x":80,"y":40},{"x":111,"y":40}],"sequenceId":"BkHGzh3iN"},{"id":"r17gBGGnnsE","points":[{"x":32,"y":37},{"x":39,"y":37}],"sequenceId":"BkHGzh3iN"}],"position":4,"trackId":"Sk6ZvoZSE"},"BkkHMnnjV":{"id":"BkkHMnnjV","measureCount":2,"notes":[],"position":8,"trackId":"Sk6ZvoZSE"},"HJ1wzh2iV":{"id":"HJ1wzh2iV","measureCount":2,"notes":[],"position":8,"trackId":"r1s4VpLx4"},"S19zM3noV":{"id":"S19zM3noV","measureCount":4,"notes":[{"id":"rkx5MM2njV","points":[{"x":108,"y":47},{"x":108,"y":47}],"sequenceId":"S19zM3noV"},{"id":"Bkbqzz2hiE","points":[{"x":100,"y":47},{"x":100,"y":47}],"sequenceId":"S19zM3noV"},{"id":"HyGqMz22iV","points":[{"x":12,"y":47},{"x":12,"y":47}],"sequenceId":"S19zM3noV"},{"id":"SyXczMhhjV","points":[{"x":104,"y":47},{"x":104,"y":47}],"sequenceId":"S19zM3noV"},{"id":"B1Nqfzn2s4","points":[{"x":24,"y":47},{"x":24,"y":47}],"sequenceId":"S19zM3noV"},{"id":"HJBcMz2njN","points":[{"x":56,"y":16},{"x":57,"y":16}],"sequenceId":"S19zM3noV"},{"id":"HyL9Gfn3i4","points":[{"x":60,"y":47},{"x":60,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1v9Mz33iE","points":[{"x":8,"y":47},{"x":8,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1dqzzhhoV","points":[{"x":44,"y":47},{"x":44,"y":47}],"sequenceId":"S19zM3noV"},{"id":"BJF5zG2noV","points":[{"x":116,"y":47},{"x":116,"y":47}],"sequenceId":"S19zM3noV"},{"id":"HkqcGf3njE","points":[{"x":120,"y":16},{"x":121,"y":16}],"sequenceId":"S19zM3noV"},{"id":"HJs5zfnnoE","points":[{"x":48,"y":47},{"x":48,"y":47}],"sequenceId":"S19zM3noV"},{"id":"H13qfG3ho4","points":[{"x":32,"y":47},{"x":32,"y":47}],"sequenceId":"S19zM3noV"},{"id":"rkacfMh3s4","points":[{"x":52,"y":47},{"x":52,"y":47}],"sequenceId":"S19zM3noV"},{"id":"ByC5fMhniE","points":[{"x":28,"y":16},{"x":29,"y":16}],"sequenceId":"S19zM3noV"},{"id":"HJyxczMnhoV","points":[{"x":44,"y":16},{"x":45,"y":16}],"sequenceId":"S19zM3noV"},{"id":"rJel5zG3hiE","points":[{"x":40,"y":47},{"x":40,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1Zx9GG32s4","points":[{"x":92,"y":47},{"x":92,"y":47}],"sequenceId":"S19zM3noV"},{"id":"B1fl5MM3hsV","points":[{"x":36,"y":47},{"x":36,"y":47}],"sequenceId":"S19zM3noV"},{"id":"BkXgqfGh2sE","points":[{"x":68,"y":16},{"x":69,"y":16}],"sequenceId":"S19zM3noV"},{"id":"BkNe5Gf22i4","points":[{"x":4,"y":47},{"x":4,"y":47}],"sequenceId":"S19zM3noV"},{"id":"Bkrl5GGhhsE","points":[{"x":56,"y":47},{"x":56,"y":47}],"sequenceId":"S19zM3noV"},{"id":"B1UlcMM33sN","points":[{"x":4,"y":16},{"x":5,"y":16}],"sequenceId":"S19zM3noV"},{"id":"H1Dg9zz33iN","points":[{"x":60,"y":16},{"x":61,"y":16}],"sequenceId":"S19zM3noV"},{"id":"Syug5ffhnoV","points":[{"x":72,"y":47},{"x":72,"y":47}],"sequenceId":"S19zM3noV"},{"id":"HkYeqGG3hs4","points":[{"x":20,"y":47},{"x":20,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1cl9MGnnoE","points":[{"x":124,"y":16},{"x":125,"y":16}],"sequenceId":"S19zM3noV"},{"id":"BkjxqzG2hoE","points":[{"x":124,"y":47},{"x":124,"y":47}],"sequenceId":"S19zM3noV"},{"id":"B12eczG33o4","points":[{"x":68,"y":47},{"x":68,"y":47}],"sequenceId":"S19zM3noV"},{"id":"S1ax5zGh2oV","points":[{"x":58,"y":17},{"x":59,"y":17}],"sequenceId":"S19zM3noV"},{"id":"H10xqzMn2j4","points":[{"x":28,"y":47},{"x":28,"y":47}],"sequenceId":"S19zM3noV"},{"id":"rJJb5fMhnj4","points":[{"x":88,"y":47},{"x":88,"y":47}],"sequenceId":"S19zM3noV"},{"id":"BJe-qMG3njE","points":[{"x":76,"y":16},{"x":77,"y":16}],"sequenceId":"S19zM3noV"},{"id":"BkZb9GM22o4","points":[{"x":112,"y":47},{"x":112,"y":47}],"sequenceId":"S19zM3noV"},{"id":"rkGW9MfnniV","points":[{"x":76,"y":47},{"x":76,"y":47}],"sequenceId":"S19zM3noV"},{"id":"HJ7Z5fG3hi4","points":[{"x":84,"y":47},{"x":84,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1NZqGfh3j4","points":[{"x":108,"y":16},{"x":109,"y":16}],"sequenceId":"S19zM3noV"},{"id":"rySb9zz32iN","points":[{"x":100,"y":16},{"x":101,"y":16}],"sequenceId":"S19zM3noV"},{"id":"BJU-czfnnjE","points":[{"x":96,"y":47},{"x":96,"y":47}],"sequenceId":"S19zM3noV"},{"id":"r1w-9GGh3iE","points":[{"x":120,"y":47},{"x":120,"y":47}],"sequenceId":"S19zM3noV"},{"id":"Hy_ZcGz3njN","points":[{"x":64,"y":47},{"x":64,"y":47}],"sequenceId":"S19zM3noV"},{"id":"ByKWqMznhj4","points":[{"x":12,"y":16},{"x":13,"y":16}],"sequenceId":"S19zM3noV"},{"id":"S19Z5GM3no4","points":[{"x":122,"y":17},{"x":123,"y":17}],"sequenceId":"S19zM3noV"},{"id":"HyiZcGGhhjN","points":[{"x":0,"y":47},{"x":0,"y":47}],"sequenceId":"S19zM3noV"},{"id":"BJ3-cMzh3sN","points":[{"x":88,"y":16},{"x":89,"y":16}],"sequenceId":"S19zM3noV"},{"id":"Bk6-5Mf32jN","points":[{"x":80,"y":47},{"x":80,"y":47}],"sequenceId":"S19zM3noV"},{"id":"B1RZcfM3hjN","points":[{"x":36,"y":16},{"x":37,"y":16}],"sequenceId":"S19zM3noV"},{"id":"SJkz5zzn3j4","points":[{"x":16,"y":47},{"x":16,"y":47}],"sequenceId":"S19zM3noV"}],"position":4,"trackId":"r1s4VpLx4"}},"tracks":{"Sk6ZvoZSE":{"id":"Sk6ZvoZSE","isMuted":false,"isSoloing":false,"sequences":[{"id":"B1xaWwiZB4","measureCount":3,"notes":[{"id":"B15NOrndV","points":[{"x":16,"y":24},{"x":23,"y":24}],"sequenceId":"B1xaWwiZB4"},{"id":"H1zNdB2uE","points":[{"x":8,"y":23},{"x":15,"y":23}],"sequenceId":"B1xaWwiZB4"},{"id":"HJEI_HnO4","points":[{"x":48,"y":26},{"x":63,"y":26}],"sequenceId":"B1xaWwiZB4"},{"id":"HJFr_Sh_V","points":[{"x":32,"y":25},{"x":39,"y":25}],"sequenceId":"B1xaWwiZB4"},{"id":"Hy5wtr3_4","points":[{"x":80,"y":28},{"x":95,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"HyJ8uS3_N","points":[{"x":40,"y":28},{"x":47,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"SJjgdB3_V","points":[{"x":0,"y":35},{"x":7,"y":35}],"sequenceId":"B1xaWwiZB4"},{"id":"Sk-wtH2_V","points":[{"x":64,"y":27},{"x":71,"y":27}],"sequenceId":"B1xaWwiZB4"},{"id":"SyQS_H2O4","points":[{"x":24,"y":28},{"x":31,"y":28}],"sequenceId":"B1xaWwiZB4"},{"id":"r1VwKS3uE","points":[{"x":72,"y":30},{"x":79,"y":30}],"sequenceId":"B1xaWwiZB4"}],"position":0,"trackId":"Sk6ZvoZSE"}],"voice":"SAWTOOTH","volume":-10},"r1s4VpLx4":{"id":"r1s4VpLx4","isMuted":false,"isSoloing":false,"sequences":[{"id":"BJk4IHnO4","measureCount":4,"notes":[{"id":"B12QoH3O4","points":[{"x":108,"y":47},{"x":108,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1Mn7sShd4","points":[{"x":100,"y":47},{"x":100,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1WhKrh_E","points":[{"x":12,"y":47},{"x":12,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1Z3QoB2_4","points":[{"x":104,"y":47},{"x":104,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1ejaFSnuN","points":[{"x":24,"y":47},{"x":24,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"B1iQFHndE","points":[{"x":56,"y":16},{"x":57,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"BJIFZqrhdN","points":[{"x":60,"y":47},{"x":60,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Bkg3FB2uV","points":[{"x":8,"y":47},{"x":8,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByMF-qB3ON","points":[{"x":44,"y":47},{"x":44,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByMbNsS2OE","points":[{"x":116,"y":47},{"x":116,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ByZTSiS2_4","points":[{"x":120,"y":16},{"x":121,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H16bCHhuE","points":[{"x":48,"y":47},{"x":48,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H17-RS2d4","points":[{"x":32,"y":47},{"x":32,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H1NYZqShuE","points":[{"x":52,"y":47},{"x":52,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"H1VSFrnd4","points":[{"x":28,"y":16},{"x":29,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H1XimKS3dV","points":[{"x":44,"y":16},{"x":45,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"H1bKW9H3d4","points":[{"x":40,"y":47},{"x":40,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HJr05SnuE","points":[{"x":92,"y":47},{"x":92,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HJxYZ9Bnd4","points":[{"x":36,"y":47},{"x":36,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Hk0dFBhO4","points":[{"x":68,"y":16},{"x":69,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"Hk1ntH3dN","points":[{"x":4,"y":47},{"x":4,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HkHY-qHhuE","points":[{"x":56,"y":47},{"x":56,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HkOjOBnO4","points":[{"x":4,"y":16},{"x":5,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"HyGiQYB3dE","points":[{"x":60,"y":16},{"x":61,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"HyM-R9H2u4","points":[{"x":72,"y":47},{"x":72,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"HybjptS2uN","points":[{"x":20,"y":47},{"x":20,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"S1TBiH2uE","points":[{"x":124,"y":16},{"x":125,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"SJW4jBnd4","points":[{"x":124,"y":47},{"x":124,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SJZZR9S2_N","points":[{"x":68,"y":47},{"x":68,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SJgi7YShdN","points":[{"x":58,"y":17},{"x":59,"y":17}],"sequenceId":"BJk4IHnO4"},{"id":"SJoaKH3_N","points":[{"x":28,"y":47},{"x":28,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkGrCcH2dE","points":[{"x":88,"y":47},{"x":88,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkVKFSnu4","points":[{"x":76,"y":16},{"x":77,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"Sk_fRBhdN","points":[{"x":112,"y":47},{"x":112,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkbAqB2_N","points":[{"x":76,"y":47},{"x":76,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"SkbS0qH3OE","points":[{"x":84,"y":47},{"x":84,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"Sy76HsBn_V","points":[{"x":108,"y":16},{"x":109,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"SyfarjSn_V","points":[{"x":100,"y":16},{"x":101,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"r1BfRBhOV","points":[{"x":96,"y":47},{"x":96,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"r1Z-EiS3_V","points":[{"x":120,"y":47},{"x":120,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"r1eM0r3ON","points":[{"x":64,"y":47},{"x":64,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"rkMp_rndE","points":[{"x":12,"y":16},{"x":13,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"rkl6HsrhuE","points":[{"x":122,"y":17},{"x":123,"y":17}],"sequenceId":"BJk4IHnO4"},{"id":"rkvx0B3dV","points":[{"x":0,"y":47},{"x":0,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ry8YtH2dN","points":[{"x":88,"y":16},{"x":89,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"ryXfRrh_V","points":[{"x":80,"y":47},{"x":80,"y":47}],"sequenceId":"BJk4IHnO4"},{"id":"ryZi7tBhdN","points":[{"x":36,"y":16},{"x":37,"y":16}],"sequenceId":"BJk4IHnO4"},{"id":"ryhlCH2OE","points":[{"x":16,"y":47},{"x":16,"y":47}],"sequenceId":"BJk4IHnO4"}],"position":0,"trackId":"r1s4VpLx4"}],"voice":"SQUARE","volume":-15}},"userId":"rk2XACMdY1Zfescnk6jy7zyHXjg2"} --------------------------------------------------------------------------------