diff --git a/system/public/aesthetic.computer/disks/clock.mjs b/system/public/aesthetic.computer/disks/clock.mjs index 6906dd4a94..e14dd1a05a 100644 --- a/system/public/aesthetic.computer/disks/clock.mjs +++ b/system/public/aesthetic.computer/disks/clock.mjs @@ -143,6 +143,7 @@ import { } from "../lib/melody-parser.mjs"; import { convertNotepatNotation } from "../lib/notepat-convert.mjs"; import { getNoteColor } from "../lib/note-colors.mjs"; +import { buildColoredMelodyString, computeCurrentNoteIndex } from "../lib/melody-highlighter.mjs"; import { loadPaintingAsAudio } from "../lib/pixel-sample.mjs"; // Utility function for padding numbers with zeros @@ -587,24 +588,10 @@ let speakRef = null; let numRef = null; // Helper function to get current note index for synchronization -function getCurrentNoteIndex(melodyState, trackIndex = 0) { - if (!hasFirstSynthFired || !melodyState) { - return -1; // No note is currently playing - } - - if (melodyState.type === "single" && melodyState.notes) { - const totalNotes = melodyState.notes.length; - return (melodyState.index - 1 + totalNotes) % totalNotes; - } else if (melodyState.type === "parallel" && melodyState.trackStates) { - const trackState = melodyState.trackStates[trackIndex]; - if (trackState && trackState.track) { - const totalNotes = trackState.track.length; - return (trackState.noteIndex - 1 + totalNotes) % totalNotes; - } - } - - return -1; -} +function getCurrentNoteIndex(melodyState, trackIndex = 0) { + // Delegate to the shared pure helper (gated on clock's hasFirstSynthFired). + return computeCurrentNoteIndex(melodyState, trackIndex, hasFirstSynthFired); +} // Function to add a floating Hz display function addFloatingHzDisplay(frequency, noteString, trackIndex = 0, screen) { @@ -1483,860 +1470,34 @@ function paint({ } // Unified: Build colored melody string for both timeline and HUD label - function buildColoredMelodyStringUnified(melodyString, melodyState) { - // This is the same logic as in drawMelodyTimeline, but returns the colored string - if (!melodyString) return ""; - let coloredMelodyString = ""; - let noteCharPositions = []; - let charIndex = 0; - let noteIndex = 0; - let inWaveform = false; - let currentTrackIndex = 0; - let noteIndexInCurrentTrack = 0; - let now = performance.now(); - let currentNoteIndex = 0; - - // Check if timing has actually started before applying current note highlighting - const timingHasStarted = hasFirstSynthFired; - - // For sequential melodies, get the effective melodyState (currentSequenceState) - let effectiveMelodyState = melodyState; - let currentSequenceIndex = 0; // Default to first sequence - let isSequentialMelody = melodyState && melodyState.type === "sequential"; - - if (isSequentialMelody) { - // Use currentSequence if set, otherwise default to 0 - currentSequenceIndex = melodyState.currentSequence ?? 0; - if (melodyState.currentSequenceState) { - effectiveMelodyState = melodyState.currentSequenceState; - } else if (melodyState.sequences && melodyState.sequences.length > 0) { - // Fall back to first sequence's parsed state - effectiveMelodyState = melodyState.sequences[0].parsed; - } - } - - // Use the unified function to get current note index - if (timingHasStarted && effectiveMelodyState && effectiveMelodyState.type === "single") { - currentNoteIndex = getCurrentNoteIndex(effectiveMelodyState); - } else if ( - timingHasStarted && - effectiveMelodyState && - effectiveMelodyState.type === "parallel" - ) { - // For parallel, handled below per track - } else { - // If timing hasn't started, set currentNoteIndex to -1 so no note is highlighted - currentNoteIndex = -1; - } - - // For sequential melodies, split by > first to identify sequences - let sequences = []; - let sequenceDelimiterPositions = []; // Track positions of > in the string - - if (isSequentialMelody) { - // Find all > positions and split into sequences - let lastSplitPos = 0; - for (let i = 0; i < melodyString.length; i++) { - if (melodyString[i] === '>') { - sequences.push(melodyString.substring(lastSplitPos, i).trim()); - sequenceDelimiterPositions.push(i); - lastSplitPos = i + 1; - } - } - sequences.push(melodyString.substring(lastSplitPos).trim()); - } - - // Split by spaces to get groups, then process each group - const groups = melodyString.trim().split(/\s+/); - let globalCharIndex = 0; - let groupStartPositions = []; - - // Calculate the starting position of each group in the original string - let searchStart = 0; - for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { - const group = groups[groupIdx]; - const groupStart = melodyString.indexOf(group, searchStart); - groupStartPositions.push(groupStart); - searchStart = groupStart + group.length; - } - - // For sequential melodies, we need to map groups to tracks within the current sequence - // Filter out sequence delimiter groups (just ">", "2>", "3>", etc.) - let effectiveGroupToTrackMap = []; // Maps group index to { sequenceIndex, trackIndex } - let currentSeqIdx = 0; - let currentTrackInSeq = 0; - - if (isSequentialMelody) { - for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { - const group = groups[groupIdx]; - // Check if this is a sequence delimiter (>, 2>, 3>, etc.) - if (/^\d*>$/.test(group)) { - // This is a delimiter, advance sequence - currentSeqIdx++; - currentTrackInSeq = 0; - effectiveGroupToTrackMap.push({ isDelimiter: true, sequenceIndex: currentSeqIdx - 1 }); - } else { - // This is a track in the current sequence - effectiveGroupToTrackMap.push({ - isDelimiter: false, - sequenceIndex: currentSeqIdx, - trackIndex: currentTrackInSeq, - isCurrentSequence: currentSeqIdx === currentSequenceIndex - }); - currentTrackInSeq++; - } - } - } - - // First pass: identify all note character positions and their duration modifiers - for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { - const group = groups[groupIdx]; - const groupStartChar = groupStartPositions[groupIdx]; - noteIndex = 0; // Reset note index for each group - - // For sequential melodies, get the track index mapping - let trackIdxForColoring = groupIdx; // Default for non-sequential - let isInCurrentSequence = true; - - if (isSequentialMelody && effectiveGroupToTrackMap[groupIdx]) { - const mapping = effectiveGroupToTrackMap[groupIdx]; - if (mapping.isDelimiter) { - // Skip delimiter groups - they have no notes - continue; - } - trackIdxForColoring = mapping.trackIndex; - isInCurrentSequence = mapping.isCurrentSequence; - } - - for (let i = 0; i < group.length; i++) { - const char = group[i]; - const absoluteCharIndex = groupStartChar + i; - - if (char === "{") { - inWaveform = true; - continue; - } else if (char === "}") { - inWaveform = false; - continue; - } else if (inWaveform) { - continue; - } - - // Handle octave-first notation (5f, 4c#, etc.) - octave number should be part of the note unit - // Also handle notepat characters after octave numbers - if ( - /[0-9]/.test(char) && - i + 1 < group.length && - /[a-gvswrqhijklmntyuop]/i.test(group[i + 1]) - ) { - let noteStart = i; // Start with the octave number - let noteEnd = i + 1; // Move to the note letter - - // Check for sharp modifiers - if (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if (nextChar === "#") { - noteEnd++; - } - } - - // Check for duration modifiers and asterisks - while (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if ( - nextChar === "." || - nextChar === "-" || - nextChar === "[" || - nextChar === "]" || - nextChar === "," || - nextChar === "*" - ) { - noteEnd++; - } else { - break; - } - } - - const noteIndexToUse = noteIndex; - // For sequential melodies, use the mapped track index; for parallel, use group index; for single, use 0 - const trackIndexToUse = isSequentialMelody ? trackIdxForColoring : - (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); - for (let j = noteStart; j <= noteEnd; j++) { - noteCharPositions.push({ - charIndex: groupStartChar + j, - noteIndex: noteIndexToUse, - trackIndex: trackIndexToUse, - isInCurrentSequence: isInCurrentSequence, - }); - } - noteIndex++; - i = noteEnd; - } - // Handle struck mode toggle (^) - NOT a timing unit, just skip it - else if (char === "^") { - // Skip ^ characters, they don't count as notes or timing units - continue; - } - // Handle standalone dashes (-) - NOT a timing unit for syntax highlighting - // (Only - with a note like -c counts, standalone --- doesn't) - else if (char === "-") { - // Check if this is a series of dashes that leads to a note - let dashEnd = i; - while (dashEnd + 1 < group.length && group[dashEnd + 1] === "-") { - dashEnd++; - } - // Check if followed by a note letter (relative octave modifier like ---c) - if ( - dashEnd + 1 < group.length && - /[a-gvswrqhijklmntyuop]/i.test(group[dashEnd + 1]) - ) { - // This is a relative octave modifier, treat as part of a note - let noteStart = i; - let noteEnd = dashEnd + 1; - - // Check for sharp modifiers - if (noteEnd + 1 < group.length && group[noteEnd + 1] === "#") { - noteEnd++; - } - - // Check for duration modifiers - while (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if (nextChar === "." || nextChar === "," || nextChar === "*" || - nextChar === "[" || nextChar === "]") { - noteEnd++; - } else { - break; - } - } - - const noteIndexToUse = noteIndex; - const trackIndexToUse = isSequentialMelody ? trackIdxForColoring : - (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); - for (let j = noteStart; j <= noteEnd; j++) { - noteCharPositions.push({ - charIndex: groupStartChar + j, - noteIndex: noteIndexToUse, - trackIndex: trackIndexToUse, - isInCurrentSequence: isInCurrentSequence, - }); - } - noteIndex++; - i = noteEnd; - } else { - // Standalone dashes - skip them, they're not timing units - i = dashEnd; - continue; - } - } - // Handle + relative octave modifier (must be followed by a note) - else if (char === "+") { - let plusEnd = i; - while (plusEnd + 1 < group.length && group[plusEnd + 1] === "+") { - plusEnd++; - } - // Check if followed by a note letter - if ( - plusEnd + 1 < group.length && - /[a-gvswrqhijklmntyuop]/i.test(group[plusEnd + 1]) - ) { - // This is a relative octave modifier, treat as part of a note - let noteStart = i; - let noteEnd = plusEnd + 1; - - // Check for sharp modifiers - if (noteEnd + 1 < group.length && group[noteEnd + 1] === "#") { - noteEnd++; - } - - // Check for duration modifiers - while (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if (nextChar === "." || nextChar === "," || nextChar === "*" || - nextChar === "[" || nextChar === "]") { - noteEnd++; - } else { - break; - } - } - - const noteIndexToUse = noteIndex; - const trackIndexToUse = isSequentialMelody ? trackIdxForColoring : - (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); - for (let j = noteStart; j <= noteEnd; j++) { - noteCharPositions.push({ - charIndex: groupStartChar + j, - noteIndex: noteIndexToUse, - trackIndex: trackIndexToUse, - isInCurrentSequence: isInCurrentSequence, - }); - } - noteIndex++; - i = noteEnd; - } else { - // Standalone + without note - skip - i = plusEnd; - continue; - } - } - // Handle regular note letters (without octave prefix) - // Also include notepat characters: vswrq (sharps), hijklmn (white notes), tyuop (sharps in higher octave) - else if (/[a-g#_vswrqhijklmntyuop]/i.test(char)) { - let noteStart = i; - let noteEnd = i; - - // Check for sharp modifiers (for regular notes or after relative modifiers) - if (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if (nextChar === "#") { - noteEnd++; - } - } - - // Check for duration modifiers and asterisks - while (noteEnd + 1 < group.length) { - const nextChar = group[noteEnd + 1]; - if ( - nextChar === "." || - nextChar === "-" || - nextChar === "[" || - nextChar === "]" || - nextChar === "," || - nextChar === "*" - ) { - noteEnd++; - } else { - break; - } - } - - const noteIndexToUse = noteIndex; - // For sequential melodies, use the mapped track index; for parallel, use group index; for single, use 0 - const trackIndexToUse = isSequentialMelody ? trackIdxForColoring : - (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); - for (let j = noteStart; j <= noteEnd; j++) { - noteCharPositions.push({ - charIndex: groupStartChar + j, - noteIndex: noteIndexToUse, - trackIndex: trackIndexToUse, - isInCurrentSequence: isInCurrentSequence, - }); - } - noteIndex++; - i = noteEnd; - } - } - } - // Color the original melody string character by character - let inWaveformForColoring = false; - - // For fade logic - red at start, fading to orange as note ends - function getRedNoteColor() { - // Always return red for currently playing notes - no orange fade - return "red"; - } - - // Helper function to get note color for static display (before timing) - function getStaticNoteColor(noteCharData) { - if (!noteCharData) { - return "gray"; - } - - // Use effectiveMelodyState for sequential melodies - const stateToUse = effectiveMelodyState || melodyState; - if (!stateToUse) return "gray"; - - let note = null; - if (stateToUse.type === "single" && stateToUse.notes) { - note = stateToUse.notes[noteCharData.noteIndex]; - } else if (stateToUse.type === "parallel" && stateToUse.tracks) { - const track = stateToUse.tracks[noteCharData.trackIndex]; - note = track && track[noteCharData.noteIndex]; - } - - if (!note) { - return "gray"; - } - - // Get the RGB color for this note - const rgb = getNoteColor(note.note); - const colorString = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; - return colorString; - } - - // Helper function to check if a note is mutated - function isNoteMutated(noteCharData) { - if (!noteCharData) return false; - - // Use effectiveMelodyState for sequential melodies - const stateToUse = effectiveMelodyState || melodyState; - if (!stateToUse) return false; - - if (stateToUse.type === "single") { - const currentNote = stateToUse.notes && stateToUse.notes[noteCharData.noteIndex]; - return currentNote && currentNote.isMutation; - } else if ( - stateToUse.type === "parallel" && - stateToUse.trackStates && - noteCharData.trackIndex < stateToUse.trackStates.length - ) { - const track = stateToUse.tracks[noteCharData.trackIndex]; - const currentNote = track && track[noteCharData.noteIndex]; - return currentNote && currentNote.isMutation; - } - - return false; - } - - // Get color for mutated notes (goldenrod for non-active mutations, red-orange fade for active) - function getMutatedNoteColor( - isCurrentlyPlaying = false, - noteCharData = null, - ) { - // Check if this is the specific note that was just mutated (rainbow flash) - const isRecentlyMutatedNote = - noteCharData && - noteCharData.noteIndex === recentlyMutatedNoteIndex && - noteCharData.trackIndex === recentlyMutatedTrackIndex; - - // Check if this note should flash rainbow during mutation - const shouldFlashForThisNote = - shouldFlashMutation && isRecentlyMutatedNote; - - if (shouldFlashForThisNote) { - // Rainbow flash effect - same as asterisk - const time = now * 0.005; // Slow down the cycle - const hue = (time % 1) * 360; // 0-360 degrees - - // Convert HSV to RGB for rainbow effect - function hsvToRgb(h, s, v) { - const c = v * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = v - c; - let r, g, b; - - if (h >= 0 && h < 60) { - r = c; - g = x; - b = 0; - } else if (h >= 60 && h < 120) { - r = x; - g = c; - b = 0; - } else if (h >= 120 && h < 180) { - r = 0; - g = c; - b = x; - } else if (h >= 180 && h < 240) { - r = 0; - g = x; - b = c; - } else if (h >= 240 && h < 300) { - r = x; - g = 0; - b = c; - } else { - r = c; - g = 0; - b = x; - } - - return [ - Math.round((r + m) * 255), - Math.round((g + m) * 255), - Math.round((b + m) * 255), - ]; - } - - return hsvToRgb(hue, 1, 1); // Full saturation and brightness for vivid rainbow - } - - if (isCurrentlyPlaying) { - // When actively playing, mutated notes still get the red-to-orange fade - return getRedNoteColor(); - } else { - // When not playing, mutated notes stay goldenrod - return "goldenrod"; - } - } - const flashDuration = 200; - const mutationFlashDuration = 300; // Longer flash for mutations - const shouldFlashGreen = - now - - (typeof specialCharFlashTime !== "undefined" - ? specialCharFlashTime - : 0) < - flashDuration; - const shouldFlashMutation = - now - - (typeof mutationFlashTime !== "undefined" ? mutationFlashTime : 0) < - mutationFlashDuration; - - // Reset recently mutated tracking when flash period expires - if (!shouldFlashMutation) { - recentlyMutatedNoteIndex = -1; - recentlyMutatedTrackIndex = -1; - } - - let currentTrackForColoring = 0; // Track which parallel track we're processing for coloring - let currentSequenceInString = 0; // Track which sequence we're in for dimming future sequences - - for (let i = 0; i < melodyString.length; i++) { - const char = melodyString[i]; - let color = "yellow"; - - // Handle sequence separator `>` for sequential melodies - if (char === ">") { - // Check if this is a sequence separator (not preceded by a digit which would make it part of a loop count) - const prevChar = i > 0 ? melodyString[i - 1] : " "; - const nextChar = i + 1 < melodyString.length ? melodyString[i + 1] : " "; - - // If preceded by a digit, it's a loop count like "3>" - color the > white - // Otherwise it's a standalone > separator - if (/\d/.test(prevChar)) { - color = "white"; // Loop count indicator - } else { - color = "cyan"; // Sequence separator - } - currentSequenceInString++; // Advance to next sequence - coloredMelodyString += `\\${color}\\${char}`; - continue; - } - - // Handle loop count digits before `>` (like "3>" meaning loop 3 times) - if (/\d/.test(char)) { - // Check if this digit is followed by > (loop count) - let lookAhead = i + 1; - while (lookAhead < melodyString.length && /\d/.test(melodyString[lookAhead])) { - lookAhead++; - } - if (lookAhead < melodyString.length && melodyString[lookAhead] === ">") { - // This is a loop count number - color it magenta - color = "magenta"; - coloredMelodyString += `\\${color}\\${char}`; - continue; - } - } - - // Handle disabled group detection for x prefix syntax - if (char === " ") { - // Space separates groups - currentTrackForColoring++; - color = timingHasStarted ? "yellow" : "gray"; - } - // Check if this character is part of a disabled group (starts with 'x') - else if (char === "x") { - // Check if this 'x' is at the start of a group - let isGroupPrefix = false; - if (i === 0) { - // First character of the string - isGroupPrefix = true; - } else { - // Check if preceded by space(s) - let j = i - 1; - while (j >= 0 && melodyString[j] === " ") { - j--; - } - isGroupPrefix = j < 0 || melodyString[j] === " "; - } - - if (isGroupPrefix) { - color = "brown"; // Render 'x' prefix in brown - } else { - // Regular 'x' character, handle normally - const noteCharData = noteCharPositions.find( - (ncp) => ncp.charIndex === i, - ); - if (noteCharData) { - color = timingHasStarted ? "yellow" : "gray"; - } else { - color = timingHasStarted ? "yellow" : "gray"; - } - } - } - // Check if we're inside a disabled group (group that starts with 'x') - else { - // Find which group this character belongs to - let currentGroupIndex = 0; - let charCount = 0; - const groups = melodyString.trim().split(/\s+/); - let isInDisabledGroup = false; - - // Find which group contains this character - for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { - const group = groups[groupIdx]; - const groupStart = melodyString.indexOf(group, charCount); - const groupEnd = groupStart + group.length; - - if (i >= groupStart && i < groupEnd) { - currentGroupIndex = groupIdx; - isInDisabledGroup = group.startsWith("x"); - break; - } - charCount = groupEnd; - } - - if (isInDisabledGroup) { - // We're inside a disabled group - render everything in gray - color = "gray"; - } - // If we're not in a disabled group, continue with normal logic - else { - // Check if this character is part of the currently playing note - const noteCharData = noteCharPositions.find( - (ncp) => ncp.charIndex === i, - ); - let isCurrentlyPlayingNote = false; - - if (noteCharData) { - if (melodyState && melodyState.type === "single") { - isCurrentlyPlayingNote = - noteCharData.noteIndex === currentNoteIndex; - } else if ( - melodyState && - melodyState.type === "parallel" && - melodyState.trackStates && - noteCharData.trackIndex < melodyState.trackStates.length - ) { - // Use the unified function to get current note index for this track - const currentPlayingIndex = getCurrentNoteIndex( - melodyState, - noteCharData.trackIndex, - ); - isCurrentlyPlayingNote = - noteCharData.noteIndex === currentPlayingIndex; - } else if ( - melodyState && - melodyState.type === "sequential" && - melodyState.currentSequenceState - ) { - // For sequential melodies, only highlight notes in the current sequence - // AND only if this note is the currently playing one - if (noteCharData.isInCurrentSequence) { - const seqState = melodyState.currentSequenceState; - if (seqState.type === "single" && seqState.notes) { - // Single track within sequence - const totalNotes = seqState.notes.length; - const currentPlayingIndex = (seqState.index - 1 + totalNotes) % totalNotes; - isCurrentlyPlayingNote = noteCharData.noteIndex === currentPlayingIndex; - } else if (seqState.type === "parallel" && seqState.trackStates) { - // Parallel tracks within sequence - const trackState = seqState.trackStates[noteCharData.trackIndex]; - if (trackState && trackState.track) { - const totalNotes = trackState.track.length; - const currentPlayingIndex = (trackState.noteIndex - 1 + totalNotes) % totalNotes; - isCurrentlyPlayingNote = noteCharData.noteIndex === currentPlayingIndex; - } - } - } - } - } - - if (char === "{") { - inWaveformForColoring = true; - color = shouldFlashGreen ? "green" : "yellow"; // Always show yellow for braces - } else if (char === "}") { - inWaveformForColoring = false; - color = shouldFlashGreen ? "green" : "yellow"; // Always show yellow for braces - } else if (inWaveformForColoring) { - // Check if we're inside a stample code block {#code} - // Look back to find the opening { and check for # - let lookBack = i - 1; - let waveContent = ""; - while (lookBack >= 0 && melodyString[lookBack] !== "{") { - waveContent = melodyString[lookBack] + waveContent; - lookBack--; - } - waveContent = waveContent + char; - - // Check if this waveform starts with # (painting code) - if (waveContent.startsWith("#") || (lookBack >= 0 && melodyString[lookBack + 1] === "#")) { - // Extract the code (everything after #) - const hashIdx = waveContent.indexOf("#"); - const code = waveContent.substring(hashIdx + 1).replace(/[^a-zA-Z0-9]/g, ""); - - // Check cache status for coloring - const cached = stampleCache.get(code); - if (cached && cached.loaded) { - color = "lime"; // Loaded - bright green - } else if (cached && cached.loading) { - color = "cyan"; // Loading - cyan - } else if (cached && cached.error) { - color = [255, 100, 100]; // Error - light red - } else { - color = "orange"; // Not started yet - } - } else { - color = "cyan"; // Regular waveform content (sine, square, stample, etc.) - } - } else if (char === "*") { - // Special handling for mutation asterisk - white flash when triggered, rainbow otherwise - if ( - shouldFlashMutation && - (triggeredAsteriskPositions.includes(i) || - triggeredAsteriskPositions.includes("*")) - ) { - // White flash when asterisk is triggered - color = "white"; - } else { - // Create a time-based rainbow that cycles through hues - const time = now * 0.005; // Slow down the cycle - const hue = (time % 1) * 360; // 0-360 degrees - - // Convert HSV to RGB for rainbow effect - function hsvToRgb(h, s, v) { - const c = v * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = v - c; - let r, g, b; - - if (h >= 0 && h < 60) { - r = c; - g = x; - b = 0; - } else if (h >= 60 && h < 120) { - r = x; - g = c; - b = 0; - } else if (h >= 120 && h < 180) { - r = 0; - g = c; - b = x; - } else if (h >= 180 && h < 240) { - r = 0; - g = x; - b = c; - } else if (h >= 240 && h < 300) { - r = x; - g = 0; - b = c; - } else { - r = c; - g = 0; - b = x; - } - - return [ - Math.round((r + m) * 255), - Math.round((g + m) * 255), - Math.round((b + m) * 255), - ]; - } - - color = hsvToRgb(hue, 1, 1); // Full saturation and brightness for vivid rainbow - } - } else if (melodyState && melodyState.isFallback) { - if (noteCharData) { - if (isCurrentlyPlayingNote) { - // Check if this note is mutated and use different coloring - const isMutated = isNoteMutated(noteCharData); - - // Check if this is a duration punctuation character within the current note - if (/[.,]/i.test(char)) { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Duration punctuation uses mutation or normal fade - } else if (/[s]/i.test(char)) { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Sharp 's' uses same color as note letters - } else if (char === "_") { - // Rest character - should flash red like regular notes - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Rests use same color as note letters - } else if (/[0-9+\-#<>]/i.test(char)) { - color = "green"; // Other special characters in current note flash green - } else { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Note letters use mutation or normal fade - } - } else { - // Not currently playing - check if it's a mutated note - const isMutated = isNoteMutated(noteCharData); - if (isMutated) { - color = getMutatedNoteColor(false, noteCharData); // Goldenrod for non-active mutated notes - } else { - // Use note-specific colors if timing hasn't started, yellow if it has - color = timingHasStarted ? "yellow" : getStaticNoteColor(noteCharData); - } - } - } else { - // For characters not identified as notes, keep the basic color scheme - color = timingHasStarted ? "yellow" : "gray"; - } - } else { - if (noteCharData) { - if (isCurrentlyPlayingNote) { - // Check if this note is mutated and use different coloring - const isMutated = isNoteMutated(noteCharData); - - // Check if this is a duration punctuation character within the current note - if (/[.,]/i.test(char)) { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Duration punctuation uses mutation or normal fade - } else if (/[s]/i.test(char)) { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Sharp 's' uses same color as note letters - } else if (char === "_") { - // Rest character - should flash red like regular notes - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Rests use same color as note letters - } else if (/[0-9+\-#\[\]]/i.test(char)) { - color = "green"; // Other special characters in current note flash green (changed <> to [] for swing) - } else { - color = isMutated - ? getMutatedNoteColor(true, noteCharData) - : getRedNoteColor(); // Note letters use mutation or normal fade - } - } else { - // Not currently playing - check if it's a mutated note - const isMutated = isNoteMutated(noteCharData); - if (isMutated) { - color = getMutatedNoteColor(false, noteCharData); // Goldenrod for non-active mutated notes - } else { - // Use gray if timing hasn't started, yellow if it has - color = timingHasStarted ? "yellow" : "gray"; - } - } - } else { - // For non-note characters, use gray until timing starts - color = timingHasStarted ? "yellow" : "gray"; - } - } - } - } - - // Apply dimming for notes not in current sequence (for sequential melodies) - // Use noteCharData.isInCurrentSequence which is set during parsing - const noteCharData2 = noteCharPositions.find(ncp => ncp.charIndex === i); - const shouldDim = isSequentialMelody && noteCharData2 && noteCharData2.isInCurrentSequence === false; - - if (shouldDim && color !== "cyan" && color !== "magenta" && color !== "white") { - // Dim the color to indicate non-current sequence - if (typeof color === "string") { - color = "gray"; // Dim all non-current sequence notes to gray - } else if (Array.isArray(color)) { - // Dim RGB values - color = [Math.round(color[0] * 0.3), Math.round(color[1] * 0.3), Math.round(color[2] * 0.3)]; - } - } - - // Handle RGB arrays specially - convert to simple RGB escape code format - if (Array.isArray(color)) { - const rgbString = `\\${color[0]},${color[1]},${color[2]}\\${char}`; - coloredMelodyString += rgbString; - } else { - coloredMelodyString += `\\${color}\\${char}`; - } - } - - return coloredMelodyString; - } + function buildColoredMelodyStringUnified(melodyString, melodyState) { + // Thin wrapper over the shared melody highlighter (lib/melody-highlighter.mjs). + // Feeds clock's runtime state in; preserves the original side effect of + // clearing recentlyMutated* once the mutation flash expires. + if (!melodyString) return ""; + const now = performance.now(); + const result = buildColoredMelodyString(melodyString, melodyState, { + now, + timingHasStarted: hasFirstSynthFired, + getCurrentNoteIndex, + recentlyMutated: { + noteIndex: recentlyMutatedNoteIndex, + trackIndex: recentlyMutatedTrackIndex, + }, + mutationFlash: { active: now - mutationFlashTime < 300 }, + specialCharFlash: now - specialCharFlashTime < 200, + triggeredAsteriskPositions, + getStampleStatus: (code) => { + const c = stampleCache.get(code); + return c ? { loaded: c.loaded, loading: c.loading, error: c.error } : null; + }, + }); + if (!(now - mutationFlashTime < 300)) { + recentlyMutatedNoteIndex = -1; + recentlyMutatedTrackIndex = -1; + } + return result; + } // (Remove any other preview label drawing from the center) } else { ink("red").write("SYNCING...", { center: "xy", size: 2 }); diff --git a/system/public/aesthetic.computer/lib/kidlisp.mjs b/system/public/aesthetic.computer/lib/kidlisp.mjs index c665854eaa..015ac33c60 100644 --- a/system/public/aesthetic.computer/lib/kidlisp.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp.mjs @@ -5,7 +5,16 @@ // ❤️‍🔥 TODO: Add UTC Support to s... timers. etc. to be compatible with 'clock.mjs'. // - Selectabe values via game controller. -import { parseMelody, noteToTone } from "./melody-parser.mjs"; +import { + parseMelody, + parseSequentialMelody, + buildMelodyState, + buildSequenceState, + sequenceDurationBeats, + noteToTone, +} from "./melody-parser.mjs"; +import { buildColoredMelodyString } from "./melody-highlighter.mjs"; +import { createTransport, DEFAULT_BPM } from "./melody-transport.mjs"; import { qrcode as qr } from "../dep/@akamfoad/qr/qr.mjs"; import { cssColors, rainbow, zebra, resetZebraCache, resetRainbowCache, staticColorMap } from "./num.mjs"; import { setFadeAlpha, clearFadeAlpha } from "./fade-state.mjs"; @@ -37,7 +46,7 @@ const KIDLISP_FUNCTIONS = new Set([ "resolution", "scroll", "flip", "spin", "resetSpin", "smoothspin", "zoom", "blur", "contrast", "pan", "unpan", "mask", "unmask", "steal", "putback", "fill", "outline", "stroke", "nofill", "nostroke", // Animation - "wiggle", "sort", "fade", "jump", "bake", "frame", "fps", "auto-density", "scale", + "wiggle", "sort", "fade", "jump", "bake", "frame", "fps", "beat", "beatphase", "auto-density", "scale", // Variables "def", "set", "let", "var", "const", // Control @@ -907,7 +916,7 @@ function readFromTokens(tokens) { // Check if the current token is a timing expression (both simple like "1.5s" and repeating like "2s...") const currentToken = tokens[0].value; - if (/^\d*\.?\d+s\.\.\.?$/.test(currentToken) || /^\d*\.?\d+s!?$/.test(currentToken)) { + if (/^\d*\.?\d+[sb]\.\.\.?$/.test(currentToken) || /^\d*\.?\d+[sb]!?$/.test(currentToken)) { // This is a timing token, collect it and all following expressions until we hit another timing token or end const timingExpr = [readExpression(tokens)]; // Read the timing token itself @@ -916,8 +925,8 @@ function readFromTokens(tokens) { tokens.length > 0 && tokens[0].value !== ")" && tokens[0].value !== "," && // Stop at commas too - !/^\d*\.?\d+s\.\.\.?$/.test(tokens[0].value) && - !/^\d*\.?\d+s!?$/.test(tokens[0].value) + !/^\d*\.?\d+[sb]\.\.\.?$/.test(tokens[0].value) && + !/^\d*\.?\d+[sb]!?$/.test(tokens[0].value) ) { const nextExpr = readExpression(tokens); timingExpr.push(nextExpr); @@ -962,10 +971,10 @@ function atom(token) { if ((token[0] === '"' && token[token.length - 1] === '"') || (token[0] === "'" && token[token.length - 1] === "'")) { return token; // Return string with quotes intact for later processing - } else if (/^\d*\.?\d+s\.\.\.?$/.test(token)) { + } else if (/^\d*\.?\d+[sb]\.\.\.?$/.test(token)) { // Preserve tokens like "1s...", "2s...", "1.5s..." as strings for timed iteration return token; - } else if (/^\d*\.?\d+s$/.test(token)) { + } else if (/^\d*\.?\d+[sb]$/.test(token)) { // Preserve tokens like "1s", "2s", "10s", "1.5s", "0.3s" as strings for timing return token; } else { @@ -1258,7 +1267,10 @@ class KidLisp { // Melody state tracking this.melodies = new Map(); // Track active melodies + this.melodyByString = new Map(); // melodyString -> melodyState (for source highlighting) this.melodyTimers = new Map(); // Track timing for each melody + this.transport = null; // Shared beat transport (set by first melody); drives `b` timers + `beat` + this.transportMasterId = null; // melodyId that owns the transport tempo // Resolution state tracking this.halfResolutionApplied = false; @@ -2242,17 +2254,35 @@ class KidLisp { this.evaluationCounts.set(type, (this.evaluationCounts.get(type) || 0) + 1); } + // ms per beat from the shared transport (falls back to the default tempo + // when no melody/transport exists yet, so `b` timers still behave sanely). + _beatMs() { + return this.transport ? this.transport.beatMs : 60000 / DEFAULT_BPM; + } + + // Offset that phase-aligns wall-clock (s/f) cycle/blink boundaries to the + // transport origin, so visual timers line up with the melody downbeat. 0 when + // there is no transport → byte-identical to legacy behavior. + getTransportOffsetMs() { + return this.transport ? -this.transport.startMs : 0; + } + // Parse timing tokens used by syntax highlighting animation. - // Supports both second-based (0.5s, 1s...) and frame-based (1f, 2f...) forms. + // Supports second (0.5s, 1s...), frame (1f, 2f...), and beat (1b, 0.5b...) forms. parseTimingTokenMeta(timingToken) { if (typeof timingToken !== "string") return null; - let match = timingToken.match(/^(\d*\.?\d+)([sf])(\.\.\.?)$/); + let match = timingToken.match(/^(\d*\.?\d+)([sfb])(\.\.\.?)$/); if (match) { const value = parseFloat(match[1]); if (!Number.isFinite(value)) return null; const unit = match[2]; - const intervalMs = unit === "s" ? value * 1000 : (value / 60) * 1000; + const intervalMs = + unit === "s" + ? value * 1000 + : unit === "b" + ? value * this._beatMs() // beats → ms via the shared transport + : (value / 60) * 1000; // frames (60fps) return { token: timingToken, value, @@ -2264,12 +2294,17 @@ class KidLisp { }; } - match = timingToken.match(/^(\d*\.?\d+)([sf])(!)?$/); + match = timingToken.match(/^(\d*\.?\d+)([sfb])(!)?$/); if (match) { const value = parseFloat(match[1]); if (!Number.isFinite(value)) return null; const unit = match[2]; - const intervalMs = unit === "s" ? value * 1000 : (value / 60) * 1000; + const intervalMs = + unit === "s" + ? value * 1000 + : unit === "b" + ? value * this._beatMs() // beats → ms via the shared transport + : (value / 60) * 1000; // frames (60fps) return { token: timingToken, value, @@ -2327,7 +2362,7 @@ class KidLisp { }; } - getTimingEditBlinkState(timingToken, now = performance.now(), offsetMs = 0) { + getTimingEditBlinkState(timingToken, now = performance.now(), offsetMs = this.getTransportOffsetMs()) { const profile = this.getTimingBlinkProfile(timingToken); const adjustedNow = now + offsetMs; const pulseMs = Math.max(1, profile.pulseMs); @@ -2340,7 +2375,7 @@ class KidLisp { }; } - getTimingCyclePosition(timingToken, totalArgs, now = performance.now(), offsetMs = 0) { + getTimingCyclePosition(timingToken, totalArgs, now = performance.now(), offsetMs = this.getTransportOffsetMs()) { if (!Number.isFinite(totalArgs) || totalArgs <= 0) return 0; const adjustedNow = now + offsetMs; const intervalMs = this.getTimingIntervalMs(timingToken); @@ -2351,7 +2386,7 @@ class KidLisp { markTimingTriggered(timingToken) { const now = performance.now(); const blinkProfile = this.getTimingBlinkProfile(timingToken); - const isDelayTimer = /^\d*\.?\d+[sf]!?$/.test(timingToken); + const isDelayTimer = /^\d*\.?\d+[sfb]!?$/.test(timingToken); const existingBlink = this.timingBlinks.get(timingToken); // Tiny timers can trigger every frame; preserve pulse anchor briefly so they visibly blink. @@ -2409,7 +2444,7 @@ class KidLisp { } // For standard timers, keep one-shot flash behavior. - const isDelayTimer = blinkInfo.isDelayTimer ?? /^\d*\.?\d+[sf]!?$/.test(timingToken); + const isDelayTimer = blinkInfo.isDelayTimer ?? /^\d*\.?\d+[sfb]!?$/.test(timingToken); if (isDelayTimer) { return elapsed < blinkWindowMs; } @@ -4911,7 +4946,7 @@ class KidLisp { lines[index - 1].split(")").length; // Check if line starts with a timing expression like "1.5s" or "2s..." - const timingMatch = line.match(/^(\d*\.?\d+s\.\.\.?)\s+(.+)$/); + const timingMatch = line.match(/^(\d*\.?\d+[sb]\.\.\.?)\s+(.+)$/); if (timingMatch && !line.startsWith("(")) { // Line starts with timing expression followed by other content // Keep it as a flat structure: 1.5s (zoom 0.75) becomes a single tokenized line @@ -5096,72 +5131,189 @@ class KidLisp { return parseMelody(melodyString); } - playMelodyNote(api, melodyId) { - const melodyState = this.melodies.get(melodyId); - if (!melodyState) return; + // Register + start a melody. Shared by the `melody` and `clock` builtins + // (the latter delegates here when given a string arg — "nice synergy" with + // the clock piece). Returns the melody string (def-like: re-runs are no-ops). + runMelody(api, args = []) { + if (args.length === 0) return; + + const melodyString = unquoteString(args[0]); + const bpm = args.length > 1 ? parseFloat(args[1]) : 120; // Default 120 BPM + const timeSignature = args.length > 2 ? unquoteString(args[2]) : "4/4"; // Default 4/4 + const feel = args.length > 3 ? unquoteString(args[3]) : "straight"; // Default straight + + // Parse time signature + const [numerator, denominator] = timeSignature.split("/").map(Number); + const beatsPerMeasure = numerator; + const beatUnit = denominator; // 4 = quarter note, 8 = eighth note, etc. + + // Convert BPM to ms per quarter note, adjusted for the beat unit. + let baseTempo = (60 / bpm) * 1000; + if (beatUnit === 8) baseTempo = baseTempo / 2; + else if (beatUnit === 2) baseTempo = baseTempo * 2; + + const melodyId = melodyString + timeSignature + feel; + + // Establish / update the shared beat transport. The first melody in a piece + // becomes the master; re-evaluating it (every frame) keeps tempo synced. + // Drives `b` timing units and the `beat`/`beatphase` vars. Runs before the + // def-like early return so live BPM edits take effect. + if (!this.transport) { + this.transport = createTransport({ + bpm, + startMs: performance.now(), + timeSignature, + }); + this.transportMasterId = melodyId; + } else if (melodyId === this.transportMasterId) { + this.transport.setBpm(bpm); + } - const noteData = melodyState.notes[melodyState.index]; - if (!noteData) return; + // def-like: already registered → don't redefine. + if (this.melodies.has(melodyId)) return; - const { note, octave, duration } = noteData; - let noteDuration = duration * melodyState.baseTempo; + // Parse single / parallel (space) / sequential (`>`) with full per-note timbre. + const parsed = parseSequentialMelody(melodyString, 4); + const melodyState = buildMelodyState(parsed, { baseTempo }); + if (!melodyState) return; - // Apply swing/feel adjustments - if (melodyState.feel === "waltz" && melodyState.timeSignature === "3/4") { - // In waltz feel, slightly rush beats 2 and 3, emphasize beat 1 - const beatInMeasure = - melodyState.measurePosition % melodyState.beatsPerMeasure; - if (beatInMeasure === 0) { - // Beat 1: slightly longer (emphasized) - noteDuration *= 1.05; - } else { - // Beats 2 and 3: slightly shorter (lighter) - noteDuration *= 0.98; - } - } else if (melodyState.feel === "swing") { - // Classic jazz swing: off-beats are delayed and shortened - const beatInMeasure = - melodyState.measurePosition % melodyState.beatsPerMeasure; - if (beatInMeasure % 1 !== 0) { - // Off-beat - noteDuration *= 0.9; // Shorter off-beats - } - } - - if (note !== "rest" && api.sound?.synth) { - // Convert note to tone format expected by the synth - const tone = this.noteToTone(note, octave); - - api.sound.synth({ - type: "sine", - tone: tone, - duration: (noteDuration / 1000) * 0.98, // 98% of the duration for smoother flow - attack: 0.01, - decay: 0.99, - volume: 0.8, // Increased volume to make it more audible - }); - } + // Attach feel/meta used by the scheduler + highlighter. + melodyState.timeSignature = timeSignature; + melodyState.beatsPerMeasure = beatsPerMeasure; + melodyState.beatUnit = beatUnit; + melodyState.feel = feel; + melodyState.isPlaying = false; - // Update measure position for swing calculations - melodyState.measurePosition += duration; + this.melodies.set(melodyId, melodyState); + this.melodyByString.set(melodyString, melodyState); // for source highlighting - // Schedule next note based on current note's duration - melodyState.nextNoteTime = performance.now() + noteDuration; - melodyState.lastPlayTime = performance.now(); + // Kick playback immediately (first notes fire on this frame). + this.updateMelodies(api); - // Advance to next note - melodyState.index = (melodyState.index + 1) % melodyState.notes.length; + return melodyString; } - // Update melody playback in simulation loop + // ── Multi-track melody scheduler ────────────────────────────────────────── + // Drives single / parallel / sequential melodies each frame. Mirrors + // clock.mjs's proven model: per-track `nextNoteTargetTime` ACCUMULATION (never + // now+duration, which drifts) with a 64-note catch-up cap for backgrounded + // tabs. Reads per-note timbre (waveType/volume/struck/sonicDuration/toneShift) + // from the parser output. Called once per frame from sim(). updateMelodies(api) { - const currentTime = performance.now(); - - for (const [melodyId, melodyState] of this.melodies) { - if (currentTime >= melodyState.nextNoteTime) { - this.playMelodyNote(api, melodyId); + const now = performance.now(); + for (const [, ms] of this.melodies) this._advanceMelody(api, ms, now); + } + + _advanceMelody(api, ms, now) { + if (!ms) return; + if (ms.type === "parallel") { + for (const ts of ms.trackStates) this._advanceTrack(api, ms, ts, now); + } else if (ms.type === "sequential") { + const seq = ms.currentSequenceState; + if (seq) { + if (seq.type === "parallel") { + for (const ts of seq.trackStates) this._advanceTrack(api, ms, ts, now); + } else { + this._advanceSingle(api, ms, seq, now); + } + } + // Time-based sequence advancement: loop the current `>` segment for its + // loopCount, then move on to the next. + if (ms.sequenceEndTime == null) { + ms.sequenceEndTime = now + this._sequenceDurationMs(ms, ms.currentSequence); } + if (now >= ms.sequenceEndTime) this._advanceSequence(ms, now); + } else { + this._advanceSingle(api, ms, ms, now); + } + } + + // Advance one playable track-state {track, noteIndex, nextNoteTargetTime}. + _advanceTrack(api, ms, ts, now) { + if (!ts.track || ts.track.length === 0) return; + if (!ts.nextNoteTargetTime) ts.nextNoteTargetTime = now; + let catchup = 0; + while (now >= ts.nextNoteTargetTime && catchup < 64) { + const noteData = ts.track[ts.noteIndex]; + if (!noteData) break; + ms.isPlaying = true; + this._playNote(api, ms, noteData, ts.trackIndex || 0); + ts.nextNoteTargetTime += this._noteDurationMs(ms, noteData, ts); + ts.noteIndex = (ts.noteIndex + 1) % ts.track.length; + catchup++; + } + } + + // Single-track variant; advances `st.index` (what the highlighter reads). + _advanceSingle(api, ms, st, now) { + const notes = st.notes; + if (!notes || notes.length === 0) return; + if (!st.nextNoteTargetTime) st.nextNoteTargetTime = now; + let catchup = 0; + while (now >= st.nextNoteTargetTime && catchup < 64) { + const noteData = notes[st.index]; + if (!noteData) break; + ms.isPlaying = true; + this._playNote(api, ms, noteData, 0); + st.nextNoteTargetTime += this._noteDurationMs(ms, noteData, st); + st.index = (st.index + 1) % notes.length; + catchup++; + } + } + + // Note duration (ms) with waltz/swing feel applied via the state's own + // measurePosition accumulator. + _noteDurationMs(ms, noteData, st) { + let dur = (noteData.duration || 1) * ms.baseTempo; + const mp = st.measurePosition || 0; + if (ms.feel === "waltz" && ms.timeSignature === "3/4") { + dur *= mp % ms.beatsPerMeasure === 0 ? 1.05 : 0.98; + } else if (ms.feel === "swing") { + if ((mp % ms.beatsPerMeasure) % 1 !== 0) dur *= 0.9; + } + st.measurePosition = mp + (noteData.duration || 1); + return dur; + } + + // Play a single note with full per-note timbre. Rests are silent but still + // consume their slot (the caller accumulates their duration regardless). + _playNote(api, ms, noteData, trackIndex) { + const note = noteData.note; + if (!note || note === "rest" || !api.sound?.synth) return; + const tone = this.noteToTone(note, noteData.octave); + const sonicMs = + (noteData.sonicDuration != null + ? noteData.sonicDuration + : noteData.duration || 1) * ms.baseTempo; + const struck = !!noteData.struck; + const opts = { + type: noteData.waveType || "sine", + tone, + duration: (sonicMs / 1000) * 0.98, + attack: struck ? 0.005 : 0.01, + decay: struck ? 0.9 : 0.99, + volume: noteData.volume != null ? noteData.volume : 0.8, + }; + if (typeof noteData.toneShift === "number" && noteData.toneShift !== 0) { + opts.toneShift = noteData.toneShift; } + api.sound.synth(opts); + } + + // Total ms a sequence occupies (longest track × loopCount). + _sequenceDurationMs(ms, seqIndex) { + const seq = ms.sequences && ms.sequences[seqIndex]; + if (!seq) return 0; + const loops = seq.loopCount || 1; + return sequenceDurationBeats(seq) * ms.baseTempo * loops; + } + + // Move to the next `>` sequence (wrapping) and rebuild its playable state. + _advanceSequence(ms, now) { + if (!ms.sequences || ms.sequences.length === 0) return; + ms.currentSequence = (ms.currentSequence + 1) % ms.sequences.length; + ms.currentSequenceState = buildSequenceState(ms.sequences[ms.currentSequence]); + ms.sequenceEndTime = now + this._sequenceDurationMs(ms, ms.currentSequence); } // Convert note letter to frequency @@ -6650,7 +6802,7 @@ class KidLisp { const timingToken = phase[0]; // Check if this timing phase should be active - if (typeof timingToken === 'string' && /^\d*\.?\d+s\.\.\.?$/.test(timingToken)) { + if (typeof timingToken === 'string' && /^\d*\.?\d+[sb]\.\.\.?$/.test(timingToken)) { // This is a timing expression - check if it should execute if (this.evaluateTimingExpression && this.evaluateTimingExpression(api, timingToken)) { activePhase = phase; @@ -7512,8 +7664,21 @@ class KidLisp { f: (api) => { // Abbreviation for frame return api.paintCount || 0; }, - clock: (api) => { - return Date.now(); // Returns UTC milliseconds since epoch + clock: (api, args = []) => { + // Polymorphic + synergistic with the `clock` piece: `(clock "cdefg" …)` + // plays a melody exactly like `(melody …)`; bare `(clock)` still returns + // UTC milliseconds since epoch. + if (args.length > 0) return this.runMelody(api, args); + return Date.now(); + }, + beat: (api) => { + // Running float beat counter from the shared transport (0 if no melody). + // Monotonic — e.g. (spin (* 2 beat)) accelerates locked to tempo. + return this.transport ? this.transport.getBeat(performance.now()) : 0; + }, + beatphase: (api) => { + // 0..1 sawtooth within the current beat — e.g. (scroll 0 (* 10 beatphase)). + return this.transport ? this.transport.getPhase(performance.now()) : 0; }, // 🎯 Performance monitoring functions (singleton behavior) perf: (api, args = []) => { @@ -8200,63 +8365,7 @@ class KidLisp { return amplitudeValue; }, // 🎵 Melody - plays a sequence of notes in a loop - melody: (api, args = []) => { - if (args.length === 0) return; - - const melodyString = unquoteString(args[0]); - const bpm = args.length > 1 ? parseFloat(args[1]) : 120; // Default 120 BPM - const timeSignature = args.length > 2 ? unquoteString(args[2]) : "4/4"; // Default 4/4 time - const feel = args.length > 3 ? unquoteString(args[3]) : "straight"; // Default straight feel - - // Parse time signature - const [numerator, denominator] = timeSignature.split("/").map(Number); - const beatsPerMeasure = numerator; - const beatUnit = denominator; // 4 = quarter note, 8 = eighth note, etc. - - // Convert BPM to milliseconds per quarter note - // Adjust base tempo based on beat unit - let baseTempo = (60 / bpm) * 1000; // 60 seconds / BPM * 1000ms - if (beatUnit === 8) { - baseTempo = baseTempo / 2; // Eighth note gets the beat - } else if (beatUnit === 2) { - baseTempo = baseTempo * 2; // Half note gets the beat - } - - const melodyId = melodyString + timeSignature + feel; // Include time signature and feel in ID - - // Check if this melody is already defined and hasn't changed - if (this.melodies.has(melodyId)) { - // Melody already exists, don't redefine (like def behavior) - return; - } - - // Parse the melody string into notes with durations - const notes = this.parseMelodyString(melodyString); - - if (notes.length === 0) return; - - // Store the melody state - const melodyState = { - notes: notes, - index: 0, - lastPlayTime: 0, - nextNoteTime: 0, - baseTempo: baseTempo, - timeSignature: timeSignature, - beatsPerMeasure: beatsPerMeasure, - beatUnit: beatUnit, - feel: feel, - measurePosition: 0, // Track position within the measure for swing - isPlaying: false, - }; - - this.melodies.set(melodyId, melodyState); - - // Start playing immediately - this.playMelodyNote(api, melodyId); - - return melodyString; - }, + melody: (api, args = []) => this.runMelody(api, args), // 🔊 Speaker - returns whether sound is enabled speaker: (api) => { // PERFORMANCE: Ultra-fast implementation with minimal logic @@ -10010,7 +10119,7 @@ class KidLisp { result = timingResult; } continue; // Skip normal function processing - } else if (typeof head === "string" && /^\d*\.?\d+s!?$/.test(head)) { + } else if (typeof head === "string" && /^\d*\.?\d+[sb]!?$/.test(head)) { // Handle second-based timing like "0s", "1s", "2s", "5s", "1.5s", "0.3s" // Also handle instant trigger modifier like "0.25s!", "1s!", "2s!" @@ -10155,13 +10264,16 @@ class KidLisp { continue; // Skip normal function processing } else if ( typeof head === "string" && - /^\d*\.?\d+s\.\.\.?$/.test(head) + /^\d*\.?\d+[sb]\.\.\.?$/.test(head) ) { // Handle timed iteration like "1s...", "2s...", "1.5s..." // ✅ Allow timing expressions to work both at top-level and as function arguments - const seconds = parseFloat(head.slice(0, head.indexOf("s"))); // Extract seconds part + // Interval in seconds — unit-aware (s/b) via the timing meta so beat + // (b) timers derive their interval from the shared transport tempo. + const _meta = this.parseTimingTokenMeta(head); + const seconds = _meta ? _meta.intervalMs / 1000 : parseFloat(head); // Get current time - use simulation time if available (for headless rendering), otherwise real time const currentTimeMs = this.getSimulationTime ? this.getSimulationTime() : Date.now(); @@ -10519,7 +10631,7 @@ class KidLisp { ) { // Check if this is a timing expression that needs full evaluation if (Array.isArray(arg) && arg.length > 0 && - typeof arg[0] === "string" && /^\d*\.?\d+s\.\.\.?$/.test(arg[0])) { + typeof arg[0] === "string" && /^\d*\.?\d+[sb]\.\.\.?$/.test(arg[0])) { // Use full evaluation for timing expressions const result = this.evaluate([arg], api, this.localEnv); return result; @@ -10788,7 +10900,7 @@ class KidLisp { } // Check if this is a timing expression like "1.5s" or "2s..." before processing as identifier - if (/^\d*\.?\d+s\.\.\.?$/.test(expression)) { + if (/^\d*\.?\d+[sb]\.\.\.?$/.test(expression)) { // This is a timing expression, evaluate it properly as an array return this.evaluate([expression], api, env); } @@ -11026,6 +11138,33 @@ class KidLisp { const whitespace = this.syntaxHighlightSource.substring(sourceIndex, tokenIndex); result += whitespace; + // Special handling for a melody string literal: (melody "...") — color + // each note via the shared melody highlighter (same as clock.mjs). + const isMelodyArg = + token.length >= 2 && + (token[0] === '"' || token[0] === "'") && + token[token.length - 1] === token[0] && + (tokens[i - 1] === "melody" || tokens[i - 1] === "clock"); + if (isMelodyArg) { + const q = token[0]; + const inner = token.slice(1, -1); + const quoteColor = color || "yellow"; + const state = this.melodyByString?.get(inner) || null; + const coloredInner = buildColoredMelodyString(inner, state, { + now: performance.now(), + timingHasStarted: !!(state && state.isPlaying), + recentlyMutated: this.melodyRecentlyMutated || { noteIndex: -1, trackIndex: -1 }, + mutationFlash: { active: false }, + specialCharFlash: false, + triggeredAsteriskPositions: [], + getStampleStatus: () => null, + }); + result += `\\${quoteColor}\\${q}${coloredInner}\\${quoteColor}\\${q}`; + lastColor = null; // reset so the next token re-emits its color + sourceIndex = tokenIndex + token.length; + continue; + } + // Special handling for compound colors (like $codes and #codes) if (color.startsWith("COMPOUND:")) { const parts = color.split(":"); @@ -11098,7 +11237,7 @@ class KidLisp { // Check if a token is part of a timing expression and get its state getTimingTokenState(token, tokens, index) { // First, check if the current token itself is a timing token - if (/^\d*\.?\d+[sf]\.\.\.?$/.test(token)) { + if (/^\d*\.?\d+[sfb]\.\.\.?$/.test(token)) { // Cycle timers - sequential execution // Try to find this timing key in our active tracking // We need to check all possible timing keys since we don't know the exact arg count here @@ -11127,7 +11266,7 @@ class KidLisp { }; } } - } else if (/^\d*\.?\d+[sf]!?$/.test(token)) { + } else if (/^\d*\.?\d+[sfb]!?$/.test(token)) { // Delay timers - all contents blink when triggered return { timingKey: token, @@ -11145,7 +11284,7 @@ class KidLisp { const prevToken = tokens[i]; // Handle cycle timer arguments - if (/^\d*\.?\d+[sf]\.\.\.?$/.test(prevToken)) { + if (/^\d*\.?\d+[sfb]\.\.\.?$/.test(prevToken)) { // Try to find this timing key in our active tracking for (const [timingKey, data] of this.activeTimingExpressions) { if (timingKey.startsWith(prevToken + "_")) { @@ -11181,7 +11320,7 @@ class KidLisp { } } } - } else if (/^\d*\.?\d+[sf]!?$/.test(prevToken)) { + } else if (/^\d*\.?\d+[sfb]!?$/.test(prevToken)) { // Handle delay timer arguments - always return state to control visibility const isBlinking = this.isTimingBlinking(prevToken); const isActive = this.isDelayTimerActive(prevToken); @@ -11600,7 +11739,7 @@ class KidLisp { const timingExprState = this.getTimingExpressionState(token, tokens, index); // Check for cycle timing patterns (3s..., 5f..., etc.) - if (/^\d*\.?\d+[sf]\.\.\.?$/.test(token)) { + if (/^\d*\.?\d+[sfb]\.\.\.?$/.test(token)) { const timingState = this.getTimingTokenState(token, tokens, index); // In edit mode, simulate blinking based on time or frames @@ -11630,7 +11769,7 @@ class KidLisp { } // Check for delay timing patterns (1.25s, 0.5s, 3f, 5f, etc.) - if (/^\d*\.?\d+[sf]!?$/.test(token)) { + if (/^\d*\.?\d+[sfb]!?$/.test(token)) { const timingState = this.getTimingTokenState(token, tokens, index); // In edit mode, simulate blinking based on time or frames @@ -11719,7 +11858,7 @@ class KidLisp { const prevToken = tokens[i]; // Found a cycle timer token - if (/^\d*\.?\d+[sf]\.\.\.?$/.test(prevToken)) { + if (/^\d*\.?\d+[sfb]\.\.\.?$/.test(prevToken)) { // Check if current token is within this cycle timer's arguments const argInfo = this.getTokenArgPosition(tokens, i, index); if (argInfo !== null && argInfo.argIndex >= 0) { @@ -11770,7 +11909,7 @@ class KidLisp { // Stop searching if we hit an opening paren that would enclose the timing expression if (prevToken === '(' && i < index - 1) { // Check if the next token after this paren is a cycle timer - if (i + 1 < tokens.length && !/^\d*\.?\d+[sf]\.\.\.?$/.test(tokens[i + 1])) { + if (i + 1 < tokens.length && !/^\d*\.?\d+[sfb]\.\.\.?$/.test(tokens[i + 1])) { break; } } @@ -12048,7 +12187,7 @@ class KidLisp { } // Check for timing patterns (but exclude pure numbers which were handled above) - if (/^\d*\.?\d+[sf]!?$/.test(token)) { + if (/^\d*\.?\d+[sfb]!?$/.test(token)) { // Check if this timing token is currently blinking if (this.isTimingBlinking(token)) { return "red"; // Bright red flash when timing triggers @@ -13877,7 +14016,7 @@ class KidLisp { // console.log(`🔄 shouldLayerEvaluate: Checking source for layer ${embeddedLayer.originalCacheId || embeddedLayer.id}:`, source); // Check for timing expressions like "0.1s...", "1s!", "0.5s" which are animation triggers - const hasTimingExpression = /\d+\.?\d*s\.\.\.?|\d+\.?\d*s!/.test(source); + const hasTimingExpression = /\d+\.?\d*[sb]\.\.\.?|\d+\.?\d*[sb]!/.test(source); const hasDynamicContent = hasTimingExpression || source.includes('frame') || @@ -13999,7 +14138,7 @@ class KidLisp { // Check for timing expressions containing scroll if (Array.isArray(node)) { // Check if this is a timing expression like ["2s...", ["scroll", 1, 0, -1]] - if (node.length > 1 && typeof node[0] === 'string' && /^\d*\.?\d+s\.\.\.?$/.test(node[0])) { + if (node.length > 1 && typeof node[0] === 'string' && /^\d*\.?\d+[sb]\.\.\.?$/.test(node[0])) { // Check if any following elements contain scroll return node.slice(1).some(elem => { if (Array.isArray(elem) && elem.length > 0 && elem[0] === 'scroll') { @@ -14212,7 +14351,7 @@ class KidLisp { extractTimingExpressions(sourceCode) { const expressions = []; // Match patterns like (3s...) or (0.25s... something) - const timingRegex = /\(\s*(\d+(?:\.\d+)?s\.\.\.)/g; + const timingRegex = /\(\s*(\d+(?:\.\d+)?[sb]\.\.\.)/g; let match; while ((match = timingRegex.exec(sourceCode)) !== null) { @@ -14286,7 +14425,7 @@ class KidLisp { if (!sourceCode) return null; // Look for timing patterns like "3s...", "1s...", "0.25s..." - const timingMatch = sourceCode.match(/(\d+(?:\.\d+)?s\.\.\.)/); + const timingMatch = sourceCode.match(/(\d+(?:\.\d+)?[sb]\.\.\.)/); return timingMatch ? timingMatch[1] : null; } diff --git a/system/public/aesthetic.computer/lib/melody-highlighter.mjs b/system/public/aesthetic.computer/lib/melody-highlighter.mjs new file mode 100644 index 0000000000..b00e941532 --- /dev/null +++ b/system/public/aesthetic.computer/lib/melody-highlighter.mjs @@ -0,0 +1,627 @@ +// Melody Highlighter — shared note-aware coloring for melody strings. +// +// Extracted from disks/clock.mjs's `buildColoredMelodyStringUnified` so both +// the clock piece AND KidLisp's `(melody "...")` string literal can render the +// SAME per-note coloring. The output uses the `\color\char` escape format that +// both clock's HUD writer and KidLisp's `buildColoredKidlispString` already +// understand (named colors like "red"/"goldenrod"/"cyan", and RGB arrays +// emitted as `\r,g,b\char`). +// +// The clock version closed over module globals (hasFirstSynthFired, flash +// timers, recentlyMutated indices, the stample cache). Those are now passed in +// via `opts` so the function is pure (no side effects). The one side effect in +// the original — resetting recentlyMutated* when the mutation flash expires — +// is the CALLER's responsibility now (see clock.mjs wrapper). + +import { getNoteColor } from "./note-colors.mjs"; + +const _now = () => + typeof performance !== "undefined" ? performance.now() : Date.now(); + +// Single hoisted HSV→RGB (the clock version inlined this twice). +export function hsvToRgb(h, s, v) { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let r, g, b; + if (h >= 0 && h < 60) { r = c; g = x; b = 0; } + else if (h >= 60 && h < 120) { r = x; g = c; b = 0; } + else if (h >= 120 && h < 180) { r = 0; g = c; b = x; } + else if (h >= 180 && h < 240) { r = 0; g = x; b = c; } + else if (h >= 240 && h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; +} + +// Pure version of clock's getCurrentNoteIndex — gates on the passed +// `timingHasStarted` instead of the module global `hasFirstSynthFired`. +export function computeCurrentNoteIndex( + melodyState, + trackIndex = 0, + timingHasStarted = true, +) { + if (!timingHasStarted || !melodyState) return -1; + if (melodyState.type === "single" && melodyState.notes) { + const totalNotes = melodyState.notes.length; + return (melodyState.index - 1 + totalNotes) % totalNotes; + } else if (melodyState.type === "parallel" && melodyState.trackStates) { + const trackState = melodyState.trackStates[trackIndex]; + if (trackState && trackState.track) { + const totalNotes = trackState.track.length; + return (trackState.noteIndex - 1 + totalNotes) % totalNotes; + } + } + return -1; +} + +// Build a `\color\char` escape string coloring each note of `melodyString`. +// +// opts: +// now : ms timestamp (default performance.now()) +// timingHasStarted : has the first synth fired? (default false) +// getCurrentNoteIndex : (state, trackIndex) => index (default: pure) +// recentlyMutated : { noteIndex, trackIndex } (rainbow target) +// mutationFlash : { active } (mutation flashing?) +// specialCharFlash : boolean (green brace flash?) +// triggeredAsteriskPositions : number[] | ["*"] (white asterisk flash) +// getStampleStatus : (code) => {loaded,loading,error} | null +export function buildColoredMelodyString(melodyString, melodyState, opts = {}) { + if (!melodyString) return ""; + + const now = opts.now ?? _now(); + const timingHasStarted = opts.timingHasStarted ?? false; + const getCurrentNoteIndex = + opts.getCurrentNoteIndex || + ((s, t = 0) => computeCurrentNoteIndex(s, t, timingHasStarted)); + const { + noteIndex: recentlyMutatedNoteIndex = -1, + trackIndex: recentlyMutatedTrackIndex = -1, + } = opts.recentlyMutated || {}; + const shouldFlashGreen = opts.specialCharFlash ?? false; + const shouldFlashMutation = opts.mutationFlash?.active ?? false; + const triggeredAsteriskPositions = opts.triggeredAsteriskPositions || []; + const getStampleStatus = opts.getStampleStatus || (() => null); + + let coloredMelodyString = ""; + let noteCharPositions = []; + let noteIndex = 0; + let inWaveform = false; + let currentNoteIndex = 0; + + // For sequential melodies, get the effective melodyState (currentSequenceState). + let effectiveMelodyState = melodyState; + let currentSequenceIndex = 0; + let isSequentialMelody = melodyState && melodyState.type === "sequential"; + + if (isSequentialMelody) { + currentSequenceIndex = melodyState.currentSequence ?? 0; + if (melodyState.currentSequenceState) { + effectiveMelodyState = melodyState.currentSequenceState; + } else if (melodyState.sequences && melodyState.sequences.length > 0) { + effectiveMelodyState = melodyState.sequences[0].parsed; + } + } + + if ( + timingHasStarted && + effectiveMelodyState && + effectiveMelodyState.type === "single" + ) { + currentNoteIndex = getCurrentNoteIndex(effectiveMelodyState); + } else if ( + timingHasStarted && + effectiveMelodyState && + effectiveMelodyState.type === "parallel" + ) { + // handled below per track + } else { + currentNoteIndex = -1; + } + + // For sequential melodies, split by > to identify sequences. + let sequences = []; + let sequenceDelimiterPositions = []; + if (isSequentialMelody) { + let lastSplitPos = 0; + for (let i = 0; i < melodyString.length; i++) { + if (melodyString[i] === ">") { + sequences.push(melodyString.substring(lastSplitPos, i).trim()); + sequenceDelimiterPositions.push(i); + lastSplitPos = i + 1; + } + } + sequences.push(melodyString.substring(lastSplitPos).trim()); + } + + // Split by spaces to get groups, then process each group. + const groups = melodyString.trim().split(/\s+/); + let groupStartPositions = []; + let searchStart = 0; + for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { + const group = groups[groupIdx]; + const groupStart = melodyString.indexOf(group, searchStart); + groupStartPositions.push(groupStart); + searchStart = groupStart + group.length; + } + + // Map groups to tracks within the current sequence (sequential only). + let effectiveGroupToTrackMap = []; + let currentSeqIdx = 0; + let currentTrackInSeq = 0; + if (isSequentialMelody) { + for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { + const group = groups[groupIdx]; + if (/^\d*>$/.test(group)) { + currentSeqIdx++; + currentTrackInSeq = 0; + effectiveGroupToTrackMap.push({ + isDelimiter: true, + sequenceIndex: currentSeqIdx - 1, + }); + } else { + effectiveGroupToTrackMap.push({ + isDelimiter: false, + sequenceIndex: currentSeqIdx, + trackIndex: currentTrackInSeq, + isCurrentSequence: currentSeqIdx === currentSequenceIndex, + }); + currentTrackInSeq++; + } + } + } + + // First pass: identify all note character positions and their modifiers. + for (let groupIdx = 0; groupIdx < groups.length; groupIdx++) { + const group = groups[groupIdx]; + const groupStartChar = groupStartPositions[groupIdx]; + noteIndex = 0; + + let trackIdxForColoring = groupIdx; + let isInCurrentSequence = true; + + if (isSequentialMelody && effectiveGroupToTrackMap[groupIdx]) { + const mapping = effectiveGroupToTrackMap[groupIdx]; + if (mapping.isDelimiter) continue; + trackIdxForColoring = mapping.trackIndex; + isInCurrentSequence = mapping.isCurrentSequence; + } + + for (let i = 0; i < group.length; i++) { + const char = group[i]; + + if (char === "{") { inWaveform = true; continue; } + else if (char === "}") { inWaveform = false; continue; } + else if (inWaveform) { continue; } + + // Octave-first notation (5f, 4c#, …) + notepat chars after octave numbers. + if ( + /[0-9]/.test(char) && + i + 1 < group.length && + /[a-gvswrqhijklmntyuop]/i.test(group[i + 1]) + ) { + let noteStart = i; + let noteEnd = i + 1; + if (noteEnd + 1 < group.length) { + if (group[noteEnd + 1] === "#") noteEnd++; + } + while (noteEnd + 1 < group.length) { + const nextChar = group[noteEnd + 1]; + if ( + nextChar === "." || nextChar === "-" || nextChar === "[" || + nextChar === "]" || nextChar === "," || nextChar === "*" + ) noteEnd++; + else break; + } + const noteIndexToUse = noteIndex; + const trackIndexToUse = isSequentialMelody + ? trackIdxForColoring + : (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); + for (let j = noteStart; j <= noteEnd; j++) { + noteCharPositions.push({ + charIndex: groupStartChar + j, + noteIndex: noteIndexToUse, + trackIndex: trackIndexToUse, + isInCurrentSequence, + }); + } + noteIndex++; + i = noteEnd; + } + // Struck mode toggle (^) — skip. + else if (char === "^") { + continue; + } + // Dashes — only count if they lead to a note (relative octave like ---c). + else if (char === "-") { + let dashEnd = i; + while (dashEnd + 1 < group.length && group[dashEnd + 1] === "-") dashEnd++; + if ( + dashEnd + 1 < group.length && + /[a-gvswrqhijklmntyuop]/i.test(group[dashEnd + 1]) + ) { + let noteStart = i; + let noteEnd = dashEnd + 1; + if (noteEnd + 1 < group.length && group[noteEnd + 1] === "#") noteEnd++; + while (noteEnd + 1 < group.length) { + const nextChar = group[noteEnd + 1]; + if ( + nextChar === "." || nextChar === "," || nextChar === "*" || + nextChar === "[" || nextChar === "]" + ) noteEnd++; + else break; + } + const noteIndexToUse = noteIndex; + const trackIndexToUse = isSequentialMelody + ? trackIdxForColoring + : (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); + for (let j = noteStart; j <= noteEnd; j++) { + noteCharPositions.push({ + charIndex: groupStartChar + j, + noteIndex: noteIndexToUse, + trackIndex: trackIndexToUse, + isInCurrentSequence, + }); + } + noteIndex++; + i = noteEnd; + } else { + i = dashEnd; + continue; + } + } + // + relative octave modifier (must be followed by a note). + else if (char === "+") { + let plusEnd = i; + while (plusEnd + 1 < group.length && group[plusEnd + 1] === "+") plusEnd++; + if ( + plusEnd + 1 < group.length && + /[a-gvswrqhijklmntyuop]/i.test(group[plusEnd + 1]) + ) { + let noteStart = i; + let noteEnd = plusEnd + 1; + if (noteEnd + 1 < group.length && group[noteEnd + 1] === "#") noteEnd++; + while (noteEnd + 1 < group.length) { + const nextChar = group[noteEnd + 1]; + if ( + nextChar === "." || nextChar === "," || nextChar === "*" || + nextChar === "[" || nextChar === "]" + ) noteEnd++; + else break; + } + const noteIndexToUse = noteIndex; + const trackIndexToUse = isSequentialMelody + ? trackIdxForColoring + : (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); + for (let j = noteStart; j <= noteEnd; j++) { + noteCharPositions.push({ + charIndex: groupStartChar + j, + noteIndex: noteIndexToUse, + trackIndex: trackIndexToUse, + isInCurrentSequence, + }); + } + noteIndex++; + i = noteEnd; + } else { + i = plusEnd; + continue; + } + } + // Regular note letters (incl. notepat chars vswrq/hijklmn/tyuop). + else if (/[a-g#_vswrqhijklmntyuop]/i.test(char)) { + let noteStart = i; + let noteEnd = i; + if (noteEnd + 1 < group.length) { + if (group[noteEnd + 1] === "#") noteEnd++; + } + while (noteEnd + 1 < group.length) { + const nextChar = group[noteEnd + 1]; + if ( + nextChar === "." || nextChar === "-" || nextChar === "[" || + nextChar === "]" || nextChar === "," || nextChar === "*" + ) noteEnd++; + else break; + } + const noteIndexToUse = noteIndex; + const trackIndexToUse = isSequentialMelody + ? trackIdxForColoring + : (effectiveMelodyState && effectiveMelodyState.type === "parallel" ? groupIdx : 0); + for (let j = noteStart; j <= noteEnd; j++) { + noteCharPositions.push({ + charIndex: groupStartChar + j, + noteIndex: noteIndexToUse, + trackIndex: trackIndexToUse, + isInCurrentSequence, + }); + } + noteIndex++; + i = noteEnd; + } + } + } + + // ── Color the original melody string character by character ── + let inWaveformForColoring = false; + + function getRedNoteColor() { + return "red"; + } + + function getStaticNoteColor(noteCharData) { + if (!noteCharData) return "gray"; + const stateToUse = effectiveMelodyState || melodyState; + if (!stateToUse) return "gray"; + let note = null; + if (stateToUse.type === "single" && stateToUse.notes) { + note = stateToUse.notes[noteCharData.noteIndex]; + } else if (stateToUse.type === "parallel" && stateToUse.tracks) { + const track = stateToUse.tracks[noteCharData.trackIndex]; + note = track && track[noteCharData.noteIndex]; + } + if (!note) return "gray"; + const rgb = getNoteColor(note.note); + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + } + + function isNoteMutated(noteCharData) { + if (!noteCharData) return false; + const stateToUse = effectiveMelodyState || melodyState; + if (!stateToUse) return false; + if (stateToUse.type === "single") { + const currentNote = stateToUse.notes && stateToUse.notes[noteCharData.noteIndex]; + return currentNote && currentNote.isMutation; + } else if ( + stateToUse.type === "parallel" && + stateToUse.trackStates && + noteCharData.trackIndex < stateToUse.trackStates.length + ) { + const track = stateToUse.tracks[noteCharData.trackIndex]; + const currentNote = track && track[noteCharData.noteIndex]; + return currentNote && currentNote.isMutation; + } + return false; + } + + function getMutatedNoteColor(isCurrentlyPlaying = false, noteCharData = null) { + const isRecentlyMutatedNote = + noteCharData && + noteCharData.noteIndex === recentlyMutatedNoteIndex && + noteCharData.trackIndex === recentlyMutatedTrackIndex; + const shouldFlashForThisNote = shouldFlashMutation && isRecentlyMutatedNote; + if (shouldFlashForThisNote) { + const time = now * 0.005; + const hue = (time % 1) * 360; + return hsvToRgb(hue, 1, 1); + } + if (isCurrentlyPlaying) return getRedNoteColor(); + return "goldenrod"; + } + + for (let i = 0; i < melodyString.length; i++) { + const char = melodyString[i]; + let color = "yellow"; + + // Sequence separator `>`. + if (char === ">") { + const prevChar = i > 0 ? melodyString[i - 1] : " "; + if (/\d/.test(prevChar)) color = "white"; + else color = "cyan"; + coloredMelodyString += `\\${color}\\${char}`; + continue; + } + + // Loop-count digits before `>` (like "3>"). + if (/\d/.test(char)) { + let lookAhead = i + 1; + while (lookAhead < melodyString.length && /\d/.test(melodyString[lookAhead])) lookAhead++; + if (lookAhead < melodyString.length && melodyString[lookAhead] === ">") { + color = "magenta"; + coloredMelodyString += `\\${color}\\${char}`; + continue; + } + } + + if (char === " ") { + color = timingHasStarted ? "yellow" : "gray"; + } else if (char === "x") { + // Disabled-group `x` prefix detection. + let isGroupPrefix = false; + if (i === 0) { + isGroupPrefix = true; + } else { + let j = i - 1; + while (j >= 0 && melodyString[j] === " ") j--; + isGroupPrefix = j < 0 || melodyString[j] === " "; + } + if (isGroupPrefix) { + color = "brown"; + } else { + color = timingHasStarted ? "yellow" : "gray"; + } + } else { + // Determine if this char is inside a disabled group. + let isInDisabledGroup = false; + { + let charCount = 0; + const groups2 = melodyString.trim().split(/\s+/); + for (let groupIdx = 0; groupIdx < groups2.length; groupIdx++) { + const group = groups2[groupIdx]; + const groupStart = melodyString.indexOf(group, charCount); + const groupEnd = groupStart + group.length; + if (i >= groupStart && i < groupEnd) { + isInDisabledGroup = group.startsWith("x"); + break; + } + charCount = groupEnd; + } + } + + if (isInDisabledGroup) { + color = "gray"; + } else { + const noteCharData = noteCharPositions.find((ncp) => ncp.charIndex === i); + let isCurrentlyPlayingNote = false; + + if (noteCharData) { + if (melodyState && melodyState.type === "single") { + isCurrentlyPlayingNote = noteCharData.noteIndex === currentNoteIndex; + } else if ( + melodyState && + melodyState.type === "parallel" && + melodyState.trackStates && + noteCharData.trackIndex < melodyState.trackStates.length + ) { + const currentPlayingIndex = getCurrentNoteIndex( + melodyState, + noteCharData.trackIndex, + ); + isCurrentlyPlayingNote = noteCharData.noteIndex === currentPlayingIndex; + } else if ( + melodyState && + melodyState.type === "sequential" && + melodyState.currentSequenceState + ) { + if (noteCharData.isInCurrentSequence) { + const seqState = melodyState.currentSequenceState; + if (seqState.type === "single" && seqState.notes) { + const totalNotes = seqState.notes.length; + const currentPlayingIndex = (seqState.index - 1 + totalNotes) % totalNotes; + isCurrentlyPlayingNote = noteCharData.noteIndex === currentPlayingIndex; + } else if (seqState.type === "parallel" && seqState.trackStates) { + const trackState = seqState.trackStates[noteCharData.trackIndex]; + if (trackState && trackState.track) { + const totalNotes = trackState.track.length; + const currentPlayingIndex = (trackState.noteIndex - 1 + totalNotes) % totalNotes; + isCurrentlyPlayingNote = noteCharData.noteIndex === currentPlayingIndex; + } + } + } + } + } + + if (char === "{") { + inWaveformForColoring = true; + color = shouldFlashGreen ? "green" : "yellow"; + } else if (char === "}") { + inWaveformForColoring = false; + color = shouldFlashGreen ? "green" : "yellow"; + } else if (inWaveformForColoring) { + // Stample code {#code} vs regular waveform content. + let lookBack = i - 1; + let waveContent = ""; + while (lookBack >= 0 && melodyString[lookBack] !== "{") { + waveContent = melodyString[lookBack] + waveContent; + lookBack--; + } + waveContent = waveContent + char; + if ( + waveContent.startsWith("#") || + (lookBack >= 0 && melodyString[lookBack + 1] === "#") + ) { + const hashIdx = waveContent.indexOf("#"); + const code = waveContent.substring(hashIdx + 1).replace(/[^a-zA-Z0-9]/g, ""); + const cached = getStampleStatus(code); + if (cached && cached.loaded) color = "lime"; + else if (cached && cached.loading) color = "cyan"; + else if (cached && cached.error) color = [255, 100, 100]; + else color = "orange"; + } else { + color = "cyan"; + } + } else if (char === "*") { + if ( + shouldFlashMutation && + (triggeredAsteriskPositions.includes(i) || + triggeredAsteriskPositions.includes("*")) + ) { + color = "white"; + } else { + const time = now * 0.005; + const hue = (time % 1) * 360; + color = hsvToRgb(hue, 1, 1); + } + } else if (melodyState && melodyState.isFallback) { + if (noteCharData) { + if (isCurrentlyPlayingNote) { + const isMutated = isNoteMutated(noteCharData); + if (/[.,]/i.test(char)) { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (/[s]/i.test(char)) { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (char === "_") { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (/[0-9+\-#<>]/i.test(char)) { + color = "green"; + } else { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } + } else { + const isMutated = isNoteMutated(noteCharData); + if (isMutated) { + color = getMutatedNoteColor(false, noteCharData); + } else { + color = timingHasStarted ? "yellow" : getStaticNoteColor(noteCharData); + } + } + } else { + color = timingHasStarted ? "yellow" : "gray"; + } + } else { + if (noteCharData) { + if (isCurrentlyPlayingNote) { + const isMutated = isNoteMutated(noteCharData); + if (/[.,]/i.test(char)) { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (/[s]/i.test(char)) { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (char === "_") { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } else if (/[0-9+\-#\[\]]/i.test(char)) { + color = "green"; + } else { + color = isMutated ? getMutatedNoteColor(true, noteCharData) : getRedNoteColor(); + } + } else { + const isMutated = isNoteMutated(noteCharData); + if (isMutated) { + color = getMutatedNoteColor(false, noteCharData); + } else { + color = timingHasStarted ? "yellow" : "gray"; + } + } + } else { + color = timingHasStarted ? "yellow" : "gray"; + } + } + } + } + + // Dim notes not in the current sequence (sequential melodies). + const noteCharData2 = noteCharPositions.find((ncp) => ncp.charIndex === i); + const shouldDim = + isSequentialMelody && noteCharData2 && noteCharData2.isInCurrentSequence === false; + if (shouldDim && color !== "cyan" && color !== "magenta" && color !== "white") { + if (typeof color === "string") { + color = "gray"; + } else if (Array.isArray(color)) { + color = [ + Math.round(color[0] * 0.3), + Math.round(color[1] * 0.3), + Math.round(color[2] * 0.3), + ]; + } + } + + if (Array.isArray(color)) { + coloredMelodyString += `\\${color[0]},${color[1]},${color[2]}\\${char}`; + } else { + coloredMelodyString += `\\${color}\\${char}`; + } + } + + return coloredMelodyString; +} diff --git a/system/public/aesthetic.computer/lib/melody-parser.mjs b/system/public/aesthetic.computer/lib/melody-parser.mjs index 92276a20e6..7919bc24a4 100644 --- a/system/public/aesthetic.computer/lib/melody-parser.mjs +++ b/system/public/aesthetic.computer/lib/melody-parser.mjs @@ -1560,7 +1560,92 @@ export function extractSayTexts(melodyState) { } } } - + return texts; } +// ── Playable state builders (shared by KidLisp's multi-track scheduler) ────── +// Turn a parsed melody (from parseSequentialMelody/parseSimultaneousMelody) into +// a runtime state with the scheduling fields the player and the highlighter both +// expect. Keeps note arrays by reference (no copy). + +// Build a playable state for one sequence/segment (single or parallel). +export function buildSequenceState(seqParsed) { + if (!seqParsed) return null; + if (seqParsed.type === "parallel") { + return { + type: "parallel", + tracks: seqParsed.tracks, + trackStates: seqParsed.tracks.map((track, i) => ({ + trackIndex: i, + track, + noteIndex: 0, + nextNoteTargetTime: 0, + measurePosition: 0, + })), + }; + } + const notes = seqParsed.tracks ? seqParsed.tracks[0] : seqParsed.notes || []; + return { type: "single", notes, index: 0, nextNoteTargetTime: 0, measurePosition: 0 }; +} + +// Total beats a sequence/segment occupies (its longest track). +export function sequenceDurationBeats(seqParsed) { + if (!seqParsed) return 0; + const tracks = + seqParsed.type === "parallel" + ? seqParsed.tracks + : [seqParsed.tracks ? seqParsed.tracks[0] : seqParsed.notes || []]; + let maxBeats = 0; + for (const tr of tracks) { + let b = 0; + for (const n of tr || []) b += n.duration || 1; + if (b > maxBeats) maxBeats = b; + } + return maxBeats; +} + +// Build the top-level melody state for single / parallel / sequential. +export function buildMelodyState(parsed, { baseTempo = 500 } = {}) { + if (!parsed) return null; + + if (parsed.type === "sequential") { + return { + type: "sequential", + sequences: parsed.sequences, + currentSequence: 0, + currentSequenceState: buildSequenceState(parsed.sequences[0]), + baseTempo, + isFallback: false, + }; + } + + if (parsed.type === "parallel") { + return { + type: "parallel", + tracks: parsed.tracks, + trackStates: parsed.tracks.map((track, i) => ({ + trackIndex: i, + track, + noteIndex: 0, + nextNoteTargetTime: 0, + measurePosition: 0, + })), + baseTempo, + isFallback: false, + }; + } + + // single (parseSimultaneousMelody returns { tracks:[track], type:'single' }) + const notes = parsed.tracks ? parsed.tracks[0] : parsed.notes || []; + return { + type: "single", + notes, + index: 0, + nextNoteTargetTime: 0, + measurePosition: 0, + baseTempo, + isFallback: !!parsed.isFallback, + }; +} + diff --git a/system/public/aesthetic.computer/lib/melody-transport.mjs b/system/public/aesthetic.computer/lib/melody-transport.mjs new file mode 100644 index 0000000000..b4800523aa --- /dev/null +++ b/system/public/aesthetic.computer/lib/melody-transport.mjs @@ -0,0 +1,58 @@ +// Melody Transport — a shared musical clock (BPM + origin) that both the melody +// scheduler and the KidLisp timer engine read, so visual timers can lock to and +// syncopate against the groove. +// +// Created lazily by the first `(melody …)` in a piece (that melody becomes the +// "master"). The `b` timing unit divides `beatMs`; the `beat`/`beatphase` +// KidLisp variables expose the running position. `s`/`f` timers keep wall-clock +// meaning but phase-align to `startMs` via the timer engine's offset hook. +// +// Per-context (a KidLisp instance owns `this.transport`; clock.mjs could own a +// module-level one) so embedded/independent pieces don't share transport state. + +export const DEFAULT_BPM = 120; + +const _now = () => + typeof performance !== "undefined" ? performance.now() : Date.now(); + +export function createTransport({ + bpm = DEFAULT_BPM, + startMs = _now(), + timeSignature = "4/4", +} = {}) { + const beatsPerMeasure = parseInt(String(timeSignature).split("/")[0], 10) || 4; + return { + startMs, + bpm, + beatMs: 60000 / bpm, + timeSignature, + beatsPerMeasure, + + // Change tempo while keeping the current beat phase continuous. + setBpm(nextBpm, now = _now()) { + if (!Number.isFinite(nextBpm) || nextBpm <= 0) return; + const beat = this.getBeat(now); + this.bpm = nextBpm; + this.beatMs = 60000 / nextBpm; + this.startMs = now - beat * this.beatMs; // re-anchor so phase doesn't jump + }, + + // Running float beat count since origin (monotonic). e.g. (spin (* 2 beat)). + getBeat(now = _now()) { + return (now - this.startMs) / this.beatMs; + }, + + // 0..1 sawtooth within the current beat. e.g. (scroll 0 (* 10 beatphase)). + getPhase(now = _now()) { + const b = this.getBeat(now); + return b - Math.floor(b); + }, + + // 0..1 within the current measure. + getMeasurePhase(now = _now()) { + const b = this.getBeat(now); + const m = b % this.beatsPerMeasure; + return m / this.beatsPerMeasure; + }, + }; +}