@@ -11,8 +11,10 @@ | |||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@auth0/nextjs-auth0": "^1.3.1", | "@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", | "next": "10.2.0", | ||||
"react": "17.0.2", | "react": "17.0.2", | ||||
"react-dom": "17.0.2", | "react-dom": "17.0.2", | ||||
@@ -1,5 +1,5 @@ | |||||
import styled from 'styled-components'; | import styled from 'styled-components'; | ||||
import {FC, ReactChild} from 'react'; | |||||
import {FC, MouseEventHandler, ReactChild} from 'react'; | |||||
const Base = styled('div')({ | const Base = styled('div')({ | ||||
height: '3rem', | height: '3rem', | ||||
@@ -22,7 +22,8 @@ const Base = styled('div')({ | |||||
}) | }) | ||||
const ClickArea = styled('button')({ | const ClickArea = styled('button')({ | ||||
display: 'block', | |||||
display: 'grid', | |||||
placeContent: 'center', | |||||
width: '100%', | width: '100%', | ||||
height: '100%', | height: '100%', | ||||
margin: 0, | margin: 0, | ||||
@@ -36,6 +37,11 @@ const ClickArea = styled('button')({ | |||||
textTransform: 'uppercase', | textTransform: 'uppercase', | ||||
fontWeight: 'bolder', | fontWeight: 'bolder', | ||||
position: 'relative', | position: 'relative', | ||||
lineHeight: 0, | |||||
cursor: 'pointer', | |||||
':disabled': { | |||||
cursor: 'not-allowed', | |||||
} | |||||
}) | }) | ||||
const VARIANTS = { | const VARIANTS = { | ||||
@@ -57,6 +63,8 @@ type Props = { | |||||
type?: 'button' | 'reset' | 'submit', | type?: 'button' | 'reset' | 'submit', | ||||
block?: boolean, | block?: boolean, | ||||
variant?: keyof typeof VARIANTS, | variant?: keyof typeof VARIANTS, | ||||
onClick?: MouseEventHandler<HTMLButtonElement>, | |||||
disabled?: boolean, | |||||
} | } | ||||
const ActionButton: FC<Props> = ({ | 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' | import styled from 'styled-components' | ||||
const Base = styled('div')({ | const Base = styled('div')({ | ||||
@@ -51,14 +51,19 @@ type Props = { | |||||
block?: boolean, | block?: boolean, | ||||
placeholder?: string, | placeholder?: string, | ||||
defaultValue?: string | number | readonly 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, | label, | ||||
className, | className, | ||||
block, | block, | ||||
style = {}, | |||||
...etcProps | ...etcProps | ||||
}) => { | |||||
}, ref) => { | |||||
return ( | return ( | ||||
<Base | <Base | ||||
className={className} | className={className} | ||||
@@ -73,13 +78,15 @@ const TextArea: FC<Props> = ({ | |||||
</Label> | </Label> | ||||
<Input | <Input | ||||
{...etcProps} | {...etcProps} | ||||
ref={ref} | |||||
style={{ | style={{ | ||||
...style, | |||||
resize: block ? 'vertical' : undefined, | resize: block ? 'vertical' : undefined, | ||||
}} | }} | ||||
/> | /> | ||||
</ClickArea> | </ClickArea> | ||||
</Base> | </Base> | ||||
) | ) | ||||
} | |||||
}) | |||||
export default TextArea | 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')({ | const Form = styled('form')({ | ||||
display: 'grid', | display: 'grid', | ||||
gap: '1rem', | 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 = { | type Props = { | ||||
onSubmit?: FormEventHandler, | onSubmit?: FormEventHandler, | ||||
action?: string, | action?: string, | ||||
@@ -23,12 +60,149 @@ const CreateRingtoneForm: FC<Props> = ({ | |||||
labels, | labels, | ||||
defaultValues = {}, | 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 ( | return ( | ||||
<Form | <Form | ||||
onSubmit={onSubmit} | onSubmit={onSubmit} | ||||
method="post" | method="post" | ||||
action={action} | action={action} | ||||
aria-label={labels['form']} | aria-label={labels['form']} | ||||
ref={formRef} | |||||
> | > | ||||
{ | { | ||||
typeof (defaultValues.id as unknown) === 'string' | typeof (defaultValues.id as unknown) === 'string' | ||||
@@ -51,12 +225,140 @@ const CreateRingtoneForm: FC<Props> = ({ | |||||
block | block | ||||
defaultValue={defaultValues.name} | 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 | <TextArea | ||||
label={labels['data'] || 'Data'} | label={labels['data'] || 'Data'} | ||||
name="data" | name="data" | ||||
block | block | ||||
defaultValue={defaultValues.data} | 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 | <ActionButton | ||||
type="submit" | type="submit" | ||||
block | block | ||||
@@ -64,7 +366,11 @@ const CreateRingtoneForm: FC<Props> = ({ | |||||
{labels['cta'] || 'Post'} | {labels['cta'] || 'Post'} | ||||
</ActionButton> | </ActionButton> | ||||
</Form> | </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 {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 {models} from '@tonality/library-common' | ||||
import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' | import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' | ||||
import Link from '../../molecules/navigation/Link' | import Link from '../../molecules/navigation/Link' | ||||
import OmnisearchForm from '../../organisms/forms/Omnisearch' | import OmnisearchForm from '../../organisms/forms/Omnisearch' | ||||
import Brand from '../../molecules/brand/Brand' | 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 = { | type Props = { | ||||
onSearch?: FormEventHandler, | onSearch?: FormEventHandler, | ||||
onSubmit?: FormEventHandler, | onSubmit?: FormEventHandler, | ||||
@@ -31,6 +40,8 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
User | User | ||||
</div> | </div> | ||||
} | } | ||||
topBarComponent={TopBarComponent} | |||||
sidebarMenuComponent={SidebarMenuComponent} | |||||
topBarCenter={ | topBarCenter={ | ||||
<OmnisearchForm | <OmnisearchForm | ||||
labels={{ | labels={{ | ||||
@@ -88,11 +99,6 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
}, | }, | ||||
}, | }, | ||||
]} | ]} | ||||
sidebarMain={ | |||||
<LeftSidebarWithMenu.SidebarMainContainer> | |||||
Hi | |||||
</LeftSidebarWithMenu.SidebarMainContainer> | |||||
} | |||||
> | > | ||||
<LeftSidebarWithMenu.ContentContainer> | <LeftSidebarWithMenu.ContentContainer> | ||||
<CreateRingtoneForm | <CreateRingtoneForm | ||||
@@ -1,4 +1,4 @@ | |||||
import getFormValues from '@theoryofnekomata/formxtr' | |||||
import getFormValues from '@theoryofnekomata/formxtra' | |||||
import {FormEvent} from 'react' | import {FormEvent} from 'react' | ||||
import {createFetchClient, FetchClient} from '../../utils/api/fetch' | import {createFetchClient, FetchClient} from '../../utils/api/fetch' | ||||
import * as endpoints from './endpoints' | import * as endpoints from './endpoints' | ||||
@@ -13,7 +13,7 @@ export default class RingtoneClient { | |||||
save = async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => { | save = async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => { | ||||
e.preventDefault() | 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)) | const response = await this.fetchClient(endpoints.create(values)) | ||||
alert(response.statusText) | 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: | dependencies: | ||||
defer-to-connect "^2.0.0" | 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": | "@testing-library/dom@^7.28.1": | ||||
version "7.31.0" | version "7.31.0" | ||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.0.tgz#938451abd3ca27e1b69bb395d4a40759fd7f5b3b" | resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.0.tgz#938451abd3ca27e1b69bb395d4a40759fd7f5b3b" | ||||
@@ -777,10 +772,20 @@ | |||||
"@babel/runtime" "^7.12.5" | "@babel/runtime" "^7.12.5" | ||||
"@testing-library/dom" "^7.28.1" | "@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": | "@types/aria-query@^4.2.0": | ||||
version "4.2.1" | version "4.2.1" | ||||
@@ -4411,6 +4416,13 @@ makeerror@1.0.x: | |||||
dependencies: | dependencies: | ||||
tmpl "1.0.x" | 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: | map-cache@^0.2.2: | ||||
version "0.2.2" | version "0.2.2" | ||||
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" | 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" | inherits "^2.0.1" | ||||
safe-buffer "^5.1.2" | 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: | merge-stream@^2.0.0: | ||||
version "2.0.0" | version "2.0.0" | ||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" | 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" | resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" | ||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== | 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: | mimic-response@^1.0.0: | ||||
version "1.0.1" | version "1.0.1" | ||||
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" | 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" | resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" | ||||
integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== | 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: | p-each-series@^2.1.0: | ||||
version "2.2.0" | version "2.2.0" | ||||
resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" | 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 | data: string | ||||
tempo: number | |||||
createdAt: Date | createdAt: Date | ||||
updatedAt: Date | updatedAt: Date | ||||