Yoshihiro Sato
Published © CC BY

The Keyboard, is This?

I pushed the M5Stack ecosystem to its limits through deliberate misuse. Then I hacked the fixed concept of what a 'keyboard' even means.

BeginnerShowcase (no instructions)10 hours23
The Keyboard, is This?

Things used in this project

Hardware components

M5Stack Chain DualKey
×1
M5Stack Chain Encoder
×1
M5Stack Atom VoiceS3R Smart Speaker Dev Kit
×1
M5Stack Atomic toChain Base
×1

Software apps and online services

Arduino IDE
Arduino IDE

Story

Read more

Schematics

Composition Diagram

Connecting and configuring the devices you use.

Code

Program for "M5ChainDualKey"

C/C++
Code for the program that goes into M5ChainDualKey
ArduinoIDE Version: 2.3.6
M5Unified Ver. : 0.2.17
M5Chain Version: 1.0.4
Adafruit_NeoPixel Version: 1.15.5
#include <M5Unified.h>
#include <M5Chain.h>
#include <Adafruit_NeoPixel.h>

// Pin definitions for LED control
#define LED_PWR_PIN 40      // LED power pin
#define LED_SIG_PIN 21      // LED signal pin
#define NUM_LEDS 2          // Number of LEDs on ChainDualKey
#define pin_Key1 0          // Key A pin
#define pin_Key2 17         // Key B pin

// Serial route definitions
// Serial1: Chain Encoder communication (Grove pins G47/G48)
// Serial2: Atom VoiceS3R communication (Grove pins G5/G6 via Atomic toChain Base)
#define ENC_RX 47
#define ENC_TX 48
#define SOUND_RX 6
#define SOUND_TX 5

Chain M5Chain;
Adafruit_NeoPixel LED(NUM_LEDS, LED_SIG_PIN, NEO_GRB + NEO_KHZ800);
m5::Button_Class Key1;
m5::Button_Class Key2;

int currentNote = 1;        // Current note index (1-32, approx. 3 octaves)
bool isMinor = false;       // Scale type: false=Major(J), true=Minor(N)
int16_t encoder_value = 0;
chain_button_press_type_t button_press_type;

void updateLEDs() {
  // 7 colors representing Do Re Mi Fa Sol La Si
  // Dim colors to reduce power consumption
  uint32_t colors[] = { 0x330000, 0x331F00, 0x333300, 0x003300, 0x000033, 0x1B0042, 0x240043 };

  // LED0: Show current note color (off at note 3 and 33 — known edge case)
  if (currentNote == 3 || currentNote == 33) {
    LED.setPixelColor(0, 0x000000);
  } else {
    LED.setPixelColor(0, colors[(currentNote - 1) % 7]);
  }

  // LED1: Show scale type — Red=Major, Blue=Minor
  LED.setPixelColor(1, isMinor ? LED.Color(0, 0, 255) : LED.Color(255, 0, 0));
  LED.show();
}

void setup() {
  Serial.begin(115200);  // USB serial for debug output

  auto cfg = M5.config();
  M5.begin(cfg);

  pinMode(pin_Key1, INPUT);
  pinMode(pin_Key2, INPUT);
  pinMode(LED_PWR_PIN, OUTPUT);
  digitalWrite(LED_PWR_PIN, HIGH);  // Power on LEDs
  LED.begin();
  LED.show();

  // Initialize Chain Encoder on Serial1 (G47=RX, G48=TX)
  M5Chain.begin(&Serial1, 115200, ENC_RX, ENC_TX);

  // Initialize Atom VoiceS3R communication on Serial2 (G6=RX, G5=TX via Atomic toChain Base)
  Serial2.begin(115200, SERIAL_8N1, SOUND_RX, SOUND_TX);

  Serial.println("System Ready: Roots Separated");
  updateLEDs();
}

void loop() {
  uint32_t ms = millis();
  // Read key states (active LOW)
  Key1.setRawState(ms, !digitalRead(pin_Key1));
  Key2.setRawState(ms, !digitalRead(pin_Key2));

  // 1. Encoder: read increment value, accumulate, clamp to 1-32
  // Using increment value instead of absolute value to avoid out-of-range after overspin
  int16_t raw_val = 0;
  uint8_t opStatus = 0;
  M5Chain.getEncoderIncValue(1, &raw_val);
  if (raw_val != 0) {
    currentNote += raw_val;
    currentNote = constrain(currentNote, 1, 32);
    M5Chain.resetEncoderIncValue(0x14, &opStatus, 100);  // Reset increment register
  }

  updateLEDs();

  // 2. Encoder button: toggle Major/Minor scale
  while (M5Chain.getEncoderButtonPressStatus(1, &button_press_type)) {
    if (button_press_type == CHAIN_BUTTON_PRESS_SINGLE) {
      isMinor = !isMinor;
      updateLEDs();
    }
  }

  // 3. Sound output logic
  // Protocol: [J/N][A/B][note number]
  //   J = Major, N = Minor
  //   A = Sustain (slow decay), B = Percussive (fast decay)
  //   Example: JA01 = Major, Sustain, note 1 (Do)
  //            NB07 = Minor, Percussive, note 7 (Si)
  //   S = Stop sound
  if (Key1.isPressed() && Key2.isPressed()) {
    // Both keys pressed simultaneously: stop sound
    Serial2.print("S\n");
    delay(100);
  } else {
    char type = isMinor ? 'N' : 'J';
    if (Key2.wasPressed()) {
      // Key B: Sustain (slow decay)
      Serial2.printf("%c%c%02d\n", type, 'A', currentNote);
    }
    if (Key1.wasPressed()) {
      // Key A: Percussive (fast decay)
      Serial2.printf("%c%c%02d\n", type, 'B', currentNote);
    }
  }
}

Program for "M5AtomVoiceS3R"

C/C++
Code for the program that goes into M5AtomVoiceS3R
ArduinoIDE Version: 2.3.6
M5Unified Ver. : 0.2.17
#include <M5Unified.h>

// Uncomment to enable USB debug mode (receives from both USB and Grove serial)
// Comment out for standalone operation (Grove serial only)
// #define DEBUG

#define RX_PIN 6   // Grove RX pin on Atomic toChain Base
#define TX_PIN 5   // Grove TX pin on Atomic toChain Base

enum Mode { IDLE, SUSTAIN, PERCUSSIVE };
Mode currentMode = IDLE;
unsigned long startTime;
int currentVol;

// Scale definitions (semitone intervals from root)
// 7 notes only — upper octave Do is handled by octave calculation
const int MAJOR_SCALE[] = { 0, 2, 4, 5, 7, 9, 11 };  // Do Re Mi Fa Sol La Si
const int MINOR_SCALE[] = { 0, 2, 3, 5, 7, 8, 10 };  // Do Re Me Fa Sol Le Te
int scaleType = 0;  // 0: Major (J), 1: Minor (N)

// Calculate frequency from base frequency and note index
// Uses equal temperament: freq = base * 2^(semitones/12)
float getFreq(float base, int index) {
  int semi;
  if (scaleType == 0) semi = MAJOR_SCALE[index % 7] + (12 * (index / 7));
  else semi = MINOR_SCALE[index % 7] + (12 * (index / 7));
  return base * pow(2.0, semi / 12.0);
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

#ifdef DEBUG
  Serial.begin(115200);  // USB serial for debug
#endif

  // Grove serial for receiving commands from ChainDualKey
  Serial2.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN);
  M5.Speaker.setVolume(128);
}

void loop() {
  M5.update();

  // Switch input source based on DEBUG mode
#ifdef DEBUG
  if (Serial.available() || Serial2.available()) {
    String input = (Serial.available()) ? Serial.readStringUntil('\n') : Serial2.readStringUntil('\n');
#else
  if (Serial2.available()) {
    String input = Serial2.readStringUntil('\n');
#endif

    input.trim();
    if (input.length() == 0) return;

    Serial.println("Recv: " + input);

    // Stop command
    if (input == "S") {
      M5.Speaker.stop();
      currentMode = IDLE;
    }
    // Play command: [J/N][A/B][note number]
    // Example: JA10 = Major, Sustain, note 10
    //          NB05 = Minor, Percussive, note 5
    else if (input.length() >= 3) {
      char scaleChar = input.charAt(0);  // J=Major, N=Minor
      char modeChar  = input.charAt(1);  // A=Sustain, B=Percussive
      int num        = input.substring(2).toInt();  // Note number (1-32)

      if (num > 0 && num <= 32) {
        scaleType = (scaleChar == 'J') ? 0 : 1;  // Sync scale state

        M5.Speaker.stop();

        // Base frequency: 41.2Hz (low B on bass guitar, one octave below standard)
        // Volume decreases with note number to compensate for high-frequency harshness
        float freq = getFreq(41.2, num - 1);
        M5.Speaker.tone(freq);

        currentMode = (modeChar == 'A') ? SUSTAIN : PERCUSSIVE;
        currentVol = (currentMode == SUSTAIN) ? 128 : 255;
        currentVol = currentVol - (num * 1);  // Higher notes = slightly lower volume
        startTime = millis();
      }
    }
  }

  // Envelope processing
  // SUSTAIN: slow linear decay (volume-- every 100ms)
  // PERCUSSIVE: fast initial decay then slow tail
  if (currentMode != IDLE) {
    unsigned long elapsed = millis() - startTime;
    if (currentMode == SUSTAIN) {
      if (elapsed > 100 && currentVol > 0) {
        currentVol--;
        M5.Speaker.setVolume(currentVol);
        startTime = millis();
      }
    } else if (currentMode == PERCUSSIVE) {
      if (elapsed > 10 && currentVol > 50) {
        // Fast decay phase
        currentVol -= 10;
        M5.Speaker.setVolume(currentVol);
        startTime = millis();
      } else if (elapsed > 50 && currentVol > 0) {
        // Slow tail phase
        currentVol--;
        M5.Speaker.setVolume(currentVol);
        startTime = millis();
      }
    }
    if (currentVol <= 0) {
      M5.Speaker.stop();
      currentMode = IDLE;
      M5.Speaker.setVolume(128);  // Reset volume for next note
    }
  }
}

Credits

Yoshihiro Sato
4 projects • 1 follower
PC Instructor

Comments