Giter Site home page Giter Site logo

aqandrew / fret-zone Goto Github PK

View Code? Open in Web Editor NEW
46.0 4.0 5.0 6.31 MB

๐ŸŽธ React clone of Guitar Pro tab editor (WIP)

Home Page: https://fret.zone/

HTML 2.10% CSS 0.06% JavaScript 87.09% SCSS 10.74%
react music guitar guitar-tabs guitar-tablature guitar-tab-notation

fret-zone's Introduction

๐Ÿ‘‹๐Ÿฝ I'm Andrew, a frontend developer in Southern California. My specialties are CSS/animation, modern JavaScript (React, Svelte), and interactive visual experiences (data visualization with D3, three.js).

fret-zone's People

Contributors

aqandrew avatar dependabot[bot] avatar kentcdodds avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

fret-zone's Issues

add TypeScript

custom objects like notes, measures, and durations are unwieldy

add tied notes

  • Whether a note is tied is boolean at the note level, not the duration level.
  • A tied note can be rendered with parentheses: (0)
  • Pressing L at a duration with no preceding notes on that string does nothing.
  • If there are preceding notes,
    • If there are empty durations between the selected duration and the preceding note on that string,
      • The empty durations are filled to fill the bar(s), using the longest possible duration lengths first.

Before pressing L:

image

After pressing L:

image

add real UI icons

PNG spritesheet or individual SVGs?

What are the pros and cons of each?

add copy/paste functionality

Guitar Pro's copy/paste doesn't make use of the OS clipboard, so clipboard contents will have to be stored somewhere in app state.

This also means that we can't paste from Guitar Pro into FretZone. ๐Ÿ˜ข

IA concerning measures' notes is wrong

Right now, a measure has notes, each of which has a duration. The state shape of a measure is as follows:

{
  id: 'foo',
  notes: [
    {
      id: 'bar',
      isRest: false,
      string: 0,
      fret: 3,
      duration: 1/4
    },
    ...
  ]
}

This is fine for sequences of single notes only. However, to support chords and make calculating measure durations much easier, the shape must be changed so that a measure has durations, each of which has notes. For example: if a single voice is playing a two-note power chord, you can't change the duration of the root note without changing the fifth's duration as well. The above state (playing a G note on the third fret of the high E string) would be better represented like this:

{
  id: 'foo',
  durations: [
    {
      id: 'bar',
      length: 1/4,
      isRest: false,
      notes: [
        {
          id: 'baz',
          string: 0,
          fret: 3
        }
      ]
    },
    ...
  ]
}

on start, AddTrackModal should be open

To help make the UX more clear.

This will break quite a few tests.

^Those tests will have to be changed so that creating a new track simply requires pressing Enter, rather than having to click the Add Track button first.

move action definitions into separate file

Action definitions account for almost half of App.jsx's length:

fret-zone/src/App.jsx

Lines 70 to 511 in 4612f11

const dispatchDeleteTrack = () => {
// If a track that's not last is being deleted,
if (selectedTrackNumber < tracks.length - 1) {
const nextTracksFirstDurationAtSelectedMeasureNumber = durations.find(
(duration) =>
duration.id ===
measures.find(
(measure) =>
measure.id ===
tracks[selectedTrackNumber + 1].measures[selectedMeasureNumber]
).durations[0]
);
// Select first duration of next track's measure at selectedMeasureNumber
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: nextTracksFirstDurationAtSelectedMeasureNumber.id,
});
}
// Otherwise, select first duration of previous track's measure at selectedMeasureNumber
else if (selectedTrackNumber !== 0) {
const previousTracksFirstDurationAtSelectedMeasureNumber = durations.find(
(duration) =>
duration.id ===
measures.find(
(measure) =>
measure.id ===
tracks[selectedTrackNumber - 1].measures[selectedMeasureNumber]
).durations[0]
);
dispatch({
type: actionTypes.SELECT_TRACK,
trackNumber: selectedTrackNumber - 1,
});
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: previousTracksFirstDurationAtSelectedMeasureNumber.id,
});
}
dispatch({
type: actionTypes.DELETE_TRACK,
trackId: tracks[selectedTrackNumber].id,
});
};
const dispatchAddTrack = useCallback(
(trackToAdd) => {
let newTrackId = nanoid();
// TODO Turn ID array generation into a function
let measureIds =
tracks.length === 0
? [nanoid()]
: tracks[0].measures.map((measure) => nanoid());
let durationIds =
tracks.length === 0
? [nanoid()]
: tracks[0].measures.map((measure) => nanoid());
dispatch({
type: actionTypes.ADD_TRACK,
id: newTrackId,
measures: measureIds,
durationIds: durationIds,
durationLength: selectedDuration?.length,
...trackToAdd,
});
return {
newTrackId: newTrackId,
durationIdToSelect: durationIds[selectedMeasureNumber],
};
},
[selectedMeasureNumber, selectedDuration, tracks]
);
const dispatchShortenDuration = useCallback(
(durationId) => {
dispatch({
type: actionTypes.SET_DURATION_LENGTH,
durationId: durationId,
newLength: selectedDuration?.length / 2,
});
},
[selectedDuration]
);
const dispatchLengthenDuration = useCallback(
(durationId) => {
dispatch({
type: actionTypes.SET_DURATION_LENGTH,
durationId: durationId,
newLength: selectedDuration?.length * 2,
});
},
[selectedDuration]
);
const dispatchSelectPreviousString = useCallback(() => {
dispatch({
type: actionTypes.SELECT_STRING,
stringNumber:
selectedStringNumber === 0
? selectedTrack?.tuning.length - 1
: selectedStringNumber - 1,
});
}, [selectedTrack, selectedStringNumber]);
const dispatchSelectNextString = useCallback(() => {
dispatch({
type: actionTypes.SELECT_STRING,
stringNumber: (selectedStringNumber + 1) % selectedTrack?.tuning.length,
});
}, [selectedTrack, selectedStringNumber]);
const dispatchSelectPreviousDuration = useCallback(() => {
// If currently selected duration is NOT first in the measure,
if (selectedDurationId !== selectedMeasure?.durations[0]) {
// Select the previous duration
dispatch({
type: actionTypes.SELECT_DURATION,
durationId:
selectedMeasure?.durations[
selectedMeasure?.durations.findIndex(
(durationId) => durationId === selectedDurationId
) - 1
],
});
} else if (selectedMeasureNumber > 0) {
const previousMeasure = measures.find(
(measure) =>
measure.id === selectedTrack?.measures[selectedMeasureNumber - 1]
);
const durationIdToSelect = previousMeasure.durations.slice(-1)[0];
// Select the last duration of the previous measure
dispatch({
type: actionTypes.SELECT_MEASURE,
measureNumber: selectedMeasureNumber - 1,
});
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: durationIdToSelect,
});
}
}, [
measures,
selectedTrack,
selectedMeasureNumber,
selectedMeasure,
selectedDurationId,
]);
const dispatchSelectNextDuration = useCallback(() => {
let shouldCheckIfMeasureIsLast = false;
// If there's a note at this duration,
// Or if this duration is a rest,
if (selectedDuration?.notes.length || selectedDuration?.isRest) {
// If this is the last duration,
if (selectedDurationId === selectedMeasure?.durations.slice(-1)[0]) {
// If the measure's total length === maximum,
if (currentBarDuration === currentBarMaximumDuration) {
shouldCheckIfMeasureIsLast = true;
}
// Add a new duration to this measure
else {
let newDurationId = nanoid();
dispatch({
type: actionTypes.ADD_DURATION,
measureId: selectedMeasure?.id,
newDurationId: newDurationId,
length: selectedDuration?.length,
isDotted: selectedDuration?.isDotted,
});
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: newDurationId,
});
}
}
// Select the next duration in this measure
else {
const nextDuration = durations.find(
(duration) =>
duration.id ===
selectedMeasure?.durations[
selectedMeasure?.durations.findIndex(
(durationId) => durationId === selectedDurationId
) + 1
]
);
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: nextDuration.id,
});
}
} else {
shouldCheckIfMeasureIsLast = true;
}
if (shouldCheckIfMeasureIsLast) {
// If selectedMeasure is last,
// Add a new measure
if (selectedMeasureNumber === selectedTrack?.measures.length - 1) {
// TODO Use parallel arrays like in dispatchAddTrack instead
// Create a mapping from track IDs to new measure IDs
let trackMeasureIds = tracks.reduce((map, track) => {
map[track.id] = {
measureId: nanoid(),
durationId: nanoid(),
};
return map;
}, {});
// TODO Pass in current measure's time signature
dispatch({
type: actionTypes.ADD_MEASURE,
trackMeasureIds: trackMeasureIds,
durationLength: selectedDuration?.length,
});
dispatch({
type: actionTypes.SELECT_MEASURE,
measureNumber: selectedMeasureNumber + 1,
});
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: trackMeasureIds[selectedTrack?.id].durationId,
});
}
// Select the next measure
else {
const nextMeasure = measures.find(
(measure) =>
measure.id === selectedTrack?.measures[selectedMeasureNumber + 1]
);
const durationIdToSelect = nextMeasure.durations[0];
dispatch({
type: actionTypes.SELECT_MEASURE,
measureNumber: selectedMeasureNumber + 1,
});
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: durationIdToSelect,
});
}
}
}, [
tracks,
measures,
durations,
selectedTrack,
selectedMeasureNumber,
selectedMeasure,
selectedDurationId,
selectedDuration,
currentBarDuration,
currentBarMaximumDuration,
]);
const dispatchDeleteMeasure = useCallback(() => {
if (selectedTrack?.measures.length > 1) {
let newSelectedMeasureNumber;
if (selectedMeasureNumber > 0) {
newSelectedMeasureNumber = selectedMeasureNumber - 1;
dispatch({
type: actionTypes.SELECT_MEASURE,
measureNumber: newSelectedMeasureNumber,
});
} else {
newSelectedMeasureNumber = selectedMeasureNumber + 1;
}
const durationToSelect = durations.find(
(duration) =>
duration.id ===
measures.find(
(measure) =>
measure.id === selectedTrack?.measures[newSelectedMeasureNumber]
).durations[0]
);
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: durationToSelect.id,
});
// Even though dispatch runs synchronously, selectedMeasureNumber does not change within this closure,
// so this still deletes the correct measure after SELECT_MEASURE has executed
dispatch({
type: actionTypes.DELETE_MEASURE,
measureNumber: selectedMeasureNumber,
});
}
}, [measures, durations, selectedTrack, selectedMeasureNumber]);
const dispatchDeleteNote = useCallback(() => {
let needToSelectNewDuration = false;
// If there is a note at this selected duration/string,
if (selectedPositionHasNote) {
// Delete that note
// TODO These lines are horribly inefficient
const selectedNoteId = selectedDuration?.notes.find(
(noteId) =>
notes.find((note) => note.id === noteId).string ===
selectedStringNumber
);
dispatch({ type: actionTypes.DELETE_NOTE, noteId: selectedNoteId });
// If the deleted note was the last one in the selected duration,
if (selectedDuration?.notes.length === 1) {
// Turn the duration into a rest
dispatch({
type: actionTypes.ADD_REST,
durationId: selectedDurationId,
});
}
// Don't select a new duration if we're in the first duration of the document
else if (
!(
selectedMeasureNumber === 0 &&
selectedDurationId === selectedMeasure?.durations[0]
)
) {
needToSelectNewDuration = true;
}
} else {
// If the selected duration is a rest,
if (selectedDuration?.isRest) {
// If this is the only duration in the measure,
if (selectedMeasure?.durations.length === 1) {
// Change the duration to NOT a rest
dispatch({
type: actionTypes.MARK_DURATION_AS_NOT_REST,
durationId: selectedDurationId,
});
} else {
// Delete that duration
dispatch({
type: actionTypes.DELETE_DURATION,
durationId: selectedDurationId,
});
needToSelectNewDuration = true;
}
}
}
if (needToSelectNewDuration) {
let durationIdToSelect;
// If the selected duration is first in the measure,
if (selectedDurationId === selectedMeasure?.durations[0]) {
// If the first measure of the document is selected,
if (selectedMeasureNumber === 0) {
// Select the next duration of this measure
durationIdToSelect =
selectedMeasure?.durations[
selectedMeasure?.durations.findIndex(
(durationId) => durationId === selectedDurationId
) + 1
];
} else {
// Select the previous measure's last duration
durationIdToSelect = measures[
selectedMeasureNumber - 1
].durations.slice(-1)[0];
dispatch({
type: actionTypes.SELECT_MEASURE,
measureNumber: selectedMeasureNumber - 1,
});
}
} else {
// Select this measure's previous duration
durationIdToSelect =
selectedMeasure?.durations[
selectedMeasure?.durations.findIndex(
(durationId) => durationId === selectedDurationId
) - 1
];
}
dispatch({
type: actionTypes.SELECT_DURATION,
durationId: durationIdToSelect,
});
}
}, [
measures,
notes,
selectedMeasureNumber,
selectedMeasure,
selectedDurationId,
selectedDuration,
selectedStringNumber,
selectedPositionHasNote,
]);
const dispatchAddNote = useCallback(
(fretNumber) => {
const fretInputTime = Date.now();
setLastFretInputTime(fretInputTime);
// TODO See other comment about "horribly inefficient"
// TODO selectedNote is probably a good variable to have
const currentFretNumber =
notes.find(
(note) =>
note.string === selectedStringNumber &&
selectedDuration?.notes.includes(note.id)
)?.fret || 0;
const enteredFretNumber = parseInt(fretNumber);
const newFretNumber = currentFretNumber * 10 + enteredFretNumber;
dispatch({
type: actionTypes.ADD_NOTE,
durationId: selectedDurationId,
id: nanoid(),
string: selectedStringNumber,
fret:
fretInputTime - lastFretInputTime < SAME_FRET_NUMBER_CUTOFF_TIME &&
newFretNumber <= MAXIMUM_FRET_NUMBER
? newFretNumber
: enteredFretNumber,
});
},
[
notes,
selectedDurationId,
selectedDuration,
selectedStringNumber,
lastFretInputTime,
]
);

These can live in a custom hook instead: https://stackoverflow.com/questions/59556939/reactjs-create-a-separate-file-for-functions-that-dispatch-actions

ArrowLeft/ArrowRight listeners add/remove dotting for no reason

Repro steps:

  1. Select a dotted note.
  2. Use ArrowLeft/ArrowRight to select an adjacent duration containing at least one note.
    • This can be a duration in an adjacent measure.
  3. If the adjacent duration was not dotted, it will be now.

Similar behavior occurs when a dotted note is navigated to from an undotted note; the target note's dotting is removed.

This bug doesn't occur when selecting adjacent durations on click.

Note: Changing a duration's dotting when selecting with arrow keys is desired when the target duration does not contain any notes.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.