Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions src/hooks/use-audio-buffer.ts → src/Metronome/audio-buffer.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
}
70 changes: 53 additions & 17 deletions src/Metronome/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -58,13 +59,6 @@ export const Metronome: React.FC<Props> = ({ 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
Expand All @@ -88,6 +82,37 @@ export const Metronome: React.FC<Props> = ({ clock }) => {
// This isn't strictly necessary afaik, but I think it will help with garbage cleanup
const source = useRef<AudioBufferSourceNode | null>(null)

/**
* generate click track buffer for duration of loop
*/
const clickTrackBuffer = useMemo<AudioBuffer>(() => {
// 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.
Expand All @@ -98,13 +123,17 @@ export const Metronome: React.FC<Props> = ({ clock }) => {
const clockMessageHandler = (
event: MessageEvent<ClockControllerMessage>
) => {
// 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()
Expand All @@ -115,7 +144,7 @@ export const Metronome: React.FC<Props> = ({ clock }) => {
return () => {
clock.removeEventListener('message', clockMessageHandler)
}
}, [audioContext, sine330, sine380, clock])
}, [audioContext, clock, clickTrackBuffer])

/**
* When "playing" is toggled on/off,
Expand Down Expand Up @@ -150,6 +179,7 @@ export const Metronome: React.FC<Props> = ({ clock }) => {
measuresPerLoop,
bpm,
clock,
loopLengthSeconds,
])

/**
Expand All @@ -163,7 +193,13 @@ export const Metronome: React.FC<Props> = ({ clock }) => {
measuresPerLoop,
loopLengthSeconds,
} as ClockWorkerUpdateMessage)
}, [bpm, timeSignature.beatsPerMeasure, measuresPerLoop, clock])
}, [
bpm,
timeSignature.beatsPerMeasure,
measuresPerLoop,
clock,
loopLengthSeconds,
])

useKeybindings({
c: { callback: toggleMuted },
Expand Down
1 change: 1 addition & 0 deletions src/workers/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ self.onmessage = (e: MessageEvent<ClockWorkerMessage>) => {
} 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)
}
Expand Down