浏览代码

Update front-end

Implement input in form.
master
父节点
当前提交
a9955de8e2
共有 16 个文件被更改,包括 799 次插入34 次删除
  1. +4
    -2
      packages/app-web/package.json
  2. +10
    -2
      packages/app-web/src/components/molecules/forms/ActionButton/index.tsx
  3. +15
    -0
      packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx
  4. +86
    -0
      packages/app-web/src/components/molecules/forms/NumericInput/index.tsx
  5. +11
    -4
      packages/app-web/src/components/molecules/forms/TextArea/index.tsx
  6. +19
    -0
      packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx
  7. +124
    -0
      packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx
  8. +315
    -9
      packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx
  9. +12
    -6
      packages/app-web/src/components/templates/CreateRingtone/index.tsx
  10. +2
    -2
      packages/app-web/src/modules/ringtone/client.ts
  11. +83
    -0
      packages/app-web/src/utils/format/keyData.ts
  12. +33
    -0
      packages/app-web/src/utils/format/song.ts
  13. +18
    -0
      packages/app-web/src/utils/sound/SoundManager.ts
  14. +26
    -0
      packages/app-web/src/utils/sound/WaveOscillator.ts
  15. +39
    -9
      packages/app-web/yarn.lock
  16. +2
    -0
      packages/library-common/src/index.ts

+ 4
- 2
packages/app-web/package.json 查看文件

@@ -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",


+ 10
- 2
packages/app-web/src/components/molecules/forms/ActionButton/index.tsx 查看文件

@@ -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> = ({


+ 15
- 0
packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx 查看文件

@@ -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()
})
})

+ 86
- 0
packages/app-web/src/components/molecules/forms/NumericInput/index.tsx 查看文件

@@ -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

+ 11
- 4
packages/app-web/src/components/molecules/forms/TextArea/index.tsx 查看文件

@@ -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

+ 19
- 0
packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx 查看文件

@@ -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)
})
})
})

+ 124
- 0
packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx 查看文件

@@ -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

+ 315
- 9
packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx 查看文件

@@ -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.
*/

+ 12
- 6
packages/app-web/src/components/templates/CreateRingtone/index.tsx 查看文件

@@ -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


+ 2
- 2
packages/app-web/src/modules/ringtone/client.ts 查看文件

@@ -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)
}


+ 83
- 0
packages/app-web/src/utils/format/keyData.ts 查看文件

@@ -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,
}
}

+ 33
- 0
packages/app-web/src/utils/format/song.ts 查看文件

@@ -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,
})
}

+ 18
- 0
packages/app-web/src/utils/sound/SoundManager.ts 查看文件

@@ -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) {

}
}

+ 26
- 0
packages/app-web/src/utils/sound/WaveOscillator.ts 查看文件

@@ -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 {}
}
}

+ 39
- 9
packages/app-web/yarn.lock 查看文件

@@ -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"


+ 2
- 0
packages/library-common/src/index.ts 查看文件

@@ -14,6 +14,8 @@ export namespace models {

data: string

tempo: number

createdAt: Date

updatedAt: Date


正在加载...
取消
保存