mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
midi awawa slopcoding
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"midi-file": "^1.2.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -2930,6 +2931,12 @@
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"midi-file": "^1.2.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
|
||||
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
|
||||
import VipMidiBeeperCard from '../vip/VipMidiBeeperCard/index.jsx';
|
||||
import VipVerificationCard from '../vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
|
||||
export default function VipPanel({ isActive = true }) {
|
||||
export default function VipPanel() {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const {
|
||||
identifySession,
|
||||
@@ -68,6 +69,7 @@ export default function VipPanel({ isActive = true }) {
|
||||
<div className="lg:col-span-2">
|
||||
{isVerified ? (
|
||||
<div className="space-y-2">
|
||||
<VipMidiBeeperCard />
|
||||
<VipAudioUploadCard
|
||||
ownRoverId={ownRoverId}
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user