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 {CSSProperties, FC, FormEventHandler, useEffect, useRef, useState} from 'react'; | ||||
import {models} from '@tonality/library-common'; | import {models} from '@tonality/library-common'; | ||||
import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' | |||||
import styled from 'styled-components'; | import styled from 'styled-components'; | ||||
import MusicalKeyboard from '@theoryofnekomata/react-musical-keyboard'; | import MusicalKeyboard from '@theoryofnekomata/react-musical-keyboard'; | ||||
import getFormValues from '@theoryofnekomata/formxtra'; | |||||
import TextInput from '../../../molecules/forms/TextInput'; | import TextInput from '../../../molecules/forms/TextInput'; | ||||
import TextArea from '../../../molecules/forms/TextArea'; | import TextArea from '../../../molecules/forms/TextArea'; | ||||
import ActionButton from '../../../molecules/forms/ActionButton'; | import ActionButton from '../../../molecules/forms/ActionButton'; | ||||
import {convertToRawDuration, formatKey} from '../../../../utils/format/keyData'; | |||||
import ToggleButton from '../../../molecules/forms/ToggleButton'; | import ToggleButton from '../../../molecules/forms/ToggleButton'; | ||||
import NumericInput from '../../../molecules/forms/NumericInput'; | 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', | display: 'grid', | ||||
gap: '1rem', | gap: '1rem', | ||||
}); | |||||
}) | |||||
const Primary = styled('div')({ | |||||
display: 'grid', | |||||
gridTemplateColumns: 'auto 6rem', | |||||
gap: '1rem', | |||||
}) | |||||
const Toolbar = styled('div')({ | const Toolbar = styled('div')({ | ||||
display: 'grid', | display: 'grid', | ||||
gridTemplateColumns: '6fr 1fr 1fr 1fr', | |||||
gap: '1rem', | gap: '1rem', | ||||
fontSize: '1.5rem', | fontSize: '1.5rem', | ||||
'@media (min-width: 720px)': { | |||||
gridTemplateColumns: '2fr 1fr', | |||||
}, | |||||
}) | }) | ||||
const DurationSelector = styled('div')({ | const DurationSelector = styled('div')({ | ||||
display: 'grid', | display: 'grid', | ||||
gridTemplateColumns: 'repeat(6, 1fr)', | gridTemplateColumns: 'repeat(6, 1fr)', | ||||
gap: '0.25rem', | |||||
}) | |||||
const OtherTools = styled('div')({ | |||||
display: 'grid', | |||||
gridTemplateColumns: 'repeat(3, 1fr)', | |||||
gap: '1rem', | |||||
}) | }) | ||||
const NOTE_GLYPHS = { | const NOTE_GLYPHS = { | ||||
@@ -52,6 +89,13 @@ type Props = { | |||||
action?: string, | action?: string, | ||||
labels: Record<string, string>, | labels: Record<string, string>, | ||||
defaultValues?: Partial<models.Ringtone>, | 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> = ({ | const CreateRingtoneForm: FC<Props> = ({ | ||||
@@ -59,318 +103,183 @@ const CreateRingtoneForm: FC<Props> = ({ | |||||
action, | action, | ||||
labels, | labels, | ||||
defaultValues = {}, | defaultValues = {}, | ||||
updateTempo, | |||||
updateView, | |||||
addRest, | |||||
addNote, | |||||
togglePlayback, | |||||
updateSong, | |||||
play, | |||||
}) => { | }) => { | ||||
const [hydrated, setHydrated] = useState(false); | 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 [noteGlyph, setNoteGlyph] = useState(NOTE_GLYPHS[4]); | ||||
const [restGlyph, setRestGlyph] = useState(REST_GLYPHS[4]); | const [restGlyph, setRestGlyph] = useState(REST_GLYPHS[4]); | ||||
const [playTimestamp, setPlayTimestamp] = useState(0) | const [playTimestamp, setPlayTimestamp] = useState(0) | ||||
const [playing, setPlaying] = useState(false) | const [playing, setPlaying] = useState(false) | ||||
const [currentTime, setCurrentTime] = useState(0) | |||||
const [notes, setNotes] = useState([]) | |||||
const dataRef = useRef<HTMLTextAreaElement>(null); | const dataRef = useRef<HTMLTextAreaElement>(null); | ||||
const formRef = useRef<HTMLFormElement>(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(() => { | 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(() => { | useEffect(() => { | ||||
setHydrated(true); | |||||
}, []); | |||||
setHydrated(true) | |||||
}, []) | |||||
// @ts-ignore | // @ts-ignore | ||||
return ( | 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} | 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; | 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)', | backgroundColor: 'var(--color-bg, white)', | ||||
}) | }) | ||||
const Padding = styled('div')({ | |||||
padding: '2rem 0', | |||||
}) | |||||
type Props = { | type Props = { | ||||
onSearch?: FormEventHandler, | onSearch?: FormEventHandler, | ||||
onSubmit?: FormEventHandler, | onSubmit?: FormEventHandler, | ||||
composer: models.Composer, | composer: models.Composer, | ||||
currentRingtone?: models.Ringtone, | currentRingtone?: models.Ringtone, | ||||
composerRingtones: 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> = ({ | const CreateRingtoneTemplate: FC<Props> = ({ | ||||
@@ -29,6 +41,13 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
composer, | composer, | ||||
currentRingtone = {}, | currentRingtone = {}, | ||||
composerRingtones = [], | composerRingtones = [], | ||||
updateTempo, | |||||
updateView, | |||||
addRest, | |||||
addNote, | |||||
togglePlayback, | |||||
updateSong, | |||||
play, | |||||
}) => { | }) => { | ||||
return ( | return ( | ||||
<LeftSidebarWithMenu.Layout | <LeftSidebarWithMenu.Layout | ||||
@@ -100,7 +119,8 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
}, | }, | ||||
]} | ]} | ||||
> | > | ||||
<LeftSidebarWithMenu.ContentContainer> | |||||
<Padding> | |||||
<CreateRingtoneForm | <CreateRingtoneForm | ||||
onSubmit={onSubmit} | onSubmit={onSubmit} | ||||
defaultValues={{ | defaultValues={{ | ||||
@@ -114,8 +134,15 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
data: 'Data', | data: 'Data', | ||||
cta: 'Post', | cta: 'Post', | ||||
}} | }} | ||||
updateTempo={updateTempo} | |||||
updateView={updateView} | |||||
addRest={addRest} | |||||
addNote={addNote} | |||||
togglePlayback={togglePlayback} | |||||
updateSong={updateSong} | |||||
play={play} | |||||
/> | /> | ||||
</LeftSidebarWithMenu.ContentContainer> | |||||
</Padding> | |||||
</LeftSidebarWithMenu.Layout> | </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 { | export default class RingtoneClient { | ||||
private readonly fetchClient: FetchClient | private readonly fetchClient: FetchClient | ||||
constructor() { | |||||
constructor(private readonly baseUrl) { | |||||
this.fetchClient = createFetchClient({ | this.fetchClient = createFetchClient({ | ||||
baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL, | |||||
baseUrl, | |||||
}) | }) | ||||
} | } | ||||
@@ -1,9 +1,12 @@ | |||||
import {GetServerSideProps, NextPage} from 'next' | import {GetServerSideProps, NextPage} from 'next' | ||||
import {useRef} from 'react' | |||||
import {useEffect, useState} from 'react'; | |||||
import {models} from '@tonality/library-common' | import {models} from '@tonality/library-common' | ||||
import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | ||||
import RingtoneClient from '../../../../modules/ringtone/client' | import RingtoneClient from '../../../../modules/ringtone/client' | ||||
import {getSession, withPageAuthRequired} from '@auth0/nextjs-auth0'; | 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 = { | type Props = { | ||||
user: models.Composer, | user: models.Composer, | ||||
@@ -14,12 +17,40 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
user, | user, | ||||
composerRingtones, | 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 ( | return ( | ||||
<CreateRingtoneTemplate | <CreateRingtoneTemplate | ||||
composer={user} | composer={user} | ||||
composerRingtones={composerRingtones} | 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 | 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 {GetServerSideProps, NextPage} from 'next' | ||||
import {useRef} from 'react' | |||||
import {useEffect, useState} from 'react'; | |||||
import {models} from '@tonality/library-common' | import {models} from '@tonality/library-common' | ||||
import RingtoneClient from '../../modules/ringtone/client' | import RingtoneClient from '../../modules/ringtone/client' | ||||
@@ -12,7 +12,17 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
user, | user, | ||||
composerRingtones, | 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 ( | return ( | ||||
<div> | <div> | ||||
Hello | 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 { | 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) { | playSong(s: string) { | ||||
@@ -1,26 +1,29 @@ | |||||
let oscillator: OscillatorNode | |||||
export default class WaveOscillator { | 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) { | private static getKeyFrequency(keyNumber: number) { | ||||
return 440 * Math.pow(Math.pow(2, 1 / 12), keyNumber - 69) | return 440 * Math.pow(Math.pow(2, 1 / 12), keyNumber - 69) | ||||
} | } | ||||
start(keyNumber: number) { | 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 { | try { | ||||
this.oscillator.stop() | |||||
this.oscillators[keyNumber].stop() | |||||
} catch {} | } 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 |