diff --git a/src/hooks/use-audio-buffer.ts b/src/Metronome/audio-buffer.ts similarity index 56% rename from src/hooks/use-audio-buffer.ts rename to src/Metronome/audio-buffer.ts index bcb15ec..a091491 100644 --- a/src/hooks/use-audio-buffer.ts +++ b/src/Metronome/audio-buffer.ts @@ -1,5 +1,3 @@ -import { useMemo } from 'react' - /** * This function builds and returns a Float32Array. * The data of the array is a quickly decaying sine wave. @@ -28,19 +26,27 @@ function decayingSine(sampleRate: number, frequency: number) { return channel } -export function useSineAudioBuffer( - 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]) +type ClickTrackConfig = { + loopLengthSeconds: number + sampleRate: number + bpm: number + beatsPerMeasure: number + measuresPerLoop: number } + +export function generateClickTrack(config: ClickTrackConfig) { + const buffer = new AudioBuffer({ + length: config.loopLengthSeconds * config.sampleRate, + numberOfChannels: 1, + sampleRate: config.sampleRate + }) + + // for each beat in the loop, copy a decaying sine to the correct beat position + for (let i = 0; i < config.beatsPerMeasure * config.measuresPerLoop; i++) { + const offset = Math.ceil(i * config.sampleRate * (60 / config.bpm)) + const frequency = i % config.beatsPerMeasure === 0 ? 380 : 330 + buffer.copyToChannel(decayingSine(config.sampleRate, frequency), 0, offset) + } + + return buffer +} \ No newline at end of file diff --git a/src/Metronome/index.tsx b/src/Metronome/index.tsx index 344910c..187df6d 100644 --- a/src/Metronome/index.tsx +++ b/src/Metronome/index.tsx @@ -4,7 +4,7 @@ * It also controls whether or not the click track makes noise, * and the global "playing" state of the app. */ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAudioContext } from '../AudioProvider' import { BeatCounter } from './controls/BeatCounter' import { MeasuresPerLoopControl } from './controls/MeasuresPerLoopControl' @@ -17,10 +17,11 @@ import type { ClockWorkerStopMessage, ClockWorkerUpdateMessage, } from '../workers/clock' -import { useSineAudioBuffer } from '../hooks/use-audio-buffer' +import { generateClickTrack } from './audio-buffer' import { PlayPause } from '../icons/PlayPause' import { useKeybindings } from '../hooks/use-keybindings' import { Scene } from '../Scene' +import { logger } from '../util/logger' export type TimeSignature = { beatsPerMeasure: number @@ -58,13 +59,6 @@ export const Metronome: React.FC = ({ clock }) => { const loopLengthSeconds = (60 / bpm) * measuresPerLoop * timeSignature.beatsPerMeasure - /** - * create 2 AudioBuffers with different frequencies, - * to be used for the metronome beep. - */ - const sine330 = useSineAudioBuffer(audioContext, 330) - const sine380 = useSineAudioBuffer(audioContext, 380) - /** * Set up metronome gain node. * See Track/index.tsx for description of the useRef/useEffect pattern @@ -88,6 +82,37 @@ export const Metronome: React.FC = ({ clock }) => { // This isn't strictly necessary afaik, but I think it will help with garbage cleanup const source = useRef(null) + /** + * generate click track buffer for duration of loop + */ + const clickTrackBuffer = useMemo(() => { + // this is a little janky, but the idea is that whenever we generate a new click track buffer, + // the old AudioBufferSourceNode might still be playing. We want to stop it if it is. + try { + source.current?.stop() + } catch (e) { + logger.error('tried to stop click track node but failed', e) + } + try { + source.current?.disconnect() + } catch (e) { + logger.error('tried to disconnect click track node but failed', e) + } + return generateClickTrack({ + loopLengthSeconds, + sampleRate: audioContext.sampleRate, + bpm, + measuresPerLoop, + beatsPerMeasure: timeSignature.beatsPerMeasure, + }) + }, [ + bpm, + loopLengthSeconds, + audioContext.sampleRate, + timeSignature.beatsPerMeasure, + measuresPerLoop, + ]) + /** * Add clock event listeners. * On each tick, set the "currentTick" value and emit a beep. @@ -98,13 +123,17 @@ export const Metronome: React.FC = ({ clock }) => { const clockMessageHandler = ( event: MessageEvent ) => { - // console.log(event.data) // this is really noisy - if (event.data.message === 'TICK' && gainNode.current) { - if (source.current) { - source.current.disconnect() - } + // DAMN! This doesn't work with pausing and restarting the metronome... DAMNNNNNNN!!!! + if ( + event.data.message === 'TICK' && + gainNode.current && + event.data.loopStart + ) { + logger.debug(event.data) + + // play click track buffer on loop start source.current = new AudioBufferSourceNode(audioContext, { - buffer: event.data.downbeat ? sine380 : sine330, + buffer: clickTrackBuffer, }) source.current.connect(gainNode.current) source.current.start() @@ -115,7 +144,7 @@ export const Metronome: React.FC = ({ clock }) => { return () => { clock.removeEventListener('message', clockMessageHandler) } - }, [audioContext, sine330, sine380, clock]) + }, [audioContext, clock, clickTrackBuffer]) /** * When "playing" is toggled on/off, @@ -150,6 +179,7 @@ export const Metronome: React.FC = ({ clock }) => { measuresPerLoop, bpm, clock, + loopLengthSeconds, ]) /** @@ -163,7 +193,13 @@ export const Metronome: React.FC = ({ clock }) => { measuresPerLoop, loopLengthSeconds, } as ClockWorkerUpdateMessage) - }, [bpm, timeSignature.beatsPerMeasure, measuresPerLoop, clock]) + }, [ + bpm, + timeSignature.beatsPerMeasure, + measuresPerLoop, + clock, + loopLengthSeconds, + ]) useKeybindings({ c: { callback: toggleMuted }, diff --git a/src/workers/clock.ts b/src/workers/clock.ts index 8703aff..2acaac1 100644 --- a/src/workers/clock.ts +++ b/src/workers/clock.ts @@ -99,6 +99,7 @@ self.onmessage = (e: MessageEvent) => { } else if (e.data.message === 'UPDATE') { // only start if it was already running if (timeoutId) { + currentTick = -1 clearInterval(timeoutId) start(e.data.bpm, e.data.beatsPerMeasure, e.data.measuresPerLoop, e.data.loopLengthSeconds) }