DAW Control Surface

Hello Guys, newbie here.

I am trying to use PieterP’s Control Surface library to make a custom Mackie compatible MIDI controller.

My end goal is to have 4 screens and 8 encoders, each screen displaying 2 DAW tracks info + meters but as of now while I am learning I am only concerned with getting 1 screen to correctly display 8 DAW channel meters.

I got it working, however, the meter decay is rather slow especially in Ableton Live, specifically the first couple of segments. With Logic Pro the decay right after the first peak is faster and more acceptable. Here is a video example with Logic on the right and Ableton on the left:

(ignore the white line across the screen)

Now I am wondering if

  • this is Ableton’s fault due to the way it’s Mackie Remote Script template is written.
  • if I butchered the code and made too many mistakes.
  • if it’s because I am using I2C displays instead of SPI.

I realize Mackie uses 150ms to decay each segment but on videos I see online of Mackie controllers they seem a lot snappier than the results I am getting with Ableton.

I tried using both Hold and Default for the VUDecay and it doesn’t make any difference, also tried setting a custom value in milliseconds (in line 41 of the code) but that also makes no difference in Logic but in Ableton it makes the segments blink repeatedly before disappearing if the ms value is lower than 85ms.

Is there a way to make the decay faster? Any help would be deeply appreciated.

I am using a teensy 3.2 and an I2C SSD1306 OLED.

#include <Encoder.h> // Include the Encoder library.
// This must be done before the Control Surface library.
#include <Control_Surface.h> // Include the Control Surface library
// Include the display interface you'd like to use
#include <Display/DisplayInterfaces/DisplayInterfaceSSD1306.hpp>
#include <Wire.h> // Include the I²C library for the display
 
// ----------------------------- MIDI Interface ----------------------------- //

USBMIDI_Interface midi;
// USBDebugMIDI_Interface midi(115200);

// ----------------------------- Display setup ------------------------------ //

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
#define OLED_RESET     -1 // Reset pin # (or -1 if sharing Arduino reset pin)
Adafruit_SSD1306 ssd1306Display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

void initializeDisplay() {
    // Initialize with the display with I²C address 0x3C
    ssd1306Display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
    Wire.setClock(3400000); // Set the I²C clock to 1.8 MHz for faster refresh / 1800000 was the default, changed to 3400000 not sure if this even works this fast.
    ssd1306Display.setRotation(0); // Normal screen orientation.
}

// --------------------------- Display interface ---------------------------- //

class MySSD1306_DisplayInterface : public SSD1306_DisplayInterface {
  public:
    MySSD1306_DisplayInterface(Adafruit_SSD1306 &display)
        : SSD1306_DisplayInterface(display) {}

    void drawBackground() override { disp.drawLine(1, 8, 126, 8, WHITE); } //remove this later
} display = ssd1306Display;
 
// -------------------------- MIDI Input Elements --------------------------- //

//constexpr unsigned int decay = MCU::VUDecay::Hold;
// Try this option if your DAW doesn't decay the VU meters automatically
constexpr unsigned int decay = 85; // milliseconds to decay one block
 
// VU meters
MCU::VU VUMeters[8] = {
  {1, decay}, // The VU meter for the first track, decay time as specified above
  {2, decay}, {3, decay}, {4, decay}, {5, decay},
  {6, decay}, {7, decay}, {8, decay},
};
 
// ---------------------------- Display Elements ---------------------------- //
 
MCU::VUDisplay vuDisp[8] = {
  // Draw the first VU meter to the display, at position (2, 50),
  // (12) pixels wide, blocks of (3) pixels high, a spacing between
  // blocks of (1) pixel, and draw in white.
  {display, VUMeters[0], {2 + 16 * 0, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[1], {2 + 16 * 1, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[2], {2 + 16 * 2, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[3], {2 + 16 * 3, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[4], {2 + 16 * 4, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[5], {2 + 16 * 5, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[6], {2 + 16 * 6, 50}, 12, 3, 1, WHITE},
  {display, VUMeters[7], {2 + 16 * 7, 50}, 12, 3, 1, WHITE},
};

 
// --------------------------------- Setup ---------------------------------- //
 
void setup() {
    initializeDisplay();     // Start the OLED display
    Control_Surface.begin(); // Initialize Control Surface
}

// ---------------------------------- Loop ---------------------------------- //

void loop() {
    Control_Surface.loop(); // Refresh all elements
}

The code looks fine to me. I2C can be slower than SPI, but if this were the bottleneck you'd expect the meters to be choppy, or if the slow refresh rate throttled the MIDI input, you'd expect the meters to lag behind the audio, not just decay more slowly.

I suspect that this is a problem with Ableton or the Remote script you're using.

Is this the one you're using? AbletonLive9_RemoteScripts/ChannelStrip.py at de96cc1986dec0748758689e9a8caf1553a826a6 · gluon/AbletonLive9_RemoteScripts · GitHub?
It seems to use the Live.Track.Track.output_meter_level property to send to the MCU's level meters. I couldn't find any official documentation for it, but according to this document:

Hold peak value of output meters of audio and MIDI tracks, 0.0 ... 1.0. For audio tracks it is the maximum of the left and right channels. The hold time is 1 second.

You could try changing output_meter_level to the maximum of output_meter_left and output_meter_right:

Smoothed momentary peak value of left channel output meter, 0.0 to 1.0. For tracks with audio output only. This value corresponds to the meters shown in Live. Please take into account that the left/right audio meters put a significant load onto the GUI part of Live.

Pieter

Thanks, Pieter, I really appreciate the response!

PieterP:
Is this the one you’re using? https://github.com/gluon/AbletonLive9_RemoteScripts/blob/de96cc1986dec0748758689e9a8caf1553a826a6/MackieControl_Classic/ChannelStrip.py#L166-L178?

Yes, that’s the script I am using, it’s for live 10 but they are the same AFAIK.

PieterP:
It seems to use the Live.Track.Track.output_meter_level property to send to the MCU’s level meters. I couldn’t find any official documentation for it, but according to this document:
You could try changing output_meter_level to the maximum of output_meter_left and output_meter_right:

Not sure I understand how to change that, do I add this to the top of the script?:

self.__output_meter_level = 1.0

Or to change from this

                meter_value = 0.0
            meter_byte = int(meter_value * 12.0) + (self.__strip_index << 4)
            if self.__last_meter_value != meter_value or meter_value != 0.0:

to this:

                meter_value = 1.0
            meter_byte = int(meter_value * 12.0) + (self.__strip_index << 4)
            if self.__last_meter_value != meter_value or meter_value != 1.0:

I tried both options but no change.

No, I mean the maximum of the actual values:

    def on_update_display_timer(self):
        if not self.main_script().is_pro_version or self.__meters_enabled and self.__channel_strip_controller.assignment_mode() == CSM_VOLPAN:
            if self.__assigned_track:
                if self.__assigned_track.can_be_armed and self.__assigned_track.arm:
                    meter_value = max(self.__assigned_track.input_meter_left,
                                      self.__assigned_track.input_meter_right)
                else:
                    meter_value = max(self.__assigned_track.output_meter_left,
                                      self.__assigned_track.output_meter_right)
            else:
                meter_value = 0.0
            meter_byte = int(meter_value * 12.0) + (self.__strip_index << 4)
            if self.__last_meter_value != meter_value or meter_value != 0.0:
                self.__last_meter_value = meter_value
                self.send_midi((208, meter_byte))

It might be a good idea to also add a check to make sure it’s an audio channel and not a MIDI channel. I can’t test it, because I don’t have Ableton.

Thanks so much, you’re awesome man, its a lot more responsive, I am more confident to order some SPI screens now. :smiley:

Without a check for Audio, it stops any meter from showing on the Oled for tracks followed by a MIDI track, I added an “or” and “elif” statements for ‘self.__assigned_track.has_audio_output’ and ‘self.__assigned_track.has_audio_input’, I am not sure that’s 100% correct but it seems to be working correctly!

    def on_update_display_timer(self):
        if not self.main_script().is_pro_version or self.__meters_enabled and self.__channel_strip_controller.assignment_mode() == CSM_VOLPAN:
            if self.__assigned_track:
                if self.__assigned_track.can_be_armed and self.__assigned_track.arm and self.__assigned_track.has_audio_input:
                    meter_value = max(self.__assigned_track.input_meter_left,
                                      self.__assigned_track.input_meter_right)
                elif self.__assigned_track.has_audio_output:
                    meter_value = max(self.__assigned_track.output_meter_left,
                                      self.__assigned_track.output_meter_right)
            else:
                meter_value = 0.0
            meter_byte = int(meter_value * 12.0) + (self.__strip_index << 4)
            if self.__last_meter_value != meter_value or meter_value != 0.0:
                self.__last_meter_value = meter_value
                self.send_midi((208, meter_byte))

Glad to hear you got it working!

Hey, i am trying to build something similar, a mixer where each fader has a screen with the track name displayed. Do you have any tips for me? How did it work in the end? Did you just used these oled screens?
Thanks in advance!

johnster12:
Hey, i am trying to build something similar, a mixer where each fader has a screen with the track name displayed. Do you have any tips for me? How did it work in the end? Did you just used these oled screens?
Thanks in advance!

Well, right now I'm stuck so I can't really help.
If you're ok with 2 screens it's rather easy to setup by following the example:
https://tttapa.github.io/Control-Surface-doc/Doxygen/d0/d84/MCU-OLED-SSD1306-x2_8ino-example.html

If you need more than 2 it starts getting more complicated for newbies, read everything here, it has a bunch of tips but this method uses a lot of pins and requires SPI screens: 8 SSD1306 0.66 64X48 OLEDS · Issue #164 · tttapa/Control-Surface · GitHub

Personally I need 4 i2c OLEDs instead of SPI because those have bigger sizes available, 1.3" and 1.5", 0.9" is really tiny and SPI is also much more expensive where I live. AFAIK My only option is to use a TCA9548A multiplexer but I dont have enough knowledge to adapt the example Sketch to use the multiplexer correctly.

It would be awesome if there was a 4/8 OLED example, there's a ton of cheap Mackie Controllers out there but where they all fail is in the lack of screens.

done84:
Personally I need 4 i2c OLEDs instead of SPI because those have bigger sizes available, 1.3" and 1.5", 0.9" is really tiny and SPI is also much more expensive where I live. AFAIK My only option is to use a TCA9548A multiplexer but I dont have enough knowledge to adapt the example Sketch to use the multiplexer correctly.

You might not need the I2C multiplexer, many displays have pads on the bottom that break out the I2C address pins of the display driver. You can use them as chip select pins by connecting them to the Arduino. When you want to send data to a certain display, you change its address to the address used in your code, while all other displays are on another unused address.

All communication with the display happens through the display interface. The only functions that cause I2C traffic are the begin method and the display method, so if you use the address pins or an I2C multiplexer, you have to select the right display first.

For example:

class MySSD1306_DisplayInterface : public SSD1306_DisplayInterface {
 public:
  MySSD1306_DisplayInterface(Adafruit_SSD1306 &display, pin_t selectPin)
    : SSD1306_DisplayInterface(display), selectPin(selectPin) {}
 
  void select() {
    digitalWrite(selectPin, LOW);
  }
  void deselect() {
    digitalWrite(selectPin, HIGH);
  }
 
  void begin() override {
    pinMode(selectPin, OUTPUT);
  
    select(); // select the right display
  
#if defined(ADAFRUIT_SSD1306_HAS_SETBUFFER) && ADAFRUIT_SSD1306_HAS_SETBUFFER
    disp.setBuffer(buffer);
#endif
    // Initialize the Adafruit_SSD1306 display
    if (!disp.begin())
      FATAL_ERROR(F("SSD1306 initialization failed."), 0x1306);
 
    // If you override the begin method, remember to call the super class method
    SSD1306_DisplayInterface::begin();
    
    deselect();
  }
  
  void display() override {
    select();
    SSD1306_DisplayInterface::display();
    deselect();
  }
 
  void drawBackground() override { disp.drawLine(1, 8, 126, 8, WHITE); }
 
#if defined(ADAFRUIT_SSD1306_HAS_SETBUFFER) && ADAFRUIT_SSD1306_HAS_SETBUFFER
  // We'll use a static buffer to avoid dynamic memory usage, and to allow
  // multiple displays to reuse one single buffer.
  static uint8_t buffer[(SCREEN_WIDTH * SCREEN_HEIGHT + 7) / 8];
#endif

 private:
  // The pin to activate the display, using the I2C address pin
  pin_t selectPin; 
  
} display_L = {ssd1306Display_L, selectPin_L},
  display_R = {ssd1306Display_R, selectPin_R};
 
#if defined(ADAFRUIT_SSD1306_HAS_SETBUFFER) && ADAFRUIT_SSD1306_HAS_SETBUFFER
uint8_t MySSD1306_DisplayInterface::buffer[];
#endif

If you use an I2C multiplexer, the code would be similar, you'd just need a different implementation for the select and deselect methods.

Can't say i've seen those pads in the common small OLEDs, are you talking about the tiny pads that change the address?

The select method is driving me crazy, stuck with this for now:

// ----------------------------- Display setup ------------------------------ //
constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
constexpr int8_t OLED_RESET = -1;
Adafruit_SSD1306 ssd1306Display_L(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
Adafruit_SSD1306 ssd1306Display_R(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --------------------------- Display interface ---------------------------- //
class MySSD1306_DisplayInterface : public SSD1306_DisplayInterface {
 public:
  MySSD1306_DisplayInterface(Adafruit_SSD1306 &display, uint8_t muxCh)
    : SSD1306_DisplayInterface(display), muxCh(muxCh) {}
 
  void begin() override {
    selectMux();
    if (!disp.begin())
      FATAL_ERROR(F("SSD1306 allocation failed."), 0x3C);
 
    SSD1306_DisplayInterface::begin();
  }

  void display() override {
    selectMux();
    SSD1306_DisplayInterface::display();
  }
 
  void drawBackground() override { disp.drawLine(1, 8, 126, 8, WHITE); }
 
 private:
  uint8_t muxCh;
// How the heck do i make this work :(
  void selectMux() {
  Wire.beginTransmission(0x70); //TCA9548A address is 0x70
  Wire.write(muxCh);
  }
} display_L = ssd1306Display_L, display_R = ssd1306Display_R;

done84:
are you talking about the tiny pads that change the address?

Exactly, the voltage on the center pad determines whether the display listens to address 0x78 or 0x7A. You could connect a wire to it instead of the resistor, and change the address using the microcontroller.

done84:
The select method is driving me crazy, stuck with this for now:

// ----------------------------- Display setup ------------------------------ //

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
constexpr int8_t OLED_RESET = -1;
Adafruit_SSD1306 ssd1306Display_L(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
Adafruit_SSD1306 ssd1306Display_R(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --------------------------- Display interface ---------------------------- //
class MySSD1306_DisplayInterface : public SSD1306_DisplayInterface {
public:
 MySSD1306_DisplayInterface(Adafruit_SSD1306 &display, uint8_t muxCh)
   : SSD1306_DisplayInterface(display), muxCh(muxCh) {}

void begin() override {
   selectMux();
   if (!disp.begin())
     FATAL_ERROR(F("SSD1306 allocation failed."), 0x3C);

SSD1306_DisplayInterface::begin();
 }

void display() override {
   selectMux();
   SSD1306_DisplayInterface::display();
 }

void drawBackground() override { disp.drawLine(1, 8, 126, 8, WHITE); }

private:
 uint8_t muxCh;
// How the heck do i make this work :frowning:
 void selectMux() {
 Wire.beginTransmission(0x70); //TCA9548A address is 0x70
 Wire.write(muxCh);
 }
} display_L = ssd1306Display_L, display_R = ssd1306Display_R;

You forgot to initialize the muxCh variable:

} display_L = {ssd1306Display_L, muxCh_L}, display_R = {ssd1306Display_R, muxCh_L};

Replace muxCh_L/R with the appropriate channels.

PieterP:
Exactly, the voltage on the center pad determines whether the display listens to address 0x78 or 0x7A. You could connect a wire to it instead of the resistor, and change the address using the microcontroller.

Oh, interesting. I'm planning on eventually having a PCB made and this approach is not very friendly in that regard tho.

PieterP:
You forgot to initialize the muxCh variable:

} display_L = {ssd1306Display_L, muxCh_L}, display_R = {ssd1306Display_R, muxCh_L};

Replace muxCh_L/R with the appropriate channels.

Ooohhh, thanks so much Pieter, it compiles now. It still doesn't work, I think something is still missing in the last bits of the display interface code but I'm closer now, thanks again!

A solution has been found over on GitHub: Does the library support multiplexers for the OLED modules? · Issue #158 · tttapa/Control-Surface · GitHub

The main issue there was a missing call to Wire.begin() before trying to select the multiplexer channel.

PieterP:
A solution has been found over on GitHub: Does the library support multiplexers for the OLED modules? · Issue #158 · tttapa/Control-Surface · GitHub

The main issue there was a missing call to Wire.begin() before trying to select the multiplexer channel.

Awesome. Working great now :smiley:

Just curious, do you have any plans to add a Display Element to show fader db values?

The display elements are meant to be extensible, you can easily write your own by inheriting from the DisplayElement class and implementing the draw method.

For example (untested):

#include <Control_Surface.h> // Include the Control Surface library
// Include the display interface you'd like to use
#include <Display/DisplayInterfaces/DisplayInterfaceSSD1306.hpp>

USBMIDI_Interface midi;

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
 
constexpr int8_t OLED_DC = 17;    // Data/Command pin of the display
constexpr int8_t OLED_reset = -1; // Use the external RC circuit for reset
constexpr int8_t OLED_CS = 10;    // Chip Select pin of the display
 
constexpr uint32_t SPI_Frequency = SPI_MAX_SPEED;
 
// Instantiate the displays
Adafruit_SSD1306 ssd1306Display = {
  SCREEN_WIDTH, SCREEN_HEIGHT, &SPI,          OLED_DC,
  OLED_reset,   OLED_CS,       SPI_Frequency,
};
 
// Implement the display interface, specifically, the begin and drawBackground
// methods.
class MySSD1306_DisplayInterface : public SSD1306_DisplayInterface {
 public:
  MySSD1306_DisplayInterface(Adafruit_SSD1306 &display)
    : SSD1306_DisplayInterface(display) {}
 
  void begin() override {
    // Initialize the Adafruit_SSD1306 display
    if (!disp.begin())
      FATAL_ERROR(F("SSD1306 allocation failed."), 0x1306);
 
    // If you override the begin method, remember to call the super class method
    SSD1306_DisplayInterface::begin();
  }
 
  void drawBackground() override { /* Anything you want */ }
 
} display = ssd1306Display;

struct DisplayFader_dB : DisplayElement {
  DisplayFader_dB(DisplayInterface &display, 
                  const CCPotentiometer &pot, 
                  PixelLocation loc)
   : DisplayElement(display), pot(pot), loc(loc) {}

  void draw() override {
    getDisplay().setCursor(loc.x, loc.y);
    getDisplay().setTextSize(1);
    getDisplay().setTextColor(WHITE);
    getDisplay().print(val2dB(pot.getValue()), 2);
    getDisplay().print(" dB");
  }

  static float val2dB(int val) {
    // You'll have to tweak this to match the values in your DAW
    auto min_dB = -60;
    auto max_dB = +3;
    return val * (max_dB - min_dB) / 127. + min_dB;
  }

  private:
    const CCPotentiometer &pot;
    PixelLocation loc;
};

CCPotentiometer pot = {A0, 7};
DisplayFader_dB dispPot = {display, pot, {10, 10}};

void setup() {
  Control_Surface.begin();
}

void loop() {
  Control_Surface.loop();
}

Interesting.

I was too vague with my "show db values" statement tho, I should have mentioned that the intention is to emulate the Mackie Controller motorized volume faders behavior but with encoders instead, so having the db value displayed on screens is necessary. I was thinking that there must be something in the MCU protocol that sends those db values and we could just receive and display it like your library already does for track names but I am not sure if thats a real thing or not.

The code you posted is focused on what a pot is doing instead of what's happening in the DAW, so functionality breaks once we move the volume faders with a mouse.

I gave it a shot at adapting your suggestion to work with an encoder using PBAbsoluteEncoder (since Channel Volume in MCU uses Pitch Bend) it works (altho it has a bunch of mistakes) but the mouse issue from above remains, what would you suggest to solve that?

[spoiler]

// ------------------------------- TEST DB ENCODER  ------------------------------- //
// ----------------------------- has a bunch of mistakes  ---------------------------- //
struct DisplayFader_dB : DisplayElement {
  DisplayFader_dB(DisplayInterface &display,
                  const PBAbsoluteEncoder &enc,
                  PixelLocation loc)
   : DisplayElement(display_L), enc(enc), loc(loc) {}

  void draw() override {
    getDisplay().setCursor(loc.x, loc.y);
    getDisplay().setTextSize(1);
    getDisplay().setTextColor(WHITE);
    getDisplay().print(val2dB(enc.getValue()), 2);
    getDisplay().print(" dB");
  }

  static float val2dB(int val) {
    // You'll have to tweak this to match the values in your DAW
    auto min_dB = -69.9; // minimum value from Ableton ??
    auto max_dB = +6;
    return val * (max_dB - min_dB) / 16384. + min_dB; // Pitch bend is 14bit = 16384 ?? possible values?
  }

  private:
    const PBAbsoluteEncoder &enc;
    PixelLocation loc;
};

// Instantiate a PBAbsoluteEncoder object
    PBAbsoluteEncoder enc = {
      {15, 16},    // pins
      CHANNEL_1, // MIDI channel = Track Volume
      127,       // large multiplier because Pitch Bend has high resolution
    };

DisplayFader_dB dispPot = {display_L, enc, {10, 10}};

[/spoiler]

It would all need to be bankable, is this possible or is it a complicated endeavor?
Sorry if I am annoying with all the questions.