Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Record audio button #113

Merged
merged 10 commits into from
Jan 15, 2024
Merged
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
Binary file modified bun.lockb
Binary file not shown.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"@types/webmidi": "2.0.8",
"autoprefixer": "10.4.16",
"bun-types": "1.0.3",
"eslint": "8.50.0",
"eslint-config-next": "13.5.3",
"eslint": "8.56.0",
"eslint-config-next": "14.0.4",
"fluent-ffmpeg": "2.1.2",
"jsdom": "22.1.0",
"postcss": "8.4.31",
Expand Down
46 changes: 25 additions & 21 deletions src/features/data/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +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 { FetchState, useRemoteResource } from '@/hooks/useFetch'
import { batchedFetch } from '@/utils'

function handleSong(response: Response): Promise<Song> {
return response.arrayBuffer().then(parseMidi)
Expand All @@ -18,26 +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
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
84 changes: 83 additions & 1 deletion src/features/midi/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { getNote } from '@/features/theory'
import { Midi } from '@tonejs/midi'
import { MidiStateEvent } from '@/types'
import { isBrowser } from '@/utils'
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 @@ -43,10 +46,11 @@ async function setupMidiDeviceListeners() {
}
}

type MidiEvent = {
export type MidiEvent = {
type: 'on' | 'off'
velocity: number
note: number
timeStamp: number
}

function parseMidiMessage(event: WebMidi.MIDIMessageEvent): MidiEvent | null {
Expand All @@ -61,6 +65,7 @@ function parseMidiMessage(event: WebMidi.MIDIMessageEvent): MidiEvent | null {
type: command === 0x9 ? 'on' : 'off',
note: data[1],
velocity: data[2],
timeStamp: event.timeStamp,
}
}

Expand Down Expand Up @@ -157,6 +162,7 @@ function onMidiMessage(e: WebMidi.MIDIMessageEvent) {
if (!msg) {
return
}

const { note, velocity } = msg
if (msg.type === 'on' && msg.velocity > 0) {
midiState.press(note, velocity)
Expand All @@ -165,4 +171,80 @@ 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 | null) | null>(null)
function startRecording() {
setIsRecording(true)
// Cleanup whatever recording was already happening
if (recordCb.current) {
recordCb.current?.()
}
recordCb.current = record(state)
}
function stopRecording() {
setIsRecording(false)
const midiBytes = recordCb.current?.() ?? new Uint8Array()
recordCb.current = null
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 Share 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>
)
}
21 changes: 18 additions & 3 deletions src/features/pages/Freeplay/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,42 @@ import React, { MouseEvent } from 'react'
import { Select } from '@/components'
import { formatInstrumentName } from '@/utils'
import { gmInstruments, InstrumentName } from '@/features/synth'
import { ArrowLeft, Midi } from '@/icons'
import { ArrowLeft, Midi, StartRecord, StopRecord } from '@/icons'
import { ButtonWithTooltip } from '../../PlaySong/components/TopBar'
import Link from 'next/link'

type TopBarProps = {
isError: boolean
isLoading: boolean
isRecordingAudio: boolean
value: InstrumentName
onChange: (instrument: InstrumentName) => void
onClickMidi: (e: MouseEvent<any>) => void
onClickRecord: (e: MouseEvent<any>) => void
}

export default function TopBar({ isError, isLoading, value, onChange, onClickMidi }: TopBarProps) {
export default function TopBar({
isError,
isLoading,
isRecordingAudio,
value,
onChange,
onClickMidi,
onClickRecord,
}: TopBarProps) {
const recordTooltip = isRecordingAudio ? 'Stop recording' : '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">
samouri marked this conversation as resolved.
Show resolved Hide resolved
<ButtonWithTooltip tooltip="Back">
<Link href="/">
<ArrowLeft size={24} />
</Link>
</ButtonWithTooltip>
<ButtonWithTooltip tooltip="Choose a MIDI device" className="ml-auto" onClick={onClickMidi}>
<ButtonWithTooltip tooltip={recordTooltip} className="ml-auto" onClick={onClickRecord}>
{isRecordingAudio ? <StopRecord size={24} /> : <StartRecord size={24} />}
</ButtonWithTooltip>
<ButtonWithTooltip tooltip="Choose a MIDI device" onClick={onClickMidi}>
<Midi size={24} />
</ButtonWithTooltip>
<Select
Expand Down
Loading