// 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 extern "C" int usb_midi_try_levels(const uint32_t *packets); 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 = 31; 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 player, 2 sample player (64 pads) 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 padNo[8][64]; // sample player: sample number per pad (127 = empty) uint8_t clockIn, resetIn; // clock 0..3 TR1..TR4, 4..11 CV In 1..8 (v24); reset TR 0..3, 255 = none uint8_t lastPad[8]; uint32_t padHash[8], setHash; bool saveReq; void PadHit(int t, int pad); 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; int SetRows(uint8_t *out) const; void SetVal(int row) const; void SetMove(int d); void SetNextPage(); 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]; uint32_t webTrkMs, webSlotMs, playerHash; // v24: last pattern/slot data from the web app void LoadNames(); void SaveNames(); 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; // v25: bar grid counted in sixteenths of the real clock (internal or external) volatile uint8_t bar16, clkDiv, subLeft, passEnd; volatile uint32_t subAt; volatile bool extRun, clockStarted; volatile uint16_t loop16[TRACKS]; uint16_t pass16[TRACKS]; uint8_t resyncMask, clockPpqn; uint32_t autoKey[TRACKS], autoBars[TRACKS]; static uint8_t ValidPpqn(int q) { return (q==1||q==2||q==4||q==8||q==12||q==24||q==48) ? q : 4; } void Sixteenth(); // v26: phase lock for running loops (no restart every pass) volatile uint32_t isrTick, barAt; volatile uint32_t passTick[TRACKS]; volatile uint8_t forceSync; volatile bool stopAll; // v27: transport stop ends every loop (handled in the main loop) uint8_t phaseMask; float corr[TRACKS]; // 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 (extRun && ext_period > 0) return max(1U, ext_period * clockPpqn / 4U); // v31a: sixteenth = pulse * ppqn / 4 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; clockStarted = false; reset_pending = true; stopAll = 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 SendLevels(); 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 // v24: real sample names (UTF-8, max 23 bytes) per SD number, kept on the card. namespace lpseq_names { DMAMEM char nm[127][24]; bool dirty = false; uint32_t dirtyMs = 0; static inline void Clear() { memset(nm, 0, sizeof(nm)); } static inline void Touch() { dirty = true; dirtyMs = millis(); } } // namespace lpseq_names // ===================================================================== ISR void AppLPSeq::Controller() { ++isrTick; if (resetIn < 4 && (digital_triggers & (1u << resetIn))) reset_pending = true; if (reset_pending) { reset_pending = false; ResetPositions(); } bool ext; if (clockIn < 4) ext = digital_triggers & (1u << clockIn); else { // v24: CV input as clock, rising edge above ~1.5 V with hysteresis static bool cvHigh = false; const int ch = clockIn - 4; const int v = ch < ADC_CHANNEL_COUNT ? OC::ADC::unsmoothed_value((ADC_CHANNEL)ch) : 0; const bool hi = cvHigh ? v > HEMISPHERE_3V_CV / 4 : v > HEMISPHERE_3V_CV / 2; ext = hi && !cvHigh; cvHigh = hi; } if (ticks_since_clock < EXT_TIMEOUT) ++ticks_since_clock; if (ext) { const uint32_t m = ticks_since_clock; ticks_since_clock = 0; // v25: smoothed pulse period (steady loop tempo), jumps straight to a real tempo change if (extRun && m > 0 && m < EXT_TIMEOUT) ext_period = (!ext_period || m * 4 > ext_period * 5 || m * 5 < ext_period * 4) ? m : (ext_period * 3 + m) / 4; if (!extRun) { extRun = true; clkDiv = 0; subLeft = 0; if (!playing) { playing = true; clockStarted = true; ResetPositions(); } } const uint8_t q = clockPpqn; if (q > 4) { if (clkDiv == 0) Sixteenth(); if (++clkDiv >= q / 4) clkDiv = 0; } else { Sixteenth(); subLeft = 4 / q - 1; subAt = 0; } } else if (extRun) { const uint32_t lim = ext_period ? constrain(ext_period * 3, (uint32_t)OC_CORE_ISR_FREQ / 4, EXT_TIMEOUT) : EXT_TIMEOUT; if (ticks_since_clock >= lim) { // clock gone extRun = false; ext_period = 0; subLeft = 0; internal_ticks = 0; clockStarted = false; playing = false; reset_pending = true; stopAll = true; // v27: clock gone = stop all } else if (subLeft && ++subAt >= Base16()) { subAt = 0; --subLeft; Sixteenth(); } } else if (++internal_ticks >= Base16()) { internal_ticks = 0; Sixteenth(); } // 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); } } // v25: one sixteenth of the clock: steps (when playing), bar grid and loop passes. void AppLPSeq::Sixteenth() { if (playing) Tick16(); if (++bar16 >= 16) { bar16 = 0; barEdge = true; barAt = isrTick; } for (int t = 0; t < TRACKS; ++t) if (loopAct[t] >= 0 && ++loop16[t] >= pass16[t]) { loop16[t] = 0; passEnd |= 1 << t; passTick[t] = isrTick; } } 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); } } 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 // v25: next sixteenth is bar 1 / step 1; running loops restart there in time bar16 = 15; clkDiv = 0; subLeft = 0; for (int t = 0; t < TRACKS; ++t) if (loopAct[t] >= 0) loop16[t] = pass16[t] ? pass16[t] - 1 : 0; forceSync = 0xFF; // v26: after a reset running loops restart exactly on the new grid } // ===================================================================== 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; webTrkMs = webSlotMs = playerHash = 0; lpseq_names::Clear(); lpseq_names::dirty = false; playing = false; loopRun = 0; ticks_since_clock = EXT_TIMEOUT; ext_period = 0; extRun = clockStarted = false; passEnd = 0; resyncMask = 0; bar16 = 15; clkDiv = subLeft = 0; subAt = 0; for (int t = 0; t < TRACKS; ++t) { loop16[t] = 0; pass16[t] = 16; autoKey[t] = 0xFFFFFFFF; autoBars[t] = 256; passTick[t] = 0; corr[t] = 1; } isrTick = barAt = 0; forceSync = 0; phaseMask = 0; stopAll = false; 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 == 1 && loopAct[sel] < 0 && loopPend[sel] < 0) LoopPress(sel, DefaultLoop(sel)); Play(); break; case 7: if (focus != F_NONE) ToggleFocus(focus); selStep = -1; if (trk[sel].loopMode == 1 && 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 == 1) LoopPress(x, y); return; } if (trk[t].loopMode == 2) { PadHit(t, s); return; } if (trk[t].loopMode == 1) { 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) {if(trk[sel].loopMode==2) editPad=y*8+x; else {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 && tr.loopMode != 1) { 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 && !extRun && loopPend[t] >= 0 && loopPend[t] < 8) { noInterrupts(); bar16 = 15; internal_ticks = Base16(); interrupts(); } } void AppLPSeq::ProcessLoopQuant() { if (stopAll) { // v27 noInterrupts(); stopAll = false; interrupts(); for (int t = 0; t < TRACKS; ++t) StopLoopNow(t); noInterrupts(); barEdge = false; passEnd = 0; interrupts(); return; } noInterrupts(); const bool bar = barEdge; barEdge = false; quarterEdge = false; const uint8_t pe = passEnd; passEnd = 0; interrupts(); for (int t = 0; t < TRACKS; ++t) { const bool end = (pe >> t) & 1; const int pd = loopPend[t]; if (pd < 0 || (loopAct[t] < 0 ? !bar : !end)) { if (end && loopAct[t] >= 0) { if ((forceSync >> t) & 1) { forceSync &= ~(1 << t); resyncMask |= 1 << t; } else phaseMask |= 1 << t; // v26: nudge speed, no SD restart } continue; } loopPend[t] = -1; if (pd == 15 || trk[t].loopMode != 1) { StopLoopNow(t); continue; } noInterrupts(); loopAct[t] = pd; if (!end) { loop16[t] = bar16; passTick[t] = barAt; } else loop16[t] = 0; forceSync &= ~(1 << t); interrupts(); corr[t] = 1; trk[t].sampleNo = trk[t].loopNo[pd]; lastLoopRun &= ~(1 << t); loopRun |= (1 << t); resyncMask |= 1 << t; } } // ===================================================================== LED colours (web render* port) AppLPSeq::RGB AppLPSeq::PadColor(int x, int y, bool &pulse) const { using namespace lpseq; pulse = false; const Track &k = trk[sel]; const RGB col = C_TRACK[sel]; if (focus == F_SAMPLE) { const int s = y * 8 + x; if (k.sampleNo == s) { pulse = true; return col; } return s < 64 ? C_DIM : C_OFF; } if (focus == F_LOOPS) { const Track &c = trk[x]; if (c.loopMode != 1) return C_OFF; if (loopPend[x] == 15 && loopAct[x] == y) { pulse = true; return C_OFFRED; } // stops at loop end if (loopPend[x] == y) { pulse = true; return C_ACTIVE; } // starts at next bar / loop end if (loopAct[x] == y) { pulse = true; return c.loopNo[y] < 127 ? C_TRACK[constrain(slots[c.loopNo[y]].b[3], 1, 8) - 1] : C_TRACK[x]; } return c.loopNo[y] < 127 ? C_TRACK[constrain(slots[c.loopNo[y]].b[3], 1, 8) - 1] : C_OFF; } if (k.loopMode == 2) { const int s = y * 8 + x, n = padNo[sel][s]; if (n >= 127) return C_OFF; const RGB pc = C_TRACK[constrain(slots[n].b[3], 1, 8) - 1]; return lastPad[sel] == s && sel < TRACKS && k.sampleNo == n ? pc : Scale(pc, 70); } if (k.loopMode == 1) { // v19: same look as the web app: loop pads in their sample colour, running loop bright, pending pulses. if (y != 0) return C_OFF; const uint8_t n = k.loopNo[x]; const RGB lc = n < 127 ? C_TRACK[constrain(slots[n].b[3], 1, 8) - 1] : col; if (loopPend[sel] == 15 && loopAct[sel] == x) { pulse = true; return C_OFFRED; } if (loopPend[sel] == x) { pulse = true; return C_ACTIVE; } if (loopAct[sel] == x) { pulse = true; return lc; } return n < 127 ? Scale(lc, 90) : C_OFF; } if ((heldMask >> (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; } 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 == 1) { // v19: selected channel always lit, so you can see which channel the pads show. if (loopPend[i] >= 0) { pulse = true; return C_TRACK[i]; } if (i == sel || ((loopRun >> i) & 1)) return C_TRACK[i]; return C_DIM; } return i == sel ? C_TRACK[i] : C_DIM; } 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) {} 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) {} 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); } // v25: card access of the app must never interleave with the players' buffer reloads. struct SdLock { SdLock() { newdigate::AudioEventResponder::disableResponse(); } ~SdLock() { newdigate::AudioEventResponder::enableResponse(); } }; bool wired = false; } // namespace lpseq_audio // v26: free room (USB-MIDI packets) in a host device's transmit buffers. The driver's // write spins forever when both are full, so every send checks this first. namespace lpseq_usb { template struct Rob { friend typename Tag::type get(Tag) { return M; } }; struct Tx1 { typedef volatile uint8_t MIDIDeviceBase::*type; friend type get(Tx1); }; struct Tx2 { typedef volatile uint8_t MIDIDeviceBase::*type; friend type get(Tx2); }; struct TxS { typedef uint16_t MIDIDeviceBase::*type; friend type get(TxS); }; struct TxP { typedef Pipe_t *MIDIDeviceBase::*type; friend type get(TxP); }; template struct Rob; template struct Rob; template struct Rob; template struct Rob; static inline int Room(MIDIDeviceBase &d) { if (!(d.*get(TxP()))) return 0; const int mx = (d.*get(TxS())) / 4; const int a = d.*get(Tx1()), b = d.*get(Tx2()); return (a < mx ? mx - a : 0) + (b < mx ? mx - b : 0); } } // namespace lpseq_usb namespace lpseq_audio { 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; } 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 || xfer) { sTrig = 0; return; } // v24: transfer owns the card // v22: player/filter/gain math every 5 ms instead of every main-loop pass; triggers still go out at once static uint32_t audioMs = 0; { const uint32_t nowA = millis(); if (!sTrig && nowA - audioMs < 8) return; audioMs = nowA; } noInterrupts(); const uint8_t trig = sTrig; sTrig = 0; interrupts(); const uint8_t run = loopRun; const uint8_t rs = resyncMask; resyncMask = 0; static uint32_t restartMs[TRACKS] = {0}; // v28: per-channel caches for the expensive math static uint8_t cutKey[TRACKS] = {255,255,255,255,255,255,255,255}; static float cutAlpha[TRACKS]; static uint32_t pitchKey[TRACKS] = {~0u,~0u,~0u,~0u,~0u,~0u,~0u,~0u}; static float pitchMul[TRACKS]; static uint32_t fsrKey[TRACKS] = {0}; static float fsrVal[TRACKS]; echo.delaySamples=min(32767U,(uint32_t)(44100.0f*45/bpm)); uint8_t defer = 0; bool opened = false; // v23: open at most one SD file per pass, never a burst of eight 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]) { if (opened) { defer |= 1<= 127) player[t].stop(); // v26: playWav stops the old file itself loadedNo[t] = k.sampleNo; corr[t] = 1; if (k.sampleNo < 127) { char path[12]; snprintf(path, sizeof(path), "/%03d.WAV", k.sampleNo); player[t].playWav(path); // v26: opens paused (one card read), started by retrigger() autoKey[t] = 0xFFFFFFFF; } if ((lastLoopRun >> t) & 1) lastLoopRun &= ~(1 << t); } if (k.sampleNo >= SLOTS) continue; // v28: a silent channel needs no parameter math until it is triggered or a loop starts if (!((trig>>t)&1) && !((run>>t)&1) && !((lastLoopRun>>t)&1) && !player[t].isPlaying()) 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(play_start_arbitrary,(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; if(cutKey[t]!=b[13]) { cutKey[t]=b[13]; const float cutoff=80*powf(250,b[13]/127.0f); cutAlpha[t]=b[13]>=127?1:1-expf(-6.2831853f*cutoff/44100); } fx[t].alpha = cutAlpha[t]; 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)); const bool isLoop=k.loopMode==1; // loop channel: every sample loops, tempo-synced const bool on=isLoop && ((run>>t)&1), was=(lastLoopRun>>t)&1; // v25: real file sample rate (48 kHz files ran 9 % too fast and drifted) const uint32_t lm=player[t].lengthMillis(); const uint32_t fk=total^(lm*2654435761u); if(fsrKey[t]!=fk) { fsrKey[t]=fk; float f=lm?total*1000.0f/lm:44100.0f; static const float R[6]={22050,32000,44100,48000,88200,96000}; float best=44100; for(int i=0;i<6;++i) if(fabsf(R[i]-f)1) { autoKey[t]=key; const float fb=player[t].getBPM(); const float raw=(fb>20?regionMs*fb/240000.0f:regionMs/barMs)*256.0f; uint32_t best=256; for(uint32_t c=64;c<=8192;c<<=1) if(fabsf(log2f(raw/c))>t)&1) && ((lastLoopRun>>t)&1) && player[t].isPlaying(); bool drift=false; // v26: phase lock -- once per pass compare the play position with the bar grid and // correct the speed for the next pass (max 3 %); only a big error restarts the loop. if(((phaseMask>>t)&1) && regionMs>20) { phaseMask&=~(1<0.08f) { drift=true; corr[t]=1; } else corr[t]=1-constrain(e,-0.03f,0.03f); } } else if(!locked) phaseMask&=~(1<>t)&1) || drift); if((on && (!was || sync || (!player[t].isPlaying() && nowP-restartMs[t]>=250))) || ((trig>>t)&1)) { if(opened && !((trig>>t)&1) && !sync) { defer |= 1<=max(120U,(uint32_t)Word(b,16)) && !opened) { // v26: >= 80 ms grainAt[t]=millis(); const uint32_t grain=max(1U,(uint32_t)(Word(b,16)*fsr/1000.0f)); 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].retrigger(); opened = true; } } lastLoopRun = run & ~defer; } // ===================================================================== USB host MIDI (main loop) 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}; if (lpseq_usb::Room(usbHostMIDI[i]) >= 4) usbHostMIDI[i].sendSysEx(sizeof(sx), sx, true); } ForceLedRefresh(); } for (int guard = 0; guard < 48 && usbHostMIDI[i].read(); ++guard) { 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); } } } 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 == 1) LoopPress(t, DefaultLoop(t)); else if (trk[t].loopMode == 2) PadHit(t, lastPad[t]); } } void AppLPSeq::UpdateLeds() { using namespace lpseq; const uint32_t now = millis(); if (now - last_led_ms < 40) 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 = 96 + ((tri * 4) / 501) * 48; // 4 steps 96..240: fewer LED resends const int lp = LaunchpadSlot(); if (lp >= 0) { MIDIDevice_BigBuffer &d = usbHostMIDI[lp]; // v26: 8 LEDs per message (14 USB packets), only while the USB buffer has room uint8_t buf[4 * 8]; int n = 0, fl = 0; bool full = false; auto flush = [&]() { if (!n) return; if (full || fl >= 4 || lpseq_usb::Room(d) < (8 + n + 2) / 3 + 1) { for (int j = 0; j < n; j += 4) ledValid[buf[j]] = false; // retried next frame full = true; n = 0; return; } uint8_t msg[8 + 4 * 8]; 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; ++fl; }; auto put = [&](uint8_t note, RGB c) { if (full) return; // stays invalid: sent next frame 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 * 8) 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; if (lpseq_usb::Room(d) < 2) break; // v26: next frame 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) && lpseq_usb::Room(usbHostMIDI[mm]) >= TRACKS + 1) { // v26: never wait 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) // 0D ch select channel (mirrors top button, no double-tap) // 0E n v24: real file name of NNN.WAV (UTF-8, max 23) // Reply: F0 7D 4C 50 7F F7 void AppLPSeq::PollWebApp() { for (int guard = 0; guard < 48 && usbMIDI.read(); ++guard) { 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); webTrkMs = millis(); } 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); webTrkMs = millis(); } 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 == 0x05 && avail >= 2) { PadHit(p[0] & 7, p[1] & 63); arg = p[0] & 7; } else if (cmd == 0x0A && avail >= 4) { { const int c = Nib(p); clockIn = c < 4 + ADC_CHANNEL_COUNT ? c : 2; } const int r = Nib(p + 2); resetIn = (r > 3 || r == clockIn) ? 255 : r; if (avail >= 6) clockPpqn = ValidPpqn(Nib(p + 4)); // v25 slotDirty = true; slotSaveMs = millis(); } else if (cmd == 0x0B && avail >= 2 + 16) { const int t = p[0] & 7, page = p[1] & 7; arg = t * 8 + page; for (int i = 0; i < 8; ++i) padNo[t][page * 8 + i] = min(127, Nib(p + 2 + i * 2)); slotDirty = true; slotSaveMs = millis(); } else if (cmd == 0x0C) { saveReq = true; } else if (cmd == 0x0F) { // Raw 7-bit panel packet, admitted only under the existing live-sync gate. if (n != 9 || d[8] != 0xF7 || p[0] > 31 || p[1] > 4 || p[2] > 127 || xfer || !lastRxMs || millis() - lastRxMs > 3000) return; if (!OC::ui.PanelEvent(p[0], p[1], int(p[2]) - 64)) return; } else if (cmd == 0x0E && avail >= 1 && p[0] < SLOTS) { arg = p[0]; char *dst = lpseq_names::nm[arg]; const int len = min(23, int((avail - 1) / 2)); memset(dst, 0, 24); for (int i = 0; i < len; ++i) dst[i] = char(Nib(p + 1 + i * 2)); lpseq_names::Touch(); } else if (cmd == 0x0D && avail >= 1) { SelectChannel(p[0] & 7); focus = F_NONE; arg = p[0] & 7; } 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 <= 0x3F) { 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 = min((int)h[22], 2); 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 != 1) 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; lpseq_audio::SdLock sdl; // v25 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 == 0x3F) { // format: only with the "FMT" guard bytes pause(); r[0] = (avail >= 3 && p[0] == 'F' && p[1] == 'M' && p[2] == 'T' && SD.format()) ? 0 : 1; settingsLoaded = true; slotDirty = true; slotSaveMs = millis(); // rewrite settings onto the fresh card lpseq_names::Clear(); lpseq_names::Touch(); resume(); 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; if (!r[0]) { memset(lpseq_names::nm[p[0] & 0x7F], 0, 24); lpseq_names::Touch(); } 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; if (!r[0] && (p[1] & 0x7F) < 127 && (p[0] & 0x7F) < 127) { memcpy(lpseq_names::nm[p[1] & 0x7F], lpseq_names::nm[p[0] & 0x7F], 24); memset(lpseq_names::nm[p[0] & 0x7F], 0, 24); lpseq_names::Touch(); } 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 && !(playing || loopRun || extRun) && now - asChangedMs > 4000u) { // v25: flash only when stopped asChangedMs = 0; OC::SaveAppData(); lastSaveMs = millis(); } } 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(); } // v29: main-loop-only telemetry, no changes to audio or sequencer ISR. void AppLPSeq::SendLevels() { static uint32_t sentAt = 0; const uint32_t now = millis(); // Same listener/transfer gate as 0x44; independent 33 ms cadence. if (xfer || !lastRxMs || now - lastRxMs > 3000 || !usb_configuration || now - sentAt < 33) return; sentAt = now; // drop rather than retry a full USB queue uint8_t m[25] = {0xF0,0x7D,0x4C,0x50,0x46}; auto level = [](int cv) -> uint8_t { // 1536 pitch units/V; round((V+5)*64/5), saturated. cv = constrain(cv, -7680, 7680); return uint8_t(constrain(((cv + 7680) * 64 + 3840) / 7680, 0, 127)); }; for (int i=0; i<8; ++i) { m[5+i] = i < DAC_CHANNEL_COUNT ? level(HS::frame.ViewOut((DAC_CHANNEL)i)) : 64; m[14+i] = i < ADC_CHANNEL_COUNT ? level(OC::ADC::value((ADC_CHANNEL)i)) : 64; } for (int i=0; i<4; ++i) if (HS::frame.gate_high[i]) m[13] |= 1u << i; // No calibrated, non-ISR stereo sample readback: explicitly unknown/zero. m[22]=m[23]=64; m[24]=0xF7; uint32_t packets[9]; for (int i=0; i<8; ++i) packets[i] = 4u | (uint32_t(m[i*3])<<8) | (uint32_t(m[i*3+1])<<16) | (uint32_t(m[i*3+2])<<24); packets[8] = 5u | (uint32_t(m[24])<<8); usb_midi_try_levels(packets); } // 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). 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; uint8_t devs = SDcard_Ready ? 8 : 0; // v18: what is plugged into the module for (int i = 0; i < 2; ++i) devs |= dev[i] == DEV_LAUNCHPAD ? 1 : dev[i] == DEV_MIDIMIX ? 2 : dev[i] == DEV_OTHER ? 4 : 0; const uint8_t hb[4] = {uint8_t((rxCount >> 7) & 0x7F), uint8_t(rxCount & 0x7F), FW_VERSION, devs}; SendMsg(0x45, hb, 4, nullptr, 0); } if (xfer || !lastRxMs || now - lastRxMs > 3000 || now - syncMs < 60) 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); if (now - webTrkMs < 1500) return; // v24: web app is sending: echo would undo its edits 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() { static uint32_t lastDumpMs = 0; if (lastDumpMs && millis() - lastDumpMs < 600) return; lastDumpMs = millis(); 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 (extRun) { gfxPrint(48, 1, clockIn < 4 ? "T" : "C"); gfxPrint(clockIn < 4 ? clockIn + 1 : clockIn - 3); } 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 && k.loopMode != 2) continue; if (k.loopMode == 2) { if (padNo[sel][s] >= 127) gfxPixel(x + 2, y + 2); else if (lastPad[sel] == s) gfxRect(x, y, 5, 5); else gfxFrame(x, y, 5, 5); 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 == 2 ? "PADS" : 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); } void AppLPSeq::Loop() { // v25: a flash save stops every interrupt (audio, clock, USB): only while nothing runs if (saveReq && !(playing || loopRun || extRun)) { saveReq = false; OC::SaveAppData(); if (SDcard_Ready && !xfer) { lpseq_audio::SdLock l; SaveSlots(); SaveNames(); } lastSaveMs = millis(); } ServiceSlots(); PollWebApp(); CheckGesture(); ProcessLoopQuant(); SendLevels(); 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 { for (int i = 0; i < 4; ++i) ioconfig.digital_inputs[i].set(i == clockIn ? "Clock" : i == resetIn ? "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) SetNextPage(); 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 == 1) LoopPress(sel, DefaultLoop(sel)); break; default: break; } } FLASHMEM void AppLPSeq::HandleEncoderEvent(const UI::Event &event) { if (settings) { if (event.control == OC::CONTROL_ENCODER_L) SetMove(event.value); 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); } // Sample player: plays the pad's sample on channel t and fires a 10 ms trigger on its output. FLASHMEM void AppLPSeq::PadHit(int t, int pad) { t &= 7; pad &= 63; const uint8_t sn = padNo[t][pad]; lastPad[t] = pad; if (sn >= 127 || trk[t].muted) return; trk[t].sampleNo = sn; const int v = 8 * 1786 * trk[t].volume / 127; noInterrupts(); sVal[t] = v; sTrig |= (1 << t); gateLeft[t] = OC_CORE_ISR_FREQ / 100; gateVal[t] = v; interrupts(); } 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; memset(padNo,127,sizeof(padNo)); clockIn=2; resetIn=1; clockPpqn=4; saveReq=false; setHash=0; for(int t=0;t<8;++t) {lastPad[t]=0;padHash[t]=0;} 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;n3||r==clockIn)?255:r; if(sz==expected+1) clockPpqn=ValidPpqn(f.read()); for(int n=0;n127) padNo[t][i]=127; } f.close(); return; } f=SD.open("/LPSEQ16.DAT",FILE_READ); if(!f) return; const int expected=4+SLOTS*SLOT_BYTES+3+24; if(f.size()==expected && f.read()=='L' && f.read()=='P' && f.read()=='S' && f.read()==16) { if(f.read((uint8_t*)slots,sizeof(slots))==(int)sizeof(slots)) { f.read(playerMap,3);f.read(cvInput,8);f.read(cvTarget,8);f.read((uint8_t*)cvAmount,8); for(int n=0;n=4000) { lpseq_audio::SdLock l; SaveSlots(); } if(idle && lpseq_names::dirty && millis()-lpseq_names::dirtyMs>=4000) { lpseq_audio::SdLock l; SaveNames(); } if(millis()-slotCheckMs<80 || !lastRxMs || millis()-lastRxMs>3000) return; slotCheckMs=millis(); if(millis()-webSlotMs<1500) return; // v24: no echo while the web app writes { const uint32_t sh=(uint32_t(clockPpqn)<<20)|(uint32_t(clockIn)<<8)|resetIn|0x10000; if(sh!=setHash) {setHash=sh;const uint8_t st[3]={clockIn,resetIn,clockPpqn};SendMsg(0x4A,nullptr,0,st,3);return;} for(int t=0;t<8;++t) { uint32_t h=2166136261u;for(int i=0;i<64;++i) h=(h^padNo[t][i])*16777619u; if(h!=padHash[t]) {padHash[t]=h; for(int page=0;page<8;++page) {uint8_t a2[2]={uint8_t(t),uint8_t(page)};SendMsg(0x4B,a2,2,&padNo[t][page*8],8);} return;} } } 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=constrain(k.loopMode+d,0,2);if(k.loopMode!=1) StopLoopNow(sel);} else if(settingRow==24) k.muted=d>0; else if(settingRow==25) {clockIn=constrain(clockIn+d,0,3+ADC_CHANNEL_COUNT);if(resetIn==clockIn) resetIn=255;} else if(settingRow==26) {int v=resetIn==255?0:resetIn+1;v=constrain(v+d,0,4);resetIn=v?v-1:255;if(resetIn==clockIn) resetIn=255;} else if(settingRow==27) {static const uint8_t PQ[7]={1,2,4,8,12,24,48};int i=0;while(i<6&&PQ[i]!=clockPpqn) ++i;clockPpqn=PQ[constrain(i+d,0,6)];} slotDirty=true;slotSaveMs=millis(); ForceLedRefresh(); } // v28: settings as five short pages; rows that do not apply are hidden. static const uint8_t kSetOrder[28]={0,23,14,15,16,24, 1,2,3,4,5,6,7, 8,9,10,11,12,13, 17,18,19,20,21,22, 25,26,27}; static const uint8_t kSetPage[28]={0,1,1,1,1,1,1,1,2,2,2,2,2,2,0,0,0,3,3,3,3,3,3,0,0,4,4,4}; // indexed by row id static const char *const kPageName[5]={"KANAL","SAMPLE","KLANG","CV/MIX","CLOCK"}; FLASHMEM int AppLPSeq::SetRows(uint8_t *out) const { const bool hasB=EditSlotNo()=3 && r<=13 && !hasB) continue; out[n++]=r; } return n; } FLASHMEM void AppLPSeq::SetMove(int d) { uint8_t rows[28]; const int n=SetRows(rows); int at=0; while(at=n) at=0; settingRow=rows[constrain(at+d,0,n-1)]; } FLASHMEM void AppLPSeq::SetNextPage() { uint8_t rows[28]; const int n=SetRows(rows); const int pg=(kSetPage[settingRow]+1)%5; for(int p=0;p<5;++p) for(int i=0;i=3 && row<=13 && !b) gfxPrint("--"); else if(row==3) gfxPrint(b[1]==2?"Loop":b[1]==1?"Sample":"1-Shot"); else if(row==4 || row==5) {gfxPrint(Word(b,row==4?4:6)*100/65535);gfxPrint("%");} else if(row==6) {const int q=Word(b,8);if(!q) gfxPrint("Auto");else {char text[10];snprintf(text,sizeof(text),"%d.%02d",q/256,(q%256)*100/256);gfxPrint(text);}} else if(row==7) gfxPrint(b[3]); else if(row==8) gfxPrint((int)b[10]-48); else if(row==9) {gfxPrint(b[2]&1?"<":">");gfxPrint(Word(b,11)*100/256);gfxPrint("%");} else if(row>=10 && row<=12) {gfxPrint(b[row+3]*100/127);gfxPrint("%");} else if(row==13) gfxPrint(Word(b,16)); else if(row==14) {static const char*o[]={"Stereo","L","R","Summe"};gfxPrint(o[trk[sel].outMode&3]);} else if(row==15) {gfxPrint(trk[sel].volume*100/127);gfxPrint("%");} else if(row==16) gfxPrint((int)trk[sel].pan-64); else if(row>=17 && row<=19) gfxPrint(target[playerMap[row-17]&7]); else if(row==20) {if(cvInput[sel]==255) gfxPrint("Aus");else {gfxPrint("CV");gfxPrint(cvInput[sel]+1);}} else if(row==21) gfxPrint(target[cvTarget[sel]&7]); else if(row==22) gfxPrint(cvAmount[sel]); else if(row==23) gfxPrint(trk[sel].loopMode==2?"Pads":trk[sel].loopMode?"Loop":"Trigger"); else if(row==24) gfxPrint(trk[sel].muted?"An":"Aus"); else if(row==25) {if(clockIn<4){gfxPrint("TR");gfxPrint(clockIn+1);}else{gfxPrint("CV");gfxPrint(clockIn-3);}} else if(row==26) {if(resetIn==255) gfxPrint("Aus");else {gfxPrint("TR");gfxPrint(resetIn+1);}} else if(row==27) gfxPrint(clockPpqn); } FLASHMEM void AppLPSeq::ViewSettings() const { static const char *const label[28]={"Kanal","Loop/Pad","WAV","Rolle","Start","Ende","Takte","Farbe","Pitch","Richtung","Filter","Delay","Slice","Slice ms","Ausgang","Lautst.","Pan","Poti oben","Poti mitte","Poti unten","CV-Eingang","CV-Ziel","CV-Tiefe","Modus","Mute","Clock","Reset","PPQN"}; uint8_t rows[28]; const int n=SetRows(rows); int at=0; while(at=n) at=0; const int pg=kSetPage[rows[at]]; gfxHeader(kPageName[pg]); gfxPrint(70,1,"CH"); gfxPrint(sel+1); if(slotDirty) gfxPrint(104,1,"..."); else if(lastSaveMs && millis()-lastSaveMs<2000) gfxPrint(104,1,"OK"); // rows of this page only, scrolled so the cursor stays visible (4 lines) int first=at; while(first>0 && kSetPage[rows[first-1]]==pg) --first; int last=at; while(last+1":" "); gfxPrint(8,y,label[r]); gfxPrint(76,y,""); SetVal(r); if(top+line==at) gfxInvert(0,y-1,128,9); } gfxPrint(0,55,"L:Seite R:");gfxPrint(at+1);gfxPrint("/");gfxPrint(n); } #endif // ARDUINO_TEENSY41