diff --git a/public/index.html b/public/index.html index 84b0816..8892fe4 100644 --- a/public/index.html +++ b/public/index.html @@ -70,7 +70,7 @@

>GitHub target="_blank" rel="noreferrer" class="m-2 underline text-cyan-600" - >Known issuesIssues diff --git a/public/manifest.json b/public/manifest.json index af7adf9..ca1290b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -3,17 +3,17 @@ "name": "Loop Supreme", "icons": [ { - "src": "loop-supreme-logo.png", + "src": "icons/loop-supreme-logo.png", "type": "image/png", "sizes": "240x240" }, { - "src": "loop-supreme-logo.svg", + "src": "icons/loop-supreme-logo.svg", "type": "image/svg", "sizes": "240x240" } ], - "start_url": "https://loop-supreme.com", + "start_url": "https://loopsupreme.com", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" diff --git a/roadmap.md b/roadmap.md index 07b79c6..aa12e47 100644 --- a/roadmap.md +++ b/roadmap.md @@ -2,13 +2,13 @@ This is a rough list of tasks that should be completed to consider this project "done" -## Project setup +## ✅ Project setup - [x] create-react-app https://github.com/ericyd/loop-supreme/pull/1 - [x] configure tailwind https://github.com/ericyd/loop-supreme/pull/1 - [x] add project roadmap https://github.com/ericyd/loop-supreme/pull/2 -## Metronome +## ✅ Metronome The `Metronome` is the heart of the app. The BPM, measure, current tick, and time signature should be synchronized to all the components. The metronome will probably be a Context, accessed via a hook that returns a Reader and Writer @@ -29,7 +29,7 @@ The `Metronome` is the heart of the app. The BPM, measure, current tick, and tim - [x] Metronome must play an audible click on each tick https://github.com/ericyd/loop-supreme/pull/4 - [x] Metronome must initialize as "stopped", and can be "started" by user input https://github.com/ericyd/loop-supreme/pull/4 - [x] Metronome can be muted, while still running https://github.com/ericyd/loop-supreme/pull/11 -- [ ] move "playing" state into MetronomeControls; there is no obvious need for it to live in Metronome +- [x] ~move "playing" state into MetronomeControls; there is no obvious need for it to live in Metronome~ irrelevant after https://github.com/ericyd/loop-supreme/pull/28 ## Scene @@ -51,6 +51,7 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T - [x] Component can remove itself from scene https://github.com/ericyd/loop-supreme/pull/5 - [x] Component has arm toggle button https://github.com/ericyd/loop-supreme/pull/8 - [x] ~audio data can be cleared from component without deleting it (to preserve track name)~ just mute, and then re-record if desired +- [ ] `regression` allow re-recording audio over a track [regression introduced here](https://github.com/ericyd/loop-supreme/pull/27) - [x] deleting a track stops playback https://github.com/ericyd/loop-supreme/pull/13 - [x] Component can record data from user device https://github.com/ericyd/loop-supreme/pull/8 - [x] Component shows waveform of recorded audio https://github.com/ericyd/loop-supreme/pull/20 @@ -78,13 +79,13 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T - [x] `space` is play/pause - [ ] add "tap tempo" functionlity and bind to `t` key -## HTML +## ✅ HTML - [x] flesh out header (add links to blog, etc) https://github.com/ericyd/loop-supreme/pull/16 - [x] track page views (done automatically through Cloudflare) - [x] OG tags, SEO https://github.com/ericyd/loop-supreme/pull/16 -## Deploy +## ✅ Deploy - [x] building (GH Actions) https://github.com/ericyd/loop-supreme/pull/17 and https://github.com/ericyd/loop-supreme/pull/19 - [x] hosting (Cloudflare) @@ -99,6 +100,7 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T - [ ] show alert if track latency cannot be detected, or if it seems wildly out of the norm (~100ms +/ 20ms ???). Consider adding a "custom latency" input option??? - [x] remove useInterval hook (not used) - [x] investigate network calls to workers. https://github.com/ericyd/loop-supreme/pull/21 +- [ ] keyboard bindings should respect certain boundaries. For example, renaming tracks causes all sorts of things to fire, e.g. `a`, `m`, `r`, `c` all do things that probably shouldn't happen. Maybe this should/could be a global thing? Always check for event target types. ## Design @@ -108,3 +110,5 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T - [ ] Add dark mode toggle button - [ ] allow Track to wrap (controls top, waveform bottom) - [ ] make Track controls slightly less wide +- [ ] add track ID indicator so keyboard controls make sense +- [ ] make beat counter sticky so you can see it even when you scroll diff --git a/scripts/postbuild.sh b/scripts/postbuild.sh index 825dfbb..dd4ada3 100755 --- a/scripts/postbuild.sh +++ b/scripts/postbuild.sh @@ -15,5 +15,5 @@ for filepath in build/static/media/*.js; do filename=$(basename $filepath | sed -E 's|([^\.]+).*|\1|') - cp src/worklets/$filename.js "build/static/media/$(ls build/static/media | grep $filename)" + cp src/workers/$filename.js "build/static/media/$(ls build/static/media | grep $filename)" done diff --git a/src/Metronome/KeyboardBindings.tsx b/src/App/KeyboardBindingsList.tsx similarity index 96% rename from src/Metronome/KeyboardBindings.tsx rename to src/App/KeyboardBindingsList.tsx index 47ffd43..8811a3b 100644 --- a/src/Metronome/KeyboardBindings.tsx +++ b/src/App/KeyboardBindingsList.tsx @@ -16,7 +16,7 @@ const trackKeyBindings = { i: 'Monitor input', } -export default function KeyboardBindings() { +export function KeyboardBindingsList() { return (

Keyboard controls

diff --git a/src/App/index.tsx b/src/App/index.tsx index a5b3a79..36f08f1 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -1,7 +1,8 @@ import React from 'react' import { AudioProvider } from '../AudioProvider' +import { Clock } from '../Clock' import { KeyboardProvider } from '../KeyboardProvider' -import { Metronome } from '../Metronome' +import { KeyboardBindingsList } from './KeyboardBindingsList' type Props = { stream: MediaStream @@ -12,7 +13,8 @@ function App(props: Props) { return ( - + + ) diff --git a/src/Clock/index.tsx b/src/Clock/index.tsx new file mode 100644 index 0000000..881eed3 --- /dev/null +++ b/src/Clock/index.tsx @@ -0,0 +1,25 @@ +import { useMemo } from 'react' +import { Metronome } from '../Metronome' +import { Scene } from '../Scene' + +export const Clock: React.FC = () => { + /** + * Instantiate the clock worker. + * This is truly the heartbeat of the entire app 🥹 + * Workers should be loaded exactly once for a Component. + * The `import.meta.url` is thanks to this SO answer https://stackoverflow.com/a/71134400/3991555, + * which is just a digestible version of the webpack docs https://webpack.js.org/guides/web-workers/ + * I tried refactoring this into a custom hook but ran into all sorts of weird issues. This is easy enough so leaving as is + */ + const clock = useMemo( + () => new Worker(new URL('../workers/clock', import.meta.url)), + [] + ) + + return ( + <> + + + + ) +} diff --git a/src/ControlPanel/BeatCounter.tsx b/src/ControlPanel/BeatCounter.tsx deleted file mode 100644 index 79bd610..0000000 --- a/src/ControlPanel/BeatCounter.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import ControlPanelItem from './ControlPanelItem' - -type BeatCounterProps = { - currentTick: number - currentMeasure: number -} -export default function BeatCounter(props: BeatCounterProps) { - return ( - - - {/* `+ 1` to convert "computer numbers" to "musician numbers" */} - {props.currentTick + 1} - - . {props.currentMeasure + 1} - - ) -} diff --git a/src/ControlPanel/MetronomeControls.tsx b/src/ControlPanel/MetronomeControls.tsx deleted file mode 100644 index c3c1b52..0000000 --- a/src/ControlPanel/MetronomeControls.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useEffect } from 'react' -import PlayPause from '../icons/PlayPause' -import { useKeyboard } from '../KeyboardProvider' -import { VolumeControl } from './VolumeControl' - -type MetronomeControlProps = { - playing: boolean - muted: boolean - togglePlaying(): void - setMuted: React.Dispatch> - setGain(gain: number): void - gain: number -} -export default function MetronomeControl(props: MetronomeControlProps) { - const keyboard = useKeyboard() - const toggleMuted = useCallback(() => { - props.setMuted((muted) => !muted) - }, [props]) - - useEffect(() => { - keyboard.on('c', 'Metronome', toggleMuted) - // kinda wish I could write "space" but I guess this is the way this works. - keyboard.on(' ', 'Metronome', (e) => { - // Only toggle playing if another control element is not currently focused - if ( - !['INPUT', 'SELECT', 'BUTTON'].includes( - document.activeElement?.tagName ?? '' - ) - ) { - props.togglePlaying() - e.preventDefault() - } - }) - }, [keyboard, props, toggleMuted]) - - return ( -
- - - -
- ) -} diff --git a/src/ControlPanel/index.tsx b/src/ControlPanel/index.tsx deleted file mode 100644 index cc9073e..0000000 --- a/src/ControlPanel/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import { useDebouncedCallback } from 'use-debounce' -import { MetronomeReader, MetronomeWriter } from '../Metronome' -import MeasuresPerLoop from './MeasuresPerLoop' -import TimeSignature from './TimeSignature' -import Tempo from './Tempo' -import BeatCounter from './BeatCounter' -import MetronomeControls from './MetronomeControls' - -type Props = { - metronome: MetronomeReader - metronomeWriter: MetronomeWriter -} - -export const ControlPanel: React.FC = ({ - metronome, - metronomeWriter, -}) => { - const setUpstreamBpm = useDebouncedCallback(metronomeWriter.setBpm, 100, { - leading: true, - trailing: false, - }) - - return ( -
- - -
-
- - - -
- - - - -
-
- ) -} diff --git a/src/Metronome/BeatCounter.tsx b/src/Metronome/BeatCounter.tsx new file mode 100644 index 0000000..5a2d2a3 --- /dev/null +++ b/src/Metronome/BeatCounter.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react' +import type { ClockControllerMessage } from '../workers/clock' +import { ControlPanelItem } from './ControlPanelItem' + +type BeatCounterProps = { + clock: Worker + beatsPerMeasure: number +} +export function BeatCounter(props: BeatCounterProps) { + const [currentTick, setCurrentTick] = useState(0) + + /** + * Add clock event listeners + */ + useEffect(() => { + const clockMessageHandler = ( + event: MessageEvent + ) => { + if (event.data.message === 'TICK') { + setCurrentTick(event.data.currentTick) + } + } + props.clock.addEventListener('message', clockMessageHandler) + return () => { + props.clock.removeEventListener('message', clockMessageHandler) + } + }, [props.clock, props.beatsPerMeasure]) + + return ( + + + {/* `+ 1` to convert "computer numbers" to "musician numbers" */} + {(currentTick % props.beatsPerMeasure) + 1} + + + . {Math.floor(currentTick / props.beatsPerMeasure) + 1} + + + ) +} diff --git a/src/ControlPanel/ControlPanelItem.tsx b/src/Metronome/ControlPanelItem.tsx similarity index 78% rename from src/ControlPanel/ControlPanelItem.tsx rename to src/Metronome/ControlPanelItem.tsx index d94a992..d59a78d 100644 --- a/src/ControlPanel/ControlPanelItem.tsx +++ b/src/Metronome/ControlPanelItem.tsx @@ -3,6 +3,6 @@ type Props = { } // This component was part of an earlier design iteration. // It should be removed eventually if its just a div -export default function ControlPanelItem(props: Props) { +export function ControlPanelItem(props: Props) { return
{props.children}
} diff --git a/src/ControlPanel/MeasuresPerLoop.tsx b/src/Metronome/controls/MeasuresPerLoopControl.tsx similarity index 87% rename from src/ControlPanel/MeasuresPerLoop.tsx rename to src/Metronome/controls/MeasuresPerLoopControl.tsx index 7210f75..946a047 100644 --- a/src/ControlPanel/MeasuresPerLoop.tsx +++ b/src/Metronome/controls/MeasuresPerLoopControl.tsx @@ -1,10 +1,10 @@ -import ControlPanelItem from './ControlPanelItem' +import { ControlPanelItem } from '../ControlPanelItem' type MeasuresPerLoopProps = { onChange(measuresPerLoop: number): void measuresPerLoop: number } -export default function MeasuresPerLoop(props: MeasuresPerLoopProps) { +export function MeasuresPerLoopControl(props: MeasuresPerLoopProps) { const handleChange: React.ChangeEventHandler = (event) => { const measuresPerLoop = Number(event.target.value) if (Number.isNaN(measuresPerLoop)) { diff --git a/src/ControlPanel/Tempo.tsx b/src/Metronome/controls/TempoControl.tsx similarity index 89% rename from src/ControlPanel/Tempo.tsx rename to src/Metronome/controls/TempoControl.tsx index bceb5f8..33accf5 100644 --- a/src/ControlPanel/Tempo.tsx +++ b/src/Metronome/controls/TempoControl.tsx @@ -1,11 +1,11 @@ import { useState } from 'react' -import ControlPanelItem from './ControlPanelItem' +import { ControlPanelItem } from '../ControlPanelItem' type TempoProps = { onChange(bpm: number): void defaultValue: number } -export default function Tempo(props: TempoProps) { +export function TempoControl(props: TempoProps) { const [visualBpm, setVisualBpm] = useState('120.0') // TODO: something about this isn't working right const handleChange: React.ChangeEventHandler = (event) => { diff --git a/src/ControlPanel/TimeSignature.tsx b/src/Metronome/controls/TimeSignatureControl.tsx similarity index 82% rename from src/ControlPanel/TimeSignature.tsx rename to src/Metronome/controls/TimeSignatureControl.tsx index 9fae35d..6b9e650 100644 --- a/src/ControlPanel/TimeSignature.tsx +++ b/src/Metronome/controls/TimeSignatureControl.tsx @@ -1,12 +1,12 @@ -import { TimeSignature as TimeSignatureType } from '../Metronome' -import ControlPanelItem from './ControlPanelItem' +import { TimeSignature } from '..' +import { ControlPanelItem } from '../ControlPanelItem' type TimeSignatureProps = { - onChange(signature: TimeSignatureType): void + onChange(signature: TimeSignature): void beatsPerMeasure: number beatUnit: number } -export default function TimeSignature(props: TimeSignatureProps) { +export function TimeSignatureControl(props: TimeSignatureProps) { const handleChange: React.ChangeEventHandler = (event) => { const [beatsPerMeasureStr, beatUnitStr] = event.target.value?.split('/') if (!beatsPerMeasureStr || !beatUnitStr) { diff --git a/src/ControlPanel/VolumeControl.tsx b/src/Metronome/controls/VolumeControl.tsx similarity index 89% rename from src/ControlPanel/VolumeControl.tsx rename to src/Metronome/controls/VolumeControl.tsx index 20d9d27..26ffaf4 100644 --- a/src/ControlPanel/VolumeControl.tsx +++ b/src/Metronome/controls/VolumeControl.tsx @@ -1,5 +1,5 @@ -import ButtonBase from '../ButtonBase' -import MetronomeIcon from '../icons/MetronomeIcon' +import ButtonBase from '../../ButtonBase' +import MetronomeIcon from '../../icons/MetronomeIcon' type Props = { muted: boolean diff --git a/src/Metronome/index.tsx b/src/Metronome/index.tsx index c8450d4..585ba90 100644 --- a/src/Metronome/index.tsx +++ b/src/Metronome/index.tsx @@ -1,10 +1,20 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useAudioContext } from '../AudioProvider' -import { ControlPanel } from '../ControlPanel' -import { Scene } from '../Scene' -import type { ClockControllerMessage } from '../worklets/clock' -import KeyboardBindings from './KeyboardBindings' -import { decayingSine } from './waveforms' +import { BeatCounter } from './BeatCounter' +import { MeasuresPerLoopControl } from './controls/MeasuresPerLoopControl' +import { TempoControl } from './controls/TempoControl' +import { TimeSignatureControl } from './controls/TimeSignatureControl' +import { useKeyboard } from '../KeyboardProvider' +import { VolumeControl } from './controls/VolumeControl' +import type { + ClockControllerMessage, + ClockWorkerStartMessage, + ClockWorkerStopMessage, + ClockWorkerUpdateMessage, +} from '../workers/clock' +import { useDecayingSine } from './waveforms' +import { useDebouncedCallback } from 'use-debounce' +import { PlayPause } from '../icons/PlayPause' export type TimeSignature = { beatsPerMeasure: number @@ -14,35 +24,18 @@ export type TimeSignature = { beatUnit: number } -export type MetronomeReader = { - bpm: number - currentTick: number - timeSignature: TimeSignature - measuresPerLoop: number - currentMeasure: number - playing: boolean - clock: Worker - gain: number - muted: boolean -} - -export type MetronomeWriter = { - setBpm: (bpm: number) => void - setTimeSignature: (timeSignature: TimeSignature) => void - setMeasuresPerLoop: (count: number) => void - togglePlaying: () => Promise - setGain: (gain: number) => void - setMuted: React.Dispatch> -} - type Props = { - children?: React.ReactNode + clock: Worker } -export const Metronome: React.FC = () => { +export const Metronome: React.FC = ({ clock }) => { const { audioContext } = useAudioContext() - const [currentTick, setCurrentTick] = useState(-1) - const [bpm, setBpm] = useState(120) + const keyboard = useKeyboard() + const [bpm, setBpmDefault] = useState(120) + const setBpm = useDebouncedCallback(setBpmDefault, 100, { + leading: true, + trailing: false, + }) const [timeSignature, setTimeSignature] = useState({ beatsPerMeasure: 4, beatUnit: 4, @@ -51,44 +44,14 @@ export const Metronome: React.FC = () => { const [playing, setPlaying] = useState(false) const [gain, setGain] = useState(0.5) const [muted, setMuted] = useState(false) + const toggleMuted = useCallback(() => setMuted((muted) => !muted), []) /** * create 2 AudioBuffers with different frequencies, * to be used for the metronome beep. */ - const sine330 = useMemo(() => { - const buffer = audioContext.createBuffer( - 1, - // this should be the maximum length needed for the audio; - // since this buffer is just holding a short sine wave, 1 second will be plenty - audioContext.sampleRate, - audioContext.sampleRate - ) - buffer.copyToChannel(decayingSine(buffer.sampleRate, 330), 0) - return buffer - }, [audioContext]) - const sine380 = useMemo(() => { - const buffer = audioContext.createBuffer( - 1, - audioContext.sampleRate, - audioContext.sampleRate - ) - buffer.copyToChannel(decayingSine(buffer.sampleRate, 380), 0) - return buffer - }, [audioContext]) - - /** - * Instantiate the clock worker. - * This is truly the heartbeat of the entire app 🥹 - * Workers should be loaded exactly once for a Component. - * The `import.meta.url` is thanks to this SO answer https://stackoverflow.com/a/71134400/3991555, - * which is just a digestible version of the webpack docs https://webpack.js.org/guides/web-workers/ - * I tried refactoring this into a custom hook but ran into all sorts of weird issues. This is easy enough so leaving as is - */ - const clock = useMemo( - () => new Worker(new URL('../worklets/clock', import.meta.url)), - [] - ) + const sine330 = useDecayingSine(audioContext, 330) + const sine380 = useDecayingSine(audioContext, 380) /** * Set up metronome gain node. @@ -110,9 +73,6 @@ export const Metronome: React.FC = () => { (event: MessageEvent) => { // console.log(event.data) // this is really noisy if (event.data.message === 'TICK') { - const { currentTick } = event.data - setCurrentTick(currentTick) - const source = new AudioBufferSourceNode(audioContext, { buffer: event.data.downbeat ? sine380 : sine330, }) @@ -125,6 +85,9 @@ export const Metronome: React.FC = () => { [audioContext, sine330, sine380] ) + /** + * Add clock event listeners + */ useEffect(() => { clock.addEventListener('message', clockMessageHandler) return () => { @@ -132,63 +95,105 @@ export const Metronome: React.FC = () => { } }, [clockMessageHandler, clock]) - async function togglePlaying() { + /** + * When "playing" is toggled on/off, + * Send a message to the clock worker to start or stop. + * In addition, suspend the audio context. + * Suspending the audio context is _probably_ redundant, + * since the clock events drive the whole app. + * But, until proven otherwise, going to leave it. + */ + const togglePlaying = useCallback(async () => { if (playing) { await audioContext.suspend() clock.postMessage({ message: 'STOP', - }) + } as ClockWorkerStopMessage) setPlaying(false) } else { await audioContext.resume() clock.postMessage({ + message: 'START', bpm, beatsPerMeasure: timeSignature.beatsPerMeasure, measuresPerLoop, - message: 'START', - }) + } as ClockWorkerStartMessage) setPlaying(true) } - } + }, [ + audioContext, + playing, + timeSignature.beatsPerMeasure, + measuresPerLoop, + bpm, + clock, + ]) + /** + * Bind any changes to core metronome properties to the clock. + */ useEffect(() => { clock.postMessage({ + message: 'UPDATE', bpm, beatsPerMeasure: timeSignature.beatsPerMeasure, measuresPerLoop, - message: 'UPDATE', - }) + } as ClockWorkerUpdateMessage) }, [bpm, timeSignature.beatsPerMeasure, measuresPerLoop, clock]) - const reader: MetronomeReader = { - bpm, - // we start at -1 to make the first beat work easily, - // but we don't want to *show* -1 to the user - currentTick: Math.max(currentTick % timeSignature.beatsPerMeasure, 0), - timeSignature, - measuresPerLoop, - currentMeasure: Math.max( - Math.floor(currentTick / timeSignature.beatsPerMeasure), - 0 - ), - playing, - clock, - gain, - muted, - } - const writer: MetronomeWriter = { - setBpm, - setTimeSignature, - setMeasuresPerLoop, - togglePlaying, - setGain, - setMuted, - } + /** + * Bind keyboard effects + */ + useEffect(() => { + keyboard.on('c', 'Metronome', toggleMuted) + // kinda wish I could write "space" but I guess this is the way this works. + keyboard.on(' ', 'Metronome', (e) => { + // Only toggle playing if another control element is not currently focused + if ( + !['INPUT', 'SELECT', 'BUTTON'].includes( + document.activeElement?.tagName ?? '' + ) + ) { + togglePlaying() + e.preventDefault() + } + }) + }, [keyboard, togglePlaying, toggleMuted]) + return ( - <> - - - - +
+
+ + + +
+ +
+
+ + + +
+ + + + +
+
) } diff --git a/src/Metronome/waveforms.ts b/src/Metronome/waveforms.ts index d097908..43bcfbb 100644 --- a/src/Metronome/waveforms.ts +++ b/src/Metronome/waveforms.ts @@ -1,10 +1,12 @@ +import { useMemo } from 'react' + /** * This function builds and returns a Float32Array. * The data of the array is a quickly decaying sine wave. * The Float32Array can be copied to an AudioBuffer for playback. * Inspired by https://blog.paul.cx/post/metronome/ */ -export function decayingSine(sampleRate: number, frequency: number) { +function decayingSine(sampleRate: number, frequency: number) { const channel = new Float32Array(sampleRate) // create a quickly decaying sine wave const durationMs = 100 @@ -25,3 +27,17 @@ export function decayingSine(sampleRate: number, frequency: number) { return channel } + +export function useDecayingSine(audioContext: AudioContext, frequency: number) { + return useMemo(() => { + const buffer = audioContext.createBuffer( + 1, + // this should be the maximum length needed for the audio; + // since this buffer is just holding a short sine wave, 1 second will be plenty + audioContext.sampleRate, + audioContext.sampleRate + ) + buffer.copyToChannel(decayingSine(buffer.sampleRate, frequency), 0) + return buffer + }, [audioContext, frequency]) +} diff --git a/src/Scene/index.tsx b/src/Scene/index.tsx index d9b1e31..acd7b0d 100644 --- a/src/Scene/index.tsx +++ b/src/Scene/index.tsx @@ -2,14 +2,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import ButtonBase from '../ButtonBase' import { Plus } from '../icons/Plus' import { useKeyboard } from '../KeyboardProvider' -import { MetronomeReader } from '../Metronome' import { Track } from '../Track' type Props = { - metronome: MetronomeReader + clock: Worker } -export const Scene: React.FC = ({ metronome }) => { +export const Scene: React.FC = ({ clock }) => { const keyboard = useKeyboard() const [tracks, setTracks] = useState([{ id: 1, selected: false }]) const exportTarget = useMemo(() => new EventTarget(), []) @@ -83,7 +82,7 @@ export const Scene: React.FC = ({ metronome }) => { id={id} selected={selected} onRemove={handleRemoveTrack(id)} - metronome={metronome} + clock={clock} exportTarget={exportTarget} /> ))} diff --git a/src/Start/index.tsx b/src/Start/index.tsx index d1b4f28..159ab70 100644 --- a/src/Start/index.tsx +++ b/src/Start/index.tsx @@ -54,7 +54,7 @@ export const Start: React.FC = () => { logger.error(e, 'Error getting user media') } - const workletUrl = new URL('../worklets/recorder', import.meta.url) + const workletUrl = new URL('../workers/recorder', import.meta.url) try { const audioContext = new AudioContext() diff --git a/src/Track/Waveform.tsx b/src/Track/Waveform.tsx index 46a5c3e..ff66f67 100644 --- a/src/Track/Waveform.tsx +++ b/src/Track/Waveform.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from 'react' import { WaveformControllerMessage, WaveformWorkerInitializeMessage, -} from '../worklets/waveform' +} from '../workers/waveform' type Props = { worker: Worker sampleRate: number } -export default function Waveform(props: Props) { +export function Waveform(props: Props) { const [path, setPath] = useState('M 0 0') const yMax = 2 diff --git a/src/Track/ArmTrackRecording.tsx b/src/Track/controls/ArmTrackRecording.tsx similarity index 89% rename from src/Track/ArmTrackRecording.tsx rename to src/Track/controls/ArmTrackRecording.tsx index 91d8394..1636eff 100644 --- a/src/Track/ArmTrackRecording.tsx +++ b/src/Track/controls/ArmTrackRecording.tsx @@ -1,4 +1,4 @@ -import ButtonBase from '../ButtonBase' +import ButtonBase from '../../ButtonBase' type Props = { toggleArmRecording(): void @@ -6,7 +6,7 @@ type Props = { recording: boolean } -export default function ArmTrackRecording(props: Props) { +export function ArmTrackRecording(props: Props) { return ( (null) const [confirmRemoval, setConfirmRemoval] = useState(false) function handleRemove() { diff --git a/src/Track/SelectInput.tsx b/src/Track/controls/SelectInput.tsx similarity index 81% rename from src/Track/SelectInput.tsx rename to src/Track/controls/SelectInput.tsx index cf89ea7..11039f6 100644 --- a/src/Track/SelectInput.tsx +++ b/src/Track/controls/SelectInput.tsx @@ -1,19 +1,17 @@ import { useEffect, useState } from 'react' -import { logger } from '../util/logger' -import { deviceIdFromStream } from './device-id-from-stream' +import { logger } from '../../util/logger' +import { deviceIdFromStream } from '../../util/device-id-from-stream' type Props = { defaultDeviceId: string setStream(stream: MediaStream): void } -export default function SelectInput(props: Props) { +export function SelectInput(props: Props) { const [inputs, setInputs] = useState([]) - const [selected, setSelected] = useState('') + const [selected, setSelected] = useState(props.defaultDeviceId) useEffect(() => { - logger.debug({ defaultDeviceId: props.defaultDeviceId }) - async function getInputs() { const devices = await navigator.mediaDevices.enumerateDevices() const audioInputs = devices.filter( @@ -21,7 +19,6 @@ export default function SelectInput(props: Props) { ) logger.debug({ audioInputs }) setInputs(audioInputs) - setSelected(props.defaultDeviceId) } getInputs() diff --git a/src/Track/VolumeControl.tsx b/src/Track/controls/VolumeControl.tsx similarity index 100% rename from src/Track/VolumeControl.tsx rename to src/Track/controls/VolumeControl.tsx diff --git a/src/Track/index.tsx b/src/Track/index.tsx index 5346bbd..3811a2a 100644 --- a/src/Track/index.tsx +++ b/src/Track/index.tsx @@ -15,33 +15,32 @@ import React, { useState, } from 'react' import { useAudioContext } from '../AudioProvider' -import { MetronomeReader } from '../Metronome' import { logger } from '../util/logger' -import { VolumeControl } from './VolumeControl' -import type { ClockControllerMessage } from '../worklets/clock' +import { VolumeControl } from './controls/VolumeControl' +import type { ClockControllerMessage } from '../workers/clock' import type { WaveformWorkerFrameMessage, WaveformWorkerMetronomeMessage, WaveformWorkerResetMessage, -} from '../worklets/waveform' -import ArmTrackRecording from './ArmTrackRecording' -import { getLatencySamples } from './get-latency-samples' -import MonitorInput from './MonitorInput' -import Mute from './Mute' -import RemoveTrack from './RemoveTrack' -import Waveform from './Waveform' +} from '../workers/waveform' +import { ArmTrackRecording } from './controls/ArmTrackRecording' +import { getLatencySamples } from '../util/get-latency-samples' +import { MonitorInput } from './controls/MonitorInput' +import { Mute } from './controls/Mute' +import { RemoveTrack } from './controls/RemoveTrack' +import { Waveform } from './Waveform' import { useKeyboard } from '../KeyboardProvider' -import SelectInput from './SelectInput' -import { deviceIdFromStream } from './device-id-from-stream' +import { SelectInput } from './controls/SelectInput' +import { deviceIdFromStream } from '../util/device-id-from-stream' import type { ExportWavWorkerEvent, WavBlobControllerEvent, -} from '../worklets/export' +} from '../workers/export' type Props = { id: number onRemove(): void - metronome: MetronomeReader + clock: Worker selected: boolean exportTarget: EventTarget } @@ -81,7 +80,7 @@ type RecordingMessage = export const Track: React.FC = ({ id, onRemove, - metronome, + clock, selected, exportTarget, }) => { @@ -89,24 +88,36 @@ export const Track: React.FC = ({ const [stream, setStream] = useState(defaultStream) const defaultDeviceId = deviceIdFromStream(defaultStream) ?? '' const keyboard = useKeyboard() + + // title is mostly display-only, but also defines the file name when downloading files const [title, setTitle] = useState(`Track ${id}`) + const handleChangeTitle: ChangeEventHandler = (event) => { + setTitle(event.target.value) + } + + // when a track is armed, it will begin recording automatically on the next loop start const [armed, setArmed] = useState(false) - const toggleArmRecording = () => setArmed((value) => !value) + const toggleArmRecording = () => { + logger.debug('Toggling arm recording') + setArmed((value) => !value) + } const [recording, setRecording] = useState(false) + + // delegate waveform generation and wav file writing to workers const waveformWorker = useMemo( - () => new Worker(new URL('../worklets/waveform', import.meta.url)), + () => new Worker(new URL('../workers/waveform', import.meta.url)), [] ) const exportWorker = useMemo( - () => new Worker(new URL('../worklets/export', import.meta.url)), + () => new Worker(new URL('../workers/export', import.meta.url)), [] ) const downloadLinkRef = useRef(null) /** * Set up track gain. - * Refs vs State ... still learning. - * I believe this is correct because if the GainNode were a piece of State, + * Refs vs State vs Memo: + * Using a ref is the easiest because if the GainNode were a piece of State or a Memo, * then the GainNode would be re-instantiated every time the gain changed. * That would destroy the audio graph that gets connected when the track is playing back. * The audio graph should stay intact, so mutating the gain value directly is (I believe) the correct way to achieve this. @@ -142,7 +153,7 @@ export const Track: React.FC = ({ const bufferSource = useRef() /** - * Builds a callback that handles the messages from the recorder worklet. + * Builds a callback that handles the messages from the recorder worker. * The most important message to handle is SHARE_RECORDING_BUFFER, * which indicates that the recording buffer is ready for playback. */ @@ -164,35 +175,17 @@ export const Track: React.FC = ({ } if (event.data.message === 'SHARE_RECORDING_BUFFER') { + // This "should" be calculated for higher accuracy, from the metronome properties. + // However, to avoid passing them as props (which is slightly more work ¯\_(ツ)_/¯), we are using this. + // Here is a reference implementation calculating the length from metronome props if it ever makes sense to change back. + // https://github.com/ericyd/loop-supreme/blob/562936dd53bbd2158e6779d1c9dbc89ee4684863/src/Track/index.tsx#L167-L189 const fullRecordingLength = event.data.recordingLength - // When in doubt... use dimensional analysis! 🙃 - // - // 60 seconds beats 60 seconds minute - // ———————————— ➗ ————— 🟰 ——————————— 𝒙 ——————— => - // minute minute minute beats - // - // seconds minutes measures beats samples samples - // ————————— 𝒙 ———————— 𝒙 ———————— 𝒙 ———————— 𝒙 ————————— 🟰 —————————— - // minute beat loop measure second loop - const targetRecordingLength = - (60 / metronome.bpm) * - metronome.measuresPerLoop * - metronome.timeSignature.beatsPerMeasure * - audioContext.sampleRate - logger.debug({ - fullRecordingLength, - targetRecordingLength, - differenceInSamples: fullRecordingLength - targetRecordingLength, - differenceInSeconds: - (fullRecordingLength - targetRecordingLength) / - audioContext.sampleRate, - }) // create recording buffer with targetRecordingLength, // to ensure it matches the loop length precisely. const recordingBuffer = audioContext.createBuffer( recordingProperties.numberOfChannels, - targetRecordingLength, + fullRecordingLength, audioContext.sampleRate ) @@ -211,7 +204,7 @@ export const Track: React.FC = ({ // See `worklets/recorder` for the buffer offset // [1] https://developer.mozilla.org/en-US/docs/Web/API/AudioBuffer/copyToChannel // [2] https://jsfiddle.net/y7qL9wr4/7 - event.data.channelsData[i].slice(0, targetRecordingLength), + event.data.channelsData[i].slice(0, fullRecordingLength), i, 0 ) @@ -229,13 +222,7 @@ export const Track: React.FC = ({ } } }, - [ - audioContext, - waveformWorker, - metronome.bpm, - metronome.measuresPerLoop, - metronome.timeSignature.beatsPerMeasure, - ] + [audioContext, waveformWorker] ) /** @@ -283,29 +270,7 @@ export const Track: React.FC = ({ } }, [audioContext, buildRecorderMessageHandler, stream]) - /** - * Update waveform worker when metronome parameters change, - * so waveforms can be scaled properly - */ - useEffect(() => { - waveformWorker.postMessage({ - message: 'UPDATE_METRONOME', - beatsPerSecond: metronome.bpm / 60, - measuresPerLoop: metronome.measuresPerLoop, - beatsPerMeasure: metronome.timeSignature.beatsPerMeasure, - } as WaveformWorkerMetronomeMessage) - }, [ - metronome.bpm, - metronome.measuresPerLoop, - metronome.timeSignature.beatsPerMeasure, - waveformWorker, - ]) - - const handleChangeTitle: ChangeEventHandler = (event) => { - setTitle(event.target.value) - } - - function handleLoopstart() { + const handleLoopstart = useCallback(() => { if (recording) { setRecording(false) recorderWorklet.current?.port?.postMessage({ @@ -333,7 +298,6 @@ export const Track: React.FC = ({ // this is almost certainly imperfect, but at least it will **appear** to be accurate. // AudioSourceNodes, including AudioBufferSourceNodes, can only be started once, therefore // need to stop, create new, and start again - // TODO: allow clearing via re-recording. Maybe set up a second buffer? if (bufferSource.current?.buffer) { bufferSource.current = new AudioBufferSourceNode(audioContext, { buffer: bufferSource.current.buffer, @@ -341,28 +305,37 @@ export const Track: React.FC = ({ bufferSource.current.connect(gainNode.current) // ramp up to desired gain quickly to avoid clips at the beginning of the loop gainNode.current.gain.value = 0.0 + if (!muted) { gainNode.current.gain.setTargetAtTime( gain, audioContext.currentTime, 0.02 ) + } bufferSource.current.start() } - } + }, [armed, audioContext, gain, muted, recording, waveformWorker]) - function delegateClockMessage(event: MessageEvent) { - if (event.data.loopStart) { - handleLoopstart() + useEffect(() => { + function delegateClockMessage(event: MessageEvent) { + if (event.data.loopStart) { + handleLoopstart() + } else { + // keep waveform worker updated to metronome settings + waveformWorker.postMessage({ + message: 'UPDATE_METRONOME', + beatsPerSecond: event.data.bpm / 60, + measuresPerLoop: event.data.measuresPerLoop, + beatsPerMeasure: event.data.beatsPerMeasure, + } as WaveformWorkerMetronomeMessage) + } } - } - useEffect(() => { - metronome.clock.addEventListener('message', delegateClockMessage) + clock.addEventListener('message', delegateClockMessage) return () => { - metronome.clock.removeEventListener('message', delegateClockMessage) + clock.removeEventListener('message', delegateClockMessage) } - // TODO: include dependency array so these event listeners aren't added/removed on every render - }) + }, [handleLoopstart, clock, waveformWorker]) /** * Attach keyboard listeners. diff --git a/src/icons/PlayPause.tsx b/src/icons/PlayPause.tsx index 1a67300..daf12dd 100644 --- a/src/icons/PlayPause.tsx +++ b/src/icons/PlayPause.tsx @@ -7,7 +7,7 @@ type Props = { // from https://iconmonstr.com/media-control-48-svg/ // and https://iconmonstr.com/media-control-49-svg/ -export default function PlayPause(props: Props) { +export function PlayPause(props: Props) { return ( diff --git a/src/Track/device-id-from-stream.ts b/src/util/device-id-from-stream.ts similarity index 100% rename from src/Track/device-id-from-stream.ts rename to src/util/device-id-from-stream.ts diff --git a/src/Track/get-latency-samples.ts b/src/util/get-latency-samples.ts similarity index 98% rename from src/Track/get-latency-samples.ts rename to src/util/get-latency-samples.ts index 3b9a1dc..e40a9cf 100644 --- a/src/Track/get-latency-samples.ts +++ b/src/util/get-latency-samples.ts @@ -1,4 +1,4 @@ -import { logger } from '../util/logger' +import { logger } from './logger' /** * diff --git a/src/worklets/clock.ts b/src/workers/clock.ts similarity index 90% rename from src/worklets/clock.ts rename to src/workers/clock.ts index 911c886..65592f8 100644 --- a/src/worklets/clock.ts +++ b/src/workers/clock.ts @@ -5,7 +5,7 @@ * https://glitch.com/edit/#!/metronomes?path=worker.js%3A1%3A0 * Why setInterval? * I found that using setInterval in the client-side app was creating really bad latency - * between the recording and the metronome. I decided to migrate to a worklet to reduce + * between the recording and the metronome. I decided to migrate to a worker to reduce * the chance timing issues due to blocking code on the main thread (e.g. from React). * However, this still may not be the endgame. * This fantastic blog post[1] and the accompanying example code[2] demonstrate that using @@ -20,21 +20,21 @@ * This is a learning process for me and this may change in the future. */ -type ClockWorkerStartMessage = { +export type ClockWorkerStartMessage = { message: 'START' bpm: number beatsPerMeasure: number measuresPerLoop: number } -type ClockWorkerUpdateMessage = { +export type ClockWorkerUpdateMessage = { message: 'UPDATE' bpm: number beatsPerMeasure: number measuresPerLoop: number } -type ClockWorkerStopMessage = { +export type ClockWorkerStopMessage = { message: 'STOP' } @@ -50,6 +50,9 @@ export type ClockControllerMessage = { downbeat: boolean // true on the first beat of each loop loopStart: boolean + bpm: number + measuresPerLoop: number + beatsPerMeasure: number } // must add `webWorker` to `compilerOptions.lib` prop of tsconfig.json @@ -73,6 +76,9 @@ self.onmessage = (e: MessageEvent) => { currentTick, downbeat: currentTick % beatsPerMeasure === 0, loopStart: currentTick === 0, + bpm, + beatsPerMeasure, + measuresPerLoop, }) timeoutId = setInterval(() => { currentTick = (currentTick + 1) % (beatsPerMeasure * measuresPerLoop) @@ -81,7 +87,10 @@ self.onmessage = (e: MessageEvent) => { currentTick, downbeat: currentTick % beatsPerMeasure === 0, loopStart: currentTick === 0, - }) + bpm, + beatsPerMeasure, + measuresPerLoop, + } as ClockControllerMessage) }, (60 / bpm) * 1000) } diff --git a/src/worklets/export.ts b/src/workers/export.ts similarity index 100% rename from src/worklets/export.ts rename to src/workers/export.ts diff --git a/src/worklets/recorder.js b/src/workers/recorder.js similarity index 100% rename from src/worklets/recorder.js rename to src/workers/recorder.js diff --git a/src/worklets/waveform.spec.ts b/src/workers/waveform.spec.ts similarity index 100% rename from src/worklets/waveform.spec.ts rename to src/workers/waveform.spec.ts diff --git a/src/worklets/waveform.ts b/src/workers/waveform.ts similarity index 94% rename from src/worklets/waveform.ts rename to src/workers/waveform.ts index 5d2c459..3058cf3 100644 --- a/src/worklets/waveform.ts +++ b/src/workers/waveform.ts @@ -136,7 +136,11 @@ export function constructPath({ // construct the SVG path command const first = positivePoints[0] - const firstPoint = `M ${round2(first[0])} ${round2(first[1])}` + const firstPoint = [ + 'M', + handleNaN(round2(first[0])), + handleNaN(round2(first[1])), + ].join(' ') const positivePath = smoothCubicBezierPoints( positivePoints.slice(1), xMax / framesPerLoop / 2 @@ -185,9 +189,9 @@ function smoothCubicBezierPoints( ): string { return points .map((pt) => { - const y = round2(pt[1]) - const x = round2(pt[0]) - const xOffset = round2(pt[0] - xControlPointOffset) + const y = handleNaN(round2(pt[1])) + const x = handleNaN(round2(pt[0])) + const xOffset = handleNaN(round2(pt[0] - xControlPointOffset)) return `S ${xOffset},${y} ${x},${y}` }) .join(' ') @@ -199,3 +203,7 @@ function roundN(decimalCount: number): (decimal: number) => number { Math.pow(10, decimalCount) } const round2 = roundN(2) + +function handleNaN(value: number) { + return Number.isNaN(value) ? 0.0 : value +}