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
+}