// Vip Midi Beeper Card // Purpose: Adds client-only MIDI file and live MIDI input playback through the existing Roomba song command path. // Scope: Keeps MIDI parsing, Web MIDI selection, local beeper pacing, and VIP card UI isolated from the rest of the page. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import CardFrame from '../../CardFrame/index.jsx'; import { useControlActions } from '../../../controls/index.js'; import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { fieldClass } from '../constants.js'; import { BEEPER_READY_GUARD_MS, DEFAULT_ARPEGGIO_NOTE_LIMIT, DEFAULT_ARPEGGIO_NOTE_TICKS, DEFAULT_FILE_NOTE_TICKS, DEFAULT_LIVE_NOTE_TICKS, LIVE_DEVICE_EMPTY_VALUE, MAX_FILE_NOTE_TICKS, PLAYBACK_MODES, } from './constants.js'; import { buildBeeperEvents, buildRoombaSongChunks, clampRoombaDurationTicks, clampRoombaNote, collectNotesForParts, getDefaultSelectedPartIds, getPresetPartIds, parseMidiFile, roombaTicksToMs, } from './midiBeeperUtils.js'; import StatusRow from './StatusRow.jsx'; function getNow() { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } export default function VipMidiBeeperCard() { const { sendSong } = useControlActions(); const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim()); const [selectedFileName, setSelectedFileName] = useState(''); const [parsedMidi, setParsedMidi] = useState(null); const [selectedPartIds, setSelectedPartIds] = useState([]); const [playbackMode, setPlaybackMode] = useState('mono'); const [monoMaxTicks, setMonoMaxTicks] = useState(MAX_FILE_NOTE_TICKS); const [arpeggioNoteTicks, setArpeggioNoteTicks] = useState(DEFAULT_ARPEGGIO_NOTE_TICKS); const [arpeggioNoteLimit, setArpeggioNoteLimit] = useState(DEFAULT_ARPEGGIO_NOTE_LIMIT); const [liveNoteTicks, setLiveNoteTicks] = useState(DEFAULT_LIVE_NOTE_TICKS); const [message, setMessage] = useState(''); const [playbackState, setPlaybackState] = useState('idle'); const [stats, setStats] = useState({ sent: 0, dropped: 0 }); const [midiAccessState, setMidiAccessState] = useState('idle'); const [midiInputs, setMidiInputs] = useState([]); const [selectedInputId, setSelectedInputId] = useState(LIVE_DEVICE_EMPTY_VALUE); const [liveEnabled, setLiveEnabled] = useState(false); const midiAccessRef = useRef(null); const activeInputRef = useRef(null); const playbackRunRef = useRef(0); const playbackTimersRef = useRef([]); const beeperBusyUntilRef = useRef(0); const playbackCursorMsRef = useRef(0); const playbackStartedAtRef = useRef(0); const playbackConfigRef = useRef(null); const playableParts = useMemo(() => parsedMidi?.parts || [], [parsedMidi]); const selectedPartNotes = useMemo( () => collectNotesForParts(playableParts, selectedPartIds), [playableParts, selectedPartIds], ); const beeperOptions = useMemo( () => ({ monoFallbackTicks: DEFAULT_FILE_NOTE_TICKS, monoMaxTicks, arpeggioTicks: arpeggioNoteTicks, arpeggioLimit: arpeggioNoteLimit, }), [arpeggioNoteLimit, arpeggioNoteTicks, monoMaxTicks], ); const beeperEvents = useMemo(() => { if (!selectedPartNotes.length) return []; return buildBeeperEvents(selectedPartNotes, playbackMode, beeperOptions); }, [beeperOptions, playbackMode, selectedPartNotes]); const songChunks = useMemo(() => buildRoombaSongChunks(beeperEvents), [beeperEvents]); const midiSupported = typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function'; const selectedInput = useMemo( () => midiInputs.find((input) => input.id === selectedInputId) || null, [midiInputs, selectedInputId], ); const selectedPartSet = useMemo( () => new Set(selectedPartIds.map((id) => String(id))), [selectedPartIds], ); useEffect(() => { /* File playback has to let settings change while a song is playing. React callbacks created at play-start would otherwise close over stale mode and channel values, so the scheduler reads the latest normalized config from a ref each time it reaches a chunk boundary. */ playbackConfigRef.current = { playableParts, selectedPartIds, playbackMode, beeperOptions, }; }, [beeperOptions, playableParts, playbackMode, selectedPartIds]); const clearPlaybackTimers = useCallback(() => { playbackTimersRef.current.forEach((timer) => clearTimeout(timer)); playbackTimersRef.current = []; }, []); const applyPartPreset = useCallback( (preset) => { setSelectedPartIds(getPresetPartIds(playableParts, preset)); }, [playableParts], ); const togglePart = useCallback((partId) => { const normalized = String(partId || ''); if (!normalized) return; setSelectedPartIds((current) => { const currentSet = new Set(current.map((entry) => String(entry))); if (currentSet.has(normalized)) { currentSet.delete(normalized); } else { currentSet.add(normalized); } return Array.from(currentSet).sort(); }); }, []); const stopPlayback = useCallback(() => { /* Stopping playback cannot stop a note that the Roomba is already beeping. It only cancels future browser timers and lets the local busy clock expire naturally, which matches the beeper's real "no interruption" behavior. */ playbackRunRef.current += 1; clearPlaybackTimers(); setPlaybackState('idle'); }, [clearPlaybackTimers]); const sendBeeperNote = useCallback( ({ note, duration, source }) => { const now = getNow(); const safeDuration = clampRoombaDurationTicks(duration); if (!ownRoverId) { setMessage('Take control of your rover first.'); setStats((prev) => ({ ...prev, dropped: prev.dropped + 1 })); return false; } if (now < beeperBusyUntilRef.current) { /* The server and Roomba will accept fast commands, but the Roomba song player drops a song while it is already playing one. This local gate chooses an intentional skip instead of sending a command that is very likely to disappear silently. */ setStats((prev) => ({ ...prev, dropped: prev.dropped + 1 })); return false; } const sent = sendSong?.([{ note: clampRoombaNote(note), duration: safeDuration }], { slot: 0 }); if (!sent) { setStats((prev) => ({ ...prev, dropped: prev.dropped + 1 })); return false; } beeperBusyUntilRef.current = now + roombaTicksToMs(safeDuration) + BEEPER_READY_GUARD_MS; setStats((prev) => ({ ...prev, sent: prev.sent + 1 })); if (source === 'live') { setMessage('Live note sent.'); } return true; }, [ownRoverId, sendSong], ); const sendBeeperChunk = useCallback( (chunk) => { const notes = Array.isArray(chunk?.notes) ? chunk.notes : []; if (!notes.length) return false; if (!ownRoverId) { setMessage('Take control of your rover first.'); setStats((prev) => ({ ...prev, dropped: prev.dropped + notes.length })); return false; } /* File playback sends a complete Roomba song definition instead of racing individual note commands against the beeper's busy state. The Roomba can play up to 16 notes from one accepted command, so the browser only has to time phrase boundaries instead of every single note. */ const sent = sendSong?.(notes, { slot: 0 }); if (!sent) { setStats((prev) => ({ ...prev, dropped: prev.dropped + notes.length })); return false; } beeperBusyUntilRef.current = getNow() + (Number(chunk.durationMs) || 0) + BEEPER_READY_GUARD_MS; setStats((prev) => ({ ...prev, sent: prev.sent + notes.length })); return true; }, [ownRoverId, sendSong], ); const buildNextPlaybackChunk = useCallback(() => { const config = playbackConfigRef.current || {}; const parts = Array.isArray(config.playableParts) ? config.playableParts : []; const partIds = Array.isArray(config.selectedPartIds) ? config.selectedPartIds : []; if (!parts.length || partIds.length === 0) return null; const notes = collectNotesForParts(parts, partIds); const events = buildBeeperEvents(notes, config.playbackMode, config.beeperOptions) .filter((event) => event.atMs >= playbackCursorMsRef.current - 0.5); const chunks = buildRoombaSongChunks(events); return chunks[0] || null; }, []); const handleFileChange = async (event) => { const file = event.target.files?.[0] || null; stopPlayback(); setMessage(''); setParsedMidi(null); setSelectedFileName(file?.name || ''); setSelectedPartIds([]); if (!file) return; try { const nextMidi = await parseMidiFile(file); setParsedMidi(nextMidi); const defaultParts = getDefaultSelectedPartIds(nextMidi.parts); setSelectedPartIds(defaultParts); setMessage(nextMidi.parts?.length ? 'Midi file loaded.' : 'Midi file loaded, but no playable parts were found.'); } catch (err) { setMessage(err?.message || 'Failed to parse Midi file.'); setSelectedFileName(''); } }; const handlePlayFile = () => { if (!songChunks.length) { setMessage('Choose at least one playable part first.'); return; } if (!ownRoverId) { setMessage('Take control of your rover first.'); return; } stopPlayback(); setLiveEnabled(false); setStats({ sent: 0, dropped: 0 }); setMessage(''); setPlaybackState('playing'); const runId = playbackRunRef.current + 1; playbackRunRef.current = runId; playbackStartedAtRef.current = getNow(); playbackCursorMsRef.current = songChunks[0]?.startMs || 0; const scheduleNextChunk = (delayMs = 0) => { if (playbackRunRef.current !== runId) return; const timer = setTimeout(() => { if (playbackRunRef.current !== runId) return; const chunk = buildNextPlaybackChunk(); if (!chunk) { setPlaybackState('idle'); setMessage(`Playback finished in ${Math.round((getNow() - playbackStartedAtRef.current) / 1000)}s.`); return; } const sourceGapMs = Math.max(0, chunk.startMs - playbackCursorMsRef.current); if (sourceGapMs > 0) { /* Real rests are handled as source-time cursor advances. The follow-up timer recomputes the next chunk from the latest channel/mode settings instead of committing to a chunk before the silence has elapsed. */ playbackCursorMsRef.current = chunk.startMs; scheduleNextChunk(sourceGapMs); return; } sendBeeperChunk(chunk); playbackCursorMsRef.current = Math.max(playbackCursorMsRef.current + 0.5, Number(chunk.sourceEndMs) || playbackCursorMsRef.current); scheduleNextChunk((Number(chunk.durationMs) || 0) + BEEPER_READY_GUARD_MS); }, Math.max(0, delayMs)); playbackTimersRef.current.push(timer); }; /* Chunks are scheduled one boundary at a time. Each boundary rebuilds the next phrase from the latest selected channels and playback settings, so changes made while the current Roomba song is playing affect the next song command without trying to interrupt the one already in the beeper. */ scheduleNextChunk(0); }; const refreshMidiInputs = useCallback((access) => { const inputs = Array.from(access?.inputs?.values?.() || []).map((input) => ({ id: input.id, name: input.name || input.manufacturer || 'Midi input', state: input.state || 'connected', })); setMidiInputs(inputs); setSelectedInputId((current) => { if (current && inputs.some((input) => input.id === current)) return current; return inputs[0]?.id || LIVE_DEVICE_EMPTY_VALUE; }); }, []); const requestMidiAccess = useCallback(async () => { if (!midiSupported) { setMidiAccessState('unsupported'); setMessage('This browser does not support live Midi input.'); return; } try { setMidiAccessState('requesting'); const access = await navigator.requestMIDIAccess({ sysex: false }); midiAccessRef.current = access; refreshMidiInputs(access); access.onstatechange = () => refreshMidiInputs(access); setMidiAccessState('ready'); setMessage('Live Midi input ready.'); } catch (err) { setMidiAccessState('error'); setMessage(err?.message || 'Midi access was denied.'); } }, [midiSupported, refreshMidiInputs]); useEffect(() => { if (!liveEnabled || !selectedInput || !midiAccessRef.current) { if (activeInputRef.current) { activeInputRef.current.onmidimessage = null; activeInputRef.current = null; } return undefined; } const input = midiAccessRef.current.inputs.get(selectedInput.id); if (!input) return undefined; const handleMidiMessage = (event) => { const [status, noteNumber, velocity] = Array.from(event.data || []); const command = status & 0xf0; const isNoteOn = command === 0x90 && velocity > 0; if (!isNoteOn) return; /* Live note-off messages cannot stop the Roomba beeper, so live mode uses fixed short notes. Users can tune that length instead of expecting MIDI keyboard release timing to behave like a synthesizer. */ sendBeeperNote({ note: noteNumber, duration: liveNoteTicks, source: 'live', }); }; if (activeInputRef.current && activeInputRef.current !== input) { activeInputRef.current.onmidimessage = null; } input.onmidimessage = handleMidiMessage; activeInputRef.current = input; return () => { if (input.onmidimessage === handleMidiMessage) { input.onmidimessage = null; } }; }, [liveEnabled, liveNoteTicks, selectedInput, sendBeeperNote]); useEffect( () => () => { /* The user-facing stop handler updates React state, but unmount cleanup should only tear down external work. Incrementing the run id cancels any late timer callback without asking React to render a card that is going away. */ playbackRunRef.current += 1; clearPlaybackTimers(); if (activeInputRef.current) { activeInputRef.current.onmidimessage = null; } if (midiAccessRef.current) { midiAccessRef.current.onstatechange = null; } }, [clearPlaybackTimers], ); return (

File playback

Parts
{playableParts.length ? (
{playableParts.map((part) => { const checked = selectedPartSet.has(String(part.id)); return ( ); })}
) : (
No playable parts
)}
Playback shape
{selectedFileName ? (
{selectedFileName}
) : (
No Midi file selected
)}

Live input

Beeper status

0} /> 0} />
{message ?
{message}
: null}
); }