mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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/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-BA_CXART.js"></script>
|
<script type="module" crossorigin src="/assets/index-DU0INlO8.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DL3_eVjU.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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_FILE_NOTE_TICKS = 10;
|
||||||
export const DEFAULT_ARPEGGIO_NOTE_TICKS = 4;
|
export const DEFAULT_ARPEGGIO_NOTE_TICKS = 4;
|
||||||
export const DEFAULT_ARPEGGIO_NOTE_LIMIT = 6;
|
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
|
// Long held MIDI notes sound bad on the Roomba and block following notes. This
|
||||||
// cap keeps playback responsive while still preserving rough note lengths.
|
// 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 FILE_CHUNK_GAP_THRESHOLD_MS = 45;
|
||||||
|
|
||||||
export const PLAYBACK_MODES = [
|
export const PLAYBACK_MODES = [
|
||||||
{ id: 'mono', label: 'Monophonic' },
|
{ id: 'arranged', label: 'Arranged' },
|
||||||
|
{ id: 'mono', label: 'Lead line' },
|
||||||
{ id: 'arpeggio', label: 'Arpeggio' },
|
{ id: 'arpeggio', label: 'Arpeggio' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ import {
|
|||||||
BEEPER_READY_GUARD_MS,
|
BEEPER_READY_GUARD_MS,
|
||||||
DEFAULT_ARPEGGIO_NOTE_LIMIT,
|
DEFAULT_ARPEGGIO_NOTE_LIMIT,
|
||||||
DEFAULT_ARPEGGIO_NOTE_TICKS,
|
DEFAULT_ARPEGGIO_NOTE_TICKS,
|
||||||
|
DEFAULT_ARRANGER_DENSITY,
|
||||||
DEFAULT_FILE_NOTE_TICKS,
|
DEFAULT_FILE_NOTE_TICKS,
|
||||||
DEFAULT_LIVE_NOTE_TICKS,
|
DEFAULT_LIVE_NOTE_TICKS,
|
||||||
LIVE_DEVICE_EMPTY_VALUE,
|
LIVE_DEVICE_EMPTY_VALUE,
|
||||||
MAX_FILE_NOTE_TICKS,
|
MAX_FILE_NOTE_TICKS,
|
||||||
PLAYBACK_MODES,
|
PLAYBACK_MODES,
|
||||||
|
PRACTICAL_MIN_NOTE_TICKS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import {
|
import {
|
||||||
buildBeeperEvents,
|
arrangeRoombaBeeperEvents,
|
||||||
buildRoombaSongChunks,
|
buildRoombaSongChunks,
|
||||||
clampRoombaDurationTicks,
|
clampRoombaDurationTicks,
|
||||||
clampRoombaNote,
|
clampRoombaNote,
|
||||||
collectNotesForParts,
|
|
||||||
getDefaultSelectedPartIds,
|
getDefaultSelectedPartIds,
|
||||||
getPresetPartIds,
|
getPresetPartIds,
|
||||||
parseMidiFile,
|
parseMidiFile,
|
||||||
@@ -40,10 +41,11 @@ export default function VipMidiBeeperCard() {
|
|||||||
const [selectedFileName, setSelectedFileName] = useState('');
|
const [selectedFileName, setSelectedFileName] = useState('');
|
||||||
const [parsedMidi, setParsedMidi] = useState(null);
|
const [parsedMidi, setParsedMidi] = useState(null);
|
||||||
const [selectedPartIds, setSelectedPartIds] = useState([]);
|
const [selectedPartIds, setSelectedPartIds] = useState([]);
|
||||||
const [playbackMode, setPlaybackMode] = useState('mono');
|
const [playbackMode, setPlaybackMode] = useState('arranged');
|
||||||
const [monoMaxTicks, setMonoMaxTicks] = useState(MAX_FILE_NOTE_TICKS);
|
const [monoMaxTicks, setMonoMaxTicks] = useState(MAX_FILE_NOTE_TICKS);
|
||||||
const [arpeggioNoteTicks, setArpeggioNoteTicks] = useState(DEFAULT_ARPEGGIO_NOTE_TICKS);
|
const [arpeggioNoteTicks, setArpeggioNoteTicks] = useState(DEFAULT_ARPEGGIO_NOTE_TICKS);
|
||||||
const [arpeggioNoteLimit, setArpeggioNoteLimit] = useState(DEFAULT_ARPEGGIO_NOTE_LIMIT);
|
const [arpeggioNoteLimit, setArpeggioNoteLimit] = useState(DEFAULT_ARPEGGIO_NOTE_LIMIT);
|
||||||
|
const [arrangerDensity, setArrangerDensity] = useState(DEFAULT_ARRANGER_DENSITY);
|
||||||
const [liveNoteTicks, setLiveNoteTicks] = useState(DEFAULT_LIVE_NOTE_TICKS);
|
const [liveNoteTicks, setLiveNoteTicks] = useState(DEFAULT_LIVE_NOTE_TICKS);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [playbackState, setPlaybackState] = useState('idle');
|
const [playbackState, setPlaybackState] = useState('idle');
|
||||||
@@ -64,25 +66,21 @@ export default function VipMidiBeeperCard() {
|
|||||||
|
|
||||||
const playableParts = useMemo(() => parsedMidi?.parts || [], [parsedMidi]);
|
const playableParts = useMemo(() => parsedMidi?.parts || [], [parsedMidi]);
|
||||||
|
|
||||||
const selectedPartNotes = useMemo(
|
|
||||||
() => collectNotesForParts(playableParts, selectedPartIds),
|
|
||||||
[playableParts, selectedPartIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const beeperOptions = useMemo(
|
const beeperOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
monoFallbackTicks: DEFAULT_FILE_NOTE_TICKS,
|
monoFallbackTicks: DEFAULT_FILE_NOTE_TICKS,
|
||||||
monoMaxTicks,
|
monoMaxTicks,
|
||||||
arpeggioTicks: arpeggioNoteTicks,
|
arpeggioTicks: arpeggioNoteTicks,
|
||||||
arpeggioLimit: arpeggioNoteLimit,
|
arpeggioLimit: arpeggioNoteLimit,
|
||||||
|
density: arrangerDensity,
|
||||||
}),
|
}),
|
||||||
[arpeggioNoteLimit, arpeggioNoteTicks, monoMaxTicks],
|
[arpeggioNoteLimit, arpeggioNoteTicks, arrangerDensity, monoMaxTicks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const beeperEvents = useMemo(() => {
|
const beeperEvents = useMemo(() => {
|
||||||
if (!selectedPartNotes.length) return [];
|
if (!playableParts.length || !selectedPartIds.length) return [];
|
||||||
return buildBeeperEvents(selectedPartNotes, playbackMode, beeperOptions);
|
return arrangeRoombaBeeperEvents(playableParts, selectedPartIds, playbackMode, beeperOptions);
|
||||||
}, [beeperOptions, playbackMode, selectedPartNotes]);
|
}, [beeperOptions, playableParts, playbackMode, selectedPartIds]);
|
||||||
|
|
||||||
const songChunks = useMemo(() => buildRoombaSongChunks(beeperEvents), [beeperEvents]);
|
const songChunks = useMemo(() => buildRoombaSongChunks(beeperEvents), [beeperEvents]);
|
||||||
|
|
||||||
@@ -223,8 +221,7 @@ export default function VipMidiBeeperCard() {
|
|||||||
const partIds = Array.isArray(config.selectedPartIds) ? config.selectedPartIds : [];
|
const partIds = Array.isArray(config.selectedPartIds) ? config.selectedPartIds : [];
|
||||||
if (!parts.length || partIds.length === 0) return null;
|
if (!parts.length || partIds.length === 0) return null;
|
||||||
|
|
||||||
const notes = collectNotesForParts(parts, partIds);
|
const events = arrangeRoombaBeeperEvents(parts, partIds, config.playbackMode, config.beeperOptions)
|
||||||
const events = buildBeeperEvents(notes, config.playbackMode, config.beeperOptions)
|
|
||||||
.filter((event) => event.atMs >= playbackCursorMsRef.current - 0.5);
|
.filter((event) => event.atMs >= playbackCursorMsRef.current - 0.5);
|
||||||
const chunks = buildRoombaSongChunks(events);
|
const chunks = buildRoombaSongChunks(events);
|
||||||
return chunks[0] || null;
|
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">
|
<div className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
|
||||||
<span>Playback shape</span>
|
<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">
|
<label className="grid gap-0.5">
|
||||||
<span>Mono max</span>
|
<span>Max note</span>
|
||||||
<input
|
<input
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min={PRACTICAL_MIN_NOTE_TICKS}
|
||||||
max="96"
|
max="96"
|
||||||
value={monoMaxTicks}
|
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>
|
||||||
<label className="grid gap-0.5">
|
<label className="grid gap-0.5">
|
||||||
<span>Arp len</span>
|
<span>Arp note</span>
|
||||||
<input
|
<input
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min={PRACTICAL_MIN_NOTE_TICKS}
|
||||||
max="32"
|
max="32"
|
||||||
value={arpeggioNoteTicks}
|
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>
|
||||||
<label className="grid gap-0.5">
|
<label className="grid gap-0.5">
|
||||||
<span>Arp notes</span>
|
<span>Arp parts</span>
|
||||||
<input
|
<input
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -535,6 +538,20 @@ export default function VipMidiBeeperCard() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { parseMidi } from 'midi-file';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_ARPEGGIO_NOTE_TICKS,
|
DEFAULT_ARPEGGIO_NOTE_TICKS,
|
||||||
DEFAULT_ARPEGGIO_NOTE_LIMIT,
|
DEFAULT_ARPEGGIO_NOTE_LIMIT,
|
||||||
|
DEFAULT_ARRANGER_DENSITY,
|
||||||
DEFAULT_FILE_NOTE_TICKS,
|
DEFAULT_FILE_NOTE_TICKS,
|
||||||
FILE_CHUNK_GAP_THRESHOLD_MS,
|
FILE_CHUNK_GAP_THRESHOLD_MS,
|
||||||
MAX_FILE_NOTE_TICKS,
|
MAX_FILE_NOTE_TICKS,
|
||||||
|
PRACTICAL_MIN_NOTE_TICKS,
|
||||||
ROOMBA_SONG_MAX_NOTES,
|
ROOMBA_SONG_MAX_NOTES,
|
||||||
ROOMBA_DURATION_TICK_MS,
|
ROOMBA_DURATION_TICK_MS,
|
||||||
ROOMBA_NOTE_MAX,
|
ROOMBA_NOTE_MAX,
|
||||||
@@ -28,6 +30,10 @@ export function clampRoombaDurationTicks(ticks) {
|
|||||||
return clamp(Math.round(ticks), 1, 255);
|
return clamp(Math.round(ticks), 1, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampPracticalDurationTicks(ticks) {
|
||||||
|
return clamp(Math.round(ticks), PRACTICAL_MIN_NOTE_TICKS, 255);
|
||||||
|
}
|
||||||
|
|
||||||
export function roombaTicksToMs(ticks) {
|
export function roombaTicksToMs(ticks) {
|
||||||
return clampRoombaDurationTicks(ticks) * ROOMBA_DURATION_TICK_MS;
|
return clampRoombaDurationTicks(ticks) * ROOMBA_DURATION_TICK_MS;
|
||||||
}
|
}
|
||||||
@@ -222,7 +228,11 @@ function buildPlayableParts(tracks = []) {
|
|||||||
maxNote: channelInfo.maxNote,
|
maxNote: channelInfo.maxNote,
|
||||||
averageNote: channelInfo.averageNote,
|
averageNote: channelInfo.averageNote,
|
||||||
isPercussion: Boolean(channelInfo.isPercussion),
|
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 = []) {
|
export function getDefaultSelectedPartIds(parts = []) {
|
||||||
const playable = Array.isArray(parts) ? parts : [];
|
const playable = Array.isArray(parts) ? parts : [];
|
||||||
const pitchedParts = playable.filter((part) => !part.isPercussion);
|
const pitchedParts = playable.filter((part) => !part.isPercussion);
|
||||||
@@ -298,10 +323,25 @@ function groupNotesByStart(notes = []) {
|
|||||||
return groups;
|
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) {
|
function noteDurationToRoombaTicks(note, fallbackTicks, maxTicks) {
|
||||||
const naturalTicks = note.durationMs / ROOMBA_DURATION_TICK_MS;
|
const naturalTicks = note.durationMs / ROOMBA_DURATION_TICK_MS;
|
||||||
const cap = clampRoombaDurationTicks(maxTicks || MAX_FILE_NOTE_TICKS);
|
const cap = clampPracticalDurationTicks(maxTicks || MAX_FILE_NOTE_TICKS);
|
||||||
return clampRoombaDurationTicks(Math.min(cap, naturalTicks || fallbackTicks));
|
return clampPracticalDurationTicks(Math.min(cap, naturalTicks || fallbackTicks));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterNotesByChannels(notes = [], selectedChannels = []) {
|
export function filterNotesByChannels(notes = [], selectedChannels = []) {
|
||||||
@@ -313,10 +353,10 @@ export function filterNotesByChannels(notes = [], selectedChannels = []) {
|
|||||||
export function buildBeeperEvents(notes = [], mode = 'mono', options = {}) {
|
export function buildBeeperEvents(notes = [], mode = 'mono', options = {}) {
|
||||||
const groups = groupNotesByStart(notes);
|
const groups = groupNotesByStart(notes);
|
||||||
const events = [];
|
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 arpeggioLimit = clamp(Math.round(options.arpeggioLimit || DEFAULT_ARPEGGIO_NOTE_LIMIT), 1, 16);
|
||||||
const monoFallbackTicks = clampRoombaDurationTicks(options.monoFallbackTicks || DEFAULT_FILE_NOTE_TICKS);
|
const monoFallbackTicks = clampPracticalDurationTicks(options.monoFallbackTicks || DEFAULT_FILE_NOTE_TICKS);
|
||||||
const monoMaxTicks = clampRoombaDurationTicks(options.monoMaxTicks || MAX_FILE_NOTE_TICKS);
|
const monoMaxTicks = clampPracticalDurationTicks(options.monoMaxTicks || MAX_FILE_NOTE_TICKS);
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
const playableNotes = [...group.notes].sort((a, b) => {
|
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);
|
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) {
|
function createChunk(startMs) {
|
||||||
return {
|
return {
|
||||||
startMs,
|
startMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user