diff --git a/packages/mosc/src/__tests__/mosc.test.ts b/packages/mosc/src/__tests__/mosc.test.ts index acbed8f..327294f 100644 --- a/packages/mosc/src/__tests__/mosc.test.ts +++ b/packages/mosc/src/__tests__/mosc.test.ts @@ -31,13 +31,15 @@ describe('sortByTime', () => { type: 'NOTE_TIME', time: 2, timeEnd: 2, - hz: 440 + hz: 440, + label: '440' }, { type: 'NOTE_TIME', time: 0, timeEnd: 2, - hz: 550 + hz: 550, + label: '550' }, { type: 'TEMPO', @@ -50,7 +52,8 @@ describe('sortByTime', () => { type: 'NOTE_TIME', time: 0, timeEnd: 2, - hz: 550 + hz: 550, + label: '550' }, { type: 'TEMPO', @@ -62,7 +65,8 @@ describe('sortByTime', () => { type: 'NOTE_TIME', time: 2, timeEnd: 2, - hz: 440 + hz: 440, + label: '440' } ]); }); @@ -83,13 +87,15 @@ describe('scoreToMs', () => { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 440 + hz: 440, + label: '440' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 550 + hz: 550, + label: '550' }, { type: 'TEMPO', @@ -101,7 +107,8 @@ describe('scoreToMs', () => { type: 'NOTE_TIME', time: 2, timeEnd: 3, - hz: 660 + hz: 660, + label: '660' }, { type: 'PARAM_TIME', @@ -122,19 +129,22 @@ describe('scoreToMs', () => { type: 'NOTE_MS', ms: 0, msEnd: 500, - hz: 440 + hz: 440, + label: '440' }, { type: 'NOTE_MS', ms: 500, msEnd: 1000, - hz: 550 + hz: 550, + label: '550' }, { type: 'NOTE_MS', ms: 1000, msEnd: 1666.6666666666665, - hz: 660 + hz: 660, + label: '660' }, { type: 'PARAM_MS', @@ -171,31 +181,36 @@ describe('scoreToMs', () => { type: 'NOTE_TIME', time: 1, timeEnd: 1, - hz: 440 + hz: 440, + label: '440' }, { type: 'NOTE_TIME', time: 2, timeEnd: 2, - hz: 550 + hz: 550, + label: '550' }, { type: 'NOTE_TIME', time: 3, timeEnd: 3, - hz: 660 + hz: 660, + label: '660' }, { type: 'NOTE_TIME', time: 4, timeEnd: 4, - hz: 770 + hz: 770, + label: '770' }, { type: 'NOTE_TIME', time: 5, timeEnd: 5, - hz: 880 + hz: 880, + label: '880' } ], lengthTime: 5 @@ -205,31 +220,36 @@ describe('scoreToMs', () => { type: 'NOTE_MS', ms: 500, msEnd: 500, - hz: 440 + hz: 440, + label: '440' }, { type: 'NOTE_MS', ms: 1166.6666666666665, msEnd: 1166.6666666666665, - hz: 550 + hz: 550, + label: '550' }, { type: 'NOTE_MS', ms: 1833.3333333333333, msEnd: 1833.3333333333333, - hz: 660 + hz: 660, + label: '660' }, { type: 'NOTE_MS', ms: 2500, msEnd: 2500, - hz: 770 + hz: 770, + label: '770' }, { type: 'NOTE_MS', ms: 3500, msEnd: 3500, - hz: 880 + hz: 880, + label: '880' } ], lengthMs: 3500 diff --git a/packages/mosc/src/mosc.ts b/packages/mosc/src/mosc.ts index 0377e70..2198194 100644 --- a/packages/mosc/src/mosc.ts +++ b/packages/mosc/src/mosc.ts @@ -7,6 +7,7 @@ export type MoscNote = { time: number; timeEnd: number; hz: number; + label: string; }; export type MoscNoteMs = { @@ -14,6 +15,7 @@ export type MoscNoteMs = { ms: number; msEnd: number; hz: number; + label: string; }; export type MoscParam = { @@ -158,6 +160,7 @@ export const scoreToMs = (score: MoscScore): MoscScoreMs => { return { type: 'NOTE_MS', hz: note.hz, + label: note.label, ms: thisTimeToMs(note.time), msEnd: thisTimeToMs(note.timeEnd) }; @@ -191,14 +194,13 @@ export const scoreToMs = (score: MoscScore): MoscScoreMs => { // soud engine base class // -type SoundEngineEvent = 'end'; -type SoundEngineEventCallback = () => void; +type SoundEngineEndEventCallback = () => void; +type SoundEngineNoteEventCallback = (noteMs: MoscNoteMs, on: boolean) => void; type SoundEngineEventCallbackCancel = () => void; export class SoundEngine { scoreMs?: MoscScoreMs; - events = new Map(); playing(): boolean { return false; @@ -222,18 +224,36 @@ export class SoundEngine { async gotoMs(ms: number): Promise {} - async setLoop(loop: boolean, startMs: number = 0, endMs: number = 0): Promise {} + setLoop(loop: boolean, startMs: number = 0, endMs: number = 0): void {} + setLoopActive(loop: boolean): void {} + setLoopStart(ms: number = 0): void {} + setLoopEnd(ms: number = 0): void {} - async setLoopStart(ms: number = 0): Promise {} + async setScore(scoreMs: MoscScoreMs): Promise {} - async setLoopEnd(ms: number = 0): Promise {} + // events - async setScore(scoreMs: MoscScoreMs): Promise {} + events = { + end: new Set(), + note: new Set() + }; + + _triggerEvent(type: string, ...params: any) { + // @ts-ignore + this.events[type].forEach(cb => cb(...params)); + } + + onEnd(callback: SoundEngineEndEventCallback): SoundEngineEventCallbackCancel { + this.events.end.add(callback); + return () => { + this.events.end.delete(callback); + }; + } - on(eventType: SoundEngineEvent, callback: SoundEngineEventCallback): SoundEngineEventCallbackCancel { - this.events.set(callback, eventType); + onNote(callback: SoundEngineNoteEventCallback): SoundEngineEventCallbackCancel { + this.events.note.add(callback); return () => { - this.events.delete(callback); + this.events.note.delete(callback); }; } } diff --git a/packages/sound-engine-tonejs/src/sound-engine-tonejs.ts b/packages/sound-engine-tonejs/src/sound-engine-tonejs.ts index 7b1d50c..7db9493 100644 --- a/packages/sound-engine-tonejs/src/sound-engine-tonejs.ts +++ b/packages/sound-engine-tonejs/src/sound-engine-tonejs.ts @@ -47,6 +47,7 @@ export class SoundEngineTonejs extends SoundEngine { _started = false; _endMs = 0; _loopEndMs = 0; + _activeNoteEvents = new Set(); synth = new Tone.PolySynth(Tone.Synth, { oscillator: { @@ -81,6 +82,17 @@ export class SoundEngineTonejs extends SoundEngine { if(!this._started) { await Tone.start(); this._started = true; + + const onEnd = () => { + this.synth.releaseAll(); + this._activeNoteEvents.forEach(noteMs => { + this._triggerEvent('note', noteMs, false); + }); + this._activeNoteEvents.clear(); + }; + + Tone.Transport.on('stop', onEnd); + Tone.Transport.on('loop', onEnd); } } @@ -92,24 +104,27 @@ export class SoundEngineTonejs extends SoundEngine { async pause(): Promise { await this.start(); Tone.Transport.stop(); - this.synth.releaseAll(); } async gotoMs(ms: number): Promise { Tone.Transport.seconds = ms * 0.001; } - async setLoop(loop: boolean, startMs: number = 0, endMs: number = 0): Promise { + setLoop(loop: boolean, startMs: number = 0, endMs: number = 0): void { + this.setLoopActive(loop); + this.setLoopStart(startMs); + this.setLoopEnd(endMs); + } + + setLoopActive(loop: boolean = true): void { Tone.Transport.loop = loop; - await this.setLoopStart(startMs); - await this.setLoopEnd(endMs); } - async setLoopStart(ms: number = 0): Promise { + setLoopStart(ms: number = 0): void { Tone.Transport.loopStart = ms * 0.001; } - async setLoopEnd(ms: number = 0): Promise { + setLoopEnd(ms: number = 0): void { this._loopEndMs = ms; Tone.Transport.loopEnd = (ms === 0 ? this._endMs : ms) * 0.001; } @@ -121,20 +136,30 @@ export class SoundEngineTonejs extends SoundEngine { Tone.Transport.cancel(); // add all new notes to tone transport - this.scoreMs.sequence.map((item: MoscItemMs): number => { + this.scoreMs.sequence.forEach((item: MoscItemMs): void => { if(item.type === 'NOTE_MS') { const noteMs = item as MoscNoteMs; - return Tone.Transport.schedule((time: number) => { + Tone.Transport.schedule((time: number) => { this.synth.triggerAttackRelease( noteMs.hz, (noteMs.msEnd * 0.001) - (noteMs.ms * 0.001), - time + 0.01 // schedule in the future slightly to avoid double note playing at end + time ); - }, noteMs.ms * 0.001); + this._activeNoteEvents.add(noteMs); + this._triggerEvent('note', noteMs, true); + }, noteMs.ms * 0.001 + 0.1); // schedule in the future slightly to avoid double note playing at end + + Tone.Transport.schedule((time: number) => { + this._activeNoteEvents.delete(noteMs); + this._triggerEvent('note', noteMs, false); + }, noteMs.msEnd * 0.001 + 0.1); + + return; } + if(item.type === 'PARAM_MS') { const paramMs = item as MoscParamMs; - return Tone.Transport.schedule(() => { + Tone.Transport.schedule(() => { // this is inaccurate // as tonejs calls these callbacks several ms ahead of schedule // and relies on scheduled events to pass the provided time @@ -160,24 +185,26 @@ export class SoundEngineTonejs extends SoundEngine { } }, paramMs.ms * 0.001); + + return; } + if(item.type === 'END_MS') { this._endMs = (item as MoscEndMs).ms; if(this._loopEndMs === 0) { this.setLoopEnd(0); } - return Tone.Transport.schedule(async () => { + Tone.Transport.schedule(async () => { if(Tone.Transport.loop) return; Tone.Transport.stop(); - this.gotoMs(0); - this.events.forEach((type, cb) => { - if(type !== 'end') return; - cb(); - }); + this._triggerEvent('end', undefined); }, this._endMs * 0.001); + + return; } + // @ts-ignore throw new Error(`Unexpected item type ${item.type} encountered`); }); diff --git a/packages/xenpaper-app/src/@types/typings.d.ts b/packages/xenpaper-app/src/@types/typings.d.ts index 4090bbb..0078480 100644 --- a/packages/xenpaper-app/src/@types/typings.d.ts +++ b/packages/xenpaper-app/src/@types/typings.d.ts @@ -1,2 +1,3 @@ declare module 'rd-parse'; declare module 'react-copy-to-clipboard'; +declare module 'react-use-dimensions'; diff --git a/packages/xenpaper-ui/package.json b/packages/xenpaper-ui/package.json index ec6b5f9..37483c6 100644 --- a/packages/xenpaper-ui/package.json +++ b/packages/xenpaper-ui/package.json @@ -24,9 +24,11 @@ "@xenpaper/mosc": "0.0.1", "@xenpaper/sound-engine-tonejs": "0.0.1", "dendriform": "^2.0.0-alpha.8", + "konva": "^8.1.0", "rd-parse": "^3.2.3", "react-copy-to-clipboard": "^5.0.3", "react-helmet": "^6.1.0", + "react-konva": "^17.0.2-4", "react-select": "^3.1.0", "react-use-dimensions": "^1.2.1", "styled-components": "^5.2.1", diff --git a/packages/xenpaper-ui/src/@types/typings.d.ts b/packages/xenpaper-ui/src/@types/typings.d.ts index 4090bbb..0078480 100644 --- a/packages/xenpaper-ui/src/@types/typings.d.ts +++ b/packages/xenpaper-ui/src/@types/typings.d.ts @@ -1,2 +1,3 @@ declare module 'rd-parse'; declare module 'react-copy-to-clipboard'; +declare module 'react-use-dimensions'; diff --git a/packages/xenpaper-ui/src/PitchRuler.tsx b/packages/xenpaper-ui/src/PitchRuler.tsx new file mode 100644 index 0000000..577f2d0 --- /dev/null +++ b/packages/xenpaper-ui/src/PitchRuler.tsx @@ -0,0 +1,242 @@ +import React, {useCallback, useRef} from 'react'; +import useDimensions from 'react-use-dimensions'; +import * as ReactKonva from 'react-konva'; +import {useStrictMode} from 'react-konva'; +import {useDendriform, useCheckbox} from 'dendriform'; +import type {Dendriform} from 'dendriform'; +import type {MoscNoteMs} from '@xenpaper/mosc'; +import {Box, Flex} from './layout/Layout'; +import styled from 'styled-components'; + +useStrictMode(true); +const {Stage, Layer, Rect, Group, Text} = ReactKonva as any; + +export type RulerState = { + notes: Map; + notesActive: Map; + collect: boolean; + viewPan: number; + viewZoom: number; +}; + +const LOW_HZ_LIMIT = 20; +const ZOOM_SPEED = 1.1; + +const hzToPan = (hz: number): number => Math.log2(hz / LOW_HZ_LIMIT); + +const panToHz = (pan: number): number => Math.pow(2, pan) * LOW_HZ_LIMIT; + +const panToPx = (pan: number, viewPan: number, viewZoom: number, height: number): number => { + return height * 0.5 - ((pan - viewPan) * height / viewZoom); +}; + +const pxToPan = (px: number, viewPan: number, viewZoom: number, height: number): number => { + return viewPan - ((px - (height * 0.5)) / height * viewZoom); +}; + +export type InitialRulerState = { + lowHz?: number; + highHz?: number; +}; + +export function useRulerState({lowHz = 440, highHz = 880}: InitialRulerState = {}): Dendriform { + return useDendriform(() => { + + let viewPan = hzToPan(440); + let viewZoom = 1; + + if(lowHz) { + const lowPan = hzToPan(lowHz); + const highPan = hzToPan(highHz); + viewPan = (lowPan + highPan) * 0.5; + viewZoom = highPan - lowPan; + } + + return { + notes: new Map(), + notesActive: new Map(), + collect: true, + viewPan, + viewZoom + }; + }); +}; + +type Props = { + rulerState: Dendriform; +}; + +export function PitchRuler(props: Props): React.ReactElement|null { + const {rulerState} = props; + + const onClear = useCallback(() => { + rulerState.set(draft => { + draft.notes.clear(); + }); + }, []); + + return + + + {rulerState.render('collect', form => ( + + ))} + + + + + + + ; +} + +type DragStartState = { + startPan: number; + startZoom: number; + startDrag: number; +}; + +function PitchRulerCanvas(props: Props): React.ReactElement|null { + const [dimensionsRef, {width = 0, height = 0}] = useDimensions(); + + const rulerState = props.rulerState.useValue(); + + const {viewPan, viewZoom} = rulerState; + + const getY = (note: MoscNoteMs): number => { + return panToPx(hzToPan(note.hz), viewPan, viewZoom, height); + }; + + const dragStartState = useRef(null); + + const handleMouseDown = useCallback(({evt}) => { + evt.preventDefault(); + dragStartState.current = { + startPan: viewPan, + startZoom: viewZoom, + startDrag: pxToPan(evt.clientY, viewPan, viewZoom, height) + }; + }, [viewPan, viewZoom, height]); + + const handleMouseMove = useCallback(({evt}) => { + evt.preventDefault(); + const dragState = dragStartState.current; + if(dragState) { + const nowDrag = pxToPan(evt.clientY, dragState.startPan, dragState.startZoom, height); + + props.rulerState.set(draft => { + draft.viewPan = dragState.startPan - nowDrag + dragState.startDrag; + }); + } + }, [height]); + + const handleMouseUp = useCallback(({evt}) => { + evt.preventDefault(); + dragStartState.current = null; + }, []); + + const handleWheel = useCallback(({evt}) => { + evt.preventDefault(); + props.rulerState.set(draft => { + draft.viewZoom *= evt.deltaY > 0 ? ZOOM_SPEED : evt.deltaY < 0 ? 1/ZOOM_SPEED : 1 + }); + }, []); + + const handleTouchStart = useCallback(({evt}) => { + evt.preventDefault(); + // console.log('handleTouchStart', {evt}); + }, []); + + const handleTouchMove = useCallback(({evt}) => { + evt.preventDefault(); + // console.log('handleTouchMove', {evt}); + }, []); + + const handleTouchEnd = useCallback(({evt}) => { + evt.preventDefault(); + // console.log('handleTouchEnd', {evt}); + }, []); + + // TODO zoom toward cursor position + + const ticksFrom = panToHz(pxToPan(height, viewPan, viewZoom, height)); + const ticksTo = panToHz(pxToPan(0, viewPan, viewZoom, height)); + + return
+ + + {/**/} + {rulerState.collect && } + + + +
; +} + +type NoteSetProps = { + notes: Map; + getY: (note: MoscNoteMs) => number; + color: string; + width: number; +}; + +function NoteSet(props: NoteSetProps): React.ReactElement { + const {width, color} = props; + return <> + {Array.from(props.notes.entries()).map(([id, note]) => { + return + + + ; + })} + ; +} + +const Label = styled.label` + user-select: none; +`; + +const Button = styled.button` + border: none; + display: block; + padding: .25rem .5rem; + cursor: pointer; + background-color: ${props => props.theme.colors.highlights.unknown}; + color: ${props => props.theme.colors.background.normal}; + position: relative; + outline: none; + opacity: 0.7; + + transition: opacity .2s ease-out; + + &:hover, &:focus, &:active { + opacity: 1; + } +`; diff --git a/packages/xenpaper-ui/src/Sidebars.tsx b/packages/xenpaper-ui/src/Sidebars.tsx index 9a3837d..346452d 100644 --- a/packages/xenpaper-ui/src/Sidebars.tsx +++ b/packages/xenpaper-ui/src/Sidebars.tsx @@ -8,33 +8,9 @@ import {textStyle, typography} from 'styled-system'; // @ts-ignore import logo from './assets/xenpaper-logo-512x512.png'; -import type {Dendriform} from 'dendriform'; -import type {SidebarState, TuneForm} from './Xenpaper'; +import type {SidebarState} from './Xenpaper'; import {CopyToClipboard} from 'react-copy-to-clipboard'; -type Props = { - sidebar: SidebarState; - onSetTune: (tune: string) => void; - setSidebar: (open: SidebarState) => void; - tuneForm: Dendriform; -}; - -export function Sidebars(props: Props): React.ReactElement|null { - const {sidebar, onSetTune, setSidebar, tuneForm} = props; - return <> - {sidebar === 'info' && } - {sidebar === 'share' && tuneForm.render(form => { - const url = form.branch('url').useValue(); - const urlEmbed = form.branch('urlEmbed').useValue(); - return ; - })} - ; -} - -// -// constituent components -// - const Flink = styled.a` color: ${props => props.theme.colors.highlights.unknown}; text-decoration: none; @@ -66,11 +42,11 @@ type SidebarInfoProps = { setSidebar: (open: SidebarState) => void; }; -const SidebarInfo = (props: SidebarInfoProps): React.ReactElement => { +export const SidebarInfo = (props: SidebarInfoProps): React.ReactElement => { const {onSetTune, setSidebar} = props; - return + return How it works - Create tunes by typing in the text area. Press play to hear what you{"'"}ve written, or press Ctrl / Cmd + Enter. + Create tunes by typing in the text area. Press play to hear what you{"'"}ve written.{/*, or press Ctrl / Cmd + Enter.*/} Notes Typing a number will create a note. Notes can be separated by spaces or commas. @@ -172,7 +148,7 @@ type SidebarShareProps = { urlEmbed: string; }; -const SidebarShare = (props: SidebarShareProps): React.ReactElement => { +export const SidebarShare = (props: SidebarShareProps): React.ReactElement => { const {setSidebar, url, urlEmbed} = props; const iframeCode = ``; @@ -180,7 +156,7 @@ const SidebarShare = (props: SidebarShareProps): React.ReactElement => { const [copiedLink, setCopiedLink] = useState(false); const [copiedIframe, setCopiedIframe] = useState(false); - return + return Share link @@ -225,11 +201,13 @@ const ShareInput = styled.input.attrs(() => ({readOnly: true}))` type SidebarProps = { setSidebar: (open: SidebarState) => void; children: React.ReactNode; + title?: string; + pad?: boolean; }; -const Sidebar = (props: SidebarProps): React.ReactElement => { - const {setSidebar, children} = props; - return +export const Sidebar = (props: SidebarProps): React.ReactElement => { + const {setSidebar, children, title, pad} = props; + return { /> - - - Xenpaper logo - - - xenpaper - Text-based microtonal sequencer. - Write down musical ideas and share the link around. - - + {title && + {title} + } + {!title && + + + Xenpaper logo + + + xenpaper + Text-based microtonal sequencer. + Write down musical ideas and share the link around. + + + } - + {children} ; }; -const TextPanel = styled(Box)` +const TextPanel = styled(Flex)` background-color: ${props => props.theme.colors.background.light}; font-family: ${props => props.theme.fonts.copy}; position: relative; @@ -302,6 +285,10 @@ const H = styled.h2` margin-bottom: 1rem; `; +const Hsize = styled.h2` + font-size: 1.5rem; +`; + const C = styled.span` font-family: ${props => props.theme.fonts.mono}; background-color: ${props => props.theme.colors.background.normal}; diff --git a/packages/xenpaper-ui/src/Xenpaper.tsx b/packages/xenpaper-ui/src/Xenpaper.tsx index e77149e..05190cf 100644 --- a/packages/xenpaper-ui/src/Xenpaper.tsx +++ b/packages/xenpaper-ui/src/Xenpaper.tsx @@ -6,24 +6,29 @@ import {ErrorMessage} from './component/ErrorMessage'; import {LoaderPane} from './component/LoaderPane'; import {Char} from './component/Char'; import {Box, Flex} from './layout/Layout'; -import {Sidebars, Footer} from './Sidebars'; +import {Sidebar, SidebarInfo, SidebarShare, Footer} from './Sidebars'; import styled from 'styled-components'; +import {PitchRuler, useRulerState} from './PitchRuler'; +import type {RulerState} from './PitchRuler'; + import {XenpaperGrammarParser} from './data/grammar'; -import type {XenpaperAST} from './data/grammar'; +import type {XenpaperAST, SetterGroupType} from './data/grammar'; import {grammarToChars} from './data/grammar-to-chars'; -import {grammarToMoscScore} from './data/grammar-to-mosc'; +import {processGrammar} from './data/process-grammar'; +import type {InitialRulerState} from './data/process-grammar'; import type {MoscScore} from '@xenpaper/mosc'; import type {HighlightColor, CharData} from './data/grammar-to-chars'; import {useHash, hashify} from './hooks/useHash'; import {useWindowLoaded} from './hooks/useWindowLoaded'; -import {useAnimationFrame} from './hooks/useAnimationFrame'; + import {useDendriform, useInput} from 'dendriform'; import type {Dendriform} from 'dendriform'; -import {setAutoFreeze} from 'immer'; +import {setAutoFreeze, enableMapSet} from 'immer'; setAutoFreeze(false); // sadly I am relying on mutations within the xenpaper AST because who cares +enableMapSet(); import {scoreToMs} from '@xenpaper/mosc'; import type {SoundEngine} from '@xenpaper/mosc'; @@ -45,18 +50,18 @@ type Parsed = { parsed?: XenpaperAST; chars?: CharData[]; score?: MoscScore; + initialRulerState?: InitialRulerState; error: string; }; const parse = (unparsed: string): Parsed => { try { const parsed = XenpaperGrammarParser(unparsed); - const score = grammarToMoscScore(parsed); + const {score, initialRulerState} = processGrammar(parsed); const chars = grammarToChars(parsed); if(score) { const scoreMs = scoreToMs(score); - soundEngine.setScore(scoreMs); } @@ -64,6 +69,7 @@ const parse = (unparsed: string): Parsed => { parsed, chars, score, + initialRulerState, error: '' }; @@ -93,7 +99,10 @@ const parse = (unparsed: string): Parsed => { } if(typeof errorAt === 'number') { - error = e.message.replace('Unexpected token ', `Unexpected token "${unparsed[errorAt]}" `); + error = unparsed[errorAt] !== undefined + ? e.message.replace('Unexpected token ', `Unexpected token "${unparsed[errorAt]}" `) + : e.message; + chars[errorAt] = { color: 'error' }; @@ -103,52 +112,34 @@ const parse = (unparsed: string): Parsed => { parsed: undefined, chars, score: undefined, + initialRulerState: undefined, error }; } }; -type CharProps = { - className: string; - color?: HighlightColor; - children: React.ReactNode; - noteId?: number; -}; - -const createCharElements = ( - hash: string, - chars: CharData[]|undefined, - time: number, - layoutModeOn: boolean, - layoutModeNotes: number[] -): CharProps[] => { - - const hashChars: string[] = hash.split(''); - - return hashChars.map((chr, index) => { - const ch: CharData|undefined = chars?.[index]; - const [start, end] = ch?.playTime ?? []; - - const activeFromPlayhead = !layoutModeOn - && time !== -1 - && start !== undefined - && end !== undefined - && start <= time - && end > time; - - const activeFromRealtime = layoutModeOn - && layoutModeNotes.length > 0 - && layoutModeNotes.some(n => n === start); - - const active = activeFromPlayhead || activeFromRealtime; - - return { - className: active ? 'active' : '', - color: ch?.color, - children: chr, - noteId: start - }; - }); +const getMsAtLine = (tune: string, chars: CharData[]|undefined, line: number): number => { + if(line === 0) { + return 0; + } + let ms = 0; + let counted = 0; + const tuneSplit = tune.split(''); + for(let i = 0; i < tuneSplit.length; i++) { + const chr = tuneSplit[i]; + const ch = chars?.[i]; + const [,end] = ch?.playTime ?? []; + if(end !== undefined) { + ms = end; + } + if(chr === '\n') { + counted++; + if(counted === line) { + return ms; + } + } + } + return 0; }; // @@ -157,7 +148,7 @@ const createCharElements = ( const PLAY_PATHS = { paused: ['M 0 0 L 12 6 L 0 12 Z'], - playing: ['M 0 0 L 4 0 L 4 12 L 0 12 Z', 'M 8 0 L 12 0 L 12 12 L 8 12 Z'], + playing: ['M 0 0 L 4 0 L 4 12 L 0 12 Z', 'M 8 0 L 12 0 L 12 12 L 8 12 Z'] // stopped: ['M 0 0 L 12 0 L 12 12 L 0 12 Z'], }; @@ -179,7 +170,7 @@ export function Xenpaper(): React.ReactElement { // application component // -export type SidebarState = 'info'|'share'|'none'; +export type SidebarState = 'info'|'share'|'ruler'|'none'; // type RealtimeState = { // on: boolean; @@ -243,55 +234,69 @@ export function XenpaperApp(props: Props): React.ReactElement { setHash(hash); }); - const parsedForm = useDendriform({ - parsed: undefined, - chars: undefined, - score: undefined, - error: '' - }); + const parsedForm = useDendriform(() => parse(tuneForm.value.tune)); tuneForm.useDerive((value) => { parsedForm.set(parse(value.tune)); }); // - // ui modes + // state syncing between sound engine and react // - /*const layoutMode = useDendriform({ - on: false, - activeNotes: [] + const playing = useDendriform(false); + const selectedLine = useDendriform(0); + selectedLine.useChange(line => { + soundEngine.setLoopStart(getMsAtLine(tuneForm.value.tune, parsedForm.value.chars, line)); }); - const handleToggleRealtime = useCallback(() => { - layoutMode.set(draft => { - draft.on = !draft.on; - draft.activeNotes = []; + useEffect(() => { + return soundEngine.onEnd(() => { + playing.set(false); }); - }, []);*/ + }, []); + + const looping = useDendriform(false); // - // state syncing between sound engine and react + // ruler state // - const playing = useDendriform(false); + const rulerState = useRulerState(parsedForm.value.initialRulerState); useEffect(() => { - return soundEngine.on('end', () => { - playing.set(false); + return soundEngine.onNote((note, on) => { + const id = `${note.ms}-${note.hz}`; + rulerState.set(draft => { + if(on) { + draft.notesActive.set(id, note); + draft.notes.set(id, note); + } else { + draft.notesActive.delete(id); + } + }); }); }, []); - const looping = useDendriform(false); - // // sound engine callbacks // const handleSetPlayback = useCallback((play: boolean) => { if(parsedForm.value.error) return; + + soundEngine.gotoMs(getMsAtLine(tuneForm.value.tune, parsedForm.value.chars, selectedLine.value)); + playing.set(play); - play ? soundEngine.play() : soundEngine.pause(); + + if(play) { + soundEngine.play(); + rulerState.set(draft => { + draft.notes.clear(); + }); + } else { + soundEngine.pause(); + } }, []); const handleTogglePlayback = useCallback((state: string) => { @@ -301,7 +306,7 @@ export function XenpaperApp(props: Props): React.ReactElement { const handleToggleLoop = useCallback(() => { const newValue = !looping.value; looping.set(newValue); - soundEngine.setLoop(newValue); + soundEngine.setLoopActive(newValue); }, []); // @@ -332,7 +337,9 @@ export function XenpaperApp(props: Props): React.ReactElement { // sidebar state // - const [sidebarState, setSidebar] = useState('info'); + const [sidebarState, setSidebar] = useState(() => { + return parsedForm.value?.initialRulerState?.lowHz ? 'ruler' : 'info'; + }); const toggleSidebarInfo = useCallback(() => { setSidebar(s => s !== 'info' ? 'info' : 'none'); @@ -342,31 +349,17 @@ export function XenpaperApp(props: Props): React.ReactElement { setSidebar(s => s !== 'share' ? 'share' : 'none'); }, []); + const toggleSidebarRuler = useCallback(() => { + setSidebar(s => s !== 'ruler' ? 'ruler' : 'none'); + }, []); + const onSetTune = useCallback(async (tune: string): Promise => { tuneForm.branch('tune').set(tune); await soundEngine.gotoMs(0); handleSetPlayback(true); }, []); - // - // textarea focus control - // - - const focusCodearea = useCallback(() => { - // TODO - }, []); - - // release layoutMode notes - - const onMouseUp = useCallback(() => { - // TODO, why errors? - //layoutMode.branch('activeNotes').set([]); - }, []); - - const codepaneContainerProps = { - onClick: focusCodearea, - onMouseUp - }; + const codepaneContainerProps = {}; // // elements @@ -398,16 +391,26 @@ export function XenpaperApp(props: Props): React.ReactElement { const sidebarToggles = <> Info Share + {/*Ruler*/} ; - const sidebar = ; + const sidebar = <> + {sidebarState === 'info' && + + } + {sidebarState === 'share' && tuneForm.render(form => { + const url = form.branch('url').useValue(); + const urlEmbed = form.branch('urlEmbed').useValue(); + return ; + })} + {sidebarState === 'ruler' && + + + + } + ; - const code = ; + const code = ; const htmlTitle = ; @@ -438,10 +441,6 @@ export function XenpaperApp(props: Props): React.ReactElement { sidebar={sidebar} codepaneContainerProps={codepaneContainerProps} />; - - /* layoutMode.branch('on').render(layoutMode => { - return Layout
mode
; - })*/ } // @@ -548,6 +547,7 @@ function EmbedLayout(props: EmbedLayoutProps): React.ReactElement { type CodePanelProps = { tuneForm: Dendriform; parsedForm: Dendriform; + selectedLine: Dendriform; }; function CodePanel(props: CodePanelProps): React.ReactElement { @@ -555,59 +555,54 @@ function CodePanel(props: CodePanelProps): React.ReactElement { const embed = form.branch('embed').useValue(); - // keep track of sound engine time - const [time, setTime] = useState(0); - useAnimationFrame(() => { - setTime(soundEngine.playing() ? soundEngine.position() : -1); - }, []); - // get dendriform state values const {chars, error} = props.parsedForm.useValue(); - const layoutModeOn = false; //layoutMode.branch('on').useValue(); - const layoutModeNotes: number[] = []; // layoutMode.branch('activeNotes').useValue(); // use value with a 200ms debounce for perf reasons - // this debounce does cause the code vlue to progress forwad + // this debounce does cause the code value to progress forward // without the calculated syntax highlighting // so colours will be momentarily skew-whiff // but thats better than parsing the xenpaper AST at every keystroke const inputProps = useInput(form.branch('tune'), 200); + const tuneChars: string[] = inputProps.value.split(''); + const charDataArray: (CharData|undefined)[] = tuneChars.map((chr, index) => chars?.[index]); - // create char elements - const charElementProps = createCharElements( - inputProps.value, - chars, - time, - layoutModeOn, - layoutModeNotes - ); - - const charElements = charElementProps.map(({...props}, index) => { // noteId, - if(!layoutModeOn) { - return ; - } + const hasPlayStartButtons = tuneChars.some(ch => ch === '\n'); + let playStartLine = 0; - /*const onPress = () => { - if(noteId !== undefined) { - layoutMode.branch('activeNotes').set(draft => { - draft.push(noteId); - }); - } - }; + const charElements: React.ReactNode[] = []; + + const createPlayStart = () => { + charElements.push( + ); + }; + + if(hasPlayStartButtons) { + createPlayStart(); + } + charDataArray.forEach((charData, index) => { + const ch = tuneChars[index]; - return ;*/ - return null; + ch={ch} + charData={charData} + soundEngine={soundEngine} + />); + + if(ch === '\n') { + createPlayStart(); + } }); // stop event propagation here so we can detect clicks outside of this element in isolation const stopPropagation = (e: Event) => e.stopPropagation(); return - + {error && Error: {error} } @@ -655,21 +650,6 @@ const Hr = styled(Box)` border-top: 1px ${props => props.theme.colors.background.light} solid; `; -const Flink = styled.a` - color: ${props => props.theme.colors.highlights.unknown}; - text-decoration: none; - font-style: normal; - - &:hover, &:focus { - color: #fff; - text-decoration: underline; - } - - &:active { - color: #fff; - } -`; - type SideButtonProps = { active?: boolean; multiline?: boolean; @@ -744,3 +724,29 @@ const EditOnXenpaperButton = styled.a` font-size: ${props => props.multiline ? '0.9rem' : '1.1rem'}; } `; + +type PlayStartProps = { + line: number; + selectedLine: Dendriform; +}; + +const PlayStart = styled(({line, selectedLine, ...props}: PlayStartProps) => { + const onClick = () => selectedLine.set(line); + return {'>'}; +})` + position: absolute; + left: .8rem; + border: none; + display: block; + cursor: pointer; + color: ${props => props.theme.colors.text.placeholder}; + outline: none; + opacity: ${props => props.selectedLine.useValue() === props.line ? '1' : '.2'}; + pointer-events: auto; + + transition: opacity .2s ease-out; + + &:hover, &:focus, &:active { + opacity: 1; + } +`; diff --git a/packages/xenpaper-ui/src/component/Char.tsx b/packages/xenpaper-ui/src/component/Char.tsx index 0ed5d40..6734061 100644 --- a/packages/xenpaper-ui/src/component/Char.tsx +++ b/packages/xenpaper-ui/src/component/Char.tsx @@ -1,12 +1,42 @@ +import React, {useState} from 'react'; import styled from 'styled-components'; -import type {HighlightColor} from '../data/grammar-to-chars'; +import type {HighlightColor, CharData} from '../data/grammar-to-chars'; +import {useAnimationFrame} from '../hooks/useAnimationFrame'; +import type {SoundEngine} from '@xenpaper/mosc'; -interface Props { +type CharProps = { + ch: string; + charData?: CharData; + soundEngine: SoundEngine; +}; + +export const Char = React.memo(function Char(props: CharProps): React.ReactElement { + const {ch, charData, soundEngine} = props; + + const [active, setActive] = useState(false); + + useAnimationFrame(() => { + const time = soundEngine.playing() ? soundEngine.position() : -1; + const [start, end] = charData?.playTime ?? []; + + const active = time !== -1 + && start !== undefined + && end !== undefined + && start <= time + && end > time; + + setActive(active); + }, [ch, charData]); + + return {ch}; +}); + +type CharSpanProps = { readonly color?: HighlightColor; readonly children: React.ReactNode; -} +}; -export const Char = styled.span` +const CharSpan = styled.span` color: ${props => props.theme.colors.highlights[props.color || 'unknown']}; transition: color 0.2s ${props => props.color === 'error' ? '0.5s' : '0s'} ease-out; diff --git a/packages/xenpaper-ui/src/component/Codearea.tsx b/packages/xenpaper-ui/src/component/Codearea.tsx index 2c70a46..496dc6d 100644 --- a/packages/xenpaper-ui/src/component/Codearea.tsx +++ b/packages/xenpaper-ui/src/component/Codearea.tsx @@ -113,7 +113,7 @@ const Textarea = styled.textarea` overflow: hidden; -webkit-text-fill-color: transparent; -webkit-font-smoothing: antialiased; - padding: 1rem; + padding: 1rem 1rem 1rem 2rem; outline: 0; &::selection { @@ -152,6 +152,6 @@ const Highlight = styled.pre` overflow-wrap: break-word; position: relative; pointer-events: ${props => props.freeze ? 'auto' : 'none'}; - padding: 1rem; + padding: 1rem 1rem 1rem 2rem; user-select: none; `; diff --git a/packages/xenpaper-ui/src/data/__tests__/grammar.test.ts b/packages/xenpaper-ui/src/data/__tests__/grammar.test.ts index 842cf4b..1d7f823 100644 --- a/packages/xenpaper-ui/src/data/__tests__/grammar.test.ts +++ b/packages/xenpaper-ui/src/data/__tests__/grammar.test.ts @@ -1649,6 +1649,54 @@ describe('grammar', () => { ]); }); + it('should parse sequence with ruler setter', () => { + expect(strip(parser('(rl:200c,400c)')).sequence.items).toEqual([ + { + type: 'SetterGroup', + setters: [ + { + type: 'SetRulerRange', + high: { + len: 4, + type: "Pitch", + value: { + cents: 400, + len: 4, + type: "PitchCents" + } + }, + len: 12, + low: { + len: 4, + type: "Pitch", + value: { + cents: 200, + len: 4, + type: "PitchCents" + } + } + } + ], + len: 14 + } + ]); + }); + + it('should parse sequence with ruler grid', () => { + expect(strip(parser('(rl:grid)')).sequence.items).toEqual([ + { + type: 'SetterGroup', + setters: [ + { + type: 'SetRulerGrid', + len: 7 + } + ], + len: 9 + } + ]); + }); + it('should error if setter is empty or not delimited properly', () => { expect(() => parser('()')).toThrow('Unexpected token at 1:2. Remainder: )'); expect(() => parser('(div:16;)')).toThrow('Unexpected token at 1:9. Remainder: )'); diff --git a/packages/xenpaper-ui/src/data/__tests__/grammar-to-mosc.test.ts b/packages/xenpaper-ui/src/data/__tests__/process-grammar.test.ts similarity index 81% rename from packages/xenpaper-ui/src/data/__tests__/grammar-to-mosc.test.ts rename to packages/xenpaper-ui/src/data/__tests__/process-grammar.test.ts index 55cd197..423d753 100644 --- a/packages/xenpaper-ui/src/data/__tests__/grammar-to-mosc.test.ts +++ b/packages/xenpaper-ui/src/data/__tests__/process-grammar.test.ts @@ -1,4 +1,4 @@ -import {grammarToMoscScore} from '../grammar-to-mosc'; +import {processGrammar} from '../process-grammar'; expect.extend({ toBeAround(actual, expected, precision = 2) { @@ -65,7 +65,7 @@ describe('grammar to mosc score', () => { const PITCH_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" pitch types","pos":0},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchRatio","numerator":1,"denominator":1,"pos":14},"pos":14},"tail":{"type":"Comma","delimiter":true,"pos":17},"pos":14},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchRatio","numerator":5,"denominator":4,"pos":18},"pos":18},"tail":{"type":"Comma","delimiter":true,"pos":21},"pos":18},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchRatio","numerator":3,"denominator":2,"pos":22},"pos":22},"tail":{"type":"Comma","delimiter":true,"pos":25},"pos":22},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchRatio","numerator":2,"denominator":1,"pos":26},"pos":26},"pos":26},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchCents","cents":0,"pos":30},"pos":30},"tail":{"type":"Comma","delimiter":true,"pos":32},"pos":30},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchCents","cents":400,"pos":33},"pos":33},"tail":{"type":"Comma","delimiter":true,"pos":37},"pos":33},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchCents","cents":700,"pos":38},"pos":38},"tail":{"type":"Comma","delimiter":true,"pos":42},"pos":38},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchCents","cents":1200,"pos":43},"pos":43},"pos":43},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchHz","hz":220,"pos":49},"pos":49},"tail":{"type":"Comma","delimiter":true,"pos":54},"pos":49},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchHz","hz":440,"pos":55},"pos":55},"tail":{"type":"Comma","delimiter":true,"pos":60},"pos":55},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchHz","hz":880,"pos":61},"pos":61},"tail":{"type":"Comma","delimiter":true,"pos":66},"pos":61},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchHz","hz":1760,"pos":67},"pos":67},"pos":67},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchOctaveDivision","numerator":0,"denominator":4,"octaveSize":2,"pos":74},"pos":74},"tail":{"type":"Comma","delimiter":true,"pos":78},"pos":74},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchOctaveDivision","numerator":1,"denominator":4,"octaveSize":2,"pos":79},"pos":79},"tail":{"type":"Comma","delimiter":true,"pos":83},"pos":79},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchOctaveDivision","numerator":2,"denominator":4,"octaveSize":2,"pos":84},"pos":84},"tail":{"type":"Comma","delimiter":true,"pos":88},"pos":84},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchOctaveDivision","numerator":3,"denominator":4,"octaveSize":2,"pos":89},"pos":89},"pos":89}],"pos":0},"pos":0}`); it('should translate pitch types', () => { - expect(grammarToMoscScore(PITCH_TEST)).toEqual({ + expect(processGrammar(PITCH_TEST).score).toEqual({ sequence: [ ...INITIAL, // pitch ratios @@ -73,103 +73,119 @@ describe('grammar to mosc score', () => { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '1/1' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 550 + hz: 550, + label: '5/4' }, { type: 'NOTE_TIME', time: 1, timeEnd: 1.5, - hz: 660 + hz: 660, + label: '3/2' }, { type: 'NOTE_TIME', time: 1.5, timeEnd: 2, - hz: 880 + hz: 880, + label: '2/1' }, // pitch cents { type: 'NOTE_TIME', time: 2, timeEnd: 2.5, - hz: 440 + hz: 440, + label: '0c' }, { type: 'NOTE_TIME', time: 2.5, timeEnd: 3, - hz: 554.3652619537442 + hz: 554.3652619537442, + label: '400c' }, { type: 'NOTE_TIME', time: 3, timeEnd: 3.5, - hz: 659.2551138257398 + hz: 659.2551138257398, + label: '700c' }, { type: 'NOTE_TIME', time: 3.5, timeEnd: 4, - hz: 880 + hz: 880, + label: '1200c' }, // pitch hz { type: 'NOTE_TIME', time: 4, timeEnd: 4.5, - hz: 220 + hz: 220, + label: '220Hz' }, { type: 'NOTE_TIME', time: 4.5, timeEnd: 5, - hz: 440 + hz: 440, + label: '440Hz' }, { type: 'NOTE_TIME', time: 5, timeEnd: 5.5, - hz: 880 + hz: 880, + label: '880Hz' }, { type: 'NOTE_TIME', time: 5.5, timeEnd: 6, - hz: 1760 + hz: 1760, + label: '1760Hz' }, // pitch octave divisions { type: 'NOTE_TIME', time: 6, timeEnd: 6.5, - hz: 440 + hz: 440, + label: '0\\4' }, { type: 'NOTE_TIME', time: 6.5, timeEnd: 7, // @ts-ignore - hz: expect.toBeAround(523.2511306011972) + hz: expect.toBeAround(523.2511306011972), + label: '1\\4' }, { type: 'NOTE_TIME', time: 7, timeEnd: 7.5, // @ts-ignore - hz: expect.toBeAround(622.2539674441618) + hz: expect.toBeAround(622.2539674441618), + label: '2\\4' }, { type: 'NOTE_TIME', time: 7.5, timeEnd: 8, // @ts-ignore - hz: expect.toBeAround(739.9888454232688) + hz: expect.toBeAround(739.9888454232688), + label: '3\\4' }, { type: 'END_TIME', @@ -197,7 +213,7 @@ describe('grammar to mosc score', () => { const SCALE_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" scale degrees","pos":0},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":16},"pos":16},"tail":{"type":"Comma","delimiter":true,"pos":17},"pos":16},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":4,"pos":18},"pos":18},"tail":{"type":"Comma","delimiter":true,"pos":19},"pos":18},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":7,"pos":20},"pos":20},"tail":{"type":"Comma","delimiter":true,"pos":21},"pos":20},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":12,"pos":22},"pos":22},"pos":22},{"type":"SetScale","scale":{"type":"PitchGroupScale","pitches":[{"type":"Pitch","value":{"type":"PitchRatio","numerator":1,"denominator":1,"pos":27},"pos":27},{"type":"Comma","delimiter":true,"pos":30},{"type":"Pitch","value":{"type":"PitchRatio","numerator":5,"denominator":4,"pos":31},"pos":31},{"type":"Comma","delimiter":true,"pos":34},{"type":"Pitch","value":{"type":"PitchRatio","numerator":3,"denominator":2,"pos":35},"pos":35},{"type":"Comma","delimiter":true,"pos":38},{"type":"Pitch","value":{"type":"PitchRatio","numerator":2,"denominator":1,"pos":39},"pos":39}],"pos":27},"pos":26},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":44},"pos":44},"tail":{"type":"Comma","delimiter":true,"pos":45},"pos":44},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":1,"pos":46},"pos":46},"tail":{"type":"Comma","delimiter":true,"pos":47},"pos":46},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":2,"pos":48},"pos":48},"tail":{"type":"Comma","delimiter":true,"pos":49},"pos":48},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":3,"pos":50},"pos":50},"pos":50},{"type":"SetScale","scale":{"type":"EdoScale","divisions":19,"octaveSize":2,"pos":54},"pos":53},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":61},"pos":61},"tail":{"type":"Comma","delimiter":true,"pos":62},"pos":61},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":6,"pos":63},"pos":63},"tail":{"type":"Comma","delimiter":true,"pos":64},"pos":63},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":11,"pos":65},"pos":65},"tail":{"type":"Comma","delimiter":true,"pos":67},"pos":65},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":19,"pos":68},"pos":68},"pos":68},{"type":"SetScale","scale":{"type":"RatioChordScale","pitches":[{"type":"RatioChordPitch","pitch":4,"pos":73},{"type":"Colon","delimiter":true,"pos":74},{"type":"RatioChordPitch","pitch":5,"pos":75},{"type":"Colon","delimiter":true,"pos":76},{"type":"RatioChordPitch","pitch":6,"pos":77},{"type":"Colon","delimiter":true,"pos":78},{"type":"RatioChordPitch","pitch":7,"pos":79},{"type":"Colon","delimiter":true,"pos":80},{"type":"RatioChordPitch","pitch":8,"pos":81}],"pos":73},"pos":72},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":84},"pos":84},"tail":{"type":"Comma","delimiter":true,"pos":85},"pos":84},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":1,"pos":86},"pos":86},"tail":{"type":"Comma","delimiter":true,"pos":87},"pos":86},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":2,"pos":88},"pos":88},"tail":{"type":"Comma","delimiter":true,"pos":89},"pos":88},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":4,"pos":90},"pos":90},"pos":90}],"pos":0},"pos":0}`); it('should translate scale degrees', () => { - expect(grammarToMoscScore(SCALE_TEST)).toEqual({ + expect(processGrammar(SCALE_TEST).score).toEqual({ sequence: [ ...INITIAL, // default scale (12edo) @@ -205,100 +221,116 @@ describe('grammar to mosc score', () => { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 554.3652619537442 + hz: 554.3652619537442, + label: '4\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 1.5, - hz: 659.2551138257398 + hz: 659.2551138257398, + label: '7\\12' }, { type: 'NOTE_TIME', time: 1.5, timeEnd: 2, - hz: 880 + hz: 880, + label: '0\\12' }, // ratios scale { type: 'NOTE_TIME', time: 2, timeEnd: 2.5, - hz: 440 + hz: 440, + label: '1/1' }, { type: 'NOTE_TIME', time: 2.5, timeEnd: 3, - hz: 550 + hz: 550, + label: '5/4' }, { type: 'NOTE_TIME', time: 3, timeEnd: 3.5, - hz: 660 + hz: 660, + label: '3/2' }, { type: 'NOTE_TIME', time: 3.5, timeEnd: 4, - hz: 880 + hz: 880, + label: '2/1' }, // 19edo scale { type: 'NOTE_TIME', time: 4, timeEnd: 4.5, - hz: 440 + hz: 440, + label: '0\\19' }, { type: 'NOTE_TIME', time: 4.5, timeEnd: 5, - hz: 547.6647393641703 + hz: 547.6647393641703, + label: '6\\19' }, { type: 'NOTE_TIME', time: 5, timeEnd: 5.5, - hz: 657.2539431279737 + hz: 657.2539431279737, + label: '11\\19' }, { type: 'NOTE_TIME', time: 5.5, timeEnd: 6, - hz: 880 + hz: 880, + label: '0\\19' }, // multi ratio scale { type: 'NOTE_TIME', time: 6, timeEnd: 6.5, - hz: 440 + hz: 440, + label: '4/4' }, { type: 'NOTE_TIME', time: 6.5, timeEnd: 7, - hz: 550 + hz: 550, + label: '5/4' }, { type: 'NOTE_TIME', time: 7, timeEnd: 7.5, - hz: 660 + hz: 660, + label: '6/4' }, { type: 'NOTE_TIME', time: 7.5, timeEnd: 8, - hz: 880 + hz: 880, + label: '8/4' }, { type: 'END_TIME', @@ -317,50 +349,57 @@ describe('grammar to mosc score', () => { const TIMING_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" timing","pos":0},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":9},"pos":9},"tail":{"type":"Comma","delimiter":true,"pos":10},"pos":9},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":11},"pos":11},"tail":{"type":"Comma","delimiter":true,"pos":12},"pos":11},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":13},"pos":13},"tail":{"type":"Hold","length":1,"pos":14},"pos":13},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":15},"pos":15},"tail":{"type":"Hold","length":1,"pos":16},"pos":15},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":18},"pos":18},"pos":18},{"type":"Rest","length":1,"pos":19},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":20},"pos":20},"tail":{"type":"Hold","length":2,"pos":21},"pos":20},{"type":"Rest","length":1,"pos":23},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":24},"pos":24},"pos":24},{"type":"Rest","length":1,"pos":25}],"pos":0},"pos":0}`); it('should translate timing', () => { - expect(grammarToMoscScore(TIMING_TEST)).toEqual({ + expect(processGrammar(TIMING_TEST).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 2, timeEnd: 3, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 3, timeEnd: 3.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 4, timeEnd: 5.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 6, timeEnd: 6.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'END_TIME', @@ -385,62 +424,71 @@ describe('grammar to mosc score', () => { const SUBDIVISION_TEST = JSON.parse(`{"type":"XenpaperGrammar","delimiter":false,"sequence":{"type":"Sequence","delimiter":false,"items":[{"type":"Comment","delimiter":false,"comment":" subdivision","len":13,"pos":0},{"type":"Whitespace","delimiter":true,"len":1,"pos":13},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":14},"len":1,"pos":14},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":15},"len":2,"pos":14},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":16},"len":1,"pos":16},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":17},"len":2,"pos":16},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":18},"len":1,"pos":18},"len":1,"pos":18},{"type":"Whitespace","delimiter":true,"len":2,"pos":19},{"type":"SetterGroup","delimiter":false,"setters":[{"type":"SetSubdivision","delimiter":false,"subdivision":1,"len":5,"pos":22}],"len":7,"pos":21},{"type":"Whitespace","delimiter":true,"len":1,"pos":28},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":29},"len":1,"pos":29},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":30},"len":2,"pos":29},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":31},"len":1,"pos":31},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":32},"len":2,"pos":31},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":33},"len":1,"pos":33},"len":1,"pos":33},{"type":"Whitespace","delimiter":true,"len":2,"pos":34},{"type":"SetterGroup","delimiter":false,"setters":[{"type":"SetSubdivision","delimiter":false,"subdivision":4,"len":5,"pos":37}],"len":7,"pos":36},{"type":"Whitespace","delimiter":true,"len":1,"pos":43},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":44},"len":1,"pos":44},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":45},"len":2,"pos":44},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":46},"len":1,"pos":46},"tail":{"type":"Comma","delimiter":true,"len":1,"pos":47},"len":2,"pos":46},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":48},"len":1,"pos":48},"len":1,"pos":48}],"len":49,"pos":0},"len":49,"pos":0}`); it('should translate subdivisions', () => { - expect(grammarToMoscScore(SUBDIVISION_TEST)).toEqual({ + expect(processGrammar(SUBDIVISION_TEST).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 1.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 1.5, timeEnd: 2.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 2.5, timeEnd: 3.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 3.5, timeEnd: 4.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 4.5, timeEnd: 4.75, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 4.75, timeEnd: 5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 5, timeEnd: 5.25, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'END_TIME', @@ -462,20 +510,22 @@ describe('grammar to mosc score', () => { const TEMPO_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" tempo changes","pos":0},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":16},"pos":16},"tail":{"type":"Comma","delimiter":true,"pos":17},"pos":16},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":18},"pos":18},"pos":18},{"type":"SetterGroup","setters":[{"type":"SetBpm","bpm":200,"pos":22}],"pos":21},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":30},"pos":30},"tail":{"type":"Comma","delimiter":true,"pos":31},"pos":30},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":32},"pos":32},"pos":32}],"pos":0},"pos":0}`); it('should translate tempo', () => { - expect(grammarToMoscScore(TEMPO_TEST)).toEqual({ + expect(processGrammar(TEMPO_TEST).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'TEMPO', @@ -487,13 +537,15 @@ describe('grammar to mosc score', () => { type: 'NOTE_TIME', time: 1, timeEnd: 1.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 1.5, timeEnd: 2, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'END_TIME', @@ -515,20 +567,22 @@ describe('grammar to mosc score', () => { const TEMPO_TEST_BMS = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" tempo changes","pos":0},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":16},"pos":16},"tail":{"type":"Comma","delimiter":true,"pos":17},"pos":16},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":18},"pos":18},"pos":18},{"type":"SetterGroup","setters":[{"type":"SetBms","bms":300,"pos":22}],"pos":21},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":30},"pos":30},"tail":{"type":"Comma","delimiter":true,"pos":31},"pos":30},{"type":"Note","pitch":{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":32},"pos":32},"pos":32}],"pos":0},"pos":0}`); it('should translate tempo', () => { - expect(grammarToMoscScore(TEMPO_TEST_BMS)).toEqual({ + expect(processGrammar(TEMPO_TEST_BMS).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 0.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0.5, timeEnd: 1, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'TEMPO', @@ -540,13 +594,15 @@ describe('grammar to mosc score', () => { type: 'NOTE_TIME', time: 1, timeEnd: 1.5, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 1.5, timeEnd: 2, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'END_TIME', @@ -565,50 +621,57 @@ describe('grammar to mosc score', () => { const CHORDS_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" chords","pos":0},{"type":"Chord","pitches":[{"type":"Pitch","value":{"type":"PitchDegree","degree":0,"pos":10},"pos":10},{"type":"Comma","delimiter":true,"pos":11},{"type":"Pitch","value":{"type":"PitchDegree","degree":4,"pos":12},"pos":12},{"type":"Comma","delimiter":true,"pos":13},{"type":"Pitch","value":{"type":"PitchDegree","degree":7,"pos":14},"pos":14}],"tail":{"type":"Hold","length":1,"pos":16},"pos":9},{"type":"Chord","pitches":[{"type":"Pitch","value":{"type":"PitchDegree","degree":2,"pos":18},"pos":18},{"type":"Comma","delimiter":true,"pos":19},{"type":"Pitch","value":{"type":"PitchDegree","degree":8,"pos":20},"pos":20},{"type":"Comma","delimiter":true,"pos":21},{"type":"Pitch","value":{"type":"PitchDegree","degree":11,"pos":22},"pos":22},{"type":"Comma","delimiter":true,"pos":24},{"type":"Pitch","value":{"type":"PitchDegree","degree":13,"pos":25},"pos":25}],"tail":{"type":"Hold","length":1,"pos":28},"pos":17}],"pos":0},"pos":0}`); it('should translate chords', () => { - expect(grammarToMoscScore(CHORDS_TEST)).toEqual({ + expect(processGrammar(CHORDS_TEST).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 440 + hz: 440, + label: '0\\12' }, { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 554.3652619537442 + hz: 554.3652619537442, + label: '4\\12' }, { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 659.2551138257398 + hz: 659.2551138257398, + label: '7\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 493.8833012561241 + hz: 493.8833012561241, + label: '2\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 698.4564628660078 + hz: 698.4564628660078, + label: '8\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 830.6093951598903 + hz: 830.6093951598903, + label: '11\\12' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 932.3275230361799 + hz: 932.3275230361799, + label: '1\\12' }, { type: 'END_TIME', @@ -627,32 +690,36 @@ describe('grammar to mosc score', () => { const RATIOCHORDS_TEST = JSON.parse(`{"type":"XenpaperGrammar","sequence":{"type":"Sequence","items":[{"type":"Comment","comment":" ratio chords","pos":0},{"type":"RatioChord","pitches":[{"type":"RatioChordPitch","pitch":4,"pos":15},{"type":"Colon","delimiter":true,"pos":16},{"type":"RatioChordPitch","pitch":5,"pos":17}],"tail":{"type":"Hold","length":1,"pos":18},"pos":15},{"type":"Chord","pitches":[{"type":"RatioChordPitch","pitch":4,"pos":20},{"type":"Colon","delimiter":true,"pos":21},{"type":"RatioChordPitch","pitch":5,"pos":22}],"tail":{"type":"Hold","length":1,"pos":24},"pos":19}],"pos":0},"pos":0}`); it('should translate ratio chords', () => { - expect(grammarToMoscScore(RATIOCHORDS_TEST)).toEqual({ + expect(processGrammar(RATIOCHORDS_TEST).score).toEqual({ sequence: [ ...INITIAL, { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 440 + hz: 440, + label: '4/4' }, { type: 'NOTE_TIME', time: 0, timeEnd: 1, - hz: 550 + hz: 550, + label: '5/4' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 440 + hz: 440, + label: '4/4' }, { type: 'NOTE_TIME', time: 1, timeEnd: 2, - hz: 550 + hz: 550, + label: '5/4' }, { type: 'END_TIME', @@ -663,3 +730,19 @@ describe('grammar to mosc score', () => { }); }); }); + +describe('grammar to ruler state', () => { + + // + // 1 (rl:0,'0) 2 + // + + const RULER_RANGE_TEST = JSON.parse(`{"type":"XenpaperGrammar","delimiter":false,"sequence":{"type":"Sequence","delimiter":false,"items":[{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":1,"len":1,"pos":0},"len":1,"pos":0},"len":1,"pos":0},{"type":"Whitespace","delimiter":true,"len":1,"pos":1},{"type":"SetterGroup","delimiter":false,"setters":[{"type":"SetRulerRange","delimiter":false,"low":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":6},"len":1,"pos":6},"high":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":0,"len":1,"pos":9},"octave":{"type":"OctaveModifier","delimiter":false,"octave":1,"len":1,"pos":8},"len":2,"pos":8},"len":7,"pos":3}],"len":9,"pos":2},{"type":"Whitespace","delimiter":true,"len":1,"pos":11},{"type":"Note","delimiter":false,"pitch":{"type":"Pitch","delimiter":false,"value":{"type":"PitchDegree","delimiter":false,"degree":2,"len":1,"pos":12},"len":1,"pos":12},"len":1,"pos":12}],"len":13,"pos":0},"len":13,"pos":0}`); + + it('should translate ruler range', () => { + expect(processGrammar(RULER_RANGE_TEST).initialRulerState).toEqual({ + lowHz: 440, + highHz: 880 + }); + }); +}); diff --git a/packages/xenpaper-ui/src/data/grammar-to-chars.ts b/packages/xenpaper-ui/src/data/grammar-to-chars.ts index 2e14426..57f3a48 100644 --- a/packages/xenpaper-ui/src/data/grammar-to-chars.ts +++ b/packages/xenpaper-ui/src/data/grammar-to-chars.ts @@ -52,6 +52,9 @@ const colorMap = new Map([ ['SetSubdivision','setter'], ['SetOsc','setter'], ['SetEnv','setter'], + ['SetRulerGrid','setter'], + ['SetRulerRange','setter'], + ['SetRulerRange.Pitch','setter'], ['SetterGroup','setterGroup'], ['SetterGroup.Semicolon','setterGroup'], ['Comment','comment'] diff --git a/packages/xenpaper-ui/src/data/grammar.ts b/packages/xenpaper-ui/src/data/grammar.ts index 372a4a7..e5aaff3 100644 --- a/packages/xenpaper-ui/src/data/grammar.ts +++ b/packages/xenpaper-ui/src/data/grammar.ts @@ -588,11 +588,42 @@ export const SetEnv = node/**/( }) ); +// ruler + +export type SetRulerRangeType = NodeType & { + low: PitchType; + high: PitchType; +}; + +export const SetRulerRange = node/**/( + 'SetRulerRange', + All(/^(rl:)/, Pitch, ',', Pitch), + ([prefix, low, high]: [string, PitchType, PitchType]) => ({ + low, + high, + len: prefix.length + 1 + low.len + high.len + }) +); + +export type SetRulerGridType = NodeType; + +export const SetRulerGrid = node/**/( + 'SetRulerGrid', + /^(rl:grid)/, + ([str]: [string]) => ({ + len: str.length + }) +); + +export type SetRulerType = SetRulerRangeType|SetRulerGridType; + +export const SetRuler = Any(SetRulerRange, SetRulerGrid); + // setters -export type SetterType = SetBpmType|SetBmsType|SetSubdivisionType|SetOscType|SetEnvType; +export type SetterType = SetBpmType|SetBmsType|SetSubdivisionType|SetOscType|SetEnvType|SetRulerType; -export const Setter = Any(SetBpm, SetBms, SetSubdivision, SetOsc, SetEnv); +export const Setter = Any(SetBpm, SetBms, SetSubdivision, SetOsc, SetEnv, SetRuler); export type SetterGroupType = NodeType & { setters: SetterType[]; diff --git a/packages/xenpaper-ui/src/data/grammar-to-mosc.ts b/packages/xenpaper-ui/src/data/process-grammar.ts similarity index 71% rename from packages/xenpaper-ui/src/data/grammar-to-mosc.ts rename to packages/xenpaper-ui/src/data/process-grammar.ts index 23002cf..9bc5504 100644 --- a/packages/xenpaper-ui/src/data/grammar-to-mosc.ts +++ b/packages/xenpaper-ui/src/data/process-grammar.ts @@ -25,6 +25,7 @@ import type { SetRootType, SetOscType, SetEnvType, + SetRulerRangeType, DelimiterType } from './grammar'; @@ -47,12 +48,6 @@ import { // utils // -const flatMap = (arr: I[], mapper: (item: I) => O[]): O[] => { - const out: O[] = []; - arr.forEach(item => out.push(...mapper(item))); - return out; -}; - const limit = (name: string, value: number, min: number, max: number): void => { if(value < min || value > max) { throw new Error(`${name} must be between ${min} and ${max}, got ${value}`); @@ -89,7 +84,7 @@ export const pitchToRatio = (pitch: PitchType, scale: number[], octaveSize: numb if(type === 'PitchDegree') { const {degree} = pitch.value as PitchDegreeType; - return scaleRatio(degree, scale, octaveSize) * octaveMulti; + return pitchDegreeToRatio(degree, scale, octaveSize) * octaveMulti; } throw new Error(`Unknown pitch type "${type}"`); @@ -103,13 +98,8 @@ const edoToRatios = (edoSize: number, octaveSize: number): number[] => { return ratios; }; -const scaleRatio = (degree: number, scale: number[], octaveSize: number): number => { +const pitchDegreeWrap = (degree: number, scale: number[]): [number, number] => { limit('Scale degree', degree, -1000, 1000); - limit('Octave size', octaveSize, -20, 20); - - if(scale.length === 0) { - return 1; - } const steps = scale.length; let octave = 0; @@ -123,9 +113,19 @@ const scaleRatio = (degree: number, scale: number[], octaveSize: number): number octave--; } - return scale[degree] * Math.pow(octaveSize, octave); + return [degree, octave]; }; +const pitchDegreeToRatio = (degree: number, scale: number[], octaveSize: number): number => { + limit('Octave size', octaveSize, -20, 20); + + if(scale.length === 0) { + return 1; + } + + const [wrappedDegree, octave] = pitchDegreeWrap(degree, scale); + return scale[wrappedDegree] * Math.pow(octaveSize, octave); +}; const pitchToHz = (pitch: PitchType, context: Context): number => { if(pitch.value.type === 'PitchHz') { @@ -153,6 +153,52 @@ const tailToTime = (tail: TailType|undefined, context: Context): {time: number, }; }; +// +// labels +// + +export const pitchToLabel = (pitch: PitchType, context: Context): string => { + const {type} = pitch.value; + + if(type === 'PitchRatio') { + const {numerator, denominator} = pitch.value as PitchRatioType; + const ratio = numerator / denominator; + limit('Pitch ratio', ratio, 0, 100); + return `${numerator}/${denominator}`; + } + + if(type === 'PitchCents') { + const {cents} = pitch.value as PitchCentsType; + return `${cents}c`; + } + + if(type === 'PitchOctaveDivision') { + const {numerator, denominator} = pitch.value as PitchOctaveDivisionType; + return `${numerator}\\${denominator}`; + } + + if(type === 'PitchDegree') { + const {degree} = pitch.value as PitchDegreeType; + const [wrappedDegree] = pitchDegreeWrap(degree, context.scale); + return context.scaleLabels[wrappedDegree]; + } + + if(type === 'PitchHz') { + const {hz} = pitch.value as PitchHzType; + return `${hz}Hz`; + } + + throw new Error(`Unknown pitch type "${type}"`); +}; + +const edoToLabels = (edoSize: number): string[] => { + const labels: string[] = []; + for(let i = 0; i < edoSize; i++) { + labels.push(`${i}\\${edoSize}`); + } + return labels; +}; + // // converters // @@ -164,12 +210,13 @@ type Context = { time: number; subdivision: number; scale: number[]; + scaleLabels: string[]; octaveSize: number; }; const times: [number,number][] = []; -const processNote = (note: NoteType, context: Context): MoscNote[] => { +const noteToMosc = (note: NoteType, context: Context): MoscNote[] => { const timeProps = tailToTime(note.tail, context); // mutate ast node to add time @@ -180,11 +227,12 @@ const processNote = (note: NoteType, context: Context): MoscNote[] => { return [{ type: 'NOTE_TIME', hz: pitchToHz(note.pitch, context), + label: pitchToLabel(note.pitch, context), ...timeProps }]; }; -const processChord = (chord: ChordType|RatioChordType, context: Context): MoscNote[] => { +const chordToMosc = (chord: ChordType|RatioChordType, context: Context): MoscNote[] => { const {tail, pitches} = chord; const timeProps = tailToTime(tail, context); @@ -199,6 +247,7 @@ const processChord = (chord: ChordType|RatioChordType, context: Context): MoscNo return { type: 'NOTE_TIME', hz: pitchToHz(pitch as PitchType, context), + label: pitchToLabel(pitch as PitchType, context), ...timeProps }; }); @@ -210,10 +259,12 @@ const processChord = (chord: ChordType|RatioChordType, context: Context): MoscNo const ratioPitchTypes: MoscNote[] = pitches .filter((pitch): pitch is RatioChordPitchType => pitch.type === 'RatioChordPitch') .map((pitch: any) => { - const ratio = (pitch as RatioChordPitchType).pitch / (firstRatioPitch as RatioChordPitchType).pitch; + const numerator = (pitch as RatioChordPitchType).pitch; + const denominator = (firstRatioPitch as RatioChordPitchType).pitch; return { type: 'NOTE_TIME', - hz: ratio * context.rootHz, + hz: numerator / denominator * context.rootHz, + label: `${numerator}/${denominator}`, ...timeProps }; }); @@ -258,9 +309,11 @@ const setScale = (setScale: SetScaleType, context: Context): void => { } context.scale = filteredPitches.map(pitch => pitchToRatio(pitch, context.scale, context.octaveSize)); + context.scaleLabels = filteredPitches.map(pitch => pitchToLabel(pitch, context)); if(scaleOctaveMarker) { context.octaveSize = context.scale.pop() || 2; + context.scaleLabels.pop(); } return; @@ -269,6 +322,7 @@ const setScale = (setScale: SetScaleType, context: Context): void => { if(type === 'EdoScale') { const {divisions, octaveSize} = scale as EdoScaleType; context.scale = edoToRatios(divisions, octaveSize); + context.scaleLabels = edoToLabels(divisions); context.octaveSize = octaveSize; return; } @@ -281,9 +335,11 @@ const setScale = (setScale: SetScaleType, context: Context): void => { .map(pitch => pitch.pitch); context.scale = ratios.map(ratio => ratio / ratios[0]); + context.scaleLabels = ratios.map(ratio => `${ratio}/${ratios[0]}`); if(scaleOctaveMarker) { context.octaveSize = context.scale.pop() || 2; + context.scaleLabels.pop(); } return; @@ -292,7 +348,7 @@ const setScale = (setScale: SetScaleType, context: Context): void => { throw new Error(`Unknown scale type "${type}"`); }; -const processSetter = (setter: SetterType, context: Context): MoscItem[] => { +const setterToMosc = (setter: SetterType, context: Context): MoscItem[] => { const {type, delimiter} = setter; if(delimiter) return []; @@ -350,13 +406,47 @@ const processSetter = (setter: SetterType, context: Context): MoscItem[] => { }]; } - throw new Error(`Unknown setters type "${type}"`); + return []; +}; + +export type InitialRulerState = { + lowHz?: number; + highHz?: number; }; -export const grammarToMoscScore = (grammar: XenpaperAST): MoscScore|undefined => { +const setterToRulerState = (setter: SetterType, context: Context): InitialRulerState => { + const {type, delimiter} = setter; + + if(delimiter) return {}; + + if(type === 'SetRulerGrid') { + return {}; + } + + if(type === 'SetRulerRange') { + const {low, high} = setter as SetRulerRangeType; + return { + lowHz: pitchToHz(low, context), + highHz: pitchToHz(high, context) + }; + } + + return {}; +}; + +export type Processed = { + score?: MoscScore; + initialRulerState?: InitialRulerState; +}; + +export const processGrammar = (grammar: XenpaperAST): Processed => { + + // console.log('grammar', JSON.stringify(grammar)); const grammarSequence = grammar.sequence; - if(!grammarSequence) return undefined; + if(!grammarSequence) { + return {}; + } const INITIAL_TEMPO: MoscTempo = { type: 'TEMPO', @@ -391,29 +481,34 @@ export const grammarToMoscScore = (grammar: XenpaperAST): MoscScore|undefined => time: 0, subdivision: 0.5, scale: edoToRatios(12, 2), + scaleLabels: edoToLabels(12), octaveSize: 2 }; - const items: MoscItem[] = flatMap(grammarSequence.items, (item): MoscItem[] => { + const moscItems: MoscItem[] = []; + let initialRulerState: InitialRulerState|undefined = {}; + + grammarSequence.items.forEach((item): void => { const {type} = item; if(type === 'Comment' || type === 'BarLine' || type === 'Whitespace') { // do nothing - return []; + return; } if(type === 'SetScale') { setScale(item as SetScaleType, context); - return []; + return; } if(type === 'SetRoot') { const {pitch} = item as SetRootType; context.rootHz = pitchToHz(pitch, context); - return []; + return; } if(type === 'Note') { - return processNote(item as NoteType, context); + moscItems.push(...noteToMosc(item as NoteType, context)); + return; } if(type === 'Rest') { @@ -424,19 +519,28 @@ export const grammarToMoscScore = (grammar: XenpaperAST): MoscScore|undefined => const arr: [number,number] = [time, context.time]; times.push(arr); rest.time = arr; - return []; + return; } if(type === 'Chord') { - return processChord(item as ChordType, context); + moscItems.push(...chordToMosc(item as ChordType, context)); + return; } if(type === 'RatioChord') { - return processChord(item as RatioChordType, context); + moscItems.push(...chordToMosc(item as RatioChordType, context)); + return; } if(type === 'SetterGroup') { - return flatMap((item as SetterGroupType).setters, setter => processSetter(setter, context)); + (item as SetterGroupType).setters.forEach(setter => { + moscItems.push(...setterToMosc(setter, context)); + initialRulerState = { + ...initialRulerState, + ...setterToRulerState(setter, context) + }; + }); + return; } throw new Error(`Unknown sequence item "${type}"`); @@ -446,7 +550,7 @@ export const grammarToMoscScore = (grammar: XenpaperAST): MoscScore|undefined => INITIAL_TEMPO, INITIAL_OSC, INITIAL_ENV, - ...items, + ...moscItems, { type: 'END_TIME', time: context.time @@ -462,8 +566,13 @@ export const grammarToMoscScore = (grammar: XenpaperAST): MoscScore|undefined => time[1] = thisTimeToMs(time[1]); }); - return { + const score = { sequence, lengthTime: context.time }; + + return { + score, + initialRulerState + }; }; diff --git a/yarn.lock b/yarn.lock index b198c22..80e9868 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8004,6 +8004,10 @@ kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" +konva@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/konva/-/konva-8.1.0.tgz#5f3a67711604cd4f506e091cd4856fcd9fc2c159" + language-subtag-registry@~0.3.2: version "0.3.21" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" @@ -10449,6 +10453,21 @@ react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" +react-konva@^17.0.2-4: + version "17.0.2-4" + resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-17.0.2-4.tgz#afd0968e1295b624bf2a7a154ba294e0d5be55cd" + dependencies: + react-reconciler "~0.26.2" + scheduler "^0.20.2" + +react-reconciler@~0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.26.2.tgz#bbad0e2d1309423f76cf3c3309ac6c96e05e9d91" + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + react-refresh@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"