The playback system is straightforward that it maps every note into its timestamps when to start/stop playing. Included are the start and end indices when the notes are highlighted in the data textarea.master
@@ -0,0 +1,32 @@ | |||
import {useEffect} from 'react'; | |||
const Timer = ({ | |||
interval, | |||
onTick, | |||
resume = false, | |||
start, | |||
}) => { | |||
useEffect(() => { | |||
let intervalHandler | |||
if (resume) { | |||
intervalHandler = setInterval(() => { | |||
onTick(Date.now()) | |||
}, interval) | |||
} | |||
return () => { | |||
if (intervalHandler) { | |||
clearInterval(intervalHandler) | |||
} | |||
} | |||
}, [interval, resume]) | |||
useEffect(() => { | |||
onTick(start) | |||
}, [start]) | |||
return ( | |||
<></> | |||
) | |||
} | |||
export default Timer |
@@ -1,32 +1,69 @@ | |||
import {CSSProperties, FC, FormEventHandler, useEffect, useRef, useState} from 'react'; | |||
import {models} from '@tonality/library-common'; | |||
import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' | |||
import styled from 'styled-components'; | |||
import MusicalKeyboard from '@theoryofnekomata/react-musical-keyboard'; | |||
import getFormValues from '@theoryofnekomata/formxtra'; | |||
import TextInput from '../../../molecules/forms/TextInput'; | |||
import TextArea from '../../../molecules/forms/TextArea'; | |||
import ActionButton from '../../../molecules/forms/ActionButton'; | |||
import {convertToRawDuration, formatKey} from '../../../../utils/format/keyData'; | |||
import ToggleButton from '../../../molecules/forms/ToggleButton'; | |||
import NumericInput from '../../../molecules/forms/NumericInput'; | |||
import WaveOscillator from '../../../../utils/sound/WaveOscillator'; | |||
import {parseSong} from '../../../../utils/format/song'; | |||
import {ALLOWED_DURATIONS, toRawDuration} from '@tonality/library-song-utils'; | |||
import Timer from '../../../molecules/timer/Timer'; | |||
const Form = styled('form')({ | |||
const KeyboardContainer = styled(LeftSidebarWithMenu.ContentContainer)({ | |||
'--color-natural-key': 'var(--color-bg, white)', | |||
'--color-accidental-key': 'var(--color-fg, black)', | |||
maxWidth: 'none', | |||
marginTop: '1rem', | |||
marginBottom: '1rem', | |||
padding: 0, | |||
overflow: 'auto', | |||
'@media (min-width: 1080px)': { | |||
maxWidth: 'calc(var(--config-base-width,360) * 2)', | |||
padding: '0 1rem', | |||
}, | |||
}) | |||
const KeyboardSizer = styled('div')({ | |||
width: 1500, | |||
'@media (min-width: 1080px)': { | |||
width: '100%', | |||
}, | |||
}) | |||
const Form = styled('form')({}); | |||
const FormContents = styled('div')({ | |||
display: 'grid', | |||
gap: '1rem', | |||
}); | |||
}) | |||
const Primary = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: 'auto 6rem', | |||
gap: '1rem', | |||
}) | |||
const Toolbar = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: '6fr 1fr 1fr 1fr', | |||
gap: '1rem', | |||
fontSize: '1.5rem', | |||
'@media (min-width: 720px)': { | |||
gridTemplateColumns: '2fr 1fr', | |||
}, | |||
}) | |||
const DurationSelector = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: 'repeat(6, 1fr)', | |||
gap: '0.25rem', | |||
}) | |||
const OtherTools = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: 'repeat(3, 1fr)', | |||
gap: '1rem', | |||
}) | |||
const NOTE_GLYPHS = { | |||
@@ -52,6 +89,13 @@ type Props = { | |||
action?: string, | |||
labels: Record<string, string>, | |||
defaultValues?: Partial<models.Ringtone>, | |||
updateTempo: ({ dataRef, setTempo, }) => (...args: unknown[]) => unknown, | |||
updateView: ({ setNoteGlyph, setRestGlyph, noteGlyphs, restGlyphs, }) => (...args: unknown[]) => unknown, | |||
addRest: ({ formRef, dataRef, }) => (...args: unknown[]) => unknown, | |||
addNote: ({ dataRef, formRef, }) => (...args: unknown[]) => unknown, | |||
togglePlayback: ({ formRef, setPlayTimestamp, setPlaying, }) => (...args: unknown[]) => unknown, | |||
updateSong: ({ formRef, }) => (...args: unknown[]) => unknown, | |||
play: ({ dataRef, playing, setPlaying, setNotes, playTimestamp, currentTime, }) => unknown, | |||
} | |||
const CreateRingtoneForm: FC<Props> = ({ | |||
@@ -59,318 +103,183 @@ const CreateRingtoneForm: FC<Props> = ({ | |||
action, | |||
labels, | |||
defaultValues = {}, | |||
updateTempo, | |||
updateView, | |||
addRest, | |||
addNote, | |||
togglePlayback, | |||
updateSong, | |||
play, | |||
}) => { | |||
const [hydrated, setHydrated] = useState(false); | |||
const [tempo, setTempo] = useState(defaultValues.tempo || 120); | |||
const [currentTime, setCurrentTime] = useState(0) | |||
const [noteGlyph, setNoteGlyph] = useState(NOTE_GLYPHS[4]); | |||
const [restGlyph, setRestGlyph] = useState(REST_GLYPHS[4]); | |||
const [playTimestamp, setPlayTimestamp] = useState(0) | |||
const [playing, setPlaying] = useState(false) | |||
const [currentTime, setCurrentTime] = useState(0) | |||
const [notes, setNotes] = useState([]) | |||
const dataRef = useRef<HTMLTextAreaElement>(null); | |||
const formRef = useRef<HTMLFormElement>(null); | |||
const songRef = useRef({ | |||
notes: [], | |||
duration: 0, | |||
index: 0, | |||
}); | |||
const addNote = (args) => { | |||
if (!(args.length > 0 && dataRef.current && formRef.current)) { | |||
return; | |||
} | |||
const values = getFormValues(formRef.current); | |||
const isDotted = Boolean(values['dotted']) | |||
const duration = Number(values['duration']) as (1 | 2 | 4 | 8 | 16 | 32) | |||
const tempo = Number(values['tempo']) | |||
const rawDuration = convertToRawDuration(duration, tempo) | |||
const formattedKeyData = formatKey({ | |||
key: args[0].key, | |||
dotted: isDotted, | |||
duration, | |||
}); | |||
const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() | |||
dataRef.current.value = newSongData; | |||
songRef.current = parseSong(newSongData, Number(values['tempo'])) | |||
const oscillator = new WaveOscillator() | |||
oscillator.start(args[0].key) | |||
setTimeout(() => { | |||
oscillator.stop() | |||
}, rawDuration) | |||
}; | |||
const addRest = () => { | |||
if (!(formRef.current && dataRef.current)) { | |||
return; | |||
} | |||
const values = getFormValues(formRef.current); | |||
const formattedKeyData = formatKey({ | |||
dotted: Boolean(values['dotted']), | |||
duration: Number(values['duration']) as (1 | 2 | 4 | 8 | 16 | 32), | |||
}); | |||
const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() | |||
dataRef.current.value = newSongData; | |||
songRef.current = parseSong(newSongData, Number(values['tempo'])) | |||
}; | |||
const updateTempo = (e) => { | |||
if (!isNaN(e.target.valueAsNumber) && e.target.valueAsNumber > 0) { | |||
songRef.current = parseSong(dataRef.current.value, e.target.valueAsNumber) | |||
} | |||
} | |||
const togglePlayback = () => { | |||
if (!formRef.current) { | |||
return | |||
} | |||
setPlayTimestamp(Date.now()) | |||
setPlaying(p => { | |||
const newValue = !p | |||
songRef.current.notes.forEach(n => { | |||
if (!isNaN(n.key)) { | |||
n.osc = n.osc || new WaveOscillator() | |||
if (!newValue && !n.stopped) { | |||
n.osc.stop() | |||
} | |||
} | |||
n.played = newValue ? false : n.played | |||
n.stopped = newValue ? false : n.stopped | |||
}) | |||
return newValue | |||
}) | |||
const handleTick = (now) => { | |||
setCurrentTime(now) | |||
} | |||
const updateSong = () => { | |||
const values = getFormValues(formRef.current); | |||
songRef.current = parseSong(values['data'], Number(values['tempo'])) | |||
} | |||
const updateView = (e) => { | |||
const duration = Number(e.target.value); | |||
setNoteGlyph(NOTE_GLYPHS[duration]); | |||
setRestGlyph(REST_GLYPHS[duration]); | |||
}; | |||
useEffect(() => { | |||
let interval = setInterval(() => { | |||
setCurrentTime(Date.now()) | |||
}, 50) | |||
return () => { | |||
clearInterval(interval) | |||
if (typeof play === 'function') { | |||
play({dataRef, playing, currentTime, setNotes, setPlaying, playTimestamp,}) | |||
} | |||
}, []) | |||
useEffect(() => { | |||
if (!playing) { | |||
return | |||
} | |||
const cursor = currentTime - playTimestamp | |||
if (cursor >= songRef.current.duration) { | |||
setPlaying(false) | |||
dataRef.current.setSelectionRange(null, null) | |||
} | |||
songRef.current.notes.forEach((n) => { | |||
const toPlay = !n.played && n.start < cursor | |||
const toStop = !n.stopped && n.end < cursor | |||
if (toPlay) { | |||
if (!isNaN(n.key) && n.osc) { | |||
n.osc.start(n.key) | |||
} | |||
dataRef.current.select() | |||
dataRef.current.setSelectionRange(n.startIndex, n.endIndex) | |||
n.played = true | |||
} | |||
if (toStop) { | |||
if (!isNaN(n.key) && n.osc) { | |||
n.osc.stop() | |||
} | |||
n.stopped = true | |||
} | |||
}) | |||
}, [currentTime, playTimestamp, playing]) | |||
}, [playTimestamp, playing, currentTime, setNotes, setPlaying, dataRef]) | |||
useEffect(() => { | |||
setHydrated(true); | |||
}, []); | |||
setHydrated(true) | |||
}, []) | |||
// @ts-ignore | |||
return ( | |||
<Form | |||
onSubmit={onSubmit} | |||
method="post" | |||
action={action} | |||
aria-label={labels['form']} | |||
ref={formRef} | |||
> | |||
{ | |||
typeof (defaultValues.id as unknown) === 'string' | |||
&& ( | |||
<input | |||
type="hidden" | |||
name="id" | |||
defaultValue={defaultValues.id} | |||
/> | |||
) | |||
} | |||
<input | |||
type="hidden" | |||
name="composerId" | |||
defaultValue={defaultValues.composerId} | |||
/> | |||
<TextInput | |||
label={labels['name'] || 'Name'} | |||
name="name" | |||
block | |||
defaultValue={defaultValues.name} | |||
/> | |||
<NumericInput | |||
label={labels['tempo'] || 'Tempo'} | |||
name="tempo" | |||
block | |||
defaultValue={defaultValues.tempo || 120} | |||
onChange={updateTempo} | |||
disabled={playing} | |||
/> | |||
{ | |||
hydrated | |||
&& ( | |||
<Toolbar> | |||
<DurationSelector> | |||
<ToggleButton | |||
name="duration" | |||
value="1" | |||
type="radio" | |||
onChange={updateView} | |||
disabled={playing} | |||
> | |||
𝅝 | |||
</ToggleButton> | |||
<ToggleButton | |||
name="duration" | |||
value="2" | |||
type="radio" | |||
onChange={updateView} | |||
disabled={playing} | |||
> | |||
𝅗𝅥 | |||
</ToggleButton> | |||
<ToggleButton | |||
name="duration" | |||
value="4" | |||
defaultChecked | |||
type="radio" | |||
onChange={updateView} | |||
disabled={playing} | |||
> | |||
♩ | |||
</ToggleButton> | |||
<ToggleButton | |||
name="duration" | |||
value="8" | |||
type="radio" | |||
onChange={updateView} | |||
disabled={playing} | |||
> | |||
♪ | |||
</ToggleButton> | |||
<ToggleButton | |||
name="duration" | |||
value="16" | |||
type="radio" | |||
onChange={updateView} | |||
disabled={playing} | |||
> | |||
𝅘𝅥𝅯 | |||
</ToggleButton> | |||
<ToggleButton | |||
name="duration" | |||
value="32" | |||
type="radio" | |||
onChange={updateView} | |||
<> | |||
<Form | |||
onSubmit={onSubmit} | |||
method="post" | |||
action={action} | |||
aria-label={labels['form']} | |||
ref={formRef} | |||
> | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<FormContents> | |||
{ | |||
typeof (defaultValues.id as unknown) === 'string' | |||
&& ( | |||
<input | |||
type="hidden" | |||
name="id" | |||
defaultValue={defaultValues.id} | |||
/> | |||
) | |||
} | |||
<input | |||
type="hidden" | |||
name="composerId" | |||
defaultValue={defaultValues.composerId} | |||
/> | |||
<Primary> | |||
<TextInput | |||
label={labels['name'] || 'Name'} | |||
name="name" | |||
block | |||
defaultValue={defaultValues.name} | |||
/> | |||
<NumericInput | |||
label={labels['tempo'] || 'Tempo'} | |||
name="tempo" | |||
block | |||
defaultValue={defaultValues.tempo || 120} | |||
onChange={typeof updateTempo === 'function' ? updateTempo({ dataRef, setTempo, }) : undefined} | |||
disabled={playing} | |||
> | |||
𝅘𝅥𝅰 | |||
</ToggleButton> | |||
</DurationSelector> | |||
<ToggleButton | |||
name="dotted" | |||
type="checkbox" | |||
disabled={playing} | |||
> | |||
{noteGlyph + ' + .'} | |||
</ToggleButton> | |||
<ActionButton | |||
onClick={addRest} | |||
disabled={playing} | |||
> | |||
{restGlyph} | |||
</ActionButton> | |||
<ActionButton | |||
onClick={togglePlayback} | |||
> | |||
{playing ? '⏹' : '▶️'} | |||
</ActionButton> | |||
</Toolbar> | |||
) | |||
} | |||
<TextArea | |||
label={labels['data'] || 'Data'} | |||
name="data" | |||
block | |||
defaultValue={defaultValues.data} | |||
readOnly={playing} | |||
ref={dataRef} | |||
onBlur={updateSong} | |||
style={{ | |||
height: '12rem', | |||
}} | |||
/> | |||
{ | |||
hydrated | |||
&& ( | |||
<div | |||
style={{ | |||
'--color-natural-key': 'var(--color-bg, white)', | |||
'--color-accidental-key': 'var(--color-fg, black)', | |||
'--color-active-key': 'Highlight', | |||
'--opacity-highlight': '1 !important', | |||
} as CSSProperties} | |||
> | |||
<MusicalKeyboard | |||
startKey={60} | |||
endKey={60 + (12 * 4) - 1} | |||
onChange={!playing ? addNote : undefined} | |||
keyLabels={k => { | |||
if (k % 12 === 0) { | |||
return (Math.floor(k / 12) - 1).toString(); | |||
} | |||
return ''; | |||
/> | |||
</Primary> | |||
{ | |||
hydrated | |||
&& ( | |||
<Toolbar> | |||
<DurationSelector> | |||
{ | |||
ALLOWED_DURATIONS.map(d => ( | |||
<ToggleButton | |||
key={d} | |||
name="duration" | |||
value={d.toString()} | |||
type="radio" | |||
onChange={typeof updateView === 'function' ? updateView({ setNoteGlyph, setRestGlyph, noteGlyphs: NOTE_GLYPHS, restGlyphs: REST_GLYPHS, }) : undefined} | |||
disabled={playing} | |||
defaultChecked={d === 4} | |||
> | |||
{NOTE_GLYPHS[d]} | |||
</ToggleButton> | |||
)) | |||
} | |||
</DurationSelector> | |||
<OtherTools> | |||
<ToggleButton | |||
name="dotted" | |||
type="checkbox" | |||
disabled={playing} | |||
> | |||
{noteGlyph + ' + .'} | |||
</ToggleButton> | |||
<ActionButton | |||
onClick={typeof addRest === 'function' ? addRest({ formRef, dataRef, }) : undefined} | |||
disabled={playing} | |||
> | |||
{restGlyph} | |||
</ActionButton> | |||
<ActionButton | |||
onClick={typeof togglePlayback === 'function' ? togglePlayback({ formRef, setPlayTimestamp, setPlaying, }) : undefined} | |||
> | |||
{playing ? '⏹' : '▶️'} | |||
</ActionButton> | |||
</OtherTools> | |||
</Toolbar> | |||
) | |||
} | |||
<TextArea | |||
label={labels['data'] || 'Data'} | |||
name="data" | |||
block | |||
defaultValue={defaultValues.data} | |||
readOnly={playing} | |||
ref={dataRef} | |||
onBlur={typeof updateSong === 'function' ? updateSong({ formRef, }) : undefined} | |||
style={{ | |||
height: '6rem', | |||
}} | |||
keysOn={ | |||
playing | |||
? songRef.current.notes | |||
.filter(n => n.played && !n.stopped && !isNaN(n.key)) | |||
.map(k => ({ key: k.key, velocity: 127, })) | |||
: [] | |||
} | |||
/> | |||
</div> | |||
) | |||
} | |||
<ActionButton | |||
type="submit" | |||
block | |||
> | |||
{labels['cta'] || 'Post'} | |||
</ActionButton> | |||
</Form> | |||
</FormContents> | |||
</LeftSidebarWithMenu.ContentContainer> | |||
{ | |||
hydrated | |||
&& ( | |||
<KeyboardContainer> | |||
<KeyboardSizer> | |||
<MusicalKeyboard | |||
startKey={60} | |||
endKey={60 + (12 * 4) - 1} | |||
onChange={!playing && typeof addNote === 'function' ? addNote({ dataRef, formRef, }) : undefined} | |||
keyLabels={k => { | |||
if (k % 12 === 0) { | |||
return (Math.floor(k / 12) - 1).toString(); | |||
} | |||
return ''; | |||
}} | |||
keysOn={ | |||
playing | |||
? notes | |||
: [] | |||
} | |||
/> | |||
</KeyboardSizer> | |||
</KeyboardContainer> | |||
) | |||
} | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<ActionButton | |||
type="submit" | |||
block | |||
> | |||
{labels['cta'] || 'Post'} | |||
</ActionButton> | |||
</LeftSidebarWithMenu.ContentContainer> | |||
</Form> | |||
<Timer | |||
onTick={handleTick} | |||
interval={toRawDuration(32, tempo)} | |||
resume={playing} | |||
start={playTimestamp} | |||
/> | |||
</> | |||
); | |||
}; | |||
export default CreateRingtoneForm; | |||
/* | |||
8g4 8g4 4a4 4g4 4c5 2b4 8g4 8g4 4a4 4g4 4d5 2c5 8g4 8g4 4g5 4e5 8c5 8c5 4b4 4a4 8f5 8f5 4e5 4c5 4d5 2c5. | |||
*/ |
@@ -15,12 +15,24 @@ const SidebarMenuComponent = styled('div')({ | |||
backgroundColor: 'var(--color-bg, white)', | |||
}) | |||
const Padding = styled('div')({ | |||
padding: '2rem 0', | |||
}) | |||
type Props = { | |||
onSearch?: FormEventHandler, | |||
onSubmit?: FormEventHandler, | |||
composer: models.Composer, | |||
currentRingtone?: models.Ringtone, | |||
composerRingtones: models.Ringtone[], | |||
updateTempo: ({ songRef, dataRef, setTempo, }) => (...args: unknown[]) => void, | |||
updateView: ({ setNoteGlyph, setRestGlyph, noteGlyphs, restGlyphs, }) => (...args: unknown[]) => void, | |||
addRest: ({ formRef, dataRef, songRef, }) => (...args: unknown[]) => void, | |||
addNote: ({ dataRef, formRef, songRef, soundManagerRef, }) => (...args: unknown[]) => void, | |||
togglePlayback: ({ formRef, songRef, soundManagerRef, setPlayTimestamp, setPlaying, }) => (...args: unknown[]) => void, | |||
updateSong: ({ formRef, songRef, }) => (...args: unknown[]) => void, | |||
play: ({ dataRef, playing, setPlaying, playTimestamp, }) => void, | |||
} | |||
const CreateRingtoneTemplate: FC<Props> = ({ | |||
@@ -29,6 +41,13 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||
composer, | |||
currentRingtone = {}, | |||
composerRingtones = [], | |||
updateTempo, | |||
updateView, | |||
addRest, | |||
addNote, | |||
togglePlayback, | |||
updateSong, | |||
play, | |||
}) => { | |||
return ( | |||
<LeftSidebarWithMenu.Layout | |||
@@ -100,7 +119,8 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||
}, | |||
]} | |||
> | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<Padding> | |||
<CreateRingtoneForm | |||
onSubmit={onSubmit} | |||
defaultValues={{ | |||
@@ -114,8 +134,15 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||
data: 'Data', | |||
cta: 'Post', | |||
}} | |||
updateTempo={updateTempo} | |||
updateView={updateView} | |||
addRest={addRest} | |||
addNote={addNote} | |||
togglePlayback={togglePlayback} | |||
updateSong={updateSong} | |||
play={play} | |||
/> | |||
</LeftSidebarWithMenu.ContentContainer> | |||
</Padding> | |||
</LeftSidebarWithMenu.Layout> | |||
) | |||
} | |||
@@ -0,0 +1,130 @@ | |||
import getFormValues from '@theoryofnekomata/formxtra'; | |||
import { | |||
Duration, | |||
parseSong, | |||
PITCH_CLASSES, | |||
Playable, | |||
SongData, | |||
stringifyNote, | |||
toRawDuration, | |||
} from '@tonality/library-song-utils' | |||
import SoundManager from '../../utils/sound/SoundManager' | |||
interface PlayableWithOscillator extends Playable { | |||
played: boolean, | |||
stopped: boolean, | |||
} | |||
export default class ComposerClient { | |||
private songRef: SongData<PlayableWithOscillator> | |||
constructor(private readonly soundManagerRef: SoundManager) {} | |||
addNote = ({ dataRef, formRef, }) => (args) => { | |||
if (!(args.length > 0 && dataRef.current && formRef.current)) { | |||
return; | |||
} | |||
const values = getFormValues(formRef.current); | |||
const isDotted = Boolean(values['dotted']) | |||
const duration = Number(values['duration']) as Duration | |||
const tempo = Number(values['tempo']) | |||
const rawDuration = toRawDuration(duration, tempo) | |||
const formattedKeyData = stringifyNote({ | |||
octave: Math.floor((args[0].key / 12) - 1), | |||
pitchClass: PITCH_CLASSES[args[0].key % 12], | |||
dotted: isDotted, | |||
duration, | |||
}); | |||
const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() | |||
dataRef.current.value = newSongData; | |||
this.songRef = parseSong(newSongData, Number(values['tempo'])) | |||
this.soundManagerRef.start(args[0].key) | |||
setTimeout(() => { | |||
this.soundManagerRef.stop(args[0].key) | |||
}, rawDuration) | |||
}; | |||
addRest = ({ formRef, dataRef, }) => () => { | |||
if (!(formRef.current && dataRef.current)) { | |||
return; | |||
} | |||
const values = getFormValues(formRef.current); | |||
const formattedKeyData = stringifyNote({ | |||
dotted: Boolean(values['dotted']), | |||
duration: Number(values['duration']) as Duration, | |||
}); | |||
const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() | |||
dataRef.current.value = newSongData; | |||
this.songRef = parseSong(newSongData, Number(values['tempo'])) | |||
}; | |||
updateTempo = ({ dataRef, setTempo, }) => (e) => { | |||
if (!isNaN(e.target.valueAsNumber) && e.target.valueAsNumber > 0) { | |||
this.songRef = parseSong(dataRef.current.value, e.target.valueAsNumber) | |||
setTempo(e.target.valueAsNumber) | |||
} | |||
} | |||
updateSong = ({ formRef, }) => () => { | |||
const values = getFormValues(formRef.current); | |||
this.songRef = parseSong(values['data'], Number(values['tempo'])) | |||
} | |||
updateView = ({ setNoteGlyph, setRestGlyph, noteGlyphs, restGlyphs, }) => (e) => { | |||
const duration = Number(e.target.value); | |||
setNoteGlyph(noteGlyphs[duration]); | |||
setRestGlyph(restGlyphs[duration]); | |||
} | |||
togglePlayback = ({ setPlayTimestamp, setPlaying, }) => () => { | |||
setPlayTimestamp(Date.now()) | |||
setPlaying(p => { | |||
const newValue = !p | |||
this.songRef.playables.forEach(n => { | |||
if (!isNaN(n.key)) { | |||
if (!newValue && !n.stopped) { | |||
this.soundManagerRef.stop(n.key) | |||
} | |||
} | |||
n.played = newValue ? false : n.played | |||
n.stopped = newValue ? false : n.stopped | |||
}) | |||
return newValue | |||
}) | |||
} | |||
play = ({ dataRef, playing, currentTime, setNotes, setPlaying, playTimestamp, }) => { | |||
if (!playing) { | |||
return | |||
} | |||
const cursor = currentTime - 100 - playTimestamp | |||
if (cursor >= this.songRef.duration) { | |||
setPlaying(false) | |||
dataRef.current.setSelectionRange(null, null) | |||
this.soundManagerRef.stop(this.songRef.playables.slice(-1)[0].key) | |||
} | |||
this.songRef.playables.forEach((n) => { | |||
const toPlay = !n.played && n.start < cursor | |||
const toStop = !n.stopped && n.end < cursor | |||
if (toPlay) { | |||
if (!isNaN(n.key)) { | |||
this.soundManagerRef.start(n.key) | |||
} | |||
dataRef.current.select() | |||
dataRef.current.setSelectionRange(n.startIndex, n.endIndex) | |||
n.played = true | |||
setNotes(oldNotes => [...oldNotes, { velocity: 127, key: n.key }]) | |||
} | |||
if (toStop) { | |||
if (!isNaN(n.key)) { | |||
this.soundManagerRef.stop(n.key) | |||
} | |||
n.stopped = true | |||
setNotes(oldNotes => oldNotes.filter(c => c.key !== n.key)) | |||
} | |||
}) | |||
} | |||
} |
@@ -5,9 +5,10 @@ import * as endpoints from './endpoints' | |||
export default class RingtoneClient { | |||
private readonly fetchClient: FetchClient | |||
constructor() { | |||
constructor(private readonly baseUrl) { | |||
this.fetchClient = createFetchClient({ | |||
baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL, | |||
baseUrl, | |||
}) | |||
} | |||
@@ -1,9 +1,12 @@ | |||
import {GetServerSideProps, NextPage} from 'next' | |||
import {useRef} from 'react' | |||
import {useEffect, useState} from 'react'; | |||
import {models} from '@tonality/library-common' | |||
import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | |||
import RingtoneClient from '../../../../modules/ringtone/client' | |||
import {getSession, withPageAuthRequired} from '@auth0/nextjs-auth0'; | |||
import WaveOscillator from '../../../../utils/sound/WaveOscillator'; | |||
import SoundManager from '../../../../utils/sound/SoundManager'; | |||
import ComposerClient from '../../../../modules/composer/client'; | |||
type Props = { | |||
user: models.Composer, | |||
@@ -14,12 +17,40 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||
user, | |||
composerRingtones, | |||
}) => { | |||
const ringtoneClient = useRef(new RingtoneClient()) | |||
const [hydrated, setHydrated] = useState(false) | |||
const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null) | |||
const [composerClient, setComposerClient] = useState<ComposerClient>(null) | |||
useEffect(() => { | |||
setHydrated(true) | |||
}, []) | |||
useEffect(() => { | |||
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL)) | |||
}, [hydrated]) | |||
useEffect(() => { | |||
const audioContext = new AudioContext() | |||
const gainNode = audioContext.createGain() | |||
gainNode.gain.value = 0.05 | |||
gainNode.connect(audioContext.destination) | |||
const oscillator = new WaveOscillator(audioContext, gainNode) | |||
const soundManager = new SoundManager(oscillator) | |||
setComposerClient(new ComposerClient(soundManager)) | |||
}, [hydrated]) | |||
return ( | |||
<CreateRingtoneTemplate | |||
composer={user} | |||
composerRingtones={composerRingtones} | |||
onSubmit={ringtoneClient.current.save} | |||
addNote={composerClient ? composerClient.addNote : undefined} | |||
addRest={composerClient ? composerClient.addRest : undefined} | |||
togglePlayback={composerClient ? composerClient.togglePlayback : undefined} | |||
updateSong={composerClient ? composerClient.updateSong : undefined} | |||
updateTempo={composerClient ? composerClient.updateTempo : undefined} | |||
updateView={composerClient ? composerClient.updateView : undefined} | |||
onSubmit={ringtoneClient ? ringtoneClient.save : undefined} | |||
play={composerClient ? composerClient.play : undefined} | |||
/> | |||
) | |||
} | |||
@@ -45,3 +76,12 @@ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ | |||
}) | |||
export default MyCreateRingtonePage | |||
/* | |||
8g4 8g4 4a4 4g4 4c5 2b4 8g4 8g4 4a4 4g4 4d5 2c5 8g4 8g4 4g5 4e5 8c5 8c5 4b4 4a4 8f5 8f5 4e5 4c5 4d5 2c5. | |||
*/ | |||
/* | |||
32c4 32g4 32e5 32e4 32c5 32g5 32g4 32e5 32c6 32c5 32g5 32e6 32e5 32c6 32g6 32g5 32e6 32c7 32c6 32g6 32e7 32e6 32c7 32g7 | |||
*/ |
@@ -1,5 +1,5 @@ | |||
import {GetServerSideProps, NextPage} from 'next' | |||
import {useRef} from 'react' | |||
import {useEffect, useState} from 'react'; | |||
import {models} from '@tonality/library-common' | |||
import RingtoneClient from '../../modules/ringtone/client' | |||
@@ -12,7 +12,17 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||
user, | |||
composerRingtones, | |||
}) => { | |||
const ringtoneClient = useRef(new RingtoneClient()) | |||
const [hydrated, setHydrated] = useState(false) | |||
const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null) | |||
useEffect(() => { | |||
setHydrated(true) | |||
}, []) | |||
useEffect(() => { | |||
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL)) | |||
}, [hydrated]) | |||
return ( | |||
<div> | |||
Hello | |||
@@ -1,83 +0,0 @@ | |||
type KeyOrRest = { | |||
key?: number, | |||
duration: 1 | 2 | 4 | 8 | 16 | 32, | |||
dotted?: boolean, | |||
} | |||
export const formatKey = ({ key, dotted, duration }: KeyOrRest) => { | |||
if (isNaN(key)) { | |||
return dotted ? `${duration}p.` : `${duration}p` | |||
} | |||
const pitchClassName = 'c,c#,d,d#,e,f,f#,g,g#,a,a#,b'.split(',') | |||
const pitchClass = key % 12 | |||
const octave = Math.floor(key / 12) - 1 | |||
return dotted ? `${duration}${pitchClassName[pitchClass]}${octave}.` : `${duration}${pitchClassName[pitchClass]}${octave}` | |||
} | |||
type Duration = 1 | 2 | 4 | 8 | 16 | 32 | |||
const ALLOWED_DURATIONS = [1, 2, 4, 8, 16, 32] as readonly Duration[] | |||
type PitchClass = 'c' | 'c#' | 'd' | 'd#' | 'e' | 'f' | 'f#' | 'g' | 'g#' | 'a' | 'a#' | |||
const PITCH_CLASSES = 'c,c#,d,d#,e,f,f#,g,g#,a,a#,b'.split(',') as readonly PitchClass[] | |||
type Note = { | |||
duration: Duration, | |||
dotted?: boolean, | |||
octave?: number, | |||
pitchClass?: PitchClass, | |||
} | |||
export const parseNote = (s: string): Note => { | |||
const duration = parseInt(s) as Duration | |||
if (!ALLOWED_DURATIONS.includes(duration)) { | |||
throw new TypeError('Invalid duration') | |||
} | |||
const durationStr = duration.toString() | |||
const dotted = s.endsWith('.') | |||
const pitchClass = s.slice(durationStr.length, dotted ? -2 : -1) as PitchClass | |||
const pitchClassIndex = PITCH_CLASSES.indexOf(pitchClass) | |||
if (pitchClassIndex !== -1) { | |||
const octave = parseInt(dotted ? s.slice(-2) : s.slice(-1)) | |||
if (!isNaN(octave)) { | |||
return { | |||
duration, | |||
dotted, | |||
octave, | |||
pitchClass, | |||
} | |||
} | |||
} | |||
return { | |||
duration, | |||
dotted, | |||
} | |||
} | |||
export const convertToRawDuration = (duration: number, tempo: number) => { | |||
if (isNaN(tempo)) { | |||
throw new RangeError('Tempo should not be NaN.') | |||
} | |||
if (!isFinite(tempo)) { | |||
throw new RangeError('Tempo should be a finite value.') | |||
} | |||
if (tempo <= 0) { | |||
throw new RangeError('Tempo should be a positive value.') | |||
} | |||
return (4 / duration) * (60000 / tempo) | |||
} | |||
export const toPlayable = (n: Note, tempo: number) => { | |||
const rawDurationBase = convertToRawDuration(n.duration, tempo) | |||
const duration = n.dotted ? rawDurationBase * 1.5 : rawDurationBase | |||
const pitchClassIndex = PITCH_CLASSES.indexOf(n.pitchClass) | |||
if (!isNaN(pitchClassIndex) && 0 <= pitchClassIndex && pitchClassIndex <= 11 && !isNaN(n.octave)) { | |||
return { | |||
duration, | |||
key: pitchClassIndex + (12 * (n.octave + 1)) | |||
} | |||
} | |||
return { | |||
duration, | |||
} | |||
} |
@@ -1,33 +0,0 @@ | |||
import {toPlayable, parseNote} from './keyData'; | |||
export const parseSong = (s: string, tempo: number) => { | |||
return s.split(' ').reduce((state, n, notes) => { | |||
let note | |||
try { | |||
note = parseNote(n) | |||
} catch { | |||
return state | |||
} | |||
const playable = toPlayable(note, tempo) | |||
return { | |||
...state, | |||
notes: [ | |||
...state.notes, | |||
{ | |||
key: playable.key, | |||
start: state.duration, | |||
end: state.duration + playable.duration, | |||
startIndex: state.index, | |||
endIndex: state.index + n.length | |||
} | |||
], | |||
duration: state.duration + playable.duration, | |||
index: state.index + n.length + 1 | |||
} | |||
}, { | |||
notes: [], | |||
duration: 0, | |||
index: 0, | |||
}) | |||
} |
@@ -1,15 +1,17 @@ | |||
import WaveOscillator from './WaveOscillator'; | |||
interface Oscillator { | |||
start(key: number), | |||
stop(key: number), | |||
} | |||
export default class SoundManager { | |||
private readonly oscillator | |||
constructor(private readonly oscillator: Oscillator) {} | |||
constructor() { | |||
this.oscillator = new WaveOscillator() | |||
start(key) { | |||
this.oscillator.start(key) | |||
} | |||
beep(key, duration) { | |||
this.oscillator.beep(key, duration) | |||
stop(key) { | |||
this.oscillator.stop(key) | |||
} | |||
playSong(s: string) { | |||
@@ -1,26 +1,29 @@ | |||
let oscillator: OscillatorNode | |||
export default class WaveOscillator { | |||
private oscillator: OscillatorNode | |||
private readonly oscillators: Record<number, OscillatorNode> = {} | |||
constructor(private readonly audioContext: AudioContext, private readonly audioDestinationNode: AudioNode) {} | |||
private static getKeyFrequency(keyNumber: number) { | |||
return 440 * Math.pow(Math.pow(2, 1 / 12), keyNumber - 69) | |||
} | |||
start(keyNumber: number) { | |||
const audioContext = new AudioContext() | |||
const gainNode = audioContext.createGain() | |||
gainNode.gain.value = 0.05 | |||
this.oscillators[keyNumber] = this.audioContext.createOscillator() | |||
this.oscillators[keyNumber].type = 'square' | |||
this.oscillators[keyNumber].frequency.value = WaveOscillator.getKeyFrequency(keyNumber) | |||
this.oscillators[keyNumber].connect(this.audioDestinationNode) | |||
try { | |||
this.oscillators[keyNumber].start() | |||
} catch { | |||
this.oscillator = audioContext.createOscillator() | |||
this.oscillator.type = 'square' | |||
this.oscillator.connect(gainNode) | |||
gainNode.connect(audioContext.destination) | |||
this.oscillator.frequency.value = WaveOscillator.getKeyFrequency(keyNumber) | |||
this.oscillator.start() | |||
} | |||
} | |||
stop() { | |||
stop(keyNumber: number) { | |||
try { | |||
this.oscillator.stop() | |||
this.oscillators[keyNumber].stop() | |||
} catch {} | |||
} | |||
} |
@@ -0,0 +1,4 @@ | |||
*.log | |||
.DS_Store | |||
node_modules | |||
dist |
@@ -0,0 +1,21 @@ | |||
MIT License | |||
Copyright (c) 2021 Allan Crisostomo | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@@ -0,0 +1,103 @@ | |||
# TSDX User Guide | |||
Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it. | |||
> This TSDX setup is meant for developing libraries (not apps!) that can be published to NPM. If you’re looking to build a Node app, you could use `ts-node-dev`, plain `ts-node`, or simple `tsc`. | |||
> If you’re new to TypeScript, checkout [this handy cheatsheet](https://devhints.io/typescript) | |||
## Commands | |||
TSDX scaffolds your new library inside `/src`. | |||
To run TSDX, use: | |||
```bash | |||
npm start # or yarn start | |||
``` | |||
This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. | |||
To do a one-off build, use `npm run build` or `yarn build`. | |||
To run tests, use `npm test` or `yarn test`. | |||
## Configuration | |||
Code quality is set up for you with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly. | |||
### Jest | |||
Jest tests are set up to run with `npm test` or `yarn test`. | |||
### Bundle Analysis | |||
[`size-limit`](https://github.com/ai/size-limit) is set up to calculate the real cost of your library with `npm run size` and visualize the bundle with `npm run analyze`. | |||
#### Setup Files | |||
This is the folder structure we set up for you: | |||
```txt | |||
/src | |||
index.tsx # EDIT THIS | |||
/test | |||
blah.test.tsx # EDIT THIS | |||
.gitignore | |||
package.json | |||
README.md # EDIT THIS | |||
tsconfig.json | |||
``` | |||
### Rollup | |||
TSDX uses [Rollup](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details. | |||
### TypeScript | |||
`tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs. | |||
## Continuous Integration | |||
### GitHub Actions | |||
Two actions are added by default: | |||
- `main` which installs deps w/ cache, lints, tests, and builds on all pushes against a Node and OS matrix | |||
- `size` which comments cost comparison of your library on every pull request using [`size-limit`](https://github.com/ai/size-limit) | |||
## Optimizations | |||
Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations: | |||
```js | |||
// ./types/index.d.ts | |||
declare var __DEV__: boolean; | |||
// inside your code... | |||
if (__DEV__) { | |||
console.log('foo'); | |||
} | |||
``` | |||
You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions. | |||
## Module Formats | |||
CJS, ESModules, and UMD module formats are supported. | |||
The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found. | |||
## Named Exports | |||
Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library. | |||
## Including Styles | |||
There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like. | |||
For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader. | |||
## Publishing to NPM | |||
We recommend using [np](https://github.com/sindresorhus/np). |
@@ -0,0 +1,55 @@ | |||
{ | |||
"version": "0.1.0", | |||
"license": "MIT", | |||
"main": "dist/index.js", | |||
"typings": "dist/index.d.ts", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=10" | |||
}, | |||
"scripts": { | |||
"start": "tsdx watch", | |||
"build": "tsdx build", | |||
"test": "tsdx test", | |||
"lint": "tsdx lint", | |||
"prepare": "tsdx build", | |||
"size": "size-limit", | |||
"analyze": "size-limit --why" | |||
}, | |||
"peerDependencies": {}, | |||
"husky": { | |||
"hooks": { | |||
"pre-commit": "tsdx lint" | |||
} | |||
}, | |||
"prettier": { | |||
"printWidth": 80, | |||
"semi": true, | |||
"singleQuote": true, | |||
"trailingComma": "es5" | |||
}, | |||
"name": "@tonality/library-song-utils", | |||
"author": "Allan Crisostomo", | |||
"module": "dist/library-song-utils.esm.js", | |||
"size-limit": [ | |||
{ | |||
"path": "dist/library-song-utils.cjs.production.min.js", | |||
"limit": "10 KB" | |||
}, | |||
{ | |||
"path": "dist/library-song-utils.esm.js", | |||
"limit": "10 KB" | |||
} | |||
], | |||
"devDependencies": { | |||
"@size-limit/preset-small-lib": "^4.11.0", | |||
"husky": "^6.0.0", | |||
"size-limit": "^4.11.0", | |||
"tsdx": "^0.14.1", | |||
"tslib": "^2.2.0", | |||
"typescript": "^4.3.2" | |||
} | |||
} |
@@ -0,0 +1,15 @@ | |||
export type Duration = 1 | 2 | 4 | 8 | 16 | 32 | |||
export const ALLOWED_DURATIONS = [1, 2, 4, 8, 16, 32] as readonly Duration[] | |||
export const toRawDuration = (duration: number, tempo: number) => { | |||
if (isNaN(tempo)) { | |||
throw new RangeError('Tempo should not be NaN.') | |||
} | |||
if (!isFinite(tempo)) { | |||
throw new RangeError('Tempo should be a finite value.') | |||
} | |||
if (tempo <= 0) { | |||
throw new RangeError('Tempo should be a positive value.') | |||
} | |||
return (4 / duration) * (60000 / tempo) | |||
} |
@@ -0,0 +1,72 @@ | |||
import {Note, parseNote, PITCH_CLASSES} from "./note"; | |||
import {toRawDuration} from './duration'; | |||
export interface Playable { | |||
key?: number, | |||
start: number, | |||
end: number, | |||
startIndex: number, | |||
endIndex: number, | |||
} | |||
export interface SongData<T = Playable> { | |||
playables: T[], | |||
duration: number, | |||
index: number, | |||
} | |||
export * from './note' | |||
export * from './duration' | |||
export const toPlayable = (n: Note, tempo: number) => { | |||
const rawDurationBase = toRawDuration(n.duration, tempo) | |||
const duration = n.dotted ? rawDurationBase * 1.5 : rawDurationBase | |||
const pitchClassIndex = PITCH_CLASSES.indexOf(n.pitchClass!) | |||
if (!isNaN(pitchClassIndex) && 0 <= pitchClassIndex && pitchClassIndex <= 11 && !isNaN(n.octave!)) { | |||
return { | |||
duration, | |||
key: pitchClassIndex + (12 * (n.octave! + 1)) | |||
} | |||
} | |||
return { | |||
duration, | |||
} | |||
} | |||
export const parseSong = <T>(s: string, tempo: number) => { | |||
return s.split(' ').reduce( | |||
(state, n, i, nn) => { | |||
let note | |||
try { | |||
note = parseNote(n) | |||
} catch { | |||
return state | |||
} | |||
const playable = toPlayable(note, tempo) | |||
return { | |||
...state, | |||
playables: [ | |||
...state.playables, | |||
{ | |||
key: playable.key, | |||
start: state.duration, | |||
end: state.duration + playable.duration, | |||
startIndex: state.index, | |||
endIndex: state.index + n.length | |||
} | |||
], | |||
duration: ( | |||
i < nn.length - 1 | |||
? state.duration + playable.duration | |||
: state.duration + playable.duration + 100 | |||
), | |||
index: state.index + n.length + 1 | |||
} as SongData<T> | |||
}, | |||
{ | |||
playables: [] as T[], | |||
duration: 100, | |||
index: 0, | |||
} | |||
) | |||
} |
@@ -0,0 +1,48 @@ | |||
import {ALLOWED_DURATIONS, Duration} from './duration'; | |||
export type Note = { | |||
duration: Duration, | |||
dotted?: boolean, | |||
octave?: number, | |||
pitchClass?: PitchClass, | |||
} | |||
export type PitchClass = 'c' | 'c#' | 'd' | 'd#' | 'e' | 'f' | 'f#' | 'g' | 'g#' | 'a' | 'a#' | |||
export const PITCH_CLASSES = 'c,c#,d,d#,e,f,f#,g,g#,a,a#,b'.split(',') as readonly PitchClass[] | |||
export const stringifyNote = ({ octave: maybeOctave, pitchClass: maybePitchClass, dotted, duration }: Note) => { | |||
const octave = maybeOctave as number | |||
const pitchClass = maybePitchClass as PitchClass | |||
if (isNaN(octave) || !pitchClass || PITCH_CLASSES.indexOf(pitchClass) < 0) { | |||
return dotted ? `${duration}p.` : `${duration}p` | |||
} | |||
return dotted ? `${duration}${pitchClass}${octave}.` : `${duration}${pitchClass}${octave}` | |||
} | |||
export const parseNote = (s: string): Note => { | |||
const duration = parseInt(s) as Duration | |||
if (!ALLOWED_DURATIONS.includes(duration)) { | |||
throw new TypeError('Invalid duration') | |||
} | |||
const durationStr = duration.toString() | |||
const dotted = s.endsWith('.') | |||
const pitchClass = s.slice(durationStr.length, dotted ? -2 : -1) as PitchClass | |||
const pitchClassIndex = PITCH_CLASSES.indexOf(pitchClass) | |||
if (pitchClassIndex !== -1) { | |||
const octave = parseInt(dotted ? s.slice(-2) : s.slice(-1)) | |||
if (!isNaN(octave)) { | |||
return { | |||
duration, | |||
dotted, | |||
octave, | |||
pitchClass, | |||
} | |||
} | |||
} | |||
return { | |||
duration, | |||
dotted, | |||
} | |||
} |
@@ -0,0 +1,35 @@ | |||
{ | |||
// see https://www.typescriptlang.org/tsconfig to better understand tsconfigs | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "esnext", | |||
"lib": ["dom", "esnext"], | |||
"importHelpers": true, | |||
// output .d.ts declaration files for consumers | |||
"declaration": true, | |||
// output .js.map sourcemap files for consumers | |||
"sourceMap": true, | |||
// match output dir to input dir. e.g. dist/index instead of dist/src/index | |||
"rootDir": "./src", | |||
// stricter type-checking for stronger correctness. Recommended by TS | |||
"strict": true, | |||
// linter checks for common issues | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
// noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
// use Node's module resolution algorithm, instead of the legacy TS one | |||
"moduleResolution": "node", | |||
// transpile JSX to React.createElement | |||
"jsx": "react", | |||
// interop between ESM and CJS modules. Recommended by TS | |||
"esModuleInterop": true, | |||
// significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS | |||
"skipLibCheck": true, | |||
// error out if import and file system have a casing mismatch. Recommended by TS | |||
"forceConsistentCasingInFileNames": true, | |||
// `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` | |||
"noEmit": true, | |||
} | |||
} |
@@ -1,9 +0,0 @@ | |||
import {FastifyPluginAsync} from 'fastify' | |||
const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => { | |||
fastify.get('/', async function (request, reply) { | |||
return {root: true}; | |||
}) | |||
} | |||
export default root |