midi awawa slopcoding

This commit is contained in:
legop3
2026-06-26 00:55:29 -04:00
parent 5634004ebd
commit e664402528
11 changed files with 1286 additions and 141 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CfAeG6CY.js"></script> <script type="module" crossorigin src="/assets/index-BA_CXART.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CdgtactY.css"> <link rel="stylesheet" crossorigin href="/assets/index-DL3_eVjU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7
View File
@@ -8,6 +8,7 @@
"name": "webui", "name": "webui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"midi-file": "^1.2.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -2930,6 +2931,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/midi-file": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/midi-file/-/midi-file-1.2.4.tgz",
"integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
"license": "MIT"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"midi-file": "^1.2.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
+3 -1
View File
@@ -6,12 +6,13 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js'; import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx'; import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
import VipMidiBeeperCard from '../vip/VipMidiBeeperCard/index.jsx';
import VipVerificationCard from '../vip/VipVerificationCard.jsx'; import VipVerificationCard from '../vip/VipVerificationCard.jsx';
import VipIdentityCard from '../vip/VipIdentityCard.jsx'; import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx'; import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx'; import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
export default function VipPanel({ isActive = true }) { export default function VipPanel() {
const session = useSessionSelector((state) => state.session); const session = useSessionSelector((state) => state.session);
const { const {
identifySession, identifySession,
@@ -68,6 +69,7 @@ export default function VipPanel({ isActive = true }) {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{isVerified ? ( {isVerified ? (
<div className="space-y-2"> <div className="space-y-2">
<VipMidiBeeperCard />
<VipAudioUploadCard <VipAudioUploadCard
ownRoverId={ownRoverId} ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}} audioForwardByRover={session?.audioForward || {}}
@@ -0,0 +1,13 @@
// Midi Beeper status row
// Purpose: Provides compact status readouts that match the existing VIP card visual language.
// Scope: Presentational only; the parent owns all state and wording.
export default function StatusRow({ label, value, active = false }) {
return (
<div className="surface-muted flex items-center justify-between gap-0.5 text-xs">
<span className="text-slate-400">{label}</span>
<span className={active ? 'font-semibold text-emerald-200' : 'font-semibold text-slate-200'}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,39 @@
// Midi Beeper constants
// Purpose: Keeps Roomba beeper timing, note limits, and UI defaults in one place.
// Scope: Shared by the Midi card, parser helpers, and playback scheduler.
export const ROOMBA_NOTE_MIN = 31;
export const ROOMBA_NOTE_MAX = 127;
// Roomba Open Interface song durations are expressed in 1/64 second ticks.
// Keeping this conversion explicit avoids confusing those device ticks with
// JavaScript milliseconds or MIDI ticks-per-beat.
export const ROOMBA_DURATION_TICK_MS = 1000 / 64;
// The Roomba drops a song command while a previous song is still playing. This
// guard accounts for browser timer jitter and websocket latency so the next
// note is less likely to arrive a few milliseconds too early.
export const BEEPER_READY_GUARD_MS = 24;
export const ROOMBA_SONG_MAX_NOTES = 16;
export const DEFAULT_LIVE_NOTE_TICKS = 8;
export const DEFAULT_FILE_NOTE_TICKS = 10;
export const DEFAULT_ARPEGGIO_NOTE_TICKS = 4;
export const DEFAULT_ARPEGGIO_NOTE_LIMIT = 6;
// Long held MIDI notes sound bad on the Roomba and block following notes. This
// cap keeps playback responsive while still preserving rough note lengths.
export const MAX_FILE_NOTE_TICKS = 48;
// Small MIDI timing gaps are usually expressive overlap/quantization noise, not
// meaningful silence for a single-note beeper. Larger gaps should split chunks
// so file playback still breathes between phrases.
export const FILE_CHUNK_GAP_THRESHOLD_MS = 45;
export const PLAYBACK_MODES = [
{ id: 'mono', label: 'Monophonic' },
{ id: 'arpeggio', label: 'Arpeggio' },
];
export const LIVE_DEVICE_EMPTY_VALUE = '';
@@ -0,0 +1,647 @@
// 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 (
<CardFrame title="Midi Beeper">
<div className="grid gap-1">
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
<section className="surface h-full">
<div className="grid h-full gap-0.5 content-start">
<p className="text-sm text-slate-200 text-center">File playback</p>
<label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Midi file</span>
<input
className={`${fieldClass} text-center`}
type="file"
accept=".mid,.midi,audio/midi,audio/x-midi"
disabled={playbackState === 'playing'}
onChange={handleFileChange}
/>
</label>
<div className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Parts</span>
<div className="grid grid-cols-4 gap-0.5">
<button type="button" className="button-dark text-xs" disabled={!playableParts.length} onClick={() => applyPartPreset('default')}>
Default
</button>
<button type="button" className="button-dark text-xs" disabled={!playableParts.length} onClick={() => applyPartPreset('melody')}>
Melody
</button>
<button type="button" className="button-dark text-xs" disabled={!playableParts.length} onClick={() => applyPartPreset('bass')}>
Bass
</button>
<button type="button" className="button-dark text-xs" disabled={!playableParts.length} onClick={() => applyPartPreset('all-pitched')}>
Pitched
</button>
</div>
{playableParts.length ? (
<div className="max-h-36 overflow-y-auto rounded-md border border-neutral-700/70 bg-neutral-900/60 p-0.5">
<div className="grid gap-0.5">
{playableParts.map((part) => {
const checked = selectedPartSet.has(String(part.id));
return (
<label
key={part.id}
className={`surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.5 ${
checked ? 'text-emerald-200' : 'text-slate-400'
}`}
title={part.sourceLabel || part.label}
>
<span className="flex min-w-0 items-center gap-0.5">
<input
type="checkbox"
checked={checked}
onChange={() => togglePart(part.id)}
/>
<span className="truncate text-left">{part.label}</span>
</span>
<span className="shrink-0 text-[0.65rem] text-slate-400">
{part.isPercussion ? 'drums' : `${part.noteCount} notes`}
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="surface-muted text-xs text-slate-500">No playable parts</div>
)}
<button type="button" className="button-dark mx-auto text-xs" disabled={!playableParts.length} onClick={() => applyPartPreset('none')}>
Clear parts
</button>
</div>
<label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Playback mode</span>
<select
className={fieldClass}
value={playbackMode}
onChange={(event) => setPlaybackMode(event.target.value === 'arpeggio' ? 'arpeggio' : 'mono')}
>
{PLAYBACK_MODES.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.label}
</option>
))}
</select>
</label>
<div className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Playback shape</span>
<div className="grid grid-cols-3 gap-0.5">
<label className="grid gap-0.5">
<span>Mono max</span>
<input
className={fieldClass}
type="number"
min="1"
max="96"
value={monoMaxTicks}
onChange={(event) => setMonoMaxTicks(clampRoombaDurationTicks(Number(event.target.value) || MAX_FILE_NOTE_TICKS))}
/>
</label>
<label className="grid gap-0.5">
<span>Arp len</span>
<input
className={fieldClass}
type="number"
min="1"
max="32"
value={arpeggioNoteTicks}
onChange={(event) => setArpeggioNoteTicks(clampRoombaDurationTicks(Number(event.target.value) || DEFAULT_ARPEGGIO_NOTE_TICKS))}
/>
</label>
<label className="grid gap-0.5">
<span>Arp notes</span>
<input
className={fieldClass}
type="number"
min="1"
max="16"
value={arpeggioNoteLimit}
onChange={(event) => {
const next = Math.max(1, Math.min(16, Math.round(Number(event.target.value) || DEFAULT_ARPEGGIO_NOTE_LIMIT)));
setArpeggioNoteLimit(next);
}}
/>
</label>
</div>
</div>
<div className="mx-auto w-full max-w-sm text-center">
{selectedFileName ? (
<div className="surface-muted text-xs text-slate-300">{selectedFileName}</div>
) : (
<div className="surface-muted text-xs text-slate-500">No Midi file selected</div>
)}
</div>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!ownRoverId || !beeperEvents.length || playbackState === 'playing'}
onClick={handlePlayFile}
>
Play file
</button>
<button
type="button"
className="button-danger text-sm"
disabled={playbackState !== 'playing'}
onClick={stopPlayback}
>
Stop
</button>
</div>
</div>
</section>
<section className="surface h-full">
<div className="grid h-full gap-0.5 grid-rows-[auto_auto_auto_1fr]">
<p className="text-sm text-slate-200 text-center">Live input</p>
<div className="flex justify-center">
<button
type="button"
className="button-dark text-sm"
disabled={!midiSupported || midiAccessState === 'requesting'}
onClick={requestMidiAccess}
>
{midiAccessState === 'ready' ? 'Refresh devices' : 'Enable Midi'}
</button>
</div>
<label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Input device</span>
<select
className={fieldClass}
value={selectedInputId}
disabled={midiAccessState !== 'ready' || liveEnabled}
onChange={(event) => setSelectedInputId(event.target.value)}
>
{midiInputs.length ? null : <option value={LIVE_DEVICE_EMPTY_VALUE}>No devices</option>}
{midiInputs.map((input) => (
<option key={input.id} value={input.id}>
{input.name}
</option>
))}
</select>
</label>
<label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Live note length</span>
<input
className={fieldClass}
type="number"
min="1"
max="32"
value={liveNoteTicks}
onChange={(event) => setLiveNoteTicks(clampRoombaDurationTicks(Number(event.target.value) || DEFAULT_LIVE_NOTE_TICKS))}
/>
</label>
<label className="surface-muted mx-auto flex w-full max-w-sm items-center justify-center gap-0.5 px-0.5 py-0.5 text-xs text-slate-300">
<input
type="checkbox"
checked={liveEnabled}
disabled={!ownRoverId || midiAccessState !== 'ready' || !selectedInput}
onChange={(event) => {
setLiveEnabled(Boolean(event.target.checked));
setStats({ sent: 0, dropped: 0 });
setMessage(event.target.checked ? 'Live Midi input enabled.' : 'Live Midi input disabled.');
}}
/>
<span>Live beeper input</span>
</label>
</div>
</section>
</div>
<section className="surface">
<div className="space-y-0.5">
<p className="text-sm text-slate-200 text-center">Beeper status</p>
<div className="grid gap-0.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-6">
<StatusRow label="Rover" value={ownRoverId || 'none'} active={Boolean(ownRoverId)} />
<StatusRow label="File state" value={playbackState} active={playbackState === 'playing'} />
<StatusRow label="Parts" value={selectedPartIds.length || 'none'} active={selectedPartIds.length > 0} />
<StatusRow label="Live input" value={liveEnabled ? 'enabled' : midiAccessState} active={liveEnabled} />
<StatusRow label="Notes sent" value={stats.sent} active={stats.sent > 0} />
<StatusRow label="Notes skipped" value={stats.dropped} active={stats.dropped === 0} />
</div>
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
</div>
</section>
</div>
</CardFrame>
);
}
@@ -0,0 +1,436 @@
// Midi Beeper utilities
// Purpose: Parses MIDI files into simple beeper note events and converts timing into Roomba song units.
// Scope: Pure helpers only; React state, browser MIDI input, and command sending live in the card component.
import { parseMidi } from 'midi-file';
import {
DEFAULT_ARPEGGIO_NOTE_TICKS,
DEFAULT_ARPEGGIO_NOTE_LIMIT,
DEFAULT_FILE_NOTE_TICKS,
FILE_CHUNK_GAP_THRESHOLD_MS,
MAX_FILE_NOTE_TICKS,
ROOMBA_SONG_MAX_NOTES,
ROOMBA_DURATION_TICK_MS,
ROOMBA_NOTE_MAX,
ROOMBA_NOTE_MIN,
} from './constants.js';
function clamp(value, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return min;
return Math.max(min, Math.min(max, number));
}
export function clampRoombaNote(note) {
return clamp(Math.round(note), ROOMBA_NOTE_MIN, ROOMBA_NOTE_MAX);
}
export function clampRoombaDurationTicks(ticks) {
return clamp(Math.round(ticks), 1, 255);
}
export function roombaTicksToMs(ticks) {
return clampRoombaDurationTicks(ticks) * ROOMBA_DURATION_TICK_MS;
}
function formatTrackLabel(index, name, noteCount, channels = []) {
const cleanName = String(name || '').trim();
const prefix = cleanName || `Track ${index + 1}`;
const channelLabel = channels.length > 1 ? `, ${channels.length} channels` : channels.length === 1 ? ', 1 channel' : '';
return `${prefix} (${noteCount} notes${channelLabel})`;
}
function collectAbsoluteEvents(track = []) {
let tick = 0;
return track.map((event) => {
tick += Number(event?.deltaTime) || 0;
return { ...event, absoluteTick: tick };
});
}
function createTempoMap(parsed) {
const tempoEvents = [];
/*
MIDI tempo is global for normal format-1 files, and tempo events often sit
in track 0 while the notes live in a later track. Reading tempo changes from
every track gives selected-track playback the same timing map without
requiring server work or a heavier sequencer library.
*/
(parsed?.tracks || []).forEach((track) => {
collectAbsoluteEvents(track).forEach((event) => {
if (event.type !== 'setTempo') return;
tempoEvents.push({
tick: event.absoluteTick,
microsecondsPerBeat: Number(event.microsecondsPerBeat) || 500000,
});
});
});
tempoEvents.sort((a, b) => a.tick - b.tick);
if (!tempoEvents.length || tempoEvents[0].tick !== 0) {
tempoEvents.unshift({ tick: 0, microsecondsPerBeat: 500000 });
}
return tempoEvents;
}
function tickToMs(tick, tempoMap, ticksPerBeat) {
let elapsedMs = 0;
let previousTick = 0;
let microsecondsPerBeat = 500000;
/*
Tempo changes are piecewise-linear: each segment uses the tempo active at
the start of that segment. This loop is intentionally small and direct
because MIDI files for this feature are expected to be short, and clarity is
more important than caching every possible tick conversion.
*/
for (const tempo of tempoMap) {
if (tempo.tick > tick) break;
const deltaTicks = Math.max(0, tempo.tick - previousTick);
elapsedMs += (deltaTicks / ticksPerBeat) * (microsecondsPerBeat / 1000);
previousTick = tempo.tick;
microsecondsPerBeat = tempo.microsecondsPerBeat;
}
const remainingTicks = Math.max(0, tick - previousTick);
return elapsedMs + (remainingTicks / ticksPerBeat) * (microsecondsPerBeat / 1000);
}
function extractTrackNotes(track = [], tempoMap, ticksPerBeat) {
const absoluteEvents = collectAbsoluteEvents(track);
const activeNotes = new Map();
const notes = [];
let trackName = '';
absoluteEvents.forEach((event) => {
if (event.type === 'trackName' && event.text) {
trackName = String(event.text);
return;
}
if (event.type !== 'noteOn' && event.type !== 'noteOff') return;
const channel = Number(event.channel) || 0;
const noteNumber = Number(event.noteNumber);
if (!Number.isFinite(noteNumber)) return;
const key = `${channel}:${noteNumber}`;
const velocity = Number(event.velocity) || 0;
const isNoteStart = event.type === 'noteOn' && velocity > 0;
if (isNoteStart) {
/*
A repeated note-on for the same note/channel before note-off is unusual
but possible. Replacing the active note keeps the parser from creating a
negative or huge duration if the file is messy.
*/
activeNotes.set(key, {
channel,
noteNumber,
startTick: event.absoluteTick,
velocity,
});
return;
}
const active = activeNotes.get(key);
if (!active) return;
activeNotes.delete(key);
const startMs = tickToMs(active.startTick, tempoMap, ticksPerBeat);
const endMs = tickToMs(event.absoluteTick, tempoMap, ticksPerBeat);
notes.push({
channel: active.channel,
noteNumber: active.noteNumber,
velocity: active.velocity,
startMs,
durationMs: Math.max(ROOMBA_DURATION_TICK_MS, endMs - startMs),
});
});
notes.sort((a, b) => a.startMs - b.startMs || b.noteNumber - a.noteNumber);
return { trackName, notes };
}
function summarizeChannels(notes = []) {
const counts = new Map();
notes.forEach((note) => {
const channel = Number(note?.channel);
if (!Number.isInteger(channel)) return;
const previous = counts.get(channel) || {
noteCount: 0,
minNote: 127,
maxNote: 0,
noteTotal: 0,
};
previous.noteCount += 1;
previous.minNote = Math.min(previous.minNote, Number(note.noteNumber) || previous.minNote);
previous.maxNote = Math.max(previous.maxNote, Number(note.noteNumber) || previous.maxNote);
previous.noteTotal += Number(note.noteNumber) || 0;
counts.set(channel, previous);
});
return Array.from(counts.entries())
.sort((a, b) => a[0] - b[0])
.map(([channel, summary]) => ({
channel,
noteCount: summary.noteCount,
minNote: summary.minNote,
maxNote: summary.maxNote,
averageNote: summary.noteCount > 0 ? summary.noteTotal / summary.noteCount : 0,
// MIDI channels are zero-based in the file data, but musicians and most
// MIDI tools display them as 1-16. Keeping both avoids off-by-one logic in
// the UI and makes channel 10 percussion obvious.
label: `Ch ${channel + 1}`,
isPercussion: channel === 9,
}));
}
function buildPartSourceLabel(track, channelInfo) {
const trackName = String(track?.name || '').trim();
const trackLabel = trackName || `Track ${Number(track?.index || 0) + 1}`;
const channelLabel = channelInfo?.label || `Ch ${Number(channelInfo?.channel || 0) + 1}`;
return `${trackLabel} - ${channelLabel}`;
}
function buildPlayableParts(tracks = []) {
const parts = [];
tracks.forEach((track) => {
(track.channels || []).forEach((channelInfo) => {
const notes = (track.notes || []).filter((note) => Number(note.channel) === Number(channelInfo.channel));
if (!notes.length) return;
const trackName = String(track?.name || '').trim();
const partNumber = parts.length + 1;
const visibleLabel = trackName ? `Part ${partNumber}: ${trackName}` : `Part ${partNumber}`;
/*
A "part" is the user-facing musical unit. Internally it is still the
MIDI file's track+channel pair, because that is the only reliable way to
separate instruments across the inconsistent MIDI files people upload.
*/
parts.push({
id: `${track.index}:${channelInfo.channel}`,
trackIndex: track.index,
channel: channelInfo.channel,
label: visibleLabel,
sourceLabel: buildPartSourceLabel(track, channelInfo),
shortLabel: `${Number(track.index) + 1}.${Number(channelInfo.channel) + 1}`,
noteCount: notes.length,
minNote: channelInfo.minNote,
maxNote: channelInfo.maxNote,
averageNote: channelInfo.averageNote,
isPercussion: Boolean(channelInfo.isPercussion),
notes,
});
});
});
return parts.sort((a, b) => {
if (a.isPercussion !== b.isPercussion) return a.isPercussion ? 1 : -1;
return b.noteCount - a.noteCount || a.trackIndex - b.trackIndex || a.channel - b.channel;
});
}
export function getDefaultSelectedPartIds(parts = []) {
const playable = Array.isArray(parts) ? parts : [];
const pitchedParts = playable.filter((part) => !part.isPercussion);
const candidates = pitchedParts.length ? pitchedParts : playable;
/*
Default to a small, useful ensemble instead of everything. Dense MIDI files
can overwhelm a one-note Roomba beeper, so selecting the largest pitched
parts gives the user a recognizable starting point while keeping drums off
unless the file has nothing else.
*/
return candidates.slice(0, 6).map((part) => part.id);
}
export function getPresetPartIds(parts = [], preset = 'default') {
const playable = Array.isArray(parts) ? parts : [];
const pitchedParts = playable.filter((part) => !part.isPercussion);
const source = pitchedParts.length ? pitchedParts : playable;
switch (preset) {
case 'none':
return [];
case 'all-pitched':
return source.map((part) => part.id);
case 'bass': {
const sorted = [...source].sort((a, b) => a.averageNote - b.averageNote || b.noteCount - a.noteCount);
return sorted.slice(0, 2).map((part) => part.id);
}
case 'melody': {
const sorted = [...source].sort((a, b) => b.averageNote - a.averageNote || b.noteCount - a.noteCount);
return sorted.slice(0, 3).map((part) => part.id);
}
default:
return getDefaultSelectedPartIds(playable);
}
}
export function collectNotesForParts(parts = [], selectedPartIds = []) {
if (!Array.isArray(parts) || !Array.isArray(selectedPartIds) || selectedPartIds.length === 0) return [];
const selected = new Set(selectedPartIds.map((id) => String(id)));
return parts
.filter((part) => selected.has(String(part.id)))
.flatMap((part) => part.notes || [])
.sort((a, b) => a.startMs - b.startMs || b.velocity - a.velocity || b.noteNumber - a.noteNumber);
}
function groupNotesByStart(notes = []) {
const groups = [];
let current = null;
notes.forEach((note) => {
/*
A tiny tolerance catches chord notes that quantize to the same musical
time but differ by a fraction of a millisecond after tempo conversion.
*/
if (!current || Math.abs(note.startMs - current.startMs) > 1) {
current = { startMs: note.startMs, notes: [] };
groups.push(current);
}
current.notes.push(note);
});
return groups;
}
function noteDurationToRoombaTicks(note, fallbackTicks, maxTicks) {
const naturalTicks = note.durationMs / ROOMBA_DURATION_TICK_MS;
const cap = clampRoombaDurationTicks(maxTicks || MAX_FILE_NOTE_TICKS);
return clampRoombaDurationTicks(Math.min(cap, naturalTicks || fallbackTicks));
}
export function filterNotesByChannels(notes = [], selectedChannels = []) {
if (!Array.isArray(selectedChannels) || selectedChannels.length === 0) return [];
const channelSet = new Set(selectedChannels.map((channel) => Number(channel)));
return notes.filter((note) => channelSet.has(Number(note?.channel)));
}
export function buildBeeperEvents(notes = [], mode = 'mono', options = {}) {
const groups = groupNotesByStart(notes);
const events = [];
const arpeggioTicks = clampRoombaDurationTicks(options.arpeggioTicks || DEFAULT_ARPEGGIO_NOTE_TICKS);
const arpeggioLimit = clamp(Math.round(options.arpeggioLimit || DEFAULT_ARPEGGIO_NOTE_LIMIT), 1, 16);
const monoFallbackTicks = clampRoombaDurationTicks(options.monoFallbackTicks || DEFAULT_FILE_NOTE_TICKS);
const monoMaxTicks = clampRoombaDurationTicks(options.monoMaxTicks || MAX_FILE_NOTE_TICKS);
groups.forEach((group) => {
const playableNotes = [...group.notes].sort((a, b) => {
/*
Higher velocity usually represents the melody or accented chord tone.
Note number breaks ties so dense chords become predictable instead of
depending on the file's internal event order.
*/
return b.velocity - a.velocity || b.noteNumber - a.noteNumber;
});
if (mode === 'arpeggio') {
playableNotes.slice(0, arpeggioLimit).forEach((note, index) => {
const ticks = arpeggioTicks;
events.push({
atMs: group.startMs + index * roombaTicksToMs(ticks),
note: clampRoombaNote(note.noteNumber),
duration: ticks,
});
});
return;
}
const selected = playableNotes[0];
if (!selected) return;
events.push({
atMs: selected.startMs,
note: clampRoombaNote(selected.noteNumber),
duration: noteDurationToRoombaTicks(selected, monoFallbackTicks, monoMaxTicks),
});
});
return events.sort((a, b) => a.atMs - b.atMs || b.note - a.note);
}
function createChunk(startMs) {
return {
startMs,
notes: [],
durationMs: 0,
sourceEndMs: startMs,
};
}
export function buildRoombaSongChunks(events = []) {
const chunks = [];
const sortedEvents = [...events].sort((a, b) => a.atMs - b.atMs || b.note - a.note);
let chunk = null;
let cursorMs = 0;
sortedEvents.forEach((event) => {
const noteDurationMs = roombaTicksToMs(event.duration);
const hasOpenChunk = Boolean(chunk && chunk.notes.length > 0);
const gapAfterCurrentChunk = hasOpenChunk ? event.atMs - cursorMs : 0;
const shouldStartNewChunk =
!hasOpenChunk ||
chunk.notes.length >= ROOMBA_SONG_MAX_NOTES ||
gapAfterCurrentChunk > FILE_CHUNK_GAP_THRESHOLD_MS;
if (shouldStartNewChunk) {
/*
A new chunk is scheduled at the MIDI event time when there is real
silence before it. If the incoming event overlaps the previous phrase,
it starts at the current serialized cursor instead so playback bends
toward "play the next note" instead of "drop the next note".
*/
const startMs = Math.max(event.atMs, cursorMs);
chunk = createChunk(startMs);
chunks.push(chunk);
cursorMs = startMs;
}
const safeNote = {
note: clampRoombaNote(event.note),
duration: clampRoombaDurationTicks(event.duration),
};
chunk.notes.push(safeNote);
chunk.durationMs += noteDurationMs;
chunk.sourceEndMs = chunk.startMs + chunk.durationMs;
cursorMs += noteDurationMs;
});
return chunks;
}
export async function parseMidiFile(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const parsed = parseMidi(bytes);
const ticksPerBeat = Number(parsed?.header?.ticksPerBeat || parsed?.header?.timeDivision || 0);
if (!ticksPerBeat || ticksPerBeat < 0) {
throw new Error('Only beat-based MIDI timing is supported.');
}
const tempoMap = createTempoMap(parsed);
const tracks = (parsed.tracks || []).map((track, index) => {
const extracted = extractTrackNotes(track, tempoMap, ticksPerBeat);
const channels = summarizeChannels(extracted.notes);
return {
index,
name: extracted.trackName,
label: formatTrackLabel(index, extracted.trackName, extracted.notes.length, channels),
notes: extracted.notes,
noteCount: extracted.notes.length,
channels,
};
});
const parts = buildPlayableParts(tracks);
return {
fileName: file.name,
format: parsed.header?.format,
ticksPerBeat,
tracks,
parts,
};
}