Skip to content

Commit

Permalink
Record performance (#37)
Browse files Browse the repository at this point in the history
* add more time signatures

* colors fixup

* move Record icon into new component

* allow passing arbitrary data from message to self

* Add basic session recorder

* fix waveform color

* fix timestamp

* add wiki link to homepage

* turn off monitoring when loop ends
  • Loading branch information
ericyd authored Dec 11, 2022
1 parent 3fb20dd commit 53383f8
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 56 deletions.
15 changes: 11 additions & 4 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,35 @@ <h1>
<span class="font-sans-real"
>Browser-based live looper for music performance and audio fun</span
>
<div className="flex flex-row">
<div className="flex flex-row flex-wrap">
<a
href="https://github.com/ericyd/loop-supreme"
target="_blank"
rel="noreferrer"
class="mr-2 underline text-cyan-600 font-sans-real"
class="mr-2 underline text-link font-sans-real"
>GitHub</a
>
<a
href="https://ericyd.hashnode.dev/loop-supreme-part-11-exporting-stems-and-changing-inputs"
target="_blank"
rel="noreferrer"
class="m-2 underline text-cyan-600 font-sans-real"
class="m-2 underline text-link font-sans-real"
>Blog</a
>
<a
href="https://github.com/ericyd/loop-supreme/issues"
target="_blank"
rel="noreferrer"
class="m-2 underline text-cyan-600 font-sans-real"
class="m-2 underline text-link font-sans-real"
>Issues</a
>
<a
href="https://github.com/ericyd/loop-supreme/wiki"
target="_blank"
rel="noreferrer"
class="m-2 underline text-link font-sans-real"
>How to do?</a
>
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions src/Metronome/controls/TimeSignatureControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ export function TimeSignatureControl(props: TimeSignatureProps) {
})
}

const options = ['4/4', '3/4', '6/8', '7/8', '9/8']

return (
<ControlPanelItem>
<select
onChange={handleChange}
value={`${props.beatsPerMeasure}/${props.beatUnit}`}
className="text-xl border border-solid border-light-gray dark:border-dark-gray bg-white dark:bg-black rounded-full p-2"
>
<option value="4/4">4/4</option>
<option value="7/8">7/8</option>
{options.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</select>
</ControlPanelItem>
)
Expand Down
129 changes: 127 additions & 2 deletions src/Scene/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +15,107 @@ type Props = {
export const Scene: React.FC<Props> = ({ clock }) => {
const [tracks, setTracks] = useState([{ id: 1, selected: false }])
const exportTarget = useMemo(() => new EventTarget(), [])
const downloadLinkRef = useRef<HTMLAnchorElement>(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<AudioWorkletNode>(() => {
// 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<RecordingMessage>) => {
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<WavBlobControllerEvent>) {
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) => [
Expand Down Expand Up @@ -92,19 +197,39 @@ export const Scene: React.FC<Props> = ({ clock }) => {
onRemove={handleRemoveTrack(id)}
clock={clock}
exportTarget={exportTarget}
sessionWorklet={sessionWorklet}
/>
))}
<div className="my-8 flex justify-between items-end">
<ButtonBase onClick={handleAddTrack} large>
<Plus />
</ButtonBase>
<ButtonBase onClick={toggleRecording} large>
<Record armed={false} recording={recording} />
</ButtonBase>
<button
onClick={handleExport}
className="border border-light-gray border-solid rounded-full p-2 mr-2 hover:shadow-button"
>
Download stems
</button>
</div>
{/* Download element - inspired by this SO answer https://stackoverflow.com/a/19328891/3991555 */}
<a
ref={downloadLinkRef}
href="https://loopsupreme.com"
className="hidden"
>
Download
</a>
</>
)
}

// returns a timestamp that is safe for any OS filename
function timestamp() {
return new Date()
.toISOString()
.replace(/\.\d{0,5}Z$/, '')
.replace(/:/g, '-')
}
4 changes: 2 additions & 2 deletions src/Start/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const Start: React.FC = () => {

function LatencyNotSupportedAlert() {
return (
<div className="mb-5 font-bold bg-red dark:text-black p-2 rounded-md">
<div className="mb-5 font-bold bg-light-red dark:text-black p-2 rounded-md">
<p>Heads up!</p>
<p>Your browser does not appear to report recording latency 😢.</p>
<p>
Expand All @@ -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?)
</a>
Expand Down
2 changes: 1 addition & 1 deletion src/Track/Waveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function Waveform({ worker, sampleRate }: Props) {
className="h-full w-full"
>
<path
className="fill-zinc-200 stroke-black"
className="fill-yellow dark:fill-purple stroke-black dark:stroke-white"
strokeWidth={0.01}
d={path}
/>
Expand Down
26 changes: 2 additions & 24 deletions src/Track/controls/ArmTrackRecording.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ButtonBase from '../../ButtonBase'
import { Record } from '../../icons/Record'

type Props = {
toggleArmRecording(): void
Expand All @@ -9,30 +10,7 @@ type Props = {
export function ArmTrackRecording(props: Props) {
return (
<ButtonBase onClick={props.toggleArmRecording}>
<svg
clipRule="evenodd"
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Arm for recording</title>
<circle cx="12" cy="12" fillRule="nonzero" r="10" />
<circle
cx="12"
cy="12"
fillRule="nonzero"
r="10"
className={`fill-red ${
props.armed
? 'animate-pulse-custom'
: props.recording
? 'opacity-100'
: 'opacity-0'
}`}
/>
</svg>
<Record {...props} />
</ButtonBase>
)
}
Loading

0 comments on commit 53383f8

Please sign in to comment.