@@ -11,8 +11,10 @@ | |||
}, | |||
"dependencies": { | |||
"@auth0/nextjs-auth0": "^1.3.1", | |||
"@tesseract-design/viewfinder": "^0.1.1", | |||
"@theoryofnekomata/formxtr": "^0.1.2", | |||
"@theoryofnekomata/formxtra": "^0.2.3", | |||
"@theoryofnekomata/react-musical-keyboard": "^1.1.4", | |||
"@theoryofnekomata/viewfinder": "0.2.4", | |||
"mem": "^8.1.1", | |||
"next": "10.2.0", | |||
"react": "17.0.2", | |||
"react-dom": "17.0.2", | |||
@@ -1,5 +1,5 @@ | |||
import styled from 'styled-components'; | |||
import {FC, ReactChild} from 'react'; | |||
import {FC, MouseEventHandler, ReactChild} from 'react'; | |||
const Base = styled('div')({ | |||
height: '3rem', | |||
@@ -22,7 +22,8 @@ const Base = styled('div')({ | |||
}) | |||
const ClickArea = styled('button')({ | |||
display: 'block', | |||
display: 'grid', | |||
placeContent: 'center', | |||
width: '100%', | |||
height: '100%', | |||
margin: 0, | |||
@@ -36,6 +37,11 @@ const ClickArea = styled('button')({ | |||
textTransform: 'uppercase', | |||
fontWeight: 'bolder', | |||
position: 'relative', | |||
lineHeight: 0, | |||
cursor: 'pointer', | |||
':disabled': { | |||
cursor: 'not-allowed', | |||
} | |||
}) | |||
const VARIANTS = { | |||
@@ -57,6 +63,8 @@ type Props = { | |||
type?: 'button' | 'reset' | 'submit', | |||
block?: boolean, | |||
variant?: keyof typeof VARIANTS, | |||
onClick?: MouseEventHandler<HTMLButtonElement>, | |||
disabled?: boolean, | |||
} | |||
const ActionButton: FC<Props> = ({ | |||
@@ -0,0 +1,15 @@ | |||
import {render, screen} from '@testing-library/react' | |||
import TextInput from '.' | |||
describe('single-line text input component', () => { | |||
it('should contain a text input element', () => { | |||
render(<TextInput label="" name="" />) | |||
expect(screen.getByRole('textbox')).toBeInTheDocument() | |||
}) | |||
it('should acquire a descriptive label', () => { | |||
const label = 'foo' | |||
render(<TextInput label={label} name="" />) | |||
expect(screen.getByLabelText(label)).toBeInTheDocument() | |||
}) | |||
}) |
@@ -0,0 +1,86 @@ | |||
import {ChangeEventHandler, FC} from 'react'; | |||
import styled from 'styled-components' | |||
const Base = styled('div')({ | |||
height: '3rem', | |||
borderRadius: '0.25rem', | |||
overflow: 'hidden', | |||
position: 'relative', | |||
backgroundColor: 'var(--color-bg, white)', | |||
'::before': { | |||
content: "''", | |||
borderWidth: 1, | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
boxSizing: 'border-box', | |||
}, | |||
}) | |||
const ClickArea = styled('label')({ | |||
position: 'relative', | |||
height: '100%', | |||
}) | |||
const Label = styled('span')({ | |||
position: 'absolute', | |||
left: -999999, | |||
}) | |||
const Input = styled('input')({ | |||
display: 'block', | |||
width: '100%', | |||
height: '100%', | |||
margin: 0, | |||
padding: '0 1rem', | |||
boxSizing: 'border-box', | |||
font: 'inherit', | |||
border: 0, | |||
backgroundColor: 'transparent', | |||
color: 'inherit', | |||
outline: 0, | |||
}) | |||
type Props = { | |||
label: string, | |||
name: string, | |||
className?: string, | |||
block?: boolean, | |||
placeholder?: string, | |||
defaultValue?: string | number | readonly string[], | |||
onChange?: ChangeEventHandler<HTMLInputElement>, | |||
disabled?: boolean, | |||
} | |||
const NumericInput: FC<Props> = ({ | |||
label, | |||
className, | |||
block, | |||
...etcProps | |||
}) => { | |||
return ( | |||
<Base | |||
className={className} | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
width: block ? '100%' : undefined, | |||
}} | |||
> | |||
<ClickArea> | |||
<Label> | |||
{label} | |||
</Label> | |||
<Input | |||
{...etcProps} | |||
type="number" | |||
/> | |||
</ClickArea> | |||
</Base> | |||
) | |||
} | |||
export default NumericInput |
@@ -1,4 +1,4 @@ | |||
import {FC} from 'react' | |||
import {ChangeEventHandler, CSSProperties, FC, FocusEventHandler, forwardRef, PropsWithoutRef} from 'react'; | |||
import styled from 'styled-components' | |||
const Base = styled('div')({ | |||
@@ -51,14 +51,19 @@ type Props = { | |||
block?: boolean, | |||
placeholder?: string, | |||
defaultValue?: string | number | readonly string[], | |||
readOnly?: boolean, | |||
onChange?: ChangeEventHandler<HTMLTextAreaElement>, | |||
onBlur?: FocusEventHandler<HTMLTextAreaElement>, | |||
style?: CSSProperties, | |||
} | |||
const TextArea: FC<Props> = ({ | |||
const TextArea = forwardRef<HTMLTextAreaElement, PropsWithoutRef<Props>>(({ | |||
label, | |||
className, | |||
block, | |||
style = {}, | |||
...etcProps | |||
}) => { | |||
}, ref) => { | |||
return ( | |||
<Base | |||
className={className} | |||
@@ -73,13 +78,15 @@ const TextArea: FC<Props> = ({ | |||
</Label> | |||
<Input | |||
{...etcProps} | |||
ref={ref} | |||
style={{ | |||
...style, | |||
resize: block ? 'vertical' : undefined, | |||
}} | |||
/> | |||
</ClickArea> | |||
</Base> | |||
) | |||
} | |||
}) | |||
export default TextArea |
@@ -0,0 +1,19 @@ | |||
import {render, screen} from '@testing-library/react' | |||
import ActionButton from '.' | |||
describe('button component for triggering actions', () => { | |||
it('should render a button element with a no-op action', () => { | |||
render(<ActionButton />) | |||
expect(screen.getByRole('button')).toBeInTheDocument() | |||
}) | |||
describe.each(['button', 'reset', 'submit'] as const)('on %p action', (type) => { | |||
beforeEach(() => { | |||
render(<ActionButton type={type} />) | |||
}) | |||
it('should render a button element with a submit action', () => { | |||
expect(screen.getByRole('button')).toHaveAttribute('type', type) | |||
}) | |||
}) | |||
}) |
@@ -0,0 +1,124 @@ | |||
import styled from 'styled-components'; | |||
import {ChangeEventHandler, FC, ReactChild} from 'react'; | |||
const Base = styled('div')({ | |||
height: '3rem', | |||
borderRadius: '0.25rem', | |||
overflow: 'hidden', | |||
position: 'relative', | |||
'::after': { | |||
content: "''", | |||
borderWidth: 1, | |||
borderStyle: 'solid', | |||
borderColor: 'inherit', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
boxSizing: 'border-box', | |||
pointerEvents: 'none', | |||
}, | |||
}) | |||
const ClickArea = styled('label')({ | |||
display: 'block', | |||
width: '100%', | |||
height: '100%', | |||
margin: 0, | |||
padding: '0 1rem', | |||
boxSizing: 'border-box', | |||
font: 'inherit', | |||
border: 0, | |||
backgroundColor: 'transparent', | |||
color: 'inherit', | |||
outline: 0, | |||
textTransform: 'uppercase', | |||
fontWeight: 'bolder', | |||
position: 'relative', | |||
borderRadius: 'inherit', | |||
}) | |||
const Input = styled('input')({ | |||
position: 'absolute', | |||
left: -999999, | |||
}) | |||
const ButtonWrapper = styled('span')({ | |||
display: 'grid', | |||
placeContent: 'center', | |||
borderRadius: 'inherit', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
lineHeight: 0, | |||
cursor: 'pointer', | |||
[`${Input}:disabled + &`]: { | |||
cursor: 'not-allowed', | |||
}, | |||
[`${Input}:checked + &`]: { | |||
backgroundColor: 'Highlight !important', | |||
}, | |||
}) | |||
const VARIANTS = { | |||
default: { | |||
backgroundColor: 'var(--color-bg, white)', | |||
borderColor: 'var(--color-fg, black)', | |||
color: 'var(--color-fg, black)', | |||
}, | |||
primary: { | |||
backgroundColor: 'var(--color-fg, black)', | |||
borderColor: 'var(--color-fg, black)', | |||
color: 'var(--color-bg, white)', | |||
}, | |||
} | |||
type Props = { | |||
children?: ReactChild, | |||
className?: string, | |||
type?: 'checkbox' | 'radio', | |||
block?: boolean, | |||
variant?: keyof typeof VARIANTS, | |||
name?: string, | |||
value?: string, | |||
defaultChecked?: boolean, | |||
onChange?: ChangeEventHandler<HTMLInputElement>, | |||
disabled?: boolean, | |||
} | |||
const ToggleButton: FC<Props> = ({ | |||
children, | |||
className, | |||
type = 'checkbox', | |||
block, | |||
variant = 'default', | |||
...etcProps | |||
}) => { | |||
return ( | |||
<Base | |||
className={className} | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
width: block ? '100%' : undefined, | |||
}} | |||
> | |||
<ClickArea> | |||
<Input | |||
{...etcProps} | |||
type={type} | |||
/> | |||
<ButtonWrapper | |||
style={VARIANTS[variant]} | |||
> | |||
{children} | |||
</ButtonWrapper> | |||
</ClickArea> | |||
</Base> | |||
) | |||
} | |||
export default ToggleButton |
@@ -1,15 +1,52 @@ | |||
import {FC, FormEventHandler} from 'react' | |||
import {models} from '@tonality/library-common' | |||
import styled from 'styled-components' | |||
import TextInput from '../../../molecules/forms/TextInput' | |||
import TextArea from '../../../molecules/forms/TextArea' | |||
import ActionButton from '../../../molecules/forms/ActionButton' | |||
import {CSSProperties, FC, FormEventHandler, useEffect, useRef, useState} from 'react'; | |||
import {models} from '@tonality/library-common'; | |||
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'; | |||
const Form = styled('form')({ | |||
display: 'grid', | |||
gap: '1rem', | |||
}); | |||
const Toolbar = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: '6fr 1fr 1fr 1fr', | |||
gap: '1rem', | |||
fontSize: '1.5rem', | |||
}) | |||
const DurationSelector = styled('div')({ | |||
display: 'grid', | |||
gridTemplateColumns: 'repeat(6, 1fr)', | |||
}) | |||
const NOTE_GLYPHS = { | |||
1: '𝅝', | |||
2: '𝅗𝅥', | |||
4: '♩', | |||
8: '♪', | |||
16: '𝅘𝅥𝅯', | |||
32: '𝅘𝅥𝅰', | |||
}; | |||
const REST_GLYPHS = { | |||
1: '𝄻', | |||
2: '𝄼', | |||
4: '𝄽', | |||
8: '𝄾', | |||
16: '𝄿', | |||
32: '𝅀', | |||
}; | |||
type Props = { | |||
onSubmit?: FormEventHandler, | |||
action?: string, | |||
@@ -23,12 +60,149 @@ const CreateRingtoneForm: FC<Props> = ({ | |||
labels, | |||
defaultValues = {}, | |||
}) => { | |||
const [hydrated, setHydrated] = useState(false); | |||
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 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 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) | |||
} | |||
}, []) | |||
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]) | |||
useEffect(() => { | |||
setHydrated(true); | |||
}, []); | |||
// @ts-ignore | |||
return ( | |||
<Form | |||
onSubmit={onSubmit} | |||
method="post" | |||
action={action} | |||
aria-label={labels['form']} | |||
ref={formRef} | |||
> | |||
{ | |||
typeof (defaultValues.id as unknown) === 'string' | |||
@@ -51,12 +225,140 @@ const CreateRingtoneForm: FC<Props> = ({ | |||
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} | |||
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 ''; | |||
}} | |||
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 | |||
@@ -64,7 +366,11 @@ const CreateRingtoneForm: FC<Props> = ({ | |||
{labels['cta'] || 'Post'} | |||
</ActionButton> | |||
</Form> | |||
) | |||
} | |||
); | |||
}; | |||
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. | |||
*/ |
@@ -1,11 +1,20 @@ | |||
import {FC, FormEventHandler} from 'react' | |||
import { LeftSidebarWithMenu } from '@tesseract-design/viewfinder' | |||
import styled from 'styled-components' | |||
import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' | |||
import {models} from '@tonality/library-common' | |||
import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' | |||
import Link from '../../molecules/navigation/Link' | |||
import OmnisearchForm from '../../organisms/forms/Omnisearch' | |||
import Brand from '../../molecules/brand/Brand' | |||
const TopBarComponent = styled('div')({ | |||
backgroundColor: 'var(--color-bg, white)', | |||
}) | |||
const SidebarMenuComponent = styled('div')({ | |||
backgroundColor: 'var(--color-bg, white)', | |||
}) | |||
type Props = { | |||
onSearch?: FormEventHandler, | |||
onSubmit?: FormEventHandler, | |||
@@ -31,6 +40,8 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||
User | |||
</div> | |||
} | |||
topBarComponent={TopBarComponent} | |||
sidebarMenuComponent={SidebarMenuComponent} | |||
topBarCenter={ | |||
<OmnisearchForm | |||
labels={{ | |||
@@ -88,11 +99,6 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||
}, | |||
}, | |||
]} | |||
sidebarMain={ | |||
<LeftSidebarWithMenu.SidebarMainContainer> | |||
Hi | |||
</LeftSidebarWithMenu.SidebarMainContainer> | |||
} | |||
> | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<CreateRingtoneForm | |||
@@ -1,4 +1,4 @@ | |||
import getFormValues from '@theoryofnekomata/formxtr' | |||
import getFormValues from '@theoryofnekomata/formxtra' | |||
import {FormEvent} from 'react' | |||
import {createFetchClient, FetchClient} from '../../utils/api/fetch' | |||
import * as endpoints from './endpoints' | |||
@@ -13,7 +13,7 @@ export default class RingtoneClient { | |||
save = async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => { | |||
e.preventDefault() | |||
const values = getFormValues(e.target as HTMLFormElement, e.submitter) | |||
const values = getFormValues(e.target as HTMLFormElement, { submitter: e.submitter }) | |||
const response = await this.fetchClient(endpoints.create(values)) | |||
alert(response.statusText) | |||
} | |||
@@ -0,0 +1,83 @@ | |||
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, | |||
} | |||
} |
@@ -0,0 +1,33 @@ | |||
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, | |||
}) | |||
} |
@@ -0,0 +1,18 @@ | |||
import WaveOscillator from './WaveOscillator'; | |||
export default class SoundManager { | |||
private readonly oscillator | |||
constructor() { | |||
this.oscillator = new WaveOscillator() | |||
} | |||
beep(key, duration) { | |||
this.oscillator.beep(key, duration) | |||
} | |||
playSong(s: string) { | |||
} | |||
} |
@@ -0,0 +1,26 @@ | |||
export default class WaveOscillator { | |||
private oscillator: OscillatorNode | |||
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.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() { | |||
try { | |||
this.oscillator.stop() | |||
} catch {} | |||
} | |||
} |
@@ -736,11 +736,6 @@ | |||
dependencies: | |||
defer-to-connect "^2.0.0" | |||
"@tesseract-design/viewfinder@^0.1.1": | |||
version "0.1.1" | |||
resolved "https://js.pack.modal.sh/@tesseract-design%2fviewfinder/-/viewfinder-0.1.1.tgz#edb72601756e9762084aef49ad3a61c3815cdcc4" | |||
integrity sha512-kl8zXF8ABYtSV9Q5M2pty1QPY1aas1Lh+9Z3RU5TAleL9ZQuDfQAntkMKM33KN/ZzrzHFxcP+NvclDGcI6t59g== | |||
"@testing-library/dom@^7.28.1": | |||
version "7.31.0" | |||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.0.tgz#938451abd3ca27e1b69bb395d4a40759fd7f5b3b" | |||
@@ -777,10 +772,20 @@ | |||
"@babel/runtime" "^7.12.5" | |||
"@testing-library/dom" "^7.28.1" | |||
"@theoryofnekomata/formxtr@^0.1.2": | |||
version "0.1.2" | |||
resolved "https://js.pack.modal.sh/@theoryofnekomata%2fformxtr/-/formxtr-0.1.2.tgz#6aaddaa52f3cfe2f2109784d637cce3922b74302" | |||
integrity sha512-R9d4l5moMtVO8D7+FhOMFLn2Qg+OzR/e9xCgtUgFCzqCtNBJY78lFO9hUaZn4uMi4gY7DApzf2oSnd6oBIvLXQ== | |||
"@theoryofnekomata/formxtra@^0.2.3": | |||
version "0.2.3" | |||
resolved "https://js.pack.modal.sh/@theoryofnekomata%2fformxtra/-/formxtra-0.2.3.tgz#5ea5ddfc2ae7246ffeb4325030ead04b557d05d1" | |||
integrity sha512-TZWMG+fV3pbUz9wPgt29DlaIppa8mmGIBA8fH8Vk+idvcNVJLwJ42nQ/ZSMS/si+EZ49LaHLf0XHZS9qCHrwig== | |||
"@theoryofnekomata/react-musical-keyboard@^1.1.4": | |||
version "1.1.4" | |||
resolved "https://js.pack.modal.sh/@theoryofnekomata%2freact-musical-keyboard/-/react-musical-keyboard-1.1.4.tgz#f5fac0a2e7f5b3a23650da54e2623b13f4a699d4" | |||
integrity sha512-Riq+zaVXqmJ3mTUN56gM5QNzHunhaqen39tMgwPw4jCe18goOx3q+1sp+B9cXem1ZA1sMcLiNtuYpcsm71GJgw== | |||
"@theoryofnekomata/viewfinder@0.2.4": | |||
version "0.2.4" | |||
resolved "https://js.pack.modal.sh/@theoryofnekomata%2fviewfinder/-/viewfinder-0.2.4.tgz#771ec79029ae60b4a355de44709842818ac0fb9c" | |||
integrity sha512-XeIxmeh3XTnLF5HlQYMY3bEKVyAWS3Ulg4JCbEJMpjgzH+++JNn4hm0Bc7gZYzlJRFOmPaV0uK8BbpoYQcz+tQ== | |||
"@types/aria-query@^4.2.0": | |||
version "4.2.1" | |||
@@ -4411,6 +4416,13 @@ makeerror@1.0.x: | |||
dependencies: | |||
tmpl "1.0.x" | |||
map-age-cleaner@^0.1.3: | |||
version "0.1.3" | |||
resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" | |||
integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== | |||
dependencies: | |||
p-defer "^1.0.0" | |||
map-cache@^0.2.2: | |||
version "0.2.2" | |||
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" | |||
@@ -4432,6 +4444,14 @@ md5.js@^1.3.4: | |||
inherits "^2.0.1" | |||
safe-buffer "^5.1.2" | |||
mem@^8.1.1: | |||
version "8.1.1" | |||
resolved "https://registry.yarnpkg.com/mem/-/mem-8.1.1.tgz#cf118b357c65ab7b7e0817bdf00c8062297c0122" | |||
integrity sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA== | |||
dependencies: | |||
map-age-cleaner "^0.1.3" | |||
mimic-fn "^3.1.0" | |||
merge-stream@^2.0.0: | |||
version "2.0.0" | |||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" | |||
@@ -4494,6 +4514,11 @@ mimic-fn@^2.1.0: | |||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" | |||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== | |||
mimic-fn@^3.1.0: | |||
version "3.1.0" | |||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" | |||
integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== | |||
mimic-response@^1.0.0: | |||
version "1.0.1" | |||
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" | |||
@@ -4922,6 +4947,11 @@ p-cancelable@^2.0.0: | |||
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" | |||
integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== | |||
p-defer@^1.0.0: | |||
version "1.0.0" | |||
resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" | |||
integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= | |||
p-each-series@^2.1.0: | |||
version "2.2.0" | |||
resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" | |||
@@ -14,6 +14,8 @@ export namespace models { | |||
data: string | |||
tempo: number | |||
createdAt: Date | |||
updatedAt: Date | |||