Audio | Part 9 β€” Adding Track Lock and Disable Features to the Music Production App

In this post, we'll introduce a feature πŸ› οΈ that allows the user to lock πŸ”’ tracks, as well as a feature that allows the user to enable βœ… or disable ❌ tracks. We'll wrap up the post πŸ“ by squashing a UI syncing bug πŸ› from our previous implementation.

Live Demo

Overview

In this blog post we’re going to expand on our previous entry by adding two new features:

  1. Disable track: The user will be able to click the lock icon to toggle between the enabled and disabled state of the corresponding track. Disabling an audio track is an essential part of mixing and editing music. This action allows producers to temporarily silence a specific track without deleting it, enabling them to focus on other elements of the mix or evaluate the impact of that track on the overall composition.

  2. Lock track: The user will be given the ability to lock or unlock a track by clicking the green enable LED icon on the far left of each track. When a track is locked, all the steps within that track are protected from being accidentally modified. This feature is particularly useful when a specific part of the composition, such as a vocal track or a drum beat, is finalized and the producer wants to ensure its integrity while editing other parts of the mix.

β™Ώ Β Disable Track Feature

We first need to add an enabled property to our Track class. While we're here, let's add a newlocked property that we'll use later.

class Track {
  ...
  enabled = true;
  locked = false; 
  ...
}

Let's next add a function named initEnable that will be immediately invoked in our initUI method.

  initUI() {
    ...
    const initEnable = () => {
      const enable_btn = this.elem.querySelector('.track-led-enable');
      enable_btn.addEventListener('click', () => {
        this.elem.classList.toggle('track-disabled');
        this.enabled = !this.enabled;

        // turn off .current styling on all steps
        if (!this.enabled)
          this.steps.forEach(step => step.classList.remove('current'));
      });
    };
    initEnable();
    ...
  }

Our initEnable function initializes the enable button for the track. The function selects the enable button element from the track's HTML element and adds a click event listener to it.

When the enable button is clicked, the function toggles the .track-disabled class on the track's HTML element using the classList.toggle method and updates the enabled property of the Track instance accordingly. If the track is disabled, the function removes the current class from all steps of the track.

In our playTracks function we check to see if the track is enabled before playing it.

const playTracks = (time, index) => {
  Tracks.forEach((track) => {

    if (!track.enabled) return;

    if (track.pattern[index]) 
      track.start(time);
  });
};

We repeat the same pattern in our highlighStep function. This causes our disabled track to not perform the playing animation to emphasise to the user this track is not active.

const highlightStep = (index) => {
  const prev_idx = index - 1;
  const is_prev_idx_pos = prev_idx >= 0;
  Tracks.forEach(track => {

    if (!track.enabled) return;

    track.steps[is_prev_idx_pos ? prev_idx : 15].classList.remove('current');
    track.steps[index].classList.add('current');
  });
};

Other than that we simply added some styling to make the steps have an opacity of 0.5 when the track is disabled. We of course change the green LED's gradient color to appear as if the LED is no longer lit to signify to the user that this track is disabled.

The styles are intuitive with nothing really to explain, so we'll omit any CSS description here. Please refer to the index.scss file if interested.

πŸ”’ Β Lock Track Feature

Let's add a lock icon that will be used as our lock button. We'll actually use two SVG's: one for when the track is locked and one for when the track is unlocked. When the track is in the locked state, we set display: none on the un-locked SVG and display: inline-block on the locked SVG, and vice versa for when the track is in the unlocked state.

<div class="track track-0">

  <div class="track-led-enable">...</div>

  <div class="track-knob-volume">...</div>

  <div class="track-lock">
    <svg class="track-lock-icon-unlocked" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
      <path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2zM3 8a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1H3z"/>
    </svg>

    <svg class="track-lock-icon-locked" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
      <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
    </svg>
  </div>

  <div class="track-title-container">...</div>

  <div class="steps">...</div>
</div> <!-- .track -->

Similar to our disable track feature, we need to create a function named initLock to be invoked inside our initUI method.

initUI() {
  ...
  const initLock = () => {
    const lock_btn = this.elem.querySelector('.track-lock');
    lock_btn.addEventListener('click', () => {
      this.elem.classList.toggle('track-locked');
      this.locked = !this.locked;
    });
  };
  initLock();
  ...
}

This feature is stupid simple: When the the track is in the locked state we simply add the class .track-lock to the track HTML element which sets pointer-events: none on the steps in the track.

Locked tracks set a translucent opacity level on the volume and load buttons for the locked track. We also set pointer pointer-events: none on these two elements for each track since we don't want the user to modify the track in any way. However, we do leave the enable button not locked since the user may want to toggle the track on and off even when the track is locked.

We also changed the shadow beneath the currently active step from green to orange in locked tracks to signify to the user that these steps are non-editable.

πŸ§ͺ Β Other Tweaks

We changed a few other things from the last blog post that we should briefly discuss. The biggest change worth mentioning is a fix to the fact that we previously had implemented the UI updates slightly incorrectly in our loopCallback function that gave rise to the UI updating slightly out of sync with the audio timeline.

As described in the Syncing Visuals section in the performance portion of the Tone.js wiki, we should "not make draw calls or DOM manipulations inside of the callback provided by Tone.Transport". The exact reason why is paraphrased in the following paragraph.

When using Tone.Transport, it's crucial to refrain from initiating draw calls or DOM manipulations within the callback supplied by Tone.Transport or any classes extending from Tone.Event such as Part, Sequence, Pattern, or Loop. Tone.Transport's callback operates using a WebWorker and isn't synchronized with the animation frame. Furthermore, Transport callbacks may trigger more frequently than animation frame callbacks, and could be invoked even when a browser tab is running in the background. Also worth noting is that Transport events can initiate well before the corresponding audio event is heard, potentially causing a misalignment between visual and audio elements.

To solve this problem, we simply utilize the Tone.Draw method in order to schedule a draw callback within the Transport callback, timed according to when the audio event is expected to happen. Tone.Draw then triggers the callback closest to the given animation frame.

const loopCallback = (time) => {  
  const { bar, beat, sixteenth, index } = getIndex();
  playTracks(time, index);

  Tone.Draw.schedule(() => {
    // this callback is invoked from a requestAnimationFrame
    // and will be invoked close to AudioContext time
    highlightStep(index);
    updateDisplay({ bar, beat, sixteenth, index });
	}, time) // use AudioContext time of the event
}; // loopCallback()

// ==============================================

const playBeat = () => {
  T.scheduleRepeat((t) => loopCallback(t), "8n");
  T.start();
}; // playBeat()

Our getIndexfunction looks more complicated than it really is. Fundamentally, getIndex is deriving our step counting index (previously named count) implicitly from Tone.Transport.position.

const getIndex = () => {
  const [ bar, beat, sixteenth ] = Tone.Transport.position.split(':').map(x => Number(x));
  const index = ((bar % 2) * 8) + (beat * 2) + (Math.floor(sixteenth) / 2);
  return { bar, beat, sixteenth, index };
};

We already listed our new playTracks function in the first section of this post. But let's also place it here for readability purposes.

const playTracks = (time, index) => {
  Tracks.forEach((track) => {
    if (!track.enabled) return;
    if (track.pattern[index])  track.start(time);
  });
};

Live Demo

Conclusion

And that's it! πŸŽ‰ With just a few lines of code, we've introduced mechanisms πŸ” to give users the ability to safeguard critical parts of their musical 🎡 composition while allowing for the flexibility to temporarily mute πŸ”‡ tracks as needed. Don't forget to check out the code πŸ‘¨β€πŸ’» and live demo πŸ“Ί for a hands-on understanding of the changes discussed here. Stay tuned πŸ“‘ for more features in upcoming posts!