Skip to content

Commit

Permalink
Sharable URLs, Download MIDI, and Preview Modal
Browse files Browse the repository at this point in the history
* Small bugfixes to the Player. Restarts song when at the end and hitting play.
* Removes support for downloading mp3-style audio. MIDI is enough for this PR,
  future one can use lamemp3 etc to convert the midi to mp3.
  • Loading branch information
samouri committed Jan 15, 2024
1 parent 55e9fff commit fad2caf
Show file tree
Hide file tree
Showing 15 changed files with 301 additions and 9,847 deletions.
9,751 changes: 0 additions & 9,751 deletions package-lock.json

This file was deleted.

51 changes: 25 additions & 26 deletions src/features/data/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { parseMidi } from '@/features/parsers'
import { Song, SongMetadata, SongSource } from '@/types'
import { getUploadedSong } from '@/features/persist'
import * as library from './library'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { useFetch } from '@/hooks'
import { FetchState } from '@/hooks/useFetch'
import { convertBase64MidiToSong } from '@/features/midi/utils'
import { FetchState, useRemoteResource } from '@/hooks/useFetch'
import { batchedFetch } from '@/utils'

function handleSong(response: Response): Promise<Song> {
return response.arrayBuffer().then(parseMidi)
Expand All @@ -19,30 +19,29 @@ function getSongUrl(id: string, source: SongSource) {
return `/api/midi?id=${id}&source=${source}`
}

function getBase64Song(data: string): Song {
const binaryMidi = Buffer.from(decodeURIComponent(data), 'base64')
return parseMidi(binaryMidi.buffer)
}

function fetchSong(id: string, source: SongSource): Promise<Song> {
if (source === 'midishare' || source === 'builtin') {
const url = getSongUrl(id, source)
return batchedFetch(url).then(handleSong)
} else if (source === 'base64') {
return Promise.resolve(getBase64Song(id))
} else if (source === 'upload') {
return Promise.resolve(getUploadedSong(id)).then((res) =>
res === null ? Promise.reject(new Error('Could not find song')) : res,
)
}

return Promise.reject(new Error(`Could not get song for ${id}, ${source}`))
}

export function useSong(id: string, source: SongSource): FetchState<Song> {
const url =
id && source && (source === 'midishare' || source === 'builtin')
? getSongUrl(id, source)
: undefined
const fetchState = useFetch(url, handleSong)
const uploadState: FetchState<Song> = useMemo(() => {
if (source !== 'upload') {
return { status: 'idle' }
}

const data = getUploadedSong(id)
if (data) {
return { status: 'success', data }
} else {
return { status: 'error', error: new Error(`Could not find uploaded song: ${id}`) }
}
}, [id, source])

return source === 'upload' ? uploadState : fetchState
}

export function useBase64Song(data: string): Song {
return convertBase64MidiToSong(data)
const getResource = useCallback(() => fetchSong(id, source), [id, source])
return useRemoteResource(getResource)
}

// TODO: replace with a signals-like library, so that setting from one component is reflected elsewhere.
Expand Down
83 changes: 79 additions & 4 deletions src/features/midi/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getNote } from '@/features/theory'
import { Midi } from '@tonejs/midi'
import { MidiStateEvent } from '@/types'
import { isBrowser } from '@/utils'
import { getRecordedMidiEvents } from '@/features/SongRecording'
import { useRef, useState } from 'react'
import { parseMidi } from '../parsers'

export async function getMidiInputs(): Promise<WebMidi.MIDIInputMap> {
if (!isBrowser() || !window.navigator.requestMIDIAccess) {
Expand Down Expand Up @@ -63,7 +65,7 @@ function parseMidiMessage(event: WebMidi.MIDIMessageEvent): MidiEvent | null {
type: command === 0x9 ? 'on' : 'off',
note: data[1],
velocity: data[2],
timeStamp: event.timeStamp
timeStamp: event.timeStamp,
}
}

Expand Down Expand Up @@ -161,8 +163,6 @@ function onMidiMessage(e: WebMidi.MIDIMessageEvent) {
return
}

getRecordedMidiEvents()?.push(msg)

const { note, velocity } = msg
if (msg.type === 'on' && msg.velocity > 0) {
midiState.press(note, velocity)
Expand All @@ -171,4 +171,79 @@ function onMidiMessage(e: WebMidi.MIDIMessageEvent) {
}
}

// This function doesn't yet handle notes left open when record was clicked. It
// should close those notes.
function midiEventsToMidi(events: MidiEvent[]) {
const midi = new Midi()
const track = midi.addTrack()
const openNotes = new Map<number, MidiEvent>()
for (const event of events) {
if (event.type === 'on') {
openNotes.set(event.note, event)
} else {
const start = openNotes.get(event.note)
if (!start) {
continue
}
openNotes.delete(event.note)
const end = event
track.addNote({
midi: start.note,
time: start.timeStamp / 1000,
duration: (end.timeStamp - start.timeStamp) / 1000,
velocity: start.velocity,
noteOffVelocity: end.velocity,
})
}
}

return midi.toArray()
}

export function record(midiState: MidiState) {
const recording: MidiEvent[] = []
// Offset times so first note in the recording occurs at ts=0
let initialTime: number | null = null
function listener(midiStateEvent: MidiStateEvent) {
if (initialTime === null) {
initialTime = midiStateEvent.time
}
const midiEvent: MidiEvent = {
type: midiStateEvent.type === 'down' ? 'on' : 'off',
velocity: midiStateEvent.velocity ?? 127,
note: midiStateEvent.note,
timeStamp: midiStateEvent.time - initialTime,
}
recording.push(midiEvent)
}
midiState.subscribe(listener)
return () => {
midiState.unsubscribe(listener)
if (recording.length > 0) {
return midiEventsToMidi(recording)
}
return null
}
}

export function useRecordMidi(state = midiState) {
const [isRecording, setIsRecording] = useState(false)
const recordCb = useRef<(() => Uint8Array) | undefined>(undefined)
function startRecording() {
setIsRecording(true)
if (recordCb.current) {
recordCb.current?.()
}
recordCb.current = record(state)
}
function stopRecording() {
setIsRecording(false)
const midiBytes = recordCb.current?.() ?? new Uint8Array()
recordCb.current = undefined
return midiBytes
}

return { startRecording, stopRecording, isRecording }
}

export default midiState
115 changes: 115 additions & 0 deletions src/features/pages/Freeplay/components/RecordingModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as React from 'react'
import { SongScrubBar } from '@/features/controls'
import { useRouter } from 'next/router'
import { useEventListener, usePlayerState } from '@/hooks'
import { Modal, Sizer } from '@/components'
import { SongSource } from '@/types'
import { SongPreview } from '@/features/SongPreview/SongPreview'
import PreviewIcon from '@/features/SongPreview/PreviewIcon'
import { Share, Download } from '@/icons'

// A function to copy a string to the clipboard
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text)
}

function downloadBase64Midi(midiBase64: string) {
const midiBytes = Buffer.from(midiBase64, 'base64')
const midiBlob = new Blob([midiBytes], { type: 'audio/midi' })
const downloadLink = document.createElement('a')
downloadLink.href = URL.createObjectURL(midiBlob)
downloadLink.download = 'recording.mid'
downloadLink.click()
}

type ModalProps = {
show: boolean
onClose: () => void
songMeta?: { source: SongSource; id: string }
}
export default function SongPreviewModal({
show = true,
onClose = () => {},
songMeta = undefined,
}: ModalProps) {
const { id, source } = songMeta ?? {}
const router = useRouter()
const [playerState, playerActions] = usePlayerState()

useEventListener<KeyboardEvent>('keydown', (event) => {
if (!show) return

if (event.key === ' ') {
event.preventDefault()
playerActions.toggle()
}
})

function handleClose() {
playerActions.stop()
return onClose()
}

if (!show || !id || !source) {
return null
}

return (
<Modal show={show && !!id} onClose={handleClose} className="min-w-[min(100%,600px)]">
<div className="flex flex-col gap-3 p-8">
<div className="flex flex-col w-full whitespace-nowrap">
<span className="font-semibold text-2xl">Preview your recording</span>
{/* <span className="overflow-hidden text-base text-gray-500"></span> */}
</div>
<div className="flex rounded-md flex-col flex-grow overflow-hidden">
<div className="relative">
<div className="absolute w-full h-full z-20 pointer-events-none rounded-md" />
<SongScrubBar height={30} />
</div>
<div
style={{
position: 'relative',
backgroundColor: '#2e2e2e',
height: 340, // TODO, do this less hacky
minHeight: 340, // without height and min-height set, causes canvas re-paint on adjust instruments open
width: '100%',
overflow: 'hidden',
}}
onClick={playerActions.toggle}
>
<PreviewIcon
isLoading={!playerState.canPlay}
isPlaying={playerState.playing}
onPlay={(e) => {
e.stopPropagation()
playerActions.play()
}}
/>
{id && source && <SongPreview songId={id} source={source} />}
</div>
<Sizer height={16} />
<div className="flex w-full gap-4">
<button
className="w-full text-black h-10 cursor-pointer rounded-md text-xl transition border-purple-primary border bg-white hover:bg-purple-primary hover:text-white flex items-center gap-2 px-1 justify-center"
onClick={() => {
const origin = window.location.origin
const url = `${origin}/play/?source=base64&id=${id}`
copyToClipboard(url)
}}
>
<Share />
Copy URL
</button>
<button
className="w-full text-white h-10 border-none cursor-pointer rounded-md text-xl transition bg-purple-primary hover:bg-purple-hover flex items-center gap-2 justify-center px-1"
onClick={() => downloadBase64Midi(id)}
>
<Download />
Download MIDI
</button>
</div>
</div>
</div>
</Modal>
)
}
16 changes: 12 additions & 4 deletions src/features/pages/Freeplay/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ type TopBarProps = {
onClickRecord: (e: MouseEvent<any>) => void
}

export default function TopBar({ isError, isLoading, isRecordingAudio, value, onChange, onClickMidi, onClickRecord }: TopBarProps) {
const recordTooltip = isRecordingAudio? "Save record" : "Start recording audio";

export default function TopBar({
isError,
isLoading,
isRecordingAudio,
value,
onChange,
onClickMidi,
onClickRecord,
}: TopBarProps) {
const recordTooltip = isRecordingAudio ? 'Save record' : 'Start recording audio'

return (
<div className="px-4 text-white transition text-2xl h-[50px] min-h-[50px] w-full bg-[#292929] flex items-center gap-4">
<ButtonWithTooltip tooltip="Back">
Expand All @@ -27,7 +35,7 @@ export default function TopBar({ isError, isLoading, isRecordingAudio, value, on
</Link>
</ButtonWithTooltip>
<ButtonWithTooltip tooltip={recordTooltip} className="ml-auto" onClick={onClickRecord}>
{isRecordingAudio? <StopRecord size={24} /> : <StartRecord size={24} />}
{isRecordingAudio ? <StopRecord size={24} /> : <StartRecord size={24} />}
</ButtonWithTooltip>
<ButtonWithTooltip tooltip="Choose a MIDI device" onClick={onClickMidi}>
<Midi size={24} />
Expand Down
30 changes: 18 additions & 12 deletions src/features/pages/Freeplay/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { MidiStateEvent, SongConfig } from '@/types'
import { SongVisualizer } from '@/features/SongVisualization'
import { InstrumentName, useSynth } from '@/features/synth'
import { startRecordingAudio, startRecordingMidi, stopRecordingAudio, stopRecordingMidi } from '@/features/SongRecording'
import midiState from '@/features/midi'
import midiState, { useRecordMidi } from '@/features/midi'
import { useSingleton } from '@/hooks'
import FreePlayer from './utils/freePlayer'
import TopBar from './components/TopBar'
import RecordingModal from './components/RecordingModal'
import Head from 'next/head'
import { MidiModal } from '../PlaySong/components/MidiModal'

Expand All @@ -15,7 +15,8 @@ export default function FreePlay() {
const synthState = useSynth(instrumentName)
const freePlayer = useSingleton(() => new FreePlayer())
const [isMidiModalOpen, setMidiModal] = useState(false)
const [isRecordingAudio, setRecordingAudio] = useState(false)
const { isRecording, startRecording, stopRecording } = useRecordMidi(midiState)
const [recordingPreview, setRecordingPreview] = useState('')

const handleNoteDown = useCallback(
(note: number, velocity: number = 80) => {
Expand Down Expand Up @@ -60,23 +61,28 @@ export default function FreePlay() {
}}
onClickRecord={(e) => {
e.stopPropagation()
if (!isRecordingAudio) {
//startRecordingAudio()
startRecordingMidi()
setRecordingAudio(true)
if (!isRecording) {
startRecording()
} else {
//stopRecordingAudio()
stopRecordingMidi()
setRecordingAudio(false)
const midiBytes = stopRecording()
if (midiBytes !== null) {
const base64MidiData = Buffer.from(midiBytes).toString('base64')
setRecordingPreview(base64MidiData)
}
}
}}
isRecordingAudio={isRecordingAudio}
isRecordingAudio={isRecording}
isLoading={synthState.loading}
isError={synthState.error}
value={instrumentName}
onChange={(name) => setInstrumentName(name)}
/>
<MidiModal isOpen={isMidiModalOpen} onClose={() => setMidiModal(false)} />
<RecordingModal
show={recordingPreview.length > 0}
onClose={() => setRecordingPreview('')}
songMeta={{ source: 'base64', id: recordingPreview }}
/>
<div className="flex-grow relative">
<SongVisualizer
song={freePlayer.song}
Expand Down

0 comments on commit fad2caf

Please sign in to comment.