diff --git a/pico-cp/README.md b/pico-cp/README.md index 4ac4cdb..649a8bb 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -62,12 +62,16 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo - **Joystick:** up/down = tempo, left/right = previous/next groove. - **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo. -- **Screen:** VARASYS logo + MIDI/USB status icons up top; the **background tints gray while running** - (black when stopped). Running time and bar count show **of the segment total** when the track has a - bar length (`b`), e.g. `1:23 of 2:00` and `bar 3 of 16`. Main beats are **squares**, subdivisions - are **circles**, with vertical gridlines lining the beats up across lanes. -- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration) — - newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.** +- **Screen:** VARASYS logo + firmware version + MIDI/USB status icons up top. Running time and bar count + show **of the segment total** when the track has a bar length (`b`), e.g. `1:23 of 2:00` and `bar 3 + of 16`. A track with a tempo ramp (`rmp`) shows a **ramp arrow + amount/every-bars** (e.g. `+4/2b`); a + gap-trainer track (`tr`) shows a **play|rest symbol + bars** (e.g. `2/2b`). Main beats are **squares**, + subdivisions are **circles**, with vertical gridlines lining the beats up across lanes. +- **RGB LED = run state:** dim **green** when stopped ("on"), dim **red** while playing, with the beat + pulsing brighter on top. (The screen background stays black — recoloring it forces a full-screen repaint.) +- The firmware **performs** ramps (tempo steps every N bars) and gap-trainer cycles (silent rest bars). +- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration · bars) + — newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.** - **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match. - The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives power‑cycles. diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 47a56bd..779707f 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ diff --git a/pico-cp/app.py b/pico-cp/app.py index 6edeafe..75b4771 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -18,7 +18,7 @@ import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart -APP_VERSION = "0.0.6" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.7" # firmware version (the A/B updater pushes/compares this) try: import rtc # set from the editor's clock SysEx so the log has real timestamps except ImportError: @@ -87,7 +87,7 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare GM_DEFAULT = 37 MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost MAXLANES = 5 # lanes shown on the pad grid (extras still play) -GRID_TOP = 150 # top of the pad grid (leaves room for stopwatch/bar) +GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp rows) LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) MIN_LOG_SEC = 5 # don't log plays shorter than this PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost @@ -196,7 +196,7 @@ PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} PRIO = {2: 3, 1: 2, 3: 1} def parse_program(s): - bpm = 120; lanes = []; bars = 0 + bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None for tok in s.strip().split(';'): tok = tok.strip() if not tok: continue @@ -204,11 +204,23 @@ def parse_program(s): bpm = int(tok[1:]); continue if tok[0] == 'b' and tok[1:].isdigit(): # b = segment length in bars (totals + Continue) bars = int(tok[1:]); continue # (lane sounds like "beep:4" have a ':' -> not matched here) + if tok.startswith('rmp'): # rmp// tempo ramp (amount may be -) + p = tok[3:].split('/') + if len(p) == 3: + try: ramp = {'amt': int(p[1]), 'every': max(1, int(p[2]))} + except ValueError: pass + continue + if tok.startswith('tr') and '/' in tok and ':' not in tok: # tr/ gap trainer (bars) + p = tok[2:].split('/') + if len(p) == 2: + try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))} + except ValueError: pass + continue if ':' not in tok: continue lane = _parse_lane(tok) if lane: lanes.append(lane) if not lanes: lanes = [_parse_lane("beep:4")] - return max(30, min(300, bpm)), lanes, bars + return max(30, min(300, bpm)), lanes, bars, ramp, trainer def _parse_lane(tok): poly = '~' in tok; mute = '!' in tok @@ -342,6 +354,7 @@ class App: self._joyNext = 0 self._touchDown = False; self._touchSeen = 0 self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0; self.rgb = (0, 0, 0) + self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120 self.programs = load_programs() self.dirty = True self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead @@ -385,6 +398,7 @@ class App: self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right) self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left) self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left) + self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators self.g_name = displayio.Group(); root.append(self.g_name) # track title self.g_idx = displayio.Group(); root.append(self.g_idx) # track number (dim, right) - set apart from the title self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads @@ -407,9 +421,10 @@ class App: def load(self, i): n = len(self.programs); self.idx = i % n self.name, prog = self.programs[self.idx] - self.bpm, self.lanes, self.bars = parse_program(prog) - self.master = self.lanes[0] - self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid(); self.draw_log() + self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog) + self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False + self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train() + self.build_grid(); self.draw_log() def _step_dur(self, L, step): beat = 60_000_000_000 / self.bpm if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar @@ -480,14 +495,18 @@ class App: adv = False while now >= L['next']: L['step'] = (L['step'] + 1) % L['steps'] - if li == 0: self._m_steps += 1 # count master-lane steps -> bars + if li == 0: + self._m_steps += 1 # count master-lane steps -> bars + nb = self._m_steps // L['steps'] + if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb) # ramp + gap-trainer lvl = 0 if L['mute'] else L['levels'][L['step']] if lvl > 0: fired.append(lvl) - self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane + if not self._muted: # gap trainer: silent during the rest bars + self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) L['next'] += self._step_dur(L, L['step']); adv = True if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) - if fired: + if fired and not self._muted: best = max(fired, key=lambda l: PRIO.get(l, 0)) if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead self.flash(best) @@ -498,6 +517,12 @@ class App: b = base[2] + (self.rgb[2]-base[2])*7//10 if abs(r-base[0])+abs(g-base[1])+abs(b-base[2]) < 6: r, g, b = base self.rgb = (r, g, b); self.led.set(r, g, b) + def _on_new_bar(self, bar): + if self.ramp and bar > 0: # tempo ramp: reset each segment, else step every N bars + if self.bars and bar % self.bars == 0: self.set_bpm(self._ramp_base) + elif bar % self.ramp['every'] == 0: self.set_bpm(self.bpm + self.ramp['amt']) + t = self.trainer # gap trainer: silence during the rest bars of each cycle + self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play']) # ---------- inputs ---------- def poll(self): @@ -547,9 +572,25 @@ class App: def draw_bpm(self): self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) def draw_status(self): # title (bright) + track number set apart (dim, right) - self._place(self.g_name, self.name[:22], 12, 116, C_TXT, C_BG, FONT_M) - self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 120, + self._place(self.g_name, self.name[:22], 12, 130, C_TXT, C_BG, FONT_M) + self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 134, C_DIM, C_BG, FONT_S, right_edge=WIDTH - 12) + def draw_train(self): # ramp + gap-trainer indicators (symbol + params), when set + g = self.g_train + while len(g): g.pop() + x = 12; y = 104 + if self.ramp: + up = self.ramp['amt'] >= 0 + pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] # rising / falling ramp + g.append(vectorio.Polygon(pixel_shader=solid(C_AMBER), points=pts, x=x, y=y)); x += 16 + a = self.ramp['amt']; lbl = ("+%d" % a if a >= 0 else "%d" % a) + "/%db" % self.ramp['every'] + tg, w, h = make_text(lbl, FONT_S, C_AMBER, C_BG); tg.x = x; tg.y = y; g.append(tg); x += w + 14 + if self.trainer: + g.append(rect(x, y, 4, 9, C_CYAN)); g.append(rect(x + 6, y, 4, 9, C_DIM)) # play | rest + x += 14 + tg, w, h = make_text("%d/%db" % (self.trainer['play'], self.trainer['mute']), FONT_S, C_CYAN, C_BG) + tg.x = x; tg.y = y; g.append(tg) + self.dirty = True def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap) if self.ic_midi_pal is not None: _recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG) @@ -572,9 +613,9 @@ class App: else: ts = self._fmt_t(el); bs = "bar %s" % cur if ts != self._lastTs: - self._place(self.g_time, ts, 12, 52, C_TXT, C_BG, FONT_M); self._lastTs = ts + self._place(self.g_time, ts, 12, 50, C_TXT, C_BG, FONT_M); self._lastTs = ts if bs != self._lastBs: - self._place(self.g_bar, bs, 12, 84, C_MUTE, C_BG, FONT_M); self._lastBs = bs + self._place(self.g_bar, bs, 12, 78, C_MUTE, C_BG, FONT_M); self._lastBs = bs # ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ---------- def _padbase(self, L, s): @@ -645,9 +686,10 @@ class App: if self.play_start is None: return dur = int(time.monotonic() - self.play_start); self.play_start = None if dur < MIN_LOG_SEC: return # skip plays under 5 seconds + mlen = self.lanes[0]['steps'] if self.lanes else 1 t = time.localtime() self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, - "dur": dur, "name": self.play_name}) + "dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name}) del self.log[200:]; self._armed = None self._save_log(); self.draw_log() def draw_log(self): @@ -663,7 +705,8 @@ class App: for k in range(min(LOG_ROWS, len(rows))): oi, e = rows[k]; armed = (oi == self._armed) # oi = index into self.log (for delete) dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) - line = "%s%s %3d bpm %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur) + bars = e.get("bars", 0); bstr = (" %dbar" % bars) if bars else "" + line = "%s%s %3dbpm %s%s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, bstr) tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) self.log_rows.append((y - 2, y + LOG_ROWH - 2, oi)) y += LOG_ROWH