diff --git a/fedac/native/macos/main.c b/fedac/native/macos/main.c --- a/fedac/native/macos/main.c +++ b/fedac/native/macos/main.c @@ -106,6 +106,12 @@ case SDLK_RETURN: s = "enter"; break; case SDLK_ESCAPE: s = "escape"; break; case SDLK_BACKSPACE: s = "backspace"; break; case SDLK_TAB: s = "tab"; break; + case SDLK_PAGEUP: s = "pageup"; break; + case SDLK_PAGEDOWN: s = "pagedown"; break; + case SDLK_HOME: s = "home"; break; + case SDLK_END: s = "end"; break; + case SDLK_LSHIFT: + case SDLK_RSHIFT: s = "shift"; break; default: break; } if (s) { snprintf(out, n, "%s", s); return; } @@ -845,20 +851,26 @@ // event so notepat's sound.synth path fires in a headless run. const char *inject_key = getenv("AC_INJECT_KEY"); int injected = 0; - // AC_INJECT_SEQUENCE=",|,|…" — scripted typing - // timeline. The first key fires at from start; each subsequent - // key fires after the prior event. Supports multi-char names - // (e.g. "enter", "space", "backspace") for the prompt. Used by the - // demo recorder to type "notepat" without real input. + // AC_INJECT_SEQUENCE=",[,]|…" — scripted + // typing timeline. is cumulative from the prior event. + // Optional delays the paired keyup; omitted (or 0) means + // fire keyup immediately (classic tap). Useful for sustained notes + // and chords under drums. Multi-char key names supported + // (e.g. "enter", "space", "pageup", "arrowup"). const char *seq_env = getenv("AC_INJECT_SEQUENCE"); - typedef struct { char key[32]; int at_ms; } SeqEvent; + typedef struct { char key[32]; int at_ms; int hold_ms; } SeqEvent; + typedef struct { char key[32]; int at_ms; int fired; } PendingUp; SeqEvent *seq = NULL; int seq_len = 0, seq_cur = 0; + PendingUp *ups = NULL; + int ups_cap = 0, ups_len = 0; if (seq_env && seq_env[0]) { // First pass: count segments to allocate. int count = 1; for (const char *p = seq_env; *p; p++) if (*p == '|') count++; seq = calloc(count, sizeof *seq); + ups = calloc(count, sizeof *ups); + ups_cap = count; int cumul = 0; const char *p = seq_env; while (*p && seq_len < count) { @@ -870,9 +882,14 @@ int klen = (int)(comma - p); if (klen >= (int)sizeof(seq[0].key)) klen = sizeof(seq[0].key) - 1; memcpy(seq[seq_len].key, p, klen); seq[seq_len].key[klen] = 0; + // Optional second comma = hold_ms. + const char *comma2 = memchr(comma + 1, ',', (size_t)(pipe - (comma + 1))); int dly = atoi(comma + 1); + int hold = (comma2 && comma2 < pipe) ? atoi(comma2 + 1) : 0; + if (hold < 0) hold = 0; cumul += dly; - seq[seq_len].at_ms = cumul; + seq[seq_len].at_ms = cumul; + seq[seq_len].hold_ms = hold; seq_len++; if (!*pipe) break; p = pipe + 1; @@ -915,21 +932,43 @@ running = 0; break; } // Scripted input timeline — dispatch the next pending key when - // its cumulative delay has elapsed. Each dispatch fires a paired - // down+up event so the piece's keyup handlers (notepat releases - // notes on keyup) run naturally. + // its cumulative delay has elapsed. If hold_ms is 0 we fire + // down+up back-to-back (tap); otherwise the keyup goes onto a + // pending queue drained below. Lets notepat sustain notes so + // the melody grooves rather than clicking. while (seq && seq_cur < seq_len && (int)(SDL_GetTicks() - start_tick) >= seq[seq_cur].at_ms) { - PieceEvent pd = {0}, pu = {0}; + PieceEvent pd = {0}; snprintf(pd.key, sizeof pd.key, "%s", seq[seq_cur].key); snprintf(pd.type, sizeof pd.type, "keyboard:down:%s", seq[seq_cur].key); - snprintf(pu.key, sizeof pu.key, "%s", seq[seq_cur].key); - snprintf(pu.type, sizeof pu.type, "keyboard:up:%s", seq[seq_cur].key); piece_act(pc, &pd); - piece_act(pc, &pu); - fprintf(stderr, "[sequence] fired %s @ %dms\n", - seq[seq_cur].key, seq[seq_cur].at_ms); + if (seq[seq_cur].hold_ms <= 0) { + PieceEvent pu = {0}; + snprintf(pu.key, sizeof pu.key, "%s", seq[seq_cur].key); + snprintf(pu.type, sizeof pu.type, "keyboard:up:%s", seq[seq_cur].key); + piece_act(pc, &pu); + } else if (ups && ups_len < ups_cap) { + snprintf(ups[ups_len].key, sizeof ups[ups_len].key, + "%s", seq[seq_cur].key); + ups[ups_len].at_ms = seq[seq_cur].at_ms + seq[seq_cur].hold_ms; + ups[ups_len].fired = 0; + ups_len++; + } + fprintf(stderr, "[sequence] fired %s @ %dms (hold %d)\n", + seq[seq_cur].key, seq[seq_cur].at_ms, + seq[seq_cur].hold_ms); seq_cur++; + } + // Drain pending keyups whose hold window has elapsed. + int now_rel = (int)(SDL_GetTicks() - start_tick); + for (int i = 0; i < ups_len; i++) { + if (ups[i].fired) continue; + if (now_rel < ups[i].at_ms) continue; + PieceEvent pu = {0}; + snprintf(pu.key, sizeof pu.key, "%s", ups[i].key); + snprintf(pu.type, sizeof pu.type, "keyboard:up:%s", ups[i].key); + piece_act(pc, &pu); + ups[i].fired = 1; } if (inject_key && !injected && (SDL_GetTicks() - start_tick) >= 300) { PieceEvent pe = {0}; @@ -1102,6 +1141,7 @@ Audio *au = piece_audio(pc); if (au) audio_wav_stop(au); } free(seq); + free(ups); piece_destroy(pc); free(fb.pixels); SDL_DestroyTexture(tex); diff --git a/fedac/native/macos/scripts/bachbeat-seq.py b/fedac/native/macos/scripts/bachbeat-seq.py new file mode 100644 --- /dev/null +++ b/fedac/native/macos/scripts/bachbeat-seq.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# bachbeat-seq.py — emit an AC_INJECT_SEQUENCE playing the opening of +# Bach's Cello Suite No. 1 Prelude (BWV 1007) on one grid + a simple +# 4/4 drum groove on the other. +# +# Source: artery/test-notepat-bach-prelude.mjs (MIDI_EVENTS array, +# transposed to notepat range). We take the first N events so the demo +# stays under ~20 s. Notes are held for ~90% of their duration so the +# phrasing comes through; gaps are ~10% so consecutive same-pitch +# repeats still register as separate hits. +# +# Drums: steady kick on beat 1, hat every eighth note, snare on beat 3. +# Simple rock/lofi beat that underlays the baroque line. + +import re, sys +from pathlib import Path + +# ---- Note-label → notepat keyboard key (matches fedac/native/pieces/notepat.mjs) ---- +LABEL_TO_KEY = { + "-a": "control", "-a#": "z", "-b": "x", + "c": "c", "c#": "v", "d": "d", "d#": "s", "e": "e", "f": "f", + "f#": "w", "g": "g", "g#": "r", "a": "a", "a#": "q", "b": "b", + "+c": "h", "+c#": "t", "+d": "i", "+d#": "y", "+e": "j", "+f": "k", + "+f#": "u", "+g": "l", "+g#": "o", "+a": "m", "+a#": "p", "+b": "n", + "++c": ";", "++c#": "'", "++d": "]", +} + +# Drum mapping for the RIGHT grid (matches waltz-seq.py). +DRUM_KEY = {"kick": "h", "snare": "i", "hat": "l", "hatOpen": "m"} + +MIDI_TICKS_PER_BEAT = 480 + +def load_events(max_events: int): + """Parse MIDI_EVENTS from the artery source — regex beats pulling in + Node deps. Extracts {key, ticks} tuples in order.""" + # scripts/ → macos/ → native/ → fedac/ → REPO/ → artery/ + src = (Path(__file__).resolve().parent.parent.parent.parent.parent + / "artery/test-notepat-bach-prelude.mjs").resolve() + text = src.read_text() + pat = re.compile(r'\{\s*key:\s*"([^"]+)"\s*,\s*ticks:\s*(\d+)') + out = [] + for m in pat.finditer(text): + label, ticks = m.group(1), int(m.group(2)) + key = LABEL_TO_KEY.get(label) + if not key: + continue + out.append((key, ticks)) + if len(out) >= max_events: break + return out + +def build(bpm: int, events_count: int, start_offset_ms: int, + melody_grid: str = "left") -> str: + """Build the combined bach + drums sequence. melody_grid picks which + grid holds the notes; the other grid plays drums.""" + if melody_grid not in ("left", "right"): + raise SystemExit("melody_grid must be 'left' or 'right'") + # Drum grid is the other one — load the right drum-key table. + # For this script we only handle drums on the RIGHT (the typical + # live-piano layout); if melody_grid=="right" we fall back to + # LEFT-grid drum keys (c/d/g/a). + drum_keys = DRUM_KEY if melody_grid == "left" else { + "kick": "c", "snare": "d", "hat": "g", "hatOpen": "a", + } + + ms_per_beat = 60000 / bpm + ms_per_tick = ms_per_beat / MIDI_TICKS_PER_BEAT + + # ---- Melody notes ---- + events = load_events(events_count) + melody_hits = [] # (abs_ms, key, hold_ms) + t = 0 + for key, ticks in events: + dur_ms = max(60, int(round(ticks * ms_per_tick))) + hold = max(40, int(dur_ms * 0.88)) + melody_hits.append((t, key, hold)) + t += dur_ms + + total_ms = t + + # ---- Drum beat (4/4) underneath ---- + # Steady eighth-note hat, kick on 1, snare on 3. Simple & spaced so + # it doesn't mask the baroque line. + drum_hits = [] + eighth_ms = ms_per_beat / 2 + beat_ms = ms_per_beat + n_beats = int(total_ms / beat_ms) + 1 + for beat in range(n_beats): + b_start = beat * beat_ms + beat_in_bar = beat % 4 + # Kick on beat 1 of every bar. + if beat_in_bar == 0: + drum_hits.append((int(b_start), drum_keys["kick"], 0)) + # Snare on beat 3. + if beat_in_bar == 2: + drum_hits.append((int(b_start), drum_keys["snare"], 0)) + # Hat on every eighth (on + off). + drum_hits.append((int(b_start), drum_keys["hat"], 0)) + drum_hits.append((int(b_start + eighth_ms), drum_keys["hat"], 0)) + # Occasional open hat for flavor on the "and of 4". + if beat_in_bar == 3: + drum_hits.append((int(b_start + eighth_ms), drum_keys["hatOpen"], 0)) + + # Merge melody + drums, sort by time (then key for stable order). + all_hits = [(t, k, h) for (t, k, h) in melody_hits] \ + + [(t, k, h) for (t, k, h) in drum_hits if t < total_ms] + all_hits.sort(key=lambda h: (h[0], h[1])) + + # Emit deltas from start_offset_ms anchor. Optional per-event hold. + out, prev = [], -start_offset_ms + for t, k, h in all_hits: + delta = t - prev + if delta < 0: delta = 0 + if h > 0: out.append(f"{k},{delta},{h}") + else: out.append(f"{k},{delta}") + prev = t + return "|".join(out) + +def main(): + bpm = int(sys.argv[1]) if len(sys.argv) > 1 else 100 + evts = int(sys.argv[2]) if len(sys.argv) > 2 else 64 + start = int(sys.argv[3]) if len(sys.argv) > 3 else 0 + grid = sys.argv[4] if len(sys.argv) > 4 else "left" + print(build(bpm, evts, start, grid)) + +if __name__ == "__main__": + main() diff --git a/fedac/native/macos/scripts/demo.sh b/fedac/native/macos/scripts/demo.sh --- a/fedac/native/macos/scripts/demo.sh +++ b/fedac/native/macos/scripts/demo.sh @@ -24,7 +24,29 @@ HANDLE="${HANDLE:-jeffrey}" CITY="${CITY:-Los Angeles}" HOUR="${HOUR:-13}" -OUT="${OUT:-$HOME/Desktop/ac-boot-shots/demo.mkv}" +# WALTZ picks a drum pattern from scripts/waltz-seq.py (classic, dark, +# dreamy, baroque, minimal, phonk, viennese, drill). BPM + BARS control +# tempo and length. When WALTZ=melody the legacy oom-pah-pah melody +# sequence plays instead — useful for quick A/B vs the drum grids. +MODE="${MODE:-waltz}" # waltz | bachbeat +WALTZ="${WALTZ:-classic}" # classic, dark, dreamy, baroque, minimal, phonk, viennese, drill +BPM="${BPM:-120}" # waltz default 120; bachbeat default 100 +BARS="${BARS:-4}" # waltz bar count +BACH_EVENTS="${BACH_EVENTS:-48}" # bachbeat: how many bach notes to play +# GRID chooses which hand plays drums. Default "right" = right-hand kit +# + left-hand melody (classic live-piano setup). "left" reverses it. +GRID="${GRID:-right}" +# MELODY=on interleaves an oom-pah-pah bass+chord line on the OTHER +# grid so notes and drums play simultaneously (waltz mode only). +MELODY="${MELODY:-on}" +# KIT_VOL down-ticks for the perc kit after it's enabled — each tick +# drops ~10% volume (notepat arrowdown). 3 = kit ~70%, 5 = ~50%. +KIT_MIXDOWN="${KIT_MIXDOWN:-3}" +case "$MODE" in + bachbeat) OUT_DEFAULT="$HOME/Desktop/ac-boot-shots/demo-bachbeat.mkv" ;; + *) OUT_DEFAULT="$HOME/Desktop/ac-boot-shots/demo-$WALTZ.mkv" ;; +esac +OUT="${OUT:-$OUT_DEFAULT}" STAGE="${STAGE:-/tmp/ac-demo-final}" REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd) @@ -41,23 +63,52 @@ # Typing timeline — all delays are cumulative offsets from prompt load. # Boot animation runs for ~2s before this, so the earliest keystroke is # effectively ~2.5s into the final video. # -# Layout: -# prompt typing: n o t e p a t (quick, ~1s) -# ~1.4s pause while notepat loads + settles -# waltz: 4 bars × 3 beats at 500ms/beat = 6s -# z c e (Cm bar) -# z f a (Fm bar) -# z g b (Gm bar) -# z c e (Cm bar back home) -SEQ=( - "n,500" "o,120" "t,120" "e,120" "p,120" "a,120" "t,120" "enter,400" - "z,1380" "c,500" "e,500" - "z,500" "f,500" "a,500" - "z,500" "g,500" "b,500" - "z,500" "c,500" "e,500" -) -# Join with '|' separators (AC_INJECT_SEQUENCE syntax). -IFS='|' SEQ_STR="${SEQ[*]}"; unset IFS +# 0.5s prompt typing: n o t e p a t (quick, ~1s) +# 1.4s pause while notepat loads + settles +# + pageup/pagedown flips LEFT or RIGHT grid into the perc kit +# + 0.4s breath before the groove kicks in +# + generated drum sequence (+ optional interleaved melody) + +# GRID=right → pagedown (right grid → perc, left grid stays melodic) +# GRID=left → pageup (left grid → perc, right grid stays melodic) +if [[ "$GRID" == "left" ]]; then + KIT_KEY="pageup" + SELECT_KEY="arrowleft" # so arrow-down mixes LEFT grid + PREVIEW_KEY="c" # left-grid kick preview +else + KIT_KEY="pagedown" + SELECT_KEY="arrowright" # so arrow-down mixes RIGHT grid + PREVIEW_KEY="h" # right-grid kick preview (+c → h) +fi + +# Build the kit-mixdown prefix: select the kit side, then fire arrowdown +# $KIT_MIXDOWN times to reduce the perc volume before the beat kicks in. +MIX_SEQ="$KIT_KEY,1400|$SELECT_KEY,200" +for ((i=0; i,|,|... +# deltas are cumulative from the prior event (not absolute). +# +# Usage: +# waltz-seq.py [style [bpm [bars [start_offset_ms [grid [melody]]]]]] +# Defaults: classic 120 4 0 right off + +import sys + +WALTZ_PATTERNS = { + "classic": { + "kick": [1,0,0,0, 0,0,0,0, 0,0,1,0], + "snare": [0,0,0,0, 1,0,0,0, 0,0,0,0], + "hat": [1,1,1,1, 1,1,1,1, 1,1,1,1], + "hatOpen": [0,0,0,0, 0,0,0,1, 0,0,0,0], + }, + "dark": { + "kick": [1,0,0,0, 0,0,1,0, 0,0,0,0], + "snare": [0,0,0,0, 1,0,0,0, 1,0,0,0], + "hat": [1,1,1,1, 1,1,1,1, 1,1,1,1], + "hatOpen": [0,0,0,1, 0,0,0,1, 0,0,0,1], + }, + "dreamy": { + "kick": [1,0,0,0, 0,0,0,0, 0,0,0,0], + "snare": [0,0,0,0, 1,0,0,0, 0,0,0,0], + "hat": [1,0,1,0, 1,0,1,0, 1,0,1,0], + "hatOpen": [0,0,0,0, 0,0,0,0, 0,0,0,1], + }, + "baroque": { + "kick": [1,0,0,1, 0,0,0,0, 1,0,0,0], + "snare": [0,0,0,0, 1,0,0,0, 0,0,1,0], + "hat": [1,1,1,1, 1,1,1,1, 1,1,1,1], + "hatOpen": [0,0,0,0, 0,0,1,0, 0,0,0,1], + }, + "minimal": { + "kick": [1,0,0,0, 0,0,0,0, 0,0,0,0], + "snare": [0,0,0,0, 0,0,0,0, 1,0,0,0], + "hat": [0,0,0,0, 1,0,0,0, 0,0,0,0], + }, + "phonk": { + "kick": [1,0,0,0, 0,0,1,0, 0,1,0,0], + "snare": [0,0,0,0, 1,0,0,0, 0,0,0,1], + "hat": [1,0,1,1, 1,0,1,1, 1,0,1,1], + "hatOpen": [0,0,0,0, 0,0,0,1, 0,0,0,1], + }, + "viennese": { + "kick": [1,0,0,0, 0,0,0,0, 0,0,0,0], + "snare": [0,0,0,0, 1,0,1,0, 1,0,0,0], + "hat": [1,1,1,1, 1,1,1,1, 1,1,1,1], + }, + "drill": { + "kick": [1,0,0,0, 0,0,0,0, 0,0,1,0], + "snare": [0,0,0,0, 0,0,0,0, 1,0,0,0], + "hat": [1,1,1,1, 1,1,1,1, 1,1,1,1], + "hatOpen": [0,0,0,1, 0,0,0,1, 0,0,0,1], + }, +} + +# Drum name → notepat perc-kit keyboard key, per grid. +# Left grid: c(c4) d(d4) g(g4) a(a4) +# Right grid: h(+c5) i(+d5) l(+g5) m(+a5) [via NOTE_TO_KEY in notepat.mjs] +DRUM_KEY_BY_GRID = { + "left": {"kick": "c", "snare": "d", "hat": "g", "hatOpen": "a"}, + "right": {"kick": "h", "snare": "i", "hat": "l", "hatOpen": "m"}, +} + +# Melody grooves — list of (key, step_index) pairs per bar. Step indices +# are 0..11 over the 12 sixteenth-note steps of a 3/4 bar. +# +# Left-hand grooves run on the LEFT grid in C minor pentatonic +# (c, d#, f, g, a#). Notepat left-grid keys: +# c → c4 d#→ s f → f g → g a#→ q +# z → -a# (sub-octave bass) x → -b (sub-octave bass alt) +# +# Four-bar phrase with varied patterns so it doesn't feel mechanical: +# bar 0: bass anchor + ascending arp +# bar 1: syncopated with call-response +# bar 2: 8th-note run up the pentatonic +# bar 3: bar 0 again (return home) +MELODY_BY_GRID = { + "left": [ + # bar 0 — long bass + rising arp resolving on held 5th + [("z", 0, 6), ("c", 4, 2), ("s", 5, 2), ("f", 6, 2), + ("g", 8, 3), ("q", 9, 2), ("g", 10, 3)], + # bar 1 — syncopated: bass + upper-neighbor, held 7th + [("z", 0, 4), ("f", 2, 3), ("g", 3, 2), ("q", 4, 4), + ("c", 6, 3), ("s", 8, 2), ("g", 10, 3)], + # bar 2 — 8th-note run up the pentatonic + [("z", 0, 3), ("c", 2, 2), ("s", 3, 2), ("f", 4, 2), + ("g", 5, 2), ("q", 6, 2), ("c", 8, 3), ("s", 10, 3)], + # bar 3 — return to bar 0 shape + [("z", 0, 6), ("c", 4, 2), ("s", 5, 2), ("f", 6, 2), + ("g", 8, 3), ("q", 9, 2), ("g", 10, 3)], + ], + "right": [ + [("h", 0, 6), ("h", 4, 2), ("y", 5, 2), ("k", 6, 2), + ("l", 8, 3), ("p", 9, 2), ("l", 10, 3)], + [("h", 0, 4), ("k", 2, 3), ("l", 3, 2), ("p", 4, 4), + ("h", 6, 3), ("y", 8, 2), ("l", 10, 3)], + [("h", 0, 3), ("h", 2, 2), ("y", 3, 2), ("k", 4, 2), + ("l", 5, 2), ("p", 6, 2), ("h", 8, 3), ("y", 10, 3)], + [("h", 0, 6), ("h", 4, 2), ("y", 5, 2), ("k", 6, 2), + ("l", 8, 3), ("p", 9, 2), ("l", 10, 3)], + ], +} + +def build(style: str, bpm: int, bars: int, start_ms: int, + grid: str = "right", melody: bool = False) -> str: + pat = WALTZ_PATTERNS.get(style) + if not pat: + raise SystemExit(f"unknown style '{style}'. pick from: {', '.join(WALTZ_PATTERNS)}") + if grid not in DRUM_KEY_BY_GRID: + raise SystemExit(f"unknown grid '{grid}'. pick from: left, right") + drum_keys = DRUM_KEY_BY_GRID[grid] + # 3/4 has 3 beats per bar; 4 sixteenth-steps per beat → 12 steps/bar. + step_ms = int(round(60000 / bpm / 4)) + steps_per_bar = 12 + + # Collect every hit as (abs_ms, key, hold_ms). Drums default hold=0 + # (taps); melody notes carry hold durations so they sustain. + hits = [] + for bar in range(bars): + bar_start = bar * steps_per_bar * step_ms + for step in range(steps_per_bar): + t = bar_start + step * step_ms + for drum, arr in pat.items(): + if arr[step]: + hits.append((t, drum_keys[drum], 0)) + + # Optional minor-pentatonic groove on the OTHER grid. hold_steps + # is in 16th-step units — multiplied by step_ms for absolute ms. + if melody: + melody_grid = "left" if grid == "right" else "right" + groove = MELODY_BY_GRID[melody_grid] + bar_groove = groove[bar % len(groove)] + for key, step, hold_steps in bar_groove: + hits.append((bar_start + step * step_ms, key, + hold_steps * step_ms)) + + hits.sort(key=lambda h: (h[0], h[1])) + + # Emit with deltas. Events at same ms share delta=0. Notes with + # hold > 0 emit three fields (key,delay,hold); drums stay two-field. + out, prev = [], -start_ms + for t, k, h in hits: + delta = t - prev + if delta < 0: delta = 0 + if h > 0: out.append(f"{k},{delta},{h}") + else: out.append(f"{k},{delta}") + prev = t + return "|".join(out) + +def main(): + style = sys.argv[1] if len(sys.argv) > 1 else "classic" + bpm = int(sys.argv[2]) if len(sys.argv) > 2 else 120 + bars = int(sys.argv[3]) if len(sys.argv) > 3 else 4 + start = int(sys.argv[4]) if len(sys.argv) > 4 else 0 + grid = sys.argv[5] if len(sys.argv) > 5 else "right" + melody = (len(sys.argv) > 6 and sys.argv[6].lower() in ("on", "1", "true", "yes")) + print(build(style, bpm, bars, start, grid, melody)) + +if __name__ == "__main__": + main()