mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
midier slope
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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/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>
|
||||
<script type="module" crossorigin src="/assets/index-BA_CXART.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DL3_eVjU.css">
|
||||
<script type="module" crossorigin src="/assets/index-DU0INlO8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user