diff --git a/public/index.html b/public/index.html index 9360049..15e9798 100644 --- a/public/index.html +++ b/public/index.html @@ -61,28 +61,35 @@

Browser-based live looper for music performance and audio fun -
+
GitHub Blog Issues + How to do?
diff --git a/roadmap.md b/roadmap.md index 5f70c22..7ed5add 100644 --- a/roadmap.md +++ b/roadmap.md @@ -65,11 +65,11 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T - [x] Ensure the audio buffer is always exactly as long as it needs to be to fill the loop https://github.com/ericyd/loop-supreme/pull/23 - [x] clean up functionality from recorder worklet that isn't being used (might want to hold off until I know how visualization will work) https://github.com/ericyd/loop-supreme/pull/20 -## Saving audio +## ✅ Saving audio - [x] Stems can be exported https://github.com/ericyd/loop-supreme/pull/26 -- [ ] Bounced master can be exported -- [ ] Live performance can be saved and exported +- [x] ~Bounced master can be exported~ not entirely sure what my goal was here... +- [x] Live performance can be saved and exported https://github.com/ericyd/loop-supreme/pull/37 ## ✅ Keyboard diff --git a/src/Metronome/controls/TimeSignatureControl.tsx b/src/Metronome/controls/TimeSignatureControl.tsx index c2f3407..95d0bd3 100644 --- a/src/Metronome/controls/TimeSignatureControl.tsx +++ b/src/Metronome/controls/TimeSignatureControl.tsx @@ -27,6 +27,8 @@ export function TimeSignatureControl(props: TimeSignatureProps) { }) } + const options = ['4/4', '3/4', '6/8', '7/8', '9/8'] + return ( ) diff --git a/src/Scene/index.tsx b/src/Scene/index.tsx index 6cdf200..5c6c9bd 100644 --- a/src/Scene/index.tsx +++ b/src/Scene/index.tsx @@ -1,8 +1,12 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAudioContext } from '../AudioProvider' import ButtonBase from '../ButtonBase' import { useKeybindings } from '../hooks/use-keybindings' import { Plus } from '../icons/Plus' -import { Track } from '../Track' +import { Record } from '../icons/Record' +import { RecordingMessage, Track } from '../Track' +import { logger } from '../util/logger' +import { ExportWavWorkerEvent, WavBlobControllerEvent } from '../workers/export' type Props = { clock: Worker @@ -11,6 +15,107 @@ type Props = { export const Scene: React.FC = ({ clock }) => { const [tracks, setTracks] = useState([{ id: 1, selected: false }]) const exportTarget = useMemo(() => new EventTarget(), []) + const downloadLinkRef = useRef(null) + const exportWorker = useMemo( + () => new Worker(new URL('../workers/export', import.meta.url)), + [] + ) + + const { audioContext } = useAudioContext() + /** + * Create a recorder worklet to record a session. + * This worklet is passed to every track, and connected to the track's main GainNode on mount. + * This way, the output from the GainNode is sent to the recorder worklet, and it all gets mixed into a single buffer. + */ + const sessionWorklet = useMemo(() => { + // for the bounced recording, we can assume we'll always use 2 channels + const numberOfChannels = 2 + + const worklet = new AudioWorkletNode(audioContext, 'recorder', { + processorOptions: { + numberOfChannels: 2, + sampleRate: audioContext.sampleRate, + // 500 seconds... ? ¯\_(ツ)_/¯ + maxRecordingSamples: audioContext.sampleRate * 500, + latencySamples: 0, + }, + }) + + worklet.port.onmessage = (event: MessageEvent) => { + if (event.data.message === 'MAX_RECORDING_LENGTH_REACHED') { + // Not exactly sure what should happen in this case ¯\_(ツ)_/¯ + alert( + "You recorded more than 500 seconds. That isn't allowed. Not sure why though, maybe it can record indefinitely?" + ) + logger.error(event.data) + } + + // See Track/index.tsx for detailed notes on the functionality here + if (event.data.message === 'SHARE_RECORDING_BUFFER') { + // this data is passed to the recorder worklet in `toggleRecording` function + const recordingDurationSeconds = + event.data.forwardData.recordingDurationSeconds + const recordingDurationSamples = Math.ceil( + audioContext.sampleRate * recordingDurationSeconds + ) + console.log({ + recordingDurationSamples, + recordingDurationSeconds, + 'event.data.channelsData[0].length': + event.data.channelsData[0].length, + }) + + logger.debug(`Posting export message for scene performance`) + exportWorker.postMessage({ + message: 'EXPORT_TO_WAV', + audioBufferLength: recordingDurationSamples, + numberOfChannels: numberOfChannels, + sampleRate: audioContext.sampleRate, + channelsData: event.data.channelsData.map((data) => + data.slice(0, recordingDurationSamples) + ), + } as ExportWavWorkerEvent) + } + } + + return worklet + }, [audioContext, exportWorker]) + + /** + * Register callback to download the wav file when it is returned from the exporter worker + */ + useEffect(() => { + function handleWavBlob(event: MessageEvent) { + logger.debug(`Handling WAV message for scene performance`) + if (event.data.message === 'WAV_BLOB' && downloadLinkRef.current) { + const url = window.URL.createObjectURL(event.data.blob) + downloadLinkRef.current.href = url + downloadLinkRef.current.download = `performance-${timestamp()}.wav` + downloadLinkRef.current.click() + window.URL.revokeObjectURL(url) + } + } + exportWorker.addEventListener('message', handleWavBlob) + return () => { + exportWorker.removeEventListener('message', handleWavBlob) + } + }, [exportWorker]) + + /** + * Allow the session recording to be toggled on and off. + * The recording duration gets passed back to the app; + * this is just a minor convenience to avoid storing the recording duration as yet another piece of state + */ + const [recording, setRecording] = useState(false) + const [recordingStart, setRecordingStart] = useState(0) + const toggleRecording = useCallback(() => { + setRecording((recording) => !recording) + sessionWorklet.port.postMessage({ + message: 'TOGGLE_RECORDING_STATE', + recordingDurationSeconds: (Date.now() - recordingStart) / 1000, + }) + setRecordingStart(Date.now()) + }, [sessionWorklet, recordingStart]) function handleAddTrack() { setTracks((tracks) => [ @@ -92,12 +197,16 @@ export const Scene: React.FC = ({ clock }) => { onRemove={handleRemoveTrack(id)} clock={clock} exportTarget={exportTarget} + sessionWorklet={sessionWorklet} /> ))}
+ + +
+ {/* Download element - inspired by this SO answer https://stackoverflow.com/a/19328891/3991555 */} + + Download + ) } + +// returns a timestamp that is safe for any OS filename +function timestamp() { + return new Date() + .toISOString() + .replace(/\.\d{0,5}Z$/, '') + .replace(/:/g, '-') +} diff --git a/src/Start/index.tsx b/src/Start/index.tsx index 7bf71bf..860b107 100644 --- a/src/Start/index.tsx +++ b/src/Start/index.tsx @@ -123,7 +123,7 @@ export const Start: React.FC = () => { function LatencyNotSupportedAlert() { return ( -
+

Heads up!

Your browser does not appear to report recording latency 😢.

@@ -136,7 +136,7 @@ function LatencyNotSupportedAlert() { href="https://ericyd.hashnode.dev/loop-supreme-part-7-latency-and-adding-track-functionality" target="_blank" rel="noreferrer" - className="underline text-cyan-600" + className="underline text-link" > (why tho?) diff --git a/src/Track/Waveform.tsx b/src/Track/Waveform.tsx index 29b2402..5aeb4ae 100644 --- a/src/Track/Waveform.tsx +++ b/src/Track/Waveform.tsx @@ -46,7 +46,7 @@ export function Waveform({ worker, sampleRate }: Props) { className="h-full w-full" > diff --git a/src/Track/controls/ArmTrackRecording.tsx b/src/Track/controls/ArmTrackRecording.tsx index 5bb7843..fffe33e 100644 --- a/src/Track/controls/ArmTrackRecording.tsx +++ b/src/Track/controls/ArmTrackRecording.tsx @@ -1,4 +1,5 @@ import ButtonBase from '../../ButtonBase' +import { Record } from '../../icons/Record' type Props = { toggleArmRecording(): void @@ -9,30 +10,7 @@ type Props = { export function ArmTrackRecording(props: Props) { return ( - - Arm for recording - - - + ) } diff --git a/src/Track/index.tsx b/src/Track/index.tsx index 15ed124..5cafb1a 100644 --- a/src/Track/index.tsx +++ b/src/Track/index.tsx @@ -43,6 +43,7 @@ type Props = { selected: boolean exportTarget: EventTarget index: number + sessionWorklet: AudioWorkletNode } type RecordingProperties = { @@ -64,6 +65,8 @@ type ShareRecordingBufferMessage = { message: 'SHARE_RECORDING_BUFFER' channelsData: Array recordingLength: number + // this allows us to send data through the recorder in messages. Saves an extra ref or piece of state + forwardData: Record } type UpdateWaveformMessage = { @@ -72,7 +75,7 @@ type UpdateWaveformMessage = { samplesPerFrame: number } -type RecordingMessage = +export type RecordingMessage = | MaxRecordingLengthReachedMessage | ShareRecordingBufferMessage | UpdateWaveformMessage @@ -84,6 +87,7 @@ export const Track: React.FC = ({ selected, exportTarget, index, + sessionWorklet, }) => { const { audioContext } = useAudioContext() // stream is initialized in SelectInput @@ -151,6 +155,22 @@ export const Track: React.FC = ({ ) }, [monitoring, audioContext]) + /** + * Connect the monitor and gain nodes to the sessionWorklet. + * This creates a continuous recording stream into sessionWorklet, + * so that the live performance can be captured. + */ + useEffect(() => { + const gain = gainNode.current + const monitor = monitorNode.current + gain.connect(sessionWorklet) + monitor.connect(sessionWorklet) + return () => { + gain.disconnect(sessionWorklet) + monitor.connect(sessionWorklet) + } + }, [sessionWorklet]) + /** * Both of these are instantiated on mount */ @@ -282,9 +302,10 @@ export const Track: React.FC = ({ if (recording) { setRecording(false) recorderWorklet.current?.port?.postMessage({ - message: 'UPDATE_RECORDING_STATE', - recording: false, + message: 'TOGGLE_RECORDING_STATE', }) + // for now, assume that track monitoring should end when the loop ends + setMonitoring(false) return } if (armed) { @@ -292,9 +313,7 @@ export const Track: React.FC = ({ setArmed(false) bufferSource.current = null recorderWorklet.current?.port?.postMessage({ - message: 'UPDATE_RECORDING_STATE', - recording: true, - reset: true, + message: 'TOGGLE_RECORDING_STATE', }) waveformWorker.postMessage({ message: 'RESET_FRAMES', @@ -319,7 +338,7 @@ export const Track: React.FC = ({ gainNode.current.gain.setTargetAtTime( gain, audioContext.currentTime, - 0.02 + 0.005 ) } bufferSource.current.start() @@ -456,7 +475,7 @@ export const Track: React.FC = ({ {/* Download element - inspired by this SO answer https://stackoverflow.com/a/19328891/3991555 */} Download diff --git a/src/icons/Record.tsx b/src/icons/Record.tsx new file mode 100644 index 0000000..010a4b8 --- /dev/null +++ b/src/icons/Record.tsx @@ -0,0 +1,32 @@ +type Props = { + armed: boolean + recording: boolean +} +export function Record({ armed, recording }: Props) { + return ( + + Arm for recording + + + + ) +} diff --git a/src/workers/recorder.js b/src/workers/recorder.js index fa278d6..eedf0ad 100644 --- a/src/workers/recorder.js +++ b/src/workers/recorder.js @@ -85,16 +85,8 @@ class RecordingProcessor extends AudioWorkletProcessor { // Consider defining a typedef for MessagePort, to constrain the types of messages it will send/receive // From https://github.com/microsoft/TypeScript-DOM-lib-generator/blob/b929eb7863a3bf73f4a887fb97063276b10b92bc/baselines/audioworklet.generated.d.ts#L463-L482 this.port.onmessage = (event) => { - if (event.data.message === 'UPDATE_RECORDING_STATE') { - this.recording = event.data.recording - if (event.data.reset) { - this.channelsData = new Array(this.numberOfChannels).fill( - new Float32Array(this.maxRecordingSamples) - ) - this.recordedSamples = 0 - this.samplesSinceLastPublish = 0 - this.gainSum = 0 - } + if (event.data.message === 'TOGGLE_RECORDING_STATE') { + this.recording = !this.recording // When the recording ends, send the buffer back to the Track if (this.recording === false) { @@ -102,8 +94,17 @@ class RecordingProcessor extends AudioWorkletProcessor { message: 'SHARE_RECORDING_BUFFER', channelsData: this.channelsData, recordingLength: this.recordedSamples, + forwardData: event.data, }) } + + // reset values so re-recording works + this.channelsData = new Array(this.numberOfChannels).fill( + new Float32Array(this.maxRecordingSamples) + ) + this.recordedSamples = 0 + this.samplesSinceLastPublish = 0 + this.gainSum = 0 } } } diff --git a/tailwind.config.js b/tailwind.config.js index 769ba2f..0f4f251 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -33,7 +33,11 @@ module.exports = { 'dark-gray': '#4b5563', blue: '#bfdbfe', red: '#f87171', + 'light-red': '#fecaca', orange: '#fb923c', + link: '#0890b2', + yellow: '#fadd8e', + purple: '#411a71', }, extend: { animation: {