midier slope

This commit is contained in:
legop3
2026-06-26 01:19:08 -04:00
parent e664402528
commit c2224d00e3
6 changed files with 245 additions and 43 deletions
@@ -21,6 +21,12 @@ 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;
export const DEFAULT_ARRANGER_DENSITY = 3;
// OI accepts smaller duration bytes, but this app targets audible rover music,
// not theoretical byte validity. Values below this have not been useful on the
// physical beeper, so arrangement controls clamp to the practical floor.
export const PRACTICAL_MIN_NOTE_TICKS = 4;
// Long held MIDI notes sound bad on the Roomba and block following notes. This
// cap keeps playback responsive while still preserving rough note lengths.
@@ -32,7 +38,8 @@ export const MAX_FILE_NOTE_TICKS = 48;
export const FILE_CHUNK_GAP_THRESHOLD_MS = 45;
export const PLAYBACK_MODES = [
{ id: 'mono', label: 'Monophonic' },
{ id: 'arranged', label: 'Arranged' },
{ id: 'mono', label: 'Lead line' },
{ id: 'arpeggio', label: 'Arpeggio' },
];
@@ -10,18 +10,19 @@ import {
BEEPER_READY_GUARD_MS,
DEFAULT_ARPEGGIO_NOTE_LIMIT,
DEFAULT_ARPEGGIO_NOTE_TICKS,
DEFAULT_ARRANGER_DENSITY,
DEFAULT_FILE_NOTE_TICKS,
DEFAULT_LIVE_NOTE_TICKS,
LIVE_DEVICE_EMPTY_VALUE,
MAX_FILE_NOTE_TICKS,
PLAYBACK_MODES,
PRACTICAL_MIN_NOTE_TICKS,
} from './constants.js';
import {
buildBeeperEvents,
arrangeRoombaBeeperEvents,
buildRoombaSongChunks,
clampRoombaDurationTicks,
clampRoombaNote,
collectNotesForParts,
getDefaultSelectedPartIds,
getPresetPartIds,
parseMidiFile,
@@ -40,10 +41,11 @@ export default function VipMidiBeeperCard() {
const [selectedFileName, setSelectedFileName] = useState('');
const [parsedMidi, setParsedMidi] = useState(null);
const [selectedPartIds, setSelectedPartIds] = useState([]);
const [playbackMode, setPlaybackMode] = useState('mono');
const [playbackMode, setPlaybackMode] = useState('arranged');
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 [arrangerDensity, setArrangerDensity] = useState(DEFAULT_ARRANGER_DENSITY);
const [liveNoteTicks, setLiveNoteTicks] = useState(DEFAULT_LIVE_NOTE_TICKS);
const [message, setMessage] = useState('');
const [playbackState, setPlaybackState] = useState('idle');
@@ -64,25 +66,21 @@ export default function VipMidiBeeperCard() {
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,
density: arrangerDensity,
}),
[arpeggioNoteLimit, arpeggioNoteTicks, monoMaxTicks],
[arpeggioNoteLimit, arpeggioNoteTicks, arrangerDensity, monoMaxTicks],
);
const beeperEvents = useMemo(() => {
if (!selectedPartNotes.length) return [];
return buildBeeperEvents(selectedPartNotes, playbackMode, beeperOptions);
}, [beeperOptions, playbackMode, selectedPartNotes]);
if (!playableParts.length || !selectedPartIds.length) return [];
return arrangeRoombaBeeperEvents(playableParts, selectedPartIds, playbackMode, beeperOptions);
}, [beeperOptions, playableParts, playbackMode, selectedPartIds]);
const songChunks = useMemo(() => buildRoombaSongChunks(beeperEvents), [beeperEvents]);
@@ -223,8 +221,7 @@ export default function VipMidiBeeperCard() {
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)
const events = arrangeRoombaBeeperEvents(parts, partIds, config.playbackMode, config.beeperOptions)
.filter((event) => event.atMs >= playbackCursorMsRef.current - 0.5);
const chunks = buildRoombaSongChunks(events);
return chunks[0] || null;
@@ -498,31 +495,37 @@ export default function VipMidiBeeperCard() {
<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">
<div className="grid grid-cols-2 gap-0.5 sm:grid-cols-4">
<label className="grid gap-0.5">
<span>Mono max</span>
<span>Max note</span>
<input
className={fieldClass}
type="number"
min="1"
min={PRACTICAL_MIN_NOTE_TICKS}
max="96"
value={monoMaxTicks}
onChange={(event) => setMonoMaxTicks(clampRoombaDurationTicks(Number(event.target.value) || MAX_FILE_NOTE_TICKS))}
onChange={(event) => {
const next = Math.max(PRACTICAL_MIN_NOTE_TICKS, clampRoombaDurationTicks(Number(event.target.value) || MAX_FILE_NOTE_TICKS));
setMonoMaxTicks(next);
}}
/>
</label>
<label className="grid gap-0.5">
<span>Arp len</span>
<span>Arp note</span>
<input
className={fieldClass}
type="number"
min="1"
min={PRACTICAL_MIN_NOTE_TICKS}
max="32"
value={arpeggioNoteTicks}
onChange={(event) => setArpeggioNoteTicks(clampRoombaDurationTicks(Number(event.target.value) || DEFAULT_ARPEGGIO_NOTE_TICKS))}
onChange={(event) => {
const next = Math.max(PRACTICAL_MIN_NOTE_TICKS, clampRoombaDurationTicks(Number(event.target.value) || DEFAULT_ARPEGGIO_NOTE_TICKS));
setArpeggioNoteTicks(next);
}}
/>
</label>
<label className="grid gap-0.5">
<span>Arp notes</span>
<span>Arp parts</span>
<input
className={fieldClass}
type="number"
@@ -535,6 +538,20 @@ export default function VipMidiBeeperCard() {
}}
/>
</label>
<label className="grid gap-0.5">
<span>Density</span>
<input
className={fieldClass}
type="number"
min="1"
max="5"
value={arrangerDensity}
onChange={(event) => {
const next = Math.max(1, Math.min(5, Math.round(Number(event.target.value) || DEFAULT_ARRANGER_DENSITY)));
setArrangerDensity(next);
}}
/>
</label>
</div>
</div>
@@ -5,9 +5,11 @@ import { parseMidi } from 'midi-file';
import {
DEFAULT_ARPEGGIO_NOTE_TICKS,
DEFAULT_ARPEGGIO_NOTE_LIMIT,
DEFAULT_ARRANGER_DENSITY,
DEFAULT_FILE_NOTE_TICKS,
FILE_CHUNK_GAP_THRESHOLD_MS,
MAX_FILE_NOTE_TICKS,
PRACTICAL_MIN_NOTE_TICKS,
ROOMBA_SONG_MAX_NOTES,
ROOMBA_DURATION_TICK_MS,
ROOMBA_NOTE_MAX,
@@ -28,6 +30,10 @@ export function clampRoombaDurationTicks(ticks) {
return clamp(Math.round(ticks), 1, 255);
}
function clampPracticalDurationTicks(ticks) {
return clamp(Math.round(ticks), PRACTICAL_MIN_NOTE_TICKS, 255);
}
export function roombaTicksToMs(ticks) {
return clampRoombaDurationTicks(ticks) * ROOMBA_DURATION_TICK_MS;
}
@@ -222,7 +228,11 @@ function buildPlayableParts(tracks = []) {
maxNote: channelInfo.maxNote,
averageNote: channelInfo.averageNote,
isPercussion: Boolean(channelInfo.isPercussion),
notes,
role: inferPartRole(channelInfo),
notes: notes.map((note) => ({
...note,
partId: `${track.index}:${channelInfo.channel}`,
})),
});
});
});
@@ -233,6 +243,21 @@ function buildPlayableParts(tracks = []) {
});
}
function inferPartRole(channelInfo) {
if (channelInfo?.isPercussion) return 'drums';
const average = Number(channelInfo?.averageNote) || 0;
const max = Number(channelInfo?.maxNote) || 0;
/*
MIDI files do not reliably tell us instrument intent, so this is deliberately
heuristic. Low average pitch behaves well as bass, high maximum pitch often
carries melody, and everything else becomes harmony/fill material.
*/
if (average > 0 && average < 52) return 'bass';
if (max >= 72 || average >= 64) return 'melody';
return 'harmony';
}
export function getDefaultSelectedPartIds(parts = []) {
const playable = Array.isArray(parts) ? parts : [];
const pitchedParts = playable.filter((part) => !part.isPercussion);
@@ -298,10 +323,25 @@ function groupNotesByStart(notes = []) {
return groups;
}
function groupNotesForArrangement(notes = [], toleranceMs = 24) {
const groups = [];
let current = null;
notes.forEach((note) => {
if (!current || Math.abs(note.startMs - current.startMs) > toleranceMs) {
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));
const cap = clampPracticalDurationTicks(maxTicks || MAX_FILE_NOTE_TICKS);
return clampPracticalDurationTicks(Math.min(cap, naturalTicks || fallbackTicks));
}
export function filterNotesByChannels(notes = [], selectedChannels = []) {
@@ -313,10 +353,10 @@ export function filterNotesByChannels(notes = [], selectedChannels = []) {
export function buildBeeperEvents(notes = [], mode = 'mono', options = {}) {
const groups = groupNotesByStart(notes);
const events = [];
const arpeggioTicks = clampRoombaDurationTicks(options.arpeggioTicks || DEFAULT_ARPEGGIO_NOTE_TICKS);
const arpeggioTicks = clampPracticalDurationTicks(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);
const monoFallbackTicks = clampPracticalDurationTicks(options.monoFallbackTicks || DEFAULT_FILE_NOTE_TICKS);
const monoMaxTicks = clampPracticalDurationTicks(options.monoMaxTicks || MAX_FILE_NOTE_TICKS);
groups.forEach((group) => {
const playableNotes = [...group.notes].sort((a, b) => {
@@ -352,6 +392,144 @@ export function buildBeeperEvents(notes = [], mode = 'mono', options = {}) {
return events.sort((a, b) => a.atMs - b.atMs || b.note - a.note);
}
function sortBestLeadNotes(a, b) {
return b.velocity - a.velocity || b.noteNumber - a.noteNumber;
}
function sortBestBassNotes(a, b) {
return b.velocity - a.velocity || a.noteNumber - b.noteNumber;
}
function chooseBestNote(notes = [], role = 'melody') {
if (!notes.length) return null;
const sorted = [...notes].sort(role === 'bass' ? sortBestBassNotes : sortBestLeadNotes);
return sorted[0] || null;
}
function selectedPartsById(parts = [], selectedPartIds = []) {
const selected = new Set((selectedPartIds || []).map((id) => String(id)));
return (parts || []).filter((part) => selected.has(String(part.id)));
}
function makeEvent(note, atMs, durationTicks) {
return {
atMs,
note: clampRoombaNote(note.noteNumber),
duration: clampPracticalDurationTicks(durationTicks),
partId: note.partId || '',
};
}
function suppressNoisyRepeats(events = [], minGapMs) {
const lastByNote = new Map();
return events.filter((event) => {
const key = event.note;
const lastAt = lastByNote.get(key);
if (typeof lastAt === 'number' && event.atMs - lastAt < minGapMs) {
return false;
}
lastByNote.set(key, event.atMs);
return true;
});
}
export function arrangeRoombaBeeperEvents(parts = [], selectedPartIds = [], mode = 'arranged', options = {}) {
const selectedParts = selectedPartsById(parts, selectedPartIds).filter((part) => !part.isPercussion);
const fallbackParts = selectedParts.length ? selectedParts : selectedPartsById(parts, selectedPartIds);
const notes = fallbackParts
.flatMap((part) => part.notes || [])
.sort((a, b) => a.startMs - b.startMs || b.velocity - a.velocity || b.noteNumber - a.noteNumber);
if (!notes.length) return [];
const density = clamp(Math.round(options.density || DEFAULT_ARRANGER_DENSITY), 1, 5);
const monoFallbackTicks = clampPracticalDurationTicks(options.monoFallbackTicks || DEFAULT_FILE_NOTE_TICKS);
const monoMaxTicks = clampPracticalDurationTicks(options.monoMaxTicks || MAX_FILE_NOTE_TICKS);
const arpeggioTicks = clampPracticalDurationTicks(options.arpeggioTicks || DEFAULT_ARPEGGIO_NOTE_TICKS);
const arpeggioLimit = clamp(Math.round(options.arpeggioLimit || DEFAULT_ARPEGGIO_NOTE_LIMIT), 1, 16);
const groups = groupNotesForArrangement(notes, density >= 4 ? 36 : 24);
const partCountCap = Math.max(1, Math.min(arpeggioLimit, fallbackParts.length || arpeggioLimit));
const events = [];
let lastBassAt = -Infinity;
let groupIndex = 0;
groups.forEach((group) => {
const byPart = new Map();
group.notes.forEach((note) => {
const partId = String(note.partId || '');
if (!partId) return;
if (!byPart.has(partId)) byPart.set(partId, []);
byPart.get(partId).push(note);
});
if (mode === 'arpeggio') {
const onePerPart = Array.from(byPart.entries())
.map(([partId, partNotes]) => {
const part = fallbackParts.find((entry) => String(entry.id) === partId);
return {
part,
note: chooseBestNote(partNotes, part?.role === 'bass' ? 'bass' : 'melody'),
};
})
.filter((entry) => entry.note)
.sort((a, b) => {
const roleRank = { bass: 0, harmony: 1, melody: 2, drums: 3 };
return (roleRank[a.part?.role] ?? 2) - (roleRank[b.part?.role] ?? 2) || b.note.velocity - a.note.velocity;
});
onePerPart.slice(0, partCountCap).forEach((entry, index) => {
events.push(makeEvent(entry.note, group.startMs + index * roombaTicksToMs(arpeggioTicks), arpeggioTicks));
});
return;
}
const bassNotes = [];
const melodyNotes = [];
const harmonyNotes = [];
group.notes.forEach((note) => {
const part = fallbackParts.find((entry) => String(entry.id) === String(note.partId || ''));
if (part?.role === 'bass') {
bassNotes.push(note);
} else if (part?.role === 'harmony') {
harmonyNotes.push(note);
} else {
melodyNotes.push(note);
}
});
const leadNote = chooseBestNote(melodyNotes.length ? melodyNotes : group.notes, 'melody');
if (leadNote) {
events.push(makeEvent(leadNote, group.startMs, noteDurationToRoombaTicks(leadNote, monoFallbackTicks, monoMaxTicks)));
}
if (mode === 'arranged') {
const bassSpacingMs = density >= 4 ? 320 : density >= 3 ? 460 : 700;
const shouldAddBass = bassNotes.length && group.startMs - lastBassAt >= bassSpacingMs;
if (shouldAddBass) {
const bassNote = chooseBestNote(bassNotes, 'bass');
if (bassNote) {
events.push(makeEvent(bassNote, group.startMs + roombaTicksToMs(arpeggioTicks), Math.max(arpeggioTicks, 6)));
lastBassAt = group.startMs;
}
}
const shouldAddHarmony = density >= 4 || (density >= 3 && groupIndex % 2 === 0);
if (shouldAddHarmony && harmonyNotes.length) {
const harmonyNote = chooseBestNote(harmonyNotes, 'melody');
if (harmonyNote) {
events.push(makeEvent(harmonyNote, group.startMs + roombaTicksToMs(arpeggioTicks) * 2, arpeggioTicks));
}
}
}
groupIndex += 1;
});
const repeatGapMs = density >= 4 ? 55 : density >= 3 ? 80 : 120;
return suppressNoisyRepeats(events, repeatGapMs).sort((a, b) => a.atMs - b.atMs || b.note - a.note);
}
function createChunk(startMs) {
return {
startMs,