Scrub and Shuttle using MIDI Remote

Today I accidentally saw a post about the jog wheel and then I recalled older threads about the scrub and shuttle functionalities.

As far as I remember, both scrubbing and shuttling are not behaving perfectly when implemented using commands assignments in the MIDI Remote, i.e. the commands Transport→Jog Left/Right and Transport→Shuttle Play (various speeds).

I understand that these commands have been optimised to work properly with keyboard shortcuts, by taking advantage of the keyDown event of the keys. This unfortunately cannot happen with MIDI Remote, and one can get a bit close by using these commands with the makeRepeating() method, still there are hiccups.

I’ve overcome these limitations, by using the Direct Access implementation, in order to directly control the jog and shuttle host values involved. This way, interruptions and some unpredictable direction changes are avoided.

For anyone interested, here’s a midi remote installation file:

Test_Scrub and Shuttle.midiremote (7.6 KB)

I’m using loopMIDI as an output port, but you can obviously alter this to your liking.

In the script’s folder after the installation, you can find an HTML MIDI Remote Surface used to demonstrate/test the script. You will need a Chromium based browser, since it needs the Web MIDI component.

And here’s a short video demonstrating the implementation (be careful cause it contains audio and annoying handles, so mute or have volume low):

In addition to the MIDI Remote API, that also looks like some wicked HTML/CSS/JS in your testing/demo browser front-end!
:cow: :bell:

Sure thing! I strongly recommend such approaches, even for commercial releases. Having CSS, even a not that comfortable with coding user, can alter the UI colors/fonts, basically anything in a layout, with a bit of help from either forums with HTML devs or some AI assistance, while one can always prepare “Settings” tabs in such environments.

Really glad about the cow bells :smiley:

I wholeheartedly agree. And since there’s sooooo much HTML/CSS/JS code in the wild, the AI has a much higher success rate, than in less common technologies. And (as you’re doing) coding it in such a way, that you can also run it locally is also very polite way of making things available to others.

A bit off-topic, but I’ve taken a similar HTML/CSS/JS approach a while ago, when developing/sharing my little/simple helper application for editing NI KK Mk3 templates

p.s. I’ve never yet let the AI code anything, but I’ve found it useful as a natural language search engine for finding correct commands and syntax.

Ah, great!!! Unfortunately I don’t have an MK3 to give this a go! Off-topic too, but explains why I don’t have it: I guess I could just buy one, but being a romantic soul I thought that perhaps NI might have thought about sharing one with the maker of a deep DAW integration script for the MK2, just in case he could perhaps extend their very own for the MK3. Anyway, no hard feelings at all :smiley:

So it not even midi remote. But it is nice to see that someone is trying to get to work again.

? It’s Mido remote.

Maybe should had look before asking. The CSS stuff is only for chromium?
But the code:
jogActive: {tag: 4041,value: -1},
jog: {tag: 4044,value: -1},
shuttleBW8: {tag: 2007,value: 0,ccValue:0},
shuttleBW4: {tag: 2006,value: 0,ccValue:9},
shuttleBW2: {tag: 2005,value: 0,ccValue:18},
shuttleBW1: {tag: 2004,value: 0,ccValue:27},
shuttleBW1d2: {tag: 2011,value: 0,ccValue:36},
shuttleBW1d4: {tag: 2012,value: 0,ccValue:45},
shuttleBW1d8: {tag: 2013,value: 0,ccValue:54},
shuttleStop: {tag: -1,value: 0,ccValue:64},
shuttleFW1d8: {tag: 2010,value: 0,ccValue:73},
shuttleFW1d4: {tag: 2009,value: 0,ccValue:82},
shuttleFW1d2: {tag: 2008,value: 0,ccValue:91},
shuttleFW1: {tag: 2000,value: 0,ccValue:100},
shuttleFW2: {tag: 2001,value: 0,ccValue:109},
shuttleFW4: {tag: 2002,value: 0,ccValue:118},
shuttleFW8: {tag: 2003,value: 0,ccValue:127}
Where is the tag values coming from and what do they do?

Yes. Only Chromium based browsers can have WEB MIDI (Chrome and Edge). But this is just for the MIDI Remote Surface UI simulating a hardware controller, nothing more than that. Parts of the MIDI Remote script provided can be nearly copy-pasted to another working script for a hardware controller.

These are parameters tags of the transport object. We can use a script to log all such parameters and then use in our scripts the ones we want. As a side note, I can always help with providing such parameter tags, since I’ve logged plenty of them.

In a project, we have a tree of objects which may contain other objects (children) and each object contains parameters.
Using MIDI Remote we can call this: directAccessObject.setParameterProcessValue(activeMapping,objectID,paramTag,paramValue) in order to set the value of a parameter.

Let’s take the “shuttleBW8” as an example:

“shuttleBW8” is just a description I gave to the “shuttle backwards 8 x” parameter and its tag is 2007. Please, don’t confuse it with the similar shuttle command. What the command actually does is to alter the value of this particular parameter, while with the method mentioned, we are doing the same at the low level, i.e. directly editing the parameter. This way, we care not about how this command is implemented, whether it’s optimised for keyboard shortcuts, etc. So, in this example, one can write directAccessObject.setParameterProcessValue(activeMapping,transportObjectID,2007,1) when wanting to activate the shuttle 8 x. We can set it to 0 when we want to disable it.

The directAccessObject can be created using page.mHostAccess.makeDirectAccess(hostObject)
where hostObject is the object we want to control at a lower level. In the snippet provided, the hostObject is page.mHostAccess.mTransport.

You can find more info for the direct access here:

What I can see is that using this object, missing parts of the MIDI Remote, which can be found in the Generic remote, are now approachable.
You may want to have a look at another broader example here:

I’m taking advantage of newer features of the MIDI Remote for choosing plugins for the instrument and inserts slots. Also, I implement Record modes, and various other things not previously doable.

This magic numbers for tags, will they be persistent over cubase versions?
Can you see what each functions input/output parameters too?

I cannot obviously see in the future,but I’m pretty sure it would be suicidal to alter them. Till now, I can talk about CB13-15. Yes, they are consistent.

Sorry,could you please rephraseor give more info? I don’t quite understand.

The functions typically have parameters as argument. A note-on as key and velocity. Other internal functions might have other arguments. A window might have x-y coordinate etc. And functions also can return a simple value: on/off/error/colour or as i C/C++ a pointer to data.

Yeah, but to which functions are you referring?
In DA,we have setParameterProcessValue, setParameterDisplayValue and for getting, getParameterProcessValue, getParameterDisplayValue, these are the basic methods.
And then we have events (you can call them functions but it’s not the same) such as
mOnObjectChange with which we grab the ID of an object when it changes,
mOnParameterChange, when the controlled parameter of an object changes.
In the case of parameters, their process value is (almost) always in the normalised [0,1] area.

The thing that “spy” on the existing parameters!
So only one simple parameter each? Not a pair like node,velocity?

Let me provide an example.

Say, we have a channel. This channel has an ID belongs to the mixConsole “tree”. Let’s say that it has an ID: 10.
Now, say, we want to alter its volume. Volume has the tag 1025. And we have to provide its normalized value from 0 up to 1.
We can write DirectAccessObject.setParameterProcessValue(activeMapping,10,1025,whateverVolume)
The arguments of this method is the activeMapping (an abstract for the currently selected page of the MIDI Remote, nothing to really worry about), the Object ID, the Parameter Tag, and the new Process Value of the parameter.

Ok. No complex values then. Make sense with the rest of the api. But can you get any useful error back from functions?

Not sure what error should these functions report back. If for example you have a non-existent objectID or paramTag, or a value not in the range, the method will just get ignored as far as I know. What kind of error would you expect or worry about?

Hi @m.c this looks amazing, would it be at all possible that you could post just the HTML part of this please? I would really like to see how it works without installing the script.

(nb still way overdue for a proper chat about all this) :vulcan_salute:

Hey, sure thing. Note that it’s just a remote surface sending midi messages.

<!doctype html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Cubase Jog/Scrub and Shuttle Example</title>
<style>
  :root{
    color-scheme:dark;
    --bg:#0f1217;
    --panel:#191e26;
    --panel2:#242b35;
    --text:#f4f6f8;
    --muted:#9ca7b5;
    --accent:#62d6ff;
    --active:#64d89b;
    --border:#35404d;
    --danger:#ff7474;
  }
  *{box-sizing:border-box}
  body{
    margin:0;
    min-height:100vh;
    font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
    background:radial-gradient(circle at top,#202938,var(--bg) 60%);
    color:var(--text);
    padding:18px;
  }
  .app{
    width:min(1050px,100%);
    margin:auto;
    background:rgba(25,30,38,.97);
    border:1px solid var(--border);
    border-radius:22px;
    padding:20px;
  }
  h1{margin:0 0 4px;font-size:clamp(22px,3vw,31px)}
  .sub{color:var(--muted);margin-bottom:18px}
  .midi-row{
    display:grid;
    grid-template-columns:1fr auto;
    gap:10px;
    margin-bottom:18px;
  }
  select,button{font:inherit}
  select{
    width:100%;
    background:var(--panel2);
    color:var(--text);
    border:1px solid var(--border);
    border-radius:12px;
    padding:12px;
  }
  button{
    border:1px solid var(--border);
    background:var(--panel2);
    color:var(--text);
    border-radius:12px;
    padding:11px 15px;
    font-weight:700;
    cursor:pointer;
    user-select:none;
    -webkit-user-select:none;
  }
  button.active{
    background:var(--active);
    color:#07130d;
    border-color:transparent;
  }
  .layout{
    display:grid;
    grid-template-columns:1fr 1fr;
    gap:18px;
  }
  .card{
    background:rgba(12,15,19,.5);
    border:1px solid var(--border);
    border-radius:18px;
    padding:18px;
  }
  .card h2{
    margin:0 0 12px;
    font-size:18px;
  }
  .wheel-wrap,.shuttle-wrap{
    min-height:350px;
    display:grid;
    place-items:center;
    position:relative;
  }
  .wheel{
    width:min(285px,74vw);
    aspect-ratio:1;
    border-radius:50%;
    position:relative;
    background:
      repeating-conic-gradient(from 0deg,#505b69 0 2deg,#20252d 2deg 8deg),
      radial-gradient(circle at 40% 35%,#626e7e,#252b34 58%,#0c0e12 72%);
    border:8px solid #11151b;
    box-shadow:inset 0 0 0 3px #6a7686,inset 0 0 38px rgba(0,0,0,.8);
    cursor:grab;
    touch-action:none;
  }
  .wheel::after{
    content:"";
    position:absolute;
    width:8px;height:38%;
    left:calc(50% - 4px);top:7%;
    border-radius:5px;
    background:var(--accent);
    transform-origin:50% 113%;
    transform:rotate(var(--rotation,0deg));
  }
  .wheel-center,.shuttle-center{
    position:absolute;
    inset:29%;
    border-radius:50%;
    background:radial-gradient(circle at 38% 32%,#48515e,#15191f 72%);
    border:3px solid #5f6b79;
    box-shadow:inset 0 0 22px #000;
    display:grid;
    place-items:center;
    text-align:center;
    font-weight:800;
    pointer-events:none;
  }
  .buttons{
    display:grid;
    grid-template-columns:1fr 1fr;
    gap:10px;
    margin-top:12px;
  }
  .hint,.protocol{
    color:var(--muted);
    font-size:13px;
    line-height:1.45;
  }

  .shuttle-stage{
    width:min(360px,86vw);
    aspect-ratio:1;
    position:relative;
  }
  .shuttle-knob{
    position:absolute;
    inset:24%;
    border-radius:50%;
    background:
      radial-gradient(circle at 40% 35%,#687483,#232932 58%,#0d1014 78%);
    border:7px solid #10141a;
    box-shadow:inset 0 0 0 3px #687586,inset 0 0 34px #000;
    cursor:ns-resize;
    touch-action:none;
    transform:rotate(var(--angle,0deg));
  }
  .shuttle-knob::after{
    content:"";
    position:absolute;
    width:8px;height:38%;
    left:calc(50% - 4px);top:8%;
    border-radius:6px;
    background:var(--active);
    box-shadow:0 0 12px rgba(100,216,155,.55);
  }
  .tick{
    position:absolute;
    left:50%;top:50%;
    width:1px;height:1px;
    transform:rotate(var(--a)) translateY(-156px);
    transform-origin:0 0;
  }
  .tick span{
    display:block;
    min-width:46px;
    text-align:center;
    transform:translate(-50%,-50%) rotate(calc(-1 * var(--a)));
    color:var(--muted);
    font-size:12px;
    font-weight:700;
  }
  .tick.active span{
    color:var(--active);
    font-size:13px;
  }
  .shuttle-readout{
    position:absolute;
    inset:39%;
    border-radius:50%;
    display:grid;
    place-items:center;
    text-align:center;
    pointer-events:none;
    font-weight:800;
    color:var(--text);
  }
  .shuttle-readout small{
    display:block;
    color:var(--muted);
    font-weight:600;
    margin-top:3px;
  }
  .status{
    margin-top:16px;
    background:#0b0e12;
    border:1px solid var(--border);
    border-radius:12px;
    padding:11px 13px;
    min-height:44px;
    color:var(--muted);
    font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
    font-size:13px;
    overflow-wrap:anywhere;
  }
  .ok{color:var(--active)}
  .bad{color:var(--danger)}
  @media(max-width:780px){
    .layout{grid-template-columns:1fr}
  }
</style>
</head>
<body>
<main class="app">
  <h3>Cubase Jog/Scrub and Shuttle</h3>
  <div class="sub">Example of using MIDI Remote Direct Access</div>

  <div class="midi-row">
    <select id="midiOut">
      <option value="">Click MIDI to connect</option>
    </select>
    <button id="connect">MIDI</button>
  </div>

  <section class="layout">
    <div class="card">
      <h2>Jog / Scrub</h2>
      <div class="wheel-wrap">
        <div id="jogWheel" class="wheel">
          <div class="wheel-center">JOG<br><small style="font-size:11px;color:var(--muted)">rotate</small></div>
        </div>
      </div>
      <div class="buttons" style="grid-template-columns:1fr;">
        <button id="scrubButton">SCRUB: OFF</button>
      </div>
      <p class="protocol">
        Jog: MCU CC 60 relative<br>
        Scrub: Note 101, ON=127 / OFF=0<br>
        Spring Return: CC 62, ON=127 / OFF=0
      </p>
    </div>

    <div class="card">
      <h2>Shuttle</h2>
      <div class="shuttle-wrap">
        <div id="shuttleStage" class="shuttle-stage">
          <div id="ticks"></div>
          <div id="shuttleKnob" class="shuttle-knob"></div>
          <div class="shuttle-readout">
            <div>
              <span id="speedLabel">STOP</span>
              <small id="ccLabel">CC 61 = 64</small>
            </div>
          </div>
        </div>
      </div>
      
      <button id="springReturnButton" class="active" style="width:100%;margin-top:10px;">
        SPRING RETURN: ON · CC 62
      </button>
    </div>
  </section>

  <div id="status" class="status">No MIDI output selected.</div>
</main>

<script>
(() => {
  "use strict";

  const MIDI_CHANNEL = 0;
  const JOG_CC = 60;
  const SCRUB_NOTE = 101;
  const SHUTTLE_CC = 61;
  const SPRING_RETURN_CC = 62;

  const shuttlePositions = [
    { label: "◀ 8×",   cc: 0   },
    { label: "◀ 4×",   cc: 9   },
    { label: "◀ 2×",   cc: 18  },
    { label: "◀ 1×",   cc: 27  },
    { label: "◀ 1/2×", cc: 36  },
    { label: "◀ 1/4×", cc: 45  },
    { label: "◀ 1/8×", cc: 54  },
    { label: "STOP",   cc: 64  },
    { label: "1/8× ▶", cc: 73  },
    { label: "1/4× ▶", cc: 82  },
    { label: "1/2× ▶", cc: 91  },
    { label: "1× ▶",   cc: 100 },
    { label: "2× ▶",   cc: 109 },
    { label: "4× ▶",   cc: 118 },
    { label: "8× ▶",   cc: 127 }
  ];

  const MIN_ANGLE = -140;
  const MAX_ANGLE = 140;
  const STEP_ANGLE = (MAX_ANGLE - MIN_ANGLE) / (shuttlePositions.length - 1);
  const CENTER_INDEX = 7;

  const el = {
    connect: document.getElementById("connect"),
    midiOut: document.getElementById("midiOut"),
    jogWheel: document.getElementById("jogWheel"),
    scrubButton: document.getElementById("scrubButton"),
    shuttleStage: document.getElementById("shuttleStage"),
    shuttleKnob: document.getElementById("shuttleKnob"),
    ticks: document.getElementById("ticks"),
    speedLabel: document.getElementById("speedLabel"),
    ccLabel: document.getElementById("ccLabel"),
    springReturnButton: document.getElementById("springReturnButton"),
    status: document.getElementById("status")
  };

  let midiAccess = null;
  let output = null;
  let jogDragging = false;
  let jogPreviousAngle = 0;
  let jogAngleRemainder = 0;
  let jogVisual = 0;
  let scrubOn = false;

  let shuttleDragging = false;
  let shuttleIndex = CENTER_INDEX;
  let shuttleLastY = 0;
  let shuttlePixelRemainder = 0;
  let springReturnEnabled = true;

  function setStatus(text, cls = "") {
    el.status.textContent = text;
    el.status.className = "status" + (cls ? " " + cls : "");
  }

  function send(data, text) {
    if (!output) {
      setStatus("No MIDI Output selected", "bad");
      return;
    }
    output.send(data);
    setStatus(text + " [" + data.join(", ") + "]", "ok");
  }

  async function connectMIDI() {
    if (!navigator.requestMIDIAccess) {
      setStatus("Your Browser doesn't seem to support Web MIDI. Please use Chrome or Edge.", "bad");
      return;
    }
    try {
      midiAccess = await navigator.requestMIDIAccess();
      refreshOutputs();
      midiAccess.onstatechange = refreshOutputs;
      setStatus("Web MIDI active. Please choose Output.", "ok");
    } catch (err) {
      setStatus("MIDI access error: " + err.message, "bad");
    }
  }

  function refreshOutputs() {
    const old = el.midiOut.value;
    el.midiOut.innerHTML = '<option value="">— MIDI output —</option>';
    if (!midiAccess) return;

    for (const out of midiAccess.outputs.values()) {
      const option = document.createElement("option");
      option.value = out.id;
      option.textContent = out.name || out.id;
      el.midiOut.appendChild(option);
    }
    if ([...el.midiOut.options].some(o => o.value === old)) el.midiOut.value = old;
    chooseOutput();
  }

  function chooseOutput() {
    output = midiAccess && el.midiOut.value
      ? midiAccess.outputs.get(el.midiOut.value)
      : null;
    setStatus(output ? "Output: " + output.name : "No MIDI Output selected", output ? "ok" : "");
  }

  function pointerAngle(target, event) {
    const r = target.getBoundingClientRect();
    const x = event.clientX - (r.left + r.width / 2);
    const y = event.clientY - (r.top + r.height / 2);
    return Math.atan2(y, x) * 180 / Math.PI + 90;
  }

  function normalizeDelta(delta) {
    if (delta > 180) delta -= 360;
    if (delta < -180) delta += 360;
    return delta;
  }

  function sendJog(direction, amount) {
    amount = Math.max(1, Math.min(63, amount));
    const value = direction > 0 ? amount : 64 + amount;
    send([0xB0 | MIDI_CHANNEL, JOG_CC, value],
      "MCU Jog " + (direction > 0 ? "→" : "←"));
  }

  function setScrub(enabled) {
    if (scrubOn === enabled) return;
    scrubOn = enabled;

    const value = enabled ? 127 : 0;
    send(
      [0x90 | MIDI_CHANNEL, SCRUB_NOTE, value],
      "SCRUB " + (enabled ? "ON" : "OFF")
    );

    el.scrubButton.classList.toggle("active", enabled);
    el.scrubButton.textContent = "SCRUB: " + (enabled ? "ON" : "OFF");
  }

  function jogPointerAngle(event) {
    const rect = el.jogWheel.getBoundingClientRect();
    const x = event.clientX - (rect.left + rect.width / 2);
    const y = event.clientY - (rect.top + rect.height / 2);
    return Math.atan2(y, x) * 180 / Math.PI;
  }

  function normalizedAngleDelta(current, previous) {
    let delta = current - previous;
    if (delta > 180) delta -= 360;
    if (delta < -180) delta += 360;
    return delta;
  }

  function pointerDistanceFromJogCenter(event) {
    const rect = el.jogWheel.getBoundingClientRect();
    const x = event.clientX - (rect.left + rect.width / 2);
    const y = event.clientY - (rect.top + rect.height / 2);
    return Math.sqrt(x * x + y * y);
  }

  el.jogWheel.addEventListener("pointerdown", e => {
    const radius = pointerDistanceFromJogCenter(e);
    const minimumRadius = el.jogWheel.getBoundingClientRect().width * 0.18;
    if (radius < minimumRadius) return;

    jogDragging = true;
    jogPreviousAngle = jogPointerAngle(e);
    jogAngleRemainder = 0;
    el.jogWheel.setPointerCapture(e.pointerId);
    e.preventDefault();
  });

  el.jogWheel.addEventListener("pointermove", e => {
    if (!jogDragging) return;

    const radius = pointerDistanceFromJogCenter(e);
    const minimumRadius = el.jogWheel.getBoundingClientRect().width * 0.18;
    if (radius < minimumRadius) return;

    const currentAngle = jogPointerAngle(e);
    const delta = normalizedAngleDelta(currentAngle, jogPreviousAngle);
    jogPreviousAngle = currentAngle;

    if (Math.abs(delta) > 90) return;

    jogAngleRemainder += delta;

    const DEGREES_PER_STEP = 3;
    const steps = Math.trunc(jogAngleRemainder / DEGREES_PER_STEP);
    if (steps === 0) return;

    jogAngleRemainder -= steps * DEGREES_PER_STEP;

    const direction = steps > 0 ? 1 : -1;
    let remaining = Math.abs(steps);

    while (remaining > 0) {
      const chunk = Math.min(63, remaining);
      jogVisual += direction * chunk * DEGREES_PER_STEP;
      el.jogWheel.style.setProperty("--rotation", jogVisual + "deg");
      sendJog(direction, chunk);
      remaining -= chunk;
    }
  });

  function endJog(e) {
    if (!jogDragging) return;
    jogDragging = false;
    jogAngleRemainder = 0;

    if (el.jogWheel.hasPointerCapture(e.pointerId)) {
      el.jogWheel.releasePointerCapture(e.pointerId);
    }
  }

  el.jogWheel.addEventListener("pointerup", endJog);
  el.jogWheel.addEventListener("pointercancel", endJog);
  el.jogWheel.addEventListener("lostpointercapture", () => {
    jogDragging = false;
    jogAngleRemainder = 0;
  });

  el.jogWheel.addEventListener("wheel", e => {
    e.preventDefault();
    const direction = e.deltaY < 0 ? 1 : -1;
    jogVisual += direction * 3;
    el.jogWheel.style.setProperty("--rotation", jogVisual + "deg");
    sendJog(direction, 1);
  }, { passive: false });

  el.scrubButton.addEventListener("click", () => setScrub(!scrubOn));

  function indexToAngle(index) {
    return MIN_ANGLE + index * STEP_ANGLE;
  }

  function renderTicks() {
    el.ticks.innerHTML = "";
    shuttlePositions.forEach((p, i) => {
      const tick = document.createElement("div");
      tick.className = "tick";
      tick.style.setProperty("--a", indexToAngle(i) + "deg");
      const span = document.createElement("span");
      span.textContent = p.label;
      tick.appendChild(span);
      el.ticks.appendChild(tick);
    });
  }

  function setShuttle(index, sendMidi = true) {
    shuttleIndex = Math.max(0, Math.min(shuttlePositions.length - 1, index));
    const p = shuttlePositions[shuttleIndex];
    const angle = indexToAngle(shuttleIndex);

    el.shuttleKnob.style.setProperty("--angle", angle + "deg");
    el.speedLabel.textContent = p.label;
    el.ccLabel.textContent = "CC " + SHUTTLE_CC + " = " + p.cc;

    [...el.ticks.children].forEach((tick, i) => {
      tick.classList.toggle("active", i === shuttleIndex);
    });

    if (sendMidi) {
      send([0xB0 | MIDI_CHANNEL, SHUTTLE_CC, p.cc],
        "Shuttle " + p.label);
    }
  }

  function angleToIndex(angle) {
    const clamped = Math.max(MIN_ANGLE, Math.min(MAX_ANGLE, angle));
    return Math.round((clamped - MIN_ANGLE) / STEP_ANGLE);
  }

  el.shuttleKnob.addEventListener("pointerdown", e => {
    shuttleDragging = true;
    shuttleLastY = e.clientY;
    shuttlePixelRemainder = 0;
    el.shuttleKnob.setPointerCapture(e.pointerId);
    e.preventDefault();
  });

  el.shuttleKnob.addEventListener("pointermove", e => {
    if (!shuttleDragging) return;

    shuttlePixelRemainder += shuttleLastY - e.clientY;
    shuttleLastY = e.clientY;

    const PIXELS_PER_POSITION = 10;
    const steps = Math.trunc(shuttlePixelRemainder / PIXELS_PER_POSITION);
    if (steps === 0) return;

    shuttlePixelRemainder -= steps * PIXELS_PER_POSITION;
    setShuttle(shuttleIndex + steps);
  });

  el.shuttleKnob.addEventListener("wheel", e => {
    e.preventDefault();
    setShuttle(shuttleIndex + (e.deltaY < 0 ? 1 : -1));
  }, { passive: false });

  function releaseShuttle(e) {
    if (!shuttleDragging) return;
    shuttleDragging = false;

    if (el.shuttleKnob.hasPointerCapture(e.pointerId)) {
      el.shuttleKnob.releasePointerCapture(e.pointerId);
    }

    if (springReturnEnabled) {
      setShuttle(CENTER_INDEX);
    }
  }

  el.shuttleKnob.addEventListener("pointerup", releaseShuttle);
  el.shuttleKnob.addEventListener("pointercancel", releaseShuttle);
  el.shuttleKnob.addEventListener("lostpointercapture", () => {
    if (!shuttleDragging) return;
    shuttleDragging = false;
    if (springReturnEnabled) {
      setShuttle(CENTER_INDEX);
    }
  });

  el.springReturnButton.addEventListener("click", () => {
    springReturnEnabled = !springReturnEnabled;
    el.springReturnButton.classList.toggle("active", springReturnEnabled);
    el.springReturnButton.textContent =
      "SPRING RETURN: " + (springReturnEnabled ? "ON" : "OFF") +
      " · CC " + SPRING_RETURN_CC;

    const ccValue = springReturnEnabled ? 127 : 0;
    send(
      [0xB0 | MIDI_CHANNEL, SPRING_RETURN_CC, ccValue],
      "Spring Return " + (springReturnEnabled ? "ON" : "OFF")
    );
  });

  el.connect.addEventListener("click", connectMIDI);
  el.midiOut.addEventListener("change", chooseOutput);

  renderTicks();
  setShuttle(CENTER_INDEX, false);
})();
</script>
</body>
</html>

Well done, this has been a ridiculuous implementation in Midi remote! You have made it usable again.

Its a shame Midi remote didnt have a good solution built in. Generic Remote was fine.

Thanks very much.

All the best