// Copyright (c) 2026, Patrick Middendorf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /* * LPSeq v8 - 1:1 port of the GRID.MK2 web app Launchpad control scheme. * * Launchpad MK2 (Session layout): * Top 1..8 = select channel 1..8 (= OUT1..OUT8) * double-tap selected channel -> LENGTH mode (pad = last step 1..64) * TRIGGER + selected step -> toggle microstep 1..8 * SWING -> swing preset 1..8 * Grid 8x8 = 64 steps of the selected channel (top-left = step 1) * Side 1..8 (top->) = SWING, TRIGGER, BPM, CLOCK, ERASE, RND, PLAY, STOP * TRIGGER + selected step -> side = trigger level/length 8..1 * Loop channels = any pad toggles the latch (OUT held high while running) * * Inputs: TR1 = clock (16th), TR2 = reset. Without TR1 the internal BPM clock runs. * USB-C (device MIDI): SysEx from the web app loads the whole project incl. * the sample number (SD file NNN.WAV) per channel. * MIDImix: fader 1..8 = channel volume (scales trigger level), MUTE 1..8 = mute. */ #include "HSApplication.h" #include "HSMIDI.h" #include "AudioIO.h" #include "Audio/AudioMixer.h" #include #ifdef ARDUINO_TEENSY41 OC_APP_CLASS(AppLPSeq, TWOCCS("LQ"), "LPSeq", "LPSeq"), public HSApplication { public: static constexpr int TRACKS = 8; static constexpr int MAX_STEPS = 64; static constexpr int HDR = 38; // bytes of track header (27 + 8 loop numbers + pitch, start, pan) static constexpr int HDR_V14 = 35; static constexpr int HDR_V13 = 27; static constexpr uint8_t FW_VERSION = 16; OC_APP_INTERFACE_DECLARE(AppLPSeq, TRACKS * (HDR + MAX_STEPS * 4) + 2 + 3); void Start() final; void Controller() final; void View() const final; void Resume() final { ForceLedRefresh(); } struct RGB { uint8_t r, g, b; }; private: // ---------------------------------------------------------------- data // Step = 4 bytes: // b0 microstep mask // b1 bit0 on | bits1-3 level-1 | bits4-6 length-1 | bit7 swing // b2 bits0-6 probability 0..100 | bit7 sustain-continue (same group as previous) // b3 bits0-2 microstep count-1 | bit3 length quantised | bit4 accent | bit5 slide struct Track { uint8_t length; // 1..64 uint8_t lengthMode; // 0 poly-metric, 1 poly-rhythmic, 2 free uint8_t seqMode; // 0 level, 1 length uint8_t gLevel; // 1..8 uint8_t gLength; // 1..8 uint8_t swingIdx; // 0..7 uint8_t swingPresets[8]; uint8_t rndGates; // 0/1 uint8_t rndGateAmt; // 1..8 (eighths) uint8_t rndVelAmt; // 0..8 (eighths) uint8_t rndNotes; // 0/1 uint8_t noteMin, noteMax, scale, root; uint8_t loopMode; // 0 steps, 1 loop uint8_t muted; uint8_t volume; // 0..127 uint8_t sampleNo; // 0..126 -> NNN.WAV, 127 = none uint8_t outMode; // 0 stereo, 1 left, 2 right, 3 sum uint8_t loopNo[8]; // loop player: sample number per launch button (127 = empty) uint8_t pitch; // 0..48, 24 = original pitch (single shots) uint8_t startPt; // 0..127 start point inside the sample (single shots) uint8_t pan; // 0 left .. 64 centre .. 127 right uint8_t st[MAX_STEPS][4]; }; Track trk[TRACKS]; uint16_t bpm; static constexpr int SLOT_BYTES = 24, SLOTS = 127; struct Slot { uint8_t b[SLOT_BYTES]; }; Slot slots[SLOTS]; uint8_t playerMap[3], cvInput[8], cvTarget[8]; int8_t cvAmount[8]; bool settings, slotDirty, settingsLoaded; uint8_t settingRow, editPad; uint32_t slotSaveMs, slotSync[127], slotCheckMs; volatile bool quarterEdge; uint8_t quarterPhase; uint32_t loopQuarter[8], loopPassQ[8], grainAt[8], grainStep[8]; volatile uint32_t loopElapsed[8]; void InitSlots(); void LoadSlots(); void SaveSlots(); void ServiceSlots(); void EditSetting(int delta); void ViewSettings() const; uint8_t EditSlotNo() const; static uint16_t Word(const uint8_t *b, int at) { return (uint16_t(b[at]) << 8) | b[at+1]; } static void PutWord(uint8_t *b, int at, uint16_t v) { b[at] = v >> 8; b[at+1] = v; } // ---------------------------------------------------------------- UI state enum Focus : uint8_t { F_NONE, F_SWING, F_TRIGGER, F_BPM, F_LOOPS, F_ERASE, F_RND, F_SAMPLE }; enum CMode : uint8_t { M_STEPS, M_LENGTH, M_SWING }; int8_t sel; // selected channel int8_t selStep; // -1 = none Focus focus; CMode cmode; int8_t lastTop; uint32_t lastTopMs; uint32_t lastRxMs; // last SysEx from web app uint32_t lastAnyMs; // last USB-C MIDI message of any kind bool xfer; // WAV transfer active: SD players paused uint32_t syncMs; // live sync to web app uint32_t syncHash[TRACKS]; uint8_t syncBpmSel; uint16_t rxCount; uint8_t knobMap[3]; // MIDImix knob rows: 0 pitch, 1 start, 2 pan, 3 off uint8_t master; // MIDImix master fader 0..127 (not saved) void ApplyKnob(int t, uint8_t target, uint8_t v); // SysEx messages received from the web app (shown as RX) uint32_t helloMs; // loop player (Ableton-style): start on bar, stop/switch at end of pass int8_t loopAct[TRACKS]; // running loop button, -1 none int8_t loopPend[TRACKS]; // queued: -1 none, 0..7 start/switch, 15 stop uint8_t loopBarsN[TRACKS]; // loop length in bars at current tempo uint16_t loopBarCnt[TRACKS]; uint16_t loopRateBpm[TRACKS]; volatile uint32_t barTick; volatile bool barEdge; // Launchpad hold gestures (web: hold two pads = sustained range / length end) uint64_t heldMask; uint32_t heldMs[MAX_STEPS]; bool gestFired; // ---------------------------------------------------------------- engine state (ISR) volatile bool playing; volatile uint8_t loopRun; // bitmask volatile int8_t pos[TRACKS]; uint8_t accL[TRACKS]; uint32_t since[TRACKS]; uint32_t fireAt[TRACKS][8]; uint8_t pending[TRACKS]; uint32_t fireDur[TRACKS]; int fireVal[TRACKS]; uint32_t gateLeft[TRACKS]; int gateVal[TRACKS]; uint32_t ticks_since_clock; uint32_t ext_period; uint32_t internal_ticks; uint32_t digital_triggers; volatile bool reset_pending; // ---------------------------------------------------------------- USB enum DevType : uint8_t { DEV_NONE, DEV_LAUNCHPAD, DEV_MIDIMIX, DEV_OTHER }; DevType dev[2]; RGB ledRgb[100]; // cached pad/side colours by note uint8_t ledTop[8]; // cached top velocity bool ledValid[100]; bool topValid[8]; uint32_t last_led_ms; uint32_t asSum, asChangedMs, asCheckMs, lastSaveMs; // autosave uint8_t mmMutesSent; // sample playback (main loop side) volatile uint8_t sTrig; // set in ISR when a channel fires volatile int sVal[TRACKS]; // trigger CV of that hit (level*volume) uint8_t loadedNo[TRACKS]; uint8_t lastLoopRun; bool audioReady; void ServiceAudio(); void StopAudio(); bool mmValid; static constexpr uint32_t EXT_TIMEOUT = OC_CORE_ISR_FREQ * 2; static constexpr uint32_t MIN_TRIG = OC_CORE_ISR_FREQ / 500; // 2 ms static constexpr uint32_t GAP = OC_CORE_ISR_FREQ / 1000; // 1 ms // ---------------------------------------------------------------- step helpers uint8_t &B(int t, int s, int i) { return trk[t].st[s][i]; } uint8_t Bc(int t, int s, int i) const { return trk[t].st[s][i]; } bool On(int t, int s) const { return Bc(t, s, 1) & 1; } uint8_t Mask(int t, int s) const { return Bc(t, s, 0); } int Level(int t, int s) const { return ((Bc(t, s, 1) >> 1) & 7) + 1; } int Len(int t, int s) const { return ((Bc(t, s, 1) >> 4) & 7) + 1; } bool Swing(int t, int s) const { return Bc(t, s, 1) >> 7; } int Prob(int t, int s) const { return Bc(t, s, 2) & 0x7F; } bool Cont(int t, int s) const { return Bc(t, s, 2) >> 7; } int Count(int t, int s) const { return (Bc(t, s, 3) & 7) + 1; } bool Accent(int t, int s) const { return (Bc(t, s, 3) >> 4) & 1; } void SetOn(int t, int s, bool v) { B(t, s, 1) = (B(t, s, 1) & ~1) | (v ? 1 : 0); } void SetCont(int t, int s, bool v) { B(t, s, 2) = (B(t, s, 2) & 0x7F) | (v ? 0x80 : 0); } void EmptyStep(int t, int s) { trk[t].st[s][0] = 1; trk[t].st[s][1] = 0x7E; trk[t].st[s][2] = 100; trk[t].st[s][3] = 7; } void InitTrack(int t); uint32_t Base16() const { if (ticks_since_clock < EXT_TIMEOUT && ext_period > 0) return ext_period; return (OC_CORE_ISR_FREQ * 15U) / (bpm ? bpm : 120); } void Tick16(); void StartStep(int t, int p, uint32_t stepTicks); void ResetPositions(); void Play() { if (!playing) { reset_pending = true; playing = true; } } void Stop() { playing = false; reset_pending = true; } // UI (ported from web store) void TopButton(int i); void SideButton(int i); void PadPress(int x, int y); void RndPad(int x, int y); void ToggleFocus(Focus f); void SelectChannel(int i) { sel = constrain(i, 0, TRACKS - 1); selStep = -1; } void ToggleStep(int t, int s); void SetTrackLength(int t, int len); void SetStepLevel(int t, int s, int v); void SetStepLength(int t, int s, int v); void ToggleMicro(int t, int s, int m); void PadDown(int x, int y); void PadUp(int x, int y); void CheckGesture(); void SustainRange(int t, int lo, int hi); void LoopPress(int t, int k); int DefaultLoop(int t) const; void ProcessLoopQuant(); void StopLoopNow(int t) { loopAct[t] = -1; loopPend[t] = -1; loopRun &= ~(1 << t); } void PackHdr(const Track &k, uint8_t *h) const; void UnpackHdr(int t, const uint8_t *h, int n); uint32_t BarTicks() const { return Base16() * 16; } // devices void PollDevices(); void HandleLaunchpad(uint8_t type, uint8_t d1, uint8_t d2); void HandleMidimix(uint8_t type, uint8_t d1, uint8_t d2); void PollWebApp(); void AutoSave(); void HandleSysEx(const uint8_t *d, unsigned n); void SendDump(); void LiveSync(); void SendTrack(int t); void HandleXfer(uint8_t cmd, const uint8_t *p, unsigned avail); void XReply(uint8_t cmd, const uint8_t *b, int n); void SendMsg(uint8_t cmd, const uint8_t *args, int na, const uint8_t *pl, int np); void UpdateLeds(); void ForceLedRefresh() { for (int i = 0; i < 100; ++i) ledValid[i] = false; for (int i = 0; i < 8; ++i) topValid[i] = false; mmValid = false; } RGB PadColor(int x, int y, bool &pulse) const; RGB TopColor(int i, bool &pulse) const; RGB SideColor(int i) const; int LaunchpadSlot() const { for (int i = 0; i < 2; ++i) if (dev[i] == DEV_LAUNCHPAD) return i; return -1; } int MidimixSlot() const { for (int i = 0; i < 2; ++i) if (dev[i] == DEV_MIDIMIX) return i; return -1; } }; // ===================================================================== colours (web COLORS * 63) namespace lpseq { typedef AppLPSeq::RGB RGB; static constexpr RGB C_OFF = {0, 0, 0}; static constexpr RGB C_DIM = {9, 9, 11}; static constexpr RGB C_PRIMARY = {6, 44, 57}; static constexpr RGB C_ACCENT = {63, 47, 6}; static constexpr RGB C_ACTIVE = {63, 63, 63}; static constexpr RGB C_OFFRED = {63, 6, 6}; static constexpr RGB C_TRACK[8] = { {6, 44, 57}, {0, 63, 0}, {63, 35, 0}, {63, 13, 38}, {38, 19, 63}, {13, 47, 54}, {63, 60, 9}, {63, 0, 0}}; static inline RGB Meter(int num, int den) { // web meterColor(num/den) if (num * 100 >= den * 85) return {63, 3, 1}; if (num * 100 >= den * 62) return {63, 45, 1}; return {5, 57, 10}; } // web VELOCITY_PALETTE (velocity, r, g, b) scaled to 0..63 static constexpr uint8_t PAL[10][4] = { {3, 63, 63, 63}, {5, 63, 0, 0}, {9, 63, 35, 0}, {13, 63, 60, 9}, {21, 0, 63, 0}, {37, 6, 47, 57}, {45, 0, 0, 63}, {49, 38, 19, 63}, {53, 63, 0, 63}, {57, 63, 13, 38}}; static inline uint8_t ToVelocity(RGB c) { if (c.r + c.g + c.b < 10) return 0; int best = 1, bestD = 1 << 30; for (int i = 0; i < 10; ++i) { const int dr = c.r - PAL[i][1], dg = c.g - PAL[i][2], db = c.b - PAL[i][3]; const int d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = PAL[i][0]; } } return best; } static inline RGB Scale(RGB c, int f /*0..256*/) { return {uint8_t(c.r * f >> 8), uint8_t(c.g * f >> 8), uint8_t(c.b * f >> 8)}; } static inline bool Same(RGB a, RGB b) { return a.r == b.r && a.g == b.g && a.b == b.b; } static inline int Nib(const uint8_t *p) { return ((p[0] & 0x0F) << 4) | (p[1] & 0x0F); } } // namespace lpseq // ===================================================================== ISR void AppLPSeq::Controller() { if (digital_triggers & DIGITAL_INPUT_MASK(OC::DIGITAL_INPUT_2)) reset_pending = true; if (reset_pending) { reset_pending = false; ResetPositions(); } const bool ext = digital_triggers & DIGITAL_INPUT_MASK(OC::DIGITAL_INPUT_1); if (ticks_since_clock < EXT_TIMEOUT) ++ticks_since_clock; for(int t=0;t<8;++t) if(loopAct[t]>=0) ++loopElapsed[t]; const uint32_t bt = BarTicks(); if (++barTick >= bt) { barTick = 0; barEdge = true; } const uint8_t qp = min(3U, barTick * 4 / bt); if (qp != quarterPhase) { quarterPhase = qp; quarterEdge = true; } if (ext) { if (ticks_since_clock < EXT_TIMEOUT) ext_period = ticks_since_clock; ticks_since_clock = 0; if (playing) Tick16(); } else if (playing && ticks_since_clock >= EXT_TIMEOUT) { if (++internal_ticks >= Base16()) { internal_ticks = 0; Tick16(); } } // microstep scheduler + gates for (int t = 0; t < TRACKS; ++t) { ++since[t]; if (pending[t]) { for (int m = 0; m < 8; ++m) { if (!((pending[t] >> m) & 1) || since[t] < fireAt[t][m]) continue; pending[t] &= ~(1 << m); gateLeft[t] = fireDur[t]; gateVal[t] = fireVal[t]; sVal[t] = fireVal[t]; sTrig |= (1 << t); } } int out = 0; if (gateLeft[t]) { --gateLeft[t]; out = gateVal[t]; } if (trk[t].loopMode && ((loopRun >> t) & 1)) out = HEMISPHERE_MAX_CV; Out(t, out); } } FLASHMEM void AppLPSeq::Tick16() { const uint32_t base = Base16(); for (int t = 0; t < TRACKS; ++t) { Track &k = trk[t]; if (k.length == 0) continue; uint32_t stepTicks = base; if (k.lengthMode == 1) { // poly-rhythmic: 8/length sixteenths per step const int a = accL[t] + k.length; if (a < 8) { accL[t] = a; continue; } accL[t] = a - 8; stepTicks = base * 8 / k.length; } int p = pos[t] + 1; if (p >= k.length || p < 0) p = 0; pos[t] = p; StartStep(t, p, stepTicks); } } FLASHMEM void AppLPSeq::StartStep(int t, int p, uint32_t stepTicks) { Track &k = trk[t]; since[t] = 0; pending[t] = 0; if (k.loopMode) return; if (!On(t, p)) return; const uint8_t mask = Mask(t, p); if (!mask) return; if (k.muted) return; if (Cont(t, p) && p != 0) { const int prev = (p - 1 + k.length) % k.length; if (On(t, prev)) return; // sustained note continues, no retrigger } const int prob = Prob(t, p); if (prob < 100 && (int)random(100) >= prob) return; if (k.rndGates && (int)random(8) >= k.rndGateAmt) return; int level = k.seqMode == 0 ? Level(t, p) : k.gLevel; const int len = k.seqMode == 0 ? k.gLength : Len(t, p); if (k.rndVelAmt) level += (int)random(-(int)k.rndVelAmt, (int)k.rndVelAmt + 1); if (Accent(t, p)) level = (level * 5 + 3) / 4; level = constrain(level, 1, 8); // level 1..8 = 0..9.3 V (web), scaled by channel volume int val = level * 1786; val = val * k.volume / 127; if (val > HEMISPHERE_MAX_CV) val = HEMISPHERE_MAX_CV; int sustained = 1; for (int i = 1; i < k.length; ++i) { const int q = (p + i) % k.length; if (!On(t, q) || !Cont(t, q)) break; ++sustained; } const uint32_t swingOff = Swing(t, p) ? (uint32_t)k.swingPresets[k.swingIdx] * stepTicks / 64 : 0; const int count = Count(t, p); const uint32_t sub = stepTicks / count; fireVal[t] = val; if (sustained > 1) { int first = 0; while (first < count && !((mask >> first) & 1)) ++first; if (first >= count) return; fireAt[t][first] = swingOff + first * sub; fireDur[t] = sustained * stepTicks > GAP ? sustained * stepTicks - GAP : 1; pending[t] = 1 << first; return; } uint32_t dur = sub * len / 8; dur = dur > GAP + MIN_TRIG ? dur - GAP : MIN_TRIG; fireDur[t] = dur; for (int m = 0; m < count; ++m) { if (!((mask >> m) & 1)) continue; fireAt[t][m] = swingOff + m * sub; pending[t] |= 1 << m; } } FLASHMEM void AppLPSeq::ResetPositions() { for (int t = 0; t < TRACKS; ++t) { pos[t] = -1; accL[t] = 0; pending[t] = 0; gateLeft[t] = 0; since[t] = 0; } internal_ticks = Base16(); // first tick fires immediately on play barTick = BarTicks() - 1; // bar grid restarts with the transport } // ===================================================================== setup FLASHMEM void AppLPSeq::InitTrack(int t) { Track &k = trk[t]; k.length = 16; k.lengthMode = 0; k.seqMode = 0; k.gLevel = 8; k.gLength = 8; k.swingIdx = 0; static const uint8_t sp[8] = {0, 8, 16, 24, 32, 40, 48, 56}; for (int i = 0; i < 8; ++i) k.swingPresets[i] = sp[i]; k.rndGates = 0; k.rndGateAmt = 8; k.rndVelAmt = 0; k.rndNotes = 0; k.noteMin = 36; k.noteMax = 72; k.scale = 0; k.root = 0; k.loopMode = 0; k.muted = 0; k.volume = 127; k.sampleNo = 127; k.outMode = 0; for (int i = 0; i < 8; ++i) k.loopNo[i] = 127; k.pitch = 24; k.startPt = 0; k.pan = 64; for (int s = 0; s < MAX_STEPS; ++s) EmptyStep(t, s); } FLASHMEM void AppLPSeq::Start() { for (int t = 0; t < TRACKS; ++t) InitTrack(t); bpm = 120; InitSlots(); asSum = asChangedMs = asCheckMs = lastSaveMs = 0; sel = 0; selStep = -1; focus = F_NONE; cmode = M_STEPS; lastTop = -1; lastTopMs = 0; lastRxMs = 0; lastAnyMs = 0; xfer = false; syncMs = 0; syncBpmSel = 0; for (int t = 0; t < TRACKS; ++t) syncHash[t] = 0; playing = false; loopRun = 0; ticks_since_clock = EXT_TIMEOUT; ext_period = 0; reset_pending = false; dev[0] = dev[1] = DEV_NONE; last_led_ms = 0; mmMutesSent = 0; sTrig = 0; lastLoopRun = 0; audioReady = false; knobMap[0] = 0; knobMap[1] = 1; knobMap[2] = 2; master = 127; rxCount = 0; helloMs = 0; heldMask = 0; gestFired = false; barTick = 0; barEdge = false; for (int t = 0; t < TRACKS; ++t) { loopAct[t] = -1; loopPend[t] = -1; loopBarsN[t] = 1; loopBarCnt[t] = 0; loopRateBpm[t] = 0; } for (int t = 0; t < TRACKS; ++t) { loadedNo[t] = 127; sVal[t] = 0; } ResetPositions(); ForceLedRefresh(); } // ===================================================================== editing (web store port) FLASHMEM void AppLPSeq::ToggleStep(int t, int s) { if (s >= trk[t].length) return; const bool on = !On(t, s); SetOn(t, s, on); if (!on) { SetCont(t, s, false); if (s + 1 < MAX_STEPS) SetCont(t, s + 1, false); } } FLASHMEM void AppLPSeq::SetTrackLength(int t, int len) { trk[t].length = constrain(len, 1, MAX_STEPS); if (pos[t] >= trk[t].length) pos[t] = -1; } FLASHMEM void AppLPSeq::SetStepLevel(int t, int s, int v) { v = constrain(v, 1, 8); B(t, s, 1) = (B(t, s, 1) & ~0x0E) | ((v - 1) << 1); SetOn(t, s, true); if (!Mask(t, s)) B(t, s, 0) = 1; } FLASHMEM void AppLPSeq::SetStepLength(int t, int s, int v) { v = constrain(v, 1, 8); B(t, s, 1) = (B(t, s, 1) & ~0x70) | ((v - 1) << 4); SetOn(t, s, true); if (!Mask(t, s)) B(t, s, 0) = 1; } FLASHMEM void AppLPSeq::ToggleMicro(int t, int s, int m) { if (m < 0 || m >= Count(t, s)) return; B(t, s, 0) ^= (1 << m); SetOn(t, s, B(t, s, 0) != 0); } FLASHMEM void AppLPSeq::ToggleFocus(Focus f) { const Focus next = focus == f ? F_NONE : f; if (next == F_SWING) cmode = M_SWING; else if (next != F_NONE) cmode = M_STEPS; else if (focus == F_SWING) cmode = M_STEPS; focus = next; } FLASHMEM void AppLPSeq::TopButton(int i) { if (focus == F_SAMPLE) { SelectChannel(i); return; } if (focus == F_TRIGGER && selStep >= 0) { ToggleMicro(sel, selStep, i); return; } if (focus == F_SWING) { trk[sel].swingIdx = constrain(i, 0, 7); return; } const uint32_t now = millis(); const bool dbl = lastTop == i && now - lastTopMs < 320 && i == sel; lastTop = i; lastTopMs = now; if (dbl) { cmode = cmode == M_LENGTH ? M_STEPS : M_LENGTH; return; } SelectChannel(i); } FLASHMEM void AppLPSeq::SideButton(int i) { if (focus == F_TRIGGER && selStep >= 0) { if (i == 1) { selStep = -1; ToggleFocus(F_TRIGGER); return; } if (i == 7) { selStep = -1; return; } const int v = 8 - i; if (trk[sel].seqMode == 1) SetStepLength(sel, selStep, v); else SetStepLevel(sel, selStep, v); return; } switch (i) { case 0: ToggleFocus(F_SWING); break; case 1: ToggleFocus(F_TRIGGER); break; case 2: ToggleFocus(F_BPM); break; case 3: ToggleFocus(F_LOOPS); break; case 4: ToggleFocus(F_ERASE); break; case 5: ToggleFocus(F_RND); break; case 6: if (trk[sel].loopMode) { if (loopAct[sel] < 0 && loopPend[sel] < 0) LoopPress(sel, DefaultLoop(sel)); } else Play(); break; case 7: if (focus != F_NONE) ToggleFocus(focus); selStep = -1; if (trk[sel].loopMode && loopAct[sel] >= 0 && loopPend[sel] != 15) LoopPress(sel, loopAct[sel]); Stop(); break; } } FLASHMEM void AppLPSeq::PadPress(int x, int y) { const int s = y * 8 + x; const int t = sel; if (focus == F_SAMPLE) { trk[t].sampleNo = s; return; } if (focus == F_LOOPS) { if (trk[x].loopMode) LoopPress(x, y); return; } if (trk[t].loopMode) { if (y == 0) LoopPress(t, x); return; } if (focus == F_RND) { RndPad(x, y); return; } if (cmode == M_LENGTH) { SetTrackLength(t, s + 1); return; } if (s >= trk[t].length) return; if (focus == F_TRIGGER) { selStep = s; SetOn(t, s, true); return; } if (focus == F_ERASE) { EmptyStep(t, s); return; } ToggleStep(t, s); selStep = s; } FLASHMEM void AppLPSeq::RndPad(int x, int y) { Track &k = trk[sel]; if (y == 0) { const int amt = x + 1; if (k.rndGates && k.rndGateAmt == amt) k.rndGates = 0; else { k.rndGates = 1; k.rndGateAmt = amt; } } else if (y == 1) { const int amt = x + 1; k.rndVelAmt = k.rndVelAmt == amt ? 0 : amt; } else if (y == 2) { if (x != 7) k.rndNotes = !k.rndNotes; } else if (!k.rndNotes) { return; } else if (y == 3) { k.noteMin = min(24 + x * 12, (int)k.noteMax); } else if (y == 4) { k.noteMax = max(24 + x * 12, (int)k.noteMin); } else if (y == 5) { k.scale = x; } else if (y == 6) { k.root = x; } else if (y == 7) { if (x < 4) k.root = 8 + x; } } // ---------------------------------------------------------------- hold gestures (web Sequencer.tsx) // Tap (< 250 ms) toggles a step. Hold one pad 600 ms selects it. Hold two or more // pads for 900 ms: neighbouring pads become one sustained note, otherwise the // highest pad sets the end of the active area (length). FLASHMEM void AppLPSeq::PadDown(int x, int y) { if(settings) {SelectChannel(x);editPad=y;return;} const int s = y * 8 + x; const Track &k = trk[sel]; if (focus != F_NONE || cmode != M_STEPS || k.loopMode) { PadPress(x, y); return; } if (s >= k.length) return; heldMask |= (uint64_t(1) << s); heldMs[s] = millis(); } FLASHMEM void AppLPSeq::PadUp(int x, int y) { if(settings) return; const int s = y * 8 + x; const uint64_t bit = uint64_t(1) << s; if (!(heldMask & bit)) return; heldMask &= ~bit; const uint32_t dur = millis() - heldMs[s]; if (!gestFired && dur < 250 && !heldMask && s < trk[sel].length) { ToggleStep(sel, s); selStep = s; } if (!heldMask) gestFired = false; } FLASHMEM void AppLPSeq::CheckGesture() { if (!heldMask || gestFired) return; int n = 0, lo = 64, hi = -1; uint32_t oldest = 0xFFFFFFFF; for (int s = 0; s < MAX_STEPS; ++s) { if (!((heldMask >> s) & 1)) continue; ++n; if (s < lo) lo = s; if (s > hi) hi = s; if (heldMs[s] < oldest) oldest = heldMs[s]; } const uint32_t el = millis() - oldest; if (n == 1) { if (el >= 600) { selStep = lo; gestFired = true; } return; } if (el < 900) return; gestFired = true; if (hi - lo + 1 == n) SustainRange(sel, lo, hi); else SetTrackLength(sel, hi + 1); } FLASHMEM void AppLPSeq::SustainRange(int t, int lo, int hi) { for (int s = lo; s <= hi && s < MAX_STEPS; ++s) { SetOn(t, s, true); if (!Mask(t, s)) B(t, s, 0) = 1; SetCont(t, s, s > lo); } if (hi + 1 < MAX_STEPS) SetCont(t, hi + 1, false); } // ---------------------------------------------------------------- loop player FLASHMEM int AppLPSeq::DefaultLoop(int t) const { if (loopAct[t] >= 0) return loopAct[t]; for (int k = 0; k < 8; ++k) if (trk[t].loopNo[k] < 127) return k; return 0; } // Same rules as web core/loopQuant.ts pressLoop(). FLASHMEM void AppLPSeq::LoopPress(int t, int k) { Track &tr = trk[t]; k &= 7; if (tr.loopNo[k] >= 127 && k == 0 && tr.sampleNo < 127 && loopAct[t] < 0) tr.loopNo[0] = tr.sampleNo; if (tr.loopNo[k] >= 127 && loopAct[t] != k) return; const uint8_t sn = tr.loopNo[k]; if (sn < SLOTS && slots[sn].b[1] != 2) { tr.sampleNo = sn; noInterrupts(); sVal[t] = 8 * 1786 * tr.volume / 127; sTrig |= (1 << t); interrupts(); return; } if (loopAct[t] == k) loopPend[t] = loopPend[t] == 15 ? -1 : 15; else loopPend[t] = loopPend[t] == k ? -1 : k; // nothing running and transport stopped: no grid to wait for, start now bool any = playing; for (int i = 0; i < TRACKS; ++i) if (loopAct[i] >= 0) any = true; if (!any && loopPend[t] >= 0 && loopPend[t] < 8) { noInterrupts(); barTick = BarTicks() - 1; interrupts(); } } FLASHMEM void AppLPSeq::ProcessLoopQuant() { noInterrupts(); const bool bar = barEdge; quarterEdge = false; barEdge = false; interrupts(); for (int t = 0; t < TRACKS; ++t) { const uint32_t duration=max(1U,(uint32_t)((uint64_t)BarTicks()*loopPassQ[t]/256)); const bool end=loopAct[t]>=0 && loopElapsed[t]>=duration; if(end) { noInterrupts();loopElapsed[t]%=duration;interrupts(); } const int pd=loopPend[t]; if(pd<0 || (loopAct[t]<0?!bar:!end)) continue; loopPend[t]=-1; if(pd==15 || !trk[t].loopMode) {StopLoopNow(t);continue;} loopAct[t]=pd;noInterrupts();loopElapsed[t]=0;interrupts();trk[t].sampleNo=trk[t].loopNo[pd]; lastLoopRun &= ~(1<> (y * 8 + x)) & 1) { pulse = true; return C_ACCENT; } if (focus == F_RND) { const int gateStep = k.rndGateAmt, velStep = k.rndVelAmt; const int loOct = constrain((k.noteMin - 24 + 6) / 12, 0, 7); const int hiOct = constrain((k.noteMax - 24 + 6) / 12, 0, 7); if (y == 0) { if (!k.rndGates) return x == 0 ? C_OFFRED : C_OFF; return x + 1 <= gateStep ? Meter(x + 1, 8) : C_DIM; } if (y == 1) { if (!velStep) return x == 0 ? C_OFFRED : C_OFF; return x + 1 <= velStep ? RGB{57, 35, 9} : C_DIM; } if (y == 2) { if (x == 7) return k.rndNotes ? RGB{19, 57, 63} : C_OFF; if (!k.rndNotes) return x == 0 ? C_OFFRED : C_OFF; if (x == 0) { pulse = true; return C_ACCENT; } return C_DIM; } if (!k.rndNotes) return C_OFF; if (y == 3) return x == loOct ? C_ACTIVE : RGB{9, 22, 38}; if (y == 4) return x == hiOct ? C_ACTIVE : RGB{9, 32, 28}; if (y == 5) { if (x == k.scale) { pulse = true; return col; } return C_DIM; } if (y == 6) return x == k.root ? C_ACTIVE : RGB{22, 9, 32}; if (x < 4) return 8 + x == k.root ? C_ACTIVE : RGB{22, 9, 32}; return C_OFF; } const int s = y * 8 + x; if (cmode == M_LENGTH) { if (s == k.length - 1) { pulse = true; return C_ACCENT; } return s < k.length ? Meter(s + 1, MAX_STEPS) : C_OFF; } if (s >= k.length) return C_OFF; if (playing && pos[sel] == s) return C_ACTIVE; return On(sel, s) ? col : C_DIM; } FLASHMEM AppLPSeq::RGB AppLPSeq::TopColor(int i, bool &pulse) const { using namespace lpseq; pulse = false; if (focus == F_TRIGGER && selStep >= 0) { const bool active = i < Count(sel, selStep); const bool on = active && ((Mask(sel, selStep) >> i) & 1); return !active ? C_OFF : on ? C_ACCENT : C_DIM; } if (focus == F_SWING) { const int p = trk[sel].swingIdx; return i == p ? C_ACCENT : i < p ? Meter(i + 1, 8) : C_DIM; } if (trk[i].loopMode) { if (loopPend[i] >= 0) { pulse = true; return C_TRACK[i]; } if ((loopRun >> i) & 1) return C_TRACK[i]; return C_DIM; } return i == sel ? C_TRACK[i] : C_DIM; } FLASHMEM AppLPSeq::RGB AppLPSeq::SideColor(int i) const { using namespace lpseq; if (focus == F_TRIGGER && selStep >= 0) { const int value = 8 - i; const int cur = trk[sel].seqMode == 1 ? Len(sel, selStep) : Level(sel, selStep); return value == cur ? C_ACCENT : value < cur ? Meter(value, 8) : C_DIM; } static const int8_t idx[8] = {-1, 0, 1, 2, 3, 4, 5, -1}; // Focus -> side index const int active = idx[focus]; RGB c = i == active ? C_ACCENT : C_DIM; if (i == 6 && playing) c = C_PRIMARY; if (i == 7 && !playing) c = C_ACTIVE; return c; } // ===================================================================== sample playback // 8 SD players (file /NNN.WAV per channel) -> 2x8 mixer -> AudioIO aux input // (summed with whatever Quadrants outputs) -> stereo out. Mono files go to both sides. namespace lpseq_audio { class ChannelFX : public AudioStream { audio_block_t *q[2]; float z[2] = {0,0}; public: volatile float alpha=1, send=0; ChannelFX(): AudioStream(2,q) {} FLASHMEM void update() override { for(int c=0;c<2;++c) { audio_block_t *b=receiveWritable(c); if(!b) continue; audio_block_t *w=allocate(); for(int i=0;idata[i]-z[c]); b->data[i] = constrain((int)z[c],-32768,32767); if(w) w->data[i] = b->data[i]*send; } transmit(b,c); release(b); if(w) { transmit(w,c+2); release(w); } } } }; DMAMEM int16_t echoBuffer[2][32768]; class StereoEcho: public AudioStream { audio_block_t *q[2]; uint32_t pos=0; public: volatile uint32_t delaySamples=16538; StereoEcho():AudioStream(2,q) {} FLASHMEM void update() override { audio_block_t *in[2]={receiveReadOnly(0),receiveReadOnly(1)}; audio_block_t *out[2]={allocate(),allocate()}; const uint32_t d=constrain(delaySamples,128U,32767U); for(int i=0;idata[i]:0)+v*35/100,-32768,32767); if(out[c]) out[c]->data[i]=v*65/100; } pos=(pos+1)&32767; } for(int c=0;c<2;++c) { if(in[c]) release(in[c]); if(out[c]) {transmit(out[c],c);release(out[c]);} } } }; ChannelFX fx[8]; StereoEcho echo; AudioMixer4 sends[2][2]; AudioMixer<2> sendSum[2], finalMix[2]; AudioConnection fxWire[16], sendWire[32], sumWire[4], echoWire[2], dryWire[2], wetWire[2]; AudioPlaySdResmp player[AppLPSeq::TRACKS]; AudioMixer4 sub[2][2]; // [side][0: ch1-4, 1: ch5-8] AudioMixer<2> mixsum[2]; AudioConnection pc[AppLPSeq::TRACKS * 4]; // 4 per track: L0->lft, L0->rgt, L1->lft, L1->rgt AudioConnection sc[4]; AudioConnection outc[2]; uint8_t sendMode[8]={255,255,255,255,255,255,255,255}; uint8_t routedMode[AppLPSeq::TRACKS] = {255, 255, 255, 255, 255, 255, 255, 255}; // Re-wire one channel's connections for its output mode. // pc index: t*4+0: out0 -> sub[0] (left), +1: out0 -> sub[1] (right), // t*4+2: out1 -> sub[0], +3: out1 -> sub[1]. // mode 0 stereo: out0 -> left, out1 -> right (mono files arrive on both outs) // mode 1 left: out0 -> left only | mode 2 right: out1 -> right only // mode 3 sum: (out0+out1)/2 on both sides static inline void Route(int t, uint8_t m) { if (routedMode[t] == m) return; routedMode[t] = m; ChannelFX &p = fx[t]; const int s = t >> 2, i = t & 3; for (int k = 0; k < 4; ++k) pc[t * 4 + k].disconnect(); if (m == 1) { pc[t * 4 + 0].connect(p, 0, sub[0][s], i); } else if (m == 2) { pc[t * 4 + 3].connect(p, 1, sub[1][s], i); } else if (m == 3) { pc[t * 4 + 0].connect(p, 0, sub[0][s], i); pc[t * 4 + 2].connect(p, 1, sub[0][s], i); pc[t * 4 + 1].connect(p, 0, sub[1][s], i); pc[t * 4 + 3].connect(p, 1, sub[1][s], i); } else { pc[t * 4 + 0].connect(p, 0, sub[0][s], i); pc[t * 4 + 3].connect(p, 1, sub[1][s], i); } } static inline void Gain(int t, float g, uint8_t mode, uint8_t pan = 64) { const float gg = (mode == 3) ? g * 0.5f : g; const float pl = pan <= 64 ? 1.0f : (127 - pan) / 63.0f; const float pr = pan >= 64 ? 1.0f : pan / 64.0f; sub[0][t >> 2].gain(t & 3, gg * pl); sub[1][t >> 2].gain(t & 3, gg * pr); } bool wired = false; static constexpr float HEADROOM = 0.5f; } FLASHMEM void AppLPSeq::StopAudio() { using namespace lpseq_audio; for (int t = 0; t < TRACKS; ++t) { player[t].stop(); Gain(t, 0, trk[t].outMode); } lastLoopRun = 0; } FLASHMEM void AppLPSeq::ServiceAudio() { using namespace lpseq_audio; if (!wired) { wired = true; for (int t = 0; t < TRACKS; ++t) { routedMode[t] = 255; player[t].enableInterpolation(true); player[t].setBufferInPSRAM(false); for(int c=0;c<2;++c) fxWire[t*2+c].connect(player[t],c,fx[t],c); Gain(t, 0, trk[t].outMode); } for (int side = 0; side < 2; ++side) { sc[side * 2].connect(sub[side][0], 0, mixsum[side], 0); sc[side * 2 + 1].connect(sub[side][1], 0, mixsum[side], 1); mixsum[side].gain(0, 1.0f); mixsum[side].gain(1, 1.0f); for(int g=0;g<2;++g) { sumWire[side*2+g].connect(sends[side][g],0,sendSum[side],g); sendSum[side].gain(g,1); } echoWire[side].connect(sendSum[side],0,echo,side); dryWire[side].connect(mixsum[side],0,finalMix[side],0); wetWire[side].connect(echo,side,finalMix[side],1); finalMix[side].gain(0,1); finalMix[side].gain(1,1); } AudioStream &aux = OC::AudioIO::AuxStream(); outc[0].connect(finalMix[0], 0, aux, 0); outc[1].connect(finalMix[1], 0, aux, 1); } audioReady = SDcard_Ready; if (!audioReady) { sTrig = 0; return; } noInterrupts(); const uint8_t trig = sTrig; sTrig = 0; interrupts(); const uint8_t run = loopRun; for (int t = 0; t < TRACKS; ++t) { Track &k = trk[t]; // (re)load file when the channel's sample changes if (k.sampleNo != loadedNo[t]) { player[t].stop(); loadedNo[t] = k.sampleNo; if (k.sampleNo < 127) { char path[12]; snprintf(path, sizeof(path), "/%03d.WAV", k.sampleNo); player[t].playWav(path); player[t].stop(); } if ((lastLoopRun >> t) & 1) lastLoopRun &= ~(1 << t); } if (k.sampleNo >= SLOTS) continue; uint8_t b[SLOT_BYTES]; memcpy(b,slots[k.sampleNo].b,SLOT_BYTES); // CV is an offset, not a saved knob value. Input 1..8 can modulate each bank. const int cv = cvInput[t] < ADC_CHANNEL_COUNT ? OC::ADC::value((ADC_CHANNEL)cvInput[t]) : 0; const int mod = cv * cvAmount[t] / (5 * 1536); if(cvTarget[t]==0) b[10]=constrain(b[10]+mod,0,96); else if(cvTarget[t]==1) PutWord(b,4,constrain((int)Word(b,4)+mod*516,0,(int)Word(b,6)-66)); else if(cvTarget[t]==7 && mod) {b[2]=(b[2]&~1)|(mod<0);PutWord(b,11,constrain((int)Word(b,11)+mod*4,64,512));} else if(cvTarget[t]>=4 && cvTarget[t]<=6) b[9+cvTarget[t]]=constrain(b[9+cvTarget[t]]+mod,0,127); const uint32_t total=max(2U,player[t].lengthSamples()); const uint32_t lo=min(total-2,(uint32_t)((uint64_t)total*Word(b,4)/65535)); const uint32_t hi=max(lo+1,min(total,(uint32_t)((uint64_t)total*Word(b,6)/65535))); const uint32_t start=min(hi-1,lo+(hi-lo)*k.startPt/127); player[t].setLoopStart(lo); player[t].setLoopFinish(hi); player[t].setPlayStart(k.startPt?play_start_arbitrary:play_start_loop,(b[2]&1)?hi-1-(start-lo):start); Route(t, k.outMode); const float mst=master/127.0f; const float vol=k.muted?0:HEADROOM*mst*k.volume/127.0f; const float cutoff=80*powf(250,b[13]/127.0f); fx[t].alpha = b[13]>=127?1:1-expf(-6.2831853f*cutoff/44100); fx[t].send=b[14]/127.0f; // Echo send follows output mode and gain, never bypasses mute or master. const int g=t>>2, i=t&3; if(sendMode[t]!=k.outMode) { sendMode[t]=k.outMode; for(int c=0;c<4;++c) sendWire[t*4+c].disconnect(); if(k.outMode==3) { for(int side=0;side<2;++side) for(int c=0;c<2;++c) sendWire[t*4+side*2+c].connect(fx[t],c+2,sends[side][g],i); } else { if(k.outMode!=2) sendWire[t*4].connect(fx[t],2,sends[0][g],i); if(k.outMode!=1) sendWire[t*4+3].connect(fx[t],3,sends[1][g],i); } } sends[0][g].gain(i,vol*(k.outMode==3?0.5f:1)); sends[1][g].gain(i,vol*(k.outMode==3?0.5f:1)); echo.delaySamples=min(32767U,(uint32_t)(44100.0f*45/bpm)); const bool isLoop=k.loopMode && b[1]==2; const bool on=isLoop && ((run>>t)&1), was=(lastLoopRun>>t)&1; const float regionMs=(hi-lo)/44.1f; const float barMs=1000.0f*BarTicks()/OC_CORE_ISR_FREQ; uint32_t barsQ=Word(b,8); if(!barsQ && isLoop) { int bars=1; const float raw=regionMs/barMs; for(int c=2;c<=32;c<<=1) if(fabsf(log2f(raw/c))>t)&1)) { player[t].stop(); player[t].play(); grainAt[t]=millis(); grainStep[t]=0; } if(!on && was && isLoop) player[t].stop(); Gain(t,vol,k.outMode,cvTarget[t]==2?constrain(k.pan+mod,0,127):k.pan); // Deterministic SD slice reorder (not overlapping granular synthesis). if(b[15] && player[t].isPlaying() && millis()-grainAt[t]>=max(10U,(uint32_t)Word(b,16))) { grainAt[t]=millis(); const uint32_t grain=max(1U,(uint32_t)(Word(b,16)*44.1f)); const uint32_t count=max(1U,(hi-lo)/grain); const uint32_t index=(grainStep[t]++*(int(b[15]*7/127)*2+1)+b[15]*count/127)%count; player[t].setPlayStart(play_start_arbitrary,lo+index*grain); player[t].stop(); player[t].play(); } } lastLoopRun = run; } // ===================================================================== USB host MIDI (main loop) FLASHMEM void AppLPSeq::PollDevices() { for (int i = 0; i < 2; ++i) { const uint16_t vid = usbHostMIDI[i].idVendor(); DevType type = DEV_NONE; if (vid == 0x1235) type = DEV_LAUNCHPAD; // Novation else if (vid == 0x09E8) type = DEV_MIDIMIX; // Akai else if (vid != 0) type = DEV_OTHER; if (type != dev[i]) { dev[i] = type; if (type == DEV_LAUNCHPAD) { static const uint8_t sx[] = {0xF0, 0x00, 0x20, 0x29, 0x02, 0x18, 0x22, 0x00, 0xF7}; usbHostMIDI[i].sendSysEx(sizeof(sx), sx, true); } ForceLedRefresh(); } while (usbHostMIDI[i].read()) { const uint8_t mtype = usbHostMIDI[i].getType(); const uint8_t d1 = usbHostMIDI[i].getData1(); const uint8_t d2 = usbHostMIDI[i].getData2(); if (dev[i] == DEV_LAUNCHPAD) HandleLaunchpad(mtype, d1, d2); else if (dev[i] == DEV_MIDIMIX) HandleMidimix(mtype, d1, d2); } } } FLASHMEM void AppLPSeq::HandleLaunchpad(uint8_t type, uint8_t d1, uint8_t d2) { if (type == usbMIDI.ControlChange && d1 >= 104 && d1 <= 111) { if (d2 > 0) TopButton(d1 - 104); return; } if (type != usbMIDI.NoteOn && type != usbMIDI.NoteOff) return; const bool down = type == usbMIDI.NoteOn && d2 > 0; const int row = d1 / 10, col = d1 % 10; if (row < 1 || row > 8 || col < 1 || col > 9) return; if (col == 9) { if (down) SideButton(8 - row); return; } if (down) PadDown(col - 1, 8 - row); else PadUp(col - 1, 8 - row); } FLASHMEM void AppLPSeq::ApplyKnob(int t, uint8_t target, uint8_t v) { Track &k = trk[t]; if (target == 0) k.pitch = (v * 48 + 63) / 127; else if (target == 1) k.startPt = v; else if (target == 2) k.pan = v; } // Akai MIDImix factory map: column c knobs = fader CC - 3, -2, -1; master fader CC 62; // mute buttons notes 1,4..22; rec-arm notes 3,6..24. FLASHMEM void AppLPSeq::HandleMidimix(uint8_t type, uint8_t d1, uint8_t d2) { static constexpr uint8_t FADER_CC[TRACKS] = {19, 23, 27, 31, 49, 53, 57, 61}; if (type == usbMIDI.ControlChange) { if (d1 == 62) { master = d2; return; } for (int t = 0; t < TRACKS; ++t) { if (d1 == FADER_CC[t]) { trk[t].volume = d2; return; } if (d1 >= FADER_CC[t] - 3 && d1 < FADER_CC[t]) { const int row = d1 - (FADER_CC[t] - 3); const uint8_t sn = trk[t].sampleNo; if (sn < SLOTS) { uint8_t *b = slots[sn].b; const int target = playerMap[row]; if (target==0) b[10] = (d2*96+63)/127; else if (target==1) PutWord(b,4,min((int)Word(b,6)-66,d2*510)); else if (target==2) trk[t].pan=d2; else if (target==4) b[13]=d2; else if (target==5) b[14]=d2; else if (target==6) b[15]=d2; else if (target==7) { const float v = (d2-64)/63.0f; b[2]=(b[2]&~1)|(v<0); PutWord(b,11,256*(v<0?max(0.25f,-v*2):1+v)); } slotDirty=true; slotSaveMs=millis(); } else ApplyKnob(t, knobMap[row], d2); return; } } return; } if (type != usbMIDI.NoteOn || d2 == 0 || d1 < 1 || d1 > 24) return; if ((d1 - 1) % 3 == 0) { trk[(d1 - 1) / 3].muted = !trk[(d1 - 1) / 3].muted; return; } if (d1 % 3 == 0) { const int t = d1 / 3 - 1; if (trk[t].loopMode) LoopPress(t, DefaultLoop(t)); } } FLASHMEM void AppLPSeq::UpdateLeds() { using namespace lpseq; const uint32_t now = millis(); if (now - last_led_ms < 25) return; last_led_ms = now; // pulse: triangle 0.35..1.0, ~1 Hz const int ph = now % 1000; const int tri = ph < 500 ? ph : 1000 - ph; // 0..500 const int pf = 90 + (tri * 166) / 500; // 90..256 const int lp = LaunchpadSlot(); if (lp >= 0) { MIDIDevice_BigBuffer &d = usbHostMIDI[lp]; uint8_t buf[8 + 4 * 24]; int n = 0; auto flush = [&]() FLASHMEM { if (!n) return; uint8_t msg[8 + 4 * 24]; msg[0] = 0xF0; msg[1] = 0x00; msg[2] = 0x20; msg[3] = 0x29; msg[4] = 0x02; msg[5] = 0x18; msg[6] = 0x0B; memcpy(msg + 7, buf, n); msg[7 + n] = 0xF7; d.sendSysEx(8 + n, msg, true); n = 0; }; auto put = [&](uint8_t note, RGB c) FLASHMEM { if (ledValid[note] && Same(ledRgb[note], c)) return; ledValid[note] = true; ledRgb[note] = c; buf[n++] = note; buf[n++] = c.r; buf[n++] = c.g; buf[n++] = c.b; if (n >= 4 * 24) flush(); }; for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x) { bool pulse; RGB c = PadColor(x, y, pulse); if (pulse) c = Scale(c, pf); put((8 - y) * 10 + x + 1, c); } for (int i = 0; i < 8; ++i) put((8 - i) * 10 + 9, SideColor(i)); flush(); for (int i = 0; i < 8; ++i) { bool pulse; RGB c = TopColor(i, pulse); if (pulse && pf < 170) c = C_DIM; // palette has no brightness: blink const uint8_t v = ToVelocity(c); if (topValid[i] && ledTop[i] == v) continue; topValid[i] = true; ledTop[i] = v; d.sendControlChange(104 + i, v, 1); } } const int mm = MidimixSlot(); if (mm >= 0) { uint8_t mutes = 0; for (int t = 0; t < TRACKS; ++t) if (trk[t].muted) mutes |= 1 << t; if (!mmValid || mutes != mmMutesSent) { for (int t = 0; t < TRACKS; ++t) usbHostMIDI[mm].sendNoteOn(1 + t * 3, (mutes >> t) & 1 ? 127 : 0, 1); mmMutesSent = mutes; mmValid = true; } } } // ===================================================================== web app link (USB-C device MIDI) // SysEx: F0 7D 4C 50 ... F7, payload bytes sent as two nibbles each. // 01 t track header // 02 t page(0..7) <8 steps x 4> steps page*8 .. page*8+7 // 03 global // 10 ping // 20 dump request -> module replies 43, 41 t, 42 t page (same layout) // Reply: F0 7D 4C 50 7F F7 FLASHMEM void AppLPSeq::PollWebApp() { while (usbMIDI.read()) { lastAnyMs = millis(); if (usbMIDI.getType() == usbMIDI.SystemExclusive) HandleSysEx(usbMIDI.getSysExArray(), usbMIDI.getSysExArrayLength()); } } FLASHMEM void AppLPSeq::HandleSysEx(const uint8_t *d, unsigned n) { using lpseq::Nib; if (n < 6 || d[0] != 0xF0 || d[1] != 0x7D || d[2] != 0x4C || d[3] != 0x50) return; const uint8_t cmd = d[4]; const uint8_t *p = d + 5; const unsigned avail = n - 6; // without F7 uint8_t arg = 0; ++rxCount; if (cmd == 0x01 && avail >= 1 + HDR_V13 * 2) { const int t = p[0] & 7; arg = t; const int nh = avail >= 1 + HDR * 2 ? HDR : avail >= 1 + HDR_V14 * 2 ? HDR_V14 : HDR_V13; uint8_t h[HDR]; for (int i = 0; i < nh; ++i) h[i] = Nib(p + 1 + i * 2); UnpackHdr(t, h, nh); } else if (cmd == 0x02 && avail >= 2 + 8 * 4 * 2) { const int t = p[0] & 7, page = p[1] & 7; arg = t * 8 + page; const uint8_t *q = p + 2; for (int s = 0; s < 8; ++s) for (int b = 0; b < 4; ++b) trk[t].st[page * 8 + s][b] = Nib(q + (s * 4 + b) * 2); } else if (cmd == 0x03 && avail >= 4) { bpm = constrain((Nib(p) << 8) | Nib(p + 2), 20, 300); } else if (cmd == 0x07 && avail >= 6) { for (int i = 0; i < 3; ++i) knobMap[i] = Nib(p + i * 2) & 3; } else if (cmd == 0x08 && avail == 1 + SLOT_BYTES * 2 && p[0] < SLOTS) { arg = p[0]; uint8_t b[SLOT_BYTES]; for (int i=0;i= 6) { for (int i=0;i<3;++i) playerMap[i] = min(7, Nib(p+i*2)); slotDirty = true; slotSaveMs = millis(); } else if (cmd == 0x04 && avail >= 1) { if (p[0]) Play(); else Stop(); } else if (cmd == 0x06 && avail >= 2) { LoopPress(p[0] & 7, p[1] & 7); arg = p[0] & 7; } else if (cmd == 0x20) { SendDump(); } else if (cmd >= 0x30 && cmd <= 0x3E) { lastRxMs = millis(); HandleXfer(cmd, p, avail); return; } else if (cmd != 0x10) { return; } lastRxMs = millis(); const uint8_t ack[] = {0xF0, 0x7D, 0x4C, 0x50, 0x7F, cmd, uint8_t(arg & 0x7F), 0xF7}; usbMIDI.sendSysEx(sizeof(ack), ack, true); usbMIDI.send_now(); } FLASHMEM void AppLPSeq::PackHdr(const Track &k, uint8_t *h) const { h[0] = k.length; h[1] = k.lengthMode; h[2] = k.seqMode; h[3] = k.gLevel; h[4] = k.gLength; h[5] = k.swingIdx; for (int i = 0; i < 8; ++i) h[6 + i] = k.swingPresets[i]; h[14] = k.rndGates; h[15] = k.rndGateAmt; h[16] = k.rndVelAmt; h[17] = k.rndNotes; h[18] = k.noteMin; h[19] = k.noteMax; h[20] = k.scale; h[21] = k.root; h[22] = k.loopMode; h[23] = k.muted; h[24] = k.volume; h[25] = k.sampleNo; h[26] = k.outMode; for (int i = 0; i < 8; ++i) h[27 + i] = k.loopNo[i]; h[35] = k.pitch; h[36] = k.startPt; h[37] = k.pan; } FLASHMEM void AppLPSeq::UnpackHdr(int t, const uint8_t *h, int n) { Track &k = trk[t]; k.length = constrain(h[0], 1, MAX_STEPS); k.lengthMode = constrain(h[1], 0, 2); k.seqMode = h[2] ? 1 : 0; k.gLevel = constrain(h[3], 1, 8); k.gLength = constrain(h[4], 1, 8); k.swingIdx = h[5] & 7; for (int i = 0; i < 8; ++i) k.swingPresets[i] = min((int)h[6 + i], 56); k.rndGates = h[14] ? 1 : 0; k.rndGateAmt = constrain(h[15], 1, 8); k.rndVelAmt = min((int)h[16], 8); k.rndNotes = h[17] ? 1 : 0; k.noteMin = h[18] & 0x7F; k.noteMax = h[19] & 0x7F; k.scale = h[20] & 7; k.root = h[21] % 12; k.loopMode = h[22] ? 1 : 0; k.muted = h[23] ? 1 : 0; k.volume = h[24] & 0x7F; k.sampleNo = h[25] & 0x7F; k.outMode = constrain(h[26], 0, 3); if (n >= HDR_V14) for (int i = 0; i < 8; ++i) k.loopNo[i] = h[27 + i] & 0x7F; if (n >= HDR) { k.pitch = min((int)h[35], 48); k.startPt = h[36] & 0x7F; k.pan = h[37] & 0x7F; } if (!k.loopMode) StopLoopNow(t); else if (loopAct[t] >= 0) k.sampleNo = k.loopNo[loopAct[t]]; // the running loop keeps playing if (pos[t] >= k.length) pos[t] = -1; } // ---------------------------------------------------------------- WAV transfer (web app <-> SD) // 7-bit packing: groups of 7 bytes -> 1 MSB byte + 7 low bytes. // 30 slot s3 s2 s1 s0 open /NNN.TMP for writing (size as 4x7 bit) // 31 seqH seqL append chunk (seq must follow) // 32 close, replace /NNN.WAV // 33 abort // 38 slot info -> 52 slot exists s3..s0 // 39 slot o3..o0 read -> 51 o3..o0 lenH lenL (max 196 bytes) // 3A end of read session // Reply 50 seqH seqL namespace lpseq_xfer { File f; uint8_t slot = 127; uint16_t seq = 0; static constexpr int CHUNK = 196; static inline uint32_t U28(const uint8_t *p) { return (uint32_t(p[0] & 0x7F) << 21) | (uint32_t(p[1] & 0x7F) << 14) | (uint32_t(p[2] & 0x7F) << 7) | (p[3] & 0x7F); } static inline void P28(uint8_t *o, uint32_t v) { o[0] = (v >> 21) & 0x7F; o[1] = (v >> 14) & 0x7F; o[2] = (v >> 7) & 0x7F; o[3] = v & 0x7F; } static inline void Path(char *b, int n, uint8_t s, const char *ext) { snprintf(b, n, "/%03d.%s", s, ext); } } FLASHMEM void AppLPSeq::XReply(uint8_t cmd, const uint8_t *b, int n) { uint8_t m[8 + 6 + 230]; int k = 0; m[k++] = 0xF0; m[k++] = 0x7D; m[k++] = 0x4C; m[k++] = 0x50; m[k++] = cmd; for (int i = 0; i < n; ++i) m[k++] = b[i] & 0x7F; m[k++] = 0xF7; usbMIDI.sendSysEx(k, m, true); usbMIDI.send_now(); } FLASHMEM void AppLPSeq::HandleXfer(uint8_t cmd, const uint8_t *p, unsigned avail) { using namespace lpseq_xfer; char path[16], path2[16]; uint8_t r[4] = {0, 0, 0, 0}; auto pause = [&]() FLASHMEM { if (!xfer) { StopAudio(); xfer = true; } }; auto resume = [&]() FLASHMEM { if (f) f.close(); xfer = false; for (int t = 0; t < TRACKS; ++t) loadedNo[t] = 255; // reload samples }; if (!SDcard_Ready) { if (cmd == 0x3E) { uint8_t b[9] = {1}; XReply(0x54, b, 9); return; } r[0] = 1; XReply(0x50, r, 3); return; } if (cmd == 0x30 && avail >= 5) { pause(); if (f) f.close(); slot = p[0] & 0x7F; seq = 0; Path(path, sizeof(path), slot, "TMP"); if (SD.exists(path)) SD.remove(path); f = SD.open(path, FILE_WRITE_BEGIN); r[0] = f ? 0 : 3; XReply(0x50, r, 3); } else if (cmd == 0x31 && avail >= 2) { const uint16_t s = (uint16_t(p[0] & 0x7F) << 7) | (p[1] & 0x7F); r[1] = p[0]; r[2] = p[1]; if (!f) { r[0] = 3; XReply(0x50, r, 3); return; } if (s != (seq & 0x3FFF)) { r[0] = 2; XReply(0x50, r, 3); return; } uint8_t out[CHUNK]; int n = 0; const uint8_t *q = p + 2; unsigned left = avail - 2; while (left > 0 && n < CHUNK) { const uint8_t msb = *q++; --left; for (int i = 0; i < 7 && left > 0 && n < CHUNK; ++i, --left) out[n++] = (*q++ & 0x7F) | (((msb >> i) & 1) << 7); } if (f.write(out, n) != (size_t)n) r[0] = 1; else ++seq; XReply(0x50, r, 3); } else if (cmd == 0x32) { if (!f) { r[0] = 3; XReply(0x50, r, 3); return; } f.close(); Path(path, sizeof(path), slot, "TMP"); Path(path2, sizeof(path2), slot, "WAV"); if (SD.exists(path2)) SD.remove(path2); r[0] = SD.rename(path, path2) ? 0 : 1; resume(); XReply(0x50, r, 3); } else if (cmd == 0x33 || cmd == 0x3A) { if (cmd == 0x33 && f) { f.close(); Path(path, sizeof(path), slot, "TMP"); SD.remove(path); } resume(); XReply(0x50, r, 3); } else if (cmd == 0x38 && avail >= 1) { pause(); const uint8_t s = p[0] & 0x7F; Path(path, sizeof(path), s, "WAV"); uint8_t b[6] = {s, 0, 0, 0, 0, 0}; File g = SD.open(path, FILE_READ); if (g) { b[1] = 1; P28(b + 2, g.size()); g.close(); } XReply(0x52, b, 6); } else if (cmd == 0x39 && avail >= 5) { pause(); const uint8_t s = p[0] & 0x7F; const uint32_t off = U28(p + 1); Path(path, sizeof(path), s, "WAV"); uint8_t b[6 + 230]; P28(b, off); int n = 0; uint8_t raw[CHUNK]; if (!f || slot != s) { if (f) f.close(); f = SD.open(path, FILE_READ); slot = s; } if (f && f.seek(off)) n = f.read(raw, CHUNK); if (n < 0) n = 0; b[4] = n >> 7; b[5] = n & 0x7F; int k = 6; for (int i = 0; i < n; i += 7) { uint8_t msb = 0; const int g = min(7, n - i); for (int j = 0; j < g; ++j) msb |= ((raw[i + j] >> 7) & 1) << j; b[k++] = msb; for (int j = 0; j < g; ++j) b[k++] = raw[i + j] & 0x7F; } XReply(0x51, b, k); } else if (cmd == 0x3B && avail >= 1) { // list page: up to 16 files NNN.WAV -> 53 page more {slot size4 ch rateH rateL bits}* pause(); const int page = p[0] & 0x7F; uint8_t b[2 + 16 * 9]; int k = 2; for (int s = page * 16; s < page * 16 + 16 && s < 127; ++s) { Path(path, sizeof(path), s, "WAV"); File g = SD.open(path, FILE_READ); if (!g) continue; uint8_t hd[44] = {0}; const uint32_t size = g.size(); g.read(hd, sizeof(hd)); g.close(); const uint32_t rate = hd[24] | (hd[25] << 8) | (uint32_t(hd[26]) << 16); b[k++] = s; P28(b + k, size); k += 4; b[k++] = hd[22] & 0x7F; const uint32_t r10 = rate / 10; b[k++] = (r10 >> 7) & 0x7F; b[k++] = r10 & 0x7F; b[k++] = hd[34] & 0x7F; } b[0] = page; b[1] = (page * 16 + 16 < 127) ? 1 : 0; if (!b[1]) resume(); XReply(0x53, b, k); } else if (cmd == 0x3C && avail >= 1) { pause(); Path(path, sizeof(path), p[0] & 0x7F, "WAV"); r[0] = SD.remove(path) ? 0 : 1; resume(); XReply(0x50, r, 3); } else if (cmd == 0x3D && avail >= 2) { pause(); Path(path, sizeof(path), p[0] & 0x7F, "WAV"); Path(path2, sizeof(path2), p[1] & 0x7F, "WAV"); r[0] = (!SD.exists(path2) && SD.rename(path, path2)) ? 0 : 1; resume(); XReply(0x50, r, 3); } else if (cmd == 0x3E) { // card info -> 54 status totalKB4 usedKB4 pause(); uint8_t b[9] = {0}; P28(b + 1, uint32_t(SD.totalSize() >> 10)); P28(b + 5, uint32_t(SD.usedSize() >> 10)); XReply(0x54, b, 9); } } // Autosave: whenever pattern data changed and stayed unchanged for 4 s, // write app data to flash (same as long-press save in the main menu). FLASHMEM void AppLPSeq::AutoSave() { const uint32_t now = millis(); if (now - asCheckMs < 500) return; asCheckMs = now; uint32_t h = 2166136261u ^ bpm ^ (knobMap[0] << 16) ^ (knobMap[1] << 20) ^ (knobMap[2] << 24); const uint8_t *b = (const uint8_t *)trk; for (unsigned i = 0; i < sizeof(trk); ++i) h = (h ^ b[i]) * 16777619u; if (!asSum) { asSum = h; return; } if (h != asSum) { asSum = h; asChangedMs = now; return; } if (asChangedMs && now - asChangedMs > 4000) { asChangedMs = 0; OC::SaveAppData(); lastSaveMs = millis(); } } FLASHMEM void AppLPSeq::SendMsg(uint8_t cmd, const uint8_t *args, int na, const uint8_t *pl, int np) { uint8_t m[8 + 2 + 64 * 2]; int n = 0; m[n++] = 0xF0; m[n++] = 0x7D; m[n++] = 0x4C; m[n++] = 0x50; m[n++] = cmd; for (int i = 0; i < na; ++i) m[n++] = args[i] & 0x7F; for (int i = 0; i < np; ++i) { m[n++] = pl[i] >> 4; m[n++] = pl[i] & 0x0F; } m[n++] = 0xF7; usbMIDI.sendSysEx(n, m, true); usbMIDI.send_now(); } // Live sync: while the web app pings (every 1 s), push play state every 40 ms // (44: playing loopRun sel bpmH bpmL pos+1 x8) and any changed track (41/42). FLASHMEM void AppLPSeq::LiveSync() { const uint32_t now = millis(); // heartbeat, sent even when nothing arrives: lets the web app tell "cable ok, but // the module does not hear me" apart from "no module at all" if (now - helloMs >= 1000) { helloMs = now; const uint8_t hb[3] = {uint8_t((rxCount >> 7) & 0x7F), uint8_t(rxCount & 0x7F), FW_VERSION}; SendMsg(0x45, hb, 3, nullptr, 0); } if (xfer || !lastRxMs || now - lastRxMs > 3000 || now - syncMs < 40) return; syncMs = now; uint8_t a[29] = {uint8_t(playing ? 1 : 0), uint8_t(loopRun & 0x7F), uint8_t((loopRun >> 7) | (sel << 1)), uint8_t((bpm >> 7) & 0x7F), uint8_t(bpm & 0x7F)}; for (int t = 0; t < TRACKS; ++t) a[5 + t] = uint8_t(pos[t] + 1) & 0x7F; for (int t = 0; t < TRACKS; ++t) { a[13 + t * 2] = loopAct[t] < 0 ? 0 : loopAct[t] + 1; a[14 + t * 2] = loopPend[t] < 0 ? 0 : loopPend[t] == 15 ? 15 : loopPend[t] + 1; } SendMsg(0x44, a, 29, nullptr, 0); for (int t = 0; t < TRACKS; ++t) { uint32_t h = 2166136261u; const uint8_t *b = (const uint8_t *)&trk[t]; for (unsigned i = 0; i < sizeof(Track); ++i) h = (h ^ b[i]) * 16777619u; if (h != syncHash[t]) { syncHash[t] = h; SendTrack(t); return; } // one per tick } } FLASHMEM void AppLPSeq::SendTrack(int t) { const Track &k = trk[t]; uint8_t h[HDR]; PackHdr(k, h); uint8_t a[1] = {uint8_t(t)}; SendMsg(0x41, a, 1, h, HDR); for (int page = 0; page < 8; ++page) { uint8_t a2[2] = {uint8_t(t), uint8_t(page)}; SendMsg(0x42, a2, 2, &k.st[page * 8][0], 32); } } FLASHMEM void AppLPSeq::SendDump() { const uint8_t b[5] = {uint8_t(bpm >> 8), uint8_t(bpm & 0xFF), knobMap[0], knobMap[1], knobMap[2]}; SendMsg(0x43, nullptr, 0, b, 5); for (int t = 0; t < TRACKS; ++t) { const Track &k = trk[t]; uint8_t h[HDR]; PackHdr(k, h); uint8_t a[1] = {uint8_t(t)}; SendMsg(0x41, a, 1, h, HDR); for (int page = 0; page < 8; ++page) { uint8_t a2[2] = {uint8_t(t), uint8_t(page)}; SendMsg(0x42, a2, 2, &k.st[page * 8][0], 32); delay(2); } } SendMsg(0x49, nullptr, 0, playerMap, 3); for (int i=0;i" : "#"); if (ticks_since_clock < EXT_TIMEOUT) gfxPrint(48, 1, "EXT"); else { gfxPrint(48, 1, bpm); } if (xfer) gfxPrint(76, 1, "WAV"); else if (millis() - lastRxMs < 1500 && lastRxMs) gfxPrint(76, 1, "WEB"); else if (millis() - lastAnyMs < 1500 && lastAnyMs) gfxPrint(76, 1, "USB"); else if (lastSaveMs && millis() - lastSaveMs < 1500) gfxPrint(76, 1, "SAV"); gfxPrint(98, 1, dev[0] == DEV_LAUNCHPAD || dev[1] == DEV_LAUNCHPAD ? "LP" : (dev[0] == DEV_OTHER || dev[1] == DEV_OTHER ? "??" : "--")); gfxPrint(114, 1, dev[0] == DEV_MIDIMIX || dev[1] == DEV_MIDIMIX ? "MX" : "--"); // 8x8 mini grid of the selected channel const Track &k = trk[sel]; for (int s = 0; s < MAX_STEPS; ++s) { const int x = (s % 8) * 6, y = 14 + (s / 8) * 6; if (s >= k.length) continue; if (k.loopMode) { if (s >= 8) continue; if (loopAct[sel] == s) gfxRect(x, y, 5, 5); else if (loopPend[sel] == s && (millis() / 250) & 1) gfxRect(x, y, 5, 5); else if (k.loopNo[s] < 127) gfxFrame(x, y, 5, 5); else gfxPixel(x + 2, y + 2); continue; } if (On(sel, s)) gfxRect(x, y, 5, 5); else gfxFrame(x, y, 5, 5); if (playing && pos[sel] == s) gfxInvert(x, y, 5, 5); if (s == selStep) gfxFrame(x - 1, y - 1, 7, 7); } static const char *const fnames[8] = {"", "SWING", "TRIG", "BPM", "LOOPS", "ERASE", "RND", "SMP"}; gfxPrint(52, 14, "CH"); gfxPrint(sel + 1); gfxPrint(76, 14, k.loopMode ? "LOOP" : "STEP"); gfxPrint(52, 24, "LEN "); gfxPrint(k.length); if (cmode == M_LENGTH) gfxPrint(100, 24, "SET"); gfxPrint(52, 34, focus ? fnames[focus] : "-"); gfxPrint(92, 34, "RX"); gfxPrint(rxCount % 1000); gfxPrint(52, 44, "SMP "); if (k.sampleNo < 127) { char b[8]; snprintf(b, sizeof(b), "%03d", k.sampleNo); gfxPrint(b); } else gfxPrint("-"); if (focus == F_SAMPLE) { static const char *const om[4] = {"ST", "L", "R", "SUM"}; gfxPrint(" "); gfxPrint(om[k.outMode]); gfxPrint(100, 44, "SET"); } else if (k.outMode == 1) gfxPrint("/L"); else if (k.outMode == 2) gfxPrint("/R"); else if (k.outMode == 3) gfxPrint("/S"); // channel activity row for (int t = 0; t < TRACKS; ++t) { const int x = 52 + t * 9; if (trk[t].muted) gfxPrint(x, 55, "x"); else if (gateLeft[t] || (trk[t].loopMode && ((loopRun >> t) & 1))) gfxRect(x, 56, 6, 6); else gfxFrame(x, 56, 6, 6); } } FLASHMEM void AppLPSeq::DrawScreensaver() const { View(); } FLASHMEM void AppLPSeq::DrawMenu() const { View(); } FLASHMEM void AppLPSeq::DrawDebugInfo() const { } // ===================================================================== app glue FLASHMEM void AppLPSeq::Init() { BaseStart(); } void AppLPSeq::Process(OC::IOFrame *ioframe) { digital_triggers = ioframe->digital_inputs.triggered(); BaseController(ioframe); } FLASHMEM void AppLPSeq::Loop() { ServiceSlots(); PollWebApp(); CheckGesture(); ProcessLoopQuant(); LiveSync(); AutoSave(); PollDevices(); UpdateLeds(); ServiceAudio(); } FLASHMEM void AppLPSeq::HandleAppEvent(OC::AppEvent event) { if (event == OC::APP_EVENT_RESUME) { Resume(); lastLoopRun = 0; } if (event == OC::APP_EVENT_SUSPEND) StopAudio(); } FLASHMEM void AppLPSeq::GetIOConfig(OC::IOConfig &ioconfig) const { ioconfig.digital_inputs[0].set("Clock"); ioconfig.digital_inputs[1].set("Reset"); static const char *const names[TRACKS] = { "Ch 1", "Ch 2", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7", "Ch 8"}; for (int t = 0; t < TRACKS; ++t) ioconfig.outputs[t].set(names[t], OC::OUTPUT_MODE_UNI); } FLASHMEM size_t AppLPSeq::SaveAppData(util::StreamBufferWriter &sb) const { for (int t = 0; t < TRACKS; ++t) { const Track &k = trk[t]; uint8_t h[HDR]; PackHdr(k, h); for (int i = 0; i < HDR; ++i) sb.Write(h[i]); for (int s = 0; s < MAX_STEPS; ++s) for (int b = 0; b < 4; ++b) sb.Write(k.st[s][b]); } sb.Write(bpm); for (int i = 0; i < 3; ++i) sb.Write(knobMap[i]); return sb.written(); } FLASHMEM size_t AppLPSeq::RestoreAppData(util::StreamBufferReader &sb) { for (int t = 0; t < TRACKS; ++t) { uint8_t h[HDR]; for (int i = 0; i < HDR; ++i) h[i] = sb.Read(); loopAct[t] = -1; loopPend[t] = -1; UnpackHdr(t, h, HDR); Track &k = trk[t]; for (int s = 0; s < MAX_STEPS; ++s) for (int b = 0; b < 4; ++b) k.st[s][b] = sb.Read(); } bpm = constrain(sb.Read(), 20, 300); for (int i = 0; i < 3; ++i) knobMap[i] = sb.Read() & 3; return sb.read(); } FLASHMEM void AppLPSeq::HandleButtonEvent(const UI::Event &event) { if (event.type == UI::EVENT_BUTTON_LONG_PRESS) { if (event.control == OC::CONTROL_BUTTON_R) { settings = !settings; heldMask = 0; ForceLedRefresh(); return; } if (event.control == OC::CONTROL_BUTTON_L) { selStep = -1; ToggleFocus(F_SAMPLE); } return; } if (event.type != UI::EVENT_BUTTON_PRESS) return; if (settings) { if (event.control == OC::CONTROL_BUTTON_R) settings = false; else if (event.control == OC::CONTROL_BUTTON_L) { editPad = (editPad + 1) & 7; } else if (event.control == OC::CONTROL_BUTTON_UP) SelectChannel((sel + 7) & 7); else if (event.control == OC::CONTROL_BUTTON_DOWN) SelectChannel((sel + 1) & 7); return; } switch (event.control) { case OC::CONTROL_BUTTON_L: if (focus == F_SAMPLE) ToggleFocus(F_SAMPLE); else if (playing) Stop(); else Play(); break; case OC::CONTROL_BUTTON_R: trk[sel].muted = !trk[sel].muted; break; case OC::CONTROL_BUTTON_UP: reset_pending = true; break; case OC::CONTROL_BUTTON_DOWN: if (trk[sel].loopMode) LoopPress(sel, DefaultLoop(sel)); break; default: break; } } FLASHMEM void AppLPSeq::HandleEncoderEvent(const UI::Event &event) { if (settings) { if (event.control == OC::CONTROL_ENCODER_L) settingRow = constrain(settingRow + event.value, 0, 24); else if (event.control == OC::CONTROL_ENCODER_R) EditSetting(event.value); return; } if (focus == F_SAMPLE) { Track &k = trk[sel]; if (event.control == OC::CONTROL_ENCODER_L) k.sampleNo = (k.sampleNo + event.value + 128) & 0x7F; else if (event.control == OC::CONTROL_ENCODER_R) k.outMode = (k.outMode + 4 + (event.value & 0x03)) & 0x03; return; } if (event.control == OC::CONTROL_ENCODER_L) bpm = constrain(bpm + event.value, 20, 300); if (event.control == OC::CONTROL_ENCODER_R) SelectChannel(sel + event.value); } FLASHMEM void AppLPSeq::InitSlots() { settings=false; settingRow=editPad=0; slotDirty=false; settingsLoaded=false; slotSaveMs=slotCheckMs=0; quarterEdge=false; quarterPhase=0; playerMap[0]=0; playerMap[1]=4; playerMap[2]=6; for(int t=0;t<8;++t) {cvInput[t]=255;cvTarget[t]=0;cvAmount[t]=24;loopQuarter[t]=0;loopPassQ[t]=256;grainAt[t]=grainStep[t]=loopElapsed[t]=0;} for(int n=0;n=4000) SaveSlots(); if(millis()-slotCheckMs<80 || !lastRxMs || millis()-lastRxMs>3000) return; slotCheckMs=millis(); for(int n=0;n=10 && settingRow<=12 && b) b[settingRow+3]=constrain(b[settingRow+3]+d,0,127); else if(settingRow==13 && b) PutWord(b,16,constrain((int)Word(b,16)+d*5,10,1000)); else if(settingRow==14) k.outMode=constrain(k.outMode+d,0,3); else if(settingRow==15) k.volume=constrain(k.volume+d,0,127); else if(settingRow==16) k.pan=constrain(k.pan+d,0,127); else if(settingRow>=17 && settingRow<=19) playerMap[settingRow-17]=constrain(playerMap[settingRow-17]+d,0,7); else if(settingRow==20) {int v=cvInput[sel]==255?0:cvInput[sel]+1;v=constrain(v+d,0,ADC_CHANNEL_COUNT);cvInput[sel]=v?v-1:255;} else if(settingRow==21) cvTarget[sel]=constrain(cvTarget[sel]+d,0,7); else if(settingRow==22) cvAmount[sel]=constrain(cvAmount[sel]+d,-127,127); else if(settingRow==23) {k.loopMode=d>0;if(!k.loopMode) StopLoopNow(sel);} else if(settingRow==24) k.muted=d>0; slotDirty=true;slotSaveMs=millis(); ForceLedRefresh(); } FLASHMEM void AppLPSeq::ViewSettings() const { static const char *const label[25]={"Bank","Pad","WAV / None","Role","Start %","End %","Bars / Auto","Color","Pitch","Direction","Filter","Delay send","Slice mix","Slice ms","Output","Volume","Pan","Knob top","Knob mid","Knob low","CV input","CV target","CV depth","Player / Seq","Mute"}; static const char *const target[8]={"Pitch","Start","Pan","Off","Filter","Delay","Slice","Direction"}; gfxHeader("LPSeq SET");gfxPrint(78,1,"CH");gfxPrint(sel+1);gfxPrint(" P");gfxPrint(editPad+1); const uint8_t sn=EditSlotNo();const uint8_t *b=sn "); if(settingRow==0) gfxPrint(sel+1); else if(settingRow==1) gfxPrint(editPad+1); else if(settingRow==2) {if(sn<127) gfxPrint(sn);else gfxPrint("None");} else if(settingRow>=3 && settingRow<=13 && !b) gfxPrint("No sample"); else if(settingRow==3) gfxPrint(b[1]==2?"Loop":b[1]==1?"Sample":"One Shot"); else if(settingRow==4 || settingRow==5) gfxPrint(Word(b,settingRow==4?4:6)*100/65535); else if(settingRow==6) {const int q=Word(b,8);if(!q) gfxPrint("Auto");else {char text[16];snprintf(text,sizeof(text),"%d.%02d",q/256,(q%256)*100/256);gfxPrint(text);}} else if(settingRow==7) gfxPrint(b[3]); else if(settingRow==8) gfxPrint((int)b[10]-48); else if(settingRow==9) {gfxPrint(b[2]&1?"REV ":"FWD ");gfxPrint(Word(b,11)*100/256);gfxPrint("%");} else if(settingRow>=10 && settingRow<=12) gfxPrint(b[settingRow+3]*100/127); else if(settingRow==13) gfxPrint(Word(b,16)); else if(settingRow==14) {static const char*o[]={"Stereo","Mono L","Mono R","Sum"};gfxPrint(o[trk[sel].outMode]);} else if(settingRow==15) gfxPrint(trk[sel].volume*100/127); else if(settingRow==16) gfxPrint(trk[sel].pan); else if(settingRow>=17 && settingRow<=19) gfxPrint(target[playerMap[settingRow-17]&7]); else if(settingRow==20) {if(cvInput[sel]==255) gfxPrint("Off");else gfxPrint(cvInput[sel]+1);} else if(settingRow==21) gfxPrint(target[cvTarget[sel]&7]); else if(settingRow==22) gfxPrint(cvAmount[sel]); else if(settingRow==23) gfxPrint(trk[sel].loopMode?"Player":"Sequencer"); else if(settingRow==24) gfxPrint(trk[sel].muted?"On":"Off"); gfxPrint(0,53,"SET ");gfxPrint(settingRow+1);gfxPrint("/25"); if(slotDirty) gfxPrint(78,53,"EDIT");else if(lastSaveMs && millis()-lastSaveMs<2000) gfxPrint(78,53,"SAV"); } #endif // ARDUINO_TEENSY41