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 😢.